Compare commits

..
Author SHA1 Message Date
Jeffrey 20d206cb9b fix: ensure CommandNotFoundException is not handled as task not found 2026-08-23 00:34:52 +02:00
Jeffrey b7f612f36e fix: improve error handling for registry file imports and telemetry scheduled tasks 2026-08-22 19:29:54 +02:00
Jeffrey c921730703 Enhance output documentation and error handling 2026-08-22 17:14:09 +02:00
Jeffrey 80eadd531c fix: add logging for unrecognized app-removal scope items 2026-08-16 22:46:26 +02:00
Jeffrey 4c29fce469 feat: improve consistency checks and error reporting 2026-08-16 22:04:00 +02:00
Jeffrey 93d77d8034 fix: remove redundant Category field from UiControlMappings in dynamic tweak controls 2026-08-16 21:27:58 +02:00
Jeffrey 78e1d601b0 fix: make intent of scopeTarget check more clear 2026-08-16 21:27:40 +02:00
Jeffrey f763390d53 fix: remove redundant Category field from UiControlMappings in dynamic tweak controls 2026-08-16 21:27:13 +02:00
Jeffrey dded2da3a7 Clean up & simplify error handling and reporting 2026-08-16 21:21:10 +02:00
Jeffrey 492a374f5c Refactor feature management scripts to improve error handling 2026-08-16 21:21:05 +02:00
JeffreyandGitHub 1a26934499 Improve error handling in Set-StoreSearchSuggestionsDisabled (#739) 2026-08-16 18:49:15 +02:00
SashankandGitHub 5072958b10 fix: match tweak presets and app-removal scope by stable ID instead of translatable text (#737) 2026-08-16 17:52:58 +02:00
JeffreyandGitHub 31feaeb6f5 Fix ForceRemoveEdge switch (#736) 2026-08-12 21:10:47 +02:00
Jeffrey dd929f8eec Surface errors when writing to file 2026-08-12 17:31:50 +02:00
Jeffrey b21be418cd fix: force removal of empty LastUsedSettings.json file 2026-08-12 16:40:26 +02:00
Jeffrey 25fb9f3725 Improve GUI error messaging 2026-08-12 16:38:14 +02:00
JeffreyandGitHub d1338cd027 Enhance app removal verification and error reporting (#735) 2026-08-12 15:56:29 +02:00
Jeffrey 9505a5a374 Update README ExecutionPolicy override instructions 2026-08-12 12:14:26 +02:00
JeffreyandGitHub c0599cba1f feat: add timeout for WinGet uninstall to prevent hanging (#731) 2026-08-09 01:24:27 +02:00
5c838384d6 fix: remove Mark-of-the-Web from script files at startup (#724)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-08-09 01:24:14 +02:00
Jeffrey 6957ce4220 Update GUI logo 2026-08-01 16:16:24 +02:00
SashankandGitHub 0f30b62221 fix: support QWord, ExpandString and MultiString in the .reg fallback writer (#715) 2026-07-26 22:33:28 +02:00
JeffreyandGitHub 32cedaf65d Improve registry backup safety and add optional backup skipping (#710) 2026-07-25 20:05:49 +02:00
JeffreyandGitHub 68cacfce89 fix: force stop widget processes to prevent prompt (#713) 2026-07-23 22:23:00 +02:00
JeffreyandGitHub a9c1736e46 refactor: rename NoRestartExplorer parameter to SkipExplorerRestart (#709)
The old `NoRestartExplorer` parameter is kept as an alias and will
continue to work, but is considered deprecated
2026-07-19 22:58:03 +02:00
JeffreyandGitHub 9c033dbf98 Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708) 2026-07-19 22:06:07 +02:00
Hill PatelandGitHub a7292e4f35 fix(registry-restore): reject Data/Kind mismatches before restore begins (#704) 2026-07-19 01:53:04 +02:00
KushidaandGitHub f739a0e474 fix: escape Windows Terminal path (#702) 2026-07-18 15:16:39 +02:00
JeffreyandGitHub 63b1f61fd1 Update README.md 2026-07-17 12:45:45 +02:00
de817399e6 fix: honor cancellation during changes (#700)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-07-14 22:06:40 +02:00
Jeffrey 8fa1332ff0 Update download messages for development and stable versions of Win11Debloat 2026-07-14 21:18:39 +02:00
140 changed files with 8703 additions and 1622 deletions
+21 -2
View File
@@ -58,6 +58,25 @@ You can launch the prerelease version of Win11Debloat by running this command:
.\Win11Debloat.ps1
```
### Running Automated Tests
The automated test cases use Pester 5 and do not modify the registry or other
system state. The optional bootstrap step installs Pester for your user account
when needed. To run the complete suite:
```powershell
.\Scripts\Run-Tests.ps1 -Bootstrap
```
After the initial setup, run the suite with:
```powershell
.\Scripts\Run-Tests.ps1
```
GitHub Actions runs the same test command with Windows PowerShell 5.1 for pull
requests and pushes to `master`.
## Implementation Guidelines
### Project Structure
@@ -72,7 +91,7 @@ Win11Debloat/
│ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
│ ├── AppRemoval/ # App package removal logic
│ ├── CLI/ # Command-line interface helpers
│ ├── Features/ # Feature apply/undo logic (e.g. InvokeChanges.ps1, ReplaceStartMenu.ps1)
│ ├── Features/ # Feature apply/undo logic (e.g. Invoke-Changes.ps1, Replace-StartMenu.ps1)
│ ├── FileIO/ # File input/output helpers
│ ├── GUI/ # GUI window definitions and logic
│ ├── Helpers/ # Shared helper functions
@@ -220,7 +239,7 @@ Windows Registry Editor Version 5.00
#### 1b. Implement the Feature Logic
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/InvokeChanges.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/Invoke-Changes.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
#### 2. Add Feature to Features.json
+26
View File
@@ -0,0 +1,26 @@
name: Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
pester:
name: Pester (Windows PowerShell 5.1)
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run tests
shell: powershell
run: ./Scripts/Run-Tests.ps1 -Bootstrap
+12
View File
@@ -2,50 +2,62 @@
"Version": "1.0",
"Categories": [
{
"CategoryId": "PrivacySuggestedContent",
"Name": "Privacy & Suggested Content",
"Icon": "&#xE72E;"
},
{
"CategoryId": "System",
"Name": "System",
"Icon": "&#xe770;"
},
{
"CategoryId": "StartMenuSearch",
"Name": "Start Menu & Search",
"Icon": "&#xe8fc;"
},
{
"CategoryId": "AI",
"Name": "AI",
"Icon": "&#xe794;"
},
{
"CategoryId": "WindowsUpdate",
"Name": "Windows Update",
"Icon": "&#xe895;"
},
{
"CategoryId": "Taskbar",
"Name": "Taskbar",
"Icon": "&#xe75b;"
},
{
"CategoryId": "Appearance",
"Name": "Appearance",
"Icon": "&#xE771;"
},
{
"CategoryId": "FileExplorer",
"Name": "File Explorer",
"Icon": "&#xec50;"
},
{
"CategoryId": "Gaming",
"Name": "Gaming",
"Icon": "&#xE7FC;"
},
{
"CategoryId": "MultiTasking",
"Name": "Multi-tasking",
"Icon": "&#xE7C4;"
},
{
"CategoryId": "OptionalWindowsFeatures",
"Name": "Optional Windows Features",
"Icon": "&#xefda;"
},
{
"CategoryId": "Other",
"Name": "Other",
"Icon": "&#xE713;"
}
+2 -2
View File
@@ -57,7 +57,7 @@ This method supports command-line parameters to customize the behaviour of the s
3. Temporarily enable PowerShell execution by entering the following command:
```PowerShell
Set-ExecutionPolicy Unrestricted -Scope Process -Force
Set-ExecutionPolicy Bypass -Scope Process -Force
```
4. In PowerShell, navigate to the directory where the files were extracted. Example: `cd c:\Win11Debloat`
@@ -112,7 +112,7 @@ Below is an overview of the key features and functionality offered by Win11Deblo
- Prevent Windows from getting updates as soon as they're available.
- Prevent automatic restarts after updates while signed in.
- Disable sharing of downloaded updates with other PCs, also known as Delivery Optimization.
- Prevent Windows from auto-installing device companion apps.
- Prevent Windows from auto-installing device companion apps, like LG Monitor App, Alienware Command Center and more.
#### Appearance
+1 -1
View File
@@ -24,7 +24,7 @@ set "SCRIPT_PATH=%~dp0Win11Debloat.ps1"
if defined wtPath (
call :Log Launching Win11Debloat.ps1 with Windows Terminal...
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $q=[char]34; Start-Process -FilePath '%wtPath%' -ArgumentList ('PowerShell -NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $w='%wtPath:'=''%'; $q=[char]34; Start-Process -FilePath $w -ArgumentList ('PowerShell -NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
) else (
echo Windows Terminal not found, using default PowerShell...
call :Log Windows Terminal not found. Using default PowerShell to launch Win11Debloat.ps1...
+10 -8
View File
@@ -470,13 +470,13 @@
<Path x:Name="LogoFallback" Data="M0,0 L80,0 L80,80 L0,80 Z M90,0 L170,0 L170,80 L90,80 Z M0,90 L80,90 L80,170 L0,170 Z M90,90 L170,90 L170,170 L90,170 Z"
Fill="{DynamicResource ButtonBgColor}" Stretch="Uniform" Margin="10"/>
<!-- Sparkle effects -->
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="50" Height="50" Margin="0,0,2,2">
<Path Canvas.Left="10" Canvas.Top="16" Data="M12,0 L14,10 L24,12 L14,14 L12,24 L10,14 L0,12 L10,10 Z"
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" Stretch="Uniform"/>
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="80" Height="80" Margin="0,0,2,2">
<Path Canvas.Left="12" Canvas.Top="32" Data="M12,0 L14,10 L24,12 L14,14 L12,24 L10,14 L0,12 L10,10 Z"
Fill="{DynamicResource AppAccentColor}" Width="60" Height="60" Stretch="Uniform"/>
<Path Canvas.Left="0" Canvas.Top="0" Data="M6,0 L7,5 L12,6 L7,7 L6,12 L5,7 L0,6 L5,5 Z"
Fill="{DynamicResource AppAccentColor}" Width="22" Height="22" Stretch="Uniform"/>
<Path Canvas.Left="35" Canvas.Top="8" Data="M4,0 L5,3 L8,4 L5,5 L4,8 L3,5 L0,4 L3,3 Z"
Fill="{DynamicResource AppAccentColor}" Width="17" Height="17" Stretch="Uniform"/>
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" Stretch="Uniform"/>
<Path Canvas.Left="55" Canvas.Top="16" Data="M4,0 L5,3 L8,4 L5,5 L4,8 L3,5 L0,4 L3,3 Z"
Fill="{DynamicResource AppAccentColor}" Width="25" Height="25" Stretch="Uniform"/>
</Canvas>
</Grid>
</Viewbox>
@@ -933,12 +933,14 @@
<StackPanel>
<TextBlock Text="Options" Style="{StaticResource CategoryHeaderTextBlock}"/>
<!-- Restore Point Option -->
<StackPanel>
<CheckBox x:Name="RegistryBackupCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a registry backup (Recommended)" AutomationProperties.Name="Create a registry backup (Recommended)"/>
</StackPanel>
<StackPanel>
<CheckBox x:Name="RestorePointCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a system restore point (Recommended)" AutomationProperties.Name="Create a system restore point (Recommended)"/>
</StackPanel>
<!-- Restart Explorer Option -->
<StackPanel>
<CheckBox x:Name="RestartExplorerCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" Content="Restart the Windows Explorer process to apply all changes immediately" AutomationProperties.Name="Restart the Windows Explorer process to apply all changes immediately"/>
</StackPanel>
-57
View File
@@ -1,57 +0,0 @@
# Forcefully removes Microsoft Edge using its uninstaller
# Credit: Based on work from loadstring1 & ave9858
function ForceRemoveEdge {
Write-Host "> Forcefully uninstalling Microsoft Edge..."
$regView = [Microsoft.Win32.RegistryView]::Registry32
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
$hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev').SetValue('AllowUninstall', '')
# Create stub (This somehow allows uninstalling Edge)
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
New-Item $edgeStub -ItemType Directory | Out-Null
New-Item "$edgeStub\MicrosoftEdge.exe" | Out-Null
# Remove edge
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
if ($null -ne $uninstallRegKey) {
Write-Host "Running uninstaller..."
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
Invoke-NonBlocking -ScriptBlock {
param($cmd)
Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait
} -ArgumentList $uninstallString
Write-Host "Removing leftover files..."
$edgePaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
"$edgeStub"
)
foreach ($path in $edgePaths) {
if (Test-Path -Path $path) {
Remove-Item -Path $path -Force -Recurse -ErrorAction SilentlyContinue
Write-Host " Removed $path" -ForegroundColor DarkGray
}
}
Write-Host "Cleaning up registry..."
# Remove MS Edge from autostart
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "Microsoft Edge Update" /f *>$null
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "Microsoft Edge Update" /f *>$null
Write-Host "Microsoft Edge was uninstalled"
}
else {
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
}
}
@@ -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
@@ -66,7 +66,14 @@ function GetInstalledAppsViaWinget {
}
}
if ($dataStart -lt 0 -or $dataStart -ge $lines.Count) { return @() }
# A missing table separator means the output is malformed or empty
if ($dataStart -lt 0) {
return $null
}
if ($dataStart -ge $lines.Count) {
return ,@()
}
$apps = [System.Collections.Generic.List[object]]::new()
@@ -94,7 +101,7 @@ function GetInstalledAppsViaWinget {
}
}
return @($apps)
return ,@($apps)
}
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
@@ -0,0 +1,146 @@
<#
.SYNOPSIS
Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries.
.OUTPUTS
System.Boolean. $true when Edge is uninstalled and cleanup succeeds; otherwise $false.
#>
function Invoke-ForceRemoveEdge {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Forcefully uninstall Microsoft Edge" -ForegroundColor Cyan
return $true
}
try {
Write-Host "> Forcefully uninstalling Microsoft Edge..."
$regView = [Microsoft.Win32.RegistryView]::Registry32
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
$edgeUpdateKey = $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev')
$edgeUpdateKey.SetValue('AllowUninstall', '')
# Create stub (This somehow allows uninstalling Edge)
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
New-Item $edgeStub -ItemType Directory -Force -ErrorAction Stop | Out-Null
New-Item "$edgeStub\MicrosoftEdge.exe" -ItemType File -Force -ErrorAction Stop | Out-Null
# Remove edge
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
if ($null -eq $uninstallRegKey) {
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
return $false
}
Write-Host "Running uninstaller..."
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
$exitCode = Invoke-NonBlocking -ScriptBlock {
param($cmd)
$process = Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait -PassThru
return $process.ExitCode
} -ArgumentList $uninstallString
if ($exitCode -ne 0) {
Write-Warning "Microsoft Edge uninstaller failed with exit code $exitCode."
return $false
}
Write-Host "Removing leftover files..."
$cleanupSucceeded = $true
$edgePaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
"$edgeStub"
)
foreach ($path in $edgePaths) {
if (Test-Path -Path $path) {
try {
Remove-Item -Path $path -Force -Recurse -ErrorAction Stop
Write-Host " Removed $path" -ForegroundColor DarkGray
}
catch {
Write-Warning "Failed to remove Edge leftover '$path': $($_.Exception.Message)"
$cleanupSucceeded = $false
}
}
}
Write-Host "Cleaning up registry..."
$registryCleanupSucceeded = $true
# Remove MS Edge from autostart. Missing values are already-clean state,
# while failures to inspect or remove an existing value are reported.
$autostartValues = @(
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'Microsoft Edge Update' },
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'Microsoft Edge Update' }
)
foreach ($autostartValue in $autostartValues) {
if (-not (Remove-EdgeAutostartValue -Path $autostartValue.Path -Name $autostartValue.Name)) {
$registryCleanupSucceeded = $false
}
}
if (-not $cleanupSucceeded -or -not $registryCleanupSucceeded) {
Write-Warning "Microsoft Edge was uninstalled, but some leftover files or autostart entries could not be removed."
return $false
}
Write-Host "Microsoft Edge was uninstalled"
return $true
}
catch {
Write-Warning "Failed to forcefully uninstall Microsoft Edge: $($_.Exception.Message)"
return $false
}
finally {
if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() }
if ($uninstallRegKey) { $uninstallRegKey.Dispose() }
if ($hklm) { $hklm.Dispose() }
}
}
<#
.SYNOPSIS
Removes an Edge autostart registry value when it exists.
.OUTPUTS
System.Boolean. $true when the value is absent or removed; $false when inspection or removal fails.
#>
function Remove-EdgeAutostartValue {
param(
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[string]$Name
)
try {
$properties = Get-ItemProperty -Path $Path -ErrorAction Stop
}
catch [System.Management.Automation.ItemNotFoundException] {
return $true
}
catch {
Write-Warning "Failed to inspect Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
return $false
}
if (-not $properties.PSObject.Properties[$Name]) {
return $true
}
try {
Remove-ItemProperty -Path $Path -Name $Name -ErrorAction Stop
return $true
}
catch {
Write-Warning "Failed to remove Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
return $false
}
}
+400
View File
@@ -0,0 +1,400 @@
<#
.SYNOPSIS
Removes one or more Windows app packages based on the target scope.
.DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
The removal method (winget vs. Appx cmdlets) is determined per-app from
Apps.json. A scheduled task is only created when the User or Sysprep
parameter was passed. After winget removal, the system is checked to
confirm whether the app is still installed before reporting an error.
Returns early if the CancelRequested flag is set.
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
.EXAMPLE
Remove-SelectedApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
.EXAMPLE
Remove-SelectedApps -appsList (Generate-AppsList)
.OUTPUTS
System.Boolean. $true when all removals can be confirmed; otherwise $false.
#>
function Remove-SelectedApps {
param (
$appslist
)
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
return $true
}
$failuresBefore = $script:AppRemovalFailures
$targetUser = Get-TargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$wingetRemovedApps = @()
$wingetRemovalFailures = @{}
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return $false }
$appIndex++
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
}
Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
$removalSucceeded = Remove-WinGetApp -app $app
$wingetRemovedApps += $app
if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) {
$wingetRemovalFailures[$app] = $true
}
}
else {
if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) {
$script:AppRemovalFailures++
}
}
}
if ($script:CancelRequested) {
return $false
}
# Check whether any winget-removed apps are still present, and report errors for each one.
if ($wingetRemovedApps.Count -gt 0) {
$postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null }
$edgeForceRemoveRequested = $false
$edgeForceRemoveSucceeded = $false
if ($null -eq $postRemovalList) {
$script:AppRemovalVerificationUnavailable = $true
foreach ($app in $wingetRemovedApps) {
$wingetRemovalFailures[$app] = $true
}
}
else {
foreach ($app in $wingetRemovedApps) {
if (-not (Test-AppInWingetList -appId $app -InstalledList $postRemovalList)) {
continue
}
if ($edgeIds -contains $app) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
if (-not $edgeForceRemoveRequested) {
$edgeForceRemoveRequested = $true
$edgeForceRemoveSucceeded = Request-EdgeForceRemove
}
if ($edgeForceRemoveSucceeded) {
continue
}
}
else {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
$wingetRemovalFailures[$app] = $true
}
}
}
$script:AppRemovalFailures += $wingetRemovalFailures.Count
return ($script:AppRemovalFailures -eq $failuresBefore)
}
<#
.SYNOPSIS
Uninstalls an app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app, with a bounded execution time.
WinGet's own exit code/success reporting is unreliable and is only logged
for diagnostics; it never causes this function to report failure. Callers
verify removal with a post-removal inventory check instead. This function
only reports failure when the winget invocation itself throws a terminating
error (e.g. it times out or cannot be started). If the User or Sysprep
parameter was passed, also schedules removal for future logins.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
.PARAMETER TimeoutSeconds
Maximum time to allow the foreground WinGet uninstall to run. Defaults
to 120 seconds.
.OUTPUTS
System.Boolean. $true unless the winget invocation threw a terminating error
or any required RunOnce scheduling failed; otherwise $false.
#>
function Remove-WinGetApp {
param(
[string]$app,
[int]$TimeoutSeconds = 120
)
if (-not $script:WingetInstalled) {
Write-Error "WinGet is either not installed or is outdated; $app could not be removed"
return $false
}
$uninstallCommandSucceeded = $true
$exitCode = $null
try {
$uninstallResult = Invoke-NonBlocking -ScriptBlock {
param($appId)
$output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1)
return [PSCustomObject]@{
ExitCode = $LASTEXITCODE
Output = $output
}
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null })
$exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' }
Write-Verbose "WinGet uninstall for $app returned exit code $exitCode."
}
catch {
$uninstallCommandSucceeded = $false
if ($_.Exception.Message -like 'Operation timed out after *') {
Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_"
}
else {
Write-Verbose "WinGet uninstall for $app failed: $_"
}
}
$scheduleSucceeded = $true
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
}
return ($uninstallCommandSucceeded -and $scheduleSucceeded)
}
<#
.SYNOPSIS
Writes captured WinGet uninstall output to the verbose stream.
.OUTPUTS
None.
#>
function Write-WinGetUninstallOutput {
param(
[object[]]$Output
)
foreach ($line in @($Output)) {
if ($null -eq $line) { continue }
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
if ([string]::IsNullOrWhiteSpace($lineText)) { continue }
Write-Verbose $lineText
}
}
<#
.SYNOPSIS
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
.PARAMETER app
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
.PARAMETER targetUser
Target scope: "AllUsers", "CurrentUser", or a specific username.
#>
function Remove-AppxApp {
param([string]$app, [string]$targetUser)
$appPattern = '*' + $app + '*'
try {
$removalResult = Invoke-NonBlocking -ScriptBlock {
param($pattern, $target)
$removalErrors = @()
$getPackageParams = @{ Name = $pattern; ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
$removePackageParams = @{ ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
switch ($target) {
'AllUsers' {
$getPackageParams.AllUsers = $true
$removePackageParams.AllUsers = $true
}
'CurrentUser' { }
default {
$userAccount = New-Object System.Security.Principal.NTAccount($target)
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
$getPackageParams.User = $userSid
$removePackageParams.User = $userSid
}
}
foreach ($package in @(Get-AppxPackage @getPackageParams)) {
$removePackageParams.Package = $package.PackageFullName
$null = Remove-AppxPackage @removePackageParams
}
if ($target -eq 'AllUsers') {
$provisionedPackages = @(Get-AppxProvisionedPackage -Online -ErrorAction Continue -ErrorVariable +removalErrors | Where-Object { $_.PackageName -like $pattern })
foreach ($package in $provisionedPackages) {
$null = Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $package.PackageName -ErrorAction Continue -ErrorVariable +removalErrors
}
}
return [PSCustomObject]@{ Success = ($removalErrors.Count -eq 0) }
} -ArgumentList @($appPattern, $targetUser)
}
catch {
Write-Error "Unable to remove $app via Appx: $_"
return $false
}
return [bool]($removalResult -and $removalResult.Success)
}
<#
.SYNOPSIS
Returns the removal method for an app identifier.
.DESCRIPTION
Parses Apps.json once (cached in script scope) to build a lookup of
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
for unknown IDs.
.PARAMETER appId
The package identifier (e.g. 'Clipchamp.Clipchamp').
#>
function Get-AppRemovalMethod {
param([string]$appId)
if (-not $script:AppRemovalMethodCache) {
$script:AppRemovalMethodCache = @{}
try {
if (Test-Path $script:AppsListFilePath) {
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
foreach ($appData in $appsJson.Apps) {
$rawMethod = $appData.RemovalMethod
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
foreach ($id in @($appData.AppId)) {
if ($id -isnot [string]) { continue }
$normalizedId = $id.Trim()
if (-not [string]::IsNullOrWhiteSpace($normalizedId)) {
$script:AppRemovalMethodCache[$normalizedId] = $method
}
}
}
}
}
catch {
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
}
}
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
return $script:AppRemovalMethodCache[$appId]
}
return 'Appx'
}
<#
.SYNOPSIS
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
.DESCRIPTION
Only invoked after it has been confirmed that Edge is still present
following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package.
.OUTPUTS
System.Boolean. $true when Edge is forcefully removed; otherwise $false.
#>
function Request-EdgeForceRemove {
if ($script:GuiWindow) {
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
if ($result -eq 'Yes') {
Write-Host ""
return (Invoke-ForceRemoveEdge)
}
}
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
Write-Host ""
return (Invoke-ForceRemoveEdge)
}
return $false
}
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
even if future catalog updates introduce IDs containing those characters.
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
#>
function Set-RunOnceWingetTask {
param([string]$appId)
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId.Replace('\', '_')
$taskName = "Uninstall_$safeAppId"
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
$escapedAppId = $appId.Replace("'", "''")
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
$operation = [PSCustomObject]@{
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
ValueName = $taskName
ValueType = 'String'
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
return $true
}
catch {
Write-Error "Failed to schedule uninstall task for $($appId): $_"
return $false
}
}
-392
View File
@@ -1,392 +0,0 @@
<#
.SYNOPSIS
Removes one or more Windows app packages based on the target scope.
.DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
The removal method (winget vs. Appx cmdlets) is determined per-app from
Apps.json. Microsoft Edge is deferred to the end of the loop so that all
winget attempts run before any force-remove prompt. A scheduled task is
only created when the User or Sysprep parameter was passed.
After each winget removal, the system is checked to confirm whether the
app is still installed before reporting an error.
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
.EXAMPLE
RemoveApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
.EXAMPLE
RemoveApps -appsList (GenerateAppsList)
#>
function RemoveApps {
param (
$appslist
)
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
Write-Host ""
return
}
$targetUser = GetTargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$edgeAppsInList = @()
$wingetRemovedApps = @()
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return }
$appIndex++
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
}
# Microsoft Edge is handled after the loop to avoid duplicate scheduled tasks and allow fallback if winget fails
if ($edgeIds -contains $app) {
$edgeAppsInList += $app
continue
}
Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
Remove-WinGetApp -app $app
$wingetRemovedApps += $app
}
else {
Remove-AppxApp -app $app -targetUser $targetUser
}
}
# Remove Microsoft Edge
if ($edgeAppsInList.Count -gt 0) {
Remove-EdgeApp -edgeAppsInList $edgeAppsInList
}
# Check whether any winget-removed apps are still present, and report errors for each one.
if ($wingetRemovedApps.Count -gt 0 -or $edgeAppsInList.Count -gt 0) {
$postRemovalList = if ($script:WingetInstalled) { GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking } else { $null }
foreach ($app in $wingetRemovedApps) {
if (Test-AppStillInstalled -appId $app -InstalledList $postRemovalList) {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
}
# Verify Edge separately (triggers its own force-remove path if still installed)
$edgeStillInstalled = $false
foreach ($edgeApp in $edgeAppsInList) {
if (Test-AppStillInstalled -appId $edgeApp -InstalledList $postRemovalList) {
$edgeStillInstalled = $true
break
}
}
if ($edgeStillInstalled) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
Request-EdgeForceRemove
}
}
Write-Host ""
}
<#
.SYNOPSIS
Uninstalls a non-Edge app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app. If the User or Sysprep
parameter was passed, also schedules removal for future logins.
After uninstall, the system is checked to confirm whether the app
is still present — winget output is not trusted on its
own, as it sometimes reports failure after a successful removal.
Edge apps are handled separately after the main loop.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
#>
function Remove-WinGetApp {
param([string]$app)
if (-not $script:WingetInstalled) {
Write-Host "ERROR: WinGet is either not installed or is outdated, $app could not be removed" -ForegroundColor Red
return
}
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(GetUserName)..."
Set-RunOnceWingetTask -appId $app
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
Set-RunOnceWingetTask -appId $app
}
Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $app
}
<#
.SYNOPSIS
Removes Microsoft Edge via WinGet (both AppIds), with fallback to force-remove.
.DESCRIPTION
Edge has multiple package IDs. Runs winget uninstall for each one,
then creates a single scheduled task if the User or Sysprep parameter
was passed. After all attempts, the system is checked to confirm
whether Edge is still present. The force-remove prompt only
appears if Edge remains installed — winget false positives are ignored.
.PARAMETER edgeAppsInList
The Edge AppIds that appear in the removal list (one or both).
#>
function Remove-EdgeApp {
param([string[]]$edgeAppsInList)
if (-not $script:WingetInstalled) {
Write-Host "ERROR: WinGet is either not installed or is outdated, Microsoft Edge could not be removed" -ForegroundColor Red
return
}
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall Microsoft Edge for user $(GetUserName)..."
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall Microsoft Edge for new users..."
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
foreach ($edgeApp in $edgeAppsInList) {
Write-Host "Removing $edgeApp"
Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $edgeApp
}
}
<#
.SYNOPSIS
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
.PARAMETER app
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
.PARAMETER targetUser
Target scope: "AllUsers", "CurrentUser", or a specific username.
#>
function Remove-AppxApp {
param([string]$app, [string]$targetUser)
$appPattern = '*' + $app + '*'
try {
switch ($targetUser) {
"AllUsers" {
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern -AllUsers | Remove-AppxPackage -AllUsers -ErrorAction Continue
Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like $pattern } | ForEach-Object { Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $_.PackageName }
} -ArgumentList $appPattern
}
"CurrentUser" {
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue
} -ArgumentList $appPattern
}
default {
Invoke-NonBlocking -ScriptBlock {
param($pattern, $user)
$userAccount = New-Object System.Security.Principal.NTAccount($user)
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
Get-AppxPackage -Name $pattern -User $userSid | Remove-AppxPackage -User $userSid -ErrorAction Continue
} -ArgumentList @($appPattern, $targetUser)
}
}
}
catch {
Write-Verbose "Something went wrong while trying to remove $($app): $_"
}
}
<#
.SYNOPSIS
Checks whether an app package is still installed after a removal attempt.
.DESCRIPTION
Checks Get-AppxPackage across all users first (fast, no process launch),
then falls back to a pre-fetched or live winget list for non-Appx packages.
Uses Test-AppInWingetList which provides exact-match-first with substring
fallback against the parsed winget objects.
Returns $true if the app is still present, $false otherwise.
.PARAMETER appId
The package identifier to check (e.g. 'Microsoft.BingNews').
.PARAMETER InstalledList
Optional pre-fetched array of winget objects from GetInstalledAppsViaWinget.
When provided, used directly; otherwise a live winget call is made.
#>
function Test-AppStillInstalled {
param(
[string]$appId,
[object[]]$InstalledList
)
# Check Get-AppxPackage for all users first (fast, covers all Store apps).
if (Get-AppxPackage -Name "$appId" -AllUsers -ErrorAction SilentlyContinue) {
return $true
}
# Use the pre-fetched list if provided; otherwise fall back to a live winget call.
if ($InstalledList) {
return (Test-AppInWingetList -appId $appId -InstalledList $InstalledList)
}
if ($script:WingetInstalled) {
$liveList = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
if (Test-AppInWingetList -appId $appId -InstalledList $liveList) {
return $true
}
}
else {
Write-Warning "Unable to verify whether '$appId' is still installed (WinGet is unavailable)"
}
return $false
}
<#
.SYNOPSIS
Returns the removal method for an app identifier.
.DESCRIPTION
Parses Apps.json once (cached in script scope) to build a lookup of
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
for unknown IDs.
.PARAMETER appId
The package identifier (e.g. 'Clipchamp.Clipchamp').
#>
function Get-AppRemovalMethod {
param([string]$appId)
if (-not $script:AppRemovalMethodCache) {
$script:AppRemovalMethodCache = @{}
try {
if (Test-Path $script:AppsListFilePath) {
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
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
}
}
}
}
catch {
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
}
}
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
return $script:AppRemovalMethodCache[$appId]
}
return 'Appx'
}
<#
.SYNOPSIS
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
.DESCRIPTION
Only invoked after it has been confirmed that Edge is still present
following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package.
#>
function Request-EdgeForceRemove {
if ($script:GuiWindow) {
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
if ($result -eq 'Yes') {
Write-Host ""
ForceRemoveEdge
}
}
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
Write-Host ""
ForceRemoveEdge
}
}
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
even if future catalog updates introduce IDs containing those characters.
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
#>
function Set-RunOnceWingetTask {
param([string]$appId)
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId.Replace('\', '_')
$taskName = "Uninstall_$safeAppId"
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
$escapedAppId = $appId.Replace("'", "''")
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
$operation = [PSCustomObject]@{
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
ValueName = $taskName
ValueType = 'String'
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
}
catch {
Write-Host "Failed to schedule uninstall task for $($appId): $_" -ForegroundColor Red
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
The identifier to search for (e.g. 'Microsoft.Copilot').
.PARAMETER InstalledList
An array of PSCustomObject from GetInstalledAppsViaWinget.
An array of PSCustomObject from Get-WingetInstalledApps.
#>
function Test-AppInWingetList {
param(
-11
View File
@@ -1,11 +0,0 @@
function AwaitKeyToExit {
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
Write-Output ""
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
}
Stop-Transcript
Exit
}
@@ -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 -ExitCode 1
}
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 -ExitCode 1
}
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"
+22
View File
@@ -0,0 +1,22 @@
<#
.SYNOPSIS
Waits for user acknowledgement, then exits the script.
.PARAMETER ExitCode
Process exit code to return after acknowledgement. Defaults to 0.
#>
function Wait-ForKeyPress {
param(
[int]$ExitCode = 0
)
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
Write-Output ""
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
}
Stop-Transcript
Exit $ExitCode
}
@@ -1,5 +1,5 @@
# Prints the header for the script
function PrintHeader {
function Write-CliHeader {
param (
$title
)
@@ -10,7 +10,7 @@ function PrintHeader {
$fullTitle = "$fullTitle (Sysprep mode)"
}
else {
$fullTitle = "$fullTitle (User: $(GetUserName))"
$fullTitle = "$fullTitle (User: $(Get-UserName))"
}
Clear-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
@@ -195,10 +195,19 @@ 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. Throws if a requested subkey
cannot be read.
#>
function Convert-RegistryKeyToSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
$RegistryKey,
[Parameter(Mandatory)]
[string]$FullPath,
[bool]$CaptureAllValues = $false,
@@ -233,7 +242,9 @@ function Convert-RegistryKeyToSnapshot {
if ($IncludeSubKeys) {
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
if ($null -eq $childKey) { continue }
if ($null -eq $childKey) {
throw "Unable to read registry subkey '$($RegistryKey.Name)\$subKeyName' while creating a backup snapshot. The backup was not created."
}
try {
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
@@ -253,20 +264,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 +312,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'"
}
@@ -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)
}
+120
View File
@@ -0,0 +1,120 @@
<#
.SYNOPSIS
Imports and executes a registry file.
.OUTPUTS
System.Boolean. $true when the registry file is applied or previewed successfully; otherwise $false.
#>
function Import-RegistryFile {
param (
$message,
$path
)
try {
Write-Host $message
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
if (-not (Test-Path $regFilePath)) {
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
Write-Host "Error: $errorMessage" -ForegroundColor Red
return $false
}
$importScript = {
param($targetRegFilePath, $hiveContext)
if ($script:Params.ContainsKey("WhatIf")) {
return (Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath)
}
# When the target user's hive is already loaded under their SID, the .reg file's
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
# which remaps Default → SID via Split-RegistryPath.
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
if ($usePowerShellFallbackOnly) {
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
if ($fallbackSucceeded) {
Write-Host "The operation completed successfully via PowerShell registry writer."
}
return $fallbackSucceeded
}
$regResult = Invoke-NonBlocking -ScriptBlock {
param($targetRegFilePath)
$result = @{
Output = @()
ExitCode = 0
Error = $null
}
try {
$global:LASTEXITCODE = 0
$output = reg import $targetRegFilePath 2>&1
$importExitCode = $LASTEXITCODE
if ($output) {
$result.Output = @($output)
}
$result.ExitCode = $importExitCode
if ($importExitCode -ne 0) {
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
}
}
catch {
$result.Error = $_.Exception.Message
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
}
return $result
} -ArgumentList $targetRegFilePath
$regOutput = @($regResult.Output)
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
if ($regOutput) {
foreach ($line in $regOutput) {
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
if ($lineText -and $lineText.Length -gt 0) {
if ($hasSuccess) {
Write-Host $lineText
}
else {
Write-Host $lineText -ForegroundColor Red
}
}
}
}
if (-not $hasSuccess) {
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
if ($fallbackSucceeded) {
Write-Host "The operation completed successfully via PowerShell registry writer."
}
return $fallbackSucceeded
}
return $true
}
if ($usesOfflineHive) {
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
$succeeded = Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
}
else {
$succeeded = & $importScript $regFilePath $null
}
return [bool]$succeeded
}
catch {
Write-Host $_.Exception.Message -ForegroundColor Red
return $false
}
}
-114
View File
@@ -1,114 +0,0 @@
# Import & execute regfile
function ImportRegistryFile {
param (
$message,
$path
)
Write-Host $message
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
if (-not (Test-Path $regFilePath)) {
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
$script:RegistryImportFailures++
Write-Host "Error: $errorMessage" -ForegroundColor Red
Write-Host ""
throw $errorMessage
}
$importScript = {
param($targetRegFilePath, $hiveContext)
if ($script:Params.ContainsKey("WhatIf")) {
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
Write-Host ""
return
}
# When the target user's hive is already loaded under their SID, the .reg file's
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
# which remaps Default → SID via Split-RegistryPath.
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
if ($usePowerShellFallbackOnly) {
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
Write-Host "The operation completed successfully via PowerShell registry writer."
Write-Host ""
return
}
$regResult = Invoke-NonBlocking -ScriptBlock {
param($targetRegFilePath)
$result = @{
Output = @()
ExitCode = 0
Error = $null
}
try {
$global:LASTEXITCODE = 0
$output = reg import $targetRegFilePath 2>&1
$importExitCode = $LASTEXITCODE
if ($output) {
$result.Output = @($output)
}
$result.ExitCode = $importExitCode
if ($importExitCode -ne 0) {
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
}
}
catch {
$result.Error = $_.Exception.Message
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
}
return $result
} -ArgumentList $targetRegFilePath
$regOutput = @($regResult.Output)
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
if ($regOutput) {
foreach ($line in $regOutput) {
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
if ($lineText -and $lineText.Length -gt 0) {
if ($hasSuccess) {
Write-Host $lineText
}
else {
Write-Host $lineText -ForegroundColor Red
}
}
}
}
if (-not $hasSuccess) {
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
Write-Host "The operation completed successfully via PowerShell registry writer."
}
Write-Host ""
}
try {
if ($usesOfflineHive) {
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
}
else {
& $importScript $regFilePath $null
}
}
catch {
$script:RegistryImportFailures++
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host ""
}
}
@@ -4,10 +4,12 @@
.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.
replacement, and other special-case features. Returns $true when the
feature completes successfully; otherwise writes a warning and returns
$false.
#>
function Invoke-FeatureApply {
param(
@@ -15,137 +17,138 @@ function Invoke-FeatureApply {
[string]$FeatureId
)
try {
# Resolve feature metadata from Features.json
$feature = $script:Features[$FeatureId]
$applyText = $feature.ApplyText
# ---- Registry-backed features: import .reg file, then handle side effects ----
# ---- Registry-backed features: import .reg file, then handle additional tasks ----
if ($feature.RegistryKey) {
ImportRegistryFile "> $applyText..." $feature.RegistryKey
if (-not (Import-RegistryFile "> $applyText..." $feature.RegistryKey)) {
return $false
}
# Post-import side effects for specific features
switch ($FeatureId) {
'DisableBing' {
# Also remove the app package for Bing search
RemoveApps @('Microsoft.BingSearch')
return (Remove-SelectedApps @('Microsoft.BingSearch'))
}
'DisableCopilot' {
# Also remove the app packages for Copilot
RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')
return (Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX'))
}
'DisableTelemetry' {
# Also disable telemetry scheduled tasks
Disable-TelemetryScheduledTasks
return (Disable-TelemetryScheduledTasks)
}
}
return
return $true
}
# ---- Custom features (no registry backing, or special handling required) ----
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
Write-Host ""
return
return $true
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
return
return (Remove-SelectedApps $appsList)
}
'RemoveGamingApps' {
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
Write-Host "> $applyText..."
RemoveApps $appsList
return
return (Remove-SelectedApps $appsList)
}
'RemoveHPApps' {
$appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl')
Write-Host "> $applyText..."
RemoveApps $appsList
return
return (Remove-SelectedApps $appsList)
}
'ForceRemoveEdge' {
Write-Host "> $applyText..."
return (Invoke-ForceRemoveEdge)
}
'DisableWidgets' {
Write-Host "> $applyText..."
# Stop widgets related processes before removing the app packages to prevent potential issues
if (-not $script:Params.ContainsKey("WhatIf")) {
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')
return
return (Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'))
}
'EnableWindowsSandbox' {
Write-Host "> $applyText..."
EnableWindowsFeature "Containers-DisposableClientVM"
Write-Host ""
return
return (Enable-WindowsFeature "Containers-DisposableClientVM")
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $applyText..."
EnableWindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host ""
return
if (-not (Enable-WindowsFeature "VirtualMachinePlatform")) { return $false }
return (Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux")
}
'ClearStart' {
Write-Host "> $applyText for user $(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
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile)
}
Write-Host ""
return
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
return $false
}
'ReplaceStart' {
Write-Host "> $applyText for user $(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")
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart"))
}
Write-Host ""
return
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
return $false
}
'ClearStartAllUsers' {
ReplaceStartMenuForAllUsers
return
return (Replace-StartMenuForAllUsers)
}
'ReplaceStartAllUsers' {
ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")
return
return (Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers"))
}
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey("Sysprep")) {
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
DisableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
return (Set-StoreSearchSuggestionsDisabledForAllUsers)
}
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
return (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb)
}
Write-Host ""
return
Write-Warning "Unable to disable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
return $false
}
}
}
catch {
Write-Warning "Failed to apply '$applyText': $($_.Exception.Message)"
return $false
}
Write-Warning "Unknown feature '$FeatureId' could not be applied."
return $false
}
<#
.SYNOPSIS
Undoes a single feature that has no RegistryUndoKey.
Undoes a single feature.
.DESCRIPTION
Handles undo for features that require custom logic rather than a simple
.reg file import. Features with a RegistryUndoKey are handled directly
via ImportRegistryFile in Invoke-UndoFeatures.
Handles registry-backed undo imports and custom undo logic. Returns
$true when the requested undo succeeds; otherwise writes a warning and
returns $false.
#>
function Invoke-FeatureUndo {
param(
@@ -154,45 +157,67 @@ function Invoke-FeatureUndo {
)
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
if (-not $feature) {
Write-Warning "Unknown feature '$FeatureId' could not be undone."
return $false
}
$undoText = if ($feature.ApplyUndoText) { $feature.ApplyUndoText } elseif ($feature.UndoLabel) { $feature.UndoLabel } else { $FeatureId }
try {
# ---- Registry-backed features: import undo data, then handle additional tasks ----
if ($feature.RegistryUndoKey) {
if (-not (Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $feature.RegistryUndoKey))) {
return $false
}
switch ($FeatureId) {
'DisableTelemetry' {
# Also re-enable telemetry scheduled tasks.
return (Enable-TelemetryScheduledTasks)
}
}
return $true
}
# ---- Custom undo features (no registry backing) ----
switch ($FeatureId) {
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) {
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
EnableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
return (Set-StoreSearchSuggestionsEnabledForAllUsers)
}
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
return (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb)
}
Write-Host ""
return
Write-Warning "Unable to re-enable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
return $false
}
'EnableWindowsSandbox' {
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Containers-DisposableClientVM'
Write-Host ""
return
Write-Host "> $undoText..."
return (Disable-WindowsFeature 'Containers-DisposableClientVM')
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
DisableWindowsFeature 'VirtualMachinePlatform'
Write-Host ""
return
}
'DisableTelemetry' {
# Also re-enable telemetry scheduled tasks
Enable-TelemetryScheduledTasks
return
Write-Host "> $undoText..."
if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false }
return (Disable-WindowsFeature 'VirtualMachinePlatform')
}
}
}
catch {
Write-Warning "Failed to undo '$undoText': $($_.Exception.Message)"
return $false
}
Write-Warning "Feature '$FeatureId' does not support undo."
return $false
}
<#
.SYNOPSIS
@@ -246,7 +271,13 @@ function Invoke-ApplyFeatures {
& $script:ApplyProgressCallback $step $TotalSteps $displayName
}
Invoke-FeatureApply -FeatureId $featureId
# Compare app-removal failure counts so a feature that only fails due to
# app removal isn't also double-reported as a feature failure.
$appRemovalFailuresBefore = $script:AppRemovalFailures
if ((-not (Invoke-FeatureApply -FeatureId $featureId)) -and ($script:AppRemovalFailures -eq $appRemovalFailuresBefore)) {
$script:FeatureFailures++
}
Write-Host ""
$step++
}
}
@@ -257,9 +288,8 @@ function Invoke-ApplyFeatures {
Undoes a list of features, reporting progress for each.
.DESCRIPTION
Iterates through the provided feature IDs. Features with a RegistryUndoKey
are handled by importing the undo .reg file; all others delegate to
Invoke-FeatureUndo for custom undo logic.
Iterates through the provided feature IDs and delegates each to
Invoke-FeatureUndo, which handles registry-backed and custom undo logic.
This is called by Invoke-AllChanges during the undo phase.
#>
function Invoke-UndoFeatures {
@@ -286,11 +316,10 @@ function Invoke-UndoFeatures {
& $script:ApplyProgressCallback $step $TotalSteps $undoText
}
if ($f -and $f.RegistryUndoKey) {
ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey)
if (-not (Invoke-FeatureUndo -FeatureId $featureId)) {
$script:FeatureFailures++
}
Invoke-FeatureUndo -FeatureId $featureId
Write-Host ""
$step++
}
}
@@ -302,8 +331,8 @@ function Invoke-UndoFeatures {
.DESCRIPTION
Sequenced in four phases:
1. Registry backup
2. System restore point
1. Registry backup (skipped when SkipRegistryBackup is present)
2. System restore point (skipped when CreateRestorePoint is absent)
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
4. Undo phase - undoes selected features via Invoke-UndoFeatures
@@ -311,13 +340,17 @@ 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."
}
$script:RegistryImportFailures = 0
$script:AppRemovalFailures = 0
$script:FeatureFailures = 0
$script:AppRemovalVerificationUnavailable = $false
# ---- Gather work items ----
$applyIds = @()
@@ -347,14 +380,15 @@ function Invoke-AllChanges {
# ---- Calculate total progress steps ----
$totalSteps = $applyIds.Count + $undoIds.Count
if ($needsBackup) { $totalSteps++ }
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ }
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$step = 0
# ================================================================
# Phase 1: Registry backup
# ================================================================
if ($needsBackup) {
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) {
if ($script:CancelRequested) { return }
$step++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..."
@@ -384,6 +418,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 +429,11 @@ function Invoke-AllChanges {
}
else {
Write-Host "> Creating a system restore point..."
CreateSystemRestorePoint
$restorePointSucceeded = Invoke-SystemRestorePoint
if (-not $restorePointSucceeded) {
if ($script:CancelRequested) { return }
$script:FeatureFailures++
}
Write-Host ""
}
}
@@ -407,6 +446,8 @@ function Invoke-AllChanges {
$step += $applyIds.Count
}
if ($script:CancelRequested) { return }
# ================================================================
# Phase 4: Undo features
# ================================================================
@@ -416,10 +457,37 @@ function Invoke-AllChanges {
}
# ================================================================
# Final: Report registry import failures
# Final: Report failures
# ================================================================
if ($script:RegistryImportFailures -gt 0) {
if ($script:AppRemovalFailures -gt 0) {
Write-Host ""
Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow
Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details."
}
if ($script:FeatureFailures -gt 0) {
Write-Host ""
Write-Warning "$($script:FeatureFailures) feature change(s) failed. See output above for details."
}
if ($script:AppRemovalVerificationUnavailable) {
Write-Host ""
Write-Warning "Unable to verify if all apps were uninstalled successfully."
}
}
<#
.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
@@ -13,7 +13,7 @@ function RestartExplorer {
Write-Host "> Attempting to restart the Windows Explorer process to apply all changes..."
if ($script:Params.ContainsKey("NoRestartExplorer")) {
if ($script:Params.ContainsKey('SkipExplorerRestart')) {
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
return
}
@@ -23,7 +23,7 @@ function RestartExplorer {
Write-Host "Warning: '$displayLabel' requires a reboot to take full effect" -ForegroundColor Yellow
}
# Only restart if the powershell process matches the OS architecture.
# Only restart if the PowerShell process matches the OS architecture.
# Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
@@ -1,10 +1,25 @@
function CreateSystemRestorePoint {
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
$failed = $false
<#
.SYNOPSIS
Creates a system restore point.
if ($SysRestore.RPSessionInterval -eq 0) {
.OUTPUTS
System.Boolean. $true when a restore point is created; otherwise $false.
#>
function Invoke-SystemRestorePoint {
$failed = $false
$isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent
try {
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" -ErrorAction Stop
}
catch {
Write-Host "Error: Unable to determine whether System Restore is enabled: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
}
if (-not $failed -and $SysRestore.RPSessionInterval -eq 0) {
# In GUI mode, skip the prompt and just try to enable it
if ($script:GuiWindow -or $Silent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
if ($script:GuiWindow -or $isSilent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
try {
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
try {
@@ -26,7 +41,6 @@ function CreateSystemRestorePoint {
}
}
else {
Write-Host ""
$failed = $true
}
}
@@ -79,17 +93,20 @@ function CreateSystemRestorePoint {
if ($result -ne "Yes") {
$script:CancelRequested = $true
return
return $false
}
}
elseif (-not $Silent) {
elseif (-not $isSilent) {
Write-Host "Failed to create a system restore point. Do you want to continue without a restore point? (y/n)" -ForegroundColor Yellow
if ($( Read-Host ) -ne 'y') {
$script:CancelRequested = $true
return
return $false
}
}
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
return $false
}
return $true
}
@@ -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)]
@@ -300,6 +317,9 @@ function Test-RegistrySnapshotAgainstAllowList {
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
$Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
}
elseif (-not (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) {
$Errors.Add("Backup contains invalid registry data for kind '$kindName' at '$valueReference'.")
}
}
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
@@ -311,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)]
@@ -428,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
@@ -439,9 +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
}
}
@@ -14,12 +14,15 @@
bundled with the script (Assets/Start/start2.bin).
.EXAMPLE
ReplaceStartMenuForAllUsers
Replace-StartMenuForAllUsers
.EXAMPLE
ReplaceStartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
.OUTPUTS
System.Boolean. $true when all resolved profiles are updated or the change is previewed; otherwise $false.
#>
function ReplaceStartMenuForAllUsers {
function Replace-StartMenuForAllUsers {
param (
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
)
@@ -29,37 +32,49 @@ function ReplaceStartMenuForAllUsers {
# Check if template bin file exists
if (-not (Test-Path $startMenuTemplate)) {
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
Write-Host ""
return
return $false
}
# Get path to start menu file for all users
$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
$success = $true
ForEach ($startMenuPath in $usersStartMenuPaths) {
ReplaceStartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate
if (-not (Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
$success = $false
}
}
# Also replace the start menu file for the default user profile
$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
return
return $true
}
# Create folder if it doesn't exist
if (-not (Test-Path $defaultStartMenuPath)) {
new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null
try {
New-Item $defaultStartMenuPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
Write-Host "Created LocalState folder for default user profile"
}
catch {
Write-Warning "Failed to create the Default profile Start Menu directory: $($_.Exception.Message)"
return $false
}
}
# Copy template to default profile
ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate
if (-not (Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
$success = $false
}
else {
Write-Host "Replaced start menu for the default user profile"
Write-Host ""
}
return $success
}
@@ -83,12 +98,15 @@ 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"
.OUTPUTS
System.Boolean. $true when the template is valid and copied, or the change is previewed; otherwise $false.
#>
function ReplaceStartMenu {
function Replace-StartMenu {
param (
[Parameter(Mandatory)]
[string]$startMenuBinFile,
@@ -98,19 +116,19 @@ function ReplaceStartMenu {
# Check if template bin file exists
if (-not (Test-Path $startMenuTemplate)) {
Write-Host "Error: Unable to replace start menu, template file not found" -ForegroundColor Red
return
return $false
}
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") {
Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red
return
return $false
}
$userName = 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
return
return $true
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
@@ -118,20 +136,27 @@ function ReplaceStartMenu {
$startMenuDir = Split-Path $startMenuBinFile -Parent
$backupBinFile = Join-Path $startMenuDir $backupFileName
try {
if (Test-Path $startMenuBinFile) {
# Backup current start menu file
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force -ErrorAction Stop
Write-Verbose "Start menu backup for user $userName saved to $backupFileName"
}
else {
Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow
New-Item -ItemType File -Path $startMenuBinFile -Force
New-Item -ItemType File -Path $startMenuBinFile -Force -ErrorAction Stop | Out-Null
}
# Copy template file
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force -ErrorAction Stop
}
catch {
Write-Warning "Failed to replace Start Menu for user ${userName}: $($_.Exception.Message)"
return $false
}
Write-Host "Replaced start menu for user $userName"
return $true
}
<#
@@ -147,12 +172,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 +186,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 +202,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 +255,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 +286,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 +367,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 +389,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 +409,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 +427,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'
@@ -0,0 +1,353 @@
<#
.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)]
[string]$Target,
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
$ArgumentObject = $null
)
$targetUserName = if ($Target -eq 'DefaultUserProfile') {
'Default'
}
elseif ($Target -like 'User:*') {
$userName = $Target.Substring(5)
if ([string]::IsNullOrWhiteSpace($userName)) {
throw 'Invalid backup target format for user restore.'
}
$userName
}
else {
throw "Unsupported backup target '$Target'."
}
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)]
$Snapshot
)
$registryParts = Split-RegistryPath -path $Snapshot.Path
if (-not $registryParts) {
throw "Unsupported registry path in backup: $($Snapshot.Path)"
}
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
if (-not $rootKey) {
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
}
$subKeyPath = $registryParts.SubKey
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
}
Test-RegistryKeySnapshotCanBeRestored -Snapshot $Snapshot
Restore-RegistryKeySnapshotAtPath -Snapshot $Snapshot -RootKey $rootKey -SubKeyPath $subKeyPath
}
<#
.SYNOPSIS
Validates registry values and subkey paths in a snapshot before live registry state is changed.
.PARAMETER Snapshot
The registry key snapshot to validate before it is restored.
#>
function Test-RegistryKeySnapshotCanBeRestored {
param(
[Parameter(Mandatory)]
$Snapshot
)
if (-not [bool]$Snapshot.Exists) { return }
$childNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($valueSnapshot in @($Snapshot.Values)) {
if ([bool]$valueSnapshot.Exists) {
$valueKind = Convert-RegistryValueKindFromBackup -KindName $valueSnapshot.Kind
$null = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $valueSnapshot.Data
}
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
$childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path
if ([string]::IsNullOrWhiteSpace($childName) -or -not $childNames.Add($childName)) {
throw "Backup contains duplicate or unsupported registry child path: $($subKeySnapshot.Path)"
}
Test-RegistryKeySnapshotCanBeRestored -Snapshot $subKeySnapshot
}
}
<#
.SYNOPSIS
Returns a snapshot child's name only when it is directly below its parent.
.PARAMETER ParentPath
The registry path of the expected parent snapshot.
.PARAMETER ChildPath
The registry path of the child snapshot to validate.
#>
function Get-DirectRegistrySnapshotChildName {
param(
[Parameter(Mandatory)]
[string]$ParentPath,
[Parameter(Mandatory)]
[string]$ChildPath
)
$parentParts = Split-RegistryPath -path $ParentPath
$childParts = Split-RegistryPath -path $ChildPath
if (-not $parentParts -or -not $childParts -or
-not $parentParts.Hive.Equals($childParts.Hive, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::IsNullOrWhiteSpace($parentParts.SubKey) -or
[string]::IsNullOrWhiteSpace($childParts.SubKey)) {
throw "Unsupported registry child path in backup: $ChildPath"
}
$childName = Split-Path -Path $childParts.SubKey -Leaf
$expectedSubKey = "$($parentParts.SubKey)\$childName"
if ([string]::IsNullOrWhiteSpace($childName) -or
-not $childParts.SubKey.Equals($expectedSubKey, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Registry child path '$ChildPath' is not directly below parent '$ParentPath'."
}
return $childName
}
<#
.SYNOPSIS
Restores a snapshot to a specific path below an already resolved registry root.
.DESCRIPTION
Writes only values and descendants represented by the backup. Existing keys are
retained so their security descriptors and unrelated data are not destroyed.
#>
function Restore-RegistryKeySnapshotAtPath {
param(
[Parameter(Mandatory)]
$Snapshot,
[Parameter(Mandatory)]
$RootKey,
[Parameter(Mandatory)]
[string]$SubKeyPath
)
if (-not $Snapshot.Exists) {
Remove-RegistrySubKeyTreeIfExists -RootKey $RootKey -SubKeyPath $SubKeyPath
return
}
$key = $RootKey.CreateSubKey($SubKeyPath)
if ($null -eq $key) {
throw "Unable to create or open registry key '$($Snapshot.Path)'"
}
try {
foreach ($valueSnapshot in @($Snapshot.Values)) {
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot $valueSnapshot
}
}
finally {
$key.Close()
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
$childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path
Restore-RegistryKeySnapshotAtPath -Snapshot $subKeySnapshot -RootKey $RootKey -SubKeyPath "$SubKeyPath\$childName"
}
}
<#
.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)]
$RegistryKey,
[Parameter(Mandatory)]
$Snapshot
)
$valueName = if ($null -ne $Snapshot.Name) { [string]$Snapshot.Name } else { '' }
if (-not [bool]$Snapshot.Exists) {
try {
$RegistryKey.DeleteValue($valueName, $false)
}
catch {
throw "Failed deleting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
}
return
}
$valueKind = Convert-RegistryValueKindFromBackup -KindName $Snapshot.Kind
$normalizedData = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $Snapshot.Data
try {
$RegistryKey.SetValue($valueName, $normalizedData, $valueKind)
}
catch {
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
)
if ([string]::IsNullOrWhiteSpace($KindName)) {
return [Microsoft.Win32.RegistryValueKind]::String
}
try {
return [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
}
catch {
throw "Unsupported registry value kind in backup: $KindName"
}
}
<#
.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,
$Data
)
switch ($Kind) {
([Microsoft.Win32.RegistryValueKind]::DWord) {
$unsigned = [uint32]$Data
return [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
}
([Microsoft.Win32.RegistryValueKind]::QWord) {
$unsigned = [uint64]$Data
return [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
}
([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) {
throw 'Invalid binary registry data in backup. Expected byte values from 0 through 255.'
}
# Keep the byte array intact instead of writing each byte to the
# pipeline. RegistryKey.SetValue requires a byte[] for Binary.
return ,$bytes
}
default {
if ($null -ne $Data) {
return [string]$Data
}
return ''
}
}
}
<#
.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
)
if ($null -eq $Data) {
return $null
}
if ($Data -is [byte[]]) {
return ,$Data
}
$items = @($Data)
if ($items.Count -eq 0) {
return ,(New-Object byte[] 0)
}
foreach ($item in $items) {
if ($item -isnot [ValueType] -and $item -isnot [string]) {
return $null
}
$parsed = 0
if (-not [int]::TryParse([string]$item, [ref]$parsed)) {
return $null
}
if ($parsed -lt 0 -or $parsed -gt 255) {
return $null
}
}
$bytes = New-Object byte[] $items.Count
for ($i = 0; $i -lt $items.Count; $i++) {
$bytes[$i] = [byte][int]$items[$i]
}
return ,$bytes
}
@@ -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
@@ -1,224 +0,0 @@
function Invoke-WithLoadedRestoreHive {
param(
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
$ArgumentObject = $null
)
$targetUserName = if ($Target -eq 'DefaultUserProfile') {
'Default'
}
elseif ($Target -like 'User:*') {
$userName = $Target.Substring(5)
if ([string]::IsNullOrWhiteSpace($userName)) {
throw 'Invalid backup target format for user restore.'
}
$userName
}
else {
throw "Unsupported backup target '$Target'."
}
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject
}
function Restore-RegistryKeySnapshot {
param(
[Parameter(Mandatory)]
$Snapshot
)
$registryParts = Split-RegistryPath -path $Snapshot.Path
if (-not $registryParts) {
throw "Unsupported registry path in backup: $($Snapshot.Path)"
}
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
if (-not $rootKey) {
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
}
$subKeyPath = $registryParts.SubKey
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
}
if (-not $Snapshot.Exists) {
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
return
}
$forceFullTree = @($Snapshot.SubKeys).Count -gt 0
if ($forceFullTree) {
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
}
$key = $rootKey.CreateSubKey($subKeyPath)
if ($null -eq $key) {
throw "Unable to create or open registry key '$($Snapshot.Path)'"
}
try {
foreach ($valueSnapshot in @($Snapshot.Values)) {
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot $valueSnapshot
}
}
finally {
$key.Close()
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
Restore-RegistryKeySnapshot -Snapshot $subKeySnapshot
}
}
function Restore-RegistryValueSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
[Parameter(Mandatory)]
$Snapshot
)
$valueName = if ($null -ne $Snapshot.Name) { [string]$Snapshot.Name } else { '' }
if (-not [bool]$Snapshot.Exists) {
try {
$RegistryKey.DeleteValue($valueName, $false)
}
catch {
throw "Failed deleting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
}
return
}
$valueKind = Convert-RegistryValueKindFromBackup -KindName $Snapshot.Kind
$normalizedData = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $Snapshot.Data
try {
$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)"
}
}
function Convert-RegistryValueKindFromBackup {
param(
[string]$KindName
)
if ([string]::IsNullOrWhiteSpace($KindName)) {
return [Microsoft.Win32.RegistryValueKind]::String
}
try {
return [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
}
catch {
throw "Unsupported registry value kind in backup: $KindName"
}
}
function Convert-RegistryValueDataFromBackup {
param(
[Microsoft.Win32.RegistryValueKind]$Kind,
$Data
)
switch ($Kind) {
([Microsoft.Win32.RegistryValueKind]::DWord) {
$unsigned = [uint32]$Data
return [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
}
([Microsoft.Win32.RegistryValueKind]::QWord) {
$unsigned = [uint64]$Data
return [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
}
([Microsoft.Win32.RegistryValueKind]::MultiString) { return @($Data | ForEach-Object { [string]$_ }) }
([Microsoft.Win32.RegistryValueKind]::Binary) {
$bytes = Convert-BackupDataToByteArray -Data $Data
if ($null -eq $bytes) {
return (New-Object byte[] 0)
}
return $bytes
}
([Microsoft.Win32.RegistryValueKind]::None) { return $null }
default {
if ($null -ne $Data) {
return [string]$Data
}
return ''
}
}
}
function Convert-BackupDataToByteArray {
param(
$Data
)
if ($null -eq $Data) {
return $null
}
if ($Data -is [byte[]]) {
return ,$Data
}
$items = @($Data)
if ($items.Count -eq 0) {
return ,(New-Object byte[] 0)
}
foreach ($item in $items) {
if ($item -isnot [ValueType] -and $item -isnot [string]) {
return $null
}
$parsed = 0
if (-not [int]::TryParse([string]$item, [ref]$parsed)) {
return $null
}
if ($parsed -lt 0 -or $parsed -gt 255) {
return $null
}
}
$bytes = New-Object byte[] $items.Count
for ($i = 0; $i -lt $items.Count; $i++) {
$bytes[$i] = [byte][int]$items[$i]
}
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)
}
}
@@ -10,24 +10,43 @@
.EXAMPLE
DisableStoreSearchSuggestionsForAllUsers
.OUTPUTS
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
#>
function DisableStoreSearchSuggestionsForAllUsers {
function Set-StoreSearchSuggestionsDisabledForAllUsers {
$success = $true
$processedProfiles = 0
# 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")
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
$success = $false
}
}
# Also disable start search suggestions for the default user profile
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
DisableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath)) {
$success = $false
}
}
if ($processedProfiles -eq 0) {
Write-Warning 'Unable to disable Microsoft Store search suggestions because no target user profiles could be resolved.'
return $false
}
return $success
}
<#
.SYNOPSIS
@@ -44,8 +63,11 @@ function DisableStoreSearchSuggestionsForAllUsers {
.EXAMPLE
DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
.OUTPUTS
System.Boolean. $true when the database ACL is restricted or previewed; otherwise $false.
#>
function DisableStoreSearchSuggestions {
function Set-StoreSearchSuggestionsDisabled {
param (
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
@@ -56,30 +78,35 @@ function DisableStoreSearchSuggestions {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan
return
return $true
}
try {
# This file doesn't exist in EEA (No Store app suggestions).
if (-not (Test-Path -Path $StoreAppsDatabase))
{
if (-not (Test-Path -Path $StoreAppsDatabase)) {
Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow
$storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent
if (-not (Test-Path -Path $storeDbDir)) {
New-Item -Path $storeDbDir -ItemType Directory -Force | Out-Null
New-Item -Path $storeDbDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null
New-Item -Path $StoreAppsDatabase -ItemType File -Force -ErrorAction Stop | Out-Null
}
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
$Acl = Get-Acl -Path $StoreAppsDatabase
$Acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
$Acl.SetAccessRule($Ace) | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl -ErrorAction Stop | Out-Null
}
catch {
Write-Warning "Failed to restrict ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
return $false
}
Write-Host "Disabled Microsoft Store search suggestions for user $userName"
return $true
}
<#
@@ -94,24 +121,43 @@ function DisableStoreSearchSuggestions {
.EXAMPLE
EnableStoreSearchSuggestionsForAllUsers
.OUTPUTS
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
#>
function EnableStoreSearchSuggestionsForAllUsers {
function Set-StoreSearchSuggestionsEnabledForAllUsers {
$success = $true
$processedProfiles = 0
# 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")
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
$success = $false
}
}
# Also re-enable for the default user profile
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
EnableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath)) {
$success = $false
}
}
if ($processedProfiles -eq 0) {
Write-Warning 'Unable to re-enable Microsoft Store search suggestions because no target user profiles could be resolved.'
return $false
}
return $success
}
<#
.SYNOPSIS
Re-enables Microsoft Store search suggestions for a single user.
@@ -127,8 +173,11 @@ function EnableStoreSearchSuggestionsForAllUsers {
.EXAMPLE
EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
.OUTPUTS
System.Boolean. $true when the deny ACL is removed, the database is absent, or the change is previewed; otherwise $false.
#>
function EnableStoreSearchSuggestions {
function Set-StoreSearchSuggestionsEnabled {
param (
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
@@ -139,23 +188,31 @@ function EnableStoreSearchSuggestions {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Re-enable Microsoft Store search suggestions for user $userName by restoring access to ${StoreAppsDatabase}" -ForegroundColor Cyan
return
return $true
}
if (-not (Test-Path -Path $StoreAppsDatabase)) {
Write-Host "Store app database not found for user $userName, nothing to undo"
return
return $true
}
# Ensure we can modify/delete the file even if restrictive ACLs were set.
$global:LASTEXITCODE = 0
takeown /F "$StoreAppsDatabase" /A | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to take ownership of store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
return $false
}
icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to grant Administrators access to store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
return $false
}
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
try {
$acl = Get-Acl -Path $StoreAppsDatabase
$acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
$denyRules = @(
$acl.Access | Where-Object {
if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false }
@@ -173,7 +230,7 @@ function EnableStoreSearchSuggestions {
$null = $acl.RemoveAccessRuleSpecific($denyRule)
}
Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $acl -ErrorAction Stop | Out-Null
}
catch {
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
@@ -182,9 +239,11 @@ function EnableStoreSearchSuggestions {
try {
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
return $true
}
catch {
throw "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
Write-Warning "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
return $false
}
}
@@ -201,12 +260,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 +274,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 +346,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
}
@@ -34,21 +34,36 @@ function Get-TelemetryScheduledTasks {
.EXAMPLE
Disable-TelemetryScheduledTasks
.OUTPUTS
System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false.
#>
function Disable-TelemetryScheduledTasks {
Write-Host "> Disabling telemetry scheduled tasks..."
$tasks = Get-TelemetryScheduledTasks
$success = $true
foreach ($task in $tasks) {
if ($script:CancelRequested) { return $false }
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
continue
}
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($path, $name)
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
try {
Import-Module ScheduledTasks -ErrorAction Stop
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
}
catch {
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
return @{ Success = $true; Status = 'NotFound' }
}
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
}
if (-not $taskObj) {
return @{ Success = $true; Status = 'NotFound' }
}
@@ -63,16 +78,23 @@ function Disable-TelemetryScheduledTasks {
}
return @{ Success = $true; Status = 'AlreadyDisabled' }
} -ArgumentList @($task.Path, $task.Name)
}
catch {
Write-Warning "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
$success = $false
continue
}
switch ($result.Status) {
'Disabled' { Write-Host "Disabled Scheduled Task: $($task.Path)$($task.Name)" }
'AlreadyDisabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already disabled" -ForegroundColor DarkGray }
'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found" -ForegroundColor DarkGray }
'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow }
'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
default { Write-Warning "Unable to determine the result of disabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
}
}
Write-Host ""
return $success
}
<#
@@ -86,21 +108,36 @@ function Disable-TelemetryScheduledTasks {
.EXAMPLE
Enable-TelemetryScheduledTasks
.OUTPUTS
System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false.
#>
function Enable-TelemetryScheduledTasks {
Write-Host "> Enabling telemetry scheduled tasks..."
$tasks = Get-TelemetryScheduledTasks
$success = $true
foreach ($task in $tasks) {
if ($script:CancelRequested) { return $false }
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
continue
}
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($path, $name)
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
try {
Import-Module ScheduledTasks -ErrorAction Stop
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
}
catch {
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
return @{ Success = $true; Status = 'NotFound' }
}
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
}
if (-not $taskObj) {
return @{ Success = $true; Status = 'NotFound' }
}
@@ -115,14 +152,21 @@ function Enable-TelemetryScheduledTasks {
}
return @{ Success = $true; Status = 'AlreadyEnabled' }
} -ArgumentList @($task.Path, $task.Name)
}
catch {
Write-Warning "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
$success = $false
continue
}
switch ($result.Status) {
'Enabled' { Write-Host "Enabled Scheduled Task: $($task.Path)$($task.Name)" }
'AlreadyEnabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already enabled." -ForegroundColor DarkGray }
'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found." -ForegroundColor DarkGray }
'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow }
'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
default { Write-Warning "Unable to determine the result of enabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
}
}
Write-Host ""
return $success
}
@@ -0,0 +1,119 @@
<#
.SYNOPSIS
Enables a Windows optional feature and pipes its output to the console.
.OUTPUTS
System.Boolean. $true when enabling succeeds or is previewed; otherwise $false.
#>
function Enable-WindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
return $true
}
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
try {
$output = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -ErrorAction Stop
return [PSCustomObject]@{
Success = $true
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
Error = $null
}
}
catch {
return [PSCustomObject]@{
Success = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $FeatureName
}
catch {
Write-Warning "Failed to enable Windows feature '$FeatureName': $($_.Exception.Message)"
return $false
}
if (-not $result -or -not $result.Success) {
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
Write-Warning "Failed to enable Windows feature '$FeatureName'$details"
return $false
}
if ($result.Output) { Write-Host $result.Output }
return $true
}
<#
.SYNOPSIS
Disables a Windows optional feature and pipes its output to the console.
.OUTPUTS
System.Boolean. $true when disabling succeeds or is previewed; otherwise $false.
#>
function Disable-WindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
return $true
}
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
try {
$output = Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart -ErrorAction Stop
return [PSCustomObject]@{
Success = $true
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
Error = $null
}
}
catch {
return [PSCustomObject]@{
Success = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $FeatureName
}
catch {
Write-Warning "Failed to disable Windows feature '$FeatureName': $($_.Exception.Message)"
return $false
}
if (-not $result -or -not $result.Success) {
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
Write-Warning "Failed to disable Windows feature '$FeatureName'$details"
return $false
}
if ($result.Output) { Write-Host $result.Output }
return $true
}
function Test-WindowsOptionalFeatureEnabled {
param (
[Parameter(Mandatory)]
[string]$FeatureName
)
try {
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
}
catch {
return $false
}
return ($feature.State -eq 'Enabled')
}
@@ -1,61 +0,0 @@
# Enables a Windows optional feature and pipes its output to the console
function EnableWindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
Write-Host ""
return
}
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}
# Disables a Windows optional feature and pipes its output to the console
function DisableWindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
Write-Host ""
return
}
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}
function Test-WindowsOptionalFeatureEnabled {
param (
[Parameter(Mandatory)]
[string]$FeatureName
)
try {
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
}
catch {
return $false
}
return ($feature.State -eq 'Enabled')
}
@@ -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 -ExitCode 1
}
}
@@ -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,7 +24,7 @@ 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
}
+37
View File
@@ -0,0 +1,37 @@
<#
.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 -ErrorAction Stop
return $true
}
catch {
Write-Error "Failed to write '$FilePath': $($_.Exception.Message)"
return $false
}
}
-22
View File
@@ -1,22 +0,0 @@
# Saves configuration JSON to a file.
# Returns $true on success, $false on failure.
function SaveToFile {
param (
[Parameter(Mandatory=$true)]
[hashtable]$Config,
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$false)]
[int]$MaxDepth = 10
)
try {
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8
return $true
}
catch {
return $false
}
}
@@ -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'
+101 -16
View File
@@ -174,6 +174,10 @@ function Update-AppSelectionStatus {
}
}
<#
.SYNOPSIS
Updates the app-removal scope description to match the selected ComboBoxItem.
#>
function Update-AppRemovalScopeDescription {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
@@ -182,20 +186,60 @@ function Update-AppRemovalScopeDescription {
$selectedItem = $AppRemovalScopeCombo.SelectedItem
if ($selectedItem) {
switch ($selectedItem.Content) {
"All users" {
# Content is the display text and will change once translated; Name is stable.
switch ($selectedItem.Name) {
"AppRemovalScopeAllUsers" {
$AppRemovalScopeDescription.Text = "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users."
}
"Current user only" {
"AppRemovalScopeCurrentUser" {
$AppRemovalScopeDescription.Text = "Apps will only be removed for the current user."
}
"Target user only" {
"AppRemovalScopeTargetUser" {
$AppRemovalScopeDescription.Text = "Apps will only be removed for the specified target user."
}
}
}
}
<#
.SYNOPSIS
Tests whether the app-removal scope combo is currently set to "Target user only".
#>
function Test-AppRemovalScopeTargetsOtherUser {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
)
return ($AppRemovalScopeCombo -and $AppRemovalScopeCombo.SelectedItem -and $AppRemovalScopeCombo.SelectedItem.Name -eq 'AppRemovalScopeTargetUser')
}
<#
.SYNOPSIS
Resolves the -AppRemovalTarget value for the selected app-removal scope.
#>
function Get-AppRemovalScopeTarget {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
[System.Windows.Controls.TextBox]$OtherUsernameTextBox
)
$selectedItem = $AppRemovalScopeCombo.SelectedItem
if (-not $selectedItem) { return $null }
if (Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo) {
return $OtherUsernameTextBox.Text.Trim()
}
switch ($selectedItem.Name) {
"AppRemovalScopeAllUsers" { return 'AllUsers' }
"AppRemovalScopeCurrentUser" { return 'CurrentUser' }
default {
Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal."
return $null
}
}
}
function Invoke-AppPreset {
param(
[System.Windows.Controls.Panel]$AppsPanel,
@@ -223,7 +267,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 +285,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 +348,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 +401,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 +501,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 +515,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 +546,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 +596,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 +604,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 +613,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 {
+42 -13
View File
@@ -147,7 +147,20 @@ function Invoke-ShowChangesOverview {
Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600
}
function Build-TweakPresetControlMap {
<#
.SYNOPSIS
Builds the control values needed to apply a saved tweak preset.
.PARAMETER Window
The window that owns the visible tweak controls.
.PARAMETER SettingsJson
The saved settings object to translate into control values.
.OUTPUTS
System.Collections.Hashtable. Control metadata keyed by control name.
#>
function Get-TweakPresetControlMap {
param(
[System.Windows.Window]$Window,
$SettingsJson
@@ -158,7 +171,7 @@ function Build-TweakPresetControlMap {
return $presetMap
}
# FeatureId -> control metadata, similar to ApplySettingsToUiControls lookup.
# FeatureId -> control metadata, similar to Apply-SettingsToUiControls lookup.
$featureIdIndex = @{}
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
@@ -199,10 +212,23 @@ 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 CategoryId
The stable CategoryId (from Features.json) 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
[string]$CategoryId
)
$presetMap = @{}
@@ -210,7 +236,7 @@ function Build-CategoryTweakPresetMap {
foreach ($controlName in $script:UiControlMappings.Keys) {
$mapping = $script:UiControlMappings[$controlName]
if ($mapping.Category -ne $Category) { continue }
if ($mapping.CategoryId -ne $CategoryId) { continue }
$control = $Window.FindName($controlName)
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
@@ -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 -CategoryId 'PrivacySuggestedContent'
$script:AITweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId '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"
}
@@ -452,11 +478,14 @@ function Test-OtherUsername {
[System.Windows.Window]$Window,
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
[System.Windows.Controls.TextBox]$OtherUsernameTextBox,
[System.Windows.Controls.TextBlock]$UsernameValidationMessage
[System.Windows.Controls.TextBlock]$UsernameValidationMessage,
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
)
# Only validate if "Other User" is selected
if ($UserSelectionCombo.SelectedIndex -ne 1) {
# Only validate if "Other User" is the deployment target, or "Target user only" is the app-removal scope
$isOtherUserSelected = ($UserSelectionCombo.SelectedIndex -eq 1)
$isAppRemovalTargetUserSelected = Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo
if (-not $isOtherUserSelected -and -not $isAppRemovalTargetUserSelected) {
return $true
}
+89 -20
View File
@@ -1,13 +1,26 @@
# MainWindow-TweaksBuilder.ps1
# Dynamic tweaks UI construction from Features.json, tweak state management, selection clear, and search/highlight.
function Build-DynamicTweaks {
<#
.SYNOPSIS
Builds the main window's dynamic tweak controls from Features.json.
.PARAMETER Window
The main window whose category columns receive the generated controls.
.PARAMETER WinVersion
The Windows build number used for the category-icon fallback.
.NOTES
Initializes script-scoped control and category mappings used by the tweak UI.
#>
function New-DynamicTweakControls {
param(
[System.Windows.Window]$Window,
[int]$WinVersion
)
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
$featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
if (-not $featuresJson) {
throw "Unable to load Features.json file. The GUI cannot continue without feature definitions."
@@ -29,7 +42,26 @@ function Build-DynamicTweaks {
$script:TweaksCompactMode = $null
$script:TweaksCardsMovedFromCol2 = @()
function CreateLabeledCombo($parent, $labelText, $comboName, $items) {
<#
.SYNOPSIS
Creates and registers a labeled combo box or a checkbox for a tweak.
.PARAMETER Parent
The panel that receives the generated control.
.PARAMETER LabelText
The display and automation label for the tweak.
.PARAMETER ComboName
The name used to register the generated control.
.PARAMETER Items
The available tweak options; two options produce a checkbox.
.OUTPUTS
System.Windows.Controls.Control. The generated checkbox or combo box.
#>
function New-LabeledCombo($parent, $labelText, $comboName, $items) {
# If only 2 items (No Change + one option), use a checkbox instead
if ($items.Count -eq 2) {
$checkbox = New-Object System.Windows.Controls.CheckBox
@@ -96,7 +128,17 @@ function Build-DynamicTweaks {
return $combo
}
function GetWikiUrlForCategory($category) {
<#
.SYNOPSIS
Returns the Features wiki URL for a tweak category.
.PARAMETER Category
The category name converted to a wiki anchor.
.OUTPUTS
System.String. The category URL, or the Features page for an empty category.
#>
function Get-WikiUrlForCategory($category) {
if (-not $category) { return 'https://github.com/Raphire/Win11Debloat/wiki/Features' }
$slug = $category.ToLowerInvariant()
@@ -107,7 +149,17 @@ function Build-DynamicTweaks {
return "https://github.com/Raphire/Win11Debloat/wiki/Features#$slug"
}
function GetOrCreateCategoryCard($categoryObj) {
<#
.SYNOPSIS
Returns the existing category panel or creates and registers a new one.
.PARAMETER CategoryObj
The category definition containing Name and Icon properties.
.OUTPUTS
System.Windows.Controls.StackPanel. The category's content panel.
#>
function Get-OrCreateCategoryCard($categoryObj) {
$categoryName = $categoryObj.Name
$categoryIcon = $categoryObj.Icon
@@ -152,7 +204,7 @@ function Build-DynamicTweaks {
$helpBtn = New-Object System.Windows.Controls.Button
$helpBtn.Content = $helpIcon
$helpBtn.ToolTip = "Open the wiki for more info on '$categoryName' tweaks"
$helpBtn.Tag = (GetWikiUrlForCategory -category $categoryName)
$helpBtn.Tag = (Get-WikiUrlForCategory -category $categoryName)
$helpBtn.Style = $Window.Resources['CategoryHelpLinkButtonStyle']
$helpBtn.Add_Click({
param($button, $e)
@@ -182,8 +234,17 @@ function Build-DynamicTweaks {
foreach ($c in $featuresJson.Categories) {
$categoryName = if ($c -is [string]) { $c } else { $c.Name }
if ($categoriesPresent.ContainsKey($categoryName)) {
# Store the full category object (or create one with default icon for string categories)
$categoryObj = if ($c -is [string]) { @{Name = $c; Icon = '&#xE712;' } } else { $c }
# Store the full category object (or create one with default icon for string categories).
# A category without its own CategoryId falls back to its Name, same as before CategoryId existed.
$categoryObj = if ($c -is [string]) {
@{Name = $c; CategoryId = $c; Icon = '&#xE712;' }
}
elseif (-not $c.CategoryId) {
@{Name = $c.Name; CategoryId = $c.Name; Icon = $c.Icon }
}
else {
$c
}
$orderedCategories += $categoryObj
}
}
@@ -191,7 +252,7 @@ function Build-DynamicTweaks {
else {
# For backward compatibility, create category objects from keys
foreach ($catName in $categoriesPresent.Keys) {
$orderedCategories += @{Name = $catName; Icon = '&#xE712;' }
$orderedCategories += @{Name = $catName; CategoryId = $catName; Icon = '&#xE712;' }
}
}
@@ -203,6 +264,7 @@ function Build-DynamicTweaks {
foreach ($categoryObj in $orderedCategories) {
$categoryName = $categoryObj.Name
$categoryId = $categoryObj.CategoryId
# Card is created lazily on the first rendered item
$panel = $null
@@ -289,8 +351,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
@@ -307,15 +369,15 @@ function Build-DynamicTweaks {
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; Category = $categoryName }
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; CategoryId = $categoryId }
}
continue
}
$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
@@ -327,7 +389,7 @@ function Build-DynamicTweaks {
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; Category = $categoryName }
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; CategoryId = $categoryId }
}
elseif ($item.Type -eq 'feature') {
$feature = $item.Data
@@ -335,8 +397,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
@@ -354,7 +416,7 @@ function Build-DynamicTweaks {
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; Category = $categoryName }
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; CategoryId = $categoryId }
}
}
}
@@ -377,7 +439,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 +485,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
+9 -2
View File
@@ -1,3 +1,10 @@
<#
.SYNOPSIS
Displays the themed About dialog for the application.
.PARAMETER Owner
The optional window that owns the dialog and its modal overlay.
#>
function Show-AboutDialog {
param (
[Parameter(Mandatory=$false)]
@@ -6,7 +13,7 @@ function Show-AboutDialog {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Determine owner window
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
@@ -42,7 +49,7 @@ function Show-AboutDialog {
}
# Apply theme resources
SetWindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
# Get UI elements
$titleBar = $aboutWindow.FindName('TitleBar')
+24 -12
View File
@@ -1,8 +1,14 @@
# Shows application selection window that allows the user to select what apps they want to remove or keep
<#
.SYNOPSIS
Displays the application-selection dialog and records the confirmed selections.
.OUTPUTS
System.Nullable[System.Boolean]. The dialog result; confirmed application IDs are stored in $script:SelectedApps.
#>
function Show-AppSelectionWindow {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Show overlay if main window exists
$overlay = $null
@@ -34,7 +40,7 @@ function Show-AppSelectionWindow {
catch { }
}
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
$appsPanel = $window.FindName('AppsPanel')
$checkAllBox = $window.FindName('CheckAllBox')
@@ -46,8 +52,14 @@ function Show-AppSelectionWindow {
# Track the last selected checkbox for shift-click range selection
$script:AppSelectionWindowLastSelectedCheckbox = $null
# Loads apps into the apps UI
function LoadApps {
<#
.SYNOPSIS
Reloads the application-selection checkboxes using the current installed-apps filter.
.NOTES
Updates the dialog loading indicator and resets range-selection state.
#>
function Load-Apps {
# Show loading indicator
$loadingIndicator.Visibility = 'Visible'
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{})
@@ -57,7 +69,7 @@ function Show-AppSelectionWindow {
if ($onlyInstalledBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
# Attempt to get a list of installed apps via WinGet, times out after 10 seconds
$listOfApps = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
$listOfApps = Get-WingetInstalledApps -TimeOut 10 -NonBlocking
if ($null -eq $listOfApps) {
# Show error that the script was unable to get list of apps from WinGet
Show-MessageBox -Message 'Unable to load list of installed apps via WinGet.' -Title 'Error' -Button 'OK' -Icon 'Error' -Owner $window | Out-Null
@@ -65,7 +77,7 @@ function Show-AppSelectionWindow {
}
}
$appsToAdd = LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true
$appsToAdd = Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true
# Reset the last selected checkbox when loading a new list
$script:AppSelectionWindowLastSelectedCheckbox = $null
@@ -82,7 +94,7 @@ function Show-AppSelectionWindow {
$checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"]
# Attach shift-click behavior for range selection
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox)
Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox)
$appsPanel.Children.Add($checkbox) | Out-Null
}
@@ -112,8 +124,8 @@ function Show-AppSelectionWindow {
}
})
$onlyInstalledBox.Add_Checked({ LoadApps })
$onlyInstalledBox.Add_Unchecked({ LoadApps })
$onlyInstalledBox.Add_Checked({ Load-Apps })
$onlyInstalledBox.Add_Unchecked({ Load-Apps })
$confirmBtn.Add_Click({
$selectedApps = @()
@@ -130,7 +142,7 @@ function Show-AppSelectionWindow {
return
}
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
return
}
@@ -141,7 +153,7 @@ function Show-AppSelectionWindow {
# Load apps after window is shown (allows UI to render first)
$window.Add_ContentRendered({
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ LoadApps }) | Out-Null
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Load-Apps }) | Out-Null
})
# Show the window and return dialog result
+36 -16
View File
@@ -1,14 +1,24 @@
<#
.SYNOPSIS
Displays the modal progress window while selected changes are applied.
.PARAMETER Owner
The optional window that owns the modal and its overlay.
.PARAMETER InvokeRestartExplorer
Indicates whether the modal should run the Explorer-restart flow after applying changes.
#>
function Show-ApplyModal {
param (
[Parameter(Mandatory=$false)]
[System.Windows.Window]$Owner = $null,
[Parameter(Mandatory=$false)]
[bool]$RestartExplorer = $false
[bool]$InvokeRestartExplorer = $false
)
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Determine owner window
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
@@ -44,7 +54,7 @@ function Show-ApplyModal {
}
# Apply theme resources
SetWindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
# Get UI elements
$script:ApplyInProgressPanel = $applyWindow.FindName('ApplyInProgressPanel')
@@ -81,7 +91,7 @@ function Show-ApplyModal {
$pct = if ($totalSteps -gt 0) { [math]::Round((($currentStep - 1) / $totalSteps) * 100) } else { 0 }
$script:ApplyProgressBarEl.Value = $pct
# Process pending window messages to keep UI responsive
DoEvents
Invoke-DoEvents
}
# Sub-step callback updates step name and interpolates progress bar within the current step
@@ -96,7 +106,7 @@ function Show-ApplyModal {
$stepFraction = ($subIndex / $subCount) / $totalSteps
$script:ApplyProgressBarEl.Value = [math]::Round(($baseProgress + $stepFraction) * 100)
}
DoEvents
Invoke-DoEvents
}
# Run changes in background to keep UI responsive
@@ -104,11 +114,12 @@ function Show-ApplyModal {
try {
Invoke-AllChanges
$registryImportFailureCount = [int]$script:RegistryImportFailures
$failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures
$appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable
# 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
@@ -118,11 +129,6 @@ function Show-ApplyModal {
}
Write-Host ""
if ($script:CancelRequested) {
Write-Host "Script execution was cancelled by the user. Some changes may not have been applied."
} elseif ($registryImportFailureCount -eq 0) {
Write-Host "All changes have been applied successfully!"
}
# Show completion state
$script:ApplyProgressBarEl.Value = 100
@@ -130,20 +136,34 @@ function Show-ApplyModal {
$script:ApplyCompletionPanel.Visibility = 'Visible'
if ($script:CancelRequested) {
Write-Warning "Script execution was cancelled by the user. Any remaining changes were not applied."
$script:ApplyCompletionIconEl.Text = [char]0xE7BA
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
$script:ApplyCompletionTitleEl.Text = "Cancelled"
$script:ApplyCompletionMessageEl.Text = "Script execution was cancelled by the user."
} elseif ($registryImportFailureCount -gt 0) {
} elseif ($failureCount -gt 0 -or $appRemovalVerificationUnavailable) {
if ($failureCount -gt 0) {
Write-Host "Script completed with $failureCount error(s)."
}
$script:ApplyCompletionIconEl.Text = [char]0xE7BA
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
if ($failureCount -eq 0 -and $appRemovalVerificationUnavailable) {
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
$script:ApplyCompletionMessageEl.Text = "All changes were applied without errors, but Win11Debloat could not confirm that all selected apps were successfully uninstalled."
}
else {
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors"
$script:ApplyCompletionMessageEl.Text = "$registryImportFailureCount registry change(s) failed. See console for details."
$script:ApplyCompletionMessageEl.Text = "$failureCount change(s) failed. See console for details."
}
} else {
Write-Host "All changes have been applied successfully!"
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
# Show completion message with reboot instructions if any applied features require reboot
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 {
@@ -215,6 +219,11 @@ function Get-DeploymentSettings {
$deploySettings += @{ Name = 'CreateRestorePoint'; Value = [bool]$restorePointCheckBox.IsChecked }
}
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox) {
$deploySettings += @{ Name = 'SkipRegistryBackup'; Value = -not [bool]$registryBackupCheckBox.IsChecked }
}
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
if ($restartExplorerCheckBox) {
$deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked }
@@ -268,6 +277,7 @@ function Get-DeploymentCategoryDetailString {
$options = @()
if ($lookup.ContainsKey('CreateRestorePoint') -and [bool]$lookup['CreateRestorePoint']) { $options += 'Restore Point' }
if (-not ($lookup.ContainsKey('SkipRegistryBackup') -and [bool]$lookup['SkipRegistryBackup'])) { $options += 'Registry Backup' }
if ($lookup.ContainsKey('RestartExplorer') -and [bool]$lookup['RestartExplorer']) { $options += 'Restart Explorer' }
$lines = @()
@@ -308,7 +318,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 +335,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 +347,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,
@@ -362,12 +384,23 @@ function Apply-ImportedDeploymentSettings {
$restorePointCheckBox.IsChecked = [bool]$lookup['CreateRestorePoint']
}
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox) {
if ($lookup.ContainsKey('SkipRegistryBackup')) {
$registryBackupCheckBox.IsChecked = -not [bool]$lookup['SkipRegistryBackup']
}
}
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) {
$restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer']
}
}
<#
.SYNOPSIS
Exports selected application, tweak, and deployment settings to a configuration file.
#>
function Export-Configuration {
param (
[System.Windows.Window]$Owner,
@@ -427,7 +460,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 +470,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,27 +499,22 @@ 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
return
}
if (-not $config.Version) {
Write-Error "Invalid configuration file format: '$($openDialog.FileName)'"
Show-MessageBox -Message "Invalid configuration file format." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
$consistencyError = Test-ConfigConsistency -Config $config
if ($consistencyError) {
Write-Error "Invalid configuration file '$($openDialog.FileName)': $consistencyError"
Show-MessageBox -Message "Invalid configuration file: $consistencyError" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
return
}
$availableCategories = Get-AvailableImportExportCategories -Config $config
if ($availableCategories.Count -eq 0) {
Write-Warning "Configuration file '$($openDialog.FileName)' contains no importable data."
Show-MessageBox -Message "The selected file contains no importable data." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
return
}
Write-Host "Available categories in config: $($availableCategories -join ', ')"
$appCount = @($config.Apps | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }).Count
@@ -504,7 +536,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 +545,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.'
+52 -38
View File
@@ -1,8 +1,12 @@
function Show-MainWindow {
<#
.SYNOPSIS
Creates and displays the main Win11Debloat window.
#>
function Show-MainWindow {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null
$WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# ---- Load XAML ----
$xaml = Get-Content -Path $script:MainWindowSchema -Raw
@@ -14,7 +18,7 @@
$reader.Close()
}
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
$mainBorder = $window.FindName('MainBorder')
$titleBarBackground = $window.FindName('TitleBarBackground')
@@ -223,7 +227,7 @@
if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false }
# ---- Build JSON-defined app presets ----
foreach ($preset in (LoadAppPresetsFromJson)) {
foreach ($preset in (Import-AppPresetsFromJson)) {
$checkbox = New-Object System.Windows.Controls.CheckBox
$checkbox.Content = $preset.Name
$checkbox.IsThreeState = $true
@@ -301,8 +305,8 @@
# ---- Load apps ----
$appLoadStatusCallback = { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo }
$onlyInstalledAppsBox.Add_Checked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
$onlyInstalledAppsBox.Add_Unchecked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
$onlyInstalledAppsBox.Add_Checked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
$onlyInstalledAppsBox.Add_Unchecked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
# ---- App presets popup ----
$presetsPopup.Add_Opened({
@@ -573,6 +577,7 @@
# ---- App removal scope combo ----
$appRemovalScopeCombo.Add_SelectionChanged({
Update-AppRemovalScopeDescription -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeDescription $appRemovalScopeDescription
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
})
# ---- Other username text box ----
@@ -584,12 +589,12 @@
$usernameTextBoxPlaceholder.Visibility = 'Collapsed'
}
Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage | Out-Null
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
})
# ---- Validate target user helper ----
$ensureValidTargetUserOrWarn = {
if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage)) {
if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo)) {
$validationMessage = if (-not [string]::IsNullOrWhiteSpace($usernameValidationMessage.Text)) {
$usernameValidationMessage.Text
}
@@ -617,9 +622,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,25 +668,23 @@
$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 ',')
$scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox
if ([string]::IsNullOrWhiteSpace($scopeTarget)) {
Write-Warning 'App removal was cancelled because the selected removal scope is invalid.'
return
}
$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()) }
}
}
Add-Parameter 'RemoveApps'
Add-Parameter 'Apps' ($selectedApps -join ',')
Add-Parameter 'AppRemovalTarget' $scopeTarget
}
# 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 +698,32 @@
$restorePointCheckBox = $window.FindName('RestorePointCheckBox')
if ($restorePointCheckBox -and $restorePointCheckBox.IsChecked) {
AddParameter 'CreateRestorePoint'
Add-Parameter 'CreateRestorePoint'
}
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox -and -not $registryBackupCheckBox.IsChecked) {
Add-Parameter 'SkipRegistryBackup'
}
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 +745,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 +758,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))"
}
}
@@ -773,11 +781,17 @@
}
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
if ($restartExplorerCheckBox -and $script:Params.ContainsKey("NoRestartExplorer")) {
if ($restartExplorerCheckBox -and $script:Params.ContainsKey('SkipExplorerRestart')) {
$restartExplorerCheckBox.IsChecked = $false
$restartExplorerCheckBox.IsEnabled = $false
}
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox -and $script:Params.ContainsKey('SkipRegistryBackup')) {
$registryBackupCheckBox.IsChecked = $false
$registryBackupCheckBox.IsEnabled = $false
}
if ($script:Params.ContainsKey("Sysprep")) {
$userSelectionCombo.SelectedIndex = 2
$userSelectionCombo.IsEnabled = $false
@@ -809,8 +823,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 +883,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: $_"
+6 -3
View File
@@ -1,4 +1,7 @@
# Shows a Windows 11 styled custom message box
<#
.SYNOPSIS
Shows a themed Windows 11-style message box.
#>
function Show-MessageBox {
param (
[Parameter(Mandatory=$true)]
@@ -24,7 +27,7 @@ function Show-MessageBox {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Determine owner window - use provided Owner, or fall back to main GUI window
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
@@ -69,7 +72,7 @@ function Show-MessageBox {
}
# Apply theme resources
SetWindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
# Get UI elements
$titleText = $msgWindow.FindName('TitleText')
+7 -7
View File
@@ -15,7 +15,7 @@
Hashtable
Returns a Hashtable describing the user's choice. Possible shapes:
RestoreRegistry - @{ Result='RestoreRegistry'; Backup=<normalizedBackup> }
RestoreStartMenu - @{ Result='RestoreStartMenu'; StartMenuScope=<scope>;
Restore-StartMenu - @{ Result='Restore-StartMenu'; StartMenuScope=<scope>;
UseManualBackupFile=<bool>; BackupFilePath=<path|string> }
Cancelled - @{ Result='Cancelled' } (from New-RestoreDialogState)
#>
@@ -26,7 +26,7 @@ function Show-RestoreBackupDialog {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
$overlay = $null
@@ -67,7 +67,7 @@ function Show-RestoreBackupDialog {
}
try {
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
}
catch { }
@@ -129,7 +129,7 @@ function Show-RestoreBackupDialog {
param([string]$BackupFilePath)
$scopeInfo = & $getStartMenuScopeInfo
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target $scopeInfo.Target
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target $scopeInfo.Target
$overviewSummaryText.Text = "This will replace the current Start Menu pinned apps layout for $($scopeInfo.SummaryText) with the selected backup."
$backupFileText.Text = Split-Path -Path $BackupFilePath -Leaf
@@ -276,7 +276,7 @@ function Show-RestoreBackupDialog {
$backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf
$backupCreatedText.Text = $createdText
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
@@ -318,7 +318,7 @@ function Show-RestoreBackupDialog {
Write-Host "Backup file selected: $($openDialog.FileName)"
try {
$selectedBackup = Load-RegistryBackupFromFile -FilePath $openDialog.FileName
$selectedBackup = Import-RegistryBackup -FilePath $openDialog.FileName
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
return
@@ -366,7 +366,7 @@ function Show-RestoreBackupDialog {
}
$window.Tag = @{
Result = 'RestoreStartMenu'
Result = 'Restore-StartMenu'
StartMenuScope = $scope
UseManualBackupFile = $useManualBackupFile
BackupFilePath = $state.SelectedStartMenuBackupFilePath
+7 -3
View File
@@ -1,3 +1,7 @@
<#
.SYNOPSIS
Shows the backup-restore dialog and performs the selected restore.
#>
function Show-RestoreBackupWindow {
param(
[System.Windows.Window]$Owner = $null
@@ -38,7 +42,7 @@ function Show-RestoreBackupWindow {
}
}
}
elseif ($dialogResult.Result -eq 'RestoreStartMenu') {
elseif ($dialogResult.Result -eq 'Restore-StartMenu') {
$scope = $dialogResult.StartMenuScope
$useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true)
$backupFilePath = $null
@@ -54,10 +58,10 @@ function Show-RestoreBackupWindow {
}
$result = if ($scope -eq 'AllUsers') {
RestoreStartMenuForAllUsers -BackupFilePath $backupFilePath
Restore-StartMenuForAllUsers -BackupFilePath $backupFilePath
}
else {
RestoreStartMenu -BackupFilePath $backupFilePath
Restore-StartMenu -BackupFilePath $backupFilePath
}
$resultEntries = @($result)
+24 -13
View File
@@ -7,8 +7,10 @@ param (
[switch]$Sysprep,
[string]$LogPath,
[string]$User,
[switch]$NoRestartExplorer,
[Alias('NoRestartExplorer')]
[switch]$SkipExplorerRestart,
[switch]$CreateRestorePoint,
[switch]$SkipRegistryBackup,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -103,13 +105,12 @@ param (
[switch]$HideDriveLetters
)
# Show error if current powershell environment does not have LanguageMode set to FullLanguage
# Check if current PowerShell environment is limited by security policies
if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") {
Write-Host "Error: Win11Debloat is unable to run on your system. PowerShell execution is restricted by security policies" -ForegroundColor Red
Write-Output ""
Write-Output "Press enter to exit..."
Read-Host | Out-Null
Exit
Write-Error "Win11Debloat is unable to run on your system, PowerShell execution is restricted by security policies"
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
Exit 1
}
Clear-Host
@@ -121,13 +122,13 @@ $tempRootPath = $env:TEMP
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
Write-Output "> Downloading Win11Debloat..."
# Download Win11Debloat from GitHub as a zip archive.
try {
if ($Dev) {
Write-Output "> Downloading development version of Win11Debloat..."
$sourceUri = "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip"
} else {
Write-Output "> Downloading Win11Debloat..."
$sourceUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
}
Invoke-RestMethod $sourceUri -OutFile $tempArchivePath
@@ -137,7 +138,7 @@ catch {
Write-Output ""
Write-Output "Press enter to exit..."
Read-Host | Out-Null
Exit
Exit 1
}
# Remove old script folder if it exists, but keep configs, logs and backups
@@ -205,7 +206,7 @@ $arguments = $($PSBoundParameters.GetEnumerator() | Where-Object { $_.Key -ne 'D
Write-Output ""
Write-Output "> Launching Win11Debloat..."
# Minimize the powershell window when no parameters are provided
# Minimize the PowerShell window when no parameters are provided
if ($arguments.Count -eq 0) {
$windowStyle = "Minimized"
}
@@ -213,7 +214,7 @@ else {
$windowStyle = "Normal"
}
# Remove Powershell 7 modules from path to prevent module loading issues in the script
# Remove PowerShell 7 modules from path to prevent module loading issues in the script
if ($PSVersionTable.PSVersion.Major -ge 7) {
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
$env:PSModulePath = $NewPSModulePath -join ';'
@@ -221,11 +222,20 @@ if ($PSVersionTable.PSVersion.Major -ge 7) {
# Run Win11Debloat script with the provided arguments
$debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1'
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs
$exitCode = 0
$debloatProcess = $null
try {
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs -ErrorAction Stop
}
catch {
$exitCode = 1
Write-Error "Failed to start Win11Debloat: $_"
}
# Wait for the process to finish before continuing
if ($null -ne $debloatProcess) {
$debloatProcess.WaitForExit()
$exitCode = $debloatProcess.ExitCode
}
# Remove all remaining script files, except for configs, logs and backups
@@ -238,3 +248,4 @@ if (Test-Path $tempWorkPath) {
}
Write-Output ""
Exit $exitCode
@@ -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
@@ -11,53 +11,51 @@ function Get-NormalizedRegistryValueName {
return [string]$ValueName
}
<#
.SYNOPSIS
Converts a parsed .reg operation into a Name/Kind/Value set for RegistryKey.SetValue.
#>
function Convert-RegOperationToValueKind {
param(
[Parameter(Mandatory)]
$Operation
)
$valueName = if ([string]::IsNullOrEmpty([string]$Operation.ValueName)) { '' } else { [string]$Operation.ValueName }
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
$valueType = [string]$Operation.ValueType
$operationKeyPath = [string]$Operation.KeyPath
# ValueType here is whatever Get-RegFileOperations parsed it as.
# Hex2/Hex7 are its names for REG_EXPAND_SZ/REG_MULTI_SZ, already decoded to string/string[].
switch ($valueType) {
'DWord' {
$unsigned = [uint32]$Operation.ValueData
$value = [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Value = $value }
}
'QWord' {
$unsigned = [uint64]$Operation.ValueData
$value = [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Value = $value }
}
'String' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::String; Value = [string]$Operation.ValueData }
}
'Hex2' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Value = [string]$Operation.ValueData }
}
'Binary' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Value = [byte[]]$Operation.ValueData }
}
'Hex7' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Value = [string[]]@($Operation.ValueData) }
}
default {
throw "Unsupported value type '$valueType' while applying reg operation for '$operationKeyPath'"
}
}
}
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)]
@@ -193,6 +191,13 @@ function Invoke-RegistryOperation {
}
}
<#
.SYNOPSIS
Applies all parsed operations from a registry file.
.OUTPUTS
System.Boolean. $true when all operations complete, including WhatIf; otherwise $false.
#>
function Invoke-RegistryOperationsFromRegFile {
param(
[Parameter(Mandatory)]
@@ -205,7 +210,7 @@ function Invoke-RegistryOperationsFromRegFile {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan
return
return $true
}
foreach ($operation in $operations) {
@@ -224,5 +229,8 @@ function Invoke-RegistryOperationsFromRegFile {
if ($accessDeniedCount -gt 0) {
Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'."
return $false
}
return $true
}
@@ -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()]
@@ -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" }
}
}
+31 -6
View File
@@ -74,7 +74,10 @@ function Get-RegFileOperations {
}
$parsedValue = Convert-RegValueData -valueData $matches.valueData.Trim()
if (-not $parsedValue) { continue }
if (-not $parsedValue) {
Write-Warning "Skipping unsupported or malformed registry value '$valueName' in '$currentKeyPath'."
continue
}
$operations += [PSCustomObject]@{
OperationType = $parsedValue.OperationType
@@ -88,6 +91,10 @@ function Get-RegFileOperations {
return $operations
}
<#
.SYNOPSIS
Converts a .reg value literal into an operation type, registry value type, and data.
#>
function Convert-RegValueData {
param(
[Parameter(Mandatory)]
@@ -121,8 +128,13 @@ function Convert-RegValueData {
}
if ($valueData -match '^hex(?:\((?<kind>[0-9a-fA-F]+)\))?:(?<bytes>[0-9a-fA-F,\s]+)$') {
$bytes = Convert-HexStringToByteArray -hexValue $matches.bytes
$parsedBytes = Convert-HexStringToByteArray -hexValue $matches.bytes
if ($null -eq $parsedBytes) {
return $null
}
$bytes = [byte[]]@($parsedBytes)
$valueType = if ($matches.kind) { "Hex$($matches.kind)" } else { 'Binary' }
$value = switch ($matches.kind) {
'2' { Convert-RegistryByteArrayToString -byteData $bytes }
'7' { Convert-RegistryByteArrayToMultiString -byteData $bytes }
@@ -150,16 +162,29 @@ function Convert-RegValueData {
return $null
}
<#
.SYNOPSIS
Converts a comma-separated hexadecimal byte string into a byte array.
#>
function Convert-HexStringToByteArray {
param(
[Parameter(Mandatory)]
[string]$hexValue
)
$parts = $hexValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
return [System.Linq.Enumerable]::Select($parts, [Func[object, byte]] {
param($h) [System.Convert]::ToByte($h, 16)
}) -as [byte[]]
$parts = @($hexValue.Split(',') | ForEach-Object { $_.Trim() })
if ($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }) {
return $null
}
$bytes = New-Object byte[] $parts.Count
for ($i = 0; $i -lt $parts.Count; $i++) {
if ($parts[$i] -notmatch '^[0-9a-fA-F]{1,2}$') {
return $null
}
$bytes[$i] = [System.Convert]::ToByte($parts[$i], 16)
}
return ,$bytes
}
function Convert-RegistryByteArrayToString {
@@ -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 -ExitCode 1
}
Write-Error "Unable to find user directory path for user $userName"
AwaitKeyToExit
Wait-ForKeyPress -ExitCode 1
}
+11
View File
@@ -0,0 +1,11 @@
<#
.SYNOPSIS
Returns the explicitly targeted user name or the current process user name.
#>
function Get-UserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}
@@ -1,9 +0,0 @@
function GetFriendlyTargetUserName {
$target = GetTargetUserForAppRemoval
switch ($target) {
"AllUsers" { return "all users" }
"CurrentUser" { return "the current user" }
default { return "user $target" }
}
}
-7
View File
@@ -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,11 +26,16 @@ 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"
}
$consistencyError = Test-ConfigConsistency -Config $configJson
if ($consistencyError) {
throw "Invalid config file '$resolvedConfigPath': $consistencyError"
}
$importedItems = 0
if ($configJson.Apps) {
@@ -38,8 +47,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 +68,7 @@ function ImportConfigToParams {
continue
}
AddParameter $setting.Name $true
Add-Parameter $setting.Name $true
$importedItems++
}
}
@@ -73,12 +82,17 @@ function ImportConfigToParams {
}
if ($deploymentLookup.ContainsKey('CreateRestorePoint') -and [bool]$deploymentLookup['CreateRestorePoint']) {
AddParameter 'CreateRestorePoint'
Add-Parameter 'CreateRestorePoint'
$importedItems++
}
if ($deploymentLookup.ContainsKey('SkipRegistryBackup') -and [bool]$deploymentLookup['SkipRegistryBackup']) {
Add-Parameter 'SkipRegistryBackup'
$importedItems++
}
if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) {
AddParameter 'NoRestartExplorer'
Add-Parameter 'SkipExplorerRestart'
$importedItems++
}
@@ -87,12 +101,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 +115,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)
}
}
}
@@ -0,0 +1,97 @@
<#
.SYNOPSIS
Validates that a configuration file is structurally consistent before it is applied.
.DESCRIPTION
Returns $null when the configuration is valid, otherwise a string describing the
first problem found. Used by both the CLI and GUI import paths to reject invalid
configs before any settings are applied.
.OUTPUTS
System.String. $null when valid, otherwise an error message.
#>
function Test-ConfigConsistency {
param($Config)
if (-not $Config) {
return 'Configuration is empty or could not be read.'
}
if (-not $Config.Version) {
return 'Configuration is missing a Version field.'
}
if (-not $Config.Apps -and -not $Config.Tweaks -and -not $Config.Deployment) {
return 'The configuration file contains no importable data.'
}
if ($null -ne $Config.Apps) {
if ($Config.Apps -isnot [string] -and $Config.Apps -isnot [System.Collections.IEnumerable]) {
return 'Configuration Apps entries must be strings.'
}
foreach ($app in @($Config.Apps)) {
if ($app -isnot [string]) {
return 'Configuration Apps entries must be strings.'
}
}
}
foreach ($categoryName in @('Tweaks', 'Deployment')) {
$category = $Config.$categoryName
if ($null -eq $category) { continue }
if ($category -is [string] -or $category -isnot [System.Collections.IEnumerable]) {
return "Configuration $categoryName entries must contain Name and Value properties."
}
foreach ($setting in @($category)) {
$hasName = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Name') } else { $null -ne $setting.PSObject.Properties['Name'] }
$hasValue = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Value') } else { $null -ne $setting.PSObject.Properties['Value'] }
if (-not $setting -or -not $hasName -or -not $hasValue -or $setting.Name -isnot [string] -or [string]::IsNullOrWhiteSpace($setting.Name)) {
return "Configuration $categoryName entries must contain Name and Value properties."
}
}
}
$lookup = @{}
foreach ($setting in @($Config.Deployment)) {
if ($setting -and $setting.Name) {
$lookup[$setting.Name] = $setting.Value
}
}
$hasScope = $lookup.ContainsKey('AppRemovalScopeIndex')
$hasUser = $lookup.ContainsKey('UserSelectionIndex')
$scopeIndex = $null
if ($hasScope) {
if (-not [int]::TryParse("$($lookup['AppRemovalScopeIndex'])", [ref]$scopeIndex) -or $scopeIndex -notin @(0, 1, 2)) {
return 'AppRemovalScopeIndex must be a supported numeric value (0, 1, or 2).'
}
}
$userIndex = $null
if ($hasUser) {
if (-not [int]::TryParse("$($lookup['UserSelectionIndex'])", [ref]$userIndex) -or $userIndex -notin @(0, 1, 2)) {
return 'UserSelectionIndex must be a supported numeric value (0, 1, or 2).'
}
}
# "Current user only" (index 1) is only valid together with "Current User" (index 0)
if ($hasScope -and $scopeIndex -eq 1) {
if (-not $hasUser -or $userIndex -ne 0) {
return "App removal scope 'Current user only' (AppRemovalScopeIndex 1) requires the deployment target 'Current User' (UserSelectionIndex 0)."
}
}
# "Target user only" (index 2) is only valid together with "Other User" (index 1)
if ($hasScope -and $scopeIndex -eq 2) {
if (-not $hasUser -or $userIndex -ne 1) {
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires the deployment target 'Other User' (UserSelectionIndex 1)."
}
if (-not $lookup.ContainsKey('OtherUsername') -or [string]::IsNullOrWhiteSpace("$($lookup['OtherUsername'])")) {
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires an 'OtherUsername' value."
}
}
return $null
}
@@ -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 {
+1 -1
View File
@@ -23,7 +23,7 @@ function Test-TargetUserName {
}
}
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
if (-not (Test-UserProfileExists -userName $normalizedUserName)) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName
@@ -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
View File
@@ -0,0 +1,49 @@
<#
.SYNOPSIS
Runs the Win11Debloat Pester test suite locally.
.PARAMETER Bootstrap
Installs a compatible Pester 5 release for the current user when Pester is
not already available.
#>
[CmdletBinding()]
param(
[switch]$Bootstrap
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$testPath = Join-Path $repositoryRoot 'Tests'
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 })) {
if (-not $Bootstrap) {
Write-Error 'Pester 5 is required. Install it with: .\Scripts\Run-Tests.ps1 -Bootstrap'
exit 1
}
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -Confirm:$false | Out-Null
}
Install-Module -Name Pester -RequiredVersion 5.9.0 -Scope CurrentUser -Force -AllowClobber -Confirm:$false
}
catch {
Write-Error "Unable to install Pester 5: $($_.Exception.Message)"
exit 1
}
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 })) {
Write-Error 'Pester 5 was not installed. Update PowerShellGet or install Pester 5 manually, then run the tests again.'
exit 1
}
}
Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.999.999 -Force
$result = Invoke-Pester -Path $testPath -Output Detailed -PassThru
if ($result.Result -ne 'Passed' -or $result.FailedContainersCount -gt 0 -or $result.TotalCount -eq 0) {
exit 1
}
@@ -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(
+1 -1
View File
@@ -31,7 +31,7 @@ function Invoke-NonBlocking {
$ps.Stop()
throw "Operation timed out after $TimeoutSeconds seconds"
}
DoEvents
Invoke-DoEvents
Start-Sleep -Milliseconds 16
}
}
+180
View File
@@ -0,0 +1,180 @@
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 = 'an unsigned QWord'; ValueName = 'Big'; ValueType = 'QWord'; ValueData = [uint64]::MaxValue; ExpectedName = 'Big'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::QWord; ExpectedValue = -1L }
@{ Case = 'a string value'; ValueName = 'Name'; ValueType = 'String'; ValueData = 42; ExpectedName = 'Name'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::String; ExpectedValue = '42' }
@{ Case = 'an expandable string value'; ValueName = 'Path'; ValueType = 'Hex2'; ValueData = 'test%PATH%'; ExpectedName = 'Path'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::ExpandString; ExpectedValue = 'test%PATH%' }
@{ Case = 'a binary value'; ValueName = 'Bytes'; ValueType = 'Binary'; ValueData = @(1, 255); ExpectedName = 'Bytes'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::Binary; ExpectedValue = [byte[]](1, 255) }
@{ Case = 'a multi-string value'; ValueName = 'List'; ValueType = 'Hex7'; ValueData = @('a', 'b', 'c'); ExpectedName = 'List'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::MultiString; ExpectedValue = [string[]]@('a', 'b', 'c') }
) {
$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
}
}
Describe 'Invoke-RegistryDeleteValueOperation' {
It 'deletes the default value and always closes an opened registry key' {
$calls = [System.Collections.Generic.List[string]]::new()
$key = [PSCustomObject]@{}
$key | Add-Member -MemberType ScriptMethod -Name DeleteValue -Value { param($Name, $ThrowOnMissing) $calls.Add("delete:${Name}:$ThrowOnMissing") }
$key | Add-Member -MemberType ScriptMethod -Name Close -Value { $calls.Add('close') }
Invoke-RegistryDeleteValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -KeyInfo ([PSCustomObject]@{ Key = $key })
$calls | Should -Be @('delete::False', 'close')
}
}
Describe 'Invoke-RegistrySetValueOperation' {
It 'throws for an unavailable set-value key before attempting conversion' {
Mock Convert-RegOperationToValueKind { throw 'conversion should not run' }
{ Invoke-RegistrySetValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test' }) -KeyInfo ([PSCustomObject]@{ Key = $null }) } |
Should -Throw "Unable to open or create registry key*"
Should -Invoke Convert-RegOperationToValueKind -Times 0 -Exactly
}
}
Describe 'Write-RegistryOperationAccessDeniedWarning' {
It 'formats the default registry value in access-denied warnings' {
Mock Write-Warning {}
Write-RegistryOperationAccessDeniedWarning -Operation ([PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -ExceptionMessage 'denied'
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "value '\(Default\)'" -and $Message -match 'denied' }
}
}
+37
View File
@@ -0,0 +1,37 @@
BeforeAll {
function Show-MessageBox { param($Message, $Title, $Button, $Icon, $Owner) 'Yes' }
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Confirm-UnsafeAppRemoval.ps1')
}
Describe 'Confirm-UnsafeAppRemoval' {
BeforeEach {
$global:Silent = $false
Mock Show-MessageBox { 'Yes' }
}
AfterEach { Remove-Variable -Name Silent -Scope Global -ErrorAction SilentlyContinue }
It 'returns true without prompting for ordinary applications' {
Confirm-UnsafeAppRemoval -SelectedApps @('Contoso.App') | Should -BeTrue
Should -Invoke Show-MessageBox -Times 0 -Exactly
}
It 'skips all prompts in silent mode' {
$global:Silent = $true
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue
Should -Invoke Show-MessageBox -Times 0 -Exactly
}
It 'stops when Microsoft Store removal is declined' {
Mock Show-MessageBox { 'No' }
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeFalse
Should -Invoke Show-MessageBox -Times 1 -Exactly
}
It 'requires confirmation for both dangerous applications' {
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue
Should -Invoke Show-MessageBox -Times 2 -Exactly
}
}
+186
View File
@@ -0,0 +1,186 @@
BeforeAll {
function Test-StoreSearchSuggestionsDisabledForAllUsers { $false }
function Test-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) $false }
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
function Get-UserName { 'Alice' }
function Test-WindowsOptionalFeatureEnabled { param($FeatureName) $false }
function Get-RegFileOperations { param($regFilePath) @() }
function Split-RegistryPath { param($path) $null }
function Get-RegistryRootKey { param($hiveName) $null }
function New-CurrentStateRegistryKey {
param([hashtable]$Values = @{}, [hashtable]$Kinds = @{})
$key = [PSCustomObject]@{ Values = $Values; Kinds = $Kinds; Closed = $false }
$key | Add-Member ScriptMethod GetValueNames { @($this.Kinds.Keys) }
$key | Add-Member ScriptMethod GetValueKind { param($name) $this.Kinds[$name] }
$key | Add-Member ScriptMethod GetValue { param($name, $defaultValue, $options) $this.Values[$name] }
$key | Add-Member ScriptMethod Close { $this.Closed = $true }
return $key
}
. (Join-Path $PSScriptRoot '..\Scripts\Features\Get-CurrentTweakState.ps1')
}
Describe 'Get-ExpectedRegistryValueKind' {
It 'maps <ValueType> to <Expected>' -ForEach @(
@{ ValueType = 'DWord'; Expected = [Microsoft.Win32.RegistryValueKind]::DWord }
@{ ValueType = 'QWord'; Expected = [Microsoft.Win32.RegistryValueKind]::QWord }
@{ ValueType = 'String'; Expected = [Microsoft.Win32.RegistryValueKind]::String }
@{ ValueType = 'Binary'; Expected = [Microsoft.Win32.RegistryValueKind]::Binary }
@{ ValueType = 'Hex2'; Expected = [Microsoft.Win32.RegistryValueKind]::ExpandString }
@{ ValueType = 'Hex7'; Expected = [Microsoft.Win32.RegistryValueKind]::MultiString }
) {
$operation = [PSCustomObject]@{ ValueType = $ValueType }
Get-ExpectedRegistryValueKind -Operation $operation | Should -Be $Expected
}
It 'returns null for unsupported operation types' {
Get-ExpectedRegistryValueKind -Operation ([PSCustomObject]@{ ValueType = 'Hex11' }) | Should -BeNullOrEmpty
}
}
Describe 'Test-FeatureApplied - special features' {
BeforeEach {
$script:Params = @{}
$script:Features = @{
DisableWidgets = [PSCustomObject]@{}
DisableStoreSearchSuggestions = [PSCustomObject]@{}
EnableWindowsSandbox = [PSCustomObject]@{}
EnableWindowsSubsystemForLinux = [PSCustomObject]@{}
}
Mock Get-AppxPackage { $null }
Mock Test-StoreSearchSuggestionsDisabledForAllUsers { $true }
Mock Test-StoreSearchSuggestionsDisabled { $true }
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-UserName { 'Alice' }
Mock Test-WindowsOptionalFeatureEnabled { $true }
}
It '<Case>' -ForEach @(
@{ Case = 'treats Widgets as disabled when all related packages are absent'; PresentPackage = $null; Expected = $true; ExpectedCalls = 3 }
@{ Case = 'treats Widgets as enabled when a related package is present'; PresentPackage = 'MicrosoftWindows.Client.WebExperience'; Expected = $false; ExpectedCalls = 2 }
) {
Mock Get-AppxPackage { param($Name) if ($Name -eq $PresentPackage) { [PSCustomObject]@{ Name = $Name } } }
Test-FeatureApplied -FeatureId 'DisableWidgets' | Should -Be $Expected
Should -Invoke Get-AppxPackage -Times $ExpectedCalls -Exactly
}
It 'uses <Case> Store detection' -ForEach @(
@{ Case = 'all-user'; Params = @{ Sysprep = $true }; AllUsersCalls = 1; UserCalls = 0 }
@{ Case = 'user-specific'; Params = @{}; AllUsersCalls = 0; UserCalls = 1 }
) {
$script:Params = $Params
Test-FeatureApplied -FeatureId 'DisableStoreSearchSuggestions' | Should -BeTrue
Should -Invoke Test-StoreSearchSuggestionsDisabledForAllUsers -Times $AllUsersCalls -Exactly
Should -Invoke Test-StoreSearchSuggestionsDisabled -Times $UserCalls -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' }
}
It 'checks the expected optional feature for Windows Sandbox' {
Test-FeatureApplied -FeatureId 'EnableWindowsSandbox' | Should -BeTrue
Should -Invoke Test-WindowsOptionalFeatureEnabled -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' }
}
It '<Case>' -ForEach @(
@{ Case = 'reports WSL applied when both optional features are enabled'; DisabledFeature = $null; Expected = $true }
@{ Case = 'reports WSL not applied when VirtualMachinePlatform is disabled'; DisabledFeature = 'VirtualMachinePlatform'; Expected = $false }
) {
Mock Test-WindowsOptionalFeatureEnabled { param($FeatureName) $FeatureName -ne $DisabledFeature }
Test-FeatureApplied -FeatureId 'EnableWindowsSubsystemForLinux' | Should -Be $Expected
}
}
Describe 'Test-FeatureApplied - registry preconditions' {
BeforeEach {
$script:Params = @{}
$script:RegfilesPath = $TestDrive
$script:Features = @{
NoRegistry = [PSCustomObject]@{ RegistryKey = '' }
MissingFile = [PSCustomObject]@{ RegistryKey = 'missing.reg' }
EmptyOperations = [PSCustomObject]@{ RegistryKey = 'empty.reg' }
}
}
It 'returns false for <Case>' -ForEach @(
@{ Case = 'a feature without registry data'; FeatureId = 'NoRegistry'; ParserBehavior = 'None' }
@{ Case = 'a missing registry file'; FeatureId = 'MissingFile'; ParserBehavior = 'None' }
@{ Case = 'an empty registry operation set'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Empty' }
@{ Case = 'a registry operation parse failure'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Throw' }
) {
if ($ParserBehavior -ne 'None') {
'' | Set-Content -LiteralPath (Join-Path $TestDrive 'empty.reg')
if ($ParserBehavior -eq 'Empty') { Mock Get-RegFileOperations { @() } }
if ($ParserBehavior -eq 'Throw') { Mock Get-RegFileOperations { throw 'parse failed' } }
}
Test-FeatureApplied -FeatureId $FeatureId | Should -BeFalse
}
}
Describe 'Test-FeatureApplied - registry state comparison' {
BeforeEach {
$script:Params = @{}
$script:RegfilesPath = $TestDrive
$script:Features = @{ RegistryFeature = [PSCustomObject]@{ RegistryKey = 'feature.reg' } }
'' | Set-Content -LiteralPath (Join-Path $TestDrive 'feature.reg')
Mock Split-RegistryPath { [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = 'Software\Example' } }
}
It 'matches set values by kind and normalized unsigned data and closes the key' {
$key = New-CurrentStateRegistryKey -Values @{ Large = -1L } -Kinds @{ Large = [Microsoft.Win32.RegistryValueKind]::QWord }
$root = [PSCustomObject]@{ Key = $key }
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key }
Mock Get-RegistryRootKey { $root }
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Large'; ValueType = 'QWord'; ValueData = [uint64]::MaxValue }) }
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue
$key.Closed | Should -BeTrue
}
It 'returns false for a value-kind or data mismatch' -ForEach @(
@{ ActualKind = [Microsoft.Win32.RegistryValueKind]::String; ActualData = '1'; ExpectedType = 'DWord'; ExpectedData = 1 }
@{ ActualKind = [Microsoft.Win32.RegistryValueKind]::DWord; ActualData = 2; ExpectedType = 'DWord'; ExpectedData = 1 }
) {
$key = New-CurrentStateRegistryKey -Values @{ Enabled = $ActualData } -Kinds @{ Enabled = $ActualKind }
$root = [PSCustomObject]@{ Key = $key }
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key }
Mock Get-RegistryRootKey { $root }
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Enabled'; ValueType = $ExpectedType; ValueData = $ExpectedData }) }
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeFalse
$key.Closed | Should -BeTrue
}
It 'treats missing keys and values as successful delete operations' -ForEach @(
@{ OperationType = 'DeleteKey'; ReturnKey = $false }
@{ OperationType = 'DeleteValue'; ReturnKey = $true }
) {
$key = New-CurrentStateRegistryKey
$root = [PSCustomObject]@{ Key = $key; ReturnKey = $ReturnKey }
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) if ($this.ReturnKey) { $this.Key } else { $null } }
Mock Get-RegistryRootKey { $root }
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = $OperationType; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Gone' }) }
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue
}
}
Describe 'Get-CurrentGroupActiveIndex' {
BeforeEach { Mock Test-FeatureApplied { $false } }
It 'returns the one-based index of the first fully applied option' {
$group = [PSCustomObject]@{ Values = @(
[PSCustomObject]@{ FeatureIds = @('One', 'Missing') }
[PSCustomObject]@{ FeatureIds = @('Two', 'Three') }
) }
Mock Test-FeatureApplied { param($FeatureId) $FeatureId -in @('Two', 'Three') }
Get-CurrentGroupActiveIndex -Group $group | Should -Be 2
}
It 'returns zero when no option is fully applied' {
$group = [PSCustomObject]@{ Values = @([PSCustomObject]@{ FeatureIds = @('One') }) }
Get-CurrentGroupActiveIndex -Group $group | Should -Be 0
}
}
@@ -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'
}
}
+136
View File
@@ -0,0 +1,136 @@
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'
}
It 'uses the regular label for a forward-only selection' {
$script:Params = @{ ApplyFeature = $true }
$script:UndoParams = @{}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when an undo selection has no undo label' {
$script:Params = @{}
$script:UndoParams = @{ ApplyFeature = $true }
$script:Features.ApplyFeature.UndoLabel = $null
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when the feature has no UndoLabel property' {
$script:Params = @{}
$script:UndoParams = @{ MissingUndoLabelFeature = $true }
$script:Features.MissingUndoLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Feature without undo label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Feature without undo label'
}
It 'returns no labels when there are no selected parameters' {
$script:Params = @{}
$script:UndoParams = @{}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
It 'keeps one label for each distinct reboot feature when their labels match' {
$script:Params = @{
FirstMatchingLabelFeature = $true
SecondMatchingLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.FirstMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo first shared label'
}
$script:Features.SecondMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo second shared label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
@($result | Where-Object { $_ -eq 'Shared label' }).Count | Should -Be 2
}
It 'accepts truthy reboot flags' {
$script:Params = @{
StringRebootFeature = $true
NumericRebootFeature = $true
}
$script:UndoParams = @{}
$script:Features.StringRebootFeature = [PSCustomObject]@{
RequiresReboot = 'true'
Label = 'String reboot'
UndoLabel = 'Undo string reboot'
}
$script:Features.NumericRebootFeature = [PSCustomObject]@{
RequiresReboot = 1
Label = 'Numeric reboot'
UndoLabel = 'Undo numeric reboot'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
$result | Should -Contain 'String reboot'
$result | Should -Contain 'Numeric reboot'
}
It 'excludes unknown, non-reboot, and blank-label selections' {
$script:Params = @{
UnknownFeature = $true
NoRebootFeature = $true
BlankLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.BlankLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = ' '
UndoLabel = $null
}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
}
+126
View File
@@ -0,0 +1,126 @@
BeforeAll {
$regFileOperationsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1'
. $regFileOperationsScriptPath
}
Describe 'Convert-RegValueData' {
It 'parses <ValueType> as an unsigned integer' -ForEach @(
@{ ValueType = 'DWord'; ValueData = 'dword:ffffffff'; Expected = [uint32]::MaxValue }
@{ ValueType = 'QWord'; ValueData = 'qword:ffffffffffffffff'; Expected = [uint64]::MaxValue }
) {
$result = Convert-RegValueData -valueData $ValueData
$result.OperationType | Should -Be 'SetValue'
$result.ValueType | Should -Be $ValueType
$result.ValueData | Should -Be $Expected
}
It 'parses registry strings and unescapes quotes and backslashes' {
$result = Convert-RegValueData -valueData '"C:\\Tools\\\"Quoted\""'
$result.ValueType | Should -Be 'String'
$result.ValueData | Should -Be 'C:\Tools\"Quoted"'
}
It 'parses <Case>' -ForEach @(
@{ Case = 'binary hex data'; ValueData = 'hex:01,ff'; ExpectedType = 'Binary'; Expected = [byte[]](1, 255) }
@{ Case = 'expandable-string hex data'; ValueData = 'hex(2):25,00,54,00,45,00,4d,00,50,00,25,00,00,00'; ExpectedType = 'Hex2'; Expected = '%TEMP%' }
@{ Case = 'multi-string hex data'; ValueData = 'hex(7):6f,00,6e,00,65,00,00,00,74,00,77,00,6f,00,00,00,00,00'; ExpectedType = 'Hex7'; Expected = @('one', 'two') }
) {
$result = Convert-RegValueData -valueData $ValueData
$result.ValueType | Should -Be $ExpectedType
$result.ValueData | Should -Be $Expected
}
It '<Case>' -ForEach @(
@{ Case = 'parses a registry value deletion'; ValueData = '-'; ExpectedOperation = 'DeleteValue' }
@{ Case = 'ignores unsupported data'; ValueData = 'hex(b):not-hex'; ExpectedOperation = $null }
@{ Case = 'rejects hex data with an empty byte token'; ValueData = 'hex:01,,ff'; ExpectedOperation = $null }
) {
$result = Convert-RegValueData -valueData $ValueData
if ($ExpectedOperation) {
$result.OperationType | Should -Be $ExpectedOperation
$result.ValueType | Should -BeNullOrEmpty
}
else {
$result | Should -BeNullOrEmpty
}
}
}
Describe 'Convert-HexStringToByteArray' {
It 'rejects empty and malformed hex tokens' -ForEach @('01,,ff', ',01', '01,', '01,gg') {
Convert-HexStringToByteArray -hexValue $_ | Should -BeNullOrEmpty
}
It 'converts byte arrays to registry strings and multi-strings' {
Convert-RegistryByteArrayToString -byteData ([byte[]](65, 0, 0, 0)) | Should -Be 'A'
Convert-RegistryByteArrayToMultiString -byteData ([byte[]](65, 0, 0, 0, 66, 0, 0, 0, 0, 0)) | Should -Be @('A', 'B')
}
}
Describe 'Get-RegFileOperations' {
It 'warns when it skips malformed registry value data' {
$regFilePath = Join-Path $TestDrive 'malformed.reg'
@'
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Example]
"Broken"=hex:01,,ff
'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8
Mock Write-Warning {}
@(Get-RegFileOperations -regFilePath $regFilePath) | Should -BeNullOrEmpty
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "Skipping unsupported or malformed registry value 'Broken'*" }
}
It 'parses key deletion, value deletion, and continued hex values' {
$regFilePath = Join-Path $TestDrive 'settings.reg'
@'
Windows Registry Editor Version 5.00
[-HKEY_CURRENT_USER\Software\Example\Removed]
[HKEY_CURRENT_USER\Software\Example]
"Enabled"=dword:00000001
@=-
"Bytes"=hex:01,\
02,03
'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8
$operations = @(Get-RegFileOperations -regFilePath $regFilePath)
$operations.Count | Should -Be 4
$operations[0].OperationType | Should -Be 'DeleteKey'
$operations[1].ValueName | Should -Be 'Enabled'
$operations[1].ValueData | Should -Be 1
$operations[2].OperationType | Should -Be 'DeleteValue'
$operations[2].ValueName | Should -Be ''
$operations[3].ValueData | Should -Be ([byte[]](1, 2, 3))
}
It 'handles comments, default-value assignment, malformed lines, and deleted-key contents' {
$regFilePath = Join-Path $TestDrive 'edge-cases.reg'
@'
Windows Registry Editor Version 5.00
; comment
[HKEY_CURRENT_USER\Software\Example]
@="default"
malformed line
[-HKEY_CURRENT_USER\Software\Removed]
"Ignored"="value"
'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8
$operations = @(Get-RegFileOperations -regFilePath $regFilePath)
$operations | Should -HaveCount 2
$operations[0].OperationType | Should -Be 'SetValue'
$operations[0].ValueName | Should -Be ''
$operations[0].ValueData | Should -Be 'default'
$operations[1].OperationType | Should -Be 'DeleteKey'
}
}
@@ -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
}
}
+116
View File
@@ -0,0 +1,116 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function New-WingetTestJob { Microsoft.PowerShell.Core\Start-Job -ScriptBlock {} }
. (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Get-WingetInstalledApps.ps1')
}
Describe 'Get-WingetInstalledApps' {
BeforeEach {
$script:WingetInstalled = $true
$script:WingetTestJob = $null
Mock Remove-Job {}
}
AfterEach {
if ($null -ne $script:WingetTestJob) {
Microsoft.PowerShell.Core\Remove-Job -Job $script:WingetTestJob -Force -ErrorAction SilentlyContinue
}
}
It 'returns null without starting a job when winget is unavailable' {
$script:WingetInstalled = $false
Mock Start-Job { throw 'Winget should not be started.' }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Start-Job -Times 0 -Exactly
}
It 'delegates to the non-blocking runner when requested' {
Mock Invoke-NonBlocking { @([PSCustomObject]@{ Name = 'App'; Id = 'Contoso.App' }) }
$result = @(Get-WingetInstalledApps -NonBlocking)
$result | Should -HaveCount 1
$result[0].Id | Should -Be 'Contoso.App'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly
}
It 'returns null when winget output has no table separator' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job { @('Name Id', 'No parseable table') }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { -not $Force }
}
It 'returns an empty collection for a valid table with no data rows' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Name Id Version'
'-----------------------------------------------------------------------'
)
}
$result = @(Get-WingetInstalledApps)
$result | Should -HaveCount 1
@($result[0]).Count | Should -Be 0
}
It 'parses valid rows and skips malformed rows' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Name Id Version'
'-----------------------------------------------------------------------'
'Contoso App Contoso.App 1.0'
'malformed-row'
'Fabrikam Tools Fabrikam.Tools 2.0'
)
}
$result = @(Get-WingetInstalledApps)[0]
$result | Should -HaveCount 2
$result.Id | Should -Be @('Contoso.App', 'Fabrikam.Tools')
}
It 'parses localized headers and long Unicode display names' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Naam Id Versie'
'-----------------------------------------------------------------------'
'Contoso hulpmiddel voor gegevens Contoso.DataTools 2026.07'
'Fabrikam Café Fabrikam.Cafe 1.0'
)
}
$result = @(Get-WingetInstalledApps)[0]
$result | Should -HaveCount 2
$result[0].Name | Should -Be 'Contoso hulpmiddel voor gegevens'
$result.Id | Should -Be @('Contoso.DataTools', 'Fabrikam.Cafe')
}
It 'returns null and force-removes the job when winget times out' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $null }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { $Force }
}
}
+167
View File
@@ -0,0 +1,167 @@
BeforeAll {
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Add-Parameter.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Import-ConfigToParams.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-ConfigConsistency.ps1')
$script:ConfigFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.WithSettings.json'
$script:SkipRegistryBackupFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.SkipRegistryBackup.json'
}
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.ContainsKey('SkipRegistryBackup') | Should -BeFalse
$script:Params['SkipExplorerRestart'] | Should -BeTrue
$script:Params.ContainsKey('User') | Should -BeFalse
$script:Params.ContainsKey('AppRemovalTarget') | Should -BeFalse
}
It 'imports SkipRegistryBackup when deployment settings request it' {
Import-ConfigToParams -ConfigPath $script:SkipRegistryBackupFixturePath -CurrentBuild 22631 | Out-Null
$script:Params['SkipRegistryBackup'] | Should -BeTrue
}
}
Describe 'Test-ConfigConsistency' {
It 'reports an error for an empty config' {
Test-ConfigConsistency -Config $null | Should -Match 'empty or could not be read'
}
It 'reports an error for a config missing a Version' {
$config = [PSCustomObject]@{ Tweaks = @( @{ Name = 'DisableTelemetry'; Value = $true } ) }
Test-ConfigConsistency -Config $config | Should -Match 'missing a Version'
}
It 'reports an error for a config with no importable data' {
$config = [PSCustomObject]@{ Version = '1.0' }
Test-ConfigConsistency -Config $config | Should -Match 'no importable data'
}
It 'reports an error for invalid app entries' {
$config = [PSCustomObject]@{ Version = '1.0'; Apps = 42 }
Test-ConfigConsistency -Config $config | Should -Match 'Apps entries must be strings'
}
It 'reports an error for malformed tweak entries' {
$config = [PSCustomObject]@{ Version = '1.0'; Tweaks = @(@{ Value = $true }) }
Test-ConfigConsistency -Config $config | Should -Match 'Tweaks entries must contain Name and Value properties'
}
It 'reports an error for deployment entries missing a required property' {
$config = [PSCustomObject]@{ Version = '1.0'; Deployment = @(@{ Name = 'CreateRestorePoint' }) }
Test-ConfigConsistency -Config $config | Should -Match 'Deployment entries must contain Name and Value properties'
}
It 'reports an error for nonnumeric deployment indexes' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(@{ Name = 'AppRemovalScopeIndex'; Value = 'all' })
}
Test-ConfigConsistency -Config $config | Should -Match 'AppRemovalScopeIndex must be a supported numeric value'
}
It 'reports an error for out-of-range deployment indexes' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(@{ Name = 'UserSelectionIndex'; Value = 3 })
}
Test-ConfigConsistency -Config $config | Should -Match 'UserSelectionIndex must be a supported numeric value'
}
It 'returns null for a consistent all-users scope' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(
@{ Name = 'UserSelectionIndex'; Value = 0 }
@{ Name = 'AppRemovalScopeIndex'; Value = 0 }
)
}
Test-ConfigConsistency -Config $config | Should -BeNullOrEmpty
}
It 'returns null for target-user scope combined with Other User and a username' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(
@{ Name = 'UserSelectionIndex'; Value = 1 }
@{ Name = 'OtherUsername'; Value = 'jdoe' }
@{ Name = 'AppRemovalScopeIndex'; Value = 2 }
)
}
Test-ConfigConsistency -Config $config | Should -BeNullOrEmpty
}
It 'returns null for current-user-only scope combined with Current User' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(
@{ Name = 'UserSelectionIndex'; Value = 0 }
@{ Name = 'AppRemovalScopeIndex'; Value = 1 }
)
}
Test-ConfigConsistency -Config $config | Should -BeNullOrEmpty
}
It 'reports an error for current-user-only scope without Current User selected' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(
@{ Name = 'UserSelectionIndex'; Value = 1 }
@{ Name = 'AppRemovalScopeIndex'; Value = 1 }
)
}
Test-ConfigConsistency -Config $config | Should -Match "requires the deployment target 'Current User'"
}
It 'reports an error for target-user scope without Other User selected' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(
@{ Name = 'UserSelectionIndex'; Value = 0 }
@{ Name = 'AppRemovalScopeIndex'; Value = 2 }
)
}
Test-ConfigConsistency -Config $config | Should -Match "requires the deployment target 'Other User'"
}
It 'reports an error for target-user scope with a blank username' {
$config = [PSCustomObject]@{
Version = '1.0'
Deployment = @(
@{ Name = 'UserSelectionIndex'; Value = 1 }
@{ Name = 'OtherUsername'; Value = ' ' }
@{ Name = 'AppRemovalScopeIndex'; Value = 2 }
)
}
Test-ConfigConsistency -Config $config | Should -Match "requires an 'OtherUsername' value"
}
}
+45
View File
@@ -0,0 +1,45 @@
BeforeAll {
$importJsonFileScriptPath = Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1'
$script:FixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading'
. $importJsonFileScriptPath
}
Describe 'Import-JsonFile' {
BeforeEach {
Mock Write-Error {}
}
It 'loads valid JSON with the expected version' {
$result = Import-JsonFile -filePath (Join-Path $script:FixturePath 'Config.Valid.json') -expectedVersion '1.0'
$result.Name | Should -Be 'Example configuration'
}
It 'parses the <Kind> settings fixture' -ForEach @(
@{ Kind = 'default'; FileName = 'DefaultSettings.Valid.json' }
@{ Kind = 'last-used'; FileName = 'LastUsedSettings.Valid.json' }
) {
$result = Import-JsonFile -filePath (Join-Path $script:FixturePath $FileName) -expectedVersion '1.0'
$result.Settings | Should -Not -BeNullOrEmpty
$result.Settings[0].Name | Should -Be 'Supported'
}
It 'returns null and reports an error for <Case>' -ForEach @(
@{ Case = 'a version mismatch'; FileName = 'Config.VersionMismatch.json'; ExpectedVersion = '1.0'; Optional = $false; Error = 'version mismatch' }
@{ Case = 'invalid JSON'; FileName = 'Config.Invalid.json'; ExpectedVersion = $null; Optional = $false; Error = 'Failed to parse JSON file' }
) {
$filePath = Join-Path $script:FixturePath $FileName
$result = Import-JsonFile -filePath $filePath -expectedVersion $ExpectedVersion -optionalFile:$Optional
$result | Should -BeNullOrEmpty
Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { $Message -match $Error }
}
It 'returns null without an error for an optional missing last-used settings file' {
$result = Import-JsonFile -filePath (Join-Path $TestDrive 'LastUsedSettings.json') -expectedVersion '1.0' -optionalFile
$result | Should -BeNullOrEmpty
Should -Invoke Write-Error -Times 0 -Exactly
}
}
+78
View File
@@ -0,0 +1,78 @@
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:regPath = Join-Path $TestDrive 'feature.reg'
'' | Set-Content -LiteralPath $script:regPath
Mock Get-RegistryFilePathForFeature { $script:regPath }
Mock Invoke-RegistryOperationsFromRegFile { $true }
Mock Invoke-WithTargetUserHive {}
Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @(); ExitCode = 0; Error = $null } }
Mock Write-Host {}
Mock Write-Warning {}
}
It 'returns false when the registry file is missing' {
Mock Get-RegistryFilePathForFeature { Join-Path $TestDrive 'missing.reg' }
Import-RegistryFile -message 'Apply' -path 'missing.reg' | Should -BeFalse
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'returns false when registry file resolution throws' {
Mock Get-RegistryFilePathForFeature { throw 'path resolution failed' }
Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeFalse
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'uses the PowerShell writer only in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeTrue
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 -BeTrue
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 -BeTrue
Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "reg import failed*" }
}
It 'returns false when the fallback cannot apply every registry operation' {
Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @('denied'); ExitCode = 5; Error = 'access denied' } }
Mock Invoke-RegistryOperationsFromRegFile { $false }
Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeFalse
}
It 'does not invoke the fallback after a successful reg import' {
Import-RegistryFile -message 'Apply' -path 'feature.reg' | Should -BeTrue
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly
Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 0 -Exactly
}
}
+515
View File
@@ -0,0 +1,515 @@
BeforeAll {
function Import-RegistryFile { param($Message, $path) }
function Remove-SelectedApps { param($Apps) $true }
function Invoke-ForceRemoveEdge { $true }
function Disable-TelemetryScheduledTasks { $true }
function Enable-TelemetryScheduledTasks { $true }
function Generate-AppsList { @() }
function Get-FriendlyTargetUserName { 'current user' }
function Set-StoreSearchSuggestionsEnabledForAllUsers { $true }
function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) $true }
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
function Get-UserName { 'Alice' }
function Disable-WindowsFeature { param($FeatureName) $true }
function New-RegistrySettingsBackup { param($ActionableKeys, $ExtraFeatures) }
function Invoke-SystemRestorePoint {}
function Enable-WindowsFeature { param($FeatureName) $true }
function Get-StartMenuBinPathForUser { param($UserName) 'start.bin' }
function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) $true }
function Replace-StartMenuForAllUsers { param($startMenuTemplate) $true }
function Set-StoreSearchSuggestionsDisabledForAllUsers { $true }
function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) $true }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1')
}
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 = '' }
ForceRemoveEdge = [PSCustomObject]@{ ApplyText = 'Force remove Edge'; 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 { $true }
Mock Remove-SelectedApps { $true }
Mock Invoke-ForceRemoveEdge { $true }
Mock Disable-TelemetryScheduledTasks { $true }
Mock Generate-AppsList { @() }
Mock Get-FriendlyTargetUserName { 'current user' }
Mock Enable-WindowsFeature { $true }
Mock Get-StartMenuBinPathForUser { 'start.bin' }
Mock Get-UserName { 'Alice' }
Mock Replace-StartMenu { $true }
Mock Replace-StartMenuForAllUsers { $true }
Mock Set-StoreSearchSuggestionsDisabledForAllUsers { $true }
Mock Set-StoreSearchSuggestionsDisabled { $true }
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-Process { @() }
Mock Stop-Process { param($InputObject) }
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 'returns false without side effects when a registry import fails' {
Mock Import-RegistryFile { $false }
Invoke-FeatureApply -FeatureId 'DisableTelemetry' | Should -BeFalse
Should -Invoke Disable-TelemetryScheduledTasks -Times 0 -Exactly
}
It 'does not call app removal when the generated selection is empty' {
Invoke-FeatureApply -FeatureId 'RemoveApps'
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 'forcefully removes Edge when requested' {
Invoke-FeatureApply -FeatureId 'ForceRemoveEdge'
Should -Invoke Invoke-ForceRemoveEdge -Times 1 -Exactly
Should -Invoke Import-RegistryFile -Times 0 -Exactly
Should -Invoke Remove-SelectedApps -Times 0 -Exactly
}
It 'returns false when applying a feature throws' {
Mock Invoke-ForceRemoveEdge { throw 'access denied' }
Mock Write-Warning {}
Invoke-FeatureApply -FeatureId 'ForceRemoveEdge' | Should -BeFalse
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "Failed to apply 'Force remove Edge'.*access denied" }
}
It 'returns false for an unknown feature' {
Mock Write-Warning {}
Invoke-FeatureApply -FeatureId 'Unknown' | Should -BeFalse
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "Unknown feature 'Unknown'.*could not be applied" }
}
It 'uses the expected static app list for <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 'stops widget processes before removing widget packages' {
$widget = [PSCustomObject]@{ Name = 'WidgetService' }
Mock Get-Process { $widget }
Invoke-FeatureApply -FeatureId 'DisableWidgets'
Should -Invoke Stop-Process -Times 1 -Exactly
}
It 'stops widget processes without a confirmation prompt' {
Mock Get-Process { [PSCustomObject]@{ Name = 'Widgets' } }
Invoke-FeatureApply -FeatureId 'DisableWidgets'
Should -Invoke Stop-Process -Times 1 -Exactly -ParameterFilter { $Force -and $ErrorAction -eq 'SilentlyContinue' }
}
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 Set-StoreSearchSuggestionsDisabledForAllUsers -Times 1 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly
}
It 'does not update Store search suggestions when the current user database cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions'
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 { $true }
}
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
}
It 'counts a failed feature application and continues with later features' {
$script:FeatureFailures = 0
Mock Invoke-FeatureApply {
param($FeatureId)
return ($FeatureId -ne 'One')
}
Invoke-ApplyFeatures -FeatureIds @('One', 'Two') -StartStep 1 -TotalSteps 2
$script:FeatureFailures | Should -Be 1
Should -Invoke Invoke-FeatureApply -Times 2 -Exactly
}
}
Describe 'Invoke-UndoFeatures' {
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 { $true }
Mock Invoke-FeatureUndo { $true }
}
It 'delegates registry-backed undo work to the feature undo handler' {
Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1
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
}
It 'counts one failure when a feature undo fails' {
$script:FeatureFailures = 0
Mock Invoke-FeatureUndo { $false }
Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1
$script:FeatureFailures | Should -Be 1
Should -Invoke Invoke-FeatureUndo -Times 1 -Exactly
}
It 'stops before undoing when cancellation is requested' {
$script:CancelRequested = $true
Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1
Should -Invoke Import-RegistryFile -Times 0 -Exactly
Should -Invoke Invoke-FeatureUndo -Times 0 -Exactly
}
}
Describe 'Invoke-FeatureUndo' {
BeforeEach {
$script:Params = @{}
$script:Features = @{
EnableWindowsSandbox = [PSCustomObject]@{ ApplyUndoText = 'Disable Sandbox' }
EnableWindowsSubsystemForLinux = [PSCustomObject]@{ ApplyUndoText = 'Disable WSL' }
DisableTelemetry = [PSCustomObject]@{}
DisableStoreSearchSuggestions = [PSCustomObject]@{}
}
Mock Set-StoreSearchSuggestionsEnabledForAllUsers { $true }
Mock Set-StoreSearchSuggestionsEnabled { $true }
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-UserName { 'Alice' }
Mock Disable-WindowsFeature { $true }
Mock Enable-TelemetryScheduledTasks { $true }
Mock Import-RegistryFile { $true }
Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" }
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 Set-StoreSearchSuggestionsEnabledForAllUsers -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); $true }
Invoke-FeatureUndo -FeatureId 'EnableWindowsSubsystemForLinux'
$script:disabledFeatures | Should -Be @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform')
}
It 'disables Sandbox and re-enables telemetry tasks' {
$script:Features.DisableTelemetry = [PSCustomObject]@{ ApplyUndoText = 'Enable telemetry'; RegistryUndoKey = 'enable-telemetry.reg' }
Invoke-FeatureUndo -FeatureId 'EnableWindowsSandbox'
Invoke-FeatureUndo -FeatureId 'DisableTelemetry'
Should -Invoke Disable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' }
Should -Invoke Enable-TelemetryScheduledTasks -Times 1 -Exactly
Should -Invoke Import-RegistryFile -Times 1 -Exactly -ParameterFilter { $path -eq 'Undo\enable-telemetry.reg' }
}
It 'returns false without side effects when a registry undo import fails' {
$script:Features.DisableTelemetry = [PSCustomObject]@{ ApplyUndoText = 'Enable telemetry'; RegistryUndoKey = 'enable-telemetry.reg' }
Mock Import-RegistryFile { $false }
Invoke-FeatureUndo -FeatureId 'DisableTelemetry' | Should -BeFalse
Should -Invoke Enable-TelemetryScheduledTasks -Times 0 -Exactly
}
It 'warns and returns false for an unknown feature' {
Mock Write-Warning {}
Invoke-FeatureUndo -FeatureId 'Unknown' | Should -BeFalse
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "Unknown feature 'Unknown'.*could not be undone" }
}
}
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 { $true }
Mock Invoke-ApplyFeatures {}
Mock Invoke-UndoFeatures {}
Mock Write-Host {}
Mock Write-Warning {}
}
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 create a registry backup when explicitly skipped' {
$script:Params['SkipRegistryBackup'] = $true
Invoke-AllChanges
Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly
Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly
Should -Invoke Invoke-UndoFeatures -Times 1 -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'); $true }
Mock Invoke-ApplyFeatures { $script:order.Add('apply') }
Invoke-AllChanges
$script:order | Should -Be @('restore-point', 'apply')
}
It 'counts a restore point failure as a feature failure when the user chooses to continue' {
$script:Params = @{ CreateRestorePoint = $true; CustomApply = $true }
$script:UndoParams = @{}
Mock Invoke-SystemRestorePoint { $false }
Invoke-AllChanges
$script:FeatureFailures | Should -Be 1
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '1 feature change\(s\) failed\.' }
}
It 'reports app removal failures after all requested work completes' {
$script:Params = @{ CustomApply = $true }
$script:UndoParams = @{}
Mock Invoke-ApplyFeatures { $script:AppRemovalFailures = 2 }
Invoke-AllChanges
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '2 app removal\(s\) failed' }
}
It 'warns when app removals could not be verified' {
$script:Params = @{ CustomApply = $true }
$script:UndoParams = @{}
Mock Invoke-ApplyFeatures { $script:AppRemovalVerificationUnavailable = $true }
Mock Write-Warning {}
Invoke-AllChanges
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -eq 'Unable to verify if all apps were uninstalled successfully.' }
}
}

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