Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708)

This commit is contained in:
Jeffrey 2026-07-19 22:06:07 +02:00 committed by GitHub
parent a7292e4f35
commit 9c033dbf98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
116 changed files with 4629 additions and 650 deletions

View File

@ -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

26
.github/workflows/tests.yml vendored Normal file
View File

@ -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

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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(

View File

@ -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 {

View File

@ -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 ""

View File

@ -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'
}

View File

@ -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'
}

View File

@ -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"

View File

@ -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
}
}

View File

@ -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 "-------------------------------------------------------------------------------------------"
}
}

View File

@ -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
}
}

View File

@ -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:<name> for an explicit user,
or CurrentUser:<name> 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"

View File

@ -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'"
}

View File

@ -1,4 +1,4 @@
function CreateSystemRestorePoint {
function Invoke-SystemRestorePoint {
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
$failed = $false

View File

@ -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)
}

View File

@ -1,5 +1,5 @@
# Import & execute regfile
function ImportRegistryFile {
function Import-RegistryFile {
param (
$message,
$path

View File

@ -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')
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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'

View File

@ -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:<user name>.
.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)
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
)

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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'

View File

@ -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 {

View File

@ -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"
}

View File

@ -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

View File

@ -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

View File

@ -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 { }
}
}
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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.'

View File

@ -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: $_"

View File

@ -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')

View File

@ -15,7 +15,7 @@
Hashtable
Returns a Hashtable describing the user's choice. Possible shapes:
RestoreRegistry - @{ Result='RestoreRegistry'; Backup=<normalizedBackup> }
RestoreStartMenu - @{ Result='RestoreStartMenu'; StartMenuScope=<scope>;
Restore-StartMenu - @{ Result='Restore-StartMenu'; StartMenuScope=<scope>;
UseManualBackupFile=<bool>; BackupFilePath=<path|string> }
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

View File

@ -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)

View File

@ -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

View File

@ -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)]

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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" }
}
}

View File

@ -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(?:\((?<kind>[0-9a-fA-F]+)\))?:(?<bytes>[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 {

View File

@ -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"]
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,9 +0,0 @@
function GetFriendlyTargetUserName {
$target = GetTargetUserForAppRemoval
switch ($target) {
"AllUsers" { return "all users" }
"CurrentUser" { return "the current user" }
default { return "user $target" }
}
}

View File

@ -1,7 +0,0 @@
function GetUserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}

View File

@ -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++
}
}

View File

@ -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)]

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -23,7 +23,7 @@ function Test-TargetUserName {
}
}
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
if (-not (Test-UserProfileExists -userName $normalizedUserName)) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName

View File

@ -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
}

View File

@ -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'."
}

49
Scripts/Run-Tests.ps1 Normal file
View File

@ -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
}

View File

@ -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(

View File

@ -31,7 +31,7 @@ function Invoke-NonBlocking {
$ps.Stop()
throw "Operation timed out after $TimeoutSeconds seconds"
}
DoEvents
Invoke-DoEvents
Start-Sleep -Milliseconds 16
}
}

View File

@ -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 <Case> 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 <Case>' -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 <Type> to <Expected>' -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=<Create> and open=<Open> for <Type>' -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 <Target> to <ExpectedUser>' -ForEach @(
@{ Target = 'DefaultUserProfile'; ExpectedUser = 'Default' }
@{ Target = 'User:Alice'; ExpectedUser = 'Alice' }
) {
Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} | Should -Be $ExpectedUser
}
It 'rejects <Case>' -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 <Case> 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
}
}

View File

@ -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
}
}

View File

@ -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 <ValueType> to <Expected>' -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 '<Case>' -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 <Case> 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 '<Case>' -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 <Case>' -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
}
}

View File

@ -0,0 +1,21 @@
BeforeAll {
$friendlyTargetScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-FriendlyRegistryBackupTarget.ps1'
. $friendlyTargetScriptPath
}
Describe 'Get-FriendlyRegistryBackupTarget' {
It 'formats <Case> as <Expected>' -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'
}
}

View File

@ -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'
}
}

View File

@ -0,0 +1,126 @@
BeforeAll {
$regFileOperationsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1'
. $regFileOperationsScriptPath
}
Describe 'Convert-RegValueData' {
It 'parses <ValueType> 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 <Case>' -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 '<Case>' -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'
}
}

View File

@ -0,0 +1,13 @@
BeforeAll {
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-TargetUserForAppRemoval.ps1')
}
Describe 'Get-TargetUserForAppRemoval' {
It '<Case>' -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
}
}

View File

@ -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
}
}

View File

@ -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 <Kind> 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 <Case>' -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
}
}

View File

@ -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
}
}

View File

@ -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 '<Case>' -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 <FeatureId>' -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 <FeatureId>' -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')
}
}

View File

@ -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'
}
}

View File

@ -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
}
}

View File

@ -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 '<Case>' -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 <Case>' -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 <ExpectedUser> in <Case>' -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
}
}

View File

@ -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 <KindName>' -ForEach @(
@{ KindName = 'dword' }
@{ KindName = 'QWord' }
@{ KindName = 'MultiString' }
) {
Test-RegistryValueKindNameSupported -KindName $KindName | Should -BeTrue
}
It 'rejects <Case>' -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 <Case>' -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 <Case>' -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'."
}
}

View File

@ -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 <Case>' -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 <Case>' -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*'
}
}

View File

@ -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 <Case> 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 <Case> 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
}
}

View File

@ -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 <Target>' -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 '<dynamic>'
}
$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'"
}
}

View File

@ -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'
)
}
}

View File

@ -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
}
}

View File

@ -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'
}
}

View File

@ -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 '<Case>' -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 <Kind>' -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 <Case>' -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 <Case>' -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
}
}

View File

@ -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 <Case>' -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
}
}

View File

@ -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 <Case>' -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
}
}

Some files were not shown because too many files have changed in this diff Show More