mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-07-20 21:10:20 +08:00
Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708)
This commit is contained in:
parent
a7292e4f35
commit
9c033dbf98
23
.github/CONTRIBUTING.md
vendored
23
.github/CONTRIBUTING.md
vendored
@ -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
26
.github/workflows/tests.yml
vendored
Normal 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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
@ -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 ""
|
||||
@ -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'
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
@ -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"
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 "-------------------------------------------------------------------------------------------"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
@ -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'"
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
function CreateSystemRestorePoint {
|
||||
function Invoke-SystemRestorePoint {
|
||||
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
|
||||
$failed = $false
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
# Import & execute regfile
|
||||
function ImportRegistryFile {
|
||||
function Import-RegistryFile {
|
||||
param (
|
||||
$message,
|
||||
$path
|
||||
@ -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')
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
@ -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) {
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Scripts/FileIO/Save-ToFile.ps1
Normal file
36
Scripts/FileIO/Save-ToFile.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
@ -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,
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.'
|
||||
@ -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: $_"
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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)]
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
13
Scripts/Helpers/Get-FriendlyTargetUserName.ps1
Normal file
13
Scripts/Helpers/Get-FriendlyTargetUserName.ps1
Normal 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" }
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
11
Scripts/Helpers/Get-UserName.ps1
Normal file
11
Scripts/Helpers/Get-UserName.ps1
Normal 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
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
function GetFriendlyTargetUserName {
|
||||
$target = GetTargetUserForAppRemoval
|
||||
|
||||
switch ($target) {
|
||||
"AllUsers" { return "all users" }
|
||||
"CurrentUser" { return "the current user" }
|
||||
default { return "user $target" }
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
function GetUserName {
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
return $script:Params.Item("User")
|
||||
}
|
||||
|
||||
return $env:USERNAME
|
||||
}
|
||||
@ -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++
|
||||
}
|
||||
}
|
||||
@ -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)]
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
@ -23,7 +23,7 @@ function Test-TargetUserName {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
|
||||
if (-not (Test-UserProfileExists -userName $normalizedUserName)) {
|
||||
return [PSCustomObject]@{
|
||||
IsValid = $false
|
||||
UserName = $normalizedUserName
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
49
Scripts/Run-Tests.ps1
Normal 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
|
||||
}
|
||||
@ -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(
|
||||
@ -31,7 +31,7 @@ function Invoke-NonBlocking {
|
||||
$ps.Stop()
|
||||
throw "Operation timed out after $TimeoutSeconds seconds"
|
||||
}
|
||||
DoEvents
|
||||
Invoke-DoEvents
|
||||
Start-Sleep -Milliseconds 16
|
||||
}
|
||||
}
|
||||
|
||||
144
Tests/Apply-RegistryRegFile.Tests.ps1
Normal file
144
Tests/Apply-RegistryRegFile.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
37
Tests/Confirm-UnsafeAppRemoval.Tests.ps1
Normal file
37
Tests/Confirm-UnsafeAppRemoval.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
186
Tests/Get-CurrentTweakState.Tests.ps1
Normal file
186
Tests/Get-CurrentTweakState.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
21
Tests/Get-FriendlyRegistryBackupTarget.Tests.ps1
Normal file
21
Tests/Get-FriendlyRegistryBackupTarget.Tests.ps1
Normal 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'
|
||||
}
|
||||
}
|
||||
31
Tests/Get-RebootFeatureLabels.Tests.ps1
Normal file
31
Tests/Get-RebootFeatureLabels.Tests.ps1
Normal 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'
|
||||
}
|
||||
}
|
||||
126
Tests/Get-RegFileOperations.Tests.ps1
Normal file
126
Tests/Get-RegFileOperations.Tests.ps1
Normal 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'
|
||||
}
|
||||
|
||||
}
|
||||
13
Tests/Get-TargetUserForAppRemoval.Tests.ps1
Normal file
13
Tests/Get-TargetUserForAppRemoval.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
38
Tests/Import-ConfigToParams.Tests.ps1
Normal file
38
Tests/Import-ConfigToParams.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
45
Tests/Import-JsonFile.Tests.ps1
Normal file
45
Tests/Import-JsonFile.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
67
Tests/Import-RegistryFile.Tests.ps1
Normal file
67
Tests/Import-RegistryFile.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
358
Tests/Invoke-Changes.Tests.ps1
Normal file
358
Tests/Invoke-Changes.Tests.ps1
Normal 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')
|
||||
}
|
||||
}
|
||||
156
Tests/MainWindow-AppSelection.Tests.ps1
Normal file
156
Tests/MainWindow-AppSelection.Tests.ps1
Normal 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'
|
||||
}
|
||||
}
|
||||
33
Tests/MainWindow-Deployment.Tests.ps1
Normal file
33
Tests/MainWindow-Deployment.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
245
Tests/Registry-BackupCreation.Tests.ps1
Normal file
245
Tests/Registry-BackupCreation.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
301
Tests/Registry-BackupValidation.Tests.ps1
Normal file
301
Tests/Registry-BackupValidation.Tests.ps1
Normal 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'."
|
||||
}
|
||||
}
|
||||
72
Tests/Registry-PathHelpers.Tests.ps1
Normal file
72
Tests/Registry-PathHelpers.Tests.ps1
Normal 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*'
|
||||
}
|
||||
}
|
||||
205
Tests/Registry-SnapshotApply.Tests.ps1
Normal file
205
Tests/Registry-SnapshotApply.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
186
Tests/Remove-SelectedApps.Tests.ps1
Normal file
186
Tests/Remove-SelectedApps.Tests.ps1
Normal 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'"
|
||||
}
|
||||
}
|
||||
108
Tests/Replace-StartMenu.Tests.ps1
Normal file
108
Tests/Replace-StartMenu.Tests.ps1
Normal 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
141
Tests/Resolve-UserProfilePath.Tests.ps1
Normal file
141
Tests/Resolve-UserProfilePath.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
57
Tests/Restore-BackupDialogFeatureLists.Tests.ps1
Normal file
57
Tests/Restore-BackupDialogFeatureLists.Tests.ps1
Normal 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'
|
||||
}
|
||||
}
|
||||
71
Tests/Restore-RegistryApplyState.Tests.ps1
Normal file
71
Tests/Restore-RegistryApplyState.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
147
Tests/Restore-RegistryBackup.Tests.ps1
Normal file
147
Tests/Restore-RegistryBackup.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
189
Tests/Settings-And-Apps-FileIO.Tests.ps1
Normal file
189
Tests/Settings-And-Apps-FileIO.Tests.ps1
Normal 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
Loading…
Reference in New Issue
Block a user