From 9c033dbf98e1d752fba2ba1e2d60953985c5cb08 Mon Sep 17 00:00:00 2001 From: Jeffrey <9938813+Raphire@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:06:07 +0200 Subject: [PATCH] Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708) --- .github/CONTRIBUTING.md | 23 +- .github/workflows/tests.yml | 26 ++ ...Winget.ps1 => Get-WingetInstalledApps.ps1} | 4 +- ...oveEdge.ps1 => Invoke-ForceRemoveEdge.ps1} | 10 +- ...RemoveApps.ps1 => Remove-SelectedApps.ps1} | 31 +- Scripts/AppRemoval/Test-AppInWingetList.ps1 | 2 +- ...IAppRemoval.ps1 => Show-CliAppRemoval.ps1} | 12 +- ... Show-CliDefaultModeAppRemovalOptions.ps1} | 4 +- ...ons.ps1 => Show-CliDefaultModeOptions.ps1} | 28 +- ...tings.ps1 => Show-CliLastUsedSettings.ps1} | 12 +- ...enuOptions.ps1 => Show-CliMenuOptions.ps1} | 4 +- ...waitKeyToExit.ps1 => Wait-ForKeyPress.ps1} | 4 +- .../{PrintHeader.ps1 => Write-CliHeader.ps1} | 6 +- ...ngChanges.ps1 => Write-PendingChanges.ps1} | 6 +- ...s1 => Backup-RegistryFeatureSelection.ps1} | 0 ...ps1 => Backup-RegistrySnapshotCapture.ps1} | 38 +- ...stryState.ps1 => Backup-RegistryState.ps1} | 2 +- ...oint.ps1 => Ensure-SystemRestorePoint.ps1} | 2 +- ...eakState.ps1 => Get-CurrentTweakState.ps1} | 2 +- ...gistryFile.ps1 => Import-RegistryFile.ps1} | 2 +- .../{InvokeChanges.ps1 => Invoke-Changes.ps1} | 90 +++-- ...xplorer.ps1 => Invoke-RestartExplorer.ps1} | 4 +- ...tion.ps1 => Registry-BackupValidation.ps1} | 143 ++++--- ...aceStartMenu.ps1 => Replace-StartMenu.ps1} | 66 ++-- ...ate.ps1 => Restore-RegistryApplyState.ps1} | 102 +++-- ...yBackup.ps1 => Restore-RegistryBackup.ps1} | 17 +- ...ons.ps1 => Set-StoreSearchSuggestions.ps1} | 38 +- ...Tasks.ps1 => Telemetry-ScheduledTasks.ps1} | 0 ...tures.ps1 => Windows-OptionalFeatures.ps1} | 4 +- ...eAppslist.ps1 => Get-ValidatedAppList.ps1} | 4 +- ...Json.ps1 => Import-AppDetailsFromJson.ps1} | 35 +- ...Json.ps1 => Import-AppPresetsFromJson.ps1} | 8 +- ...psFromFile.ps1 => Import-AppsFromFile.ps1} | 4 +- .../{LoadJsonFile.ps1 => Import-JsonFile.ps1} | 8 +- .../{LoadSettings.ps1 => Import-Settings.ps1} | 11 +- .../{SaveSettings.ps1 => Save-Settings.ps1} | 11 +- Scripts/FileIO/Save-ToFile.ps1 | 36 ++ Scripts/FileIO/SaveToFile.ps1 | 22 -- ...ols.ps1 => Apply-SettingsToUiControls.ps1} | 20 +- ...vior.ps1 => Attach-ShiftClickBehavior.ps1} | 24 +- ...arkMode.ps1 => Get-SystemUsesDarkMode.ps1} | 10 +- Scripts/GUI/MainWindow-AppSelection.ps1 | 65 +++- Scripts/GUI/MainWindow-Deployment.ps1 | 42 +- Scripts/GUI/MainWindow-TweaksBuilder.ps1 | 87 ++++- ...1 => Restore-BackupDialogFeatureLists.ps1} | 0 ...urces.ps1 => Set-WindowThemeResources.ps1} | 6 +- Scripts/GUI/Show-AboutDialog.ps1 | 13 +- Scripts/GUI/Show-AppSelectionWindow.ps1 | 36 +- Scripts/GUI/Show-ApplyModal.ps1 | 26 +- ....ps1 => Show-ImportExportConfigWindow.ps1} | 44 ++- Scripts/GUI/Show-MainWindow.ps1 | 64 ++-- Scripts/GUI/Show-MessageBox.ps1 | 9 +- Scripts/GUI/Show-RestoreBackupDialog.ps1 | 14 +- Scripts/GUI/Show-RestoreBackupWindow.ps1 | 10 +- .../{AddParameter.ps1 => Add-Parameter.ps1} | 7 +- ...yRegFile.ps1 => Apply-RegistryRegFile.ps1} | 19 - ...moval.ps1 => Confirm-UnsafeAppRemoval.ps1} | 9 +- ...rateAppsList.ps1 => Generate-AppsList.ps1} | 11 +- ...1 => Get-FriendlyRegistryBackupTarget.ps1} | 8 +- .../Helpers/Get-FriendlyTargetUserName.ps1 | 13 + Scripts/Helpers/Get-RegFileOperations.ps1 | 37 +- ...al.ps1 => Get-TargetUserForAppRemoval.ps1} | 2 +- ...serDirectory.ps1 => Get-UserDirectory.ps1} | 8 +- Scripts/Helpers/Get-UserName.ps1 | 11 + Scripts/Helpers/GetFriendlyTargetUserName.ps1 | 9 - Scripts/Helpers/GetUserName.ps1 | 7 - ...ToParams.ps1 => Import-ConfigToParams.ps1} | 28 +- ...thHelpers.ps1 => Registry-PathHelpers.ps1} | 41 ++ ...lePath.ps1 => Resolve-UserProfilePath.ps1} | 130 +++---- ...port.ps1 => Test-ModernStandbySupport.ps1} | 2 +- Scripts/Helpers/Test-TargetUserName.ps1 | 2 +- ...rExists.ps1 => Test-UserProfileExists.ps1} | 6 +- ...erHiveHelpers.ps1 => User-HiveHelpers.ps1} | 4 +- Scripts/Run-Tests.ps1 | 49 +++ .../{DoEvents.ps1 => Invoke-DoEvents.ps1} | 4 +- Scripts/Threading/Invoke-NonBlocking.ps1 | 2 +- Tests/Apply-RegistryRegFile.Tests.ps1 | 144 +++++++ Tests/Confirm-UnsafeAppRemoval.Tests.ps1 | 37 ++ Tests/Get-CurrentTweakState.Tests.ps1 | 186 +++++++++ ...Get-FriendlyRegistryBackupTarget.Tests.ps1 | 21 + Tests/Get-RebootFeatureLabels.Tests.ps1 | 31 ++ Tests/Get-RegFileOperations.Tests.ps1 | 126 ++++++ Tests/Get-TargetUserForAppRemoval.Tests.ps1 | 13 + Tests/Import-ConfigToParams.Tests.ps1 | 38 ++ Tests/Import-JsonFile.Tests.ps1 | 45 +++ Tests/Import-RegistryFile.Tests.ps1 | 67 ++++ Tests/Invoke-Changes.Tests.ps1 | 358 ++++++++++++++++++ Tests/MainWindow-AppSelection.Tests.ps1 | 156 ++++++++ Tests/MainWindow-Deployment.Tests.ps1 | 33 ++ Tests/Registry-BackupCreation.Tests.ps1 | 245 ++++++++++++ Tests/Registry-BackupValidation.Tests.ps1 | 301 +++++++++++++++ Tests/Registry-PathHelpers.Tests.ps1 | 72 ++++ Tests/Registry-SnapshotApply.Tests.ps1 | 205 ++++++++++ Tests/Remove-SelectedApps.Tests.ps1 | 186 +++++++++ Tests/Replace-StartMenu.Tests.ps1 | 108 ++++++ Tests/Resolve-UserProfilePath.Tests.ps1 | 141 +++++++ ...Restore-BackupDialogFeatureLists.Tests.ps1 | 57 +++ Tests/Restore-RegistryApplyState.Tests.ps1 | 71 ++++ Tests/Restore-RegistryBackup.Tests.ps1 | 147 +++++++ Tests/Settings-And-Apps-FileIO.Tests.ps1 | 189 +++++++++ Tests/Show-CliMenuOptions.Tests.ps1 | 113 ++++++ Tests/Test-AppInWingetList.Tests.ps1 | 33 ++ .../JsonFileLoading/Config.Invalid.json | 4 + .../JsonFileLoading/Config.Valid.json | 4 + .../Config.VersionMismatch.json | 4 + .../DefaultSettings.MissingSettings.json | 3 + .../DefaultSettings.Valid.json | 10 + .../ExportedConfig.WithSettings.json | 23 ++ .../LastUsedSettings.Invalid.json | 6 + .../LastUsedSettings.Valid.json | 6 + .../RegistryBackup.Invalid.json | 4 + .../RegistryBackup.RealStructure.json | 43 +++ .../JsonFileLoading/RegistryBackup.Valid.json | 7 + Tests/User-HiveHelpers.Tests.ps1 | 149 ++++++++ Tests/User-LookupHelpers.Tests.ps1 | 31 ++ Win11Debloat.ps1 | 162 ++++---- 116 files changed, 4629 insertions(+), 650 deletions(-) create mode 100644 .github/workflows/tests.yml rename Scripts/AppRemoval/{GetInstalledAppsViaWinget.ps1 => Get-WingetInstalledApps.ps1} (98%) rename Scripts/AppRemoval/{ForceRemoveEdge.ps1 => Invoke-ForceRemoveEdge.ps1} (94%) rename Scripts/AppRemoval/{RemoveApps.ps1 => Remove-SelectedApps.ps1} (92%) rename Scripts/CLI/{ShowCLIAppRemoval.ps1 => Show-CliAppRemoval.ps1} (76%) rename Scripts/CLI/{ShowCLIDefaultModeAppRemovalOptions.ps1 => Show-CliDefaultModeAppRemovalOptions.ps1} (94%) rename Scripts/CLI/{ShowCLIDefaultModeOptions.ps1 => Show-CliDefaultModeOptions.ps1} (64%) rename Scripts/CLI/{ShowCLILastUsedSettings.ps1 => Show-CliLastUsedSettings.ps1} (58%) rename Scripts/CLI/{ShowCLIMenuOptions.ps1 => Show-CliMenuOptions.ps1} (93%) rename Scripts/CLI/{AwaitKeyToExit.ps1 => Wait-ForKeyPress.ps1} (88%) rename Scripts/CLI/{PrintHeader.ps1 => Write-CliHeader.ps1} (85%) rename Scripts/CLI/{PrintPendingChanges.ps1 => Write-PendingChanges.ps1} (96%) rename Scripts/Features/{BackupRegistryFeatureSelection.ps1 => Backup-RegistryFeatureSelection.ps1} (100%) rename Scripts/Features/{BackupRegistrySnapshotCapture.ps1 => Backup-RegistrySnapshotCapture.ps1} (88%) rename Scripts/Features/{BackupRegistryState.ps1 => Backup-RegistryState.ps1} (98%) rename Scripts/Features/{CreateSystemRestorePoint.ps1 => Ensure-SystemRestorePoint.ps1} (99%) rename Scripts/Features/{GetCurrentTweakState.ps1 => Get-CurrentTweakState.ps1} (98%) rename Scripts/Features/{ImportRegistryFile.ps1 => Import-RegistryFile.ps1} (99%) rename Scripts/Features/{InvokeChanges.ps1 => Invoke-Changes.ps1} (82%) rename Scripts/Features/{RestartExplorer.ps1 => Invoke-RestartExplorer.ps1} (97%) rename Scripts/Features/{RegistryBackupValidation.ps1 => Registry-BackupValidation.ps1} (84%) rename Scripts/Features/{ReplaceStartMenu.ps1 => Replace-StartMenu.ps1} (80%) rename Scripts/Features/{RestoreRegistryApplyState.ps1 => Restore-RegistryApplyState.ps1} (71%) rename Scripts/Features/{RestoreRegistryBackup.ps1 => Restore-RegistryBackup.ps1} (91%) rename Scripts/Features/{StoreSearchSuggestions.ps1 => Set-StoreSearchSuggestions.ps1} (87%) rename Scripts/Features/{TelemetryScheduledTasks.ps1 => Telemetry-ScheduledTasks.ps1} (100%) rename Scripts/Features/{WindowsOptionalFeatures.ps1 => Windows-OptionalFeatures.ps1} (96%) rename Scripts/FileIO/{ValidateAppslist.ps1 => Get-ValidatedAppList.ps1} (76%) rename Scripts/FileIO/{LoadAppsDetailsFromJson.ps1 => Import-AppDetailsFromJson.ps1} (65%) rename Scripts/FileIO/{LoadAppPresetsFromJson.ps1 => Import-AppPresetsFromJson.ps1} (69%) rename Scripts/FileIO/{LoadAppsFromFile.ps1 => Import-AppsFromFile.ps1} (96%) rename Scripts/FileIO/{LoadJsonFile.ps1 => Import-JsonFile.ps1} (83%) rename Scripts/FileIO/{LoadSettings.ps1 => Import-Settings.ps1} (78%) rename Scripts/FileIO/{SaveSettings.ps1 => Save-Settings.ps1} (74%) create mode 100644 Scripts/FileIO/Save-ToFile.ps1 delete mode 100644 Scripts/FileIO/SaveToFile.ps1 rename Scripts/GUI/{ApplySettingsToUiControls.ps1 => Apply-SettingsToUiControls.ps1} (82%) rename Scripts/GUI/{AttachShiftClickBehavior.ps1 => Attach-ShiftClickBehavior.ps1} (81%) rename Scripts/GUI/{GetSystemUsesDarkMode.ps1 => Get-SystemUsesDarkMode.ps1} (65%) rename Scripts/GUI/{RestoreBackupDialogFeatureLists.ps1 => Restore-BackupDialogFeatureLists.ps1} (100%) rename Scripts/GUI/{SetWindowThemeResources.ps1 => Set-WindowThemeResources.ps1} (97%) rename Scripts/GUI/{Show-ConfigWindow.ps1 => Show-ImportExportConfigWindow.ps1} (93%) rename Scripts/Helpers/{AddParameter.ps1 => Add-Parameter.ps1} (73%) rename Scripts/Helpers/{ApplyRegistryRegFile.ps1 => Apply-RegistryRegFile.ps1} (93%) rename Scripts/Helpers/{ConfirmUnsafeAppRemoval.ps1 => Confirm-UnsafeAppRemoval.ps1} (81%) rename Scripts/Helpers/{GenerateAppsList.ps1 => Generate-AppsList.ps1} (60%) rename Scripts/Helpers/{GetFriendlyRegistryBackupTarget.ps1 => Get-FriendlyRegistryBackupTarget.ps1} (85%) create mode 100644 Scripts/Helpers/Get-FriendlyTargetUserName.ps1 rename Scripts/Helpers/{GetTargetUserForAppRemoval.ps1 => Get-TargetUserForAppRemoval.ps1} (89%) rename Scripts/Helpers/{GetUserDirectory.ps1 => Get-UserDirectory.ps1} (92%) create mode 100644 Scripts/Helpers/Get-UserName.ps1 delete mode 100644 Scripts/Helpers/GetFriendlyTargetUserName.ps1 delete mode 100644 Scripts/Helpers/GetUserName.ps1 rename Scripts/Helpers/{ImportConfigToParams.ps1 => Import-ConfigToParams.ps1} (82%) rename Scripts/Helpers/{RegistryPathHelpers.ps1 => Registry-PathHelpers.ps1} (73%) rename Scripts/Helpers/{ResolveUserProfilePath.ps1 => Resolve-UserProfilePath.ps1} (85%) rename Scripts/Helpers/{CheckModernStandbySupport.ps1 => Test-ModernStandbySupport.ps1} (94%) rename Scripts/Helpers/{CheckIfUserExists.ps1 => Test-UserProfileExists.ps1} (85%) rename Scripts/Helpers/{UserHiveHelpers.ps1 => User-HiveHelpers.ps1} (96%) create mode 100644 Scripts/Run-Tests.ps1 rename Scripts/Threading/{DoEvents.ps1 => Invoke-DoEvents.ps1} (91%) create mode 100644 Tests/Apply-RegistryRegFile.Tests.ps1 create mode 100644 Tests/Confirm-UnsafeAppRemoval.Tests.ps1 create mode 100644 Tests/Get-CurrentTweakState.Tests.ps1 create mode 100644 Tests/Get-FriendlyRegistryBackupTarget.Tests.ps1 create mode 100644 Tests/Get-RebootFeatureLabels.Tests.ps1 create mode 100644 Tests/Get-RegFileOperations.Tests.ps1 create mode 100644 Tests/Get-TargetUserForAppRemoval.Tests.ps1 create mode 100644 Tests/Import-ConfigToParams.Tests.ps1 create mode 100644 Tests/Import-JsonFile.Tests.ps1 create mode 100644 Tests/Import-RegistryFile.Tests.ps1 create mode 100644 Tests/Invoke-Changes.Tests.ps1 create mode 100644 Tests/MainWindow-AppSelection.Tests.ps1 create mode 100644 Tests/MainWindow-Deployment.Tests.ps1 create mode 100644 Tests/Registry-BackupCreation.Tests.ps1 create mode 100644 Tests/Registry-BackupValidation.Tests.ps1 create mode 100644 Tests/Registry-PathHelpers.Tests.ps1 create mode 100644 Tests/Registry-SnapshotApply.Tests.ps1 create mode 100644 Tests/Remove-SelectedApps.Tests.ps1 create mode 100644 Tests/Replace-StartMenu.Tests.ps1 create mode 100644 Tests/Resolve-UserProfilePath.Tests.ps1 create mode 100644 Tests/Restore-BackupDialogFeatureLists.Tests.ps1 create mode 100644 Tests/Restore-RegistryApplyState.Tests.ps1 create mode 100644 Tests/Restore-RegistryBackup.Tests.ps1 create mode 100644 Tests/Settings-And-Apps-FileIO.Tests.ps1 create mode 100644 Tests/Show-CliMenuOptions.Tests.ps1 create mode 100644 Tests/Test-AppInWingetList.Tests.ps1 create mode 100644 Tests/TestData/JsonFileLoading/Config.Invalid.json create mode 100644 Tests/TestData/JsonFileLoading/Config.Valid.json create mode 100644 Tests/TestData/JsonFileLoading/Config.VersionMismatch.json create mode 100644 Tests/TestData/JsonFileLoading/DefaultSettings.MissingSettings.json create mode 100644 Tests/TestData/JsonFileLoading/DefaultSettings.Valid.json create mode 100644 Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json create mode 100644 Tests/TestData/JsonFileLoading/LastUsedSettings.Invalid.json create mode 100644 Tests/TestData/JsonFileLoading/LastUsedSettings.Valid.json create mode 100644 Tests/TestData/JsonFileLoading/RegistryBackup.Invalid.json create mode 100644 Tests/TestData/JsonFileLoading/RegistryBackup.RealStructure.json create mode 100644 Tests/TestData/JsonFileLoading/RegistryBackup.Valid.json create mode 100644 Tests/User-HiveHelpers.Tests.ps1 create mode 100644 Tests/User-LookupHelpers.Tests.ps1 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index dbe7155..0e9c9d3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -58,6 +58,25 @@ You can launch the prerelease version of Win11Debloat by running this command: .\Win11Debloat.ps1 ``` +### Running Automated Tests + +The automated test cases use Pester 5 and do not modify the registry or other +system state. The optional bootstrap step installs Pester for your user account +when needed. To run the complete suite: + +```powershell +.\Scripts\Run-Tests.ps1 -Bootstrap +``` + +After the initial setup, run the suite with: + +```powershell +.\Scripts\Run-Tests.ps1 +``` + +GitHub Actions runs the same test command with Windows PowerShell 5.1 for pull +requests and pushes to `master`. + ## Implementation Guidelines ### Project Structure @@ -72,7 +91,7 @@ Win11Debloat/ │ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat │ ├── AppRemoval/ # App package removal logic │ ├── CLI/ # Command-line interface helpers -│ ├── Features/ # Feature apply/undo logic (e.g. InvokeChanges.ps1, ReplaceStartMenu.ps1) +│ ├── Features/ # Feature apply/undo logic (e.g. Invoke-Changes.ps1, Replace-StartMenu.ps1) │ ├── FileIO/ # File input/output helpers │ ├── GUI/ # GUI window definitions and logic │ ├── Helpers/ # Shared helper functions @@ -220,7 +239,7 @@ Windows Registry Editor Version 5.00 #### 1b. Implement the Feature Logic -If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/InvokeChanges.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file. +If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/Invoke-Changes.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file. #### 2. Add Feature to Features.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0b95720 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + pester: + name: Pester (Windows PowerShell 5.1) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run tests + shell: powershell + run: ./Scripts/Run-Tests.ps1 -Bootstrap diff --git a/Scripts/AppRemoval/GetInstalledAppsViaWinget.ps1 b/Scripts/AppRemoval/Get-WingetInstalledApps.ps1 similarity index 98% rename from Scripts/AppRemoval/GetInstalledAppsViaWinget.ps1 rename to Scripts/AppRemoval/Get-WingetInstalledApps.ps1 index 85b6726..1ec7706 100644 --- a/Scripts/AppRemoval/GetInstalledAppsViaWinget.ps1 +++ b/Scripts/AppRemoval/Get-WingetInstalledApps.ps1 @@ -17,7 +17,7 @@ PSCustomObject[] with Name and Id properties. Returns $null on failure, or an empty array when winget succeeds but lists no apps. #> -function GetInstalledAppsViaWinget { +function Get-WingetInstalledApps { param ( [int]$TimeOut = 10, [switch]$NonBlocking @@ -107,4 +107,4 @@ function GetInstalledAppsViaWinget { else { return & $fetchBlock $TimeOut } -} \ No newline at end of file +} diff --git a/Scripts/AppRemoval/ForceRemoveEdge.ps1 b/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 similarity index 94% rename from Scripts/AppRemoval/ForceRemoveEdge.ps1 rename to Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 index e12b708..e332436 100644 --- a/Scripts/AppRemoval/ForceRemoveEdge.ps1 +++ b/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 @@ -1,6 +1,8 @@ -# Forcefully removes Microsoft Edge using its uninstaller -# Credit: Based on work from loadstring1 & ave9858 -function ForceRemoveEdge { +<# + .SYNOPSIS + Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries. +#> +function Invoke-ForceRemoveEdge { Write-Host "> Forcefully uninstalling Microsoft Edge..." $regView = [Microsoft.Win32.RegistryView]::Registry32 @@ -54,4 +56,4 @@ function ForceRemoveEdge { else { Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red } -} \ No newline at end of file +} diff --git a/Scripts/AppRemoval/RemoveApps.ps1 b/Scripts/AppRemoval/Remove-SelectedApps.ps1 similarity index 92% rename from Scripts/AppRemoval/RemoveApps.ps1 rename to Scripts/AppRemoval/Remove-SelectedApps.ps1 index 3ebb9a7..a33319c 100644 --- a/Scripts/AppRemoval/RemoveApps.ps1 +++ b/Scripts/AppRemoval/Remove-SelectedApps.ps1 @@ -14,12 +14,12 @@ An array of app package identifiers to remove (e.g. 'Microsoft.BingNews'). .EXAMPLE - RemoveApps @('Microsoft.BingNews', 'Microsoft.BingWeather') + Remove-SelectedApps @('Microsoft.BingNews', 'Microsoft.BingWeather') .EXAMPLE - RemoveApps -appsList (GenerateAppsList) + Remove-SelectedApps -appsList (Generate-AppsList) #> -function RemoveApps { +function Remove-SelectedApps { param ( $appslist ) @@ -33,7 +33,7 @@ function RemoveApps { return } - $targetUser = GetTargetUserForAppRemoval + $targetUser = Get-TargetUserForAppRemoval $appCount = @($appsList).Count $appIndex = 0 @@ -67,7 +67,7 @@ function RemoveApps { # Check whether any winget-removed apps are still present, and report errors for each one. if ($wingetRemovedApps.Count -gt 0) { - $postRemovalList = if ($script:WingetInstalled) { GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking } else { $null } + $postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null } $edgeForceRemoveRequested = $false foreach ($app in $wingetRemovedApps) { @@ -116,7 +116,7 @@ function Remove-WinGetApp { } -ArgumentList $app if ($script:Params.ContainsKey("User")) { - Write-Host "Adding scheduled task to uninstall $app for user $(GetUserName)..." + Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..." Set-RunOnceWingetTask -appId $app } elseif ($script:Params.ContainsKey("Sysprep")) { @@ -185,7 +185,7 @@ function Remove-AppxApp { The package identifier to check (e.g. 'Microsoft.BingNews'). .PARAMETER InstalledList - Optional pre-fetched array of winget objects from GetInstalledAppsViaWinget. + Optional pre-fetched array of winget objects from Get-WingetInstalledApps. When provided, used directly; otherwise a live winget call is made. #> function Test-AppStillInstalled { @@ -205,7 +205,7 @@ function Test-AppStillInstalled { } if ($script:WingetInstalled) { - $liveList = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking + $liveList = Get-WingetInstalledApps -TimeOut 10 -NonBlocking if (Test-AppInWingetList -appId $appId -InstalledList $liveList) { return $true } @@ -241,11 +241,12 @@ function Get-AppRemovalMethod { foreach ($appData in $appsJson.Apps) { $rawMethod = $appData.RemovalMethod $method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' } - if ($appData.AppId -is [array]) { - foreach ($id in $appData.AppId) { $script:AppRemovalMethodCache[$id.Trim()] = $method } - } - else { - $script:AppRemovalMethodCache[$appData.AppId.Trim()] = $method + foreach ($id in @($appData.AppId)) { + if ($id -isnot [string]) { continue } + $normalizedId = $id.Trim() + if (-not [string]::IsNullOrWhiteSpace($normalizedId)) { + $script:AppRemovalMethodCache[$normalizedId] = $method + } } } } @@ -276,12 +277,12 @@ function Request-EdgeForceRemove { $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 "" - ForceRemoveEdge + Invoke-ForceRemoveEdge } } elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') { Write-Host "" - ForceRemoveEdge + Invoke-ForceRemoveEdge } } diff --git a/Scripts/AppRemoval/Test-AppInWingetList.ps1 b/Scripts/AppRemoval/Test-AppInWingetList.ps1 index 6777d8c..01d977a 100644 --- a/Scripts/AppRemoval/Test-AppInWingetList.ps1 +++ b/Scripts/AppRemoval/Test-AppInWingetList.ps1 @@ -12,7 +12,7 @@ The identifier to search for (e.g. 'Microsoft.Copilot'). .PARAMETER InstalledList - An array of PSCustomObject from GetInstalledAppsViaWinget. + An array of PSCustomObject from Get-WingetInstalledApps. #> function Test-AppInWingetList { param( diff --git a/Scripts/CLI/ShowCLIAppRemoval.ps1 b/Scripts/CLI/Show-CliAppRemoval.ps1 similarity index 76% rename from Scripts/CLI/ShowCLIAppRemoval.ps1 rename to Scripts/CLI/Show-CliAppRemoval.ps1 index 5af4d11..66e0cc3 100644 --- a/Scripts/CLI/ShowCLIAppRemoval.ps1 +++ b/Scripts/CLI/Show-CliAppRemoval.ps1 @@ -1,6 +1,6 @@ # Shows the CLI app removal menu and prompts the user to select which apps to remove. -function ShowCLIAppRemoval { - PrintHeader "App Removal" +function Show-CliAppRemoval { + Write-CliHeader "App Removal" Write-Output "> Opening app selection form..." @@ -8,10 +8,10 @@ function ShowCLIAppRemoval { if ($result -eq $true) { Write-Output "You have selected $($script:SelectedApps.Count) apps for removal" - AddParameter 'RemoveApps' - AddParameter 'Apps' ($script:SelectedApps -join ',') + Add-Parameter 'RemoveApps' + Add-Parameter 'Apps' ($script:SelectedApps -join ',') - SaveSettings + Save-Settings # Suppress prompt if Silent parameter was passed if (-not $Silent) { @@ -19,7 +19,7 @@ function ShowCLIAppRemoval { Write-Output "" Write-Output "Press enter to remove the selected apps or press CTRL+C to quit..." Read-Host | Out-Null - PrintHeader "App Removal" + Write-CliHeader "App Removal" } } else { diff --git a/Scripts/CLI/ShowCLIDefaultModeAppRemovalOptions.ps1 b/Scripts/CLI/Show-CliDefaultModeAppRemovalOptions.ps1 similarity index 94% rename from Scripts/CLI/ShowCLIDefaultModeAppRemovalOptions.ps1 rename to Scripts/CLI/Show-CliDefaultModeAppRemovalOptions.ps1 index 635feba..c476893 100644 --- a/Scripts/CLI/ShowCLIDefaultModeAppRemovalOptions.ps1 +++ b/Scripts/CLI/Show-CliDefaultModeAppRemovalOptions.ps1 @@ -1,6 +1,6 @@ # Shows the CLI default mode app removal options. Loops until a valid option is selected. -function ShowCLIDefaultModeAppRemovalOptions { - PrintHeader 'Default Mode' +function Show-CliDefaultModeAppRemovalOptions { + Write-CliHeader 'Default Mode' Write-Host "Please note: The default selection of apps includes Microsoft Teams, Spotify, Sticky Notes and more. Select option 2 to verify and change what apps are removed by the script" -ForegroundColor DarkGray Write-Host "" diff --git a/Scripts/CLI/ShowCLIDefaultModeOptions.ps1 b/Scripts/CLI/Show-CliDefaultModeOptions.ps1 similarity index 64% rename from Scripts/CLI/ShowCLIDefaultModeOptions.ps1 rename to Scripts/CLI/Show-CliDefaultModeOptions.ps1 index b66b6da..059086c 100644 --- a/Scripts/CLI/ShowCLIDefaultModeOptions.ps1 +++ b/Scripts/CLI/Show-CliDefaultModeOptions.ps1 @@ -1,5 +1,5 @@ # Show CLI default mode options for removing apps, or set selection if RunDefaults or RunDefaultsLite parameter was passed -function ShowCLIDefaultModeOptions { +function Show-CliDefaultModeOptions { if ($RunDefaults) { $RemoveAppsInput = '1' } @@ -7,7 +7,7 @@ function ShowCLIDefaultModeOptions { $RemoveAppsInput = '0' } else { - $RemoveAppsInput = ShowCLIDefaultModeAppRemovalOptions + $RemoveAppsInput = Show-CliDefaultModeAppRemovalOptions if ($RemoveAppsInput -eq '2' -and ($script:SelectedApps.contains('Microsoft.XboxGameOverlay') -or $script:SelectedApps.contains('Microsoft.XboxGamingOverlay')) -and $( Read-Host -Prompt "Disable Game Bar integration and game/screen recording? This also stops ms-gamingoverlay and ms-gamebar popups (y/n)" ) -eq 'y') { @@ -15,40 +15,40 @@ function ShowCLIDefaultModeOptions { } } - PrintHeader 'Default Mode' + Write-CliHeader 'Default Mode' try { # Select app removal options based on user input switch ($RemoveAppsInput) { '1' { - AddParameter 'RemoveApps' - AddParameter 'Apps' 'Default' + Add-Parameter 'RemoveApps' + Add-Parameter 'Apps' 'Default' } '2' { - AddParameter 'RemoveApps' - AddParameter 'Apps' ($script:SelectedApps -join ',') + Add-Parameter 'RemoveApps' + Add-Parameter 'Apps' ($script:SelectedApps -join ',') if ($DisableGameBarIntegrationInput) { - AddParameter 'DisableDVR' - AddParameter 'DisableGameBarIntegration' + Add-Parameter 'DisableDVR' + Add-Parameter 'DisableGameBarIntegration' } } } - LoadSettings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" + Import-Settings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" } catch { Write-Error "Failed to load settings from DefaultSettings.json file: $_" - AwaitKeyToExit + Wait-ForKeyPress } - SaveSettings + Save-Settings if ($Silent) { # Skip change summary and confirmation prompt return } - PrintPendingChanges - PrintHeader 'Default Mode' + Write-PendingChanges + Write-CliHeader 'Default Mode' } \ No newline at end of file diff --git a/Scripts/CLI/ShowCLILastUsedSettings.ps1 b/Scripts/CLI/Show-CliLastUsedSettings.ps1 similarity index 58% rename from Scripts/CLI/ShowCLILastUsedSettings.ps1 rename to Scripts/CLI/Show-CliLastUsedSettings.ps1 index 08b544a..4c8ea31 100644 --- a/Scripts/CLI/ShowCLILastUsedSettings.ps1 +++ b/Scripts/CLI/Show-CliLastUsedSettings.ps1 @@ -1,13 +1,13 @@ # Shows the CLI last used settings from LastUsedSettings.json file, displays pending changes and prompts the user to apply them. -function ShowCLILastUsedSettings { - PrintHeader 'Custom Mode' +function Show-CliLastUsedSettings { + Write-CliHeader 'Custom Mode' try { - LoadSettings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" + Import-Settings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" } catch { Write-Error "Failed to load settings from LastUsedSettings.json file: $_" - AwaitKeyToExit + Wait-ForKeyPress } if ($Silent) { @@ -15,6 +15,6 @@ function ShowCLILastUsedSettings { return } - PrintPendingChanges - PrintHeader 'Custom Mode' + Write-PendingChanges + Write-CliHeader 'Custom Mode' } \ No newline at end of file diff --git a/Scripts/CLI/ShowCLIMenuOptions.ps1 b/Scripts/CLI/Show-CliMenuOptions.ps1 similarity index 93% rename from Scripts/CLI/ShowCLIMenuOptions.ps1 rename to Scripts/CLI/Show-CliMenuOptions.ps1 index e978190..94e7b34 100644 --- a/Scripts/CLI/ShowCLIMenuOptions.ps1 +++ b/Scripts/CLI/Show-CliMenuOptions.ps1 @@ -1,9 +1,9 @@ # Shows the CLI menu options and prompts the user to select one. Loops until a valid option is selected. -function ShowCLIMenuOptions { +function Show-CliMenuOptions { Do { $ModeSelectionMessage = "Please select an option (1/2)" - PrintHeader 'Menu' + Write-CliHeader 'Menu' Write-Host "(1) Default mode: Quickly apply the recommended changes" Write-Host "(2) App removal mode: Select & remove apps, without making other changes" diff --git a/Scripts/CLI/AwaitKeyToExit.ps1 b/Scripts/CLI/Wait-ForKeyPress.ps1 similarity index 88% rename from Scripts/CLI/AwaitKeyToExit.ps1 rename to Scripts/CLI/Wait-ForKeyPress.ps1 index 8ba892c..172d670 100644 --- a/Scripts/CLI/AwaitKeyToExit.ps1 +++ b/Scripts/CLI/Wait-ForKeyPress.ps1 @@ -1,4 +1,4 @@ -function AwaitKeyToExit { +function Wait-ForKeyPress { # Suppress prompt if Silent parameter was passed if (-not $Silent) { Write-Output "" @@ -8,4 +8,4 @@ function AwaitKeyToExit { Stop-Transcript Exit -} \ No newline at end of file +} diff --git a/Scripts/CLI/PrintHeader.ps1 b/Scripts/CLI/Write-CliHeader.ps1 similarity index 85% rename from Scripts/CLI/PrintHeader.ps1 rename to Scripts/CLI/Write-CliHeader.ps1 index 42d3130..b9b54d6 100644 --- a/Scripts/CLI/PrintHeader.ps1 +++ b/Scripts/CLI/Write-CliHeader.ps1 @@ -1,5 +1,5 @@ # Prints the header for the script -function PrintHeader { +function Write-CliHeader { param ( $title ) @@ -10,11 +10,11 @@ function PrintHeader { $fullTitle = "$fullTitle (Sysprep mode)" } else { - $fullTitle = "$fullTitle (User: $(GetUserName))" + $fullTitle = "$fullTitle (User: $(Get-UserName))" } Clear-Host Write-Host "-------------------------------------------------------------------------------------------" Write-Host $fullTitle Write-Host "-------------------------------------------------------------------------------------------" -} \ No newline at end of file +} diff --git a/Scripts/CLI/PrintPendingChanges.ps1 b/Scripts/CLI/Write-PendingChanges.ps1 similarity index 96% rename from Scripts/CLI/PrintPendingChanges.ps1 rename to Scripts/CLI/Write-PendingChanges.ps1 index 5a135b9..09a2479 100644 --- a/Scripts/CLI/PrintPendingChanges.ps1 +++ b/Scripts/CLI/Write-PendingChanges.ps1 @@ -12,7 +12,7 @@ After printing the summary the function pauses until the user presses Enter, giving them an opportunity to review and cancel via Ctrl+C. #> -function PrintPendingChanges { +function Write-PendingChanges { Write-Output "Win11Debloat will make the following changes:" if ($script:Params['CreateRestorePoint']) { @@ -32,7 +32,7 @@ function PrintPendingChanges { continue } 'RemoveApps' { - $appsList = GenerateAppsList + $appsList = Generate-AppsList if ($appsList.Count -eq 0) { Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow @@ -56,4 +56,4 @@ function PrintPendingChanges { Write-Output "" Write-Output "Press enter to execute the script or press CTRL+C to quit..." Read-Host | Out-Null -} \ No newline at end of file +} diff --git a/Scripts/Features/BackupRegistryFeatureSelection.ps1 b/Scripts/Features/Backup-RegistryFeatureSelection.ps1 similarity index 100% rename from Scripts/Features/BackupRegistryFeatureSelection.ps1 rename to Scripts/Features/Backup-RegistryFeatureSelection.ps1 diff --git a/Scripts/Features/BackupRegistrySnapshotCapture.ps1 b/Scripts/Features/Backup-RegistrySnapshotCapture.ps1 similarity index 88% rename from Scripts/Features/BackupRegistrySnapshotCapture.ps1 rename to Scripts/Features/Backup-RegistrySnapshotCapture.ps1 index 645691b..1dcc405 100644 --- a/Scripts/Features/BackupRegistrySnapshotCapture.ps1 +++ b/Scripts/Features/Backup-RegistrySnapshotCapture.ps1 @@ -195,10 +195,18 @@ function Get-RegistryKeySnapshot { } } +<# + .SYNOPSIS + Converts an open registry key into a backup snapshot. + + .DESCRIPTION + Captures all values or selected value names, records missing selected values, + and recursively captures subkeys when requested. +#> function Convert-RegistryKeyToSnapshot { param( [Parameter(Mandatory)] - [Microsoft.Win32.RegistryKey]$RegistryKey, + $RegistryKey, [Parameter(Mandatory)] [string]$FullPath, [bool]$CaptureAllValues = $false, @@ -253,20 +261,34 @@ function Convert-RegistryKeyToSnapshot { } } +<# + .SYNOPSIS + Converts a registry value into a serializable backup snapshot. + + .DESCRIPTION + Preserves the value kind and normalizes supported data types for JSON + serialization without expanding environment-string values. REG_NONE values + are rejected. +#> function Convert-RegistryValueToSnapshot { param( [Parameter(Mandatory)] - [Microsoft.Win32.RegistryKey]$RegistryKey, + $RegistryKey, [Parameter(Mandatory)] [AllowEmptyString()] [string]$ValueName ) $valueKind = $RegistryKey.GetValueKind($ValueName) + if ($valueKind -eq [Microsoft.Win32.RegistryValueKind]::None) { + throw "REG_NONE registry values are not supported for backup. Key='$($RegistryKey.Name)' Name='$ValueName'" + } + $value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) try { $normalizedValue = switch ($valueKind) { - ([Microsoft.Win32.RegistryValueKind]::Binary) { @($value | ForEach-Object { [int]$_ }) } + # Prevent an empty byte sequence from being unrolled to $null by the switch pipeline. + ([Microsoft.Win32.RegistryValueKind]::Binary) { if ($null -eq $value) { ,@() } else { ,@($value | ForEach-Object { [int]$_ }) } } ([Microsoft.Win32.RegistryValueKind]::MultiString) { @($value) } ([Microsoft.Win32.RegistryValueKind]::DWord) { [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$value), 0) } ([Microsoft.Win32.RegistryValueKind]::QWord) { [BitConverter]::ToUInt64([BitConverter]::GetBytes([int64]$value), 0) } @@ -287,12 +309,20 @@ function Convert-RegistryValueToSnapshot { } } +<# + .SYNOPSIS + Describes the user profile targeted by a registry backup. + + .DESCRIPTION + Returns DefaultUserProfile for Sysprep, User: for an explicit user, + or CurrentUser: otherwise. +#> function Get-RegistryBackupTargetDescription { if ($script:Params.ContainsKey('Sysprep')) { return 'DefaultUserProfile' } - $resolvedUserName = [string](GetUserName) + $resolvedUserName = [string](Get-UserName) if ($script:Params.ContainsKey('User')) { return "User:$resolvedUserName" diff --git a/Scripts/Features/BackupRegistryState.ps1 b/Scripts/Features/Backup-RegistryState.ps1 similarity index 98% rename from Scripts/Features/BackupRegistryState.ps1 rename to Scripts/Features/Backup-RegistryState.ps1 index 0ce7e9e..30a8753 100644 --- a/Scripts/Features/BackupRegistryState.ps1 +++ b/Scripts/Features/Backup-RegistryState.ps1 @@ -37,7 +37,7 @@ function New-RegistrySettingsBackup { $backupFilePath = Join-Path $backupDirectory $backupFileName $backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -UndoFeatures $undoFeatures -CreatedAt $timestamp - if (-not (SaveToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) { + if (-not (Save-ToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) { throw "Failed to save registry backup to '$backupFilePath'" } diff --git a/Scripts/Features/CreateSystemRestorePoint.ps1 b/Scripts/Features/Ensure-SystemRestorePoint.ps1 similarity index 99% rename from Scripts/Features/CreateSystemRestorePoint.ps1 rename to Scripts/Features/Ensure-SystemRestorePoint.ps1 index 624dcbe..7c0d72d 100644 --- a/Scripts/Features/CreateSystemRestorePoint.ps1 +++ b/Scripts/Features/Ensure-SystemRestorePoint.ps1 @@ -1,4 +1,4 @@ -function CreateSystemRestorePoint { +function Invoke-SystemRestorePoint { $SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" $failed = $false diff --git a/Scripts/Features/GetCurrentTweakState.ps1 b/Scripts/Features/Get-CurrentTweakState.ps1 similarity index 98% rename from Scripts/Features/GetCurrentTweakState.ps1 rename to Scripts/Features/Get-CurrentTweakState.ps1 index da7f232..770b5c6 100644 --- a/Scripts/Features/GetCurrentTweakState.ps1 +++ b/Scripts/Features/Get-CurrentTweakState.ps1 @@ -66,7 +66,7 @@ function Test-FeatureApplied { return (Test-StoreSearchSuggestionsDisabledForAllUsers) } - $storeDbPath = GetStoreAppsDatabasePathForUser -UserName (GetUserName) + $storeDbPath = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName) return (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDbPath) } diff --git a/Scripts/Features/ImportRegistryFile.ps1 b/Scripts/Features/Import-RegistryFile.ps1 similarity index 99% rename from Scripts/Features/ImportRegistryFile.ps1 rename to Scripts/Features/Import-RegistryFile.ps1 index 2524280..1c17d98 100644 --- a/Scripts/Features/ImportRegistryFile.ps1 +++ b/Scripts/Features/Import-RegistryFile.ps1 @@ -1,5 +1,5 @@ # Import & execute regfile -function ImportRegistryFile { +function Import-RegistryFile { param ( $message, $path diff --git a/Scripts/Features/InvokeChanges.ps1 b/Scripts/Features/Invoke-Changes.ps1 similarity index 82% rename from Scripts/Features/InvokeChanges.ps1 rename to Scripts/Features/Invoke-Changes.ps1 index a8a1e92..1a1de4f 100644 --- a/Scripts/Features/InvokeChanges.ps1 +++ b/Scripts/Features/Invoke-Changes.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Handles two categories of features: - - Registry-backed: imports the .reg file via ImportRegistryFile, then runs + - 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. @@ -21,17 +21,17 @@ function Invoke-FeatureApply { # ---- Registry-backed features: import .reg file, then handle side effects ---- if ($feature.RegistryKey) { - ImportRegistryFile "> $applyText..." $feature.RegistryKey + Import-RegistryFile "> $applyText..." $feature.RegistryKey # Post-import side effects for specific features switch ($FeatureId) { 'DisableBing' { # Also remove the app package for Bing search - RemoveApps @('Microsoft.BingSearch') + Remove-SelectedApps @('Microsoft.BingSearch') } 'DisableCopilot' { # Also remove the app packages for Copilot - RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX') + Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX') } 'DisableTelemetry' { # Also disable telemetry scheduled tasks @@ -44,8 +44,8 @@ function Invoke-FeatureApply { # ---- Custom features (no registry backing, or special handling required) ---- switch ($FeatureId) { 'RemoveApps' { - Write-Host "> $applyText for $(GetFriendlyTargetUserName)..." - $appsList = GenerateAppsList + Write-Host "> $applyText for $(Get-FriendlyTargetUserName)..." + $appsList = Generate-AppsList if ($appsList.Count -eq 0) { Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow @@ -54,19 +54,19 @@ function Invoke-FeatureApply { } Write-Host "$($appsList.Count) apps selected for removal" - RemoveApps $appsList + Remove-SelectedApps $appsList return } 'RemoveGamingApps' { $appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay') Write-Host "> $applyText..." - RemoveApps $appsList + Remove-SelectedApps $appsList return } '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..." - RemoveApps $appsList + Remove-SelectedApps $appsList return } 'DisableWidgets' { @@ -76,46 +76,46 @@ function Invoke-FeatureApply { Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process } - RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime') + Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime') return } 'EnableWindowsSandbox' { Write-Host "> $applyText..." - EnableWindowsFeature "Containers-DisposableClientVM" + Enable-WindowsFeature "Containers-DisposableClientVM" Write-Host "" return } 'EnableWindowsSubsystemForLinux' { Write-Host "> $applyText..." - EnableWindowsFeature "VirtualMachinePlatform" - EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux" + Enable-WindowsFeature "VirtualMachinePlatform" + Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux" Write-Host "" return } 'ClearStart' { - Write-Host "> $applyText for user $(GetUserName)..." - $startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName) + Write-Host "> $applyText for user $(Get-UserName)..." + $startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName) if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) { - ReplaceStartMenu -startMenuBinFile $startMenuBinFile + Replace-StartMenu -startMenuBinFile $startMenuBinFile } Write-Host "" return } 'ReplaceStart' { - Write-Host "> $applyText for user $(GetUserName)..." - $startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName) + Write-Host "> $applyText for user $(Get-UserName)..." + $startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName) if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) { - ReplaceStartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart") + Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart") } Write-Host "" return } 'ClearStartAllUsers' { - ReplaceStartMenuForAllUsers + Replace-StartMenuForAllUsers return } 'ReplaceStartAllUsers' { - ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers") + Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers") return } 'DisableStoreSearchSuggestions' { @@ -126,10 +126,10 @@ function Invoke-FeatureApply { return } - Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..." - $storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName) + Write-Host "> Disabling Microsoft Store search suggestions for user $(Get-UserName)..." + $storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName) if ($storeDb) { - DisableStoreSearchSuggestions -StoreAppsDatabase $storeDb + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb } Write-Host "" return @@ -145,7 +145,7 @@ function Invoke-FeatureApply { .DESCRIPTION Handles undo for features that require custom logic rather than a simple .reg file import. Features with a RegistryUndoKey are handled directly - via ImportRegistryFile in Invoke-UndoFeatures. + via Import-RegistryFile in Invoke-UndoFeatures. #> function Invoke-FeatureUndo { param( @@ -164,24 +164,24 @@ function Invoke-FeatureUndo { return } - Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..." - $storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName) + Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..." + $storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName) if ($storeDb) { - EnableStoreSearchSuggestions -StoreAppsDatabase $storeDb + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb } Write-Host "" return } 'EnableWindowsSandbox' { Write-Host "> $($feature.ApplyUndoText)..." - DisableWindowsFeature 'Containers-DisposableClientVM' + Disable-WindowsFeature 'Containers-DisposableClientVM' Write-Host "" return } 'EnableWindowsSubsystemForLinux' { Write-Host "> $($feature.ApplyUndoText)..." - DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux' - DisableWindowsFeature 'VirtualMachinePlatform' + Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux' + Disable-WindowsFeature 'VirtualMachinePlatform' Write-Host "" return } @@ -287,7 +287,7 @@ function Invoke-UndoFeatures { } if ($f -and $f.RegistryUndoKey) { - ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey) + Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey) } Invoke-FeatureUndo -FeatureId $featureId @@ -311,8 +311,10 @@ function Invoke-UndoFeatures { (used by the GUI modal). Cancellation is checked between each step. #> function Invoke-AllChanges { + if ($script:CancelRequested) { return } + # Guard: prevent running as SYSTEM account without explicit target user - $isSystem = ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18') + $isSystem = Test-RunningAsSystem if ($isSystem -and -not $script:Params.ContainsKey("User") -and -not $script:Params.ContainsKey("Sysprep")) { throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user." } @@ -355,6 +357,7 @@ function Invoke-AllChanges { # Phase 1: Registry backup # ================================================================ if ($needsBackup) { + if ($script:CancelRequested) { return } $step++ if ($script:ApplyProgressCallback) { & $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..." @@ -384,6 +387,7 @@ function Invoke-AllChanges { # Phase 2: System restore point # ================================================================ if ($script:Params.ContainsKey("CreateRestorePoint")) { + if ($script:CancelRequested) { return } $step++ if ($script:ApplyProgressCallback) { & $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..." @@ -394,7 +398,7 @@ function Invoke-AllChanges { } else { Write-Host "> Creating a system restore point..." - CreateSystemRestorePoint + Invoke-SystemRestorePoint Write-Host "" } } @@ -407,6 +411,8 @@ function Invoke-AllChanges { $step += $applyIds.Count } + if ($script:CancelRequested) { return } + # ================================================================ # Phase 4: Undo features # ================================================================ @@ -423,3 +429,19 @@ function Invoke-AllChanges { Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow } } + +<# + .SYNOPSIS + Tests whether Win11Debloat is running under the SYSTEM account. + + .DESCRIPTION + Compares the current Windows identity's security identifier (SID) with + the well-known Local System SID (S-1-5-18). + + .OUTPUTS + System.Boolean + Returns $true when the current process runs as SYSTEM; otherwise, $false. +#> +function Test-RunningAsSystem { + return ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18') +} diff --git a/Scripts/Features/RestartExplorer.ps1 b/Scripts/Features/Invoke-RestartExplorer.ps1 similarity index 97% rename from Scripts/Features/RestartExplorer.ps1 rename to Scripts/Features/Invoke-RestartExplorer.ps1 index c5cb510..3be5c68 100644 --- a/Scripts/Features/RestartExplorer.ps1 +++ b/Scripts/Features/Invoke-RestartExplorer.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Restarts the Explorer process to ensure all UI modifications take effect. Shows a warning if any of the applied features require a reboot to take full effect. #> -function RestartExplorer { +function Invoke-RestartExplorer { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Restart the Windows Explorer process" -ForegroundColor Cyan return @@ -32,4 +32,4 @@ function RestartExplorer { else { Write-Host "Unable to restart Windows Explorer process, please manually reboot your PC to apply all changes" -ForegroundColor Yellow } -} \ No newline at end of file +} diff --git a/Scripts/Features/RegistryBackupValidation.ps1 b/Scripts/Features/Registry-BackupValidation.ps1 similarity index 84% rename from Scripts/Features/RegistryBackupValidation.ps1 rename to Scripts/Features/Registry-BackupValidation.ps1 index 0411280..e578501 100644 --- a/Scripts/Features/RegistryBackupValidation.ps1 +++ b/Scripts/Features/Registry-BackupValidation.ps1 @@ -248,6 +248,14 @@ function New-RegistryBackupAllowListPlanMap { return $planMap } +<# + .SYNOPSIS + Converts registry value names into a case-insensitive set. + + .DESCRIPTION + Preserves empty names and prevents PowerShell from enumerating the returned + HashSet. +#> function ConvertTo-RegistryValueNameSet { param( [AllowEmptyCollection()] @@ -259,9 +267,18 @@ function ConvertTo-RegistryValueNameSet { $null = $valueNameSet.Add([string]$valueName) } - return $valueNameSet + # Prevent PowerShell from enumerating the HashSet into an array or single string + return ,$valueNameSet } +<# + .SYNOPSIS + Validates a registry snapshot against the selected-feature allow list. + + .DESCRIPTION + Recursively validates snapshot paths, value names, value kinds, and value + data, appending validation errors to the supplied list. +#> function Test-RegistrySnapshotAgainstAllowList { param( [Parameter(Mandatory)] @@ -301,7 +318,7 @@ function Test-RegistrySnapshotAgainstAllowList { $Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.") } elseif (-not (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) { - $Errors.Add("Backup value '$valueReference' has Data that does not fit its Kind '$kindName'.") + $Errors.Add("Backup contains invalid registry data for kind '$kindName' at '$valueReference'.") } } elseif (-not [string]::IsNullOrWhiteSpace($kindName)) { @@ -314,6 +331,64 @@ function Test-RegistrySnapshotAgainstAllowList { } } +<# + .SYNOPSIS + Tests whether backed-up registry data is valid for its declared value kind. + + .DESCRIPTION + Rejects corrupted or hand-edited backup data that cannot be restored safely, + such as a DWord that overflows UInt32 or binary data containing an invalid byte. + This validation runs before Restore-RegistryKeySnapshot mutates the live + registry, preventing a failed conversion from leaving a partially restored key. + + .PARAMETER KindName + The declared registry value kind name, such as DWord, QWord, or Binary. + + .PARAMETER Data + The backed-up value data to validate against the declared kind. + + .OUTPUTS + System.Boolean +#> +function Test-RegistryValueDataMatchesKind { + param( + [Parameter(Mandatory)] + [string]$KindName, + [AllowNull()] + $Data + ) + + $kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true) + switch ($kind) { + ([Microsoft.Win32.RegistryValueKind]::DWord) { + $parsed = [uint32]0 + return [uint32]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed) + } + ([Microsoft.Win32.RegistryValueKind]::QWord) { + $parsed = [uint64]0 + return [uint64]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed) + } + ([Microsoft.Win32.RegistryValueKind]::Binary) { + if ($null -eq $Data -or $Data -isnot [array]) { return $false } + foreach ($item in @($Data)) { + if ($item -isnot [ValueType] -and $item -isnot [string]) { return $false } + $parsed = 0 + if (-not [int]::TryParse([string]$item, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt 255) { + return $false + } + } + return $true + } + ([Microsoft.Win32.RegistryValueKind]::MultiString) { + foreach ($item in @($Data)) { + if ($item -isnot [string]) { return $false } + } + return $true + } + default { return ($null -eq $Data -or $Data -is [string]) } + } +} + function Test-RegistryValueAllowedByPlan { param( [Parameter(Mandatory)] @@ -431,6 +506,14 @@ function Get-NormalizedRegistryPathKey { return "$normalizedHive\\$normalizedSubKey" } +<# + .SYNOPSIS + Tests whether a registry value-kind name is supported in backups. + + .DESCRIPTION + Parses kind names case-insensitively and rejects empty, invalid, Unknown, + and None values. +#> function Test-RegistryValueKindNameSupported { param( [string]$KindName @@ -442,64 +525,10 @@ function Test-RegistryValueKindNameSupported { try { $kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true) - return $kind -ne [Microsoft.Win32.RegistryValueKind]::Unknown + return $kind -notin @([Microsoft.Win32.RegistryValueKind]::Unknown, [Microsoft.Win32.RegistryValueKind]::None) } catch { return $false } } -<# - .SYNOPSIS - Checks whether a backed-up value's Data can be converted to its declared Kind. - - .DESCRIPTION - A corrupted or hand-edited backup can have Data that doesn't fit its declared - Kind (e.g. Kind=DWord with Data=4294967296, which overflows uint32). Restore- - RegistryValueSnapshot's Convert-RegistryValueDataFromBackup performs the same - narrowing casts without a try/catch, and by the time it runs the live registry - subtree has already been deleted (Restore-RegistryKeySnapshot deletes before - rewriting) - so an invalid Data/Kind pairing must be rejected here, before any - restore begins, not left to fail mid-restore. See #686. - - .PARAMETER KindName - The value's declared registry kind name (e.g. "DWord", "QWord", "String"). - - .PARAMETER Data - The value's backed-up data to validate against KindName. -#> -function Test-RegistryValueDataMatchesKind { - param( - [string]$KindName, - $Data - ) - - $kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true) - - switch ($kind) { - ([Microsoft.Win32.RegistryValueKind]::DWord) { - try { - [void][uint32]$Data - return $true - } - catch { - return $false - } - } - ([Microsoft.Win32.RegistryValueKind]::QWord) { - try { - [void][uint64]$Data - return $true - } - catch { - return $false - } - } - default { - # String/MultiString/Binary/None conversions in Convert-RegistryValueDataFromBackup - # cannot throw for arbitrary Data - they stringify, array-map to strings, or fall - # back to an empty byte array / null. - return $true - } - } -} diff --git a/Scripts/Features/ReplaceStartMenu.ps1 b/Scripts/Features/Replace-StartMenu.ps1 similarity index 80% rename from Scripts/Features/ReplaceStartMenu.ps1 rename to Scripts/Features/Replace-StartMenu.ps1 index 6314269..3970d9e 100644 --- a/Scripts/Features/ReplaceStartMenu.ps1 +++ b/Scripts/Features/Replace-StartMenu.ps1 @@ -14,12 +14,12 @@ bundled with the script (Assets/Start/start2.bin). .EXAMPLE - ReplaceStartMenuForAllUsers + Replace-StartMenuForAllUsers .EXAMPLE - ReplaceStartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin" + Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin" #> -function ReplaceStartMenuForAllUsers { +function Replace-StartMenuForAllUsers { param ( [string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin" ) @@ -34,16 +34,16 @@ function ReplaceStartMenuForAllUsers { } # Get path to start menu file for all users - $userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" + $userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" $usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue # Go through all users and replace the start menu file ForEach ($startMenuPath in $usersStartMenuPaths) { - ReplaceStartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate + Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate } # Also replace the start menu file for the default user profile - $defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false + $defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan @@ -57,7 +57,7 @@ function ReplaceStartMenuForAllUsers { } # Copy template to default profile - ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate + Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate Write-Host "Replaced start menu for the default user profile" Write-Host "" } @@ -83,12 +83,12 @@ function ReplaceStartMenuForAllUsers { bundled with the script (Assets/Start/start2.bin). .EXAMPLE - ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" + Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" .EXAMPLE - ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin" + Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin" #> -function ReplaceStartMenu { +function Replace-StartMenu { param ( [Parameter(Mandatory)] [string]$startMenuBinFile, @@ -106,7 +106,7 @@ function ReplaceStartMenu { return } - $userName = GetStartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile + $userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan @@ -147,12 +147,12 @@ function ReplaceStartMenu { The target username. Pass an empty string or omit to resolve for the current user. .EXAMPLE - GetStartMenuBinPathForUser -UserName "Jeff" + Get-StartMenuBinPathForUser -UserName "Jeff" .EXAMPLE - GetStartMenuBinPathForUser -UserName "Default" + Get-StartMenuBinPathForUser -UserName "Default" #> -function GetStartMenuBinPathForUser { +function Get-StartMenuBinPathForUser { param( [string]$UserName ) @@ -161,7 +161,7 @@ function GetStartMenuBinPathForUser { return "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" } - return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false) + return (Get-UserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false) } <# @@ -177,9 +177,9 @@ function GetStartMenuBinPathForUser { The full path to a start2.bin file. .EXAMPLE - GetStartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" + Get-StartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" #> -function GetStartMenuUserNameFromPath { +function Get-StartMenuUserNameFromPath { param( [string]$StartMenuBinFile ) @@ -230,7 +230,7 @@ function Get-StartMenuBackupPath { return $null } else { - $userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" + $userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" $usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue foreach ($startMenuPath in $usersStartMenuPaths) { $latestBackup = Get-ChildItem -Path (Join-Path $startMenuPath.FullName 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue | @@ -261,19 +261,19 @@ function Get-StartMenuBackupPath { finds the latest Win11Debloat-StartBackup-*.bak file. .EXAMPLE - RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" + Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" .EXAMPLE - RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak" + Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak" #> -function RestoreStartMenuFromBackup { +function Restore-StartMenuFromBackup { param( [Parameter(Mandatory)] [string]$StartMenuBinFile, [string]$BackupFilePath ) - $userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile + $userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile $backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) { # Auto-detect latest backup in the same folder as the start2.bin $startMenuDir = Split-Path $StartMenuBinFile -Parent @@ -342,19 +342,19 @@ function RestoreStartMenuFromBackup { .DESCRIPTION Resolves the start2.bin path for the currently logged-in user, then - delegates to RestoreStartMenuFromBackup. + delegates to Restore-StartMenuFromBackup. .PARAMETER BackupFilePath Path to the backup file to restore from. If omitted, automatically finds the latest Win11Debloat-StartBackup-*.bak file. .EXAMPLE - RestoreStartMenu + Restore-StartMenu .EXAMPLE - RestoreStartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak" + Restore-StartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak" #> -function RestoreStartMenu { +function Restore-StartMenu { param( [string]$BackupFilePath ) @@ -364,7 +364,7 @@ function RestoreStartMenu { Write-Host "Restoring start menu for user $targetUserName from backup..." - return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath + return Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath } <# @@ -384,17 +384,17 @@ function RestoreStartMenu { LocalState folder. .EXAMPLE - RestoreStartMenuForAllUsers + Restore-StartMenuForAllUsers .EXAMPLE - RestoreStartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak" + Restore-StartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak" #> -function RestoreStartMenuForAllUsers { +function Restore-StartMenuForAllUsers { param( [string]$BackupFilePath ) - $userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" + $userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" $usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue $results = @() @@ -402,10 +402,10 @@ function RestoreStartMenuForAllUsers { foreach ($startMenuPath in $usersStartMenuPaths) { $startMenuBinFile = Join-Path $startMenuPath.FullName 'start2.bin' - $results += RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath + $results += Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath } - $defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false + $defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false if (Test-Path $defaultStartMenuPath) { $defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin' diff --git a/Scripts/Features/RestoreRegistryApplyState.ps1 b/Scripts/Features/Restore-RegistryApplyState.ps1 similarity index 71% rename from Scripts/Features/RestoreRegistryApplyState.ps1 rename to Scripts/Features/Restore-RegistryApplyState.ps1 index 54b4011..615956a 100644 --- a/Scripts/Features/RestoreRegistryApplyState.ps1 +++ b/Scripts/Features/Restore-RegistryApplyState.ps1 @@ -1,3 +1,16 @@ +<# + .SYNOPSIS + Runs a script block against the registry hive for a backup target. + + .PARAMETER Target + A supported backup target: DefaultUserProfile or User:. + + .PARAMETER ScriptBlock + The operation to run after the target user hive is available. + + .PARAMETER ArgumentObject + Optional object passed to the script block. +#> function Invoke-WithLoadedRestoreHive { param( [Parameter(Mandatory)] @@ -24,6 +37,13 @@ function Invoke-WithLoadedRestoreHive { Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject } +<# + .SYNOPSIS + Restores a registry key and its child keys from a backup snapshot. + + .PARAMETER Snapshot + The saved registry-key state, including existence, values, and subkeys. +#> function Restore-RegistryKeySnapshot { param( [Parameter(Mandatory)] @@ -74,10 +94,20 @@ function Restore-RegistryKeySnapshot { } } +<# + .SYNOPSIS + Restores or removes a registry value from a backup snapshot. + + .PARAMETER RegistryKey + The open registry key that contains the value. + + .PARAMETER Snapshot + The saved registry-value state to apply. +#> function Restore-RegistryValueSnapshot { param( [Parameter(Mandatory)] - [Microsoft.Win32.RegistryKey]$RegistryKey, + $RegistryKey, [Parameter(Mandatory)] $Snapshot ) @@ -101,21 +131,20 @@ function Restore-RegistryValueSnapshot { $RegistryKey.SetValue($valueName, $normalizedData, $valueKind) } catch { - $retryBytes = Convert-BackupDataToByteArray -Data $Snapshot.Data - if ($null -ne $retryBytes) { - try { - $RegistryKey.SetValue($valueName, $retryBytes, [Microsoft.Win32.RegistryValueKind]::Binary) - return - } - catch { - # Fall through to original error message for context. - } - } - throw "Failed setting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)" } } +<# + .SYNOPSIS + Converts a backed-up registry value-kind name to its .NET enum value. + + .PARAMETER KindName + The registry value-kind name stored in the backup. + + .OUTPUTS + Microsoft.Win32.RegistryValueKind +#> function Convert-RegistryValueKindFromBackup { param( [string]$KindName @@ -133,6 +162,16 @@ function Convert-RegistryValueKindFromBackup { } } +<# + .SYNOPSIS + Converts backed-up data to a value suitable for registry restoration. + + .PARAMETER Kind + The registry value kind that determines how the data is converted. + + .PARAMETER Data + The serialized value data from the backup. +#> function Convert-RegistryValueDataFromBackup { param( [Microsoft.Win32.RegistryValueKind]$Kind, @@ -148,15 +187,20 @@ function Convert-RegistryValueDataFromBackup { $unsigned = [uint64]$Data return [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0) } - ([Microsoft.Win32.RegistryValueKind]::MultiString) { return @($Data | ForEach-Object { [string]$_ }) } + ([Microsoft.Win32.RegistryValueKind]::MultiString) { return ,([string[]]@($Data | ForEach-Object { [string]$_ })) } ([Microsoft.Win32.RegistryValueKind]::Binary) { + if ($null -eq $Data) { + return ,(New-Object byte[] 0) + } + $bytes = Convert-BackupDataToByteArray -Data $Data if ($null -eq $bytes) { - return (New-Object byte[] 0) + throw 'Invalid binary registry data in backup. Expected byte values from 0 through 255.' } - return $bytes + # Keep the byte array intact instead of writing each byte to the + # pipeline. RegistryKey.SetValue requires a byte[] for Binary. + return ,$bytes } - ([Microsoft.Win32.RegistryValueKind]::None) { return $null } default { if ($null -ne $Data) { return [string]$Data @@ -167,6 +211,17 @@ function Convert-RegistryValueDataFromBackup { } } +<# + .SYNOPSIS + Converts serialized binary backup data to a byte array. + + .PARAMETER Data + A byte array or collection of integer byte values from the backup. + + .OUTPUTS + System.Byte[] + Returns $null when the input contains invalid byte data. +#> function Convert-BackupDataToByteArray { param( $Data @@ -207,18 +262,3 @@ function Convert-BackupDataToByteArray { return ,$bytes } - -function Remove-RegistrySubKeyTreeIfExists { - param( - [Parameter(Mandatory)] - [Microsoft.Win32.RegistryKey]$RootKey, - [Parameter(Mandatory)] - [string]$SubKeyPath - ) - - $existing = $RootKey.OpenSubKey($SubKeyPath, $false) - if ($existing) { - $existing.Close() - $RootKey.DeleteSubKeyTree($SubKeyPath, $false) - } -} diff --git a/Scripts/Features/RestoreRegistryBackup.ps1 b/Scripts/Features/Restore-RegistryBackup.ps1 similarity index 91% rename from Scripts/Features/RestoreRegistryBackup.ps1 rename to Scripts/Features/Restore-RegistryBackup.ps1 index 5811677..d14b6e6 100644 --- a/Scripts/Features/RestoreRegistryBackup.ps1 +++ b/Scripts/Features/Restore-RegistryBackup.ps1 @@ -12,9 +12,9 @@ .OUTPUTS PSCustomObject - A normalized registry backup object produced by Normalize-RegistryBackup. + A normalized registry backup object produced by ConvertTo-NormalizedRegistryBackup. #> -function Load-RegistryBackupFromFile { +function Import-RegistryBackup { param( [Parameter(Mandatory)] [string]$FilePath @@ -31,7 +31,7 @@ function Load-RegistryBackupFromFile { throw "Failed to read backup file '$FilePath'. The file is not valid JSON." } - return Normalize-RegistryBackup -Backup $rawBackup + return ConvertTo-NormalizedRegistryBackup -Backup $rawBackup } <# @@ -52,7 +52,7 @@ function Load-RegistryBackupFromFile { ComputerName, Target, SelectedFeatures, SelectedUndoFeatures, and RegistryKeys properties. #> -function Normalize-RegistryBackup { +function ConvertTo-NormalizedRegistryBackup { param( [Parameter(Mandatory)] $Backup @@ -93,7 +93,10 @@ function Normalize-RegistryBackup { } elseif ($normalizedTarget -like 'CurrentUser:*') { $targetCurrentUserName = $normalizedTarget.Substring(12) - if ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or + if (Test-RunningAsSystem) { + $errors.Add("Backup was made for '$targetCurrentUserName' and is user-scoped. Re-run as that user; SYSTEM cannot restore a CurrentUser backup.") + } + elseif ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or -not (Test-UserNameMatch -UserNameA $targetCurrentUserName -UserNameB $env:USERNAME)) { $errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$env:USERNAME'.") } @@ -176,7 +179,7 @@ function Normalize-RegistryBackup { registry, loading the appropriate user hive when required. .PARAMETER Backup - A normalized backup object (as produced by Normalize-RegistryBackup) whose + A normalized backup object (as produced by ConvertTo-NormalizedRegistryBackup) whose RegistryKeys snapshots should be restored. .OUTPUTS @@ -190,7 +193,7 @@ function Restore-RegistryBackupState { $Backup ) - $friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target) + $friendlyTarget = Get-FriendlyRegistryBackupTarget -Target ([string]$Backup.Target) if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Restore registry backup for $friendlyTarget" -ForegroundColor Cyan diff --git a/Scripts/Features/StoreSearchSuggestions.ps1 b/Scripts/Features/Set-StoreSearchSuggestions.ps1 similarity index 87% rename from Scripts/Features/StoreSearchSuggestions.ps1 rename to Scripts/Features/Set-StoreSearchSuggestions.ps1 index aa0fe8c..d376110 100644 --- a/Scripts/Features/StoreSearchSuggestions.ps1 +++ b/Scripts/Features/Set-StoreSearchSuggestions.ps1 @@ -11,20 +11,20 @@ .EXAMPLE DisableStoreSearchSuggestionsForAllUsers #> -function DisableStoreSearchSuggestionsForAllUsers { +function Set-StoreSearchSuggestionsDisabledForAllUsers { # Get path to Store app database for all users - $userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages" + $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) { - DisableStoreSearchSuggestions -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") } # Also disable start search suggestions for the default user profile - $defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default" + $defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default" if ($defaultStoreDbPath) { - DisableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath + Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath } } @@ -45,7 +45,7 @@ function DisableStoreSearchSuggestionsForAllUsers { .EXAMPLE DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" #> -function DisableStoreSearchSuggestions { +function Set-StoreSearchSuggestionsDisabled { param ( [Parameter(Mandatory)] [string]$StoreAppsDatabase @@ -95,20 +95,20 @@ function DisableStoreSearchSuggestions { .EXAMPLE EnableStoreSearchSuggestionsForAllUsers #> -function EnableStoreSearchSuggestionsForAllUsers { +function Set-StoreSearchSuggestionsEnabledForAllUsers { # Get path to Store app database for all users - $userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages" + $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) { - EnableStoreSearchSuggestions -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") } # Also re-enable for the default user profile - $defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default" + $defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default" if ($defaultStoreDbPath) { - EnableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath + Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath } } @@ -128,7 +128,7 @@ function EnableStoreSearchSuggestionsForAllUsers { .EXAMPLE EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" #> -function EnableStoreSearchSuggestions { +function Set-StoreSearchSuggestionsEnabled { param ( [Parameter(Mandatory)] [string]$StoreAppsDatabase @@ -201,12 +201,12 @@ function EnableStoreSearchSuggestions { The target username. Pass an empty string or omit to resolve for the current user. .EXAMPLE - GetStoreAppsDatabasePathForUser -UserName "Jeff" + Get-StoreAppsDatabasePathForUser -UserName "Jeff" .EXAMPLE - GetStoreAppsDatabasePathForUser -UserName "Default" + Get-StoreAppsDatabasePathForUser -UserName "Default" #> -function GetStoreAppsDatabasePathForUser { +function Get-StoreAppsDatabasePathForUser { param( [string]$UserName ) @@ -215,7 +215,7 @@ function GetStoreAppsDatabasePathForUser { return "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" } - return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false) + return (Get-UserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false) } <# @@ -287,13 +287,13 @@ function Test-StoreSearchSuggestionsDisabled { function Test-StoreSearchSuggestionsDisabledForAllUsers { $paths = @() - $userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages" + $userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages" $usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue foreach ($storeDbPath in $usersStoreDbPaths) { $paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db") } - $defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default" + $defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default" if ($defaultStoreDbPath) { $paths += $defaultStoreDbPath } @@ -309,4 +309,4 @@ function Test-StoreSearchSuggestionsDisabledForAllUsers { } return $true -} \ No newline at end of file +} diff --git a/Scripts/Features/TelemetryScheduledTasks.ps1 b/Scripts/Features/Telemetry-ScheduledTasks.ps1 similarity index 100% rename from Scripts/Features/TelemetryScheduledTasks.ps1 rename to Scripts/Features/Telemetry-ScheduledTasks.ps1 diff --git a/Scripts/Features/WindowsOptionalFeatures.ps1 b/Scripts/Features/Windows-OptionalFeatures.ps1 similarity index 96% rename from Scripts/Features/WindowsOptionalFeatures.ps1 rename to Scripts/Features/Windows-OptionalFeatures.ps1 index 3c9fdc5..e284f79 100644 --- a/Scripts/Features/WindowsOptionalFeatures.ps1 +++ b/Scripts/Features/Windows-OptionalFeatures.ps1 @@ -1,5 +1,5 @@ # Enables a Windows optional feature and pipes its output to the console -function EnableWindowsFeature { +function Enable-WindowsFeature { param ( [string]$FeatureName ) @@ -22,7 +22,7 @@ function EnableWindowsFeature { } # Disables a Windows optional feature and pipes its output to the console -function DisableWindowsFeature { +function Disable-WindowsFeature { param ( [string]$FeatureName ) diff --git a/Scripts/FileIO/ValidateAppslist.ps1 b/Scripts/FileIO/Get-ValidatedAppList.ps1 similarity index 76% rename from Scripts/FileIO/ValidateAppslist.ps1 rename to Scripts/FileIO/Get-ValidatedAppList.ps1 index 0cb0905..d471d02 100644 --- a/Scripts/FileIO/ValidateAppslist.ps1 +++ b/Scripts/FileIO/Get-ValidatedAppList.ps1 @@ -1,10 +1,10 @@ # Returns a validated list of apps based on the provided appsList and the supported apps from Apps.json -function ValidateAppslist { +function Get-ValidatedAppList { param ( $appsList ) - $supportedAppsList = @(LoadAppsDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 } + $supportedAppsList = @(Import-AppDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 } $validatedAppsList = @() # Validate provided appsList against supportedAppsList diff --git a/Scripts/FileIO/LoadAppsDetailsFromJson.ps1 b/Scripts/FileIO/Import-AppDetailsFromJson.ps1 similarity index 65% rename from Scripts/FileIO/LoadAppsDetailsFromJson.ps1 rename to Scripts/FileIO/Import-AppDetailsFromJson.ps1 index f0a265b..6218554 100644 --- a/Scripts/FileIO/LoadAppsDetailsFromJson.ps1 +++ b/Scripts/FileIO/Import-AppDetailsFromJson.ps1 @@ -1,5 +1,27 @@ -# Read Apps.json and return list of app objects with optional filtering -function LoadAppsDetailsFromJson { +<# + .SYNOPSIS + Loads application details from Apps.json. + + .DESCRIPTION + Reads the application definitions from Apps.json, optionally filters the + results to installed applications, and returns normalized app objects for + display and selection. + + .PARAMETER OnlyInstalled + Filters the results to applications detected through Appx or the supplied + winget installation list. + + .PARAMETER InstalledList + A pre-fetched winget installation list used when filtering installed apps. + + .PARAMETER InitialCheckedFromJson + Sets each returned app's IsChecked value from its SelectedByDefault setting. + + .OUTPUTS + System.Management.Automation.PSCustomObject[] + Application detail objects containing display, selection, and removal data. +#> +function Import-AppDetailsFromJson { param ( [switch]$OnlyInstalled, [object[]]$InstalledList = $null, @@ -17,8 +39,13 @@ function LoadAppsDetailsFromJson { foreach ($appData in $jsonContent.Apps) { # Handle AppId as array (could be single or multiple IDs) - $appIdArray = if ($appData.AppId -is [array]) { $appData.AppId } else { @($appData.AppId) } - $appIdArray = $appIdArray | ForEach-Object { $_.Trim() } | Where-Object { $_.length -gt 0 } + $appIdArray = @( + foreach ($rawAppId in @($appData.AppId)) { + if ($rawAppId -isnot [string]) { continue } + $normalizedAppId = $rawAppId.Trim() + if ($normalizedAppId.Length -gt 0) { $normalizedAppId } + } + ) if ($appIdArray.Count -eq 0) { continue } if ($OnlyInstalled) { diff --git a/Scripts/FileIO/LoadAppPresetsFromJson.ps1 b/Scripts/FileIO/Import-AppPresetsFromJson.ps1 similarity index 69% rename from Scripts/FileIO/LoadAppPresetsFromJson.ps1 rename to Scripts/FileIO/Import-AppPresetsFromJson.ps1 index 965efaa..6113124 100644 --- a/Scripts/FileIO/LoadAppPresetsFromJson.ps1 +++ b/Scripts/FileIO/Import-AppPresetsFromJson.ps1 @@ -1,6 +1,8 @@ -# Read Apps.json and return the list of preset objects (Name + AppIds). -# Returns an empty array if the file cannot be read or contains no presets. -function LoadAppPresetsFromJson { +<# + .SYNOPSIS + Returns preset names and application IDs from Apps.json, or an empty array when unavailable. +#> +function Import-AppPresetsFromJson { try { $jsonContent = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json } diff --git a/Scripts/FileIO/LoadAppsFromFile.ps1 b/Scripts/FileIO/Import-AppsFromFile.ps1 similarity index 96% rename from Scripts/FileIO/LoadAppsFromFile.ps1 rename to Scripts/FileIO/Import-AppsFromFile.ps1 index dc59ff3..f5eb9b3 100644 --- a/Scripts/FileIO/LoadAppsFromFile.ps1 +++ b/Scripts/FileIO/Import-AppsFromFile.ps1 @@ -14,7 +14,7 @@ System.String[]. An array of app ID strings, or an empty array if the file does not exist or contains no selected-by-default apps. #> -function LoadAppsFromFile { +function Import-AppsFromFile { param ( $appsFilePath ) @@ -41,6 +41,6 @@ function LoadAppsFromFile { } catch { Write-Error "Unable to read apps list from file: $appsFilePath" - AwaitKeyToExit + Wait-ForKeyPress } } diff --git a/Scripts/FileIO/LoadJsonFile.ps1 b/Scripts/FileIO/Import-JsonFile.ps1 similarity index 83% rename from Scripts/FileIO/LoadJsonFile.ps1 rename to Scripts/FileIO/Import-JsonFile.ps1 index 8af93cf..f2373a4 100644 --- a/Scripts/FileIO/LoadJsonFile.ps1 +++ b/Scripts/FileIO/Import-JsonFile.ps1 @@ -1,6 +1,8 @@ -# Loads a JSON file from the specified path and returns the parsed object -# Returns $null if the file doesn't exist or if parsing fails -function LoadJsonFile { +<# + .SYNOPSIS + Imports a JSON file, optionally validates its version, and returns $null on failure. +#> +function Import-JsonFile { param ( [string]$filePath, [string]$expectedVersion = $null, diff --git a/Scripts/FileIO/LoadSettings.ps1 b/Scripts/FileIO/Import-Settings.ps1 similarity index 78% rename from Scripts/FileIO/LoadSettings.ps1 rename to Scripts/FileIO/Import-Settings.ps1 index 2df00f9..0c83268 100644 --- a/Scripts/FileIO/LoadSettings.ps1 +++ b/Scripts/FileIO/Import-Settings.ps1 @@ -1,11 +1,14 @@ -# Loads settings from a JSON file and adds them to script params -function LoadSettings { +<# + .SYNOPSIS + Imports enabled, compatible feature settings from a JSON file into the active parameters. +#> +function Import-Settings { param ( [string]$filePath, [string]$expectedVersion = "1.0" ) - $settingsJson = LoadJsonFile -filePath $filePath -expectedVersion $expectedVersion + $settingsJson = Import-JsonFile -filePath $filePath -expectedVersion $expectedVersion if (-not $settingsJson -or -not $settingsJson.Settings) { throw "Failed to load settings from $(Split-Path $filePath -Leaf)" @@ -29,6 +32,6 @@ function LoadSettings { continue } - AddParameter $setting.Name $setting.Value + Add-Parameter $setting.Name $setting.Value } } diff --git a/Scripts/FileIO/SaveSettings.ps1 b/Scripts/FileIO/Save-Settings.ps1 similarity index 74% rename from Scripts/FileIO/SaveSettings.ps1 rename to Scripts/FileIO/Save-Settings.ps1 index c8adca6..733dfe7 100644 --- a/Scripts/FileIO/SaveSettings.ps1 +++ b/Scripts/FileIO/Save-Settings.ps1 @@ -1,5 +1,8 @@ -# Saves the current settings, excluding control parameters, to 'LastUsedSettings.json' file -function SaveSettings { +<# + .SYNOPSIS + Saves active feature settings, excluding control parameters, unless running in WhatIf mode. +#> +function Save-Settings { if ($script:Params.ContainsKey("WhatIf")) { Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan return @@ -21,8 +24,8 @@ function SaveSettings { } } - if (-not (SaveToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) { + if (-not (Save-ToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) { Write-Output "" Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red } -} \ No newline at end of file +} diff --git a/Scripts/FileIO/Save-ToFile.ps1 b/Scripts/FileIO/Save-ToFile.ps1 new file mode 100644 index 0000000..10a38a9 --- /dev/null +++ b/Scripts/FileIO/Save-ToFile.ps1 @@ -0,0 +1,36 @@ +<# + .SYNOPSIS + Serializes a configuration hashtable to a UTF-8 JSON file. + + .PARAMETER Config + The configuration data to serialize. + + .PARAMETER FilePath + The destination file path. + + .PARAMETER MaxDepth + The maximum object depth passed to ConvertTo-Json. + + .OUTPUTS + System.Boolean. $true when the file is written; otherwise $false. +#> +function Save-ToFile { + param ( + [Parameter(Mandatory=$true)] + [hashtable]$Config, + + [Parameter(Mandatory=$true)] + [string]$FilePath, + + [Parameter(Mandatory=$false)] + [int]$MaxDepth = 10 + ) + + try { + $Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8 + return $true + } + catch { + return $false + } +} diff --git a/Scripts/FileIO/SaveToFile.ps1 b/Scripts/FileIO/SaveToFile.ps1 deleted file mode 100644 index 52c31de..0000000 --- a/Scripts/FileIO/SaveToFile.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -# Saves configuration JSON to a file. -# Returns $true on success, $false on failure. -function SaveToFile { - param ( - [Parameter(Mandatory=$true)] - [hashtable]$Config, - - [Parameter(Mandatory=$true)] - [string]$FilePath, - - [Parameter(Mandatory=$false)] - [int]$MaxDepth = 10 - ) - - try { - $Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8 - return $true - } - catch { - return $false - } -} diff --git a/Scripts/GUI/ApplySettingsToUiControls.ps1 b/Scripts/GUI/Apply-SettingsToUiControls.ps1 similarity index 82% rename from Scripts/GUI/ApplySettingsToUiControls.ps1 rename to Scripts/GUI/Apply-SettingsToUiControls.ps1 index eb5ad16..0d0b36b 100644 --- a/Scripts/GUI/ApplySettingsToUiControls.ps1 +++ b/Scripts/GUI/Apply-SettingsToUiControls.ps1 @@ -1,6 +1,20 @@ -# Applies settings from a JSON object to UI controls (checkboxes and comboboxes) -# Used by LoadDefaultsBtn and LoadLastUsedBtn in the UI -function ApplySettingsToUiControls { +<# + .SYNOPSIS + Applies enabled settings from JSON to mapped checkbox and combo-box controls. + + .PARAMETER Window + The window that owns the mapped controls. + + .PARAMETER SettingsJson + The settings object containing a Settings collection. + + .PARAMETER UiControlMappings + The feature-to-control mapping used to locate and update controls. + + .OUTPUTS + System.Boolean. $false for invalid settings input; otherwise $true. +#> +function Apply-SettingsToUiControls { param ( $window, $settingsJson, diff --git a/Scripts/GUI/AttachShiftClickBehavior.ps1 b/Scripts/GUI/Attach-ShiftClickBehavior.ps1 similarity index 81% rename from Scripts/GUI/AttachShiftClickBehavior.ps1 rename to Scripts/GUI/Attach-ShiftClickBehavior.ps1 index 4ec834f..369be0b 100644 --- a/Scripts/GUI/AttachShiftClickBehavior.ps1 +++ b/Scripts/GUI/Attach-ShiftClickBehavior.ps1 @@ -1,10 +1,20 @@ -# Attaches shift-click selection behavior to a checkbox in an apps panel -# Parameters: -# - $checkbox: The checkbox to attach the behavior to -# - $appsPanel: The StackPanel containing checkbox items -# - $lastSelectedCheckboxRef: A reference to a variable storing the last clicked checkbox -# - $updateStatusCallback: Optional callback to update selection status -function AttachShiftClickBehavior { +<# + .SYNOPSIS + Attaches shift-click range-selection behavior to an application checkbox. + + .PARAMETER Checkbox + The checkbox that receives the mouse event handler. + + .PARAMETER AppsPanel + The panel whose visible checkboxes participate in range selection. + + .PARAMETER LastSelectedCheckboxRef + A reference that stores the previously clicked checkbox. + + .PARAMETER UpdateStatusCallback + An optional callback invoked after a range selection changes. +#> +function Attach-ShiftClickBehavior { param ( [System.Windows.Controls.CheckBox]$checkbox, [System.Windows.Controls.StackPanel]$appsPanel, diff --git a/Scripts/GUI/GetSystemUsesDarkMode.ps1 b/Scripts/GUI/Get-SystemUsesDarkMode.ps1 similarity index 65% rename from Scripts/GUI/GetSystemUsesDarkMode.ps1 rename to Scripts/GUI/Get-SystemUsesDarkMode.ps1 index 05c1790..0935654 100644 --- a/Scripts/GUI/GetSystemUsesDarkMode.ps1 +++ b/Scripts/GUI/Get-SystemUsesDarkMode.ps1 @@ -1,5 +1,11 @@ -# Checks if the system is set to use dark mode for apps -function GetSystemUsesDarkMode { +<# + .SYNOPSIS + Returns whether Windows apps are configured to use dark mode. + + .OUTPUTS + System.Boolean. $false when the personalization setting cannot be read. +#> +function Get-SystemUsesDarkMode { try { $personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' diff --git a/Scripts/GUI/MainWindow-AppSelection.ps1 b/Scripts/GUI/MainWindow-AppSelection.ps1 index 8b549ef..4df7baa 100644 --- a/Scripts/GUI/MainWindow-AppSelection.ps1 +++ b/Scripts/GUI/MainWindow-AppSelection.ps1 @@ -223,7 +223,7 @@ function Update-AppPresetStates { $script:UpdatingPresets = $true try { # Helper: count matching and checked apps, set checkbox state - function SetPresetState($CheckBox, [scriptblock]$MatchFilter) { + function Set-PresetState($CheckBox, [scriptblock]$MatchFilter) { $total = 0; $checked = 0 foreach ($child in $AppsPanel.Children) { if ($child -is [System.Windows.Controls.CheckBox]) { @@ -241,15 +241,15 @@ function Update-AppPresetStates { $presetDefaultApps = $window.FindName('PresetDefaultApps') $presetLastUsed = $window.FindName('PresetLastUsed') - SetPresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true } + Set-PresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true } foreach ($jsonCb in $script:JsonPresetCheckboxes) { $localIds = $jsonCb.PresetAppIds - SetPresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure() + Set-PresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure() } # Last used preset: only update if it's visible (has saved apps) if ($presetLastUsed.Visibility -ne 'Collapsed' -and $script:SavedAppIds) { - SetPresetState $presetLastUsed { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 } + Set-PresetState $presetLastUsed { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 } } } finally { @@ -304,7 +304,29 @@ function Find-ParentScrollViewer { return $null } -function Load-AppsWithList { +<# + .SYNOPSIS + Loads application details and adds their interactive checkboxes to the main window. + + .PARAMETER Window + The main application window and resource owner. + + .PARAMETER AppsPanel + The panel populated with application checkboxes. + + .PARAMETER OnlyInstalledAppsBox + The filter control that determines whether only installed apps are loaded. + + .PARAMETER LoadingAppsIndicator + The loading indicator shown while application details are prepared. + + .PARAMETER ImportConfigBtn + The optional import control re-enabled after loading completes. + + .PARAMETER ListOfApps + An optional pre-fetched list of installed WinGet applications. +#> +function Add-AppsToMainWindow { param( [System.Windows.Window]$Window, [System.Windows.Controls.Panel]$AppsPanel, @@ -335,7 +357,7 @@ function Load-AppsWithList { $script:AppsListFilePath = $appsListFilePath . $helperScript . $loaderScript - LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false + Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false } -ArgumentList $loaderScriptPath, $helperScriptPath, $appsFilePath, $ListOfApps, $onlyInstalled } @@ -435,7 +457,7 @@ function Load-AppsWithList { -AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') ` -UserSelectionCombo $w.FindName('UserSelectionCombo') }) - AttachShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel ` + Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel ` -lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) ` -updateStatusCallback { $w = $script:MainWindow @@ -449,7 +471,7 @@ function Load-AppsWithList { $AppsPanel.Children.Add($checkbox) | Out-Null - if (($i + 1) % $batchSize -eq 0) { DoEvents } + if (($i + 1) % $batchSize -eq 0) { Invoke-DoEvents } } $sortArrowName = $Window.FindName('SortArrowName') @@ -480,7 +502,26 @@ function Load-AppsWithList { } } -function Load-AppsIntoMainUI { +<# + .SYNOPSIS + Starts asynchronous loading of application checkboxes for the main window. + + .PARAMETER Window + The main application window. + + .PARAMETER AppsPanel + The panel that receives application checkboxes. + + .PARAMETER OnlyInstalledAppsBox + The installed-applications filter control. + + .PARAMETER LoadingAppsIndicator + The loading indicator shown until loading completes. + + .PARAMETER ImportConfigBtn + The optional import control disabled while loading is in progress. +#> +function Initialize-MainWindowApps { param( [System.Windows.Window]$Window, [System.Windows.Controls.Panel]$AppsPanel, @@ -511,7 +552,7 @@ function Load-AppsIntoMainUI { # Force a render so the loading indicator is visible, then schedule the # actual loading at Background priority so this call returns immediately. # This is critical when called from Add_Loaded: the window must finish - # its initialization before we start a nested message pump via DoEvents. + # its initialization before we start a nested message pump via Invoke-DoEvents. $Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action] {}) $Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { try { @@ -519,7 +560,7 @@ function Load-AppsIntoMainUI { if ($OnlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) { Write-Host "Retrieving installed apps via winget..." - $listOfApps = GetInstalledAppsViaWinget -TimeOut 20 -NonBlocking + $listOfApps = Get-WingetInstalledApps -TimeOut 20 -NonBlocking if ($null -eq $listOfApps) { Write-Warning "WinGet returned no data (command timed out or failed)" @@ -528,7 +569,7 @@ function Load-AppsIntoMainUI { } } - Load-AppsWithList -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox ` + Add-AppsToMainWindow -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox ` -LoadingAppsIndicator $LoadingAppsIndicator -ImportConfigBtn $ImportConfigBtn -ListOfApps $listOfApps } catch { diff --git a/Scripts/GUI/MainWindow-Deployment.ps1 b/Scripts/GUI/MainWindow-Deployment.ps1 index 34b5cb5..604855d 100644 --- a/Scripts/GUI/MainWindow-Deployment.ps1 +++ b/Scripts/GUI/MainWindow-Deployment.ps1 @@ -147,7 +147,20 @@ function Invoke-ShowChangesOverview { Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600 } -function Build-TweakPresetControlMap { +<# + .SYNOPSIS + Builds the control values needed to apply a saved tweak preset. + + .PARAMETER Window + The window that owns the visible tweak controls. + + .PARAMETER SettingsJson + The saved settings object to translate into control values. + + .OUTPUTS + System.Collections.Hashtable. Control metadata keyed by control name. +#> +function Get-TweakPresetControlMap { param( [System.Windows.Window]$Window, $SettingsJson @@ -158,7 +171,7 @@ function Build-TweakPresetControlMap { return $presetMap } - # FeatureId -> control metadata, similar to ApplySettingsToUiControls lookup. + # FeatureId -> control metadata, similar to Apply-SettingsToUiControls lookup. $featureIdIndex = @{} foreach ($controlName in $script:UiControlMappings.Keys) { $control = $Window.FindName($controlName) @@ -199,7 +212,20 @@ function Build-TweakPresetControlMap { return $presetMap } -function Build-CategoryTweakPresetMap { +<# + .SYNOPSIS + Builds the enabled state map for visible tweak controls in a category. + + .PARAMETER Window + The window that owns the visible tweak controls. + + .PARAMETER Category + The category whose mapped controls are included. + + .OUTPUTS + System.Collections.Hashtable. Control metadata keyed by control name. +#> +function Get-CategoryTweakPresetMap { param( [System.Windows.Window]$Window, [string]$Category @@ -375,10 +401,10 @@ function Initialize-TweakPresetSources { $LastUsedSettingsJson ) - $script:DefaultTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson - $script:LastUsedTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson - $script:PrivacyTweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'Privacy & Suggested Content' - $script:AITweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'AI' + $script:DefaultTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson + $script:LastUsedTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson + $script:PrivacyTweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -Category 'Privacy & Suggested Content' + $script:AITweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -Category 'AI' $presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn') if ($presetLastUsedTweaksBtn) { @@ -421,7 +447,7 @@ function Update-UserSelectionDescription { switch ($UserSelectionCombo.SelectedIndex) { 0 { - $currentUserName = GetUserName + $currentUserName = Get-UserName if ([string]::IsNullOrWhiteSpace($currentUserName)) { $UserSelectionDescription.Text = "The currently logged-in user profile" } diff --git a/Scripts/GUI/MainWindow-TweaksBuilder.ps1 b/Scripts/GUI/MainWindow-TweaksBuilder.ps1 index ec6bb8f..d1e940f 100644 --- a/Scripts/GUI/MainWindow-TweaksBuilder.ps1 +++ b/Scripts/GUI/MainWindow-TweaksBuilder.ps1 @@ -1,13 +1,26 @@ # MainWindow-TweaksBuilder.ps1 # Dynamic tweaks UI construction from Features.json, tweak state management, selection clear, and search/highlight. -function Build-DynamicTweaks { +<# + .SYNOPSIS + Builds the main window's dynamic tweak controls from Features.json. + + .PARAMETER Window + The main window whose category columns receive the generated controls. + + .PARAMETER WinVersion + The Windows build number used for the category-icon fallback. + + .NOTES + Initializes script-scoped control and category mappings used by the tweak UI. +#> +function New-DynamicTweakControls { param( [System.Windows.Window]$Window, [int]$WinVersion ) - $featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0" + $featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0" if (-not $featuresJson) { throw "Unable to load Features.json file. The GUI cannot continue without feature definitions." @@ -29,7 +42,26 @@ function Build-DynamicTweaks { $script:TweaksCompactMode = $null $script:TweaksCardsMovedFromCol2 = @() - function CreateLabeledCombo($parent, $labelText, $comboName, $items) { + <# + .SYNOPSIS + Creates and registers a labeled combo box or a checkbox for a tweak. + + .PARAMETER Parent + The panel that receives the generated control. + + .PARAMETER LabelText + The display and automation label for the tweak. + + .PARAMETER ComboName + The name used to register the generated control. + + .PARAMETER Items + The available tweak options; two options produce a checkbox. + + .OUTPUTS + System.Windows.Controls.Control. The generated checkbox or combo box. + #> + function New-LabeledCombo($parent, $labelText, $comboName, $items) { # If only 2 items (No Change + one option), use a checkbox instead if ($items.Count -eq 2) { $checkbox = New-Object System.Windows.Controls.CheckBox @@ -96,7 +128,17 @@ function Build-DynamicTweaks { return $combo } - function GetWikiUrlForCategory($category) { + <# + .SYNOPSIS + Returns the Features wiki URL for a tweak category. + + .PARAMETER Category + The category name converted to a wiki anchor. + + .OUTPUTS + System.String. The category URL, or the Features page for an empty category. + #> + function Get-WikiUrlForCategory($category) { if (-not $category) { return 'https://github.com/Raphire/Win11Debloat/wiki/Features' } $slug = $category.ToLowerInvariant() @@ -107,7 +149,17 @@ function Build-DynamicTweaks { return "https://github.com/Raphire/Win11Debloat/wiki/Features#$slug" } - function GetOrCreateCategoryCard($categoryObj) { + <# + .SYNOPSIS + Returns the existing category panel or creates and registers a new one. + + .PARAMETER CategoryObj + The category definition containing Name and Icon properties. + + .OUTPUTS + System.Windows.Controls.StackPanel. The category's content panel. + #> + function Get-OrCreateCategoryCard($categoryObj) { $categoryName = $categoryObj.Name $categoryIcon = $categoryObj.Icon @@ -152,7 +204,7 @@ function Build-DynamicTweaks { $helpBtn = New-Object System.Windows.Controls.Button $helpBtn.Content = $helpIcon $helpBtn.ToolTip = "Open the wiki for more info on '$categoryName' tweaks" - $helpBtn.Tag = (GetWikiUrlForCategory -category $categoryName) + $helpBtn.Tag = (Get-WikiUrlForCategory -category $categoryName) $helpBtn.Style = $Window.Resources['CategoryHelpLinkButtonStyle'] $helpBtn.Add_Click({ param($button, $e) @@ -289,8 +341,8 @@ function Build-DynamicTweaks { if ($soleFeature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($soleFeature.FeatureId -match '^Enable') { $opt = 'Enable' } $items = @('No Change', $opt) $comboName = ("Feature_{0}_Combo" -f $soleFeature.FeatureId) -replace '[^a-zA-Z0-9_]', '' - if (-not $panel) { $panel = GetOrCreateCategoryCard -categoryObj $categoryObj } - $combo = CreateLabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items + if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj } + $combo = New-LabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items # attach tooltip from Features.json if present if ($soleFeature.ToolTip -or $soleFeature.DisableWhenApplied -eq $true) { $tooltipText = $soleFeature.ToolTip @@ -314,8 +366,8 @@ function Build-DynamicTweaks { $items = @('No Change') + ($filteredValues | ForEach-Object { $_.Label }) $comboName = 'Group_{0}Combo' -f $group.GroupId - if (-not $panel) { $panel = GetOrCreateCategoryCard -categoryObj $categoryObj } - $combo = CreateLabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items + if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj } + $combo = New-LabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items # attach tooltip from UiGroups if present if ($group.ToolTip) { $tipBlock = New-Object System.Windows.Controls.TextBlock @@ -335,8 +387,8 @@ function Build-DynamicTweaks { if ($feature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($feature.FeatureId -match '^Enable') { $opt = 'Enable' } $items = @('No Change', $opt) $comboName = ("Feature_{0}_Combo" -f $feature.FeatureId) -replace '[^a-zA-Z0-9_]', '' - if (-not $panel) { $panel = GetOrCreateCategoryCard -categoryObj $categoryObj } - $combo = CreateLabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items + if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj } + $combo = New-LabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items # attach tooltip from Features.json if present, and include the disabled-state reason if ($feature.ToolTip -or $feature.DisableWhenApplied -eq $true) { $tooltipText = $feature.ToolTip @@ -377,7 +429,7 @@ function Update-CurrentTweakSystemState { if (-not $script:UiControlMappings) { return } if (-not $script:Features) { return } - $featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0" + $featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0" if (-not $featuresJson) { return } $groupMap = @{} @@ -423,7 +475,14 @@ function Update-CurrentTweakSystemState { } } -function Load-CurrentTweakStateIntoUI { +<# + .SYNOPSIS + Updates tweak controls to reflect the current system state. + + .PARAMETER Window + The window that owns the generated tweak controls. +#> +function Set-CurrentTweakStateInUi { param([System.Windows.Window]$Window) Update-CurrentTweakSystemState -Window $Window -ApplyToUi:$true diff --git a/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1 b/Scripts/GUI/Restore-BackupDialogFeatureLists.ps1 similarity index 100% rename from Scripts/GUI/RestoreBackupDialogFeatureLists.ps1 rename to Scripts/GUI/Restore-BackupDialogFeatureLists.ps1 diff --git a/Scripts/GUI/SetWindowThemeResources.ps1 b/Scripts/GUI/Set-WindowThemeResources.ps1 similarity index 97% rename from Scripts/GUI/SetWindowThemeResources.ps1 rename to Scripts/GUI/Set-WindowThemeResources.ps1 index dd58412..8d6ec47 100644 --- a/Scripts/GUI/SetWindowThemeResources.ps1 +++ b/Scripts/GUI/Set-WindowThemeResources.ps1 @@ -17,13 +17,13 @@ When $true, dark theme colors are applied; when $false, light theme colors. .EXAMPLE - SetWindowThemeResources -window $MainWindow -usesDarkMode $true + Set-WindowThemeResources -window $MainWindow -usesDarkMode $true .EXAMPLE - SetWindowThemeResources -window $Dialog -usesDarkMode $false + Set-WindowThemeResources -window $Dialog -usesDarkMode $false #> # Sets resource colors for a WPF window based on dark mode preference -function SetWindowThemeResources { +function Set-WindowThemeResources { param ( $window, [bool]$usesDarkMode diff --git a/Scripts/GUI/Show-AboutDialog.ps1 b/Scripts/GUI/Show-AboutDialog.ps1 index 562b38c..9fad37d 100644 --- a/Scripts/GUI/Show-AboutDialog.ps1 +++ b/Scripts/GUI/Show-AboutDialog.ps1 @@ -1,3 +1,10 @@ +<# + .SYNOPSIS + Displays the themed About dialog for the application. + + .PARAMETER Owner + The optional window that owns the dialog and its modal overlay. +#> function Show-AboutDialog { param ( [Parameter(Mandatory=$false)] @@ -6,7 +13,7 @@ function Show-AboutDialog { Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null - $usesDarkMode = GetSystemUsesDarkMode + $usesDarkMode = Get-SystemUsesDarkMode # Determine owner window $ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow } @@ -42,7 +49,7 @@ function Show-AboutDialog { } # Apply theme resources - SetWindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode + Set-WindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode # Get UI elements $titleBar = $aboutWindow.FindName('TitleBar') @@ -95,4 +102,4 @@ function Show-AboutDialog { catch { } } } -} \ No newline at end of file +} diff --git a/Scripts/GUI/Show-AppSelectionWindow.ps1 b/Scripts/GUI/Show-AppSelectionWindow.ps1 index 01c3545..e817057 100644 --- a/Scripts/GUI/Show-AppSelectionWindow.ps1 +++ b/Scripts/GUI/Show-AppSelectionWindow.ps1 @@ -1,8 +1,14 @@ -# Shows application selection window that allows the user to select what apps they want to remove or keep +<# + .SYNOPSIS + Displays the application-selection dialog and records the confirmed selections. + + .OUTPUTS + System.Nullable[System.Boolean]. The dialog result; confirmed application IDs are stored in $script:SelectedApps. +#> function Show-AppSelectionWindow { Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null - $usesDarkMode = GetSystemUsesDarkMode + $usesDarkMode = Get-SystemUsesDarkMode # Show overlay if main window exists $overlay = $null @@ -34,7 +40,7 @@ function Show-AppSelectionWindow { catch { } } - SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode + Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode $appsPanel = $window.FindName('AppsPanel') $checkAllBox = $window.FindName('CheckAllBox') @@ -46,8 +52,14 @@ function Show-AppSelectionWindow { # Track the last selected checkbox for shift-click range selection $script:AppSelectionWindowLastSelectedCheckbox = $null - # Loads apps into the apps UI - function LoadApps { + <# + .SYNOPSIS + Reloads the application-selection checkboxes using the current installed-apps filter. + + .NOTES + Updates the dialog loading indicator and resets range-selection state. + #> + function Load-Apps { # Show loading indicator $loadingIndicator.Visibility = 'Visible' $window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{}) @@ -57,7 +69,7 @@ function Show-AppSelectionWindow { if ($onlyInstalledBox.IsChecked -and ($script:WingetInstalled -eq $true)) { # Attempt to get a list of installed apps via WinGet, times out after 10 seconds - $listOfApps = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking + $listOfApps = Get-WingetInstalledApps -TimeOut 10 -NonBlocking if ($null -eq $listOfApps) { # Show error that the script was unable to get list of apps from WinGet Show-MessageBox -Message 'Unable to load list of installed apps via WinGet.' -Title 'Error' -Button 'OK' -Icon 'Error' -Owner $window | Out-Null @@ -65,7 +77,7 @@ function Show-AppSelectionWindow { } } - $appsToAdd = LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true + $appsToAdd = Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true # Reset the last selected checkbox when loading a new list $script:AppSelectionWindowLastSelectedCheckbox = $null @@ -82,7 +94,7 @@ function Show-AppSelectionWindow { $checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"] # Attach shift-click behavior for range selection - AttachShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox) + Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox) $appsPanel.Children.Add($checkbox) | Out-Null } @@ -112,8 +124,8 @@ function Show-AppSelectionWindow { } }) - $onlyInstalledBox.Add_Checked({ LoadApps }) - $onlyInstalledBox.Add_Unchecked({ LoadApps }) + $onlyInstalledBox.Add_Checked({ Load-Apps }) + $onlyInstalledBox.Add_Unchecked({ Load-Apps }) $confirmBtn.Add_Click({ $selectedApps = @() @@ -130,7 +142,7 @@ function Show-AppSelectionWindow { return } - if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { + if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } @@ -141,7 +153,7 @@ function Show-AppSelectionWindow { # Load apps after window is shown (allows UI to render first) $window.Add_ContentRendered({ - $window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ LoadApps }) | Out-Null + $window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Load-Apps }) | Out-Null }) # Show the window and return dialog result diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index 7985d8a..d30dc22 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -1,14 +1,24 @@ +<# + .SYNOPSIS + Displays the modal progress window while selected changes are applied. + + .PARAMETER Owner + The optional window that owns the modal and its overlay. + + .PARAMETER InvokeRestartExplorer + Indicates whether the modal should run the Explorer-restart flow after applying changes. +#> function Show-ApplyModal { param ( [Parameter(Mandatory=$false)] [System.Windows.Window]$Owner = $null, [Parameter(Mandatory=$false)] - [bool]$RestartExplorer = $false + [bool]$InvokeRestartExplorer = $false ) Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null - $usesDarkMode = GetSystemUsesDarkMode + $usesDarkMode = Get-SystemUsesDarkMode # Determine owner window $ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow } @@ -44,7 +54,7 @@ function Show-ApplyModal { } # Apply theme resources - SetWindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode + Set-WindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode # Get UI elements $script:ApplyInProgressPanel = $applyWindow.FindName('ApplyInProgressPanel') @@ -81,7 +91,7 @@ function Show-ApplyModal { $pct = if ($totalSteps -gt 0) { [math]::Round((($currentStep - 1) / $totalSteps) * 100) } else { 0 } $script:ApplyProgressBarEl.Value = $pct # Process pending window messages to keep UI responsive - DoEvents + Invoke-DoEvents } # Sub-step callback updates step name and interpolates progress bar within the current step @@ -96,7 +106,7 @@ function Show-ApplyModal { $stepFraction = ($subIndex / $subCount) / $totalSteps $script:ApplyProgressBarEl.Value = [math]::Round(($baseProgress + $stepFraction) * 100) } - DoEvents + Invoke-DoEvents } # Run changes in background to keep UI responsive @@ -107,8 +117,8 @@ function Show-ApplyModal { $registryImportFailureCount = [int]$script:RegistryImportFailures # Restart explorer if requested - if ($RestartExplorer -and -not $script:CancelRequested) { - RestartExplorer + if ($InvokeRestartExplorer -and -not $script:CancelRequested) { + Invoke-RestartExplorer # Wait for Explorer to finish relaunching, then reclaim focus. Start-Sleep -Milliseconds 800 @@ -143,7 +153,7 @@ function Show-ApplyModal { $script:ApplyCompletionTitleEl.Text = "Changes Applied" # Show completion message with reboot instructions if any applied features require reboot - if ($RestartExplorer) { + if ($InvokeRestartExplorer) { $rebootFeatures = Get-RebootFeatureLabels if ($rebootFeatures.Count -gt 0) { diff --git a/Scripts/GUI/Show-ConfigWindow.ps1 b/Scripts/GUI/Show-ImportExportConfigWindow.ps1 similarity index 93% rename from Scripts/GUI/Show-ConfigWindow.ps1 rename to Scripts/GUI/Show-ImportExportConfigWindow.ps1 index 82fb826..b6b9aaa 100644 --- a/Scripts/GUI/Show-ConfigWindow.ps1 +++ b/Scripts/GUI/Show-ImportExportConfigWindow.ps1 @@ -1,3 +1,7 @@ +<# + .SYNOPSIS + Shows a modal category-selection dialog for importing or exporting configuration. +#> function Show-ImportExportConfigWindow { param ( [System.Windows.Window]$Owner, @@ -45,7 +49,7 @@ function Show-ImportExportConfigWindow { } $dlg.Owner = $Owner - SetWindowThemeResources -window $dlg -usesDarkMode $UsesDarkMode + Set-WindowThemeResources -window $dlg -usesDarkMode $UsesDarkMode # Copy the CheckBox default style from the main window so checkboxes get the themed template try { @@ -308,7 +312,11 @@ function Build-CategoryDetails { return $details } -function Apply-ImportedApplications { +<# + .SYNOPSIS + Applies imported application selections to the application checkboxes. +#> +function Set-ImportedApplications { param ( [System.Windows.Controls.Panel]$AppsPanel, [string[]]$AppIds @@ -321,7 +329,11 @@ function Apply-ImportedApplications { } } -function Apply-ImportedTweakSettings { +<# + .SYNOPSIS + Applies imported tweak settings to their mapped UI controls. +#> +function Set-ImportedTweakSettings { param ( [System.Windows.Window]$Owner, [hashtable]$UiControlMappings, @@ -329,10 +341,14 @@ function Apply-ImportedTweakSettings { ) $settingsJson = [PSCustomObject]@{ Settings = @($TweakSettings) } - ApplySettingsToUiControls -window $Owner -settingsJson $settingsJson -uiControlMappings $UiControlMappings + Apply-SettingsToUiControls -window $Owner -settingsJson $settingsJson -uiControlMappings $UiControlMappings } -function Apply-ImportedDeploymentSettings { +<# + .SYNOPSIS + Applies imported deployment settings to the deployment controls. +#> +function Set-ImportedDeploymentSettings { param ( [System.Windows.Window]$Owner, [System.Windows.Controls.ComboBox]$UserSelectionCombo, @@ -368,6 +384,10 @@ function Apply-ImportedDeploymentSettings { } } +<# + .SYNOPSIS + Exports selected application, tweak, and deployment settings to a configuration file. +#> function Export-Configuration { param ( [System.Windows.Window]$Owner, @@ -427,7 +447,7 @@ function Export-Configuration { return } - if (SaveToFile -Config $config -FilePath $saveDialog.FileName) { + if (Save-ToFile -Config $config -FilePath $saveDialog.FileName) { Write-Host "Configuration exported successfully: $($saveDialog.FileName)" Show-MessageBox -Message "Configuration exported successfully." -Title 'Export Configuration' -Button 'OK' -Icon 'Information' | Out-Null } @@ -437,6 +457,10 @@ function Export-Configuration { } } +<# + .SYNOPSIS + Imports selected application, tweak, and deployment settings from a configuration file. +#> function Import-Configuration { param ( [System.Windows.Window]$Owner, @@ -462,7 +486,7 @@ function Import-Configuration { Write-Host "Importing configuration from '$($openDialog.FileName)'..." - $config = LoadJsonFile -filePath $openDialog.FileName -expectedVersion '1.0' + $config = Import-JsonFile -filePath $openDialog.FileName -expectedVersion '1.0' if (-not $config) { Write-Error "Failed to read configuration file '$($openDialog.FileName)'" Show-MessageBox -Message "Failed to read configuration file" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null @@ -504,7 +528,7 @@ function Import-Configuration { ) Write-Host "Importing $($appIds.Count) app selection(s)." - Apply-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds + Set-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds if ($OnAppsImported) { & $OnAppsImported @@ -513,11 +537,11 @@ function Import-Configuration { if ($categories -contains 'System Tweaks' -and $config.Tweaks) { $tweakCount = @($config.Tweaks).Count Write-Host "Importing $tweakCount tweak(s)." - Apply-ImportedTweakSettings -Owner $Owner -UiControlMappings $UiControlMappings -TweakSettings @($config.Tweaks) + Set-ImportedTweakSettings -Owner $Owner -UiControlMappings $UiControlMappings -TweakSettings @($config.Tweaks) } if ($categories -contains 'Deployment Settings' -and $config.Deployment) { Write-Host 'Importing deployment settings.' - Apply-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment) + Set-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment) } Write-Host 'Configuration imported successfully.' diff --git a/Scripts/GUI/Show-MainWindow.ps1 b/Scripts/GUI/Show-MainWindow.ps1 index b666dce..ae2d441 100644 --- a/Scripts/GUI/Show-MainWindow.ps1 +++ b/Scripts/GUI/Show-MainWindow.ps1 @@ -1,8 +1,12 @@ -function Show-MainWindow { +<# + .SYNOPSIS + Creates and displays the main Win11Debloat window. +#> +function Show-MainWindow { Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null $WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild - $usesDarkMode = GetSystemUsesDarkMode + $usesDarkMode = Get-SystemUsesDarkMode # ---- Load XAML ---- $xaml = Get-Content -Path $script:MainWindowSchema -Raw @@ -14,7 +18,7 @@ $reader.Close() } - SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode + Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode $mainBorder = $window.FindName('MainBorder') $titleBarBackground = $window.FindName('TitleBarBackground') @@ -223,7 +227,7 @@ if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false } # ---- Build JSON-defined app presets ---- - foreach ($preset in (LoadAppPresetsFromJson)) { + foreach ($preset in (Import-AppPresetsFromJson)) { $checkbox = New-Object System.Windows.Controls.CheckBox $checkbox.Content = $preset.Name $checkbox.IsThreeState = $true @@ -301,8 +305,8 @@ # ---- Load apps ---- $appLoadStatusCallback = { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo } - $onlyInstalledAppsBox.Add_Checked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn }) - $onlyInstalledAppsBox.Add_Unchecked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn }) + $onlyInstalledAppsBox.Add_Checked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn }) + $onlyInstalledAppsBox.Add_Unchecked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn }) # ---- App presets popup ---- $presetsPopup.Add_Opened({ @@ -617,9 +621,9 @@ $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false } - $defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" + $defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" if ($defaultsJson) { - ApplySettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings + Apply-SettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings } if ($script:IsLoadingApps) { @@ -663,17 +667,17 @@ $hasAppSelection = ($selectedApps.Count -gt 0) if ($selectedApps.Count -gt 0) { - if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } + if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } - AddParameter 'RemoveApps' - AddParameter 'Apps' ($selectedApps -join ',') + Add-Parameter 'RemoveApps' + Add-Parameter 'Apps' ($selectedApps -join ',') $selectedScopeItem = $appRemovalScopeCombo.SelectedItem if ($selectedScopeItem) { switch ($selectedScopeItem.Content) { - "All users" { AddParameter 'AppRemovalTarget' 'AllUsers' } - "Current user only" { AddParameter 'AppRemovalTarget' 'CurrentUser' } - "Target user only" { AddParameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) } + "All users" { Add-Parameter 'AppRemovalTarget' 'AllUsers' } + "Current user only" { Add-Parameter 'AppRemovalTarget' 'CurrentUser' } + "Target user only" { Add-Parameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) } } } } @@ -681,7 +685,7 @@ # Apply dynamic tweaks foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) { if ($tweakAction.Action -eq 'Apply') { - AddParameter $tweakAction.FeatureId + Add-Parameter $tweakAction.FeatureId $null = $selectedForwardFeatureIds.Add([string]$tweakAction.FeatureId) continue } @@ -695,27 +699,27 @@ $restorePointCheckBox = $window.FindName('RestorePointCheckBox') if ($restorePointCheckBox -and $restorePointCheckBox.IsChecked) { - AddParameter 'CreateRestorePoint' + Add-Parameter 'CreateRestorePoint' } switch ($userSelectionCombo.SelectedIndex) { - 0 { Write-Host "Selected user mode: current user ($(GetUserName))" } + 0 { Write-Host "Selected user mode: current user ($(Get-UserName))" } 1 { Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())" - AddParameter User ($otherUsernameTextBox.Text.Trim()) + Add-Parameter User ($otherUsernameTextBox.Text.Trim()) } 2 { Write-Host "Selected user mode: default user profile (Sysprep)" - AddParameter Sysprep + Add-Parameter Sysprep } } - SaveSettings + Save-Settings $restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox') $shouldRestartExplorer = $restartExplorerCheckBox -and $restartExplorerCheckBox.IsChecked - Show-ApplyModal -Owner $window -RestartExplorer $shouldRestartExplorer + Show-ApplyModal -Owner $window -InvokeRestartExplorer $shouldRestartExplorer $window.Close() }) @@ -737,12 +741,12 @@ $window.Add_Loaded({ try { & $updateHomeContentPosition - Build-DynamicTweaks -Window $window -WinVersion $WinVersion - Load-CurrentTweakStateIntoUI -Window $window + New-DynamicTweakControls -Window $window -WinVersion $WinVersion + Set-CurrentTweakStateInUi -Window $window Update-TweaksResponsiveColumns -Window $window - $lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile - $defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" + $lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile + $defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" $script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson @@ -750,13 +754,13 @@ Register-TweakPresetControlStateHandlers -Window $window Update-TweakPresetStates -Window $window - Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn + Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn # Update Current User label if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) { $currentUserItem = $userSelectionCombo.Items[0] if ($currentUserItem -is [System.Windows.Controls.ComboBoxItem]) { - $currentUserItem.Content = "Current User ($(GetUserName))" + $currentUserItem.Content = "Current User ($(Get-UserName))" } } @@ -809,8 +813,8 @@ }) # ---- Tweak presets wiring ---- - $lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile - $defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" + $lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile + $defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" $script:DefaultTweakPresetMap = @{} $script:LastUsedTweakPresetMap = @{} $script:PrivacyTweakPresetMap = @{} @@ -869,7 +873,7 @@ # ---- Preload app data ---- try { - $script:PreloadedAppData = LoadAppsDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false + $script:PreloadedAppData = Import-AppDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false } catch { Write-Warning "Failed to preload apps list: $_" diff --git a/Scripts/GUI/Show-MessageBox.ps1 b/Scripts/GUI/Show-MessageBox.ps1 index 4d2e9e7..4bdda16 100644 --- a/Scripts/GUI/Show-MessageBox.ps1 +++ b/Scripts/GUI/Show-MessageBox.ps1 @@ -1,4 +1,7 @@ -# Shows a Windows 11 styled custom message box +<# + .SYNOPSIS + Shows a themed Windows 11-style message box. +#> function Show-MessageBox { param ( [Parameter(Mandatory=$true)] @@ -24,7 +27,7 @@ function Show-MessageBox { Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null - $usesDarkMode = GetSystemUsesDarkMode + $usesDarkMode = Get-SystemUsesDarkMode # Determine owner window - use provided Owner, or fall back to main GUI window $ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow } @@ -69,7 +72,7 @@ function Show-MessageBox { } # Apply theme resources - SetWindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode + Set-WindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode # Get UI elements $titleText = $msgWindow.FindName('TitleText') diff --git a/Scripts/GUI/Show-RestoreBackupDialog.ps1 b/Scripts/GUI/Show-RestoreBackupDialog.ps1 index 054fb15..ece11ac 100644 --- a/Scripts/GUI/Show-RestoreBackupDialog.ps1 +++ b/Scripts/GUI/Show-RestoreBackupDialog.ps1 @@ -15,7 +15,7 @@ Hashtable Returns a Hashtable describing the user's choice. Possible shapes: RestoreRegistry - @{ Result='RestoreRegistry'; Backup= } - RestoreStartMenu - @{ Result='RestoreStartMenu'; StartMenuScope=; + Restore-StartMenu - @{ Result='Restore-StartMenu'; StartMenuScope=; UseManualBackupFile=; BackupFilePath= } Cancelled - @{ Result='Cancelled' } (from New-RestoreDialogState) #> @@ -26,7 +26,7 @@ function Show-RestoreBackupDialog { Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null - $usesDarkMode = GetSystemUsesDarkMode + $usesDarkMode = Get-SystemUsesDarkMode $ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow } $overlay = $null @@ -67,7 +67,7 @@ function Show-RestoreBackupDialog { } try { - SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode + Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode } catch { } @@ -129,7 +129,7 @@ function Show-RestoreBackupDialog { param([string]$BackupFilePath) $scopeInfo = & $getStartMenuScopeInfo - $backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target $scopeInfo.Target + $backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target $scopeInfo.Target $overviewSummaryText.Text = "This will replace the current Start Menu pinned apps layout for $($scopeInfo.SummaryText) with the selected backup." $backupFileText.Text = Split-Path -Path $BackupFilePath -Leaf @@ -276,7 +276,7 @@ function Show-RestoreBackupDialog { $backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf $backupCreatedText.Text = $createdText - $backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target) + $backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target) $featuresItemsControl.ItemsSource = $revertibleFeaturesList $overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' } $reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList @@ -318,7 +318,7 @@ function Show-RestoreBackupDialog { Write-Host "Backup file selected: $($openDialog.FileName)" try { - $selectedBackup = Load-RegistryBackupFromFile -FilePath $openDialog.FileName + $selectedBackup = Import-RegistryBackup -FilePath $openDialog.FileName if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) { return @@ -366,7 +366,7 @@ function Show-RestoreBackupDialog { } $window.Tag = @{ - Result = 'RestoreStartMenu' + Result = 'Restore-StartMenu' StartMenuScope = $scope UseManualBackupFile = $useManualBackupFile BackupFilePath = $state.SelectedStartMenuBackupFilePath diff --git a/Scripts/GUI/Show-RestoreBackupWindow.ps1 b/Scripts/GUI/Show-RestoreBackupWindow.ps1 index f8e0995..3d2db0f 100644 --- a/Scripts/GUI/Show-RestoreBackupWindow.ps1 +++ b/Scripts/GUI/Show-RestoreBackupWindow.ps1 @@ -1,3 +1,7 @@ +<# + .SYNOPSIS + Shows the backup-restore dialog and performs the selected restore. +#> function Show-RestoreBackupWindow { param( [System.Windows.Window]$Owner = $null @@ -38,7 +42,7 @@ function Show-RestoreBackupWindow { } } } - elseif ($dialogResult.Result -eq 'RestoreStartMenu') { + elseif ($dialogResult.Result -eq 'Restore-StartMenu') { $scope = $dialogResult.StartMenuScope $useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true) $backupFilePath = $null @@ -54,10 +58,10 @@ function Show-RestoreBackupWindow { } $result = if ($scope -eq 'AllUsers') { - RestoreStartMenuForAllUsers -BackupFilePath $backupFilePath + Restore-StartMenuForAllUsers -BackupFilePath $backupFilePath } else { - RestoreStartMenu -BackupFilePath $backupFilePath + Restore-StartMenu -BackupFilePath $backupFilePath } $resultEntries = @($result) diff --git a/Scripts/Helpers/AddParameter.ps1 b/Scripts/Helpers/Add-Parameter.ps1 similarity index 73% rename from Scripts/Helpers/AddParameter.ps1 rename to Scripts/Helpers/Add-Parameter.ps1 index 742d76f..6a01f92 100644 --- a/Scripts/Helpers/AddParameter.ps1 +++ b/Scripts/Helpers/Add-Parameter.ps1 @@ -1,5 +1,8 @@ -# Add parameter to script and write to file -function AddParameter { +<# + .SYNOPSIS + Adds or updates a value in the active parameter collection. +#> +function Add-Parameter { param ( $parameterName, $value = $true diff --git a/Scripts/Helpers/ApplyRegistryRegFile.ps1 b/Scripts/Helpers/Apply-RegistryRegFile.ps1 similarity index 93% rename from Scripts/Helpers/ApplyRegistryRegFile.ps1 rename to Scripts/Helpers/Apply-RegistryRegFile.ps1 index 0185c6c..1a79685 100644 --- a/Scripts/Helpers/ApplyRegistryRegFile.ps1 +++ b/Scripts/Helpers/Apply-RegistryRegFile.ps1 @@ -39,25 +39,6 @@ function Convert-RegOperationToValueKind { } } -function Remove-RegistrySubKeyTreeIfExists { - param( - [Parameter(Mandatory)] - [Microsoft.Win32.RegistryKey]$RootKey, - [Parameter(Mandatory)] - [string]$SubKeyPath - ) - - try { - $RootKey.DeleteSubKeyTree($SubKeyPath, $false) - } - catch [System.UnauthorizedAccessException], [System.Security.SecurityException] { - throw - } - catch { - # Best-effort cleanup only; missing keys are fine. - } -} - function Get-RegistryKeyForOperation { param( [Parameter(Mandatory)] diff --git a/Scripts/Helpers/ConfirmUnsafeAppRemoval.ps1 b/Scripts/Helpers/Confirm-UnsafeAppRemoval.ps1 similarity index 81% rename from Scripts/Helpers/ConfirmUnsafeAppRemoval.ps1 rename to Scripts/Helpers/Confirm-UnsafeAppRemoval.ps1 index 2e3f9bd..0ddc7c8 100644 --- a/Scripts/Helpers/ConfirmUnsafeAppRemoval.ps1 +++ b/Scripts/Helpers/Confirm-UnsafeAppRemoval.ps1 @@ -1,7 +1,8 @@ -# Shows confirmation dialogs for apps that require extra caution before removal. -# Returns $true if the user confirmed all warnings (or if no warnings were triggered), -# $false if the user declined any warning. -function ConfirmUnsafeAppRemoval { +<# + .SYNOPSIS + Confirms removal of applications that require an extra safety warning. +#> +function Confirm-UnsafeAppRemoval { param ( [string[]]$SelectedApps, $Owner = $null diff --git a/Scripts/Helpers/GenerateAppsList.ps1 b/Scripts/Helpers/Generate-AppsList.ps1 similarity index 60% rename from Scripts/Helpers/GenerateAppsList.ps1 rename to Scripts/Helpers/Generate-AppsList.ps1 index 8d37ea7..b290083 100644 --- a/Scripts/Helpers/GenerateAppsList.ps1 +++ b/Scripts/Helpers/Generate-AppsList.ps1 @@ -1,5 +1,8 @@ -# Generates a list of apps to remove based on the Apps parameter -function GenerateAppsList { +<# + .SYNOPSIS + Builds the validated application-removal list from the Apps parameter. +#> +function Generate-AppsList { if (-not ($script:Params["Apps"] -and $script:Params["Apps"] -is [string])) { return @() } @@ -8,12 +11,12 @@ function GenerateAppsList { switch ($appMode) { 'default' { - $appsList = LoadAppsFromFile $script:AppsListFilePath + $appsList = Import-AppsFromFile $script:AppsListFilePath return $appsList } default { $appsList = $script:Params["Apps"].Split(',') | ForEach-Object { $_.Trim() } - $validatedAppsList = ValidateAppslist $appsList + $validatedAppsList = Get-ValidatedAppList $appsList return $validatedAppsList } } diff --git a/Scripts/Helpers/GetFriendlyRegistryBackupTarget.ps1 b/Scripts/Helpers/Get-FriendlyRegistryBackupTarget.ps1 similarity index 85% rename from Scripts/Helpers/GetFriendlyRegistryBackupTarget.ps1 rename to Scripts/Helpers/Get-FriendlyRegistryBackupTarget.ps1 index 22712c0..5669e1a 100644 --- a/Scripts/Helpers/GetFriendlyRegistryBackupTarget.ps1 +++ b/Scripts/Helpers/Get-FriendlyRegistryBackupTarget.ps1 @@ -1,4 +1,8 @@ -function GetFriendlyRegistryBackupTarget { +<# + .SYNOPSIS + Converts a registry-backup target identifier into a user-friendly label. +#> +function Get-FriendlyRegistryBackupTarget { param( [AllowNull()] [AllowEmptyString()] @@ -40,4 +44,4 @@ function GetFriendlyRegistryBackupTarget { } return $Target -} \ No newline at end of file +} diff --git a/Scripts/Helpers/Get-FriendlyTargetUserName.ps1 b/Scripts/Helpers/Get-FriendlyTargetUserName.ps1 new file mode 100644 index 0000000..2be1024 --- /dev/null +++ b/Scripts/Helpers/Get-FriendlyTargetUserName.ps1 @@ -0,0 +1,13 @@ +<# + .SYNOPSIS + Returns a readable description of the current app-removal target. +#> +function Get-FriendlyTargetUserName { + $target = Get-TargetUserForAppRemoval + + switch ($target) { + "AllUsers" { return "all users" } + "CurrentUser" { return "the current user" } + default { return "user $target" } + } +} diff --git a/Scripts/Helpers/Get-RegFileOperations.ps1 b/Scripts/Helpers/Get-RegFileOperations.ps1 index 853423a..273140d 100644 --- a/Scripts/Helpers/Get-RegFileOperations.ps1 +++ b/Scripts/Helpers/Get-RegFileOperations.ps1 @@ -74,7 +74,10 @@ function Get-RegFileOperations { } $parsedValue = Convert-RegValueData -valueData $matches.valueData.Trim() - if (-not $parsedValue) { continue } + if (-not $parsedValue) { + Write-Warning "Skipping unsupported or malformed registry value '$valueName' in '$currentKeyPath'." + continue + } $operations += [PSCustomObject]@{ OperationType = $parsedValue.OperationType @@ -88,6 +91,10 @@ function Get-RegFileOperations { return $operations } +<# + .SYNOPSIS + Converts a .reg value literal into an operation type, registry value type, and data. +#> function Convert-RegValueData { param( [Parameter(Mandatory)] @@ -121,8 +128,13 @@ function Convert-RegValueData { } if ($valueData -match '^hex(?:\((?[0-9a-fA-F]+)\))?:(?[0-9a-fA-F,\s]+)$') { - $bytes = Convert-HexStringToByteArray -hexValue $matches.bytes + $parsedBytes = Convert-HexStringToByteArray -hexValue $matches.bytes + if ($null -eq $parsedBytes) { + return $null + } + $bytes = [byte[]]@($parsedBytes) $valueType = if ($matches.kind) { "Hex$($matches.kind)" } else { 'Binary' } + $value = switch ($matches.kind) { '2' { Convert-RegistryByteArrayToString -byteData $bytes } '7' { Convert-RegistryByteArrayToMultiString -byteData $bytes } @@ -150,16 +162,29 @@ function Convert-RegValueData { return $null } +<# + .SYNOPSIS + Converts a comma-separated hexadecimal byte string into a byte array. +#> function Convert-HexStringToByteArray { param( [Parameter(Mandatory)] [string]$hexValue ) - $parts = $hexValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } - return [System.Linq.Enumerable]::Select($parts, [Func[object, byte]] { - param($h) [System.Convert]::ToByte($h, 16) - }) -as [byte[]] + $parts = @($hexValue.Split(',') | ForEach-Object { $_.Trim() }) + if ($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { + return $null + } + + $bytes = New-Object byte[] $parts.Count + for ($i = 0; $i -lt $parts.Count; $i++) { + if ($parts[$i] -notmatch '^[0-9a-fA-F]{1,2}$') { + return $null + } + $bytes[$i] = [System.Convert]::ToByte($parts[$i], 16) + } + return ,$bytes } function Convert-RegistryByteArrayToString { diff --git a/Scripts/Helpers/GetTargetUserForAppRemoval.ps1 b/Scripts/Helpers/Get-TargetUserForAppRemoval.ps1 similarity index 89% rename from Scripts/Helpers/GetTargetUserForAppRemoval.ps1 rename to Scripts/Helpers/Get-TargetUserForAppRemoval.ps1 index 6fc524b..d26f68d 100644 --- a/Scripts/Helpers/GetTargetUserForAppRemoval.ps1 +++ b/Scripts/Helpers/Get-TargetUserForAppRemoval.ps1 @@ -1,6 +1,6 @@ # Target is determined from $script:Params["AppRemovalTarget"] or defaults to "AllUsers" # Target values: "AllUsers" (removes for all users + from image), "CurrentUser", or a specific username -function GetTargetUserForAppRemoval { +function Get-TargetUserForAppRemoval { if ($script:Params.ContainsKey("AppRemovalTarget")) { return $script:Params["AppRemovalTarget"] } diff --git a/Scripts/Helpers/GetUserDirectory.ps1 b/Scripts/Helpers/Get-UserDirectory.ps1 similarity index 92% rename from Scripts/Helpers/GetUserDirectory.ps1 rename to Scripts/Helpers/Get-UserDirectory.ps1 index 93fd847..a3fe0d4 100644 --- a/Scripts/Helpers/GetUserDirectory.ps1 +++ b/Scripts/Helpers/Get-UserDirectory.ps1 @@ -1,5 +1,5 @@ # Returns the directory path of the specified user, exits script if user path can't be found -function GetUserDirectory { +function Get-UserDirectory { param ( $userName, $fileName = "", @@ -29,7 +29,7 @@ function GetUserDirectory { } } - $userContext = ResolveUserProfileContext -UserName $userName + $userContext = Resolve-UserProfileContext -UserName $userName $resolvedUserDirectory = if ($userContext) { $userContext.ProfilePath } else { $null } if ($resolvedUserDirectory) { $userPath = if ([string]::IsNullOrWhiteSpace($fileName)) { @@ -46,9 +46,9 @@ function GetUserDirectory { } catch { Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system" - AwaitKeyToExit + Wait-ForKeyPress } Write-Error "Unable to find user directory path for user $userName" - AwaitKeyToExit + Wait-ForKeyPress } diff --git a/Scripts/Helpers/Get-UserName.ps1 b/Scripts/Helpers/Get-UserName.ps1 new file mode 100644 index 0000000..f3cd92a --- /dev/null +++ b/Scripts/Helpers/Get-UserName.ps1 @@ -0,0 +1,11 @@ +<# + .SYNOPSIS + Returns the explicitly targeted user name or the current process user name. +#> +function Get-UserName { + if ($script:Params.ContainsKey("User")) { + return $script:Params.Item("User") + } + + return $env:USERNAME +} diff --git a/Scripts/Helpers/GetFriendlyTargetUserName.ps1 b/Scripts/Helpers/GetFriendlyTargetUserName.ps1 deleted file mode 100644 index 149b746..0000000 --- a/Scripts/Helpers/GetFriendlyTargetUserName.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -function GetFriendlyTargetUserName { - $target = GetTargetUserForAppRemoval - - switch ($target) { - "AllUsers" { return "all users" } - "CurrentUser" { return "the current user" } - default { return "user $target" } - } -} diff --git a/Scripts/Helpers/GetUserName.ps1 b/Scripts/Helpers/GetUserName.ps1 deleted file mode 100644 index 45b088c..0000000 --- a/Scripts/Helpers/GetUserName.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -function GetUserName { - if ($script:Params.ContainsKey("User")) { - return $script:Params.Item("User") - } - - return $env:USERNAME -} diff --git a/Scripts/Helpers/ImportConfigToParams.ps1 b/Scripts/Helpers/Import-ConfigToParams.ps1 similarity index 82% rename from Scripts/Helpers/ImportConfigToParams.ps1 rename to Scripts/Helpers/Import-ConfigToParams.ps1 index 2870108..3cecfc8 100644 --- a/Scripts/Helpers/ImportConfigToParams.ps1 +++ b/Scripts/Helpers/Import-ConfigToParams.ps1 @@ -1,4 +1,8 @@ -function ImportConfigToParams { +<# + .SYNOPSIS + Imports valid application, tweak, and deployment selections from a configuration JSON file into active parameters. +#> +function Import-ConfigToParams { param ( [Parameter(Mandatory = $true)] [string]$ConfigPath, @@ -22,7 +26,7 @@ function ImportConfigToParams { throw "Provided config file must be a .json file: $resolvedConfigPath" } - $configJson = LoadJsonFile -filePath $resolvedConfigPath -expectedVersion $ExpectedVersion + $configJson = Import-JsonFile -filePath $resolvedConfigPath -expectedVersion $ExpectedVersion if ($null -eq $configJson) { throw "Failed to read config file: $resolvedConfigPath" } @@ -38,8 +42,8 @@ function ImportConfigToParams { ) if ($appIds.Count -gt 0) { - AddParameter 'RemoveApps' - AddParameter 'Apps' ($appIds -join ',') + Add-Parameter 'RemoveApps' + Add-Parameter 'Apps' ($appIds -join ',') $importedItems++ } } @@ -59,7 +63,7 @@ function ImportConfigToParams { continue } - AddParameter $setting.Name $true + Add-Parameter $setting.Name $true $importedItems++ } } @@ -73,12 +77,12 @@ function ImportConfigToParams { } if ($deploymentLookup.ContainsKey('CreateRestorePoint') -and [bool]$deploymentLookup['CreateRestorePoint']) { - AddParameter 'CreateRestorePoint' + Add-Parameter 'CreateRestorePoint' $importedItems++ } if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) { - AddParameter 'NoRestartExplorer' + Add-Parameter 'NoRestartExplorer' $importedItems++ } @@ -87,12 +91,12 @@ function ImportConfigToParams { 1 { $otherUserName = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' } if (-not [string]::IsNullOrWhiteSpace($otherUserName)) { - AddParameter 'User' $otherUserName + Add-Parameter 'User' $otherUserName $importedItems++ } } 2 { - AddParameter 'Sysprep' + Add-Parameter 'Sysprep' $importedItems++ } } @@ -101,17 +105,17 @@ function ImportConfigToParams { if ($deploymentLookup.ContainsKey('AppRemovalScopeIndex') -and $script:Params.ContainsKey('RemoveApps')) { switch ([int]$deploymentLookup['AppRemovalScopeIndex']) { 0 { - AddParameter 'AppRemovalTarget' 'AllUsers' + Add-Parameter 'AppRemovalTarget' 'AllUsers' $importedItems++ } 1 { - AddParameter 'AppRemovalTarget' 'CurrentUser' + Add-Parameter 'AppRemovalTarget' 'CurrentUser' $importedItems++ } 2 { $targetUser = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' } if (-not [string]::IsNullOrWhiteSpace($targetUser)) { - AddParameter 'AppRemovalTarget' $targetUser + Add-Parameter 'AppRemovalTarget' $targetUser $importedItems++ } } diff --git a/Scripts/Helpers/RegistryPathHelpers.ps1 b/Scripts/Helpers/Registry-PathHelpers.ps1 similarity index 73% rename from Scripts/Helpers/RegistryPathHelpers.ps1 rename to Scripts/Helpers/Registry-PathHelpers.ps1 index d666646..565c670 100644 --- a/Scripts/Helpers/RegistryPathHelpers.ps1 +++ b/Scripts/Helpers/Registry-PathHelpers.ps1 @@ -1,3 +1,7 @@ +<# + .SYNOPSIS + Normalizes a rooted registry path and returns its hive and subkey components. +#> function Split-RegistryPath { param( [Parameter(Mandatory)] @@ -51,6 +55,10 @@ function Split-RegistryPath { } } +<# + .SYNOPSIS + Returns the .NET registry root key for a supported registry hive name. +#> function Get-RegistryRootKey { param( [Parameter(Mandatory)] @@ -67,6 +75,39 @@ function Get-RegistryRootKey { } } +<# + .SYNOPSIS + Deletes a registry subkey tree and ignores a key that has already disappeared. +#> +function Remove-RegistrySubKeyTreeIfExists { + param( + [Parameter(Mandatory)] + $RootKey, + [Parameter(Mandatory)] + [string]$SubKeyPath + ) + + try { + $RootKey.DeleteSubKeyTree($SubKeyPath, $false) + } + catch { + $failure = $_.Exception + while ($failure.InnerException) { + $failure = $failure.InnerException + } + if ($failure -is [System.ArgumentException]) { + # The key can disappear between snapshot inspection and deletion. + return + } + + throw + } +} + +<# + .SYNOPSIS + Returns a feature's registry-file path, using the Sysprep layout when targeting another profile. +#> function Get-RegistryFilePathForFeature { param( [Parameter(Mandatory)] diff --git a/Scripts/Helpers/ResolveUserProfilePath.ps1 b/Scripts/Helpers/Resolve-UserProfilePath.ps1 similarity index 85% rename from Scripts/Helpers/ResolveUserProfilePath.ps1 rename to Scripts/Helpers/Resolve-UserProfilePath.ps1 index dcae5ad..38b21e7 100644 --- a/Scripts/Helpers/ResolveUserProfilePath.ps1 +++ b/Scripts/Helpers/Resolve-UserProfilePath.ps1 @@ -12,7 +12,7 @@ .OUTPUTS System.String #> -function NormalizeUserLookupValue { +function Normalize-UserLookupValue { param( [string]$Value ) @@ -44,12 +44,12 @@ if (-not $script:ResolvedUserSidCache) { .OUTPUTS System.String #> -function GetUserLookupCacheKey { +function Get-UserLookupCacheKey { param( [string]$Value ) - $normalizedValue = NormalizeUserLookupValue -Value $Value + $normalizedValue = Normalize-UserLookupValue -Value $Value if ([string]::IsNullOrWhiteSpace($normalizedValue)) { return '' } @@ -72,13 +72,13 @@ function GetUserLookupCacheKey { .OUTPUTS System.String[] #> -function GetNormalizedLookupCandidates { +function Get-NormalizedLookupCandidates { param( [string[]]$Candidates ) $normalized = @($Candidates) | - ForEach-Object { NormalizeUserLookupValue -Value $_ } | + ForEach-Object { Normalize-UserLookupValue -Value $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique @@ -100,7 +100,7 @@ function GetNormalizedLookupCandidates { .OUTPUTS System.String #> -function EscapeWqlString { +function Escape-WqlString { param( [string]$Value ) @@ -126,22 +126,22 @@ function EscapeWqlString { .OUTPUTS System.String #> -function GetLocalUserNameSegment { +function Get-LocalUserNameSegment { param( [string]$UserName ) - $normalizedName = NormalizeUserLookupValue -Value $UserName + $normalizedName = Normalize-UserLookupValue -Value $UserName if ([string]::IsNullOrWhiteSpace($normalizedName)) { return '' } if ($normalizedName.Contains('\')) { - return NormalizeUserLookupValue -Value (($normalizedName -split '\\')[-1]) + return Normalize-UserLookupValue -Value (($normalizedName -split '\\')[-1]) } if ($normalizedName.Contains('@')) { - return NormalizeUserLookupValue -Value (($normalizedName -split '@')[0]) + return Normalize-UserLookupValue -Value (($normalizedName -split '@')[0]) } return $normalizedName @@ -165,12 +165,12 @@ function GetLocalUserNameSegment { .OUTPUTS System.String #> -function ResolveNetBiosDomainName { +function Resolve-NetBiosDomainName { param( [string]$RawDomain ) - $trimmed = NormalizeUserLookupValue -Value $RawDomain + $trimmed = Normalize-UserLookupValue -Value $RawDomain if ([string]::IsNullOrWhiteSpace($trimmed)) { return '' } @@ -194,7 +194,7 @@ function ResolveNetBiosDomainName { } if ($ntDomainInstance -and -not [string]::IsNullOrWhiteSpace($ntDomainInstance.DomainName)) { - $fromNtDomain = NormalizeUserLookupValue -Value $ntDomainInstance.DomainName + $fromNtDomain = Normalize-UserLookupValue -Value $ntDomainInstance.DomainName if (-not [string]::IsNullOrWhiteSpace($fromNtDomain)) { return $fromNtDomain } @@ -205,7 +205,7 @@ function ResolveNetBiosDomainName { } if ($trimmed.Contains('.')) { - $leaf = NormalizeUserLookupValue -Value (($trimmed -split '\.')[0]) + $leaf = Normalize-UserLookupValue -Value (($trimmed -split '\.')[0]) if (-not [string]::IsNullOrWhiteSpace($leaf)) { return $leaf } @@ -221,7 +221,7 @@ function ResolveNetBiosDomainName { .DESCRIPTION Cached in script scope for the process lifetime. Returns $false on error or workgroup. When joined, also caches the NetBIOS domain label - (ResolveNetBiosDomainName) for use as a profile-folder suffix. + (Resolve-NetBiosDomainName) for use as a profile-folder suffix. .OUTPUTS System.Boolean @@ -239,7 +239,7 @@ function Test-MachineIsDomainJoined { $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) { $script:MachineIsDomainJoined = $true - $script:MachineNetBiosDomain = ResolveNetBiosDomainName -RawDomain ([string]$computerSystem.Domain) + $script:MachineNetBiosDomain = Resolve-NetBiosDomainName -RawDomain ([string]$computerSystem.Domain) } } catch { @@ -262,7 +262,7 @@ function Test-MachineIsDomainJoined { .OUTPUTS System.String #> -function GetProfileFolderDomainSuffix { +function Get-ProfileFolderDomainSuffix { if (-not (Test-MachineIsDomainJoined)) { return '' } @@ -301,12 +301,12 @@ function GetProfileFolderDomainSuffix { .OUTPUTS System.String[] #> -function GetUserNameMatchCandidates { +function Get-UserNameMatchCandidates { param( [string]$Value ) - $normalized = NormalizeUserLookupValue -Value $Value + $normalized = Normalize-UserLookupValue -Value $Value if ([string]::IsNullOrWhiteSpace($normalized)) { return @() } @@ -314,13 +314,13 @@ function GetUserNameMatchCandidates { $candidates = New-Object 'System.Collections.Generic.List[string]' [void]$candidates.Add($normalized) - $localSegment = GetLocalUserNameSegment -UserName $normalized + $localSegment = Get-LocalUserNameSegment -UserName $normalized if (-not [string]::IsNullOrWhiteSpace($localSegment) -and ($localSegment -ine $normalized)) { [void]$candidates.Add($localSegment) } # Domain-suffixed forms only apply where Windows writes them. - $domainSuffix = GetProfileFolderDomainSuffix + $domainSuffix = Get-ProfileFolderDomainSuffix if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) { # Prefer the local segment as the stem so DOMAIN\user still yields # user.CONTOSO rather than the fully qualified string. @@ -340,7 +340,7 @@ function GetUserNameMatchCandidates { # (registry, backup metadata), so add that form too. $suffixWithDot = ".$domainSuffix" if ($normalized.Length -gt $suffixWithDot.Length -and $normalized.EndsWith($suffixWithDot, [System.StringComparison]::OrdinalIgnoreCase)) { - $bareStem = NormalizeUserLookupValue -Value ($normalized.Substring(0, $normalized.Length - $suffixWithDot.Length)) + $bareStem = Normalize-UserLookupValue -Value ($normalized.Substring(0, $normalized.Length - $suffixWithDot.Length)) if (-not [string]::IsNullOrWhiteSpace($bareStem)) { $alreadyPresent = $false foreach ($existing in $candidates) { @@ -361,7 +361,7 @@ function GetUserNameMatchCandidates { Test whether a user name and a profile folder leaf share an account. .DESCRIPTION - Compares candidate sets (via GetUserNameMatchCandidates) instead of + Compares candidate sets (via Get-UserNameMatchCandidates) instead of raw strings, so different forms of the same account still match. .PARAMETER UserName @@ -383,8 +383,8 @@ function Test-UserNameMatchesProfileLeaf { return $false } - $leafCandidates = @(GetUserNameMatchCandidates -Value $ProfileLeaf) - $userCandidates = @(GetUserNameMatchCandidates -Value $UserName) + $leafCandidates = @(Get-UserNameMatchCandidates -Value $ProfileLeaf) + $userCandidates = @(Get-UserNameMatchCandidates -Value $UserName) foreach ($leaf in $leafCandidates) { foreach ($user in $userCandidates) { @@ -430,13 +430,13 @@ function Test-UserNameMatch { # Workgroup: strict equality (no suffix disambiguation available). if (-not (Test-MachineIsDomainJoined)) { - $normalizedA = NormalizeUserLookupValue -Value $UserNameA - $normalizedB = NormalizeUserLookupValue -Value $UserNameB + $normalizedA = Normalize-UserLookupValue -Value $UserNameA + $normalizedB = Normalize-UserLookupValue -Value $UserNameB return ($normalizedA -ieq $normalizedB) } - $candidatesA = @(GetUserNameMatchCandidates -Value $UserNameA) - $candidatesB = @(GetUserNameMatchCandidates -Value $UserNameB) + $candidatesA = @(Get-UserNameMatchCandidates -Value $UserNameA) + $candidatesB = @(Get-UserNameMatchCandidates -Value $UserNameB) foreach ($a in $candidatesA) { foreach ($b in $candidatesB) { @@ -463,7 +463,7 @@ function Test-UserNameMatch { .PARAMETER Sid Resolved SID to cache. #> -function SetResolvedUserSidCache { +function Set-ResolvedUserSidCache { param( [string[]]$Candidates, [string]$Sid @@ -474,7 +474,7 @@ function SetResolvedUserSidCache { } foreach ($candidate in @($Candidates)) { - $cacheKey = GetUserLookupCacheKey -Value $candidate + $cacheKey = Get-UserLookupCacheKey -Value $candidate if ($cacheKey) { $script:ResolvedUserSidCache[$cacheKey] = $Sid } @@ -494,13 +494,13 @@ function SetResolvedUserSidCache { .OUTPUTS System.String #> -function GetCachedResolvedUserSid { +function Get-CachedResolvedUserSid { param( [string[]]$Candidates ) foreach ($candidate in @($Candidates)) { - $cacheKey = GetUserLookupCacheKey -Value $candidate + $cacheKey = Get-UserLookupCacheKey -Value $candidate if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) { return $script:ResolvedUserSidCache[$cacheKey] } @@ -523,7 +523,7 @@ function GetCachedResolvedUserSid { .OUTPUTS System.String #> -function TryResolveSidByNtAccount { +function Try-ResolveSidByNtAccount { param( [string]$UserName ) @@ -560,12 +560,12 @@ function TryResolveSidByNtAccount { .OUTPUTS System.String #> -function TryResolveSidByLocalLookup { +function Try-ResolveSidByLocalLookup { param( [string[]]$Candidates ) - $lookupCandidates = GetNormalizedLookupCandidates -Candidates $Candidates + $lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates if ($lookupCandidates.Count -eq 0) { return $null } @@ -586,8 +586,8 @@ function TryResolveSidByLocalLookup { foreach ($candidate in $lookupCandidates) { try { - $escapedCandidate = EscapeWqlString -Value $candidate - $escapedComputerName = EscapeWqlString -Value $env:COMPUTERNAME + $escapedCandidate = Escape-WqlString -Value $candidate + $escapedComputerName = Escape-WqlString -Value $env:COMPUTERNAME $filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')" $matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1 @@ -617,12 +617,12 @@ function TryResolveSidByLocalLookup { .OUTPUTS System.String #> -function TryResolveSidFromProfileList { +function Try-ResolveSidFromProfileList { param( [string[]]$Candidates ) - $lookupCandidates = GetNormalizedLookupCandidates -Candidates $Candidates + $lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates if ($lookupCandidates.Count -eq 0) { return $null } @@ -635,7 +635,7 @@ function TryResolveSidFromProfileList { if ([string]::IsNullOrWhiteSpace($imagePath)) { continue } $expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath) - $leafName = NormalizeUserLookupValue -Value (Split-Path -Leaf $expandedPath) + $leafName = Normalize-UserLookupValue -Value (Split-Path -Leaf $expandedPath) foreach ($candidate in $lookupCandidates) { if (Test-MachineIsDomainJoined) { @@ -680,7 +680,7 @@ function TryResolveSidFromProfileList { .OUTPUTS System.Management.Automation.PSCustomObject #> -function NewResolvedUserContext { +function New-ResolvedUserContext { param( [string]$UserName, [string]$UserSid, @@ -708,12 +708,12 @@ function NewResolvedUserContext { .OUTPUTS System.String #> -function GetQualifiedProcessIdentityName { +function Get-QualifiedProcessIdentityName { param( [string]$Candidate ) - $normalizedCandidate = NormalizeUserLookupValue -Value $Candidate + $normalizedCandidate = Normalize-UserLookupValue -Value $Candidate if ([string]::IsNullOrWhiteSpace($normalizedCandidate)) { return $null } @@ -735,7 +735,7 @@ function GetQualifiedProcessIdentityName { return $null } - $currentLocalSegment = GetLocalUserNameSegment -UserName $currentName + $currentLocalSegment = Get-LocalUserNameSegment -UserName $currentName if (-not [string]::IsNullOrWhiteSpace($currentLocalSegment) -and $currentLocalSegment -ieq $normalizedCandidate) { return $currentName } @@ -762,19 +762,19 @@ function GetQualifiedProcessIdentityName { .OUTPUTS System.String #> -function ResolveUserSid { +function Resolve-UserSid { param( [Parameter(Mandatory)] [string]$UserName ) - $candidateUserName = NormalizeUserLookupValue -Value $UserName + $candidateUserName = Normalize-UserLookupValue -Value $UserName if ([string]::IsNullOrWhiteSpace($candidateUserName)) { return $null } $hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@') - $localNameSegment = GetLocalUserNameSegment -UserName $candidateUserName + $localNameSegment = Get-LocalUserNameSegment -UserName $candidateUserName $leafNameCandidates = @() if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) { $leafNameCandidates = @($localNameSegment) @@ -796,7 +796,7 @@ function ResolveUserSid { @($candidateUserName) } - $cachedSid = GetCachedResolvedUserSid -Candidates $lookupCandidates + $cachedSid = Get-CachedResolvedUserSid -Candidates $lookupCandidates if ($cachedSid) { return $cachedSid } @@ -811,12 +811,12 @@ function ResolveUserSid { } elseif (Test-MachineIsDomainJoined) { # Prefer process identity (authoritative), then USERDOMAIN\input. - $processQualifiedName = GetQualifiedProcessIdentityName -Candidate $candidateUserName + $processQualifiedName = Get-QualifiedProcessIdentityName -Candidate $candidateUserName if (-not [string]::IsNullOrWhiteSpace($processQualifiedName)) { [void]$qualifiedNamesToTry.Add($processQualifiedName) } - $domainSuffix = GetProfileFolderDomainSuffix + $domainSuffix = Get-ProfileFolderDomainSuffix if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) { $domainQualifiedName = "$domainSuffix\$candidateUserName" if (-not ($qualifiedNamesToTry -contains $domainQualifiedName)) { @@ -831,10 +831,10 @@ function ResolveUserSid { # Step 2: resolve qualified form(s) via NTAccount.Translate. foreach ($qualifiedName in $qualifiedNamesToTry) { - $resolvedSid = TryResolveSidByNtAccount -UserName $qualifiedName + $resolvedSid = Try-ResolveSidByNtAccount -UserName $qualifiedName if ($resolvedSid) { $allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique - SetResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid + Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid return $resolvedSid } } @@ -842,20 +842,20 @@ function ResolveUserSid { # Step 3: local SAM fallback (workgroup only; skipped on domain to avoid # nameshare shadowing). if (-not (Test-MachineIsDomainJoined)) { - $resolvedSid = TryResolveSidByLocalLookup -Candidates $lookupCandidates + $resolvedSid = Try-ResolveSidByLocalLookup -Candidates $lookupCandidates if ($resolvedSid) { $allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique - SetResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid + Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid return $resolvedSid } } # Step 4: ProfileList leaf heuristic (last resort; disambiguates by # on-disk folder name, suffix-aware on domain boxes). - $resolvedSid = TryResolveSidFromProfileList -Candidates $profileHeuristicCandidates + $resolvedSid = Try-ResolveSidFromProfileList -Candidates $profileHeuristicCandidates if ($resolvedSid) { $allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique - SetResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid + Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid return $resolvedSid } @@ -878,13 +878,13 @@ function ResolveUserSid { .OUTPUTS System.Management.Automation.PSCustomObject #> -function ResolveUserProfileContext { +function Resolve-UserProfileContext { param( [Parameter(Mandatory)] [string]$UserName ) - $candidateUserName = NormalizeUserLookupValue -Value $UserName + $candidateUserName = Normalize-UserLookupValue -Value $UserName if ([string]::IsNullOrWhiteSpace($candidateUserName)) { return $null } @@ -902,14 +902,14 @@ function ResolveUserProfileContext { $defaultProfilePath = Join-Path $rootPath 'Default' if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) { - return (NewResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath) + return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath) } } return $null } - $userSid = ResolveUserSid -UserName $candidateUserName + $userSid = Resolve-UserSid -UserName $candidateUserName if ($userSid) { $sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid" @@ -919,7 +919,7 @@ function ResolveUserProfileContext { if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) { $expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath) if (Test-Path -LiteralPath $expandedPath -PathType Container) { - return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath) + return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath) } } } @@ -932,7 +932,7 @@ function ResolveUserProfileContext { $matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop) $resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1 if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) { - return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath) + return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath) } } catch { @@ -948,7 +948,7 @@ function ResolveUserProfileContext { # Exact leaf match first (common case; avoids an unnecessary scan). $candidateUserPath = Join-Path $rootPath $candidateUserName if (Test-Path -LiteralPath $candidateUserPath -PathType Container) { - return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath) + return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath) } # Only domain-joined boxes write suffixed folders; scanning workgroup @@ -957,7 +957,7 @@ function ResolveUserProfileContext { try { foreach ($child in @(Get-ChildItem -LiteralPath $rootPath -Directory -ErrorAction SilentlyContinue)) { if (Test-UserNameMatchesProfileLeaf -UserName $candidateUserName -ProfileLeaf $child.Name) { - return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $child.FullName) + return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $child.FullName) } } } diff --git a/Scripts/Helpers/CheckModernStandbySupport.ps1 b/Scripts/Helpers/Test-ModernStandbySupport.ps1 similarity index 94% rename from Scripts/Helpers/CheckModernStandbySupport.ps1 rename to Scripts/Helpers/Test-ModernStandbySupport.ps1 index 45cf9ae..b4efb0f 100644 --- a/Scripts/Helpers/CheckModernStandbySupport.ps1 +++ b/Scripts/Helpers/Test-ModernStandbySupport.ps1 @@ -1,5 +1,5 @@ # Check if this machine supports S0 Modern Standby power state. Returns true if S0 Modern Standby is supported, false otherwise. -function CheckModernStandbySupport { +function Test-ModernStandbySupport { $count = 0 try { diff --git a/Scripts/Helpers/Test-TargetUserName.ps1 b/Scripts/Helpers/Test-TargetUserName.ps1 index fb5d045..8a13494 100644 --- a/Scripts/Helpers/Test-TargetUserName.ps1 +++ b/Scripts/Helpers/Test-TargetUserName.ps1 @@ -23,7 +23,7 @@ function Test-TargetUserName { } } - if (-not (CheckIfUserExists -userName $normalizedUserName)) { + if (-not (Test-UserProfileExists -userName $normalizedUserName)) { return [PSCustomObject]@{ IsValid = $false UserName = $normalizedUserName diff --git a/Scripts/Helpers/CheckIfUserExists.ps1 b/Scripts/Helpers/Test-UserProfileExists.ps1 similarity index 85% rename from Scripts/Helpers/CheckIfUserExists.ps1 rename to Scripts/Helpers/Test-UserProfileExists.ps1 index 984319a..b3508b1 100644 --- a/Scripts/Helpers/CheckIfUserExists.ps1 +++ b/Scripts/Helpers/Test-UserProfileExists.ps1 @@ -1,4 +1,4 @@ -function CheckIfUserExists { +function Test-UserProfileExists { param ( [string]$userName ) @@ -10,7 +10,7 @@ function CheckIfUserExists { $lookupName = $userName.Trim() # Validate special characters against the local username segment (user in DOMAIN\user or user@domain). - $localUserName = GetLocalUserNameSegment -UserName $lookupName + $localUserName = Get-LocalUserNameSegment -UserName $lookupName if ($localUserName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) { return $false @@ -22,7 +22,7 @@ function CheckIfUserExists { } try { - $userContext = ResolveUserProfileContext -UserName $lookupName + $userContext = Resolve-UserProfileContext -UserName $lookupName if (-not $userContext -or [string]::IsNullOrWhiteSpace($userContext.ProfilePath)) { return $false } diff --git a/Scripts/Helpers/UserHiveHelpers.ps1 b/Scripts/Helpers/User-HiveHelpers.ps1 similarity index 96% rename from Scripts/Helpers/UserHiveHelpers.ps1 rename to Scripts/Helpers/User-HiveHelpers.ps1 index e372d7d..3066df7 100644 --- a/Scripts/Helpers/UserHiveHelpers.ps1 +++ b/Scripts/Helpers/User-HiveHelpers.ps1 @@ -31,12 +31,12 @@ function Resolve-TargetUserHiveContext { [string]$TargetUserName ) - $normalizedTargetUserName = NormalizeUserLookupValue -Value $TargetUserName + $normalizedTargetUserName = Normalize-UserLookupValue -Value $TargetUserName if ([string]::IsNullOrWhiteSpace($normalizedTargetUserName)) { throw 'Target user name for registry hive resolution is empty.' } - $userContext = ResolveUserProfileContext -UserName $normalizedTargetUserName + $userContext = Resolve-UserProfileContext -UserName $normalizedTargetUserName if (-not $userContext -or [string]::IsNullOrWhiteSpace([string]$userContext.ProfilePath)) { throw "Unable to resolve profile path for target user '$normalizedTargetUserName'." } diff --git a/Scripts/Run-Tests.ps1 b/Scripts/Run-Tests.ps1 new file mode 100644 index 0000000..fd0c5e8 --- /dev/null +++ b/Scripts/Run-Tests.ps1 @@ -0,0 +1,49 @@ +<# + .SYNOPSIS + Runs the Win11Debloat Pester test suite locally. + + .PARAMETER Bootstrap + Installs a compatible Pester 5 release for the current user when Pester is + not already available. +#> +[CmdletBinding()] +param( + [switch]$Bootstrap +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$testPath = Join-Path $repositoryRoot 'Tests' + +if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 })) { + if (-not $Bootstrap) { + Write-Error 'Pester 5 is required. Install it with: .\Scripts\Run-Tests.ps1 -Bootstrap' + exit 1 + } + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -Confirm:$false | Out-Null + } + + Install-Module -Name Pester -RequiredVersion 5.9.0 -Scope CurrentUser -Force -AllowClobber -Confirm:$false + } + catch { + Write-Error "Unable to install Pester 5: $($_.Exception.Message)" + exit 1 + } + + if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 })) { + Write-Error 'Pester 5 was not installed. Update PowerShellGet or install Pester 5 manually, then run the tests again.' + exit 1 + } +} + +Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.999.999 -Force +$result = Invoke-Pester -Path $testPath -Output Detailed -PassThru + +if ($result.Result -ne 'Passed' -or $result.FailedContainersCount -gt 0 -or $result.TotalCount -eq 0) { + exit 1 +} diff --git a/Scripts/Threading/DoEvents.ps1 b/Scripts/Threading/Invoke-DoEvents.ps1 similarity index 91% rename from Scripts/Threading/DoEvents.ps1 rename to Scripts/Threading/Invoke-DoEvents.ps1 index 26217c0..7f12d53 100644 --- a/Scripts/Threading/DoEvents.ps1 +++ b/Scripts/Threading/Invoke-DoEvents.ps1 @@ -1,6 +1,6 @@ # Processes all pending WPF window messages (input, render, etc.) to keep the UI responsive -# during long-running operations on the UI thread. Equivalent to Application.DoEvents(). -function DoEvents { +# during long-running operations on the UI thread. Equivalent to Application.Invoke-DoEvents(). +function Invoke-DoEvents { if (-not $script:GuiWindow) { return } $frame = [System.Windows.Threading.DispatcherFrame]::new() $null = [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke( diff --git a/Scripts/Threading/Invoke-NonBlocking.ps1 b/Scripts/Threading/Invoke-NonBlocking.ps1 index b2d02ac..2448dc9 100644 --- a/Scripts/Threading/Invoke-NonBlocking.ps1 +++ b/Scripts/Threading/Invoke-NonBlocking.ps1 @@ -31,7 +31,7 @@ function Invoke-NonBlocking { $ps.Stop() throw "Operation timed out after $TimeoutSeconds seconds" } - DoEvents + Invoke-DoEvents Start-Sleep -Milliseconds 16 } } diff --git a/Tests/Apply-RegistryRegFile.Tests.ps1 b/Tests/Apply-RegistryRegFile.Tests.ps1 new file mode 100644 index 0000000..a746b76 --- /dev/null +++ b/Tests/Apply-RegistryRegFile.Tests.ps1 @@ -0,0 +1,144 @@ +BeforeAll { + function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) } + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Apply-RegistryRegFile.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1') +} + +Describe 'Convert-RegOperationToValueKind' { + It 'converts to a registry-compatible value' -ForEach @( + @{ Case = 'an unsigned DWord'; ValueName = $null; ValueType = 'DWord'; ValueData = [uint32]::MaxValue; ExpectedName = ''; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::DWord; ExpectedValue = -1 } + @{ Case = 'a string value'; ValueName = 'Name'; ValueType = 'String'; ValueData = 42; ExpectedName = 'Name'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::String; ExpectedValue = '42' } + @{ Case = 'a binary value'; ValueName = 'Bytes'; ValueType = 'Binary'; ValueData = @(1, 255); ExpectedName = 'Bytes'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::Binary; ExpectedValue = [byte[]](1, 255) } + ) { + $result = Convert-RegOperationToValueKind -Operation ([PSCustomObject]@{ + KeyPath = 'HK'; ValueName = $ValueName; ValueType = $ValueType; ValueData = $ValueData + }) + + $result.Name | Should -Be $ExpectedName + $result.Kind | Should -Be $ExpectedKind + $result.Value | Should -Be $ExpectedValue + } + + It 'throws for unsupported value types' { + { Convert-RegOperationToValueKind -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\X'; ValueType = 'Hex9'; ValueData = 1 }) } | + Should -Throw "Unsupported value type 'Hex9' while applying reg operation for 'HKCU\X'" + } +} + +Describe 'Get-RegistryKeyForOperation' { + It 'rejects ' -ForEach @( + @{ Case = 'an unsupported path format'; RegistryPath = 'HKCU\Software\Example'; ExpectedError = 'Unsupported registry path:*' } + @{ Case = 'an unsupported registry hive'; RegistryPath = 'HKEY_UNKNOWN\Software\Example'; ExpectedError = "Unsupported registry hive 'HKEY_UNKNOWN'*" } + ) { + { Get-RegistryKeyForOperation -RegistryPath $RegistryPath } | Should -Throw $ExpectedError + } +} + +Describe 'Invoke-RegistryOperation' { + BeforeEach { + Mock Get-RegistryKeyForOperation { [PSCustomObject]@{ RootKey = [Microsoft.Win32.Registry]::CurrentUser; SubKeyPath = 'Software\Example'; Key = 'key' } } + Mock Remove-RegistrySubKeyTreeIfExists {} + Mock Invoke-RegistryDeleteValueOperation {} + Mock Invoke-RegistrySetValueOperation {} + } + + It 'dispatches to ' -ForEach @( + @{ Type = 'DeleteKey'; Expected = 'Remove-RegistrySubKeyTreeIfExists' } + @{ Type = 'DeleteValue'; Expected = 'Invoke-RegistryDeleteValueOperation' } + @{ Type = 'SetValue'; Expected = 'Invoke-RegistrySetValueOperation' } + ) { + $operation = [PSCustomObject]@{ OperationType = $Type; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Value' } + + Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg' + + Should -Invoke $Expected -Times 1 -Exactly + } + + It 'opens keys with create= and open= for ' -ForEach @( + @{ Type = 'DeleteKey'; Create = $false; Open = $false } + @{ Type = 'DeleteValue'; Create = $false; Open = $true } + @{ Type = 'SetValue'; Create = $true; Open = $true } + ) { + $operation = [PSCustomObject]@{ OperationType = $Type; KeyPath = 'HKEY_CURRENT_USER\Software\Example' } + + Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg' + + Should -Invoke Get-RegistryKeyForOperation -Times 1 -Exactly -ParameterFilter { + [bool]$CreateIfMissing -eq $Create -and [bool]$OpenKey -eq $Open + } + } + + It 'rejects unknown operation types with file context' { + $operation = [PSCustomObject]@{ OperationType = 'Unknown'; KeyPath = 'HKEY_CURRENT_USER\Software\Example' } + { Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg' } | + Should -Throw "Unsupported reg operation type 'Unknown' in 'feature.reg'" + } +} + +Describe 'Invoke-RegistryOperationsFromRegFile' { + BeforeEach { + $script:Params = @{} + Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKCU\One' }, [PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Two' }) } + Mock Invoke-RegistryOperation {} + Mock Write-RegistryOperationAccessDeniedWarning {} + Mock Write-Warning {} + Mock Write-Host {} + } + + It 'honors WhatIf without dispatching operations' { + $script:Params = @{ WhatIf = $true } + + Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' + + Should -Invoke Invoke-RegistryOperation -Times 0 -Exactly + } + + It 'continues after one access-denied operation and emits a summary warning' { + $script:calls = 0 + Mock Invoke-RegistryOperation { + $script:calls++ + if ($script:calls -eq 1) { throw [System.UnauthorizedAccessException]::new('denied') } + } + + { Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' } | Should -Not -Throw + Should -Invoke Write-RegistryOperationAccessDeniedWarning -Times 1 -Exactly + Should -Invoke Write-Warning -Times 1 -Exactly + } + + It 'throws when every operation is blocked by access restrictions' { + Mock Invoke-RegistryOperation { throw [System.Security.SecurityException]::new('blocked') } + + { Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' } | + Should -Throw "Registry fallback import could not apply any operations in 'feature.reg' because all 2 operation(s) were blocked*" + } +} + +Describe 'Invoke-WithLoadedRestoreHive' { + BeforeEach { Mock Invoke-WithTargetUserHive { param($TargetUserName) $TargetUserName } } + + It 'maps to ' -ForEach @( + @{ Target = 'DefaultUserProfile'; ExpectedUser = 'Default' } + @{ Target = 'User:Alice'; ExpectedUser = 'Alice' } + ) { + Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} | Should -Be $ExpectedUser + } + + It 'rejects ' -ForEach @( + @{ Case = 'an empty user target'; Target = 'User:'; ExpectedError = 'Invalid backup target format for user restore.' } + @{ Case = 'a current-user target'; Target = 'CurrentUser:Alice'; ExpectedError = "Unsupported backup target 'CurrentUser:Alice'." } + ) { + { Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} } | Should -Throw $ExpectedError + } +} + +Describe 'Restore-RegistryKeySnapshot - validation' { + It 'rejects before registry mutation' -ForEach @( + @{ Case = 'an unsupported snapshot path'; Path = 'HKCU\Software'; ExpectedError = 'Unsupported registry path in backup:*' } + @{ Case = 'a root-level snapshot path'; Path = 'HKEY_CURRENT_USER'; ExpectedError = 'Unsupported root-level registry path in backup:*' } + ) { + { Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Path = $Path; Exists = $true }) } | + Should -Throw $ExpectedError + } +} diff --git a/Tests/Confirm-UnsafeAppRemoval.Tests.ps1 b/Tests/Confirm-UnsafeAppRemoval.Tests.ps1 new file mode 100644 index 0000000..f6063fc --- /dev/null +++ b/Tests/Confirm-UnsafeAppRemoval.Tests.ps1 @@ -0,0 +1,37 @@ +BeforeAll { + function Show-MessageBox { param($Message, $Title, $Button, $Icon, $Owner) 'Yes' } + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Confirm-UnsafeAppRemoval.ps1') +} + +Describe 'Confirm-UnsafeAppRemoval' { + BeforeEach { + $global:Silent = $false + Mock Show-MessageBox { 'Yes' } + } + + AfterEach { Remove-Variable -Name Silent -Scope Global -ErrorAction SilentlyContinue } + + It 'returns true without prompting for ordinary applications' { + Confirm-UnsafeAppRemoval -SelectedApps @('Contoso.App') | Should -BeTrue + Should -Invoke Show-MessageBox -Times 0 -Exactly + } + + It 'skips all prompts in silent mode' { + $global:Silent = $true + + Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue + Should -Invoke Show-MessageBox -Times 0 -Exactly + } + + It 'stops when Microsoft Store removal is declined' { + Mock Show-MessageBox { 'No' } + + Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeFalse + Should -Invoke Show-MessageBox -Times 1 -Exactly + } + + It 'requires confirmation for both dangerous applications' { + Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue + Should -Invoke Show-MessageBox -Times 2 -Exactly + } +} diff --git a/Tests/Get-CurrentTweakState.Tests.ps1 b/Tests/Get-CurrentTweakState.Tests.ps1 new file mode 100644 index 0000000..413ad4f --- /dev/null +++ b/Tests/Get-CurrentTweakState.Tests.ps1 @@ -0,0 +1,186 @@ +BeforeAll { + function Test-StoreSearchSuggestionsDisabledForAllUsers { $false } + function Test-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) $false } + function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' } + function Get-UserName { 'Alice' } + function Test-WindowsOptionalFeatureEnabled { param($FeatureName) $false } + function Get-RegFileOperations { param($regFilePath) @() } + function Split-RegistryPath { param($path) $null } + function Get-RegistryRootKey { param($hiveName) $null } + + function New-CurrentStateRegistryKey { + param([hashtable]$Values = @{}, [hashtable]$Kinds = @{}) + $key = [PSCustomObject]@{ Values = $Values; Kinds = $Kinds; Closed = $false } + $key | Add-Member ScriptMethod GetValueNames { @($this.Kinds.Keys) } + $key | Add-Member ScriptMethod GetValueKind { param($name) $this.Kinds[$name] } + $key | Add-Member ScriptMethod GetValue { param($name, $defaultValue, $options) $this.Values[$name] } + $key | Add-Member ScriptMethod Close { $this.Closed = $true } + return $key + } + + . (Join-Path $PSScriptRoot '..\Scripts\Features\Get-CurrentTweakState.ps1') +} + +Describe 'Get-ExpectedRegistryValueKind' { + It 'maps to ' -ForEach @( + @{ ValueType = 'DWord'; Expected = [Microsoft.Win32.RegistryValueKind]::DWord } + @{ ValueType = 'QWord'; Expected = [Microsoft.Win32.RegistryValueKind]::QWord } + @{ ValueType = 'String'; Expected = [Microsoft.Win32.RegistryValueKind]::String } + @{ ValueType = 'Binary'; Expected = [Microsoft.Win32.RegistryValueKind]::Binary } + @{ ValueType = 'Hex2'; Expected = [Microsoft.Win32.RegistryValueKind]::ExpandString } + @{ ValueType = 'Hex7'; Expected = [Microsoft.Win32.RegistryValueKind]::MultiString } + ) { + $operation = [PSCustomObject]@{ ValueType = $ValueType } + + Get-ExpectedRegistryValueKind -Operation $operation | Should -Be $Expected + } + + It 'returns null for unsupported operation types' { + Get-ExpectedRegistryValueKind -Operation ([PSCustomObject]@{ ValueType = 'Hex11' }) | Should -BeNullOrEmpty + } +} + +Describe 'Test-FeatureApplied - special features' { + BeforeEach { + $script:Params = @{} + $script:Features = @{ + DisableWidgets = [PSCustomObject]@{} + DisableStoreSearchSuggestions = [PSCustomObject]@{} + EnableWindowsSandbox = [PSCustomObject]@{} + EnableWindowsSubsystemForLinux = [PSCustomObject]@{} + } + Mock Get-AppxPackage { $null } + Mock Test-StoreSearchSuggestionsDisabledForAllUsers { $true } + Mock Test-StoreSearchSuggestionsDisabled { $true } + Mock Get-StoreAppsDatabasePathForUser { 'store.db' } + Mock Get-UserName { 'Alice' } + Mock Test-WindowsOptionalFeatureEnabled { $true } + } + + It '' -ForEach @( + @{ Case = 'treats Widgets as disabled when all related packages are absent'; PresentPackage = $null; Expected = $true; ExpectedCalls = 3 } + @{ Case = 'treats Widgets as enabled when a related package is present'; PresentPackage = 'MicrosoftWindows.Client.WebExperience'; Expected = $false; ExpectedCalls = 2 } + ) { + Mock Get-AppxPackage { param($Name) if ($Name -eq $PresentPackage) { [PSCustomObject]@{ Name = $Name } } } + + Test-FeatureApplied -FeatureId 'DisableWidgets' | Should -Be $Expected + Should -Invoke Get-AppxPackage -Times $ExpectedCalls -Exactly + } + + It 'uses Store detection' -ForEach @( + @{ Case = 'all-user'; Params = @{ Sysprep = $true }; AllUsersCalls = 1; UserCalls = 0 } + @{ Case = 'user-specific'; Params = @{}; AllUsersCalls = 0; UserCalls = 1 } + ) { + $script:Params = $Params + Test-FeatureApplied -FeatureId 'DisableStoreSearchSuggestions' | Should -BeTrue + Should -Invoke Test-StoreSearchSuggestionsDisabledForAllUsers -Times $AllUsersCalls -Exactly + Should -Invoke Test-StoreSearchSuggestionsDisabled -Times $UserCalls -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' } + } + + It 'checks the expected optional feature for Windows Sandbox' { + Test-FeatureApplied -FeatureId 'EnableWindowsSandbox' | Should -BeTrue + Should -Invoke Test-WindowsOptionalFeatureEnabled -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' } + } + + It '' -ForEach @( + @{ Case = 'reports WSL applied when both optional features are enabled'; DisabledFeature = $null; Expected = $true } + @{ Case = 'reports WSL not applied when VirtualMachinePlatform is disabled'; DisabledFeature = 'VirtualMachinePlatform'; Expected = $false } + ) { + Mock Test-WindowsOptionalFeatureEnabled { param($FeatureName) $FeatureName -ne $DisabledFeature } + Test-FeatureApplied -FeatureId 'EnableWindowsSubsystemForLinux' | Should -Be $Expected + } +} + +Describe 'Test-FeatureApplied - registry preconditions' { + BeforeEach { + $script:Params = @{} + $script:RegfilesPath = $TestDrive + $script:Features = @{ + NoRegistry = [PSCustomObject]@{ RegistryKey = '' } + MissingFile = [PSCustomObject]@{ RegistryKey = 'missing.reg' } + EmptyOperations = [PSCustomObject]@{ RegistryKey = 'empty.reg' } + } + } + + It 'returns false for ' -ForEach @( + @{ Case = 'a feature without registry data'; FeatureId = 'NoRegistry'; ParserBehavior = 'None' } + @{ Case = 'a missing registry file'; FeatureId = 'MissingFile'; ParserBehavior = 'None' } + @{ Case = 'an empty registry operation set'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Empty' } + @{ Case = 'a registry operation parse failure'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Throw' } + ) { + if ($ParserBehavior -ne 'None') { + '' | Set-Content -LiteralPath (Join-Path $TestDrive 'empty.reg') + if ($ParserBehavior -eq 'Empty') { Mock Get-RegFileOperations { @() } } + if ($ParserBehavior -eq 'Throw') { Mock Get-RegFileOperations { throw 'parse failed' } } + } + + Test-FeatureApplied -FeatureId $FeatureId | Should -BeFalse + } +} + +Describe 'Test-FeatureApplied - registry state comparison' { + BeforeEach { + $script:Params = @{} + $script:RegfilesPath = $TestDrive + $script:Features = @{ RegistryFeature = [PSCustomObject]@{ RegistryKey = 'feature.reg' } } + '' | Set-Content -LiteralPath (Join-Path $TestDrive 'feature.reg') + Mock Split-RegistryPath { [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = 'Software\Example' } } + } + + It 'matches set values by kind and normalized unsigned data and closes the key' { + $key = New-CurrentStateRegistryKey -Values @{ Large = -1L } -Kinds @{ Large = [Microsoft.Win32.RegistryValueKind]::QWord } + $root = [PSCustomObject]@{ Key = $key } + $root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key } + Mock Get-RegistryRootKey { $root } + Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Large'; ValueType = 'QWord'; ValueData = [uint64]::MaxValue }) } + + Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue + $key.Closed | Should -BeTrue + } + + It 'returns false for a value-kind or data mismatch' -ForEach @( + @{ ActualKind = [Microsoft.Win32.RegistryValueKind]::String; ActualData = '1'; ExpectedType = 'DWord'; ExpectedData = 1 } + @{ ActualKind = [Microsoft.Win32.RegistryValueKind]::DWord; ActualData = 2; ExpectedType = 'DWord'; ExpectedData = 1 } + ) { + $key = New-CurrentStateRegistryKey -Values @{ Enabled = $ActualData } -Kinds @{ Enabled = $ActualKind } + $root = [PSCustomObject]@{ Key = $key } + $root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key } + Mock Get-RegistryRootKey { $root } + Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Enabled'; ValueType = $ExpectedType; ValueData = $ExpectedData }) } + + Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeFalse + $key.Closed | Should -BeTrue + } + + It 'treats missing keys and values as successful delete operations' -ForEach @( + @{ OperationType = 'DeleteKey'; ReturnKey = $false } + @{ OperationType = 'DeleteValue'; ReturnKey = $true } + ) { + $key = New-CurrentStateRegistryKey + $root = [PSCustomObject]@{ Key = $key; ReturnKey = $ReturnKey } + $root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) if ($this.ReturnKey) { $this.Key } else { $null } } + Mock Get-RegistryRootKey { $root } + Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = $OperationType; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Gone' }) } + + Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue + } +} + +Describe 'Get-CurrentGroupActiveIndex' { + BeforeEach { Mock Test-FeatureApplied { $false } } + + It 'returns the one-based index of the first fully applied option' { + $group = [PSCustomObject]@{ Values = @( + [PSCustomObject]@{ FeatureIds = @('One', 'Missing') } + [PSCustomObject]@{ FeatureIds = @('Two', 'Three') } + ) } + Mock Test-FeatureApplied { param($FeatureId) $FeatureId -in @('Two', 'Three') } + + Get-CurrentGroupActiveIndex -Group $group | Should -Be 2 + } + + It 'returns zero when no option is fully applied' { + $group = [PSCustomObject]@{ Values = @([PSCustomObject]@{ FeatureIds = @('One') }) } + Get-CurrentGroupActiveIndex -Group $group | Should -Be 0 + } +} diff --git a/Tests/Get-FriendlyRegistryBackupTarget.Tests.ps1 b/Tests/Get-FriendlyRegistryBackupTarget.Tests.ps1 new file mode 100644 index 0000000..a616fe4 --- /dev/null +++ b/Tests/Get-FriendlyRegistryBackupTarget.Tests.ps1 @@ -0,0 +1,21 @@ +BeforeAll { + $friendlyTargetScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-FriendlyRegistryBackupTarget.ps1' + . $friendlyTargetScriptPath +} + +Describe 'Get-FriendlyRegistryBackupTarget' { + It 'formats as ' -ForEach @( + @{ Case = 'a null target'; Target = $null; Expected = 'Unknown' } + @{ Case = 'the default profile'; Target = 'DefaultUserProfile'; Expected = 'Default user profile' } + @{ Case = 'the current-user marker'; Target = 'CurrentUser'; Expected = 'Current user' } + @{ Case = 'the all-users marker'; Target = 'AllUsers'; Expected = 'All users' } + @{ Case = 'a named current user'; Target = 'CurrentUser:Alice'; Expected = 'Current user (Alice)' } + @{ Case = 'a named target user'; Target = 'User:Bob'; Expected = 'User (Bob)' } + ) { + Get-FriendlyRegistryBackupTarget -Target $Target | Should -Be $Expected + } + + It 'keeps unrecognized target text visible to the user' { + Get-FriendlyRegistryBackupTarget -Target 'Custom:Value' | Should -Be 'Custom:Value' + } +} diff --git a/Tests/Get-RebootFeatureLabels.Tests.ps1 b/Tests/Get-RebootFeatureLabels.Tests.ps1 new file mode 100644 index 0000000..4acf9d3 --- /dev/null +++ b/Tests/Get-RebootFeatureLabels.Tests.ps1 @@ -0,0 +1,31 @@ +BeforeAll { + $rebootFeatureLabelsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RebootFeatureLabels.ps1' + . $rebootFeatureLabelsScriptPath +} + +Describe 'Get-RebootFeatureLabels' { + BeforeEach { + $script:Params = @{ + ApplyFeature = $true + NoRebootFeature = $true + } + $script:UndoParams = @{ + UndoFeature = $true + ApplyFeature = $true + } + $script:Features = @{ + ApplyFeature = [PSCustomObject]@{ RequiresReboot = $true; Label = 'Apply feature'; UndoLabel = 'Undo apply feature' } + UndoFeature = [PSCustomObject]@{ RequiresReboot = $true; Label = 'Undoable feature'; UndoLabel = 'Undo feature' } + NoRebootFeature = [PSCustomObject]@{ RequiresReboot = $false; Label = 'No reboot'; UndoLabel = 'Undo no reboot' } + } + } + + It 'includes reboot-required selections once and uses undo labels for undo operations' { + $result = @(Get-RebootFeatureLabels) + + $result | Should -HaveCount 2 + $result | Should -Contain 'Undo apply feature' + $result | Should -Contain 'Undo feature' + $result | Should -Not -Contain 'No reboot' + } +} diff --git a/Tests/Get-RegFileOperations.Tests.ps1 b/Tests/Get-RegFileOperations.Tests.ps1 new file mode 100644 index 0000000..c15562b --- /dev/null +++ b/Tests/Get-RegFileOperations.Tests.ps1 @@ -0,0 +1,126 @@ +BeforeAll { + $regFileOperationsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1' + . $regFileOperationsScriptPath +} + +Describe 'Convert-RegValueData' { + It 'parses as an unsigned integer' -ForEach @( + @{ ValueType = 'DWord'; ValueData = 'dword:ffffffff'; Expected = [uint32]::MaxValue } + @{ ValueType = 'QWord'; ValueData = 'qword:ffffffffffffffff'; Expected = [uint64]::MaxValue } + ) { + $result = Convert-RegValueData -valueData $ValueData + + $result.OperationType | Should -Be 'SetValue' + $result.ValueType | Should -Be $ValueType + $result.ValueData | Should -Be $Expected + } + + It 'parses registry strings and unescapes quotes and backslashes' { + $result = Convert-RegValueData -valueData '"C:\\Tools\\\"Quoted\""' + + $result.ValueType | Should -Be 'String' + $result.ValueData | Should -Be 'C:\Tools\"Quoted"' + } + + It 'parses ' -ForEach @( + @{ Case = 'binary hex data'; ValueData = 'hex:01,ff'; ExpectedType = 'Binary'; Expected = [byte[]](1, 255) } + @{ Case = 'expandable-string hex data'; ValueData = 'hex(2):25,00,54,00,45,00,4d,00,50,00,25,00,00,00'; ExpectedType = 'Hex2'; Expected = '%TEMP%' } + @{ Case = 'multi-string hex data'; ValueData = 'hex(7):6f,00,6e,00,65,00,00,00,74,00,77,00,6f,00,00,00,00,00'; ExpectedType = 'Hex7'; Expected = @('one', 'two') } + ) { + $result = Convert-RegValueData -valueData $ValueData + + $result.ValueType | Should -Be $ExpectedType + $result.ValueData | Should -Be $Expected + } + + It '' -ForEach @( + @{ Case = 'parses a registry value deletion'; ValueData = '-'; ExpectedOperation = 'DeleteValue' } + @{ Case = 'ignores unsupported data'; ValueData = 'hex(b):not-hex'; ExpectedOperation = $null } + @{ Case = 'rejects hex data with an empty byte token'; ValueData = 'hex:01,,ff'; ExpectedOperation = $null } + ) { + $result = Convert-RegValueData -valueData $ValueData + if ($ExpectedOperation) { + $result.OperationType | Should -Be $ExpectedOperation + $result.ValueType | Should -BeNullOrEmpty + } + else { + $result | Should -BeNullOrEmpty + } + } +} + +Describe 'Convert-HexStringToByteArray' { + It 'rejects empty and malformed hex tokens' -ForEach @('01,,ff', ',01', '01,', '01,gg') { + Convert-HexStringToByteArray -hexValue $_ | Should -BeNullOrEmpty + } + + It 'converts byte arrays to registry strings and multi-strings' { + Convert-RegistryByteArrayToString -byteData ([byte[]](65, 0, 0, 0)) | Should -Be 'A' + Convert-RegistryByteArrayToMultiString -byteData ([byte[]](65, 0, 0, 0, 66, 0, 0, 0, 0, 0)) | Should -Be @('A', 'B') + } +} + +Describe 'Get-RegFileOperations' { + It 'warns when it skips malformed registry value data' { + $regFilePath = Join-Path $TestDrive 'malformed.reg' + @' +Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Example] +"Broken"=hex:01,,ff +'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8 + + Mock Write-Warning {} + @(Get-RegFileOperations -regFilePath $regFilePath) | Should -BeNullOrEmpty + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "Skipping unsupported or malformed registry value 'Broken'*" } + } + + It 'parses key deletion, value deletion, and continued hex values' { + $regFilePath = Join-Path $TestDrive 'settings.reg' + @' +Windows Registry Editor Version 5.00 + +[-HKEY_CURRENT_USER\Software\Example\Removed] + +[HKEY_CURRENT_USER\Software\Example] +"Enabled"=dword:00000001 +@=- +"Bytes"=hex:01,\ + 02,03 +'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8 + + $operations = @(Get-RegFileOperations -regFilePath $regFilePath) + + $operations.Count | Should -Be 4 + $operations[0].OperationType | Should -Be 'DeleteKey' + $operations[1].ValueName | Should -Be 'Enabled' + $operations[1].ValueData | Should -Be 1 + $operations[2].OperationType | Should -Be 'DeleteValue' + $operations[2].ValueName | Should -Be '' + $operations[3].ValueData | Should -Be ([byte[]](1, 2, 3)) + } + + It 'handles comments, default-value assignment, malformed lines, and deleted-key contents' { + $regFilePath = Join-Path $TestDrive 'edge-cases.reg' + @' +Windows Registry Editor Version 5.00 +; comment + +[HKEY_CURRENT_USER\Software\Example] +@="default" +malformed line + +[-HKEY_CURRENT_USER\Software\Removed] +"Ignored"="value" +'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8 + + $operations = @(Get-RegFileOperations -regFilePath $regFilePath) + + $operations | Should -HaveCount 2 + $operations[0].OperationType | Should -Be 'SetValue' + $operations[0].ValueName | Should -Be '' + $operations[0].ValueData | Should -Be 'default' + $operations[1].OperationType | Should -Be 'DeleteKey' + } + +} diff --git a/Tests/Get-TargetUserForAppRemoval.Tests.ps1 b/Tests/Get-TargetUserForAppRemoval.Tests.ps1 new file mode 100644 index 0000000..02177ea --- /dev/null +++ b/Tests/Get-TargetUserForAppRemoval.Tests.ps1 @@ -0,0 +1,13 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-TargetUserForAppRemoval.ps1') +} + +Describe 'Get-TargetUserForAppRemoval' { + It '' -ForEach @( + @{ Case = 'defaults to all users'; Params = @{}; Expected = 'AllUsers' } + @{ Case = 'returns an explicit target unchanged'; Params = @{ AppRemovalTarget = 'Alice' }; Expected = 'Alice' } + ) { + $script:Params = $Params + Get-TargetUserForAppRemoval | Should -Be $Expected + } +} diff --git a/Tests/Import-ConfigToParams.Tests.ps1 b/Tests/Import-ConfigToParams.Tests.ps1 new file mode 100644 index 0000000..15a973f --- /dev/null +++ b/Tests/Import-ConfigToParams.Tests.ps1 @@ -0,0 +1,38 @@ +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') + $script:ConfigFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.WithSettings.json' +} + +Describe 'Import-ConfigToParams' { + BeforeEach { + $script:Params = @{} + $script:ModernStandbySupported = $false + $script:Features = @{} + foreach ($featureId in @( + 'DisableSettings365Ads', 'DisableSnapAssist', 'EnableDarkMode', 'ShowSearchBoxTb', + 'DisableTelemetry', 'DisableWidgets', 'DisableLockscreenTips', 'DisableSnapLayouts', + 'DisableAISvcAutoStart', 'DisableMouseAcceleration', 'DisableCopilot', 'DisableRecall' + )) { + $script:Features[$featureId] = [PSCustomObject]@{ FeatureId = $featureId; MinVersion = $null; MaxVersion = $null } + } + } + + It 'loads the selected tweaks and deployment settings from an exported config file' { + $result = Import-ConfigToParams -ConfigPath $script:ConfigFixturePath -CurrentBuild 22631 + + $result | Should -Be (Resolve-Path -LiteralPath $script:ConfigFixturePath).Path + foreach ($featureId in @( + 'DisableSettings365Ads', 'DisableSnapAssist', 'EnableDarkMode', 'ShowSearchBoxTb', + 'DisableTelemetry', 'DisableWidgets', 'DisableLockscreenTips', 'DisableSnapLayouts', + 'DisableAISvcAutoStart', 'DisableMouseAcceleration', 'DisableCopilot', 'DisableRecall' + )) { + $script:Params[$featureId] | Should -BeTrue + } + $script:Params['CreateRestorePoint'] | Should -BeTrue + $script:Params['NoRestartExplorer'] | Should -BeTrue + $script:Params.ContainsKey('User') | Should -BeFalse + $script:Params.ContainsKey('AppRemovalTarget') | Should -BeFalse + } +} diff --git a/Tests/Import-JsonFile.Tests.ps1 b/Tests/Import-JsonFile.Tests.ps1 new file mode 100644 index 0000000..9af3358 --- /dev/null +++ b/Tests/Import-JsonFile.Tests.ps1 @@ -0,0 +1,45 @@ +BeforeAll { + $importJsonFileScriptPath = Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1' + $script:FixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading' + . $importJsonFileScriptPath +} + +Describe 'Import-JsonFile' { + BeforeEach { + Mock Write-Error {} + } + + It 'loads valid JSON with the expected version' { + $result = Import-JsonFile -filePath (Join-Path $script:FixturePath 'Config.Valid.json') -expectedVersion '1.0' + + $result.Name | Should -Be 'Example configuration' + } + + It 'parses the settings fixture' -ForEach @( + @{ Kind = 'default'; FileName = 'DefaultSettings.Valid.json' } + @{ Kind = 'last-used'; FileName = 'LastUsedSettings.Valid.json' } + ) { + $result = Import-JsonFile -filePath (Join-Path $script:FixturePath $FileName) -expectedVersion '1.0' + + $result.Settings | Should -Not -BeNullOrEmpty + $result.Settings[0].Name | Should -Be 'Supported' + } + + It 'returns null and reports an error for ' -ForEach @( + @{ Case = 'a version mismatch'; FileName = 'Config.VersionMismatch.json'; ExpectedVersion = '1.0'; Optional = $false; Error = 'version mismatch' } + @{ Case = 'invalid JSON'; FileName = 'Config.Invalid.json'; ExpectedVersion = $null; Optional = $false; Error = 'Failed to parse JSON file' } + ) { + $filePath = Join-Path $script:FixturePath $FileName + $result = Import-JsonFile -filePath $filePath -expectedVersion $ExpectedVersion -optionalFile:$Optional + + $result | Should -BeNullOrEmpty + Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { $Message -match $Error } + } + + It 'returns null without an error for an optional missing last-used settings file' { + $result = Import-JsonFile -filePath (Join-Path $TestDrive 'LastUsedSettings.json') -expectedVersion '1.0' -optionalFile + + $result | Should -BeNullOrEmpty + Should -Invoke Write-Error -Times 0 -Exactly + } +} diff --git a/Tests/Import-RegistryFile.Tests.ps1 b/Tests/Import-RegistryFile.Tests.ps1 new file mode 100644 index 0000000..3ff3de2 --- /dev/null +++ b/Tests/Import-RegistryFile.Tests.ps1 @@ -0,0 +1,67 @@ +BeforeAll { + function Get-RegistryFilePathForFeature { param($RegistryKey) $RegistryKey } + function Invoke-RegistryOperationsFromRegFile { param($RegFilePath) } + function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject, [switch]$PassHiveContext) } + function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) } + . (Join-Path $PSScriptRoot '..\Scripts\Features\Import-RegistryFile.ps1') +} + +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-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' { + 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 + 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' + Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly -ParameterFilter { $RegFilePath -eq $script:regPath } + Should -Invoke Invoke-NonBlocking -Times 0 -Exactly + } + + It 'uses the PowerShell writer for an already-loaded target-user hive' { + $script:Params = @{ User = 'Alice' } + Mock Invoke-WithTargetUserHive { + param($TargetUserName, $ScriptBlock, $ArgumentObject, $PassHiveContext) + & $ScriptBlock $ArgumentObject ([PSCustomObject]@{ WasAlreadyLoaded = $true }) + } + + Import-RegistryFile -message 'Apply' -path 'feature.reg' + + Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' -and $PassHiveContext } + Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly + Should -Invoke Invoke-NonBlocking -Times 0 -Exactly + } + + 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' + + 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 'does not invoke the fallback after a successful reg import' { + Import-RegistryFile -message 'Apply' -path 'feature.reg' + 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 new file mode 100644 index 0000000..16019a7 --- /dev/null +++ b/Tests/Invoke-Changes.Tests.ps1 @@ -0,0 +1,358 @@ +BeforeAll { + function Import-RegistryFile { param($Message, $path) } + function Remove-SelectedApps { param($Apps) } + function Disable-TelemetryScheduledTasks {} + function Enable-TelemetryScheduledTasks {} + function Generate-AppsList { @() } + function Get-FriendlyTargetUserName { 'current user' } + function EnableStoreSearchSuggestionsForAllUsers {} + function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) } + function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' } + function Get-UserName { 'Alice' } + function Disable-WindowsFeature { param($FeatureName) } + function New-RegistrySettingsBackup { param($ActionableKeys, $ExtraFeatures) } + function Invoke-SystemRestorePoint {} + function Enable-WindowsFeature { param($FeatureName) } + function Get-StartMenuBinPathForUser { param($UserName) 'start.bin' } + function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) } + function Replace-StartMenuForAllUsers { param($startMenuTemplate) } + function DisableStoreSearchSuggestionsForAllUsers {} + function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) } + + . (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1') +} + +Describe 'Resolve-UndoRegFilePath' { + BeforeEach { + $script:RegfilesPath = $TestDrive + New-Item -ItemType Directory -Path (Join-Path $TestDrive 'Undo') -Force | Out-Null + } + + It '' -ForEach @( + @{ Case = 'prefers an existing file in Undo'; FileName = 'feature.reg'; CreateUndoFile = $true; Expected = 'Undo\feature.reg' } + @{ Case = 'falls back to the original file name'; FileName = 'missing.reg'; CreateUndoFile = $false; Expected = 'missing.reg' } + ) { + if ($CreateUndoFile) { + '' | Set-Content -LiteralPath (Join-Path $TestDrive "Undo\$FileName") + } + + Resolve-UndoRegFilePath -FileName $FileName | Should -Be $Expected + } +} + +Describe 'Invoke-FeatureApply' { + BeforeEach { + $script:Params = @{} + $script:Features = @{ + RegistryFeature = [PSCustomObject]@{ ApplyText = 'Apply registry feature'; RegistryKey = 'feature.reg' } + DisableTelemetry = [PSCustomObject]@{ ApplyText = 'Disable telemetry'; RegistryKey = 'telemetry.reg' } + DisableBing = [PSCustomObject]@{ ApplyText = 'Disable Bing'; RegistryKey = 'bing.reg' } + DisableCopilot = [PSCustomObject]@{ ApplyText = 'Disable Copilot'; RegistryKey = 'copilot.reg' } + RemoveApps = [PSCustomObject]@{ ApplyText = 'Remove apps'; RegistryKey = '' } + RemoveGamingApps = [PSCustomObject]@{ ApplyText = 'Remove gaming'; RegistryKey = '' } + RemoveHPApps = [PSCustomObject]@{ ApplyText = 'Remove HP'; RegistryKey = '' } + DisableWidgets = [PSCustomObject]@{ ApplyText = 'Disable widgets'; RegistryKey = '' } + EnableWindowsSandbox = [PSCustomObject]@{ ApplyText = 'Enable Sandbox'; RegistryKey = '' } + EnableWindowsSubsystemForLinux = [PSCustomObject]@{ ApplyText = 'Enable WSL'; RegistryKey = '' } + ClearStart = [PSCustomObject]@{ ApplyText = 'Clear Start'; RegistryKey = '' } + ReplaceStart = [PSCustomObject]@{ ApplyText = 'Replace Start'; RegistryKey = '' } + ClearStartAllUsers = [PSCustomObject]@{ ApplyText = 'Clear Start all users'; RegistryKey = '' } + ReplaceStartAllUsers = [PSCustomObject]@{ ApplyText = 'Replace Start all users'; RegistryKey = '' } + DisableStoreSearchSuggestions = [PSCustomObject]@{ ApplyText = 'Disable Store suggestions'; RegistryKey = '' } + } + Mock Import-RegistryFile {} + Mock Remove-SelectedApps {} + Mock Disable-TelemetryScheduledTasks {} + Mock Generate-AppsList { @() } + Mock Get-FriendlyTargetUserName { 'current user' } + Mock Enable-WindowsFeature {} + Mock Get-StartMenuBinPathForUser { 'start.bin' } + Mock Get-UserName { 'Alice' } + Mock Replace-StartMenu {} + Mock Replace-StartMenuForAllUsers {} + Mock DisableStoreSearchSuggestionsForAllUsers {} + Mock Set-StoreSearchSuggestionsDisabled {} + Mock Get-StoreAppsDatabasePathForUser { 'store.db' } + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Write-Host {} + } + + It 'imports a registry-backed feature' { + Invoke-FeatureApply -FeatureId 'RegistryFeature' + + Should -Invoke Import-RegistryFile -Times 1 -Exactly -ParameterFilter { $path -eq 'feature.reg' } + Should -Invoke Remove-SelectedApps -Times 0 -Exactly + } + + It 'runs the telemetry side effect after importing its registry file' { + Invoke-FeatureApply -FeatureId 'DisableTelemetry' + + Should -Invoke Import-RegistryFile -Times 1 -Exactly + Should -Invoke Disable-TelemetryScheduledTasks -Times 1 -Exactly + } + + It 'does not call app removal when the generated selection is empty' { + Invoke-FeatureApply -FeatureId 'RemoveApps' + + Should -Invoke Generate-AppsList -Times 1 -Exactly + Should -Invoke Remove-SelectedApps -Times 0 -Exactly + } + + It 'passes a non-empty generated selection to app removal' { + Mock Generate-AppsList { @('One.App', 'Two.App') } + + Invoke-FeatureApply -FeatureId 'RemoveApps' + + Should -Invoke Remove-SelectedApps -Times 1 -Exactly -ParameterFilter { @($Apps).Count -eq 2 } + } + + It 'runs registry-backed companion app removal for ' -ForEach @( + @{ FeatureId = 'DisableBing'; ExpectedApps = @('Microsoft.BingSearch') } + @{ FeatureId = 'DisableCopilot'; ExpectedApps = @('Microsoft.Copilot', 'XP9CXNGPPJ97XX') } + ) { + Invoke-FeatureApply -FeatureId $FeatureId + Should -Invoke Import-RegistryFile -Times 1 -Exactly + Should -Invoke Remove-SelectedApps -Times 1 -Exactly -ParameterFilter { @($Apps) -join ',' -eq $ExpectedApps -join ',' } + } + + It 'uses the expected static app list for ' -ForEach @( + @{ FeatureId = 'RemoveGamingApps'; MinimumCount = 3; ExpectedApp = 'Microsoft.GamingApp' } + @{ FeatureId = 'RemoveHPApps'; MinimumCount = 10; ExpectedApp = 'AD2F1837.myHP' } + @{ FeatureId = 'DisableWidgets'; MinimumCount = 3; ExpectedApp = 'MicrosoftWindows.Client.WebExperience' } + ) { + Invoke-FeatureApply -FeatureId $FeatureId + Should -Invoke Remove-SelectedApps -Times 1 -Exactly -ParameterFilter { @($Apps).Count -ge $MinimumCount -and $Apps -contains $ExpectedApp } + } + + It 'does not stop widget processes in WhatIf mode' { + $script:Params = @{ WhatIf = $true } + Invoke-FeatureApply -FeatureId 'DisableWidgets' + Should -Invoke Get-Process -Times 0 -Exactly + Should -Invoke Stop-Process -Times 0 -Exactly + } + + It 'enables the expected optional Windows features' { + Invoke-FeatureApply -FeatureId 'EnableWindowsSandbox' + Invoke-FeatureApply -FeatureId 'EnableWindowsSubsystemForLinux' + Should -Invoke Enable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' } + Should -Invoke Enable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'VirtualMachinePlatform' } + Should -Invoke Enable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Microsoft-Windows-Subsystem-Linux' } + } + + It 'applies current-user Start layouts only when a target path resolves' { + $script:Params = @{ ReplaceStart = 'template.bin' } + Invoke-FeatureApply -FeatureId 'ClearStart' + Invoke-FeatureApply -FeatureId 'ReplaceStart' + Should -Invoke Replace-StartMenu -Times 1 -Exactly -ParameterFilter { $startMenuBinFile -eq 'start.bin' -and -not $startMenuTemplate } + Should -Invoke Replace-StartMenu -Times 1 -Exactly -ParameterFilter { $startMenuBinFile -eq 'start.bin' -and $startMenuTemplate -eq 'template.bin' } + + Mock Get-StartMenuBinPathForUser { $null } + Invoke-FeatureApply -FeatureId 'ClearStart' + Should -Invoke Replace-StartMenu -Times 2 -Exactly + } + + It 'applies all-user Start templates correctly' { + $script:Params = @{ ReplaceStartAllUsers = 'all-users.bin' } + Invoke-FeatureApply -FeatureId 'ClearStartAllUsers' + Invoke-FeatureApply -FeatureId 'ReplaceStartAllUsers' + Should -Invoke Replace-StartMenuForAllUsers -Times 2 -Exactly + Should -Invoke Replace-StartMenuForAllUsers -Times 1 -Exactly -ParameterFilter { $null -eq $startMenuTemplate } + Should -Invoke Replace-StartMenuForAllUsers -Times 1 -Exactly -ParameterFilter { $startMenuTemplate -eq 'all-users.bin' } + } + + It 'applies Store-search scope to all users during Sysprep' { + $script:Params = @{ Sysprep = $true } + Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions' + + Should -Invoke DisableStoreSearchSuggestionsForAllUsers -Times 1 -Exactly + Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly + } +} + +Describe 'Invoke-ApplyFeatures' { + BeforeEach { + $script:CancelRequested = $false + $script:Features = @{ + One = [PSCustomObject]@{ ApplyText = 'Apply one' } + Two = [PSCustomObject]@{ ApplyText = 'Apply two' } + } + $script:progressCalls = New-Object System.Collections.Generic.List[object] + $script:ApplyProgressCallback = { param($Step, $Total, $Text) $script:progressCalls.Add(@($Step, $Total, $Text)) } + Mock Invoke-FeatureApply {} + } + + It 'reports progress and applies each feature in order' { + Invoke-ApplyFeatures -FeatureIds @('One', 'Two') -StartStep 3 -TotalSteps 5 + + Should -Invoke Invoke-FeatureApply -Times 2 -Exactly + $script:progressCalls | Should -HaveCount 2 + $script:progressCalls[0] | Should -Be @(3, 5, 'Apply one') + $script:progressCalls[1] | Should -Be @(4, 5, 'Apply two') + } + + It 'stops before processing work when cancellation was requested' { + $script:CancelRequested = $true + + Invoke-ApplyFeatures -FeatureIds @('One', 'Two') -StartStep 1 -TotalSteps 2 + + Should -Invoke Invoke-FeatureApply -Times 0 -Exactly + $script:progressCalls | Should -HaveCount 0 + } +} + +Describe 'Invoke-UndoFeatures' { + BeforeEach { + $script:CancelRequested = $false + $script:ApplyProgressCallback = $null + $script:Features = @{ + RegistryUndo = [PSCustomObject]@{ UndoLabel = 'Undo registry'; ApplyUndoText = 'Restoring registry'; RegistryUndoKey = 'undo.reg' } + CustomUndo = [PSCustomObject]@{ UndoLabel = 'Undo custom'; ApplyUndoText = ''; RegistryUndoKey = '' } + } + Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" } + Mock Import-RegistryFile {} + Mock Invoke-FeatureUndo {} + } + + It 'imports registry undo data and still invokes custom undo side effects' { + 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' } + } + + It 'handles unknown and custom features without attempting a registry import' { + Invoke-UndoFeatures -FeatureIds @('CustomUndo', 'Unknown') -StartStep 1 -TotalSteps 2 + + Should -Invoke Import-RegistryFile -Times 0 -Exactly + Should -Invoke Invoke-FeatureUndo -Times 2 -Exactly + } +} + +Describe 'Invoke-FeatureUndo' { + BeforeEach { + $script:Params = @{} + $script:Features = @{ + EnableWindowsSandbox = [PSCustomObject]@{ ApplyUndoText = 'Disable Sandbox' } + EnableWindowsSubsystemForLinux = [PSCustomObject]@{ ApplyUndoText = 'Disable WSL' } + DisableTelemetry = [PSCustomObject]@{} + DisableStoreSearchSuggestions = [PSCustomObject]@{} + } + Mock EnableStoreSearchSuggestionsForAllUsers {} + Mock Set-StoreSearchSuggestionsEnabled {} + Mock Get-StoreAppsDatabasePathForUser { 'store.db' } + Mock Get-UserName { 'Alice' } + Mock Disable-WindowsFeature {} + Mock Enable-TelemetryScheduledTasks {} + Mock Write-Host {} + } + + It 'undoes Store search suggestions for the selected target scope' -ForEach @( + @{ Params = @{ Sysprep = $true }; AllUsers = 1; CurrentUser = 0 } + @{ Params = @{}; AllUsers = 0; CurrentUser = 1 } + ) { + $script:Params = $Params + Invoke-FeatureUndo -FeatureId 'DisableStoreSearchSuggestions' + Should -Invoke EnableStoreSearchSuggestionsForAllUsers -Times $AllUsers -Exactly + Should -Invoke Set-StoreSearchSuggestionsEnabled -Times $CurrentUser -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' } + } + + 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) } + Invoke-FeatureUndo -FeatureId 'EnableWindowsSubsystemForLinux' + $script:disabledFeatures | Should -Be @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform') + } + + It 'disables Sandbox and re-enables telemetry tasks' { + 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 + } +} + +Describe 'Invoke-AllChanges' { + BeforeEach { + $script:Params = @{ RegistryApply = $true; CustomApply = $true } + $script:UndoParams = @{ RegistryUndo = $true } + $script:ControlParams = @('WhatIf', 'Silent', 'User', 'Sysprep') + $script:Features = @{ + RegistryApply = [PSCustomObject]@{ RegistryKey = 'apply.reg' } + CustomApply = [PSCustomObject]@{ RegistryKey = '' } + RegistryUndo = [PSCustomObject]@{ RegistryUndoKey = 'undo.reg' } + } + $script:CancelRequested = $false + $script:ApplyProgressCallback = $null + Mock Test-RunningAsSystem { $false } + Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" } + Mock New-RegistrySettingsBackup {} + Mock Invoke-SystemRestorePoint {} + Mock Invoke-ApplyFeatures {} + Mock Invoke-UndoFeatures {} + Mock Write-Host {} + } + + It 'backs up registry work before applying and undoing selected features' { + $script:order = [System.Collections.Generic.List[string]]::new() + Mock New-RegistrySettingsBackup { $script:order.Add('backup') } + Mock Invoke-ApplyFeatures { $script:order.Add('apply') } + Mock Invoke-UndoFeatures { $script:order.Add('undo') } + + Invoke-AllChanges + + $script:order | Should -Be @('backup', 'apply', 'undo') + Should -Invoke New-RegistrySettingsBackup -Times 1 -Exactly -ParameterFilter { + $ActionableKeys -contains 'RegistryApply' -and @($ExtraFeatures).Count -eq 1 -and $ExtraFeatures[0].RegistryKey -eq 'Undo\undo.reg' + } + } + + It 'prevents every mutation when registry backup creation fails' { + Mock New-RegistrySettingsBackup { throw 'disk full' } + { Invoke-AllChanges } | Should -Throw 'Registry backup failed before applying changes.*disk full' + Should -Invoke Invoke-ApplyFeatures -Times 0 -Exactly + Should -Invoke Invoke-UndoFeatures -Times 0 -Exactly + Should -Invoke Invoke-SystemRestorePoint -Times 0 -Exactly + } + + It 'does not run when cancellation was already requested' { + $script:CancelRequested = $true + Invoke-AllChanges + Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly + Should -Invoke Invoke-ApplyFeatures -Times 0 -Exactly + Should -Invoke Invoke-UndoFeatures -Times 0 -Exactly + } + + It 'does not enter the undo phase when cancellation occurs during apply' { + Mock Invoke-ApplyFeatures { $script:CancelRequested = $true } + Invoke-AllChanges + Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly + Should -Invoke Invoke-UndoFeatures -Times 0 -Exactly + } + + It 'rejects SYSTEM execution without an explicit user target' { + Mock Test-RunningAsSystem { $true } + { Invoke-AllChanges } | Should -Throw "Win11Debloat is running as the SYSTEM account*" + Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly + } + + It 'allows SYSTEM execution with an explicit target and filters control parameters from features' { + Mock Test-RunningAsSystem { $true } + $script:Params = @{ User = 'Alice'; WhatIf = $true; CustomApply = $true } + Invoke-AllChanges + Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly + Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly -ParameterFilter { + @($FeatureIds).Count -eq 1 -and $FeatureIds[0] -eq 'CustomApply' + } + } + + It 'sequences an optional restore point before feature application' { + $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-ApplyFeatures { $script:order.Add('apply') } + Invoke-AllChanges + $script:order | Should -Be @('restore-point', 'apply') + } +} diff --git a/Tests/MainWindow-AppSelection.Tests.ps1 b/Tests/MainWindow-AppSelection.Tests.ps1 new file mode 100644 index 0000000..1e43196 --- /dev/null +++ b/Tests/MainWindow-AppSelection.Tests.ps1 @@ -0,0 +1,156 @@ +BeforeAll { + Add-Type -AssemblyName PresentationFramework + . (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-AppSelection.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-Deployment.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-Navigation.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-TweaksBuilder.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\GUI\Set-WindowThemeResources.ps1') + + function New-TestWindow { + $window = New-Object System.Windows.Window + [System.Windows.NameScope]::SetNameScope($window, [System.Windows.NameScope]::new()) + $window.Resources['ProgressActiveColor'] = [System.Windows.Media.Brushes]::Green + $window.Resources['ProgressInactiveColor'] = [System.Windows.Media.Brushes]::Gray + return $window + } +} + +Describe 'ConvertTo-NormalizedCheckboxState' { + It 'normalizes indeterminate tri-state checkboxes to checked' { + $checkBox = New-Object System.Windows.Controls.CheckBox + $checkBox.IsThreeState = $true + $checkBox.IsChecked = $null + Add-Member -InputObject $checkBox -MemberType NoteProperty -Name 'WasIndeterminateBeforeClick' -Value $true + + ConvertTo-NormalizedCheckboxState -CheckBox $checkBox | Should -BeTrue + $checkBox.IsChecked | Should -BeTrue + $checkBox.WasIndeterminateBeforeClick | Should -BeFalse + } + + It 'sets tri-state preset checkboxes for empty, partial, and complete selections' { + $checkBox = New-Object System.Windows.Controls.CheckBox + $checkBox.IsThreeState = $true + + Set-TriStatePresetCheckBoxState -CheckBox $checkBox -Total 0 -Selected 0 + $checkBox.IsEnabled | Should -BeFalse + Set-TriStatePresetCheckBoxState -CheckBox $checkBox -Total 3 -Selected 1 + $checkBox.IsChecked.HasValue | Should -BeFalse + Set-TriStatePresetCheckBoxState -CheckBox $checkBox -Total 3 -Selected 3 + $checkBox.IsChecked | Should -BeTrue + } + + It 'rebuilds highlighted-search indexes and sorts app controls' { + $panel = New-Object System.Windows.Controls.StackPanel + $first = New-Object System.Windows.Controls.CheckBox + $first.Content = 'Zeta' + $first | Add-Member NoteProperty AppName 'Zeta' + $first | Add-Member NoteProperty AppDescription 'Last' + $first | Add-Member NoteProperty AppIdDisplay 'Zeta.App' + $first.Background = [System.Windows.Media.Brushes]::Yellow + $second = New-Object System.Windows.Controls.CheckBox + $second.Content = 'Alpha' + $second | Add-Member NoteProperty AppName 'Alpha' + $second | Add-Member NoteProperty AppDescription 'First' + $second | Add-Member NoteProperty AppIdDisplay 'Alpha.App' + $second.Background = [System.Windows.Media.Brushes]::Yellow + $null = $panel.Children.Add($first) + $null = $panel.Children.Add($second) + $script:AppSearchMatches = @() + $script:AppSearchMatchIndex = -1 + $script:SortColumn = 'Name' + $script:SortAscending = $true + $nameArrow = New-Object System.Windows.Controls.TextBlock + $descriptionArrow = New-Object System.Windows.Controls.TextBlock + $appIdArrow = New-Object System.Windows.Controls.TextBlock + $nameArrow.RenderTransform = New-Object System.Windows.Media.RotateTransform + $descriptionArrow.RenderTransform = New-Object System.Windows.Media.RotateTransform + $appIdArrow.RenderTransform = New-Object System.Windows.Media.RotateTransform + + Update-AppsPanelRebuildSearchIndex -AppsPanel $panel -ActiveMatch $second + Update-AppsPanelSort -AppsPanel $panel -SortArrowName $nameArrow -SortArrowDescription $descriptionArrow -SortArrowAppId $appIdArrow + + $panel.Children[0].AppName | Should -Be 'Alpha' + $script:AppSearchMatches.Count | Should -Be 2 + $script:AppSearchMatches[$script:AppSearchMatchIndex] | Should -Be $second + $nameArrow.Opacity | Should -Be 1 + } + + It 'finds matching combo-box content case-insensitively' { + $comboBox = New-Object System.Windows.Controls.ComboBox + $null = $comboBox.Items.Add('Disable telemetry') + $null = $comboBox.Items.Add((New-Object System.Windows.Controls.ComboBoxItem -Property @{ Content = 'Enable widgets' })) + + Test-ComboBoxContainsMatch -ComboBox $comboBox -SearchText 'telemetry' | Should -BeTrue + Test-ComboBoxContainsMatch -ComboBox $comboBox -SearchText 'missing' | Should -BeFalse + } +} + +Describe 'Get-PendingTweakActions' { + BeforeEach { + $script:FeatureLabelLookup = @{ DisableTelemetry = 'Disable telemetry'; EnableWidgets = 'Enable widgets' } + $script:UndoFeatureLabelLookup = @{ DisableTelemetry = 'Enable telemetry' } + } + + It 'builds pending apply and undo actions from mapped checkbox state' { + $window = New-TestWindow + $applyCheckBox = New-Object System.Windows.Controls.CheckBox + $applyCheckBox.IsChecked = $true + $applyCheckBox | Add-Member NoteProperty InitialState $false + $undoCheckBox = New-Object System.Windows.Controls.CheckBox + $undoCheckBox.IsChecked = $false + $undoCheckBox | Add-Member NoteProperty InitialState $true + $window.RegisterName('ApplyTelemetry', $applyCheckBox) + $window.RegisterName('UndoTelemetry', $undoCheckBox) + $script:UiControlMappings = @{ + ApplyTelemetry = [PSCustomObject]@{ Type = 'feature'; FeatureId = 'DisableTelemetry' } + UndoTelemetry = [PSCustomObject]@{ Type = 'feature'; FeatureId = 'DisableTelemetry' } + } + + $actions = @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$false | Sort-Object Action) + + $actions.Action | Should -Be @('Apply', 'Undo') + $actions.Label | Should -Be @('Disable telemetry', 'Enable telemetry') + } + + It 'builds a category preset map for visible mapped controls' { + $window = New-TestWindow + $checkBox = New-Object System.Windows.Controls.CheckBox + $checkBox.Visibility = 'Visible' + $window.RegisterName('DisableTelemetryCheckBox', $checkBox) + $script:UiControlMappings = @{ DisableTelemetryCheckBox = [PSCustomObject]@{ Category = 'Privacy'; Type = 'feature'; FeatureId = 'DisableTelemetry' } } + + $map = Get-CategoryTweakPresetMap -Window $window -Category 'Privacy' + + $map['DisableTelemetryCheckBox'].ControlType | Should -Be 'CheckBox' + $map['DisableTelemetryCheckBox'].DesiredValue | Should -BeTrue + } + + It 'updates navigation visibility and progress indicators for an interior tab' { + $window = New-TestWindow + $tabControl = New-Object System.Windows.Controls.TabControl + 0..3 | ForEach-Object { $null = $tabControl.Items.Add((New-Object System.Windows.Controls.TabItem)) } + $tabControl.SelectedIndex = 2 + foreach ($name in @('PreviousBtn', 'NextBtn', 'BottomNavGrid')) { $window.RegisterName($name, (New-Object System.Windows.Controls.Border)) } + foreach ($name in @('ProgressIndicator1', 'ProgressIndicator2', 'ProgressIndicator3')) { $window.RegisterName($name, (New-Object System.Windows.Shapes.Rectangle)) } + + Update-NavigationButtons -Window $window -TabControl $tabControl + + $window.FindName('PreviousBtn').Visibility | Should -Be 'Visible' + $window.FindName('NextBtn').Visibility | Should -Be 'Visible' + $window.FindName('ProgressIndicator1').Fill | Should -Be $window.Resources['ProgressActiveColor'] + $window.FindName('ProgressIndicator3').Fill | Should -Be $window.Resources['ProgressInactiveColor'] + } +} + +Describe 'Set-WindowThemeResources' { + It 'populates themed resources and selects the Windows 11 icon font' { + $window = New-TestWindow + $script:SharedStylesSchema = $null + Mock Get-ItemPropertyValue { 22631 } + + Set-WindowThemeResources -window $window -usesDarkMode:$true + + $window.Resources['AppBgColor'] | Should -Not -BeNullOrEmpty + $window.Resources['AppIconFontFamily'].Source | Should -Be 'Segoe Fluent Icons' + } +} diff --git a/Tests/MainWindow-Deployment.Tests.ps1 b/Tests/MainWindow-Deployment.Tests.ps1 new file mode 100644 index 0000000..2ec767f --- /dev/null +++ b/Tests/MainWindow-Deployment.Tests.ps1 @@ -0,0 +1,33 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-Deployment.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\GUI\Get-SystemUsesDarkMode.ps1') +} + +Describe 'Get-UndoFeatureLabel' { + BeforeEach { + $script:UndoFeatureLabelLookup = @{ DisableTelemetry = 'Enable telemetry' } + $script:FeatureLabelLookup = @{ DisableTelemetry = 'Disable telemetry'; DisableWidgets = 'Disable widgets' } + } + + It 'prefers undo labels and falls back to feature labels' { + Get-UndoFeatureLabel -FeatureId 'DisableTelemetry' | Should -Be 'Enable telemetry' + Get-UndoFeatureLabel -FeatureId 'DisableWidgets' | Should -Be 'Disable widgets' + } + + It 'reads selected app IDs from string or array settings and removes blanks' { + $stringSettings = [PSCustomObject]@{ Settings = @([PSCustomObject]@{ Name = 'Apps'; Value = ' One.App, ,Two.App ' }) } + $arraySettings = [PSCustomObject]@{ Settings = @([PSCustomObject]@{ Name = 'Apps'; Value = @(' One.App ', '', 'Two.App') }) } + + Get-SavedAppIdsFromSettingsJson -SettingsJson $stringSettings | Should -Be @('One.App', 'Two.App') + Get-SavedAppIdsFromSettingsJson -SettingsJson $arraySettings | Should -Be @('One.App', 'Two.App') + Get-SavedAppIdsFromSettingsJson -SettingsJson ([PSCustomObject]@{ Settings = @() }) | Should -BeNullOrEmpty + } + + It 'returns the AppsUseLightTheme registry preference and fails closed' { + Mock Get-ItemProperty { [PSCustomObject]@{ AppsUseLightTheme = 0 } } + Get-SystemUsesDarkMode | Should -BeTrue + + Mock Get-ItemProperty { throw 'Registry unavailable' } + Get-SystemUsesDarkMode | Should -BeFalse + } +} diff --git a/Tests/Registry-BackupCreation.Tests.ps1 b/Tests/Registry-BackupCreation.Tests.ps1 new file mode 100644 index 0000000..7158962 --- /dev/null +++ b/Tests/Registry-BackupCreation.Tests.ps1 @@ -0,0 +1,245 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\User-HiveHelpers.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistryFeatureSelection.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Save-ToFile.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistryState.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistrySnapshotCapture.ps1') +} + +Describe 'Get-SelectedFeatures' { + BeforeEach { + $script:Features = @{ + First = [PSCustomObject]@{ FeatureId = 'One'; RegistryKey = 'one.reg' } + Duplicate = [PSCustomObject]@{ FeatureId = 'one'; RegistryKey = 'duplicate.reg' } + Empty = $null + } + } + + It 'keeps the first matching feature and ignores unknown, null, and duplicate keys' { + $result = @(Get-SelectedFeatures -ActionableKeys @('missing', 'First', 'Duplicate', 'Empty')) + + $result | Should -HaveCount 1 + $result[0].RegistryKey | Should -Be 'one.reg' + } +} + +Describe 'Get-RegistryBackupPayload' { + BeforeAll { + $script:OriginalComputerName = $env:COMPUTERNAME + } + + BeforeEach { + Mock Get-RegistryBackedFeatures { param($Features) @($Features | Where-Object RegistryKey) } + Mock Get-RegistryBackupCapturePlans { @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example' }) } + Mock Get-RegistrySnapshotsForBackup { @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true }) } + Mock Get-RegistryBackupTargetDescription { 'CurrentUser:Tester' } + $env:COMPUTERNAME = 'TestComputer' + } + + AfterAll { + $env:COMPUTERNAME = $script:OriginalComputerName + } + + It 'builds a versioned payload and preserves distinct apply and undo IDs' { + $apply = @([PSCustomObject]@{ FeatureId = 'One'; RegistryKey = 'one.reg' }, [PSCustomObject]@{ FeatureId = 'one'; RegistryKey = 'other.reg' }) + $undo = @([PSCustomObject]@{ FeatureId = 'UndoOne'; RegistryUndoKey = 'undo.reg' }) + + $result = Get-RegistryBackupPayload -SelectedFeatures $apply -UndoFeatures $undo -CreatedAt ([datetime]'2026-01-02T03:04:05Z') + + $result.Version | Should -Be '1.0' + $result.BackupType | Should -Be 'RegistryState' + $result.Target | Should -Be 'CurrentUser:Tester' + $result.SelectedFeatures | Should -Be 'One' + $result.SelectedUndoFeatures | Should -Be 'UndoOne' + $result.RegistryKeys | Should -HaveCount 1 + } + + It 'does not add SelectedUndoFeatures when no undo features were supplied' { + $result = Get-RegistryBackupPayload -SelectedFeatures @() -UndoFeatures @() -CreatedAt (Get-Date) + + $result.ContainsKey('SelectedUndoFeatures') | Should -BeFalse + } +} + +Describe 'New-RegistrySettingsBackup' { + BeforeEach { + $script:RegistryBackupsPath = Join-Path $TestDrive 'Backups' + $script:Features = @{ Feature = [PSCustomObject]@{ FeatureId = 'Feature'; RegistryKey = 'feature.reg' } } + Mock Get-RegistryBackupPayload { @{ Version = '1.0' } } + Mock Save-ToFile { $true } + Mock Write-Host {} + } + + It 'returns null and does not write when no selected feature has registry data' { + $script:Features.Feature.RegistryKey = '' + + New-RegistrySettingsBackup -ActionableKeys @('Feature') | Should -BeNullOrEmpty + Should -Invoke Save-ToFile -Times 0 -Exactly + } + + It 'creates the backup directory and saves a generated payload' { + $result = New-RegistrySettingsBackup -ActionableKeys @('Feature') + + $result | Should -Match 'Win11Debloat-RegistryBackup-\d{8}_\d{6}\.json$' + Test-Path -LiteralPath $script:RegistryBackupsPath | Should -BeTrue + Should -Invoke Save-ToFile -Times 1 -Exactly -ParameterFilter { $FilePath -eq $result -and $MaxDepth -eq 25 } + } + + It 'throws when persistence reports failure' { + Mock Save-ToFile { $false } + + { New-RegistrySettingsBackup -ActionableKeys @('Feature') } | Should -Throw 'Failed to save registry backup to *' + } +} + +Describe 'Add-RegistryPlanOperation' { + It 'merges duplicate value operations and upgrades a key deletion to a recursive capture' { + $plans = @{} + Add-RegistryPlanOperation -PlanMap $plans -Operation ([PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'SetValue'; ValueName = 'One' }) + Add-RegistryPlanOperation -PlanMap $plans -Operation ([PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'DeleteValue'; ValueName = 'one' }) + Add-RegistryPlanOperation -PlanMap $plans -Operation ([PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'DeleteKey'; ValueName = $null }) + + $plan = $plans['hkey_current_user\software\example'] + $plan.IncludeSubKeys | Should -BeTrue + $plan.CaptureAllValues | Should -BeTrue + $plan.ValueNames | Should -HaveCount 1 + } + + It '' -ForEach @( + @{ Case = 'uses an explicit undo key'; RegistryUndoKey = 'undo.reg'; RegistryKey = 'apply.reg'; Expected = 'Undo/undo.reg' } + @{ Case = 'falls back to a relative regular registry key'; RegistryUndoKey = ''; RegistryKey = 'apply.reg'; Expected = 'apply.reg' } + @{ Case = 'preserves a rooted regular registry key'; RegistryUndoKey = ''; RegistryKey = 'C:\temp\apply.reg'; Expected = 'C:\temp\apply.reg' } + ) { + $script:RegfilesPath = $TestDrive + function Resolve-UndoRegFilePath { param($FileName) "Undo/$FileName" } + + $expectedPath = if ([System.IO.Path]::IsPathRooted($Expected)) { $Expected } else { Join-Path $TestDrive $Expected } + Resolve-RegistryBackupUndoFilePath -Feature ([PSCustomObject]@{ RegistryUndoKey = $RegistryUndoKey; RegistryKey = $RegistryKey }) | + Should -Be $expectedPath + } +} + +Describe 'Get-RegistryBackupCapturePlans' { + BeforeEach { + $script:Params = @{} + $script:RegfilesPath = $TestDrive + $script:applyPath = Join-Path $TestDrive 'apply.reg' + $script:undoPath = Join-Path $TestDrive 'undo.reg' + '' | Set-Content -LiteralPath $script:applyPath + '' | Set-Content -LiteralPath $script:undoPath + Mock Get-RegistryFilePathForFeature { $script:applyPath } + Mock Resolve-RegistryBackupUndoFilePath { $script:undoPath } + Mock Get-RegFileOperations { + param($regFilePath) + if ($regFilePath -eq $script:applyPath) { + @( + [PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'SetValue'; ValueName = 'Enabled' } + [PSCustomObject]@{ KeyPath = $null; OperationType = 'SetValue'; ValueName = 'Ignored' } + ) + } + else { + @([PSCustomObject]@{ KeyPath = 'hkey_current_user\software\example'; OperationType = 'DeleteValue'; ValueName = 'Removed' }) + } + } + } + + It 'merges apply and undo operations for the same path and ignores operations without a path' { + $plans = @(Get-RegistryBackupCapturePlans ` + -SelectedRegistryFeatures @([PSCustomObject]@{ RegistryKey = 'apply.reg' }) ` + -UndoRegistryFeatures @([PSCustomObject]@{ RegistryUndoKey = 'undo.reg' })) + + $plans | Should -HaveCount 1 + $plans[0].ValueNames | Should -Contain 'Enabled' + $plans[0].ValueNames | Should -Contain 'Removed' + $plans[0].ValueNames | Should -Not -Contain 'Ignored' + } + + It 'passes Sysprep selection through to registry-file resolution' { + Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @([PSCustomObject]@{ RegistryKey = 'apply.reg' }) -UseSysprepRegFiles | Out-Null + + Should -Invoke Get-RegistryFilePathForFeature -Times 1 -Exactly -ParameterFilter { $UseSysprepRegFiles } + } + + It 'throws a descriptive error when an apply registry file is missing' { + Mock Get-RegistryFilePathForFeature { Join-Path $TestDrive 'missing.reg' } + + { Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @([PSCustomObject]@{ RegistryKey = 'missing.reg' }) } | + Should -Throw 'Unable to find registry file for backup: missing.reg*' + Should -Invoke Get-RegFileOperations -Times 0 -Exactly + } + + It 'throws a descriptive error when an undo registry file is missing' { + Mock Resolve-RegistryBackupUndoFilePath { Join-Path $TestDrive 'missing-undo.reg' } + + { Get-RegistryBackupCapturePlans -UndoRegistryFeatures @([PSCustomObject]@{ RegistryUndoKey = 'missing-undo.reg' }) } | + Should -Throw 'Unable to find registry undo file for backup: missing-undo.reg*' + } + + It 'skips undo features that do not resolve to a registry file' { + Mock Resolve-RegistryBackupUndoFilePath { $null } + + @(Get-RegistryBackupCapturePlans -UndoRegistryFeatures @([PSCustomObject]@{ RegistryUndoKey = ''; RegistryKey = '' })) | + Should -HaveCount 0 + Should -Invoke Get-RegFileOperations -Times 0 -Exactly + } +} + +Describe 'Get-RegistrySnapshotsForBackup' { + BeforeEach { + $script:Params = @{} + Mock Get-RegistryKeySnapshot { + param($KeyPath) + [PSCustomObject]@{ Path = $KeyPath; Exists = $true } + } + Mock Invoke-WithLoadedBackupHive { + param($ScriptBlock, $ArgumentObject) + & $ScriptBlock $ArgumentObject + } + } + + It 'returns an empty collection without touching the registry when there are no plans' { + @(Get-RegistrySnapshotsForBackup -CapturePlans @()) | Should -HaveCount 0 + Should -Invoke Get-RegistryKeySnapshot -Times 0 -Exactly + Should -Invoke Invoke-WithLoadedBackupHive -Times 0 -Exactly + } + + It 'captures each plan directly for the current user' { + $plans = @( + [PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\One'; CaptureAllValues = $true; ValueNames = @(); IncludeSubKeys = $true } + [PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Two'; CaptureAllValues = $false; ValueNames = @('Value'); IncludeSubKeys = $false } + ) + + @(Get-RegistrySnapshotsForBackup -CapturePlans $plans) | Should -HaveCount 2 + Should -Invoke Get-RegistryKeySnapshot -Times 2 -Exactly + Should -Invoke Invoke-WithLoadedBackupHive -Times 0 -Exactly + } + + It 'captures through the loaded-hive wrapper in ' -ForEach @( + @{ Case = 'User mode'; Params = @{ User = 'Alice' } } + @{ Case = 'Sysprep mode'; Params = @{ Sysprep = $true } } + ) { + $script:Params = $Params + $plan = [PSCustomObject]@{ Path = 'HKEY_USERS\Default\Software\Example'; CaptureAllValues = $false; ValueNames = @('Value'); IncludeSubKeys = $false } + + @(Get-RegistrySnapshotsForBackup -CapturePlans @($plan)) | Should -HaveCount 1 + Should -Invoke Invoke-WithLoadedBackupHive -Times 1 -Exactly + Should -Invoke Get-RegistryKeySnapshot -Times 1 -Exactly + } +} + +Describe 'Invoke-WithLoadedBackupHive' { + BeforeEach { + Mock Invoke-WithTargetUserHive { param($TargetUserName) $TargetUserName } + } + + It 'targets in ' -ForEach @( + @{ Case = 'Sysprep mode'; Params = @{ Sysprep = $true }; ExpectedUser = 'Default' } + @{ Case = 'User mode'; Params = @{ User = 'Alice' }; ExpectedUser = 'Alice' } + ) { + $script:Params = $Params + Invoke-WithLoadedBackupHive -ScriptBlock { } | Should -Be $ExpectedUser + } +} diff --git a/Tests/Registry-BackupValidation.Tests.ps1 b/Tests/Registry-BackupValidation.Tests.ps1 new file mode 100644 index 0000000..7f3b382 --- /dev/null +++ b/Tests/Registry-BackupValidation.Tests.ps1 @@ -0,0 +1,301 @@ +BeforeAll { + $registryPathHelperScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1' + $validationScriptPath = Join-Path $PSScriptRoot '..\Scripts\Features\Registry-BackupValidation.ps1' + + . $registryPathHelperScriptPath + . $validationScriptPath +} + +Describe 'Get-NormalizedSelectedFeatureIdsFromBackup' { + It 'returns distinct feature IDs without changing their first occurrence' { + $backup = [PSCustomObject]@{ + SelectedFeatures = @('DisableTelemetry', 'disabletelemetry', 'DisableCopilot') + } + + $result = Get-NormalizedSelectedFeatureIdsFromBackup -Backup $backup + + $result.Errors | Should -BeNullOrEmpty + $result.SelectedFeatures | Should -Be @('DisableTelemetry', 'DisableCopilot') + } + + It 'reports missing SelectedFeatures' { + $result = Get-NormalizedSelectedFeatureIdsFromBackup -Backup ([PSCustomObject]@{}) + + $result.SelectedFeatures | Should -BeNullOrEmpty + $result.Errors | Should -Contain 'Missing property: SelectedFeatures' + } + + It 'reports non-string and empty feature IDs' { + $backup = [PSCustomObject]@{ + SelectedFeatures = @('DisableTelemetry', '', 42, $null) + } + + $result = Get-NormalizedSelectedFeatureIdsFromBackup -Backup $backup + + $result.SelectedFeatures | Should -Be 'DisableTelemetry' + $result.Errors | Should -Contain 'SelectedFeatures must contain non-empty string feature IDs.' + } +} + +Describe 'Get-NormalizedSelectedUndoFeatureIdsFromBackup' { + It 'allows backups created before SelectedUndoFeatures was introduced' { + $result = Get-NormalizedSelectedUndoFeatureIdsFromBackup -Backup ([PSCustomObject]@{}) + + $result.SelectedUndoFeatures | Should -BeNullOrEmpty + $result.Errors | Should -BeNullOrEmpty + } + + It 'deduplicates undo feature IDs case-insensitively' { + $backup = [PSCustomObject]@{ + SelectedUndoFeatures = @('EnableTelemetry', 'enabletelemetry') + } + + $result = Get-NormalizedSelectedUndoFeatureIdsFromBackup -Backup $backup + + $result.Errors | Should -BeNullOrEmpty + $result.SelectedUndoFeatures | Should -Be 'EnableTelemetry' + } +} + +Describe 'Get-SelectedRegistryFeaturesForBackupValidation' { + BeforeEach { + $script:Features = @{ + ApplyFeature = [PSCustomObject]@{ Id = 'ApplyFeature'; RegistryKey = 'ApplyFeature.reg' } + UndoFeature = [PSCustomObject]@{ Id = 'UndoFeature'; RegistryKey = 'UndoFeature.reg'; RegistryUndoKey = 'UndoFeature.undo.reg' } + FallbackUndoFeature = [PSCustomObject]@{ Id = 'FallbackUndoFeature'; RegistryKey = 'FallbackUndoFeature.reg'; RegistryUndoKey = '' } + CustomFeature = [PSCustomObject]@{ Id = 'CustomFeature'; RegistryKey = '' } + } + } + + It 'creates a case-insensitive registry value-name set' { + $valueNames = ConvertTo-RegistryValueNameSet -ValueNames @('Enabled', 'enabled', 'Mode') + + $valueNames.Count | Should -Be 2 + $valueNames.Contains('ENABLED') | Should -BeTrue + $valueNames.Contains('mode') | Should -BeTrue + } + + It 'selects registry-backed apply features and reports unknown IDs' { + $errors = New-Object System.Collections.Generic.List[string] + + $result = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @('ApplyFeature', 'CustomFeature', 'MissingFeature') -IsUndoFeature:$false -Errors $errors) + + $result.Id | Should -Be 'ApplyFeature' + $errors | Should -Contain "Selected feature 'MissingFeature' was not found in the current feature catalog." + } + + It 'uses undo registry keys and falls back to apply keys when needed' { + $errors = New-Object System.Collections.Generic.List[string] + + $result = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @('UndoFeature', 'FallbackUndoFeature') -IsUndoFeature:$true -Errors $errors) + + $result.Id | Should -Be @('UndoFeature', 'FallbackUndoFeature') + $errors | Should -BeNullOrEmpty + } +} + +Describe 'Test-RegistryValueKindNameSupported' { + It 'accepts supported registry value kind ' -ForEach @( + @{ KindName = 'dword' } + @{ KindName = 'QWord' } + @{ KindName = 'MultiString' } + ) { + Test-RegistryValueKindNameSupported -KindName $KindName | Should -BeTrue + } + + It 'rejects ' -ForEach @( + @{ Case = 'a null kind name'; KindName = $null } + @{ Case = 'an empty kind name'; KindName = '' } + @{ Case = 'the Unknown registry kind'; KindName = 'Unknown' } + @{ Case = 'the unsupported None registry kind'; KindName = 'None' } + @{ Case = 'an invalid registry kind name'; KindName = 'NotARegistryValueKind' } + ) { + Test-RegistryValueKindNameSupported -KindName $KindName | Should -BeFalse + } +} + +Describe 'Test-RegistryValueDataMatchesKind' { + It 'accepts ' -ForEach @( + @{ Case = 'the largest DWord'; Kind = 'DWord'; Data = [uint32]::MaxValue } + @{ Case = 'the largest QWord'; Kind = 'QWord'; Data = [uint64]::MaxValue } + @{ Case = 'non-empty Binary bytes'; Kind = 'Binary'; Data = @(0, 255) } + @{ Case = 'empty Binary bytes'; Kind = 'Binary'; Data = [byte[]]::new(0) } + @{ Case = 'a string array'; Kind = 'MultiString'; Data = @('one', 'two') } + ) { + Test-RegistryValueDataMatchesKind -KindName $Kind -Data $Data | Should -BeTrue + } + + It 'rejects ' -ForEach @( + @{ Case = 'an overflowing DWord'; Kind = 'DWord'; Data = '4294967296' } + @{ Case = 'a negative QWord'; Kind = 'QWord'; Data = -1 } + @{ Case = 'null Binary data'; Kind = 'Binary'; Data = $null } + @{ Case = 'an invalid binary byte'; Kind = 'Binary'; Data = @(0, 256) } + @{ Case = 'an object in a MultiString'; Kind = 'MultiString'; Data = @('one', [PSCustomObject]@{}) } + ) { + Test-RegistryValueDataMatchesKind -KindName $Kind -Data $Data | Should -BeFalse + } +} + +Describe 'Get-NormalizedRegistryValueName' { + It 'normalizes a null value name to the default registry value' { + Get-NormalizedRegistryValueName -ValueName $null | Should -Be '' + Get-RegistryValueReferenceForError -SnapshotPath 'HKEY_CURRENT_USER\Software\Example' -ValueName '' | + Should -Be 'HKEY_CURRENT_USER\Software\Example\\(Default)' + } + + It 'keeps a named registry value in the error reference' { + Get-RegistryValueReferenceForError -SnapshotPath 'HKEY_CURRENT_USER\Software\Example' -ValueName 'Setting' | + Should -Be 'HKEY_CURRENT_USER\Software\Example\\Setting' + } +} + +Describe 'Normalize-RegistryKeySnapshot' { + It 'normalizes nested snapshots and supplies value defaults' { + $snapshot = [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + Exists = $true + Values = @( + [PSCustomObject]@{ Name = 'Enabled'; Kind = 'DWord'; Data = 1 } + [PSCustomObject]@{ Name = 'Removed'; Exists = $false } + ) + SubKeys = @( + [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example\Child' + Exists = $true + } + ) + } + + $result = Normalize-RegistryKeySnapshot -Snapshot $snapshot + + $result.Path | Should -Be 'HKEY_CURRENT_USER\Software\Example' + $result.Exists | Should -BeTrue + $result.Values.Count | Should -Be 2 + $result.Values[0].Exists | Should -BeTrue + $result.Values[0].Kind | Should -Be 'DWord' + $result.Values[1].Exists | Should -BeFalse + $result.Values[1].Kind | Should -BeNullOrEmpty + $result.Values[1].Data | Should -BeNullOrEmpty + $result.SubKeys[0].Path | Should -Be 'HKEY_CURRENT_USER\Software\Example\Child' + $result.SubKeys[0].Values | Should -BeNullOrEmpty + } + + It 'rejects a snapshot without a registry path' { + { Normalize-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Exists = $true }) } | + Should -Throw 'Backup validation failed: Registry key snapshot is missing Path.' + } +} + +Describe 'Test-RegistrySnapshotAgainstAllowList' { + It 'normalizes path separators and hive casing' { + Get-NormalizedRegistryPathKey -Path 'hkey_current_user/Software/Example/' | + Should -Match '^HKEY_CURRENT_USER\\+Software\\Example$' + } + + It 'matches configured value names case-insensitively' { + $planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @( + [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + IncludeSubKeys = $false + CaptureAllValues = $false + ValueNames = @('Enabled') + } + ) + $planMatch = Find-RegistryAllowListPlanMatch -NormalizedPath (Get-NormalizedRegistryPathKey -Path 'HKEY_CURRENT_USER\Software\Example') -PlanMap $planMap + + Test-RegistryValueAllowedByPlan -PlanMatch $planMatch -ValueName 'enabled' | Should -BeTrue + Test-RegistryValueAllowedByPlan -PlanMatch $planMatch -ValueName 'Unexpected' | Should -BeFalse + } + + It 'allows configured values at an exact path' { + $planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @( + [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + IncludeSubKeys = $false + CaptureAllValues = $false + ValueNames = @('Enabled') + } + ) + $errors = New-Object 'System.Collections.Generic.List[string]' + $snapshot = [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + Values = @([PSCustomObject]@{ Name = 'Enabled'; Exists = $true; Kind = 'DWord'; Data = 1 }) + SubKeys = @() + } + + Test-RegistrySnapshotAgainstAllowList -Snapshot $snapshot -PlanMap $planMap -Errors $errors + + $errors | Should -BeNullOrEmpty + } + + It 'rejects corrupt value data even when its path and kind are allowed' { + $planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @( + [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + IncludeSubKeys = $false + CaptureAllValues = $false + ValueNames = @('Bytes') + } + ) + $errors = New-Object 'System.Collections.Generic.List[string]' + $snapshot = [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + Values = @([PSCustomObject]@{ Name = 'Bytes'; Exists = $true; Kind = 'Binary'; Data = @(1, 256) }) + SubKeys = @() + } + + Test-RegistrySnapshotAgainstAllowList -Snapshot $snapshot -PlanMap $planMap -Errors $errors + + $errors | Should -HaveCount 1 + $errors[0] | Should -BeLike "Backup contains invalid registry data for kind 'Binary'*" + } + + It 'allows all values in descendants of a recursive plan' { + $planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @( + [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + IncludeSubKeys = $true + CaptureAllValues = $true + ValueNames = @() + } + ) + $errors = New-Object 'System.Collections.Generic.List[string]' + $snapshot = [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example\Child' + Values = @([PSCustomObject]@{ Name = 'Unlisted'; Exists = $true; Kind = 'String'; Data = 'value' }) + SubKeys = @() + } + + Test-RegistrySnapshotAgainstAllowList -Snapshot $snapshot -PlanMap $planMap -Errors $errors + + $errors | Should -BeNullOrEmpty + } + + It 'reports unexpected paths and values' { + $planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @( + [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + IncludeSubKeys = $false + CaptureAllValues = $false + ValueNames = @('Enabled') + } + ) + $errors = New-Object 'System.Collections.Generic.List[string]' + $unexpectedPathSnapshot = [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Unexpected' + Values = @() + SubKeys = @() + } + $unexpectedValueSnapshot = [PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example' + Values = @([PSCustomObject]@{ Name = 'Unexpected'; Exists = $true; Kind = 'String'; Data = 'value' }) + SubKeys = @() + } + + Test-RegistrySnapshotAgainstAllowList -Snapshot $unexpectedPathSnapshot -PlanMap $planMap -Errors $errors + Test-RegistrySnapshotAgainstAllowList -Snapshot $unexpectedValueSnapshot -PlanMap $planMap -Errors $errors + + $errors | Should -Contain "Backup contains unexpected registry path 'HKEY_CURRENT_USER\Software\Unexpected' that is not allowed by SelectedFeatures." + $errors | Should -Contain "Backup contains unexpected value 'Unexpected' under 'HKEY_CURRENT_USER\Software\Example'." + } +} diff --git a/Tests/Registry-PathHelpers.Tests.ps1 b/Tests/Registry-PathHelpers.Tests.ps1 new file mode 100644 index 0000000..efc845d --- /dev/null +++ b/Tests/Registry-PathHelpers.Tests.ps1 @@ -0,0 +1,72 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1') +} + +Describe 'Split-RegistryPath' { + BeforeEach { $script:RegistryTargetHiveMountName = $null } + + It 'normalizes whitespace, slashes, and a registry subkey' { + $result = Split-RegistryPath -path ' HKEY_CURRENT_USER/Software/Example/ ' + + $result.Hive | Should -Be 'HKEY_CURRENT_USER' + $result.SubKey | Should -Be 'Software\Example' + } + + It 'returns null for ' -ForEach @( + @{ Case = 'a blank path'; Path = ' ' } + @{ Case = 'a path without a hive'; Path = 'Software\Example' } + @{ Case = 'an abbreviated hive path'; Path = 'HKCU\Software\Example' } + ) { + Split-RegistryPath -path $Path | Should -BeNullOrEmpty + } + + It 'rewrites the Default user hive only while a target hive is mounted' { + $script:RegistryTargetHiveMountName = 'S-1-5-21-123' + + (Split-RegistryPath -path 'HKEY_USERS\Default\Software\Example').SubKey | Should -Be 'S-1-5-21-123\Software\Example' + (Split-RegistryPath -path 'HKEY_USERS\Other\Software\Example').SubKey | Should -Be 'Other\Software\Example' + } +} + +Describe 'Get-RegistryFilePathForFeature' { + BeforeEach { $script:RegfilesPath = 'C:\Regfiles'; $script:Params = @{} } + + It 'uses ' -ForEach @( + @{ Case = 'the normal layout by default'; Params = @{}; UseSysprepRegFiles = $false; ExpectedRoot = 'C:\Regfiles' } + @{ Case = 'the Sysprep layout for an explicit switch'; Params = @{}; UseSysprepRegFiles = $true; ExpectedRoot = 'C:\Regfiles\Sysprep' } + @{ Case = 'the Sysprep layout for User mode'; Params = @{ User = 'Alice' }; UseSysprepRegFiles = $false; ExpectedRoot = 'C:\Regfiles\Sysprep' } + ) { + $script:Params = $Params + Get-RegistryFilePathForFeature -RegistryKey 'Feature.reg' -UseSysprepRegFiles:$UseSysprepRegFiles | + Should -Be (Join-Path $ExpectedRoot 'Feature.reg') + } +} + +Describe 'Remove-RegistrySubKeyTreeIfExists' { + It 'deletes a subtree and ignores a key that disappears during deletion' { + $root = [PSCustomObject]@{ Calls = 0 } + $root | Add-Member ScriptMethod DeleteSubKeyTree { param($path, $throwOnMissing) $this.Calls++ } + { Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Not -Throw + $root.Calls | Should -Be 1 + + $root.PSObject.Members.Remove('DeleteSubKeyTree') + $root | Add-Member ScriptMethod DeleteSubKeyTree { throw [System.ArgumentException]::new('already gone') } + { Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Not -Throw + } + + It 'preserves access-denied failures' -ForEach @( + @{ Exception = [System.UnauthorizedAccessException]::new('denied') } + @{ Exception = [System.Security.SecurityException]::new('blocked') } + ) { + $root = [PSCustomObject]@{ Failure = $Exception } + $root | Add-Member ScriptMethod DeleteSubKeyTree { throw $this.Failure } + { Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Throw + } + + It 'preserves unexpected registry deletion failures' { + $root = [PSCustomObject]@{ Failure = [System.IO.IOException]::new('registry I/O failure') } + $root | Add-Member ScriptMethod DeleteSubKeyTree { throw $this.Failure } + + { Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Throw '*registry I/O failure*' + } +} diff --git a/Tests/Registry-SnapshotApply.Tests.ps1 b/Tests/Registry-SnapshotApply.Tests.ps1 new file mode 100644 index 0000000..f91164b --- /dev/null +++ b/Tests/Registry-SnapshotApply.Tests.ps1 @@ -0,0 +1,205 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistrySnapshotCapture.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1') + + function New-FakeRegistryKey { + param( + [string]$Name = 'HKEY_CURRENT_USER\Software\Example', + [hashtable]$Values = @{}, + [hashtable]$Kinds = @{}, + [hashtable]$Children = @{} + ) + + $key = [PSCustomObject]@{ + Name = $Name + Values = $Values + Kinds = $Kinds + Children = $Children + Closed = $false + DeletedValues = [System.Collections.Generic.List[string]]::new() + SetCalls = [System.Collections.Generic.List[object]]::new() + } + $key | Add-Member ScriptMethod GetValueNames { @($this.Kinds.Keys) } + $key | Add-Member ScriptMethod GetValueKind { param($valueName) $this.Kinds[$valueName] } + $key | Add-Member ScriptMethod GetValue { param($valueName, $defaultValue, $options) $this.Values[$valueName] } + $key | Add-Member ScriptMethod GetSubKeyNames { @($this.Children.Keys) } + $key | Add-Member ScriptMethod OpenSubKey { param($subKeyName, $writable) $this.Children[$subKeyName] } + $key | Add-Member ScriptMethod Close { $this.Closed = $true } + $key | Add-Member ScriptMethod DeleteValue { param($valueName, $throwOnMissing) $this.DeletedValues.Add([string]$valueName) } + $key | Add-Member ScriptMethod SetValue { param($valueName, $data, $kind) $this.SetCalls.Add(@($valueName, $data, $kind)) } + return $key + } +} + +Describe 'Convert-RegistryValueToSnapshot' { + It 'normalizes without expanding registry strings' -ForEach @( + @{ Case = 'null binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = $null; Expected = @() } + @{ Case = 'empty binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = [byte[]]::new(0); Expected = @() } + @{ Case = 'binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = [byte[]](1, 255); Expected = @(1, 255) } + @{ Case = 'multi-string'; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Data = [string[]]@('one', 'two'); Expected = @('one', 'two') } + @{ Case = 'unsigned DWord'; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Data = -1; Expected = [uint32]::MaxValue } + @{ Case = 'unsigned QWord'; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Data = -1L; Expected = [uint64]::MaxValue } + @{ Case = 'expandable string'; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Data = '%TEMP%'; Expected = '%TEMP%' } + ) { + $key = New-FakeRegistryKey -Values @{ Value = $Data } -Kinds @{ Value = $Kind } + $snapshot = Convert-RegistryValueToSnapshot -RegistryKey $key -ValueName 'Value' + $snapshot.Exists | Should -BeTrue + $snapshot.Kind | Should -Be $Kind.ToString() + if ($Kind -eq [Microsoft.Win32.RegistryValueKind]::Binary) { + $snapshot.Data -is [array] | Should -BeTrue + $snapshot.Data.Count | Should -Be $Expected.Count + if ($Expected.Count -gt 0) { + $snapshot.Data | Should -Be $Expected + } + return + } + $snapshot.Data | Should -Be $Expected + } + + It 'rejects REG_NONE values because their data cannot be read reliably through .NET' { + $key = New-FakeRegistryKey -Values @{ Value = [byte[]](1) } -Kinds @{ Value = [Microsoft.Win32.RegistryValueKind]::None } + + { Convert-RegistryValueToSnapshot -RegistryKey $key -ValueName 'Value' } | + Should -Throw 'REG_NONE registry values are not supported for backup*' + } +} + +Describe 'Registry value backup round trip' { + BeforeAll { + $script:RoundTripRegistrySubKey = "Software\Win11Debloat\Tests\RegistrySnapshotRoundTrip-$([guid]::NewGuid().ToString('N'))" + $script:RoundTripRegistryKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($script:RoundTripRegistrySubKey) + } + + AfterAll { + if ($script:RoundTripRegistryKey) { + $script:RoundTripRegistryKey.Close() + } + [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($script:RoundTripRegistrySubKey, $false) + } + + It 'captures, serializes, and restores values' -ForEach @( + @{ Case = 'String'; Kind = [Microsoft.Win32.RegistryValueKind]::String; Data = 'value' } + @{ Case = 'ExpandString'; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Data = '%TEMP%\value' } + @{ Case = 'Binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = [byte[]](1) } + @{ Case = 'DWord'; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Data = [int]42 } + @{ Case = 'MultiString'; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Data = [string[]]@('one', 'two') } + @{ Case = 'QWord'; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Data = [int64]42 } + ) { + $sourceName = "Source-$Case" + $restoredName = "Restored-$Case" + $script:RoundTripRegistryKey.SetValue($sourceName, $Data, $Kind) + + $snapshot = Convert-RegistryValueToSnapshot -RegistryKey $script:RoundTripRegistryKey -ValueName $sourceName + $serializedSnapshot = $snapshot | ConvertTo-Json -Depth 5 | ConvertFrom-Json + $serializedSnapshot.Name = $restoredName + + Restore-RegistryValueSnapshot -RegistryKey $script:RoundTripRegistryKey -Snapshot $serializedSnapshot + $restoredSnapshot = Convert-RegistryValueToSnapshot -RegistryKey $script:RoundTripRegistryKey -ValueName $restoredName + + $restoredSnapshot.Kind | Should -Be $snapshot.Kind + if ($Kind -in @([Microsoft.Win32.RegistryValueKind]::Binary, [Microsoft.Win32.RegistryValueKind]::None, [Microsoft.Win32.RegistryValueKind]::MultiString)) { + $restoredSnapshot.Data -is [array] | Should -BeTrue + } + $restoredSnapshot.Data | Should -Be $snapshot.Data + } +} + +Describe 'Convert-RegistryKeyToSnapshot' { + It 'captures selected missing values and recursively closes child keys' { + $child = New-FakeRegistryKey -Name 'HKEY_CURRENT_USER\Software\Example\Child' -Values @{ ChildValue = 'data' } -Kinds @{ ChildValue = [Microsoft.Win32.RegistryValueKind]::String } + $root = New-FakeRegistryKey -Values @{ Present = 1 } -Kinds @{ Present = [Microsoft.Win32.RegistryValueKind]::DWord } -Children @{ Child = $child } + + $snapshot = Convert-RegistryKeyToSnapshot -RegistryKey $root -FullPath $root.Name -ValueNames @('Missing', 'Present') -IncludeSubKeys:$true + + $snapshot.Values | Should -HaveCount 2 + ($snapshot.Values | Where-Object Name -eq 'Missing').Exists | Should -BeFalse + $snapshot.SubKeys | Should -HaveCount 1 + $snapshot.SubKeys[0].Values[0].Data | Should -Be 'data' + $child.Closed | Should -BeTrue + } +} + +Describe 'Get-RegistryKeySnapshot' { + BeforeEach { + Mock Split-RegistryPath { [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = 'Software\Example' } } + } + + It 'returns an explicit non-existent snapshot when the key is absent' { + $root = [PSCustomObject]@{} + $root | Add-Member ScriptMethod OpenSubKey { $null } + Mock Get-RegistryRootKey { $root } + + $snapshot = Get-RegistryKeySnapshot -KeyPath 'HKEY_CURRENT_USER\Software\Example' + $snapshot.Exists | Should -BeFalse + $snapshot.Values | Should -HaveCount 0 + } + + It 'closes an opened root snapshot key' { + $key = New-FakeRegistryKey + $root = [PSCustomObject]@{ Key = $key } + $root | Add-Member ScriptMethod OpenSubKey { $this.Key } + Mock Get-RegistryRootKey { $root } + + Get-RegistryKeySnapshot -KeyPath 'HKEY_CURRENT_USER\Software\Example' | Out-Null + $key.Closed | Should -BeTrue + } +} + +Describe 'Restore-RegistryValueSnapshot' { + It 'deletes a missing value using the default-value name when necessary' { + $key = New-FakeRegistryKey + Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot ([PSCustomObject]@{ Name = $null; Exists = $false }) + $key.DeletedValues | Should -Be @('') + } + + It 'sets a value with its original kind and converted bit pattern' { + $key = New-FakeRegistryKey + Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot ([PSCustomObject]@{ Name = 'Large'; Exists = $true; Kind = 'QWord'; Data = [uint64]::MaxValue }) + $key.SetCalls | Should -HaveCount 1 + $key.SetCalls[0][0] | Should -Be 'Large' + $key.SetCalls[0][1] | Should -Be ([int64]-1) + $key.SetCalls[0][1].GetType() | Should -Be ([int64]) + $key.SetCalls[0][2] | Should -Be ([Microsoft.Win32.RegistryValueKind]::QWord) + } + + It 'does not silently downgrade a failed value write to Binary' { + $key = New-FakeRegistryKey + $key.PSObject.Members.Remove('SetValue') + $key | Add-Member ScriptMethod SetValue { throw 'write denied' } + { Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot ([PSCustomObject]@{ Name = 'Value'; Exists = $true; Kind = 'String'; Data = 'data' }) } | + Should -Throw '*write denied*' + } +} + +Describe 'Restore-RegistryKeySnapshot - mutation flow' { + BeforeEach { + Mock Split-RegistryPath { + param($path) + [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = $path.Substring('HKEY_CURRENT_USER\'.Length) } + } + Mock Remove-RegistrySubKeyTreeIfExists {} + } + + It 'removes a key that did not exist when the backup was created' { + Mock Get-RegistryRootKey { [PSCustomObject]@{} } + Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Gone'; Exists = $false; Values = @(); SubKeys = @() }) + Should -Invoke Remove-RegistrySubKeyTreeIfExists -Times 1 -Exactly -ParameterFilter { $SubKeyPath -eq 'Software\Gone' } + } + + It 'creates, populates, and closes an existing key snapshot' { + $key = New-FakeRegistryKey + $root = [PSCustomObject]@{ Key = $key } + $root | Add-Member ScriptMethod CreateSubKey { param($path) $this.Key } + Mock Get-RegistryRootKey { $root } + + Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ + Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true + Values = @([PSCustomObject]@{ Name = 'Enabled'; Exists = $true; Kind = 'DWord'; Data = 1 }) + SubKeys = @() + }) + + $key.SetCalls | Should -HaveCount 1 + $key.Closed | Should -BeTrue + } +} diff --git a/Tests/Remove-SelectedApps.Tests.ps1 b/Tests/Remove-SelectedApps.Tests.ps1 new file mode 100644 index 0000000..512d3b2 --- /dev/null +++ b/Tests/Remove-SelectedApps.Tests.ps1 @@ -0,0 +1,186 @@ +BeforeAll { + function Get-TargetUserForAppRemoval { 'AllUsers' } + function Get-WingetInstalledApps { param($TimeOut, [switch]$NonBlocking) @() } + function Test-AppInWingetList { param($appId, $InstalledList) $false } + function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) } + function Get-UserName { 'Alice' } + function Invoke-ForceRemoveEdge {} + function Show-MessageBox { 'No' } + function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) } + function Invoke-RegistryOperation { param($Operation, $RegFilePath) } + + . (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Remove-SelectedApps.ps1') +} + +Describe 'Remove-SelectedApps' { + BeforeEach { + $script:Params = @{} + $script:CancelRequested = $false + $script:ApplySubStepCallback = $null + $script:WingetInstalled = $true + Mock Get-TargetUserForAppRemoval { 'AllUsers' } + Mock Get-AppRemovalMethod { 'Appx' } + Mock Remove-WinGetApp {} + Mock Remove-AppxApp {} + Mock Test-AppStillInstalled { $false } + Mock Get-WingetInstalledApps { @() } + Mock Request-EdgeForceRemove {} + Mock Write-Host {} + } + + It 'honors WhatIf without invoking either removal backend' { + $script:Params = @{ WhatIf = $true } + Remove-SelectedApps -appsList @('One.App', 'Two.App') + Should -Invoke Remove-WinGetApp -Times 0 -Exactly + Should -Invoke Remove-AppxApp -Times 0 -Exactly + Should -Invoke Write-Host -Times 2 -Exactly -ParameterFilter { $Object -like '*WhatIf*Remove App*' } + } + + It 'dispatches each app to its configured backend and target scope' { + Mock Get-AppRemovalMethod { param($appId) if ($appId -eq 'Winget.App') { 'WinGet' } else { 'Appx' } } + Remove-SelectedApps -appsList @('Winget.App', 'Appx.App') + Should -Invoke Remove-WinGetApp -Times 1 -Exactly -ParameterFilter { $app -eq 'Winget.App' } + Should -Invoke Remove-AppxApp -Times 1 -Exactly -ParameterFilter { $app -eq 'Appx.App' -and $targetUser -eq 'AllUsers' } + } + + It 'stops before the first removal when cancellation is requested' { + $script:CancelRequested = $true + Remove-SelectedApps -appsList @('One.App') + Should -Invoke Remove-WinGetApp -Times 0 -Exactly + Should -Invoke Remove-AppxApp -Times 0 -Exactly + } + + It 'prompts for forced Edge removal at most once after failed winget removals' { + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Test-AppStillInstalled { $true } + Remove-SelectedApps -appsList @('Microsoft.Edge', 'XPFFTQ037JWMHS') + Should -Invoke Request-EdgeForceRemove -Times 1 -Exactly + } +} + +Describe 'Get-AppRemovalMethod' { + BeforeEach { + $script:AppRemovalMethodCache = $null + $script:AppsListFilePath = Join-Path $TestDrive 'Apps.json' + } + + It 'caches aliases and skips malformed IDs' { + '{"Apps":[{"AppId":[" One.App ","Alias.App"],"RemovalMethod":"WinGet"},{"AppId":null},{"AppId":42},{"AppId":"Two.App"}]}' | + Set-Content -LiteralPath $script:AppsListFilePath -Encoding UTF8 + + Get-AppRemovalMethod -appId 'One.App' | Should -Be 'WinGet' + Get-AppRemovalMethod -appId 'Alias.App' | Should -Be 'WinGet' + Get-AppRemovalMethod -appId 'Two.App' | Should -Be 'Appx' + Get-AppRemovalMethod -appId 'Unknown.App' | Should -Be 'Appx' + } + + It 'warns and defaults to Appx when the catalog is malformed' { + 'not json' | Set-Content -LiteralPath $script:AppsListFilePath + Mock Write-Warning {} + Get-AppRemovalMethod -appId 'Unknown.App' | Should -Be 'Appx' + Should -Invoke Write-Warning -Times 1 -Exactly + } +} + +Describe 'Remove-WinGetApp' { + BeforeEach { + $script:Params = @{} + $script:WingetInstalled = $true + Mock Invoke-NonBlocking {} + Mock Set-RunOnceWingetTask {} + Mock Get-UserName { 'Alice' } + Mock Write-Host {} + } + + It 'reports unavailable winget without invoking or scheduling removal' { + $script:WingetInstalled = $false + Remove-WinGetApp -app 'One.App' + Should -Invoke Invoke-NonBlocking -Times 0 -Exactly + Should -Invoke Set-RunOnceWingetTask -Times 0 -Exactly + } + + It 'schedules removal only for explicit user or Sysprep targets' -ForEach @( + @{ Params = @{}; Scheduled = 0 } + @{ Params = @{ User = 'Alice' }; Scheduled = 1 } + @{ Params = @{ Sysprep = $true }; Scheduled = 1 } + ) { + $script:Params = $Params + Remove-WinGetApp -app 'One.App' + Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'One.App' } + Should -Invoke Set-RunOnceWingetTask -Times $Scheduled -Exactly -ParameterFilter { $appId -eq 'One.App' } + } +} + +Describe 'Remove-AppxApp' { + BeforeEach { Mock Invoke-NonBlocking {} } + + It 'passes the wildcard and target user data for ' -ForEach @( + @{ Target = 'AllUsers'; ExpectedArguments = 1 } + @{ Target = 'CurrentUser'; ExpectedArguments = 1 } + @{ Target = 'Alice'; ExpectedArguments = 2 } + ) { + Remove-AppxApp -app 'One.App' -targetUser $Target + Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { + @($ArgumentList).Count -eq $ExpectedArguments -and @($ArgumentList)[0] -eq '*One.App*' -and + ($ExpectedArguments -eq 1 -or @($ArgumentList)[1] -eq 'Alice') + } + } +} + +Describe 'Test-AppStillInstalled' { + BeforeEach { + $script:WingetInstalled = $true + Mock Get-AppxPackage { $null } + Mock Test-AppInWingetList { $false } + Mock Get-WingetInstalledApps { @() } + Mock Write-Warning {} + } + + It 'prefers all-user Appx detection and avoids winget lookup' { + Mock Get-AppxPackage { [PSCustomObject]@{ Name = 'One.App' } } + Test-AppStillInstalled -appId 'One.App' | Should -BeTrue + Should -Invoke Get-AppxPackage -Times 1 -Exactly -ParameterFilter { $AllUsers } + Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly + } + + It 'uses a supplied winget list without launching a live query' { + Mock Test-AppInWingetList { $true } + Test-AppStillInstalled -appId 'One.App' -InstalledList @([PSCustomObject]@{ Id = 'One.App' }) | Should -BeTrue + Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly + } + + It 'warns when a non-Appx app cannot be verified without winget' { + $script:WingetInstalled = $false + Test-AppStillInstalled -appId 'One.App' | Should -BeFalse + Should -Invoke Write-Warning -Times 1 -Exactly + } +} + +Describe 'Set-RunOnceWingetTask' { + BeforeEach { + $script:Params = @{ User = 'Alice' } + Mock Invoke-WithTargetUserHive { + param($TargetUserName, $ScriptBlock, $ArgumentObject) + & $ScriptBlock $ArgumentObject + } + $script:runOnceOperation = $null + Mock Invoke-RegistryOperation { + param($Operation) + $script:runOnceOperation = $Operation + } + } + + It 'encodes shell metacharacters and writes a safe RunOnce operation' { + Set-RunOnceWingetTask -appId "Vendor.App&'Test" + Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' } + Should -Invoke Invoke-RegistryOperation -Times 1 -Exactly -ParameterFilter { + $Operation.ValueName -eq "Uninstall_Vendor.App&'Test" -and + $RegFilePath -eq '' + } + + $script:runOnceOperation.ValueData | Should -Match '^powershell\.exe -NoProfile -EncodedCommand [A-Za-z0-9+/=]+$' + $encodedCommand = $script:runOnceOperation.ValueData -replace '^powershell\.exe -NoProfile -EncodedCommand ', '' + $decodedCommand = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand)) + $decodedCommand | Should -Be "winget uninstall --accept-source-agreements --disable-interactivity --id 'Vendor.App&''Test'" + } +} diff --git a/Tests/Replace-StartMenu.Tests.ps1 b/Tests/Replace-StartMenu.Tests.ps1 new file mode 100644 index 0000000..6a2bd1c --- /dev/null +++ b/Tests/Replace-StartMenu.Tests.ps1 @@ -0,0 +1,108 @@ +BeforeAll { + function Get-UserDirectory { param($userName, $fileName, $exitIfPathNotFound) } + + . (Join-Path $PSScriptRoot '..\Scripts\Features\Replace-StartMenu.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Telemetry-ScheduledTasks.ps1') +} + +Describe 'Get-StartMenuUserNameFromPath' { + It 'extracts a user name from a start-menu path and falls back for unknown paths' { + Get-StartMenuUserNameFromPath -StartMenuBinFile 'C:\Users\Alice\AppData\Local\Packages\Start\start2.bin' | Should -Be 'Alice' + Get-StartMenuUserNameFromPath -StartMenuBinFile 'C:\Temp\start2.bin' | Should -Be 'unknown' + } + + It 'returns the latest current-user start-menu backup' { + Mock Get-ChildItem { + @( + [PSCustomObject]@{ Name = 'Win11Debloat-StartBackup-20260101_120000.bak'; FullName = 'C:\Backups\older.bak' } + [PSCustomObject]@{ Name = 'Win11Debloat-StartBackup-20260102_120000.bak'; FullName = 'C:\Backups\newer.bak' } + ) + } + + Get-StartMenuBackupPath -Scope CurrentUser | Should -Be 'C:\Backups\newer.bak' + } + + It 'returns the first available all-users start-menu backup' { + $script:allUsersPath = 'C:\Users\*\AppData\Local\Packages\Start\LocalState' + Mock Get-UserDirectory { $script:allUsersPath } + Mock Get-ChildItem { + param($Path) + if ($Path -eq $script:allUsersPath) { + return [PSCustomObject]@{ FullName = 'C:\Users\Alice\AppData\Local\Packages\Start\LocalState' } + } + + return [PSCustomObject]@{ Name = 'Win11Debloat-StartBackup-20260103_120000.bak'; FullName = 'C:\Users\Alice\backup.bak' } + } + + Get-StartMenuBackupPath -Scope AllUsers | Should -Be 'C:\Users\Alice\backup.bak' + } + + It 'restores a start menu backup and preserves the replaced file' { + $script:Params = @{} + $startMenuFile = Join-Path $TestDrive 'start2.bin' + $backupFile = Join-Path $TestDrive 'Win11Debloat-StartBackup-20260101_120000.bak' + Set-Content -LiteralPath $startMenuFile -Value 'current' + Set-Content -LiteralPath $backupFile -Value 'backup' + + $result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath $backupFile + + $result.Result | Should -BeTrue + Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'backup' + @(Get-ChildItem -LiteralPath $TestDrive -Filter 'Win11Debloat-StartRestore-*.bak').Count | Should -Be 1 + } + + It 'reports a missing backup without changing the start-menu file' { + $script:Params = @{} + $startMenuFile = Join-Path $TestDrive 'start2.bin' + Set-Content -LiteralPath $startMenuFile -Value 'current' + + $result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath (Join-Path $TestDrive 'missing.bak') + + $result.Result | Should -BeFalse + Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current' + } + + It 'delegates current-user restore to the common backup operation' { + $script:Params = @{} + Mock Restore-StartMenuFromBackup { [PSCustomObject]@{ Result = $true } } + + Restore-StartMenu -BackupFilePath 'C:\Backups\backup.bak' | Should -Not -BeNullOrEmpty + Should -Invoke Restore-StartMenuFromBackup -Times 1 -Exactly -ParameterFilter { $BackupFilePath -eq 'C:\Backups\backup.bak' } + } + + It 'restores every discovered user and removes the default-profile start menu' { + $script:Params = @{} + $script:allUsersStartPath = 'C:\Users\*\LocalState' + $script:defaultStartPath = Join-Path $TestDrive 'DefaultLocalState' + $defaultBin = Join-Path $script:defaultStartPath 'start2.bin' + New-Item -ItemType Directory -Path $script:defaultStartPath | Out-Null + Set-Content -LiteralPath $defaultBin -Value 'template' + Mock Get-UserDirectory { param($userName) if ($userName -eq '*') { $script:allUsersStartPath } else { $script:defaultStartPath } } + Mock Get-ChildItem { param($Path) if ($Path -eq $script:allUsersStartPath) { [PSCustomObject]@{ FullName = 'C:\Users\Alice\LocalState' } } } + Mock Restore-StartMenuFromBackup { [PSCustomObject]@{ UserName = 'Alice'; Result = $true; Message = 'Restored' } } + + $result = @(Restore-StartMenuForAllUsers -BackupFilePath 'C:\Backups\backup.bak') + + $result.Count | Should -Be 2 + Should -Invoke Restore-StartMenuFromBackup -Times 1 -Exactly + Test-Path -LiteralPath $defaultBin | Should -BeFalse + } +} + +Describe 'Get-TelemetryScheduledTasks' { + It 'returns the expected telemetry task catalog' { + $tasks = @(Get-TelemetryScheduledTasks) + + $tasks.Count | Should -Be 8 + @($tasks | ForEach-Object { "$($_.Path)|$($_.Name)" }) | Should -Be @( + '\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser' + '\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser Exp' + '\Microsoft\Windows\Application Experience\|ProgramDataUpdater' + '\Microsoft\Windows\Application Experience\|StartupAppTask' + '\Microsoft\Windows\Customer Experience Improvement Program\|Consolidator' + '\Microsoft\Windows\Customer Experience Improvement Program\|UsbCeip' + '\Microsoft\Windows\DiskDiagnostic\|Microsoft-Windows-DiskDiagnosticDataCollector' + '\Microsoft\Windows\Autochk\|Proxy' + ) + } +} diff --git a/Tests/Resolve-UserProfilePath.Tests.ps1 b/Tests/Resolve-UserProfilePath.Tests.ps1 new file mode 100644 index 0000000..fc6ddc1 --- /dev/null +++ b/Tests/Resolve-UserProfilePath.Tests.ps1 @@ -0,0 +1,141 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Resolve-UserProfilePath.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-UserProfileExists.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-UserDirectory.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-ModernStandbySupport.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-RestartExplorer.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Threading\Invoke-DoEvents.ps1') + + function powercfg { $script:PowerCfgOutput } + function Wait-ForKeyPress {} + function Get-RebootFeatureLabels { @() } +} + +Describe 'Test-UserProfileExists' { + BeforeEach { + Mock Resolve-UserProfileContext { + [PSCustomObject]@{ ProfilePath = 'C:\Users\Alice'; UserSid = 'S-1-5-21-1000' } + } + Mock Wait-ForKeyPress {} + Mock Write-Error {} + } + + It 'rejects blank and path-unsafe user names before resolving a profile' { + Test-UserProfileExists -userName '' | Should -BeFalse + Test-UserProfileExists -userName 'Alice[1]' | Should -BeFalse + Should -Invoke Resolve-UserProfileContext -Times 0 -Exactly + } + + It 'accepts a resolved ordinary user and the Default profile without a SID' { + Test-UserProfileExists -userName ' Alice ' | Should -BeTrue + Mock Resolve-UserProfileContext { [PSCustomObject]@{ ProfilePath = 'C:\Users\Default'; UserSid = $null } } + Test-UserProfileExists -userName 'Default' | Should -BeTrue + } + + It 'returns a resolved profile path and optionally appends a file name' { + Mock Test-Path { $true } + + Get-UserDirectory -userName 'Alice' | Should -Be 'C:\Users\Alice' + Get-UserDirectory -userName 'Alice' -fileName 'AppData\Local' | Should -Be 'C:\Users\Alice\AppData\Local' + } +} + +Describe 'Test-ModernStandbySupport' { + BeforeEach { + $script:Params = @{} + Mock Write-Host {} + } + + It 'detects S0 Modern Standby from powercfg output' { + $script:PowerCfgOutput = @('The following sleep states are available on this system:', ' Standby (S0 Low Power Idle) Network Connected') + + Test-ModernStandbySupport | Should -BeTrue + } + + It 'does not restart Explorer for WhatIf or an explicit skip' { + Mock Stop-Process {} + $script:Params = @{ WhatIf = $true } + Invoke-RestartExplorer + $script:Params = @{ NoRestartExplorer = $true } + Invoke-RestartExplorer + + Should -Invoke Stop-Process -Times 0 -Exactly + } + + It 'restarts Explorer when allowed and reports reboot-required features' { + Mock Stop-Process {} + Mock Get-RebootFeatureLabels { @('Disable telemetry') } + + Invoke-RestartExplorer + + Should -Invoke Stop-Process -Times 1 -Exactly -ParameterFilter { $processName -eq 'Explorer' -and $Force } + Should -Invoke Get-RebootFeatureLabels -Times 1 -Exactly + } +} + +Describe 'Resolve-UserSid' { + BeforeEach { + $script:ResolvedUserSidCache = @{} + $script:MachineDomainJoinStateKnown = $null + $script:MachineIsDomainJoined = $false + $script:MachineNetBiosDomain = '' + } + + It 'builds domain-aware candidate forms and compares profile folder leaves' { + Mock Get-ProfileFolderDomainSuffix { 'CONTOSO' } + + Get-UserNameMatchCandidates -Value 'CONTOSO\Alice' | Should -Be @('CONTOSO\Alice', 'Alice', 'Alice.CONTOSO') + Test-UserNameMatchesProfileLeaf -UserName 'CONTOSO\Alice' -ProfileLeaf 'Alice.CONTOSO' | Should -BeTrue + } + + It 'prefers the NetBIOS domain source and falls back to the DNS label' { + Mock Get-CimInstance { [PSCustomObject]@{ DomainName = 'CONTOSO'; DomainControllerName = '\\dc01.contoso.com' } } + Resolve-NetBiosDomainName -RawDomain 'contoso.com' | Should -Be 'CONTOSO' + + Mock Get-CimInstance { throw 'CIM unavailable' } + Resolve-NetBiosDomainName -RawDomain 'contoso.com' | Should -Be 'contoso' + } + + It 'stores and retrieves resolved SIDs through normalized cache keys' { + Set-ResolvedUserSidCache -Candidates @('Alice', 'CONTOSO\Alice') -Sid 'S-1-5-21-1000' + + Get-CachedResolvedUserSid -Candidates @('contoso\alice') | Should -Be 'S-1-5-21-1000' + } + + It 'returns a local-user SID before falling back to CIM' { + Mock Get-Command { [PSCustomObject]@{ Name = 'Get-LocalUser' } } -ParameterFilter { $Name -eq 'Get-LocalUser' } + Mock Get-LocalUser { [PSCustomObject]@{ SID = [PSCustomObject]@{ Value = 'S-1-5-21-1000' } } } + Mock Get-CimInstance { throw 'CIM should not be queried' } + + Try-ResolveSidByLocalLookup -Candidates @('Alice') | Should -Be 'S-1-5-21-1000' + Should -Invoke Get-CimInstance -Times 0 -Exactly + } + + It 'recovers a workgroup SID from a matching ProfileList folder' { + Mock Test-MachineIsDomainJoined { $false } + Mock Get-ChildItem { [PSCustomObject]@{ PSPath = 'Registry::ProfileList\S-1-5-21-1000'; PSChildName = 'S-1-5-21-1000' } } + Mock Get-ItemPropertyValue { 'C:\Users\Alice' } + + Try-ResolveSidFromProfileList -Candidates @('Alice') | Should -Be 'S-1-5-21-1000' + } + + It 'constructs user contexts and resolves a workgroup SID through NTAccount' { + $context = New-ResolvedUserContext -UserName 'Alice' -UserSid 'S-1-5-21-1000' -ProfilePath 'C:\Users\Alice' + $context.UserName | Should -Be 'Alice' + $context.UserSid | Should -Be 'S-1-5-21-1000' + + Mock Get-CachedResolvedUserSid { $null } + Mock Test-MachineIsDomainJoined { $false } + Mock Try-ResolveSidByNtAccount { 'S-1-5-21-1000' } + Mock Set-ResolvedUserSidCache {} + + Resolve-UserSid -UserName 'Alice' | Should -Be 'S-1-5-21-1000' + Should -Invoke Try-ResolveSidByNtAccount -Times 1 -Exactly -ParameterFilter { $UserName -eq 'Alice' } + } + + It 'does not qualify blank names or process UI events without a GUI window' { + Get-QualifiedProcessIdentityName -Candidate '' | Should -BeNullOrEmpty + $script:GuiWindow = $null + { Invoke-DoEvents } | Should -Not -Throw + } +} diff --git a/Tests/Restore-BackupDialogFeatureLists.Tests.ps1 b/Tests/Restore-BackupDialogFeatureLists.Tests.ps1 new file mode 100644 index 0000000..18b8277 --- /dev/null +++ b/Tests/Restore-BackupDialogFeatureLists.Tests.ps1 @@ -0,0 +1,57 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\Scripts\GUI\Restore-BackupDialogFeatureLists.ps1') +} + +Describe 'New-RestoreDialogState' { + BeforeEach { + $script:Features = @{ + RegistryFeature = [PSCustomObject]@{ Label = 'Registry feature'; Category = 'Privacy'; RegistryKey = 'RegistryFeature.reg' } + CustomFeature = [PSCustomObject]@{ Label = 'Custom feature'; Category = 'Privacy'; RegistryKey = '' } + HiddenFeature = [PSCustomObject]@{ Label = 'Hidden feature'; Category = ''; RegistryKey = 'HiddenFeature.reg' } + } + } + + It 'creates a dialog state with the supplied values' { + $backup = [PSCustomObject]@{ SelectedFeatures = @('RegistryFeature') } + + $state = New-RestoreDialogState -Result 'OK' -SelectedFile 'C:\Backups\backup.json' -Backup $backup + + $state.Result | Should -Be 'OK' + $state.SelectedFile | Should -Be 'C:\Backups\backup.json' + $state.Backup | Should -Be $backup + } + + It 'looks up feature definitions and resolves display labels with fallbacks' { + Get-RestoreDialogFeatureDefinition -FeatureId 'RegistryFeature' -Features $script:Features | Should -Be $script:Features.RegistryFeature + Get-RestoreDialogFeatureDefinition -FeatureId 'MissingFeature' -Features $script:Features | Should -BeNullOrEmpty + Get-RestoreDialogFeatureDisplayLabel -FeatureId 'RegistryFeature' -Features $script:Features | Should -Be 'Registry feature' + Get-RestoreDialogFeatureDisplayLabel -FeatureId 'MissingFeature' -Features $script:Features | Should -Be 'MissingFeature' + Get-RestoreDialogFeatureDisplayLabel -FeatureId '' -Features $script:Features | Should -Be 'Unknown feature' + } + + It 'identifies visible, automatically revertible features' { + Test-RestoreDialogFeatureCanAutoRevert -FeatureId 'RegistryFeature' -Features $script:Features | Should -BeTrue + Test-RestoreDialogFeatureCanAutoRevert -FeatureId 'CustomFeature' -Features $script:Features | Should -BeFalse + Test-RestoreDialogFeatureVisibleInOverview -FeatureId 'RegistryFeature' -Features $script:Features | Should -BeTrue + Test-RestoreDialogFeatureVisibleInOverview -FeatureId 'HiddenFeature' -Features $script:Features | Should -BeFalse + } + + It 'deduplicates and combines forward and undo feature IDs' { + $backup = [PSCustomObject]@{ + SelectedFeatures = @('RegistryFeature', 'registryfeature', '', 'CustomFeature') + SelectedUndoFeatures = @('CustomFeature', 'HiddenFeature', 'hiddenfeature') + } + + Get-SelectedForwardFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('RegistryFeature', 'CustomFeature') + Get-SelectedUndoFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('CustomFeature', 'HiddenFeature') + Get-CombinedSelectedFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('RegistryFeature', 'CustomFeature', 'HiddenFeature') + Get-SelectedFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('RegistryFeature', 'CustomFeature', 'HiddenFeature') + } + + It 'separates visible feature labels into revertible and manual lists' { + $lists = Get-RestoreBackupFeatureLists -SelectedFeatureIds @('RegistryFeature', 'CustomFeature', 'HiddenFeature', 'MissingFeature') -Features $script:Features + + $lists.Revertible.DisplayText | Should -Be '- Registry feature' + $lists.NonRevertible.DisplayText | Should -Be '- Custom feature' + } +} diff --git a/Tests/Restore-RegistryApplyState.Tests.ps1 b/Tests/Restore-RegistryApplyState.Tests.ps1 new file mode 100644 index 0000000..38406b2 --- /dev/null +++ b/Tests/Restore-RegistryApplyState.Tests.ps1 @@ -0,0 +1,71 @@ +BeforeAll { + $restoreApplyStateScriptPath = Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1' + . $restoreApplyStateScriptPath +} + +Describe 'Convert-RegistryValueKindFromBackup' { + It 'defaults a missing kind to String' { + Convert-RegistryValueKindFromBackup -KindName $null | Should -Be ([Microsoft.Win32.RegistryValueKind]::String) + } + + It '' -ForEach @( + @{ Case = 'parses registry kinds case-insensitively'; KindName = 'dword'; Expected = [Microsoft.Win32.RegistryValueKind]::DWord; ExpectedError = $null } + @{ Case = 'rejects an invalid registry kind'; KindName = 'NotARegistryValueKind'; Expected = $null; ExpectedError = 'Unsupported registry value kind in backup: NotARegistryValueKind' } + ) { + if ($ExpectedError) { + { Convert-RegistryValueKindFromBackup -KindName $KindName } | Should -Throw $ExpectedError + } + else { + Convert-RegistryValueKindFromBackup -KindName $KindName | Should -Be $Expected + } + } +} + +Describe 'Convert-RegistryValueDataFromBackup' { + It 'preserves the bit pattern of an unsigned ' -ForEach @( + @{ Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Data = [uint32]::MaxValue; Expected = -1 } + @{ Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Data = [uint64]::MaxValue; Expected = -1L } + ) { + Convert-RegistryValueDataFromBackup -Kind $Kind -Data $Data | Should -Be $Expected + } + + It 'converts ' -ForEach @( + @{ Case = 'a multi-string value'; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Data = @(1, 'two'); Expected = @('1', 'two'); ExpectNull = $false } + @{ Case = 'a binary value'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = @('1', 255); Expected = [byte[]](1, 255); ExpectNull = $false } + @{ Case = 'a null string to an empty string'; Kind = [Microsoft.Win32.RegistryValueKind]::String; Data = $null; Expected = ''; ExpectNull = $false } + ) { + $result = Convert-RegistryValueDataFromBackup -Kind $Kind -Data $Data + if ($ExpectNull) { + $result | Should -BeNullOrEmpty + } + else { + $result | Should -Be $Expected + if ($Kind -eq [Microsoft.Win32.RegistryValueKind]::MultiString) { + $result.GetType() | Should -Be ([string[]]) + } + } + } + + It 'rejects binary backup data that cannot be represented as bytes' { + { Convert-RegistryValueDataFromBackup -Kind ([Microsoft.Win32.RegistryValueKind]::Binary) -Data @(-1, 256, 'invalid') } | + Should -Throw 'Invalid binary registry data in backup*' + } + + It 'preserves an empty binary value as a byte array' { + $result = Convert-RegistryValueDataFromBackup -Kind ([Microsoft.Win32.RegistryValueKind]::Binary) -Data $null + $result | Should -BeNullOrEmpty + $result.GetType() | Should -Be ([byte[]]) + } + +} + +Describe 'Convert-BackupDataToByteArray' { + It 'rejects ' -ForEach @( + @{ Case = 'an object value'; Data = [PSCustomObject]@{ Value = 1 } } + @{ Case = 'a negative byte'; Data = @(-1) } + @{ Case = 'a byte greater than 255'; Data = @(256) } + @{ Case = 'non-numeric input'; Data = @('invalid') } + ) { + Convert-BackupDataToByteArray -Data $Data | Should -BeNullOrEmpty + } +} diff --git a/Tests/Restore-RegistryBackup.Tests.ps1 b/Tests/Restore-RegistryBackup.Tests.ps1 new file mode 100644 index 0000000..a82afc3 --- /dev/null +++ b/Tests/Restore-RegistryBackup.Tests.ps1 @@ -0,0 +1,147 @@ +BeforeAll { + function Test-RunningAsSystem { $false } + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-FriendlyRegistryBackupTarget.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Resolve-UserProfilePath.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-TargetUserName.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Registry-BackupValidation.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryBackup.ps1') + $script:JsonFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading' +} + +Describe 'Import-RegistryBackup' { + It 'loads JSON and passes the parsed backup to normalization' { + Mock ConvertTo-NormalizedRegistryBackup { [PSCustomObject]@{ Target = 'DefaultUserProfile' } } + + $result = Import-RegistryBackup -FilePath (Join-Path $script:JsonFixturePath 'RegistryBackup.Valid.json') + + $result.Target | Should -Be 'DefaultUserProfile' + Should -Invoke ConvertTo-NormalizedRegistryBackup -Times 1 -Exactly + } + + It 'parses and normalizes registry snapshots from a real backup structure' { + Mock Test-UserNameMatch { $true } + Mock Test-RegistryBackupMatchesSelectedFeatures { @() } + + $result = Import-RegistryBackup -FilePath (Join-Path $script:JsonFixturePath 'RegistryBackup.RealStructure.json') + + $result.Target | Should -Be 'CurrentUser:fixture-user' + $result.SelectedFeatures | Should -Be @('RemoveApps', 'DisableStickyKeys') + $result.SelectedUndoFeatures | Should -Be 'DisableTransparency' + $result.RegistryKeys | Should -HaveCount 2 + $result.RegistryKeys[0].Path | Should -Be 'HKEY_CURRENT_USER\Control Panel\Accessibility\StickyKeys' + $result.RegistryKeys[0].Values[0].Name | Should -Be 'Flags' + $result.RegistryKeys[0].Values[0].Kind | Should -Be 'String' + $result.RegistryKeys[0].Values[0].Data | Should -Be '510' + $result.RegistryKeys[1].Path | Should -Be 'HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' + $result.RegistryKeys[1].Values[0].Name | Should -Be 'EnableTransparency' + $result.RegistryKeys[1].Values[0].Kind | Should -Be 'DWord' + $result.RegistryKeys[1].Values[0].Data | Should -Be 0 + Should -Invoke Test-RegistryBackupMatchesSelectedFeatures -Times 1 -Exactly + } + + It 'rejects ' -ForEach @( + @{ Case = 'a missing backup file'; FileName = 'missing.json'; ExpectedError = 'Backup file was not found:*' } + @{ Case = 'an invalid JSON backup file'; FileName = 'RegistryBackup.Invalid.json'; ExpectedError = $null } + ) { + $path = Join-Path $script:JsonFixturePath $FileName + $errorPattern = if ($null -ne $ExpectedError) { $ExpectedError } else { "Failed to read backup file '$path'. The file is not valid JSON." } + + { Import-RegistryBackup -FilePath $path } | Should -Throw $errorPattern + } +} + +Describe 'ConvertTo-NormalizedRegistryBackup' { + BeforeEach { + $script:Features = @{ Example = [PSCustomObject]@{ FeatureId = 'Example'; RegistryKey = 'Example.reg' } } + Mock Test-RegistryBackupMatchesSelectedFeatures { @() } + Mock Write-Error {} + } + + It 'normalizes a valid legacy backup without undo selections' { + $backup = [PSCustomObject]@{ + Version = '1.0'; BackupType = 'RegistryState'; Target = 'DefaultUserProfile' + CreatedAt = '2026-01-01T00:00:00.0000000Z'; CreatedBy = 'Win11Debloat'; ComputerName = 'PC' + SelectedFeatures = @('Example', 'example'); RegistryKeys = @() + } + + $result = ConvertTo-NormalizedRegistryBackup -Backup $backup + + $result.Target | Should -Be 'DefaultUserProfile' + $result.SelectedFeatures -is [array] | Should -BeTrue + $result.SelectedFeatures | Should -Be 'Example' + $result.SelectedUndoFeatures | Should -BeNullOrEmpty + Should -Invoke Test-RegistryBackupMatchesSelectedFeatures -Times 1 -Exactly + } + + It 'aggregates invalid metadata and does not attempt allow-list validation without feature IDs' { + $backup = [PSCustomObject]@{ Version = '2.0'; BackupType = 'Other'; Target = 'Unknown'; RegistryKeys = @() } + + { ConvertTo-NormalizedRegistryBackup -Backup $backup } | Should -Throw 'Validation failed with * errors. See console output for details.' + + Should -Invoke Write-Error -Times 1 -Exactly + Should -Invoke Test-RegistryBackupMatchesSelectedFeatures -Times 0 -Exactly + } + + It 'reports an invalid user target as a validation failure' { + Mock Test-TargetUserName { [PSCustomObject]@{ IsValid = $false } } + $backup = [PSCustomObject]@{ + Version = '1.0'; BackupType = 'RegistryState'; Target = 'User:bad/user' + SelectedFeatures = @('Example'); RegistryKeys = @() + } + + { ConvertTo-NormalizedRegistryBackup -Backup $backup } | Should -Throw "Validation failed: Invalid user 'User:bad/user'" + } + + It 'does not allow current-user backup restore when running as SYSTEM' { + Mock Test-RunningAsSystem { $true } + $backup = [PSCustomObject]@{ + Version = '1.0'; BackupType = 'RegistryState'; Target = 'CurrentUser:Alice' + SelectedFeatures = @('Example'); RegistryKeys = @() + } + + { ConvertTo-NormalizedRegistryBackup -Backup $backup } | + Should -Throw "Validation failed: Backup was made for 'Alice' and is user-scoped*" + } +} + +Describe 'Restore-RegistryBackupState' { + BeforeEach { + $script:Params = @{} + Mock Get-FriendlyRegistryBackupTarget { 'friendly target' } + Mock Restore-RegistryKeySnapshot {} + Mock Invoke-WithLoadedRestoreHive {} + Mock Write-Host {} + $script:backup = [PSCustomObject]@{ Target = 'CurrentUser:Tester'; RegistryKeys = @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\One' }, [PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Two' }) } + } + + It 'restores every root snapshot directly for the current user' { + (Restore-RegistryBackupState -Backup $script:backup).Result | Should -BeTrue + + Should -Invoke Restore-RegistryKeySnapshot -Times 2 -Exactly + Should -Invoke Invoke-WithLoadedRestoreHive -Times 0 -Exactly + } + + It 'delegates default-profile restores through the loaded hive wrapper' { + $script:backup.Target = 'DefaultUserProfile' + Mock Invoke-WithLoadedRestoreHive { + param($Target, $ScriptBlock, $ArgumentObject) + & $ScriptBlock $ArgumentObject + } + + (Restore-RegistryBackupState -Backup $script:backup).Result | Should -BeTrue + + Should -Invoke Invoke-WithLoadedRestoreHive -Times 1 -Exactly -ParameterFilter { $Target -eq 'DefaultUserProfile' } + Should -Invoke Restore-RegistryKeySnapshot -Times 2 -Exactly + } + + It 'honors WhatIf without restoring snapshots or loading a hive' { + $script:Params = @{ WhatIf = $true } + + (Restore-RegistryBackupState -Backup $script:backup).Result | Should -BeTrue + + Should -Invoke Restore-RegistryKeySnapshot -Times 0 -Exactly + Should -Invoke Invoke-WithLoadedRestoreHive -Times 0 -Exactly + } +} diff --git a/Tests/Settings-And-Apps-FileIO.Tests.ps1 b/Tests/Settings-And-Apps-FileIO.Tests.ps1 new file mode 100644 index 0000000..d84cb06 --- /dev/null +++ b/Tests/Settings-And-Apps-FileIO.Tests.ps1 @@ -0,0 +1,189 @@ +BeforeAll { + function Wait-ForKeyPress {} + function Test-AppInWingetList { param($appId, $InstalledList) $false } + + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\Add-Parameter.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Save-ToFile.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-Settings.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Save-Settings.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-AppsFromFile.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-AppDetailsFromJson.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-AppPresetsFromJson.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\FileIO\Get-ValidatedAppList.ps1') + $script:JsonFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading' +} + +Describe 'Import-Settings' { + BeforeEach { + $script:Params = @{} + $script:ModernStandbySupported = $false + $script:Features = @{ + Supported = [PSCustomObject]@{ FeatureId = 'Supported'; MinVersion = 22000; MaxVersion = 30000 } + TooNew = [PSCustomObject]@{ FeatureId = 'TooNew'; MinVersion = 99999; MaxVersion = $null } + DisableModernStandbyNetworking = [PSCustomObject]@{ FeatureId = 'DisableModernStandbyNetworking'; MinVersion = $null; MaxVersion = $null } + } + Mock Get-ItemPropertyValue { 22631 } + Mock Add-Parameter {} + Mock Write-Error {} + } + + It 'loads enabled, known, compatible settings and skips all other entries' { + Import-Settings -filePath (Join-Path $script:JsonFixturePath 'DefaultSettings.Valid.json') + + Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $parameterName -eq 'Supported' -and $value -eq 'configured' } + } + + It 'throws when ' -ForEach @( + @{ Case = 'the last-used settings JSON is invalid'; FileName = 'LastUsedSettings.Invalid.json'; WriteErrorCalls = 1 } + @{ Case = 'the default settings file has no Settings property'; FileName = 'DefaultSettings.MissingSettings.json'; WriteErrorCalls = 0 } + ) { + $path = Join-Path $script:JsonFixturePath $FileName + + { + & { + $ErrorActionPreference = 'Continue' + Import-Settings -filePath $path + } + } | Should -Throw "Failed to load settings from $FileName" + Should -Invoke Add-Parameter -Times 0 -Exactly + Should -Invoke Write-Error -Times $WriteErrorCalls -Exactly + } +} + +Describe 'Save-Settings' { + BeforeEach { + $script:SavedSettingsFilePath = Join-Path $TestDrive 'LastUsedSettings.json' + $script:ControlParams = @('Silent', 'WhatIf') + $script:Features = @{ Feature = [PSCustomObject]@{ FeatureId = 'Feature' } } + Mock Save-ToFile { $true } + Mock Write-Host {} + Mock Write-Output {} + } + + It 'saves only feature parameters and excludes control or unknown parameters' { + $script:Params = @{ Feature = 'configured'; Silent = $true; Unknown = 42 } + + Save-Settings + + Should -Invoke Save-ToFile -Times 1 -Exactly -ParameterFilter { + $FilePath -eq $script:SavedSettingsFilePath -and + @($Config.Settings).Count -eq 1 -and + $Config.Settings[0].Name -eq 'Feature' -and + $Config.Settings[0].Value -eq 'configured' + } + } + + It 'does not persist in WhatIf mode' { + $script:Params = @{ Feature = $true; WhatIf = $true } + + Save-Settings + + Should -Invoke Save-ToFile -Times 0 -Exactly + } + + It 'reports a persistence failure without throwing' { + $script:Params = @{ Feature = $true } + Mock Save-ToFile { $false } + + { Save-Settings } | Should -Not -Throw + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like 'Error:*' } + } +} + +Describe 'Import-AppsFromFile' { + It 'returns selected IDs, trims whitespace, and supports scalar and array AppId values' { + $path = Join-Path $TestDrive 'apps.json' + '{"Apps":[{"AppId":" One.App ","SelectedByDefault":true},{"AppId":["Two.App"," "],"SelectedByDefault":true},{"AppId":"Ignored.App","SelectedByDefault":false}]}' | + Set-Content -LiteralPath $path -Encoding UTF8 + + @(Import-AppsFromFile -appsFilePath $path) | Should -Be @('One.App', 'Two.App') + } + + It 'returns an empty collection for a missing file' { + @(Import-AppsFromFile -appsFilePath (Join-Path $TestDrive 'missing.json')) | Should -HaveCount 0 + } + + It 'reports invalid JSON and invokes the CLI acknowledgement hook' { + $path = Join-Path $TestDrive 'invalid-apps.json' + 'not json' | Set-Content -LiteralPath $path + Mock Write-Error {} + Mock Wait-ForKeyPress {} + + @(Import-AppsFromFile -appsFilePath $path) | Should -HaveCount 0 + Should -Invoke Write-Error -Times 1 -Exactly + Should -Invoke Wait-ForKeyPress -Times 1 -Exactly + } +} + +Describe 'Get-ValidatedAppList' { + It 'normalizes wildcard selections and skips unsupported applications' { + Mock Import-AppDetailsFromJson { @([PSCustomObject]@{ AppId = @('One.App', 'Two.App') }) } + Mock Write-Host {} + + @(Get-ValidatedAppList -appsList @('*One.App*', ' Missing.App ')) | Should -Be @('One.App') + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*Missing.App*" } + } +} + +Describe 'Import-AppDetailsFromJson' { + BeforeEach { + $script:AppsListFilePath = Join-Path $TestDrive 'Apps.json' + '{"Apps":[{"AppId":["One.App","Alias.App"],"FriendlyName":"One","SelectedByDefault":true,"RemovalMethod":"WinGet"},{"AppId":"Two.App","SelectedByDefault":false},{"AppId":" ","SelectedByDefault":true}],"Presets":[{"Name":"Minimal","AppIds":["One.App"]}]}' | + Set-Content -LiteralPath $script:AppsListFilePath -Encoding UTF8 + } + + It 'projects app metadata, defaults removal method, and honors initial checked state' { + $apps = @(Import-AppDetailsFromJson -InitialCheckedFromJson) + + $apps | Should -HaveCount 2 + $apps[0].DisplayName | Should -Be 'One (One.App, Alias.App)' + $apps[0].IsChecked | Should -BeTrue + $apps[0].RemovalMethod | Should -Be 'WinGet' + $apps[1].FriendlyName | Should -Be 'Two.App' + $apps[1].RemovalMethod | Should -Be 'Appx' + $apps[1].AppId -is [array] | Should -BeTrue + @($apps[1].AppId) | Should -HaveCount 1 + } + + It 'skips missing, blank, and non-string app IDs without failing the complete catalog' { + '{"Apps":[{"AppId":null},{"FriendlyName":"Missing"},{"AppId":42},{"AppId":[" Valid.App ",null,{},""]}]}' | + Set-Content -LiteralPath $script:AppsListFilePath -Encoding UTF8 + + $apps = @(Import-AppDetailsFromJson) + + $apps | Should -HaveCount 1 + $apps[0].AppId -is [array] | Should -BeTrue + $apps[0].AppId | Should -Be @('Valid.App') + } + + It 'filters to installed apps using Appx and winget detection' { + Mock Get-AppxPackage { param($Name) if ($Name -eq 'Two.App') { [PSCustomObject]@{ Name = $Name } } } + Mock Test-AppInWingetList { $false } + + $apps = @(Import-AppDetailsFromJson -OnlyInstalled -InstalledList @()) + + $apps | Should -HaveCount 1 + $apps[0].AppId | Should -Be 'Two.App' + } + + It 'loads presets and preserves their ID arrays' { + $presets = @(Import-AppPresetsFromJson) + + $presets | Should -HaveCount 1 + $presets[0].Name | Should -Be 'Minimal' + $presets[0].AppIds -is [array] | Should -BeTrue + $presets[0].AppIds | Should -Be 'One.App' + } + + It 'returns empty collections and reports malformed JSON' { + 'not json' | Set-Content -LiteralPath $script:AppsListFilePath + Mock Write-Error {} + Mock Write-Warning {} + + @(Import-AppDetailsFromJson) | Should -HaveCount 0 + @(Import-AppPresetsFromJson) | Should -HaveCount 0 + Should -Invoke Write-Error -Times 1 -Exactly + Should -Invoke Write-Warning -Times 1 -Exactly + } +} diff --git a/Tests/Show-CliMenuOptions.Tests.ps1 b/Tests/Show-CliMenuOptions.Tests.ps1 new file mode 100644 index 0000000..35e0f6b --- /dev/null +++ b/Tests/Show-CliMenuOptions.Tests.ps1 @@ -0,0 +1,113 @@ +BeforeAll { + function Get-UserName { 'Alice' } + function Generate-AppsList { @() } + function Add-Parameter { param($Name, $Value) } + function Save-Settings {} + function Import-Settings { param($filePath, $expectedVersion) } + function Wait-ForKeyPress {} + function Show-AppSelectionWindow { $false } + + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Write-CliHeader.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Write-PendingChanges.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliAppRemoval.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliDefaultModeAppRemovalOptions.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliDefaultModeOptions.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliLastUsedSettings.ps1') + . (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliMenuOptions.ps1') +} + +Describe 'Show-CliMenuOptions' { + BeforeEach { + $script:Params = @{} + $script:ControlParams = @('WhatIf', 'Silent') + $script:Features = @{ DisableTelemetry = [PSCustomObject]@{ Label = 'Disable telemetry' }; CreateRestorePoint = [PSCustomObject]@{ Label = 'Create restore point' } } + $script:SelectedApps = @('One.App', 'Two.App') + $script:SavedSettingsFilePath = 'C:\Settings\LastUsedSettings.json' + $script:DefaultSettingsFilePath = 'C:\Settings\DefaultSettings.json' + $script:InputQueue = @() + $script:InputIndex = 0 + $script:Silent = $false + $script:RunDefaults = $false + $script:RunDefaultsLite = $false + Mock Clear-Host {} + Mock Write-Host {} + Mock Write-Output {} + Mock Read-Host { $script:InputQueue[$script:InputIndex++] } + Mock Get-UserName { 'Alice' } + Mock Generate-AppsList { @('One.App', 'Two.App') } + Mock Add-Parameter {} + Mock Save-Settings {} + Mock Import-Settings {} + Mock Show-AppSelectionWindow { $true } + Mock Test-Path { $true } + } + + It 'prints user or Sysprep context in the CLI header' { + $script:Params = @{} + Write-CliHeader -title 'Menu' + $script:Params = @{ Sysprep = $true } + Write-CliHeader -title 'Menu' + + Should -Invoke Write-Host -Times 6 -Exactly + Should -Invoke Get-UserName -Times 1 -Exactly + } + + It 'summarizes selected features and app removal before confirmation' { + $script:Params = @{ DisableTelemetry = $true; RemoveApps = $true; Apps = 'Default' } + $script:ControlParams = @('WhatIf', 'Silent', 'Apps') + $script:InputQueue = @('') + + Write-PendingChanges + + Should -Invoke Generate-AppsList -Times 1 -Exactly + Should -Invoke Read-Host -Times 1 -Exactly + Should -Invoke Write-Output -Times 2 -ParameterFilter { $InputObject -like '- *' } + } + + It 'returns the saved-settings menu option only when the file is available' { + $script:InputQueue = @('3') + + Show-CliMenuOptions | Should -Be '3' + Should -Invoke Test-Path -Times 2 -Exactly + } + + It 're-prompts after a cancelled manual app selection and accepts no removal' { + Mock Show-AppSelectionWindow { $false } + $script:InputQueue = @('2', 'n') + + Show-CliDefaultModeAppRemovalOptions | Should -Be 'n' + Should -Invoke Show-AppSelectionWindow -Times 1 -Exactly + Should -Invoke Read-Host -Times 2 -Exactly + } + + It 'records selected app removal and skips confirmation in silent mode' { + $script:Silent = $true + + Show-CliAppRemoval + + Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'RemoveApps' } + Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'Apps' -and $Value -eq 'One.App,Two.App' } + Should -Invoke Save-Settings -Times 1 -Exactly + Should -Invoke Read-Host -Times 0 -Exactly + } + + It 'loads saved settings without prompting in silent mode' { + $script:Silent = $true + + Show-CliLastUsedSettings + + Should -Invoke Import-Settings -Times 1 -Exactly -ParameterFilter { $filePath -eq $script:SavedSettingsFilePath -and $expectedVersion -eq '1.0' } + Should -Invoke Read-Host -Times 0 -Exactly + } + + It 'applies default app-removal parameters for the RunDefaults switch' { + $script:RunDefaults = $true + $script:Silent = $true + + Show-CliDefaultModeOptions + + Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'RemoveApps' } + Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'Apps' -and $Value -eq 'Default' } + Should -Invoke Import-Settings -Times 1 -Exactly + } +} diff --git a/Tests/Test-AppInWingetList.Tests.ps1 b/Tests/Test-AppInWingetList.Tests.ps1 new file mode 100644 index 0000000..babd484 --- /dev/null +++ b/Tests/Test-AppInWingetList.Tests.ps1 @@ -0,0 +1,33 @@ +BeforeAll { + $wingetListScriptPath = Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Test-AppInWingetList.ps1' + . $wingetListScriptPath +} + +Describe 'Test-AppInWingetList' { + BeforeAll { + $installedApps = @( + [PSCustomObject]@{ Id = 'Microsoft.Copilot' } + [PSCustomObject]@{ Id = 'Microsoft.EdgeDev' } + [PSCustomObject]@{ Id = 'Contoso.Music-Player' } + ) + } + + It 'matches an exact winget ID' { + Test-AppInWingetList -appId 'Microsoft.Copilot' -InstalledList $installedApps | Should -BeTrue + } + + It '' -ForEach @( + @{ Case = 'matches a delimited substring'; AppId = 'Music'; Expected = $true } + @{ Case = 'does not match an alphanumeric continuation'; AppId = 'Microsoft.Edge'; Expected = $false } + ) { + Test-AppInWingetList -appId $AppId -InstalledList $installedApps | Should -Be $Expected + } + + It 'returns false for ' -ForEach @( + @{ Case = 'an empty installed-app list'; AppId = 'Microsoft.Copilot'; InstalledList = @() } + @{ Case = 'an app that is not installed'; AppId = 'Missing.App'; InstalledList = $null } + ) { + $list = if ($null -eq $InstalledList) { $installedApps } else { $InstalledList } + Test-AppInWingetList -appId $AppId -InstalledList $list | Should -BeFalse + } +} diff --git a/Tests/TestData/JsonFileLoading/Config.Invalid.json b/Tests/TestData/JsonFileLoading/Config.Invalid.json new file mode 100644 index 0000000..512e1f1 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/Config.Invalid.json @@ -0,0 +1,4 @@ +{ + "Version": "1.0", + "Name": +} diff --git a/Tests/TestData/JsonFileLoading/Config.Valid.json b/Tests/TestData/JsonFileLoading/Config.Valid.json new file mode 100644 index 0000000..b85a762 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/Config.Valid.json @@ -0,0 +1,4 @@ +{ + "Version": "1.0", + "Name": "Example configuration" +} diff --git a/Tests/TestData/JsonFileLoading/Config.VersionMismatch.json b/Tests/TestData/JsonFileLoading/Config.VersionMismatch.json new file mode 100644 index 0000000..f1ec905 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/Config.VersionMismatch.json @@ -0,0 +1,4 @@ +{ + "Version": "2.0", + "Name": "Incompatible configuration" +} diff --git a/Tests/TestData/JsonFileLoading/DefaultSettings.MissingSettings.json b/Tests/TestData/JsonFileLoading/DefaultSettings.MissingSettings.json new file mode 100644 index 0000000..d25f981 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/DefaultSettings.MissingSettings.json @@ -0,0 +1,3 @@ +{ + "Version": "1.0" +} diff --git a/Tests/TestData/JsonFileLoading/DefaultSettings.Valid.json b/Tests/TestData/JsonFileLoading/DefaultSettings.Valid.json new file mode 100644 index 0000000..f224b8e --- /dev/null +++ b/Tests/TestData/JsonFileLoading/DefaultSettings.Valid.json @@ -0,0 +1,10 @@ +{ + "Version": "1.0", + "Settings": [ + { "Name": "Supported", "Value": "configured" }, + { "Name": "Supported", "Value": false }, + { "Name": "Unknown", "Value": true }, + { "Name": "TooNew", "Value": true }, + { "Name": "DisableModernStandbyNetworking", "Value": true } + ] +} diff --git a/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json b/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json new file mode 100644 index 0000000..195d3a8 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/ExportedConfig.WithSettings.json @@ -0,0 +1,23 @@ +{ + "Version": "1.0", + "Deployment": [ + { "Name": "UserSelectionIndex", "Value": 0 }, + { "Name": "AppRemovalScopeIndex", "Value": 0 }, + { "Name": "CreateRestorePoint", "Value": true }, + { "Name": "RestartExplorer", "Value": false } + ], + "Tweaks": [ + { "Name": "DisableSettings365Ads", "Value": true }, + { "Name": "DisableSnapAssist", "Value": true }, + { "Name": "EnableDarkMode", "Value": true }, + { "Name": "ShowSearchBoxTb", "Value": true }, + { "Name": "DisableTelemetry", "Value": true }, + { "Name": "DisableWidgets", "Value": true }, + { "Name": "DisableLockscreenTips", "Value": true }, + { "Name": "DisableSnapLayouts", "Value": true }, + { "Name": "DisableAISvcAutoStart", "Value": true }, + { "Name": "DisableMouseAcceleration", "Value": true }, + { "Name": "DisableCopilot", "Value": true }, + { "Name": "DisableRecall", "Value": true } + ] +} diff --git a/Tests/TestData/JsonFileLoading/LastUsedSettings.Invalid.json b/Tests/TestData/JsonFileLoading/LastUsedSettings.Invalid.json new file mode 100644 index 0000000..8669423 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/LastUsedSettings.Invalid.json @@ -0,0 +1,6 @@ +{ + "Version": "1.0", + "Settings": [ + { "Name": "Supported", "Value": true } + +} diff --git a/Tests/TestData/JsonFileLoading/LastUsedSettings.Valid.json b/Tests/TestData/JsonFileLoading/LastUsedSettings.Valid.json new file mode 100644 index 0000000..20ec008 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/LastUsedSettings.Valid.json @@ -0,0 +1,6 @@ +{ + "Version": "1.0", + "Settings": [ + { "Name": "Supported", "Value": true } + ] +} diff --git a/Tests/TestData/JsonFileLoading/RegistryBackup.Invalid.json b/Tests/TestData/JsonFileLoading/RegistryBackup.Invalid.json new file mode 100644 index 0000000..c1b14e2 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/RegistryBackup.Invalid.json @@ -0,0 +1,4 @@ +{ + "Version": "1.0", + "BackupType": +} diff --git a/Tests/TestData/JsonFileLoading/RegistryBackup.RealStructure.json b/Tests/TestData/JsonFileLoading/RegistryBackup.RealStructure.json new file mode 100644 index 0000000..64b6883 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/RegistryBackup.RealStructure.json @@ -0,0 +1,43 @@ +{ + "RegistryKeys": [ + { + "SubKeys": [], + "Path": "HKEY_CURRENT_USER\\Control Panel\\Accessibility\\StickyKeys", + "Values": [ + { + "Kind": "String", + "Name": "Flags", + "Data": "510", + "Exists": true + } + ], + "Exists": true + }, + { + "SubKeys": [], + "Path": "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "Values": [ + { + "Kind": "DWord", + "Name": "EnableTransparency", + "Data": 0, + "Exists": true + } + ], + "Exists": true + } + ], + "ComputerName": "TEST-COMPUTER", + "BackupType": "RegistryState", + "SelectedUndoFeatures": [ + "DisableTransparency" + ], + "CreatedBy": "Win11Debloat", + "Version": "1.0", + "Target": "CurrentUser:fixture-user", + "SelectedFeatures": [ + "RemoveApps", + "DisableStickyKeys" + ], + "CreatedAt": "2026-07-11T20:00:33.9825666+02:00" +} diff --git a/Tests/TestData/JsonFileLoading/RegistryBackup.Valid.json b/Tests/TestData/JsonFileLoading/RegistryBackup.Valid.json new file mode 100644 index 0000000..461c857 --- /dev/null +++ b/Tests/TestData/JsonFileLoading/RegistryBackup.Valid.json @@ -0,0 +1,7 @@ +{ + "Version": "1.0", + "BackupType": "RegistryState", + "Target": "DefaultUserProfile", + "SelectedFeatures": [], + "RegistryKeys": [] +} diff --git a/Tests/User-HiveHelpers.Tests.ps1 b/Tests/User-HiveHelpers.Tests.ps1 new file mode 100644 index 0000000..7802942 --- /dev/null +++ b/Tests/User-HiveHelpers.Tests.ps1 @@ -0,0 +1,149 @@ +BeforeAll { + function Normalize-UserLookupValue { param($Value) ([string]$Value).Trim() } + function Resolve-UserProfileContext { param($UserName) $null } + function reg { param($Action, $Mount, $Path) $global:LASTEXITCODE = 0 } + . (Join-Path $PSScriptRoot '..\Scripts\Helpers\User-HiveHelpers.ps1') +} + +Describe 'New-TargetUserHiveContext' { + It 'projects user information and defaults an empty mount name' { + $result = New-TargetUserHiveContext -TargetUserName 'Alice' -UserContext ([PSCustomObject]@{ UserSid = 'S-1'; ProfilePath = 'C:\Users\Alice' }) -HiveDatPath 'C:\Users\Alice\NTUSER.DAT' -MountName '' + + $result.TargetUserName | Should -Be 'Alice' + $result.UserSid | Should -Be 'S-1' + $result.MountName | Should -Be 'Default' + $result.WasAlreadyLoaded | Should -BeFalse + } +} + +Describe 'Resolve-TargetUserHiveContext' { + BeforeEach { + Mock Normalize-UserLookupValue { param($Value) ([string]$Value).Trim() } + Mock Resolve-UserProfileContext { [PSCustomObject]@{ UserSid = 'S-1-5-21-123'; ProfilePath = $TestDrive } } + Mock Test-Path { $true } + } + + It 'uses an already loaded SID hive for a normal user' { + $result = Resolve-TargetUserHiveContext -TargetUserName ' Alice ' + + $result.MountName | Should -Be 'S-1-5-21-123' + $result.WasAlreadyLoaded | Should -BeTrue + $result.WasLoadedByScript | Should -BeFalse + } + + It 'uses the temporary Default mount for ' -ForEach @( + @{ Case = 'an unloaded user'; User = 'Alice'; Sid = '' } + @{ Case = 'the default profile'; User = 'Default'; Sid = 'S-1-5-21-123' } + ) { + Mock Resolve-UserProfileContext { [PSCustomObject]@{ UserSid = $Sid; ProfilePath = $TestDrive } } + Mock Test-Path { param($LiteralPath) $LiteralPath -like '*NTUSER.DAT' } + + $result = Resolve-TargetUserHiveContext -TargetUserName $User + + $result.MountName | Should -Be 'Default' + $result.WasAlreadyLoaded | Should -BeFalse + } + + It 'rejects ' -ForEach @( + @{ Case = 'an empty user name'; UserName = ' '; Setup = 'None'; ExpectedError = 'Target user name for registry hive resolution is empty.' } + @{ Case = 'an unresolved profile'; UserName = 'Missing'; Setup = 'MissingProfile'; ExpectedError = "Unable to resolve profile path for target user 'Missing'." } + @{ Case = 'a missing hive file'; UserName = 'Alice'; Setup = 'MissingHive'; ExpectedError = 'Unable to find target user hive at *' } + ) { + if ($Setup -eq 'MissingProfile') { + Mock Resolve-UserProfileContext { $null } + } + elseif ($Setup -eq 'MissingHive') { + Mock Resolve-UserProfileContext { [PSCustomObject]@{ UserSid = 'S-1'; ProfilePath = $TestDrive } } + Mock Test-Path { $false } + } + + { Resolve-TargetUserHiveContext -TargetUserName $UserName } | Should -Throw $ExpectedError + } +} + +Describe 'Resolve-LoadedTargetUserHiveContext' { + It '' -ForEach @( + @{ Case = 'returns a loaded context when a SID hive is mounted'; Sid = 'S-1'; HiveMounted = $true; ExpectedLoaded = $true } + @{ Case = 'returns null when the SID is empty'; Sid = ''; HiveMounted = $true; ExpectedLoaded = $false } + @{ Case = 'returns null when the SID hive is not mounted'; Sid = 'S-1'; HiveMounted = $false; ExpectedLoaded = $false } + ) { + $input = [PSCustomObject]@{ TargetUserName = 'Alice'; UserSid = $Sid; ProfilePath = 'C:\Users\Alice'; HiveDatPath = 'C:\Users\Alice\NTUSER.DAT' } + Mock Test-Path { $HiveMounted } + + $result = Resolve-LoadedTargetUserHiveContext -HiveContext $input + if ($ExpectedLoaded) { + $result.WasAlreadyLoaded | Should -BeTrue + } + else { + $result | Should -BeNullOrEmpty + } + } +} + +Describe 'Invoke-WithTargetUserHive' { + BeforeEach { + $script:RegistryTargetHiveMountName = 'Previous' + $script:context = [PSCustomObject]@{ + TargetUserName = 'Alice'; UserSid = 'S-1'; ProfilePath = 'C:\Users\Alice'; HiveDatPath = 'C:\Users\Alice\NTUSER.DAT' + MountName = 'Temporary'; WasAlreadyLoaded = $false; WasLoadedByScript = $false + } + Mock Resolve-TargetUserHiveContext { $script:context } + Mock Resolve-LoadedTargetUserHiveContext { $null } + Mock reg { $global:LASTEXITCODE = 0 } + Mock Write-Warning {} + } + + It 'loads, executes, passes context, unloads, and restores the previous mount name' { + $result = Invoke-WithTargetUserHive -TargetUserName 'Alice' -ArgumentObject 'payload' -PassHiveContext -ScriptBlock { + param($Argument, $Context) + "$Argument|$($Context.MountName)|$script:RegistryTargetHiveMountName" + } + + $result | Should -Be 'payload|Temporary|Temporary' + Should -Invoke reg -Times 1 -Exactly -ParameterFilter { $Action -eq 'load' } + Should -Invoke reg -Times 1 -Exactly -ParameterFilter { $Action -eq 'unload' } + $script:RegistryTargetHiveMountName | Should -Be 'Previous' + } + + It 'does not load or unload a hive that was already mounted' { + $script:context.WasAlreadyLoaded = $true + + Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { 'done' } | Should -Be 'done' + Should -Invoke reg -Times 0 -Exactly + } + + It 'unloads and restores state when the scriptblock throws' { + { Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { throw 'script failed' } } | Should -Throw 'script failed' + + Should -Invoke reg -Times 1 -Exactly -ParameterFilter { $Action -eq 'unload' } + $script:RegistryTargetHiveMountName | Should -Be 'Previous' + } + + It 'throws when loading fails without an already-loaded SID fallback' { + Mock reg { if ($Action -eq 'load') { $global:LASTEXITCODE = 5 } } + + { Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { 'never' } } | + Should -Throw "Failed to load target user hive 'C:\Users\Alice\NTUSER.DAT' (exit code: 5)." + Should -Invoke reg -Times 0 -Exactly -ParameterFilter { $Action -eq 'unload' } + } + + It 'uses a loaded SID fallback after a load race' { + Mock reg { if ($Action -eq 'load') { $global:LASTEXITCODE = 5 } } + Mock Resolve-LoadedTargetUserHiveContext { + [PSCustomObject]@{ TargetUserName = 'Alice'; UserSid = 'S-1'; ProfilePath = 'C:\Users\Alice'; HiveDatPath = 'C:\Users\Alice\NTUSER.DAT'; MountName = 'S-1'; WasAlreadyLoaded = $true; WasLoadedByScript = $false } + } + + Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { $script:RegistryTargetHiveMountName } | Should -Be 'S-1' + Should -Invoke reg -Times 0 -Exactly -ParameterFilter { $Action -eq 'unload' } + } + + It 'warns on unload failure without discarding the scriptblock result' { + Mock reg { + if ($Action -eq 'load') { $global:LASTEXITCODE = 0 } + if ($Action -eq 'unload') { $global:LASTEXITCODE = 5 } + } + + Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { 'result' } | Should -Be 'result' + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "Failed to unload registry hive*" } + } +} diff --git a/Tests/User-LookupHelpers.Tests.ps1 b/Tests/User-LookupHelpers.Tests.ps1 new file mode 100644 index 0000000..6d56f46 --- /dev/null +++ b/Tests/User-LookupHelpers.Tests.ps1 @@ -0,0 +1,31 @@ +BeforeAll { + $userProfileScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Resolve-UserProfilePath.ps1' + . $userProfileScriptPath +} + +Describe 'Normalize-UserLookupValue' { + It 'removes zero-width characters and collapses whitespace' { + Normalize-UserLookupValue -Value " domain\u$([char]0x200B)ser name " | Should -Be 'domain\user name' + } + + It 'uses normalized, case-insensitive cache keys' { + Get-UserLookupCacheKey -Value ' DOMAIN\Alice ' | Should -Be 'domain\alice' + Get-UserLookupCacheKey -Value ' ' | Should -Be '' + } + + It 'normalizes, filters, and de-duplicates lookup candidates' { + $result = Get-NormalizedLookupCandidates -Candidates @(' Alice ', '', 'alice', "Bob$([char]0x200B)") + + $result | Should -HaveCount 3 + $result[0] | Should -Be 'Alice' + $result[1] | Should -Be 'alice' + $result[2] | Should -Be 'Bob' + } + + It 'escapes WQL literals and extracts local user-name segments' { + Escape-WqlString -Value "O'Brian" | Should -Be "O''Brian" + Get-LocalUserNameSegment -UserName 'CONTOSO\Alice' | Should -Be 'Alice' + Get-LocalUserNameSegment -UserName 'alice@contoso.com' | Should -Be 'alice' + Get-LocalUserNameSegment -UserName ' Alice ' | Should -Be 'Alice' + } +} diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index 7c18042..1536e8f 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -182,7 +182,7 @@ $script:SharedStylesSchema = Join-Path $schemasPath 'SharedStyles.xaml' $script:BubbleHintSchema = Join-Path $schemasPath 'BubbleHint.xaml' $script:ImportExportConfigSchema = Join-Path $schemasPath 'ImportExportConfigWindow.xaml' $script:RestoreBackupWindowSchema = Join-Path $schemasPath 'RestoreBackupWindow.xaml' -$script:LoadAppsDetailsScriptPath = Join-Path (Join-Path $scriptsPath 'FileIO') 'LoadAppsDetailsFromJson.ps1' +$script:LoadAppsDetailsScriptPath = Join-Path (Join-Path $scriptsPath 'FileIO') 'Import-AppDetailsFromJson.ps1' $script:TestAppInWingetListScriptPath = Join-Path (Join-Path $scriptsPath 'AppRemoval') 'Test-AppInWingetList.ps1' $script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'NoRestartExplorer', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'CLI', 'AppRemovalTarget' @@ -310,59 +310,59 @@ if (-not $script:WingetInstalled -and -not $Silent) { ################################################################################################################## # App removal functions -. "$PSScriptRoot/Scripts/AppRemoval/ForceRemoveEdge.ps1" -. "$PSScriptRoot/Scripts/AppRemoval/RemoveApps.ps1" -. "$PSScriptRoot/Scripts/AppRemoval/GetInstalledAppsViaWinget.ps1" +. "$PSScriptRoot/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1" +. "$PSScriptRoot/Scripts/AppRemoval/Remove-SelectedApps.ps1" +. "$PSScriptRoot/Scripts/AppRemoval/Get-WingetInstalledApps.ps1" . "$PSScriptRoot/Scripts/AppRemoval/Test-AppInWingetList.ps1" # CLI functions -. "$PSScriptRoot/Scripts/CLI/AwaitKeyToExit.ps1" -. "$PSScriptRoot/Scripts/CLI/ShowCLILastUsedSettings.ps1" -. "$PSScriptRoot/Scripts/CLI/ShowCLIDefaultModeAppRemovalOptions.ps1" -. "$PSScriptRoot/Scripts/CLI/ShowCLIDefaultModeOptions.ps1" -. "$PSScriptRoot/Scripts/CLI/ShowCLIAppRemoval.ps1" -. "$PSScriptRoot/Scripts/CLI/ShowCLIMenuOptions.ps1" -. "$PSScriptRoot/Scripts/CLI/PrintPendingChanges.ps1" -. "$PSScriptRoot/Scripts/CLI/PrintHeader.ps1" +. "$PSScriptRoot/Scripts/CLI/Wait-ForKeyPress.ps1" +. "$PSScriptRoot/Scripts/CLI/Show-CliLastUsedSettings.ps1" +. "$PSScriptRoot/Scripts/CLI/Show-CliDefaultModeAppRemovalOptions.ps1" +. "$PSScriptRoot/Scripts/CLI/Show-CliDefaultModeOptions.ps1" +. "$PSScriptRoot/Scripts/CLI/Show-CliAppRemoval.ps1" +. "$PSScriptRoot/Scripts/CLI/Show-CliMenuOptions.ps1" +. "$PSScriptRoot/Scripts/CLI/Write-PendingChanges.ps1" +. "$PSScriptRoot/Scripts/CLI/Write-CliHeader.ps1" # Features functions -. "$PSScriptRoot/Scripts/Features/GetCurrentTweakState.ps1" -. "$PSScriptRoot/Scripts/Features/InvokeChanges.ps1" -. "$PSScriptRoot/Scripts/Features/CreateSystemRestorePoint.ps1" -. "$PSScriptRoot/Scripts/Features/BackupRegistryFeatureSelection.ps1" -. "$PSScriptRoot/Scripts/Features/BackupRegistrySnapshotCapture.ps1" -. "$PSScriptRoot/Scripts/Features/BackupRegistryState.ps1" -. "$PSScriptRoot/Scripts/Features/RegistryBackupValidation.ps1" -. "$PSScriptRoot/Scripts/Features/RestoreRegistryApplyState.ps1" -. "$PSScriptRoot/Scripts/Features/RestoreRegistryBackup.ps1" -. "$PSScriptRoot/Scripts/Features/StoreSearchSuggestions.ps1" -. "$PSScriptRoot/Scripts/Features/TelemetryScheduledTasks.ps1" -. "$PSScriptRoot/Scripts/Features/WindowsOptionalFeatures.ps1" -. "$PSScriptRoot/Scripts/Features/ImportRegistryFile.ps1" -. "$PSScriptRoot/Scripts/Features/ReplaceStartMenu.ps1" -. "$PSScriptRoot/Scripts/Features/RestartExplorer.ps1" +. "$PSScriptRoot/Scripts/Features/Get-CurrentTweakState.ps1" +. "$PSScriptRoot/Scripts/Features/Invoke-Changes.ps1" +. "$PSScriptRoot/Scripts/Features/Ensure-SystemRestorePoint.ps1" +. "$PSScriptRoot/Scripts/Features/Backup-RegistryFeatureSelection.ps1" +. "$PSScriptRoot/Scripts/Features/Backup-RegistrySnapshotCapture.ps1" +. "$PSScriptRoot/Scripts/Features/Backup-RegistryState.ps1" +. "$PSScriptRoot/Scripts/Features/Registry-BackupValidation.ps1" +. "$PSScriptRoot/Scripts/Features/Restore-RegistryApplyState.ps1" +. "$PSScriptRoot/Scripts/Features/Restore-RegistryBackup.ps1" +. "$PSScriptRoot/Scripts/Features/Set-StoreSearchSuggestions.ps1" +. "$PSScriptRoot/Scripts/Features/Telemetry-ScheduledTasks.ps1" +. "$PSScriptRoot/Scripts/Features/Windows-OptionalFeatures.ps1" +. "$PSScriptRoot/Scripts/Features/Import-RegistryFile.ps1" +. "$PSScriptRoot/Scripts/Features/Replace-StartMenu.ps1" +. "$PSScriptRoot/Scripts/Features/Invoke-RestartExplorer.ps1" # File I/O functions -. "$PSScriptRoot/Scripts/FileIO/LoadJsonFile.ps1" -. "$PSScriptRoot/Scripts/FileIO/SaveToFile.ps1" -. "$PSScriptRoot/Scripts/FileIO/SaveSettings.ps1" -. "$PSScriptRoot/Scripts/FileIO/LoadSettings.ps1" -. "$PSScriptRoot/Scripts/FileIO/ValidateAppslist.ps1" -. "$PSScriptRoot/Scripts/FileIO/LoadAppsFromFile.ps1" -. "$PSScriptRoot/Scripts/FileIO/LoadAppsDetailsFromJson.ps1" -. "$PSScriptRoot/Scripts/FileIO/LoadAppPresetsFromJson.ps1" +. "$PSScriptRoot/Scripts/FileIO/Import-JsonFile.ps1" +. "$PSScriptRoot/Scripts/FileIO/Save-ToFile.ps1" +. "$PSScriptRoot/Scripts/FileIO/Save-Settings.ps1" +. "$PSScriptRoot/Scripts/FileIO/Import-Settings.ps1" +. "$PSScriptRoot/Scripts/FileIO/Get-ValidatedAppList.ps1" +. "$PSScriptRoot/Scripts/FileIO/Import-AppsFromFile.ps1" +. "$PSScriptRoot/Scripts/FileIO/Import-AppDetailsFromJson.ps1" +. "$PSScriptRoot/Scripts/FileIO/Import-AppPresetsFromJson.ps1" # GUI functions -. "$PSScriptRoot/Scripts/GUI/GetSystemUsesDarkMode.ps1" -. "$PSScriptRoot/Scripts/GUI/SetWindowThemeResources.ps1" -. "$PSScriptRoot/Scripts/GUI/AttachShiftClickBehavior.ps1" -. "$PSScriptRoot/Scripts/GUI/ApplySettingsToUiControls.ps1" +. "$PSScriptRoot/Scripts/GUI/Get-SystemUsesDarkMode.ps1" +. "$PSScriptRoot/Scripts/GUI/Set-WindowThemeResources.ps1" +. "$PSScriptRoot/Scripts/GUI/Attach-ShiftClickBehavior.ps1" +. "$PSScriptRoot/Scripts/GUI/Apply-SettingsToUiControls.ps1" . "$PSScriptRoot/Scripts/GUI/Show-MessageBox.ps1" -. "$PSScriptRoot/Scripts/GUI/Show-ConfigWindow.ps1" +. "$PSScriptRoot/Scripts/GUI/Show-ImportExportConfigWindow.ps1" . "$PSScriptRoot/Scripts/GUI/Show-ApplyModal.ps1" . "$PSScriptRoot/Scripts/GUI/Show-AppSelectionWindow.ps1" . "$PSScriptRoot/Scripts/GUI/Show-RestoreBackupWindow.ps1" -. "$PSScriptRoot/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1" +. "$PSScriptRoot/Scripts/GUI/Restore-BackupDialogFeatureLists.ps1" . "$PSScriptRoot/Scripts/GUI/Show-RestoreBackupDialog.ps1" . "$PSScriptRoot/Scripts/GUI/MainWindow-WindowChrome.ps1" . "$PSScriptRoot/Scripts/GUI/MainWindow-AppSelection.ps1" @@ -374,27 +374,27 @@ if (-not $script:WingetInstalled -and -not $Silent) { . "$PSScriptRoot/Scripts/GUI/Show-Bubble.ps1" # Helper functions -. "$PSScriptRoot/Scripts/Helpers/AddParameter.ps1" -. "$PSScriptRoot/Scripts/Helpers/ResolveUserProfilePath.ps1" -. "$PSScriptRoot/Scripts/Helpers/UserHiveHelpers.ps1" -. "$PSScriptRoot/Scripts/Helpers/CheckIfUserExists.ps1" -. "$PSScriptRoot/Scripts/Helpers/CheckModernStandbySupport.ps1" -. "$PSScriptRoot/Scripts/Helpers/GenerateAppsList.ps1" -. "$PSScriptRoot/Scripts/Helpers/GetFriendlyRegistryBackupTarget.ps1" -. "$PSScriptRoot/Scripts/Helpers/GetFriendlyTargetUserName.ps1" +. "$PSScriptRoot/Scripts/Helpers/Add-Parameter.ps1" +. "$PSScriptRoot/Scripts/Helpers/Resolve-UserProfilePath.ps1" +. "$PSScriptRoot/Scripts/Helpers/User-HiveHelpers.ps1" +. "$PSScriptRoot/Scripts/Helpers/Test-UserProfileExists.ps1" +. "$PSScriptRoot/Scripts/Helpers/Test-ModernStandbySupport.ps1" +. "$PSScriptRoot/Scripts/Helpers/Generate-AppsList.ps1" +. "$PSScriptRoot/Scripts/Helpers/Get-FriendlyRegistryBackupTarget.ps1" +. "$PSScriptRoot/Scripts/Helpers/Get-FriendlyTargetUserName.ps1" . "$PSScriptRoot/Scripts/Helpers/Get-RebootFeatureLabels.ps1" -. "$PSScriptRoot/Scripts/Helpers/ImportConfigToParams.ps1" -. "$PSScriptRoot/Scripts/Helpers/GetTargetUserForAppRemoval.ps1" +. "$PSScriptRoot/Scripts/Helpers/Import-ConfigToParams.ps1" +. "$PSScriptRoot/Scripts/Helpers/Get-TargetUserForAppRemoval.ps1" . "$PSScriptRoot/Scripts/Helpers/Get-RegFileOperations.ps1" . "$PSScriptRoot/Scripts/Helpers/Test-TargetUserName.ps1" -. "$PSScriptRoot/Scripts/Helpers/GetUserDirectory.ps1" -. "$PSScriptRoot/Scripts/Helpers/GetUserName.ps1" -. "$PSScriptRoot/Scripts/Helpers/RegistryPathHelpers.ps1" -. "$PSScriptRoot/Scripts/Helpers/ApplyRegistryRegFile.ps1" -. "$PSScriptRoot/Scripts/Helpers/ConfirmUnsafeAppRemoval.ps1" +. "$PSScriptRoot/Scripts/Helpers/Get-UserDirectory.ps1" +. "$PSScriptRoot/Scripts/Helpers/Get-UserName.ps1" +. "$PSScriptRoot/Scripts/Helpers/Registry-PathHelpers.ps1" +. "$PSScriptRoot/Scripts/Helpers/Apply-RegistryRegFile.ps1" +. "$PSScriptRoot/Scripts/Helpers/Confirm-UnsafeAppRemoval.ps1" # Threading functions -. "$PSScriptRoot/Scripts/Threading/DoEvents.ps1" +. "$PSScriptRoot/Scripts/Threading/Invoke-DoEvents.ps1" . "$PSScriptRoot/Scripts/Threading/Invoke-NonBlocking.ps1" @@ -411,7 +411,7 @@ if (-not $script:WingetInstalled -and -not $Silent) { $WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild # Check if the machine supports Modern Standby, this is used to determine if the DisableModernStandbyNetworking option can be used -$script:ModernStandbySupported = CheckModernStandbySupport +$script:ModernStandbySupported = Test-ModernStandbySupport $script:Params = $PSBoundParameters $script:UndoParams = @{} @@ -444,24 +444,24 @@ else { } if ($script:Params.ContainsKey("Sysprep")) { - GetUserDirectory -userName "Default" | Out-Null + Get-UserDirectory -userName "Default" | Out-Null # Exit script if run in Sysprep mode on Windows 10 if ($WinVersion -lt 22000) { Write-Error "Win11Debloat Sysprep mode is not supported on Windows 10" - AwaitKeyToExit + Wait-ForKeyPress } } # Ensure that target user exists, if User or AppRemovalTarget parameter was provided if ($script:Params.ContainsKey("User")) { - GetUserDirectory -userName $script:Params.Item("User") | Out-Null + Get-UserDirectory -userName $script:Params.Item("User") | Out-Null } if ($script:Params.ContainsKey("AppRemovalTarget")) { $appRemovalTargetValue = $script:Params.Item("AppRemovalTarget") # 'AllUsers' / 'CurrentUser' are sentinel scope values, not real usernames - don't resolve them as a profile if ($appRemovalTargetValue -notin @('AllUsers', 'CurrentUser')) { - GetUserDirectory -userName $appRemovalTargetValue | Out-Null + Get-UserDirectory -userName $appRemovalTargetValue | Out-Null } } @@ -476,35 +476,35 @@ $launchInCLI = $CLI -or $script:Params.ContainsKey("User") -or $script:Params.Co # Change script execution based on provided parameters or user input if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSavedSettings -or $Config -or ($controlParamsCount -eq $script:Params.Count)) { if ($RunDefaults -or $RunDefaultsLite) { - ShowCLIDefaultModeOptions + Show-CliDefaultModeOptions } elseif ($RunSavedSettings) { if (-not (Test-Path $script:SavedSettingsFilePath)) { - PrintHeader 'Custom Mode' + Write-CliHeader 'Custom Mode' Write-Error "Unable to find LastUsedSettings.json file, no changes were made" - AwaitKeyToExit + Wait-ForKeyPress } - ShowCLILastUsedSettings + Show-CliLastUsedSettings } elseif ($Config) { try { - ImportConfigToParams -ConfigPath $Config -CurrentBuild $WinVersion -ExpectedVersion '1.0' + Import-ConfigToParams -ConfigPath $Config -CurrentBuild $WinVersion -ExpectedVersion '1.0' } catch { Write-Error "$_" - AwaitKeyToExit + Wait-ForKeyPress } if (-not $Silent) { - PrintHeader 'Custom Mode' - PrintPendingChanges - PrintHeader 'Custom Mode' + Write-CliHeader 'Custom Mode' + Write-PendingChanges + Write-CliHeader 'Custom Mode' } } else { if ($launchInCLI) { - $Mode = ShowCLIMenuOptions + $Mode = Show-CliMenuOptions } else { try { @@ -525,7 +525,7 @@ if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSa $null = [System.Console]::ReadKey() } - $Mode = ShowCLIMenuOptions + $Mode = Show-CliMenuOptions } } } @@ -534,29 +534,29 @@ if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSa switch ($Mode) { # Default mode, loads defaults and app removal options '1' { - ShowCLIDefaultModeOptions + Show-CliDefaultModeOptions } # App removal, remove apps based on user selection '2' { - ShowCLIAppRemoval + Show-CliAppRemoval } # Load last used options from the "LastUsedSettings.json" file '3' { - ShowCLILastUsedSettings + Show-CliLastUsedSettings } } } else { - PrintHeader 'Configuration' + Write-CliHeader 'Configuration' } # If the number of keys in ControlParams equals the number of keys in Params then no modifications/changes were selected # or added by the user, and the script can exit without making any changes. if (($controlParamsCount -eq $script:Params.Keys.Count) -or ($script:Params.Keys.Count -eq 1 -and ($script:Params.Keys -contains 'CreateRestorePoint' -or $script:Params.Keys -contains 'Apps'))) { Write-Output "The script completed without making any changes." - AwaitKeyToExit + Wait-ForKeyPress } # Execute all selected/provided parameters using the consolidated function @@ -565,12 +565,12 @@ Invoke-AllChanges if ($script:CancelRequested) { Write-Warning "Script execution was cancelled by the user. Any remaining changes were not applied." - AwaitKeyToExit + Wait-ForKeyPress } # Restart Explorer process unless running in Sysprep or User context if (-not ($script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User"))) { - RestartExplorer + Invoke-RestartExplorer } Write-Output "" @@ -578,4 +578,4 @@ Write-Output "" Write-Output "" Write-Output "Script completed! Please check above for any errors." -AwaitKeyToExit +Wait-ForKeyPress