mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-08-23 08:02:07 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d206cb9b | ||
|
|
b7f612f36e | ||
|
|
c921730703 | ||
|
|
80eadd531c | ||
|
|
4c29fce469 | ||
|
|
93d77d8034 | ||
|
|
78e1d601b0 | ||
|
|
f763390d53 | ||
|
|
dded2da3a7 | ||
|
|
492a374f5c | ||
|
|
1a26934499 | ||
|
|
5072958b10 | ||
|
|
31feaeb6f5 | ||
|
|
dd929f8eec | ||
|
|
b21be418cd | ||
|
|
25fb9f3725 | ||
|
|
d1338cd027 | ||
|
|
9505a5a374 | ||
|
|
c0599cba1f | ||
|
|
5c838384d6 | ||
|
|
6957ce4220 | ||
|
|
0f30b62221 | ||
|
|
32cedaf65d | ||
|
|
68cacfce89 | ||
|
|
a9c1736e46 | ||
|
|
9c033dbf98 | ||
|
|
a7292e4f35 | ||
|
|
f739a0e474 | ||
|
|
63b1f61fd1 | ||
|
|
de817399e6 | ||
|
|
8fa1332ff0 |
+21
-2
@@ -58,6 +58,25 @@ You can launch the prerelease version of Win11Debloat by running this command:
|
|||||||
.\Win11Debloat.ps1
|
.\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
|
## Implementation Guidelines
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -72,7 +91,7 @@ Win11Debloat/
|
|||||||
│ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
|
│ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
|
||||||
│ ├── AppRemoval/ # App package removal logic
|
│ ├── AppRemoval/ # App package removal logic
|
||||||
│ ├── CLI/ # Command-line interface helpers
|
│ ├── 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
|
│ ├── FileIO/ # File input/output helpers
|
||||||
│ ├── GUI/ # GUI window definitions and logic
|
│ ├── GUI/ # GUI window definitions and logic
|
||||||
│ ├── Helpers/ # Shared helper functions
|
│ ├── Helpers/ # Shared helper functions
|
||||||
@@ -220,7 +239,7 @@ Windows Registry Editor Version 5.00
|
|||||||
|
|
||||||
#### 1b. Implement the Feature Logic
|
#### 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
|
#### 2. Add Feature to Features.json
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,50 +2,62 @@
|
|||||||
"Version": "1.0",
|
"Version": "1.0",
|
||||||
"Categories": [
|
"Categories": [
|
||||||
{
|
{
|
||||||
|
"CategoryId": "PrivacySuggestedContent",
|
||||||
"Name": "Privacy & Suggested Content",
|
"Name": "Privacy & Suggested Content",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "System",
|
||||||
"Name": "System",
|
"Name": "System",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "StartMenuSearch",
|
||||||
"Name": "Start Menu & Search",
|
"Name": "Start Menu & Search",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "AI",
|
||||||
"Name": "AI",
|
"Name": "AI",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "WindowsUpdate",
|
||||||
"Name": "Windows Update",
|
"Name": "Windows Update",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "Taskbar",
|
||||||
"Name": "Taskbar",
|
"Name": "Taskbar",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "Appearance",
|
||||||
"Name": "Appearance",
|
"Name": "Appearance",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "FileExplorer",
|
||||||
"Name": "File Explorer",
|
"Name": "File Explorer",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "Gaming",
|
||||||
"Name": "Gaming",
|
"Name": "Gaming",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "MultiTasking",
|
||||||
"Name": "Multi-tasking",
|
"Name": "Multi-tasking",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "OptionalWindowsFeatures",
|
||||||
"Name": "Optional Windows Features",
|
"Name": "Optional Windows Features",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"CategoryId": "Other",
|
||||||
"Name": "Other",
|
"Name": "Other",
|
||||||
"Icon": ""
|
"Icon": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
3. Temporarily enable PowerShell execution by entering the following command:
|
||||||
|
|
||||||
```PowerShell
|
```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`
|
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 Windows from getting updates as soon as they're available.
|
||||||
- Prevent automatic restarts after updates while signed in.
|
- Prevent automatic restarts after updates while signed in.
|
||||||
- Disable sharing of downloaded updates with other PCs, also known as Delivery Optimization.
|
- 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
|
#### Appearance
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ set "SCRIPT_PATH=%~dp0Win11Debloat.ps1"
|
|||||||
|
|
||||||
if defined wtPath (
|
if defined wtPath (
|
||||||
call :Log Launching Win11Debloat.ps1 with Windows Terminal...
|
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 (
|
) else (
|
||||||
echo Windows Terminal not found, using default PowerShell...
|
echo Windows Terminal not found, using default PowerShell...
|
||||||
call :Log Windows Terminal not found. Using default PowerShell to launch Win11Debloat.ps1...
|
call :Log Windows Terminal not found. Using default PowerShell to launch Win11Debloat.ps1...
|
||||||
|
|||||||
+10
-8
@@ -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"
|
<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"/>
|
Fill="{DynamicResource ButtonBgColor}" Stretch="Uniform" Margin="10"/>
|
||||||
<!-- Sparkle effects -->
|
<!-- Sparkle effects -->
|
||||||
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="50" Height="50" Margin="0,0,2,2">
|
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="80" Height="80" 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"
|
<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="40" Height="40" Stretch="Uniform"/>
|
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"
|
<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"/>
|
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" 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"
|
<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="17" Height="17" Stretch="Uniform"/>
|
Fill="{DynamicResource AppAccentColor}" Width="25" Height="25" Stretch="Uniform"/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
@@ -933,12 +933,14 @@
|
|||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Options" Style="{StaticResource CategoryHeaderTextBlock}"/>
|
<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>
|
<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)"/>
|
<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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Restart Explorer Option -->
|
|
||||||
<StackPanel>
|
<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"/>
|
<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>
|
</StackPanel>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-3
@@ -17,7 +17,7 @@
|
|||||||
PSCustomObject[] with Name and Id properties. Returns $null on
|
PSCustomObject[] with Name and Id properties. Returns $null on
|
||||||
failure, or an empty array when winget succeeds but lists no apps.
|
failure, or an empty array when winget succeeds but lists no apps.
|
||||||
#>
|
#>
|
||||||
function GetInstalledAppsViaWinget {
|
function Get-WingetInstalledApps {
|
||||||
param (
|
param (
|
||||||
[int]$TimeOut = 10,
|
[int]$TimeOut = 10,
|
||||||
[switch]$NonBlocking
|
[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()
|
$apps = [System.Collections.Generic.List[object]]::new()
|
||||||
|
|
||||||
@@ -94,7 +101,7 @@ function GetInstalledAppsViaWinget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return @($apps)
|
return ,@($apps)
|
||||||
}
|
}
|
||||||
|
|
||||||
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
The identifier to search for (e.g. 'Microsoft.Copilot').
|
The identifier to search for (e.g. 'Microsoft.Copilot').
|
||||||
|
|
||||||
.PARAMETER InstalledList
|
.PARAMETER InstalledList
|
||||||
An array of PSCustomObject from GetInstalledAppsViaWinget.
|
An array of PSCustomObject from Get-WingetInstalledApps.
|
||||||
#>
|
#>
|
||||||
function Test-AppInWingetList {
|
function Test-AppInWingetList {
|
||||||
param(
|
param(
|
||||||
|
|||||||
@@ -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.
|
# Shows the CLI app removal menu and prompts the user to select which apps to remove.
|
||||||
function ShowCLIAppRemoval {
|
function Show-CliAppRemoval {
|
||||||
PrintHeader "App Removal"
|
Write-CliHeader "App Removal"
|
||||||
|
|
||||||
Write-Output "> Opening app selection form..."
|
Write-Output "> Opening app selection form..."
|
||||||
|
|
||||||
@@ -8,10 +8,10 @@ function ShowCLIAppRemoval {
|
|||||||
|
|
||||||
if ($result -eq $true) {
|
if ($result -eq $true) {
|
||||||
Write-Output "You have selected $($script:SelectedApps.Count) apps for removal"
|
Write-Output "You have selected $($script:SelectedApps.Count) apps for removal"
|
||||||
AddParameter 'RemoveApps'
|
Add-Parameter 'RemoveApps'
|
||||||
AddParameter 'Apps' ($script:SelectedApps -join ',')
|
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
|
||||||
|
|
||||||
SaveSettings
|
Save-Settings
|
||||||
|
|
||||||
# Suppress prompt if Silent parameter was passed
|
# Suppress prompt if Silent parameter was passed
|
||||||
if (-not $Silent) {
|
if (-not $Silent) {
|
||||||
@@ -19,7 +19,7 @@ function ShowCLIAppRemoval {
|
|||||||
Write-Output ""
|
Write-Output ""
|
||||||
Write-Output "Press enter to remove the selected apps or press CTRL+C to quit..."
|
Write-Output "Press enter to remove the selected apps or press CTRL+C to quit..."
|
||||||
Read-Host | Out-Null
|
Read-Host | Out-Null
|
||||||
PrintHeader "App Removal"
|
Write-CliHeader "App Removal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Shows the CLI default mode app removal options. Loops until a valid option is selected.
|
# Shows the CLI default mode app removal options. Loops until a valid option is selected.
|
||||||
function ShowCLIDefaultModeAppRemovalOptions {
|
function Show-CliDefaultModeAppRemovalOptions {
|
||||||
PrintHeader 'Default Mode'
|
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 "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 ""
|
Write-Host ""
|
||||||
+14
-14
@@ -1,5 +1,5 @@
|
|||||||
# Show CLI default mode options for removing apps, or set selection if RunDefaults or RunDefaultsLite parameter was passed
|
# 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) {
|
if ($RunDefaults) {
|
||||||
$RemoveAppsInput = '1'
|
$RemoveAppsInput = '1'
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ function ShowCLIDefaultModeOptions {
|
|||||||
$RemoveAppsInput = '0'
|
$RemoveAppsInput = '0'
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$RemoveAppsInput = ShowCLIDefaultModeAppRemovalOptions
|
$RemoveAppsInput = Show-CliDefaultModeAppRemovalOptions
|
||||||
|
|
||||||
if ($RemoveAppsInput -eq '2' -and ($script:SelectedApps.contains('Microsoft.XboxGameOverlay') -or $script:SelectedApps.contains('Microsoft.XboxGamingOverlay')) -and
|
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') {
|
$( 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 {
|
try {
|
||||||
# Select app removal options based on user input
|
# Select app removal options based on user input
|
||||||
switch ($RemoveAppsInput) {
|
switch ($RemoveAppsInput) {
|
||||||
'1' {
|
'1' {
|
||||||
AddParameter 'RemoveApps'
|
Add-Parameter 'RemoveApps'
|
||||||
AddParameter 'Apps' 'Default'
|
Add-Parameter 'Apps' 'Default'
|
||||||
}
|
}
|
||||||
'2' {
|
'2' {
|
||||||
AddParameter 'RemoveApps'
|
Add-Parameter 'RemoveApps'
|
||||||
AddParameter 'Apps' ($script:SelectedApps -join ',')
|
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
|
||||||
|
|
||||||
if ($DisableGameBarIntegrationInput) {
|
if ($DisableGameBarIntegrationInput) {
|
||||||
AddParameter 'DisableDVR'
|
Add-Parameter 'DisableDVR'
|
||||||
AddParameter 'DisableGameBarIntegration'
|
Add-Parameter 'DisableGameBarIntegration'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadSettings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
Import-Settings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Error "Failed to load settings from DefaultSettings.json file: $_"
|
Write-Error "Failed to load settings from DefaultSettings.json file: $_"
|
||||||
AwaitKeyToExit
|
Wait-ForKeyPress -ExitCode 1
|
||||||
}
|
}
|
||||||
|
|
||||||
SaveSettings
|
Save-Settings
|
||||||
|
|
||||||
if ($Silent) {
|
if ($Silent) {
|
||||||
# Skip change summary and confirmation prompt
|
# Skip change summary and confirmation prompt
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintPendingChanges
|
Write-PendingChanges
|
||||||
PrintHeader 'Default Mode'
|
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.
|
# Shows the CLI last used settings from LastUsedSettings.json file, displays pending changes and prompts the user to apply them.
|
||||||
function ShowCLILastUsedSettings {
|
function Show-CliLastUsedSettings {
|
||||||
PrintHeader 'Custom Mode'
|
Write-CliHeader 'Custom Mode'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
LoadSettings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
|
Import-Settings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Error "Failed to load settings from LastUsedSettings.json file: $_"
|
Write-Error "Failed to load settings from LastUsedSettings.json file: $_"
|
||||||
AwaitKeyToExit
|
Wait-ForKeyPress -ExitCode 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Silent) {
|
if ($Silent) {
|
||||||
@@ -15,6 +15,6 @@ function ShowCLILastUsedSettings {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintPendingChanges
|
Write-PendingChanges
|
||||||
PrintHeader 'Custom Mode'
|
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.
|
# 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 {
|
Do {
|
||||||
$ModeSelectionMessage = "Please select an option (1/2)"
|
$ModeSelectionMessage = "Please select an option (1/2)"
|
||||||
|
|
||||||
PrintHeader 'Menu'
|
Write-CliHeader 'Menu'
|
||||||
|
|
||||||
Write-Host "(1) Default mode: Quickly apply the recommended changes"
|
Write-Host "(1) Default mode: Quickly apply the recommended changes"
|
||||||
Write-Host "(2) App removal mode: Select & remove apps, without making other changes"
|
Write-Host "(2) App removal mode: Select & remove apps, without making other changes"
|
||||||
@@ -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
|
# Prints the header for the script
|
||||||
function PrintHeader {
|
function Write-CliHeader {
|
||||||
param (
|
param (
|
||||||
$title
|
$title
|
||||||
)
|
)
|
||||||
@@ -10,7 +10,7 @@ function PrintHeader {
|
|||||||
$fullTitle = "$fullTitle (Sysprep mode)"
|
$fullTitle = "$fullTitle (Sysprep mode)"
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$fullTitle = "$fullTitle (User: $(GetUserName))"
|
$fullTitle = "$fullTitle (User: $(Get-UserName))"
|
||||||
}
|
}
|
||||||
|
|
||||||
Clear-Host
|
Clear-Host
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
After printing the summary the function pauses until the user presses
|
After printing the summary the function pauses until the user presses
|
||||||
Enter, giving them an opportunity to review and cancel via Ctrl+C.
|
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:"
|
Write-Output "Win11Debloat will make the following changes:"
|
||||||
|
|
||||||
if ($script:Params['CreateRestorePoint']) {
|
if ($script:Params['CreateRestorePoint']) {
|
||||||
@@ -32,7 +32,7 @@ function PrintPendingChanges {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
'RemoveApps' {
|
'RemoveApps' {
|
||||||
$appsList = GenerateAppsList
|
$appsList = Generate-AppsList
|
||||||
|
|
||||||
if ($appsList.Count -eq 0) {
|
if ($appsList.Count -eq 0) {
|
||||||
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
||||||
+38
-5
@@ -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 {
|
function Convert-RegistryKeyToSnapshot {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
$RegistryKey,
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$FullPath,
|
[string]$FullPath,
|
||||||
[bool]$CaptureAllValues = $false,
|
[bool]$CaptureAllValues = $false,
|
||||||
@@ -233,7 +242,9 @@ function Convert-RegistryKeyToSnapshot {
|
|||||||
if ($IncludeSubKeys) {
|
if ($IncludeSubKeys) {
|
||||||
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
|
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
|
||||||
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
|
$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 {
|
try {
|
||||||
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
|
$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 {
|
function Convert-RegistryValueToSnapshot {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
$RegistryKey,
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[AllowEmptyString()]
|
[AllowEmptyString()]
|
||||||
[string]$ValueName
|
[string]$ValueName
|
||||||
)
|
)
|
||||||
|
|
||||||
$valueKind = $RegistryKey.GetValueKind($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)
|
$value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||||
try {
|
try {
|
||||||
$normalizedValue = switch ($valueKind) {
|
$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]::MultiString) { @($value) }
|
||||||
([Microsoft.Win32.RegistryValueKind]::DWord) { [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$value), 0) }
|
([Microsoft.Win32.RegistryValueKind]::DWord) { [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$value), 0) }
|
||||||
([Microsoft.Win32.RegistryValueKind]::QWord) { [BitConverter]::ToUInt64([BitConverter]::GetBytes([int64]$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 {
|
function Get-RegistryBackupTargetDescription {
|
||||||
if ($script:Params.ContainsKey('Sysprep')) {
|
if ($script:Params.ContainsKey('Sysprep')) {
|
||||||
return 'DefaultUserProfile'
|
return 'DefaultUserProfile'
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolvedUserName = [string](GetUserName)
|
$resolvedUserName = [string](Get-UserName)
|
||||||
|
|
||||||
if ($script:Params.ContainsKey('User')) {
|
if ($script:Params.ContainsKey('User')) {
|
||||||
return "User:$resolvedUserName"
|
return "User:$resolvedUserName"
|
||||||
+1
-1
@@ -37,7 +37,7 @@ function New-RegistrySettingsBackup {
|
|||||||
$backupFilePath = Join-Path $backupDirectory $backupFileName
|
$backupFilePath = Join-Path $backupDirectory $backupFileName
|
||||||
|
|
||||||
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -UndoFeatures $undoFeatures -CreatedAt $timestamp
|
$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'"
|
throw "Failed to save registry backup to '$backupFilePath'"
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -66,7 +66,7 @@ function Test-FeatureApplied {
|
|||||||
return (Test-StoreSearchSuggestionsDisabledForAllUsers)
|
return (Test-StoreSearchSuggestionsDisabledForAllUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
$storeDbPath = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
|
$storeDbPath = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||||
|
|
||||||
return (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDbPath)
|
return (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDbPath)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
.DESCRIPTION
|
||||||
Handles two categories of features:
|
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).
|
any post-import side effects (e.g., removing companion app packages).
|
||||||
- Custom logic: app removal, Windows optional features, start menu
|
- 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 {
|
function Invoke-FeatureApply {
|
||||||
param(
|
param(
|
||||||
@@ -15,137 +17,138 @@ function Invoke-FeatureApply {
|
|||||||
[string]$FeatureId
|
[string]$FeatureId
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolve feature metadata from Features.json
|
try {
|
||||||
$feature = $script:Features[$FeatureId]
|
# Resolve feature metadata from Features.json
|
||||||
$applyText = $feature.ApplyText
|
$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) {
|
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) {
|
switch ($FeatureId) {
|
||||||
'DisableBing' {
|
'DisableBing' {
|
||||||
# Also remove the app package for Bing search
|
# Also remove the app package for Bing search
|
||||||
RemoveApps @('Microsoft.BingSearch')
|
return (Remove-SelectedApps @('Microsoft.BingSearch'))
|
||||||
}
|
}
|
||||||
'DisableCopilot' {
|
'DisableCopilot' {
|
||||||
# Also remove the app packages for Copilot
|
# Also remove the app packages for Copilot
|
||||||
RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')
|
return (Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX'))
|
||||||
}
|
}
|
||||||
'DisableTelemetry' {
|
'DisableTelemetry' {
|
||||||
# Also disable telemetry scheduled tasks
|
# Also disable telemetry scheduled tasks
|
||||||
Disable-TelemetryScheduledTasks
|
return (Disable-TelemetryScheduledTasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- Custom features (no registry backing, or special handling required) ----
|
# ---- Custom features (no registry backing, or special handling required) ----
|
||||||
switch ($FeatureId) {
|
switch ($FeatureId) {
|
||||||
'RemoveApps' {
|
'RemoveApps' {
|
||||||
Write-Host "> $applyText for $(GetFriendlyTargetUserName)..."
|
Write-Host "> $applyText for $(Get-FriendlyTargetUserName)..."
|
||||||
$appsList = GenerateAppsList
|
$appsList = Generate-AppsList
|
||||||
|
|
||||||
if ($appsList.Count -eq 0) {
|
if ($appsList.Count -eq 0) {
|
||||||
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
||||||
Write-Host ""
|
return $true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "$($appsList.Count) apps selected for removal"
|
Write-Host "$($appsList.Count) apps selected for removal"
|
||||||
RemoveApps $appsList
|
return (Remove-SelectedApps $appsList)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'RemoveGamingApps' {
|
'RemoveGamingApps' {
|
||||||
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
|
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
|
||||||
Write-Host "> $applyText..."
|
Write-Host "> $applyText..."
|
||||||
RemoveApps $appsList
|
return (Remove-SelectedApps $appsList)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'RemoveHPApps' {
|
'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')
|
$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..."
|
Write-Host "> $applyText..."
|
||||||
RemoveApps $appsList
|
return (Remove-SelectedApps $appsList)
|
||||||
return
|
}
|
||||||
|
'ForceRemoveEdge' {
|
||||||
|
Write-Host "> $applyText..."
|
||||||
|
return (Invoke-ForceRemoveEdge)
|
||||||
}
|
}
|
||||||
'DisableWidgets' {
|
'DisableWidgets' {
|
||||||
Write-Host "> $applyText..."
|
Write-Host "> $applyText..."
|
||||||
# Stop widgets related processes before removing the app packages to prevent potential issues
|
# Stop widgets related processes before removing the app packages to prevent potential issues
|
||||||
if (-not $script:Params.ContainsKey("WhatIf")) {
|
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 (Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'EnableWindowsSandbox' {
|
'EnableWindowsSandbox' {
|
||||||
Write-Host "> $applyText..."
|
Write-Host "> $applyText..."
|
||||||
EnableWindowsFeature "Containers-DisposableClientVM"
|
return (Enable-WindowsFeature "Containers-DisposableClientVM")
|
||||||
Write-Host ""
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'EnableWindowsSubsystemForLinux' {
|
'EnableWindowsSubsystemForLinux' {
|
||||||
Write-Host "> $applyText..."
|
Write-Host "> $applyText..."
|
||||||
EnableWindowsFeature "VirtualMachinePlatform"
|
if (-not (Enable-WindowsFeature "VirtualMachinePlatform")) { return $false }
|
||||||
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
|
return (Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux")
|
||||||
Write-Host ""
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'ClearStart' {
|
'ClearStart' {
|
||||||
Write-Host "> $applyText for user $(GetUserName)..."
|
Write-Host "> $applyText for user $(Get-UserName)..."
|
||||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
|
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
|
||||||
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
||||||
ReplaceStartMenu -startMenuBinFile $startMenuBinFile
|
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile)
|
||||||
}
|
}
|
||||||
Write-Host ""
|
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
|
||||||
return
|
return $false
|
||||||
}
|
}
|
||||||
'ReplaceStart' {
|
'ReplaceStart' {
|
||||||
Write-Host "> $applyText for user $(GetUserName)..."
|
Write-Host "> $applyText for user $(Get-UserName)..."
|
||||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
|
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
|
||||||
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
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 ""
|
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
|
||||||
return
|
return $false
|
||||||
}
|
}
|
||||||
'ClearStartAllUsers' {
|
'ClearStartAllUsers' {
|
||||||
ReplaceStartMenuForAllUsers
|
return (Replace-StartMenuForAllUsers)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'ReplaceStartAllUsers' {
|
'ReplaceStartAllUsers' {
|
||||||
ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")
|
return (Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
'DisableStoreSearchSuggestions' {
|
'DisableStoreSearchSuggestions' {
|
||||||
if ($script:Params.ContainsKey("Sysprep")) {
|
if ($script:Params.ContainsKey("Sysprep")) {
|
||||||
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
|
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
|
||||||
DisableStoreSearchSuggestionsForAllUsers
|
return (Set-StoreSearchSuggestionsDisabledForAllUsers)
|
||||||
Write-Host ""
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..."
|
Write-Host "> Disabling Microsoft Store search suggestions for user $(Get-UserName)..."
|
||||||
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
|
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||||
if ($storeDb) {
|
if ($storeDb) {
|
||||||
DisableStoreSearchSuggestions -StoreAppsDatabase $storeDb
|
return (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb)
|
||||||
}
|
}
|
||||||
Write-Host ""
|
Write-Warning "Unable to disable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
|
||||||
return
|
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
|
.SYNOPSIS
|
||||||
Undoes a single feature that has no RegistryUndoKey.
|
Undoes a single feature.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Handles undo for features that require custom logic rather than a simple
|
Handles registry-backed undo imports and custom undo logic. Returns
|
||||||
.reg file import. Features with a RegistryUndoKey are handled directly
|
$true when the requested undo succeeds; otherwise writes a warning and
|
||||||
via ImportRegistryFile in Invoke-UndoFeatures.
|
returns $false.
|
||||||
#>
|
#>
|
||||||
function Invoke-FeatureUndo {
|
function Invoke-FeatureUndo {
|
||||||
param(
|
param(
|
||||||
@@ -154,43 +157,65 @@ function Invoke-FeatureUndo {
|
|||||||
)
|
)
|
||||||
|
|
||||||
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
|
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
|
||||||
|
if (-not $feature) {
|
||||||
|
Write-Warning "Unknown feature '$FeatureId' could not be undone."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
switch ($FeatureId) {
|
$undoText = if ($feature.ApplyUndoText) { $feature.ApplyUndoText } elseif ($feature.UndoLabel) { $feature.UndoLabel } else { $FeatureId }
|
||||||
'DisableStoreSearchSuggestions' {
|
|
||||||
if ($script:Params.ContainsKey('Sysprep')) {
|
try {
|
||||||
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
|
# ---- Registry-backed features: import undo data, then handle additional tasks ----
|
||||||
EnableStoreSearchSuggestionsForAllUsers
|
if ($feature.RegistryUndoKey) {
|
||||||
Write-Host ""
|
if (-not (Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $feature.RegistryUndoKey))) {
|
||||||
return
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..."
|
switch ($FeatureId) {
|
||||||
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
|
'DisableTelemetry' {
|
||||||
if ($storeDb) {
|
# Also re-enable telemetry scheduled tasks.
|
||||||
EnableStoreSearchSuggestions -StoreAppsDatabase $storeDb
|
return (Enable-TelemetryScheduledTasks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Write-Host ""
|
|
||||||
return
|
return $true
|
||||||
}
|
}
|
||||||
'EnableWindowsSandbox' {
|
|
||||||
Write-Host "> $($feature.ApplyUndoText)..."
|
# ---- Custom undo features (no registry backing) ----
|
||||||
DisableWindowsFeature 'Containers-DisposableClientVM'
|
switch ($FeatureId) {
|
||||||
Write-Host ""
|
'DisableStoreSearchSuggestions' {
|
||||||
return
|
if ($script:Params.ContainsKey('Sysprep')) {
|
||||||
}
|
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
|
||||||
'EnableWindowsSubsystemForLinux' {
|
return (Set-StoreSearchSuggestionsEnabledForAllUsers)
|
||||||
Write-Host "> $($feature.ApplyUndoText)..."
|
}
|
||||||
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
|
|
||||||
DisableWindowsFeature 'VirtualMachinePlatform'
|
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..."
|
||||||
Write-Host ""
|
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||||
return
|
if ($storeDb) {
|
||||||
}
|
return (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb)
|
||||||
'DisableTelemetry' {
|
}
|
||||||
# Also re-enable telemetry scheduled tasks
|
Write-Warning "Unable to re-enable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
|
||||||
Enable-TelemetryScheduledTasks
|
return $false
|
||||||
return
|
}
|
||||||
|
'EnableWindowsSandbox' {
|
||||||
|
Write-Host "> $undoText..."
|
||||||
|
return (Disable-WindowsFeature 'Containers-DisposableClientVM')
|
||||||
|
}
|
||||||
|
'EnableWindowsSubsystemForLinux' {
|
||||||
|
Write-Host "> $undoText..."
|
||||||
|
if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false }
|
||||||
|
return (Disable-WindowsFeature 'VirtualMachinePlatform')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catch {
|
||||||
|
Write-Warning "Failed to undo '$undoText': $($_.Exception.Message)"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Feature '$FeatureId' does not support undo."
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -246,7 +271,13 @@ function Invoke-ApplyFeatures {
|
|||||||
& $script:ApplyProgressCallback $step $TotalSteps $displayName
|
& $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++
|
$step++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,9 +288,8 @@ function Invoke-ApplyFeatures {
|
|||||||
Undoes a list of features, reporting progress for each.
|
Undoes a list of features, reporting progress for each.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Iterates through the provided feature IDs. Features with a RegistryUndoKey
|
Iterates through the provided feature IDs and delegates each to
|
||||||
are handled by importing the undo .reg file; all others delegate to
|
Invoke-FeatureUndo, which handles registry-backed and custom undo logic.
|
||||||
Invoke-FeatureUndo for custom undo logic.
|
|
||||||
This is called by Invoke-AllChanges during the undo phase.
|
This is called by Invoke-AllChanges during the undo phase.
|
||||||
#>
|
#>
|
||||||
function Invoke-UndoFeatures {
|
function Invoke-UndoFeatures {
|
||||||
@@ -286,11 +316,10 @@ function Invoke-UndoFeatures {
|
|||||||
& $script:ApplyProgressCallback $step $TotalSteps $undoText
|
& $script:ApplyProgressCallback $step $TotalSteps $undoText
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($f -and $f.RegistryUndoKey) {
|
if (-not (Invoke-FeatureUndo -FeatureId $featureId)) {
|
||||||
ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey)
|
$script:FeatureFailures++
|
||||||
}
|
}
|
||||||
|
Write-Host ""
|
||||||
Invoke-FeatureUndo -FeatureId $featureId
|
|
||||||
$step++
|
$step++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,8 +331,8 @@ function Invoke-UndoFeatures {
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Sequenced in four phases:
|
Sequenced in four phases:
|
||||||
1. Registry backup
|
1. Registry backup (skipped when SkipRegistryBackup is present)
|
||||||
2. System restore point
|
2. System restore point (skipped when CreateRestorePoint is absent)
|
||||||
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
|
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
|
||||||
4. Undo phase - undoes selected features via Invoke-UndoFeatures
|
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.
|
(used by the GUI modal). Cancellation is checked between each step.
|
||||||
#>
|
#>
|
||||||
function Invoke-AllChanges {
|
function Invoke-AllChanges {
|
||||||
|
if ($script:CancelRequested) { return }
|
||||||
|
|
||||||
# Guard: prevent running as SYSTEM account without explicit target user
|
# 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")) {
|
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."
|
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 ----
|
# ---- Gather work items ----
|
||||||
$applyIds = @()
|
$applyIds = @()
|
||||||
@@ -347,14 +380,15 @@ function Invoke-AllChanges {
|
|||||||
|
|
||||||
# ---- Calculate total progress steps ----
|
# ---- Calculate total progress steps ----
|
||||||
$totalSteps = $applyIds.Count + $undoIds.Count
|
$totalSteps = $applyIds.Count + $undoIds.Count
|
||||||
if ($needsBackup) { $totalSteps++ }
|
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ }
|
||||||
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
|
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
|
||||||
$step = 0
|
$step = 0
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Phase 1: Registry backup
|
# Phase 1: Registry backup
|
||||||
# ================================================================
|
# ================================================================
|
||||||
if ($needsBackup) {
|
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) {
|
||||||
|
if ($script:CancelRequested) { return }
|
||||||
$step++
|
$step++
|
||||||
if ($script:ApplyProgressCallback) {
|
if ($script:ApplyProgressCallback) {
|
||||||
& $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..."
|
& $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..."
|
||||||
@@ -384,6 +418,7 @@ function Invoke-AllChanges {
|
|||||||
# Phase 2: System restore point
|
# Phase 2: System restore point
|
||||||
# ================================================================
|
# ================================================================
|
||||||
if ($script:Params.ContainsKey("CreateRestorePoint")) {
|
if ($script:Params.ContainsKey("CreateRestorePoint")) {
|
||||||
|
if ($script:CancelRequested) { return }
|
||||||
$step++
|
$step++
|
||||||
if ($script:ApplyProgressCallback) {
|
if ($script:ApplyProgressCallback) {
|
||||||
& $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..."
|
& $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..."
|
||||||
@@ -394,7 +429,11 @@ function Invoke-AllChanges {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Write-Host "> Creating a system restore point..."
|
Write-Host "> Creating a system restore point..."
|
||||||
CreateSystemRestorePoint
|
$restorePointSucceeded = Invoke-SystemRestorePoint
|
||||||
|
if (-not $restorePointSucceeded) {
|
||||||
|
if ($script:CancelRequested) { return }
|
||||||
|
$script:FeatureFailures++
|
||||||
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,6 +446,8 @@ function Invoke-AllChanges {
|
|||||||
$step += $applyIds.Count
|
$step += $applyIds.Count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($script:CancelRequested) { return }
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Phase 4: Undo features
|
# 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 ""
|
||||||
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
|
.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.
|
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")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Restart the Windows Explorer process" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Restart the Windows Explorer process" -ForegroundColor Cyan
|
||||||
return
|
return
|
||||||
@@ -13,7 +13,7 @@ function RestartExplorer {
|
|||||||
|
|
||||||
Write-Host "> Attempting to restart the Windows Explorer process to apply all changes..."
|
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
|
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ function RestartExplorer {
|
|||||||
Write-Host "Warning: '$displayLabel' requires a reboot to take full effect" -ForegroundColor Yellow
|
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
|
# Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS
|
||||||
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
|
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
|
||||||
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
|
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
|
||||||
+26
-9
@@ -1,10 +1,25 @@
|
|||||||
function CreateSystemRestorePoint {
|
<#
|
||||||
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
|
.SYNOPSIS
|
||||||
$failed = $false
|
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
|
# 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 {
|
try {
|
||||||
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
|
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +41,6 @@ function CreateSystemRestorePoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Write-Host ""
|
|
||||||
$failed = $true
|
$failed = $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,17 +93,20 @@ function CreateSystemRestorePoint {
|
|||||||
|
|
||||||
if ($result -ne "Yes") {
|
if ($result -ne "Yes") {
|
||||||
$script:CancelRequested = $true
|
$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
|
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') {
|
if ($( Read-Host ) -ne 'y') {
|
||||||
$script:CancelRequested = $true
|
$script:CancelRequested = $true
|
||||||
return
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
|
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $true
|
||||||
}
|
}
|
||||||
+89
-2
@@ -248,6 +248,14 @@ function New-RegistryBackupAllowListPlanMap {
|
|||||||
return $planMap
|
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 {
|
function ConvertTo-RegistryValueNameSet {
|
||||||
param(
|
param(
|
||||||
[AllowEmptyCollection()]
|
[AllowEmptyCollection()]
|
||||||
@@ -259,9 +267,18 @@ function ConvertTo-RegistryValueNameSet {
|
|||||||
$null = $valueNameSet.Add([string]$valueName)
|
$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 {
|
function Test-RegistrySnapshotAgainstAllowList {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -300,6 +317,9 @@ function Test-RegistrySnapshotAgainstAllowList {
|
|||||||
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
|
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
|
||||||
$Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
|
$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)) {
|
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
|
||||||
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
|
$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 {
|
function Test-RegistryValueAllowedByPlan {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -428,6 +506,14 @@ function Get-NormalizedRegistryPathKey {
|
|||||||
return "$normalizedHive\\$normalizedSubKey"
|
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 {
|
function Test-RegistryValueKindNameSupported {
|
||||||
param(
|
param(
|
||||||
[string]$KindName
|
[string]$KindName
|
||||||
@@ -439,9 +525,10 @@ function Test-RegistryValueKindNameSupported {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
$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 {
|
catch {
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,12 +14,15 @@
|
|||||||
bundled with the script (Assets/Start/start2.bin).
|
bundled with the script (Assets/Start/start2.bin).
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
ReplaceStartMenuForAllUsers
|
Replace-StartMenuForAllUsers
|
||||||
|
|
||||||
.EXAMPLE
|
.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 (
|
param (
|
||||||
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
||||||
)
|
)
|
||||||
@@ -29,37 +32,49 @@ function ReplaceStartMenuForAllUsers {
|
|||||||
# Check if template bin file exists
|
# Check if template bin file exists
|
||||||
if (-not (Test-Path $startMenuTemplate)) {
|
if (-not (Test-Path $startMenuTemplate)) {
|
||||||
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
|
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
|
||||||
Write-Host ""
|
return $false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get path to start menu file for all users
|
# 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
|
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# Go through all users and replace the start menu file
|
# Go through all users and replace the start menu file
|
||||||
|
$success = $true
|
||||||
ForEach ($startMenuPath in $usersStartMenuPaths) {
|
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
|
# 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")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan
|
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
|
# Create folder if it doesn't exist
|
||||||
if (-not (Test-Path $defaultStartMenuPath)) {
|
if (-not (Test-Path $defaultStartMenuPath)) {
|
||||||
new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null
|
try {
|
||||||
Write-Host "Created LocalState folder for default user profile"
|
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
|
# Copy template to default profile
|
||||||
ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate
|
if (-not (Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
|
||||||
Write-Host "Replaced start menu for the default user profile"
|
$success = $false
|
||||||
Write-Host ""
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Replaced start menu for the default user profile"
|
||||||
|
}
|
||||||
|
return $success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -83,12 +98,15 @@ function ReplaceStartMenuForAllUsers {
|
|||||||
bundled with the script (Assets/Start/start2.bin).
|
bundled with the script (Assets/Start/start2.bin).
|
||||||
|
|
||||||
.EXAMPLE
|
.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
|
.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 (
|
param (
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$startMenuBinFile,
|
[string]$startMenuBinFile,
|
||||||
@@ -98,19 +116,19 @@ function ReplaceStartMenu {
|
|||||||
# Check if template bin file exists
|
# Check if template bin file exists
|
||||||
if (-not (Test-Path $startMenuTemplate)) {
|
if (-not (Test-Path $startMenuTemplate)) {
|
||||||
Write-Host "Error: Unable to replace start menu, template file not found" -ForegroundColor Red
|
Write-Host "Error: Unable to replace start menu, template file not found" -ForegroundColor Red
|
||||||
return
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") {
|
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
|
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")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan
|
||||||
return
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||||
@@ -118,20 +136,27 @@ function ReplaceStartMenu {
|
|||||||
$startMenuDir = Split-Path $startMenuBinFile -Parent
|
$startMenuDir = Split-Path $startMenuBinFile -Parent
|
||||||
$backupBinFile = Join-Path $startMenuDir $backupFileName
|
$backupBinFile = Join-Path $startMenuDir $backupFileName
|
||||||
|
|
||||||
if (Test-Path $startMenuBinFile) {
|
try {
|
||||||
# Backup current start menu file
|
if (Test-Path $startMenuBinFile) {
|
||||||
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force
|
# Backup current start menu file
|
||||||
Write-Verbose "Start menu backup for user $userName saved to $backupFileName"
|
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
|
else {
|
||||||
New-Item -ItemType File -Path $startMenuBinFile -Force
|
Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow
|
||||||
}
|
New-Item -ItemType File -Path $startMenuBinFile -Force -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
# Copy template file
|
# Copy 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"
|
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.
|
The target username. Pass an empty string or omit to resolve for the current user.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
GetStartMenuBinPathForUser -UserName "Jeff"
|
Get-StartMenuBinPathForUser -UserName "Jeff"
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
GetStartMenuBinPathForUser -UserName "Default"
|
Get-StartMenuBinPathForUser -UserName "Default"
|
||||||
#>
|
#>
|
||||||
function GetStartMenuBinPathForUser {
|
function Get-StartMenuBinPathForUser {
|
||||||
param(
|
param(
|
||||||
[string]$UserName
|
[string]$UserName
|
||||||
)
|
)
|
||||||
@@ -161,7 +186,7 @@ function GetStartMenuBinPathForUser {
|
|||||||
return "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
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.
|
The full path to a start2.bin file.
|
||||||
|
|
||||||
.EXAMPLE
|
.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(
|
param(
|
||||||
[string]$StartMenuBinFile
|
[string]$StartMenuBinFile
|
||||||
)
|
)
|
||||||
@@ -230,7 +255,7 @@ function Get-StartMenuBackupPath {
|
|||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
else {
|
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
|
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||||
foreach ($startMenuPath in $usersStartMenuPaths) {
|
foreach ($startMenuPath in $usersStartMenuPaths) {
|
||||||
$latestBackup = Get-ChildItem -Path (Join-Path $startMenuPath.FullName 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue |
|
$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.
|
finds the latest Win11Debloat-StartBackup-*.bak file.
|
||||||
|
|
||||||
.EXAMPLE
|
.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
|
.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(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$StartMenuBinFile,
|
[string]$StartMenuBinFile,
|
||||||
[string]$BackupFilePath
|
[string]$BackupFilePath
|
||||||
)
|
)
|
||||||
|
|
||||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
||||||
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
|
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
|
||||||
# Auto-detect latest backup in the same folder as the start2.bin
|
# Auto-detect latest backup in the same folder as the start2.bin
|
||||||
$startMenuDir = Split-Path $StartMenuBinFile -Parent
|
$startMenuDir = Split-Path $StartMenuBinFile -Parent
|
||||||
@@ -342,19 +367,19 @@ function RestoreStartMenuFromBackup {
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Resolves the start2.bin path for the currently logged-in user, then
|
Resolves the start2.bin path for the currently logged-in user, then
|
||||||
delegates to RestoreStartMenuFromBackup.
|
delegates to Restore-StartMenuFromBackup.
|
||||||
|
|
||||||
.PARAMETER BackupFilePath
|
.PARAMETER BackupFilePath
|
||||||
Path to the backup file to restore from. If omitted, automatically
|
Path to the backup file to restore from. If omitted, automatically
|
||||||
finds the latest Win11Debloat-StartBackup-*.bak file.
|
finds the latest Win11Debloat-StartBackup-*.bak file.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
RestoreStartMenu
|
Restore-StartMenu
|
||||||
|
|
||||||
.EXAMPLE
|
.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(
|
param(
|
||||||
[string]$BackupFilePath
|
[string]$BackupFilePath
|
||||||
)
|
)
|
||||||
@@ -364,7 +389,7 @@ function RestoreStartMenu {
|
|||||||
|
|
||||||
Write-Host "Restoring start menu for user $targetUserName from backup..."
|
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.
|
LocalState folder.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
RestoreStartMenuForAllUsers
|
Restore-StartMenuForAllUsers
|
||||||
|
|
||||||
.EXAMPLE
|
.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(
|
param(
|
||||||
[string]$BackupFilePath
|
[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
|
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||||
$results = @()
|
$results = @()
|
||||||
|
|
||||||
@@ -402,10 +427,10 @@ function RestoreStartMenuForAllUsers {
|
|||||||
|
|
||||||
foreach ($startMenuPath in $usersStartMenuPaths) {
|
foreach ($startMenuPath in $usersStartMenuPaths) {
|
||||||
$startMenuBinFile = Join-Path $startMenuPath.FullName 'start2.bin'
|
$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) {
|
if (Test-Path $defaultStartMenuPath) {
|
||||||
$defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin'
|
$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
|
||||||
|
}
|
||||||
+10
-7
@@ -12,9 +12,9 @@
|
|||||||
|
|
||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
PSCustomObject
|
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(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$FilePath
|
[string]$FilePath
|
||||||
@@ -31,7 +31,7 @@ function Load-RegistryBackupFromFile {
|
|||||||
throw "Failed to read backup file '$FilePath'. The file is not valid JSON."
|
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
|
ComputerName, Target, SelectedFeatures, SelectedUndoFeatures, and
|
||||||
RegistryKeys properties.
|
RegistryKeys properties.
|
||||||
#>
|
#>
|
||||||
function Normalize-RegistryBackup {
|
function ConvertTo-NormalizedRegistryBackup {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
$Backup
|
$Backup
|
||||||
@@ -93,7 +93,10 @@ function Normalize-RegistryBackup {
|
|||||||
}
|
}
|
||||||
elseif ($normalizedTarget -like 'CurrentUser:*') {
|
elseif ($normalizedTarget -like 'CurrentUser:*') {
|
||||||
$targetCurrentUserName = $normalizedTarget.Substring(12)
|
$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)) {
|
-not (Test-UserNameMatch -UserNameA $targetCurrentUserName -UserNameB $env:USERNAME)) {
|
||||||
$errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$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.
|
registry, loading the appropriate user hive when required.
|
||||||
|
|
||||||
.PARAMETER Backup
|
.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.
|
RegistryKeys snapshots should be restored.
|
||||||
|
|
||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
@@ -190,7 +193,7 @@ function Restore-RegistryBackupState {
|
|||||||
$Backup
|
$Backup
|
||||||
)
|
)
|
||||||
|
|
||||||
$friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
$friendlyTarget = Get-FriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
||||||
|
|
||||||
if ($script:Params.ContainsKey("WhatIf")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Restore registry backup for $friendlyTarget" -ForegroundColor Cyan
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+97
-38
@@ -10,22 +10,41 @@
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
DisableStoreSearchSuggestionsForAllUsers
|
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
|
# 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
|
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# Go through all users and disable start search suggestions
|
# Go through all users and disable start search suggestions
|
||||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
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
|
# Also disable start search suggestions for the default user profile
|
||||||
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
|
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||||
if ($defaultStoreDbPath) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +63,11 @@ function DisableStoreSearchSuggestionsForAllUsers {
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
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 (
|
param (
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$StoreAppsDatabase
|
[string]$StoreAppsDatabase
|
||||||
@@ -56,30 +78,35 @@ function DisableStoreSearchSuggestions {
|
|||||||
|
|
||||||
if ($script:Params.ContainsKey("WhatIf")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan
|
||||||
return
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
# This file doesn't exist in EEA (No Store app suggestions).
|
try {
|
||||||
if (-not (Test-Path -Path $StoreAppsDatabase))
|
# This file doesn't exist in EEA (No Store app suggestions).
|
||||||
{
|
if (-not (Test-Path -Path $StoreAppsDatabase)) {
|
||||||
Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow
|
Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow
|
||||||
|
|
||||||
$storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent
|
$storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent
|
||||||
|
if (-not (Test-Path -Path $storeDbDir)) {
|
||||||
|
New-Item -Path $storeDbDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-Path -Path $storeDbDir)) {
|
New-Item -Path $StoreAppsDatabase -ItemType File -Force -ErrorAction Stop | Out-Null
|
||||||
New-Item -Path $storeDbDir -ItemType Directory -Force | Out-Null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null
|
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
|
||||||
|
$Acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
|
||||||
|
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
|
||||||
|
$Acl.SetAccessRule($Ace) | 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
|
||||||
}
|
}
|
||||||
|
|
||||||
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
|
|
||||||
$Acl = Get-Acl -Path $StoreAppsDatabase
|
|
||||||
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
|
|
||||||
$Acl.SetAccessRule($Ace) | Out-Null
|
|
||||||
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl | Out-Null
|
|
||||||
|
|
||||||
Write-Host "Disabled Microsoft Store search suggestions for user $userName"
|
Write-Host "Disabled Microsoft Store search suggestions for user $userName"
|
||||||
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
<#
|
<#
|
||||||
@@ -94,22 +121,41 @@ function DisableStoreSearchSuggestions {
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
EnableStoreSearchSuggestionsForAllUsers
|
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
|
# 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
|
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# Go through all users and re-enable start search suggestions
|
# Go through all users and re-enable start search suggestions
|
||||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
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
|
# Also re-enable for the default user profile
|
||||||
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
|
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||||
if ($defaultStoreDbPath) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
<#
|
<#
|
||||||
@@ -127,8 +173,11 @@ function EnableStoreSearchSuggestionsForAllUsers {
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
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 (
|
param (
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$StoreAppsDatabase
|
[string]$StoreAppsDatabase
|
||||||
@@ -139,23 +188,31 @@ function EnableStoreSearchSuggestions {
|
|||||||
|
|
||||||
if ($script:Params.ContainsKey("WhatIf")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Re-enable Microsoft Store search suggestions for user $userName by restoring access to ${StoreAppsDatabase}" -ForegroundColor Cyan
|
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)) {
|
if (-not (Test-Path -Path $StoreAppsDatabase)) {
|
||||||
Write-Host "Store app database not found for user $userName, nothing to undo"
|
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.
|
# Ensure we can modify/delete the file even if restrictive ACLs were set.
|
||||||
$global:LASTEXITCODE = 0
|
$global:LASTEXITCODE = 0
|
||||||
takeown /F "$StoreAppsDatabase" /A | Out-Null
|
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
|
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
|
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$acl = Get-Acl -Path $StoreAppsDatabase
|
$acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
|
||||||
$denyRules = @(
|
$denyRules = @(
|
||||||
$acl.Access | Where-Object {
|
$acl.Access | Where-Object {
|
||||||
if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false }
|
if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false }
|
||||||
@@ -173,7 +230,7 @@ function EnableStoreSearchSuggestions {
|
|||||||
$null = $acl.RemoveAccessRuleSpecific($denyRule)
|
$null = $acl.RemoveAccessRuleSpecific($denyRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null
|
Set-Acl -Path $StoreAppsDatabase -AclObject $acl -ErrorAction Stop | Out-Null
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
|
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
|
||||||
@@ -182,9 +239,11 @@ function EnableStoreSearchSuggestions {
|
|||||||
try {
|
try {
|
||||||
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
|
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
|
||||||
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
|
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
|
||||||
|
return $true
|
||||||
}
|
}
|
||||||
catch {
|
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.
|
The target username. Pass an empty string or omit to resolve for the current user.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
GetStoreAppsDatabasePathForUser -UserName "Jeff"
|
Get-StoreAppsDatabasePathForUser -UserName "Jeff"
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
GetStoreAppsDatabasePathForUser -UserName "Default"
|
Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||||
#>
|
#>
|
||||||
function GetStoreAppsDatabasePathForUser {
|
function Get-StoreAppsDatabasePathForUser {
|
||||||
param(
|
param(
|
||||||
[string]$UserName
|
[string]$UserName
|
||||||
)
|
)
|
||||||
@@ -215,7 +274,7 @@ function GetStoreAppsDatabasePathForUser {
|
|||||||
return "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
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 {
|
function Test-StoreSearchSuggestionsDisabledForAllUsers {
|
||||||
$paths = @()
|
$paths = @()
|
||||||
|
|
||||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||||
$paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
|
$paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
|
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||||
if ($defaultStoreDbPath) {
|
if ($defaultStoreDbPath) {
|
||||||
$paths += $defaultStoreDbPath
|
$paths += $defaultStoreDbPath
|
||||||
}
|
}
|
||||||
+56
-12
@@ -34,21 +34,36 @@ function Get-TelemetryScheduledTasks {
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
Disable-TelemetryScheduledTasks
|
Disable-TelemetryScheduledTasks
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false.
|
||||||
#>
|
#>
|
||||||
function Disable-TelemetryScheduledTasks {
|
function Disable-TelemetryScheduledTasks {
|
||||||
Write-Host "> Disabling telemetry scheduled tasks..."
|
Write-Host "> Disabling telemetry scheduled tasks..."
|
||||||
$tasks = Get-TelemetryScheduledTasks
|
$tasks = Get-TelemetryScheduledTasks
|
||||||
|
|
||||||
|
$success = $true
|
||||||
foreach ($task in $tasks) {
|
foreach ($task in $tasks) {
|
||||||
|
if ($script:CancelRequested) { return $false }
|
||||||
|
|
||||||
if ($script:Params.ContainsKey("WhatIf")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = Invoke-NonBlocking -ScriptBlock {
|
try {
|
||||||
|
$result = Invoke-NonBlocking -ScriptBlock {
|
||||||
param($path, $name)
|
param($path, $name)
|
||||||
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
|
try {
|
||||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
|
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) {
|
if (-not $taskObj) {
|
||||||
return @{ Success = $true; Status = 'NotFound' }
|
return @{ Success = $true; Status = 'NotFound' }
|
||||||
}
|
}
|
||||||
@@ -62,17 +77,24 @@ function Disable-TelemetryScheduledTasks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return @{ Success = $true; Status = 'AlreadyDisabled' }
|
return @{ Success = $true; Status = 'AlreadyDisabled' }
|
||||||
} -ArgumentList @($task.Path, $task.Name)
|
} -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) {
|
switch ($result.Status) {
|
||||||
'Disabled' { Write-Host "Disabled Scheduled Task: $($task.Path)$($task.Name)" }
|
'Disabled' { Write-Host "Disabled Scheduled Task: $($task.Path)$($task.Name)" }
|
||||||
'AlreadyDisabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already disabled" -ForegroundColor DarkGray }
|
'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 }
|
'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
|
.EXAMPLE
|
||||||
Enable-TelemetryScheduledTasks
|
Enable-TelemetryScheduledTasks
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false.
|
||||||
#>
|
#>
|
||||||
function Enable-TelemetryScheduledTasks {
|
function Enable-TelemetryScheduledTasks {
|
||||||
Write-Host "> Enabling telemetry scheduled tasks..."
|
Write-Host "> Enabling telemetry scheduled tasks..."
|
||||||
$tasks = Get-TelemetryScheduledTasks
|
$tasks = Get-TelemetryScheduledTasks
|
||||||
|
|
||||||
|
$success = $true
|
||||||
foreach ($task in $tasks) {
|
foreach ($task in $tasks) {
|
||||||
|
if ($script:CancelRequested) { return $false }
|
||||||
|
|
||||||
if ($script:Params.ContainsKey("WhatIf")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = Invoke-NonBlocking -ScriptBlock {
|
try {
|
||||||
|
$result = Invoke-NonBlocking -ScriptBlock {
|
||||||
param($path, $name)
|
param($path, $name)
|
||||||
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
|
try {
|
||||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
|
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) {
|
if (-not $taskObj) {
|
||||||
return @{ Success = $true; Status = 'NotFound' }
|
return @{ Success = $true; Status = 'NotFound' }
|
||||||
}
|
}
|
||||||
@@ -114,15 +151,22 @@ function Enable-TelemetryScheduledTasks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return @{ Success = $true; Status = 'AlreadyEnabled' }
|
return @{ Success = $true; Status = 'AlreadyEnabled' }
|
||||||
} -ArgumentList @($task.Path, $task.Name)
|
} -ArgumentList @($task.Path, $task.Name)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
|
||||||
|
$success = $false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
switch ($result.Status) {
|
switch ($result.Status) {
|
||||||
'Enabled' { Write-Host "Enabled Scheduled Task: $($task.Path)$($task.Name)" }
|
'Enabled' { Write-Host "Enabled Scheduled Task: $($task.Path)$($task.Name)" }
|
||||||
'AlreadyEnabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already enabled." -ForegroundColor DarkGray }
|
'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 }
|
'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
|
# Returns a validated list of apps based on the provided appsList and the supported apps from Apps.json
|
||||||
function ValidateAppslist {
|
function Get-ValidatedAppList {
|
||||||
param (
|
param (
|
||||||
$appsList
|
$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 = @()
|
$validatedAppsList = @()
|
||||||
|
|
||||||
# Validate provided appsList against supportedAppsList
|
# Validate provided appsList against supportedAppsList
|
||||||
+31
-4
@@ -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 (
|
param (
|
||||||
[switch]$OnlyInstalled,
|
[switch]$OnlyInstalled,
|
||||||
[object[]]$InstalledList = $null,
|
[object[]]$InstalledList = $null,
|
||||||
@@ -17,8 +39,13 @@ function LoadAppsDetailsFromJson {
|
|||||||
|
|
||||||
foreach ($appData in $jsonContent.Apps) {
|
foreach ($appData in $jsonContent.Apps) {
|
||||||
# Handle AppId as array (could be single or multiple IDs)
|
# Handle AppId as array (could be single or multiple IDs)
|
||||||
$appIdArray = if ($appData.AppId -is [array]) { $appData.AppId } else { @($appData.AppId) }
|
$appIdArray = @(
|
||||||
$appIdArray = $appIdArray | ForEach-Object { $_.Trim() } | Where-Object { $_.length -gt 0 }
|
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 ($appIdArray.Count -eq 0) { continue }
|
||||||
|
|
||||||
if ($OnlyInstalled) {
|
if ($OnlyInstalled) {
|
||||||
+5
-3
@@ -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.
|
.SYNOPSIS
|
||||||
function LoadAppPresetsFromJson {
|
Returns preset names and application IDs from Apps.json, or an empty array when unavailable.
|
||||||
|
#>
|
||||||
|
function Import-AppPresetsFromJson {
|
||||||
try {
|
try {
|
||||||
$jsonContent = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
|
$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
|
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.
|
file does not exist or contains no selected-by-default apps.
|
||||||
#>
|
#>
|
||||||
function LoadAppsFromFile {
|
function Import-AppsFromFile {
|
||||||
param (
|
param (
|
||||||
$appsFilePath
|
$appsFilePath
|
||||||
)
|
)
|
||||||
@@ -41,6 +41,6 @@ function LoadAppsFromFile {
|
|||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Error "Unable to read apps list from file: $appsFilePath"
|
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
|
.SYNOPSIS
|
||||||
function LoadJsonFile {
|
Imports a JSON file, optionally validates its version, and returns $null on failure.
|
||||||
|
#>
|
||||||
|
function Import-JsonFile {
|
||||||
param (
|
param (
|
||||||
[string]$filePath,
|
[string]$filePath,
|
||||||
[string]$expectedVersion = $null,
|
[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 (
|
param (
|
||||||
[string]$filePath,
|
[string]$filePath,
|
||||||
[string]$expectedVersion = "1.0"
|
[string]$expectedVersion = "1.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
$settingsJson = LoadJsonFile -filePath $filePath -expectedVersion $expectedVersion
|
$settingsJson = Import-JsonFile -filePath $filePath -expectedVersion $expectedVersion
|
||||||
|
|
||||||
if (-not $settingsJson -or -not $settingsJson.Settings) {
|
if (-not $settingsJson -or -not $settingsJson.Settings) {
|
||||||
throw "Failed to load settings from $(Split-Path $filePath -Leaf)"
|
throw "Failed to load settings from $(Split-Path $filePath -Leaf)"
|
||||||
@@ -29,6 +32,6 @@ function LoadSettings {
|
|||||||
continue
|
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")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan
|
||||||
return
|
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-Output ""
|
||||||
Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red
|
Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-3
@@ -1,6 +1,20 @@
|
|||||||
# Applies settings from a JSON object to UI controls (checkboxes and comboboxes)
|
<#
|
||||||
# Used by LoadDefaultsBtn and LoadLastUsedBtn in the UI
|
.SYNOPSIS
|
||||||
function ApplySettingsToUiControls {
|
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 (
|
param (
|
||||||
$window,
|
$window,
|
||||||
$settingsJson,
|
$settingsJson,
|
||||||
+17
-7
@@ -1,10 +1,20 @@
|
|||||||
# Attaches shift-click selection behavior to a checkbox in an apps panel
|
<#
|
||||||
# Parameters:
|
.SYNOPSIS
|
||||||
# - $checkbox: The checkbox to attach the behavior to
|
Attaches shift-click range-selection behavior to an application checkbox.
|
||||||
# - $appsPanel: The StackPanel containing checkbox items
|
|
||||||
# - $lastSelectedCheckboxRef: A reference to a variable storing the last clicked checkbox
|
.PARAMETER Checkbox
|
||||||
# - $updateStatusCallback: Optional callback to update selection status
|
The checkbox that receives the mouse event handler.
|
||||||
function AttachShiftClickBehavior {
|
|
||||||
|
.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 (
|
param (
|
||||||
[System.Windows.Controls.CheckBox]$checkbox,
|
[System.Windows.Controls.CheckBox]$checkbox,
|
||||||
[System.Windows.Controls.StackPanel]$appsPanel,
|
[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 {
|
try {
|
||||||
$personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
$personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
||||||
|
|
||||||
@@ -174,6 +174,10 @@ function Update-AppSelectionStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Updates the app-removal scope description to match the selected ComboBoxItem.
|
||||||
|
#>
|
||||||
function Update-AppRemovalScopeDescription {
|
function Update-AppRemovalScopeDescription {
|
||||||
param(
|
param(
|
||||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
|
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
|
||||||
@@ -182,20 +186,60 @@ function Update-AppRemovalScopeDescription {
|
|||||||
|
|
||||||
$selectedItem = $AppRemovalScopeCombo.SelectedItem
|
$selectedItem = $AppRemovalScopeCombo.SelectedItem
|
||||||
if ($selectedItem) {
|
if ($selectedItem) {
|
||||||
switch ($selectedItem.Content) {
|
# Content is the display text and will change once translated; Name is stable.
|
||||||
"All users" {
|
switch ($selectedItem.Name) {
|
||||||
|
"AppRemovalScopeAllUsers" {
|
||||||
$AppRemovalScopeDescription.Text = "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users."
|
$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."
|
$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."
|
$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 {
|
function Invoke-AppPreset {
|
||||||
param(
|
param(
|
||||||
[System.Windows.Controls.Panel]$AppsPanel,
|
[System.Windows.Controls.Panel]$AppsPanel,
|
||||||
@@ -223,7 +267,7 @@ function Update-AppPresetStates {
|
|||||||
$script:UpdatingPresets = $true
|
$script:UpdatingPresets = $true
|
||||||
try {
|
try {
|
||||||
# Helper: count matching and checked apps, set checkbox state
|
# Helper: count matching and checked apps, set checkbox state
|
||||||
function SetPresetState($CheckBox, [scriptblock]$MatchFilter) {
|
function Set-PresetState($CheckBox, [scriptblock]$MatchFilter) {
|
||||||
$total = 0; $checked = 0
|
$total = 0; $checked = 0
|
||||||
foreach ($child in $AppsPanel.Children) {
|
foreach ($child in $AppsPanel.Children) {
|
||||||
if ($child -is [System.Windows.Controls.CheckBox]) {
|
if ($child -is [System.Windows.Controls.CheckBox]) {
|
||||||
@@ -241,15 +285,15 @@ function Update-AppPresetStates {
|
|||||||
$presetDefaultApps = $window.FindName('PresetDefaultApps')
|
$presetDefaultApps = $window.FindName('PresetDefaultApps')
|
||||||
$presetLastUsed = $window.FindName('PresetLastUsed')
|
$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) {
|
foreach ($jsonCb in $script:JsonPresetCheckboxes) {
|
||||||
$localIds = $jsonCb.PresetAppIds
|
$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)
|
# Last used preset: only update if it's visible (has saved apps)
|
||||||
if ($presetLastUsed.Visibility -ne 'Collapsed' -and $script:SavedAppIds) {
|
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 {
|
finally {
|
||||||
@@ -304,7 +348,29 @@ function Find-ParentScrollViewer {
|
|||||||
return $null
|
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(
|
param(
|
||||||
[System.Windows.Window]$Window,
|
[System.Windows.Window]$Window,
|
||||||
[System.Windows.Controls.Panel]$AppsPanel,
|
[System.Windows.Controls.Panel]$AppsPanel,
|
||||||
@@ -335,7 +401,7 @@ function Load-AppsWithList {
|
|||||||
$script:AppsListFilePath = $appsListFilePath
|
$script:AppsListFilePath = $appsListFilePath
|
||||||
. $helperScript
|
. $helperScript
|
||||||
. $loaderScript
|
. $loaderScript
|
||||||
LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
|
Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
|
||||||
} -ArgumentList $loaderScriptPath, $helperScriptPath, $appsFilePath, $ListOfApps, $onlyInstalled
|
} -ArgumentList $loaderScriptPath, $helperScriptPath, $appsFilePath, $ListOfApps, $onlyInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +501,7 @@ function Load-AppsWithList {
|
|||||||
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
|
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
|
||||||
-UserSelectionCombo $w.FindName('UserSelectionCombo')
|
-UserSelectionCombo $w.FindName('UserSelectionCombo')
|
||||||
})
|
})
|
||||||
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
|
Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
|
||||||
-lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) `
|
-lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) `
|
||||||
-updateStatusCallback {
|
-updateStatusCallback {
|
||||||
$w = $script:MainWindow
|
$w = $script:MainWindow
|
||||||
@@ -449,7 +515,7 @@ function Load-AppsWithList {
|
|||||||
|
|
||||||
$AppsPanel.Children.Add($checkbox) | Out-Null
|
$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')
|
$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(
|
param(
|
||||||
[System.Windows.Window]$Window,
|
[System.Windows.Window]$Window,
|
||||||
[System.Windows.Controls.Panel]$AppsPanel,
|
[System.Windows.Controls.Panel]$AppsPanel,
|
||||||
@@ -511,7 +596,7 @@ function Load-AppsIntoMainUI {
|
|||||||
# Force a render so the loading indicator is visible, then schedule the
|
# Force a render so the loading indicator is visible, then schedule the
|
||||||
# actual loading at Background priority so this call returns immediately.
|
# actual loading at Background priority so this call returns immediately.
|
||||||
# This is critical when called from Add_Loaded: the window must finish
|
# 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.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action] {})
|
||||||
$Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action] {
|
$Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action] {
|
||||||
try {
|
try {
|
||||||
@@ -519,7 +604,7 @@ function Load-AppsIntoMainUI {
|
|||||||
|
|
||||||
if ($OnlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
|
if ($OnlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
|
||||||
Write-Host "Retrieving installed apps via winget..."
|
Write-Host "Retrieving installed apps via winget..."
|
||||||
$listOfApps = GetInstalledAppsViaWinget -TimeOut 20 -NonBlocking
|
$listOfApps = Get-WingetInstalledApps -TimeOut 20 -NonBlocking
|
||||||
|
|
||||||
if ($null -eq $listOfApps) {
|
if ($null -eq $listOfApps) {
|
||||||
Write-Warning "WinGet returned no data (command timed out or failed)"
|
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
|
-LoadingAppsIndicator $LoadingAppsIndicator -ImportConfigBtn $ImportConfigBtn -ListOfApps $listOfApps
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -147,7 +147,20 @@ function Invoke-ShowChangesOverview {
|
|||||||
Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600
|
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(
|
param(
|
||||||
[System.Windows.Window]$Window,
|
[System.Windows.Window]$Window,
|
||||||
$SettingsJson
|
$SettingsJson
|
||||||
@@ -158,7 +171,7 @@ function Build-TweakPresetControlMap {
|
|||||||
return $presetMap
|
return $presetMap
|
||||||
}
|
}
|
||||||
|
|
||||||
# FeatureId -> control metadata, similar to ApplySettingsToUiControls lookup.
|
# FeatureId -> control metadata, similar to Apply-SettingsToUiControls lookup.
|
||||||
$featureIdIndex = @{}
|
$featureIdIndex = @{}
|
||||||
foreach ($controlName in $script:UiControlMappings.Keys) {
|
foreach ($controlName in $script:UiControlMappings.Keys) {
|
||||||
$control = $Window.FindName($controlName)
|
$control = $Window.FindName($controlName)
|
||||||
@@ -199,10 +212,23 @@ function Build-TweakPresetControlMap {
|
|||||||
return $presetMap
|
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(
|
param(
|
||||||
[System.Windows.Window]$Window,
|
[System.Windows.Window]$Window,
|
||||||
[string]$Category
|
[string]$CategoryId
|
||||||
)
|
)
|
||||||
|
|
||||||
$presetMap = @{}
|
$presetMap = @{}
|
||||||
@@ -210,7 +236,7 @@ function Build-CategoryTweakPresetMap {
|
|||||||
|
|
||||||
foreach ($controlName in $script:UiControlMappings.Keys) {
|
foreach ($controlName in $script:UiControlMappings.Keys) {
|
||||||
$mapping = $script:UiControlMappings[$controlName]
|
$mapping = $script:UiControlMappings[$controlName]
|
||||||
if ($mapping.Category -ne $Category) { continue }
|
if ($mapping.CategoryId -ne $CategoryId) { continue }
|
||||||
|
|
||||||
$control = $Window.FindName($controlName)
|
$control = $Window.FindName($controlName)
|
||||||
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
|
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
|
||||||
@@ -375,10 +401,10 @@ function Initialize-TweakPresetSources {
|
|||||||
$LastUsedSettingsJson
|
$LastUsedSettingsJson
|
||||||
)
|
)
|
||||||
|
|
||||||
$script:DefaultTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson
|
$script:DefaultTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson
|
||||||
$script:LastUsedTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson
|
$script:LastUsedTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson
|
||||||
$script:PrivacyTweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'Privacy & Suggested Content'
|
$script:PrivacyTweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId 'PrivacySuggestedContent'
|
||||||
$script:AITweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'AI'
|
$script:AITweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId 'AI'
|
||||||
|
|
||||||
$presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn')
|
$presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn')
|
||||||
if ($presetLastUsedTweaksBtn) {
|
if ($presetLastUsedTweaksBtn) {
|
||||||
@@ -421,7 +447,7 @@ function Update-UserSelectionDescription {
|
|||||||
|
|
||||||
switch ($UserSelectionCombo.SelectedIndex) {
|
switch ($UserSelectionCombo.SelectedIndex) {
|
||||||
0 {
|
0 {
|
||||||
$currentUserName = GetUserName
|
$currentUserName = Get-UserName
|
||||||
if ([string]::IsNullOrWhiteSpace($currentUserName)) {
|
if ([string]::IsNullOrWhiteSpace($currentUserName)) {
|
||||||
$UserSelectionDescription.Text = "The currently logged-in user profile"
|
$UserSelectionDescription.Text = "The currently logged-in user profile"
|
||||||
}
|
}
|
||||||
@@ -452,11 +478,14 @@ function Test-OtherUsername {
|
|||||||
[System.Windows.Window]$Window,
|
[System.Windows.Window]$Window,
|
||||||
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
|
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
|
||||||
[System.Windows.Controls.TextBox]$OtherUsernameTextBox,
|
[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
|
# Only validate if "Other User" is the deployment target, or "Target user only" is the app-removal scope
|
||||||
if ($UserSelectionCombo.SelectedIndex -ne 1) {
|
$isOtherUserSelected = ($UserSelectionCombo.SelectedIndex -eq 1)
|
||||||
|
$isAppRemovalTargetUserSelected = Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo
|
||||||
|
if (-not $isOtherUserSelected -and -not $isAppRemovalTargetUserSelected) {
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
# MainWindow-TweaksBuilder.ps1
|
# MainWindow-TweaksBuilder.ps1
|
||||||
# Dynamic tweaks UI construction from Features.json, tweak state management, selection clear, and search/highlight.
|
# 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(
|
param(
|
||||||
[System.Windows.Window]$Window,
|
[System.Windows.Window]$Window,
|
||||||
[int]$WinVersion
|
[int]$WinVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
|
$featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
|
||||||
|
|
||||||
if (-not $featuresJson) {
|
if (-not $featuresJson) {
|
||||||
throw "Unable to load Features.json file. The GUI cannot continue without feature definitions."
|
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:TweaksCompactMode = $null
|
||||||
$script:TweaksCardsMovedFromCol2 = @()
|
$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 only 2 items (No Change + one option), use a checkbox instead
|
||||||
if ($items.Count -eq 2) {
|
if ($items.Count -eq 2) {
|
||||||
$checkbox = New-Object System.Windows.Controls.CheckBox
|
$checkbox = New-Object System.Windows.Controls.CheckBox
|
||||||
@@ -96,7 +128,17 @@ function Build-DynamicTweaks {
|
|||||||
return $combo
|
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' }
|
if (-not $category) { return 'https://github.com/Raphire/Win11Debloat/wiki/Features' }
|
||||||
|
|
||||||
$slug = $category.ToLowerInvariant()
|
$slug = $category.ToLowerInvariant()
|
||||||
@@ -107,7 +149,17 @@ function Build-DynamicTweaks {
|
|||||||
return "https://github.com/Raphire/Win11Debloat/wiki/Features#$slug"
|
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
|
$categoryName = $categoryObj.Name
|
||||||
$categoryIcon = $categoryObj.Icon
|
$categoryIcon = $categoryObj.Icon
|
||||||
|
|
||||||
@@ -152,7 +204,7 @@ function Build-DynamicTweaks {
|
|||||||
$helpBtn = New-Object System.Windows.Controls.Button
|
$helpBtn = New-Object System.Windows.Controls.Button
|
||||||
$helpBtn.Content = $helpIcon
|
$helpBtn.Content = $helpIcon
|
||||||
$helpBtn.ToolTip = "Open the wiki for more info on '$categoryName' tweaks"
|
$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.Style = $Window.Resources['CategoryHelpLinkButtonStyle']
|
||||||
$helpBtn.Add_Click({
|
$helpBtn.Add_Click({
|
||||||
param($button, $e)
|
param($button, $e)
|
||||||
@@ -182,8 +234,17 @@ function Build-DynamicTweaks {
|
|||||||
foreach ($c in $featuresJson.Categories) {
|
foreach ($c in $featuresJson.Categories) {
|
||||||
$categoryName = if ($c -is [string]) { $c } else { $c.Name }
|
$categoryName = if ($c -is [string]) { $c } else { $c.Name }
|
||||||
if ($categoriesPresent.ContainsKey($categoryName)) {
|
if ($categoriesPresent.ContainsKey($categoryName)) {
|
||||||
# Store the full category object (or create one with default icon for string categories)
|
# Store the full category object (or create one with default icon for string categories).
|
||||||
$categoryObj = if ($c -is [string]) { @{Name = $c; Icon = '' } } else { $c }
|
# 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 = '' }
|
||||||
|
}
|
||||||
|
elseif (-not $c.CategoryId) {
|
||||||
|
@{Name = $c.Name; CategoryId = $c.Name; Icon = $c.Icon }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$c
|
||||||
|
}
|
||||||
$orderedCategories += $categoryObj
|
$orderedCategories += $categoryObj
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +252,7 @@ function Build-DynamicTweaks {
|
|||||||
else {
|
else {
|
||||||
# For backward compatibility, create category objects from keys
|
# For backward compatibility, create category objects from keys
|
||||||
foreach ($catName in $categoriesPresent.Keys) {
|
foreach ($catName in $categoriesPresent.Keys) {
|
||||||
$orderedCategories += @{Name = $catName; Icon = '' }
|
$orderedCategories += @{Name = $catName; CategoryId = $catName; Icon = '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +264,7 @@ function Build-DynamicTweaks {
|
|||||||
|
|
||||||
foreach ($categoryObj in $orderedCategories) {
|
foreach ($categoryObj in $orderedCategories) {
|
||||||
$categoryName = $categoryObj.Name
|
$categoryName = $categoryObj.Name
|
||||||
|
$categoryId = $categoryObj.CategoryId
|
||||||
|
|
||||||
# Card is created lazily on the first rendered item
|
# Card is created lazily on the first rendered item
|
||||||
$panel = $null
|
$panel = $null
|
||||||
@@ -289,8 +351,8 @@ function Build-DynamicTweaks {
|
|||||||
if ($soleFeature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($soleFeature.FeatureId -match '^Enable') { $opt = 'Enable' }
|
if ($soleFeature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($soleFeature.FeatureId -match '^Enable') { $opt = 'Enable' }
|
||||||
$items = @('No Change', $opt)
|
$items = @('No Change', $opt)
|
||||||
$comboName = ("Feature_{0}_Combo" -f $soleFeature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
|
$comboName = ("Feature_{0}_Combo" -f $soleFeature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
|
||||||
if (-not $panel) { $panel = GetOrCreateCategoryCard -categoryObj $categoryObj }
|
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
|
||||||
$combo = CreateLabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items
|
$combo = New-LabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items
|
||||||
# attach tooltip from Features.json if present
|
# attach tooltip from Features.json if present
|
||||||
if ($soleFeature.ToolTip -or $soleFeature.DisableWhenApplied -eq $true) {
|
if ($soleFeature.ToolTip -or $soleFeature.DisableWhenApplied -eq $true) {
|
||||||
$tooltipText = $soleFeature.ToolTip
|
$tooltipText = $soleFeature.ToolTip
|
||||||
@@ -307,15 +369,15 @@ function Build-DynamicTweaks {
|
|||||||
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
||||||
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = @('No Change') + ($filteredValues | ForEach-Object { $_.Label })
|
$items = @('No Change') + ($filteredValues | ForEach-Object { $_.Label })
|
||||||
$comboName = 'Group_{0}Combo' -f $group.GroupId
|
$comboName = 'Group_{0}Combo' -f $group.GroupId
|
||||||
if (-not $panel) { $panel = GetOrCreateCategoryCard -categoryObj $categoryObj }
|
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
|
||||||
$combo = CreateLabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items
|
$combo = New-LabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items
|
||||||
# attach tooltip from UiGroups if present
|
# attach tooltip from UiGroups if present
|
||||||
if ($group.ToolTip) {
|
if ($group.ToolTip) {
|
||||||
$tipBlock = New-Object System.Windows.Controls.TextBlock
|
$tipBlock = New-Object System.Windows.Controls.TextBlock
|
||||||
@@ -327,7 +389,7 @@ function Build-DynamicTweaks {
|
|||||||
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
||||||
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
|
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') {
|
elseif ($item.Type -eq 'feature') {
|
||||||
$feature = $item.Data
|
$feature = $item.Data
|
||||||
@@ -335,8 +397,8 @@ function Build-DynamicTweaks {
|
|||||||
if ($feature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($feature.FeatureId -match '^Enable') { $opt = 'Enable' }
|
if ($feature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($feature.FeatureId -match '^Enable') { $opt = 'Enable' }
|
||||||
$items = @('No Change', $opt)
|
$items = @('No Change', $opt)
|
||||||
$comboName = ("Feature_{0}_Combo" -f $feature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
|
$comboName = ("Feature_{0}_Combo" -f $feature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
|
||||||
if (-not $panel) { $panel = GetOrCreateCategoryCard -categoryObj $categoryObj }
|
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
|
||||||
$combo = CreateLabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items
|
$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
|
# attach tooltip from Features.json if present, and include the disabled-state reason
|
||||||
if ($feature.ToolTip -or $feature.DisableWhenApplied -eq $true) {
|
if ($feature.ToolTip -or $feature.DisableWhenApplied -eq $true) {
|
||||||
$tooltipText = $feature.ToolTip
|
$tooltipText = $feature.ToolTip
|
||||||
@@ -354,7 +416,7 @@ function Build-DynamicTweaks {
|
|||||||
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
||||||
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
|
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:UiControlMappings) { return }
|
||||||
if (-not $script:Features) { 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 }
|
if (-not $featuresJson) { return }
|
||||||
|
|
||||||
$groupMap = @{}
|
$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)
|
param([System.Windows.Window]$Window)
|
||||||
|
|
||||||
Update-CurrentTweakSystemState -Window $Window -ApplyToUi:$true
|
Update-CurrentTweakSystemState -Window $Window -ApplyToUi:$true
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
When $true, dark theme colors are applied; when $false, light theme colors.
|
When $true, dark theme colors are applied; when $false, light theme colors.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
SetWindowThemeResources -window $MainWindow -usesDarkMode $true
|
Set-WindowThemeResources -window $MainWindow -usesDarkMode $true
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
SetWindowThemeResources -window $Dialog -usesDarkMode $false
|
Set-WindowThemeResources -window $Dialog -usesDarkMode $false
|
||||||
#>
|
#>
|
||||||
# Sets resource colors for a WPF window based on dark mode preference
|
# Sets resource colors for a WPF window based on dark mode preference
|
||||||
function SetWindowThemeResources {
|
function Set-WindowThemeResources {
|
||||||
param (
|
param (
|
||||||
$window,
|
$window,
|
||||||
[bool]$usesDarkMode
|
[bool]$usesDarkMode
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Displays the themed About dialog for the application.
|
||||||
|
|
||||||
|
.PARAMETER Owner
|
||||||
|
The optional window that owns the dialog and its modal overlay.
|
||||||
|
#>
|
||||||
function Show-AboutDialog {
|
function Show-AboutDialog {
|
||||||
param (
|
param (
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
@@ -6,7 +13,7 @@ function Show-AboutDialog {
|
|||||||
|
|
||||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||||
|
|
||||||
$usesDarkMode = GetSystemUsesDarkMode
|
$usesDarkMode = Get-SystemUsesDarkMode
|
||||||
|
|
||||||
# Determine owner window
|
# Determine owner window
|
||||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||||
@@ -42,7 +49,7 @@ function Show-AboutDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Apply theme resources
|
# Apply theme resources
|
||||||
SetWindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
|
Set-WindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
|
||||||
|
|
||||||
# Get UI elements
|
# Get UI elements
|
||||||
$titleBar = $aboutWindow.FindName('TitleBar')
|
$titleBar = $aboutWindow.FindName('TitleBar')
|
||||||
|
|||||||
@@ -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 {
|
function Show-AppSelectionWindow {
|
||||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||||
|
|
||||||
$usesDarkMode = GetSystemUsesDarkMode
|
$usesDarkMode = Get-SystemUsesDarkMode
|
||||||
|
|
||||||
# Show overlay if main window exists
|
# Show overlay if main window exists
|
||||||
$overlay = $null
|
$overlay = $null
|
||||||
@@ -34,7 +40,7 @@ function Show-AppSelectionWindow {
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||||
|
|
||||||
$appsPanel = $window.FindName('AppsPanel')
|
$appsPanel = $window.FindName('AppsPanel')
|
||||||
$checkAllBox = $window.FindName('CheckAllBox')
|
$checkAllBox = $window.FindName('CheckAllBox')
|
||||||
@@ -46,8 +52,14 @@ function Show-AppSelectionWindow {
|
|||||||
# Track the last selected checkbox for shift-click range selection
|
# Track the last selected checkbox for shift-click range selection
|
||||||
$script:AppSelectionWindowLastSelectedCheckbox = $null
|
$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
|
# Show loading indicator
|
||||||
$loadingIndicator.Visibility = 'Visible'
|
$loadingIndicator.Visibility = 'Visible'
|
||||||
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{})
|
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{})
|
||||||
@@ -57,7 +69,7 @@ function Show-AppSelectionWindow {
|
|||||||
|
|
||||||
if ($onlyInstalledBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
|
if ($onlyInstalledBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
|
||||||
# Attempt to get a list of installed apps via WinGet, times out after 10 seconds
|
# 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) {
|
if ($null -eq $listOfApps) {
|
||||||
# Show error that the script was unable to get list of apps from WinGet
|
# 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
|
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
|
# Reset the last selected checkbox when loading a new list
|
||||||
$script:AppSelectionWindowLastSelectedCheckbox = $null
|
$script:AppSelectionWindowLastSelectedCheckbox = $null
|
||||||
@@ -82,7 +94,7 @@ function Show-AppSelectionWindow {
|
|||||||
$checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"]
|
$checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"]
|
||||||
|
|
||||||
# Attach shift-click behavior for range selection
|
# 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
|
$appsPanel.Children.Add($checkbox) | Out-Null
|
||||||
}
|
}
|
||||||
@@ -112,8 +124,8 @@ function Show-AppSelectionWindow {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$onlyInstalledBox.Add_Checked({ LoadApps })
|
$onlyInstalledBox.Add_Checked({ Load-Apps })
|
||||||
$onlyInstalledBox.Add_Unchecked({ LoadApps })
|
$onlyInstalledBox.Add_Unchecked({ Load-Apps })
|
||||||
|
|
||||||
$confirmBtn.Add_Click({
|
$confirmBtn.Add_Click({
|
||||||
$selectedApps = @()
|
$selectedApps = @()
|
||||||
@@ -130,7 +142,7 @@ function Show-AppSelectionWindow {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
|
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +153,7 @@ function Show-AppSelectionWindow {
|
|||||||
|
|
||||||
# Load apps after window is shown (allows UI to render first)
|
# Load apps after window is shown (allows UI to render first)
|
||||||
$window.Add_ContentRendered({
|
$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
|
# Show the window and return dialog result
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Displays the modal progress window while selected changes are applied.
|
||||||
|
|
||||||
|
.PARAMETER Owner
|
||||||
|
The optional window that owns the modal and its overlay.
|
||||||
|
|
||||||
|
.PARAMETER InvokeRestartExplorer
|
||||||
|
Indicates whether the modal should run the Explorer-restart flow after applying changes.
|
||||||
|
#>
|
||||||
function Show-ApplyModal {
|
function Show-ApplyModal {
|
||||||
param (
|
param (
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[System.Windows.Window]$Owner = $null,
|
[System.Windows.Window]$Owner = $null,
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[bool]$RestartExplorer = $false
|
[bool]$InvokeRestartExplorer = $false
|
||||||
)
|
)
|
||||||
|
|
||||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||||
|
|
||||||
$usesDarkMode = GetSystemUsesDarkMode
|
$usesDarkMode = Get-SystemUsesDarkMode
|
||||||
|
|
||||||
# Determine owner window
|
# Determine owner window
|
||||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||||
@@ -44,7 +54,7 @@ function Show-ApplyModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Apply theme resources
|
# Apply theme resources
|
||||||
SetWindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
|
Set-WindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
|
||||||
|
|
||||||
# Get UI elements
|
# Get UI elements
|
||||||
$script:ApplyInProgressPanel = $applyWindow.FindName('ApplyInProgressPanel')
|
$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 }
|
$pct = if ($totalSteps -gt 0) { [math]::Round((($currentStep - 1) / $totalSteps) * 100) } else { 0 }
|
||||||
$script:ApplyProgressBarEl.Value = $pct
|
$script:ApplyProgressBarEl.Value = $pct
|
||||||
# Process pending window messages to keep UI responsive
|
# 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
|
# 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
|
$stepFraction = ($subIndex / $subCount) / $totalSteps
|
||||||
$script:ApplyProgressBarEl.Value = [math]::Round(($baseProgress + $stepFraction) * 100)
|
$script:ApplyProgressBarEl.Value = [math]::Round(($baseProgress + $stepFraction) * 100)
|
||||||
}
|
}
|
||||||
DoEvents
|
Invoke-DoEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run changes in background to keep UI responsive
|
# Run changes in background to keep UI responsive
|
||||||
@@ -104,11 +114,12 @@ function Show-ApplyModal {
|
|||||||
try {
|
try {
|
||||||
Invoke-AllChanges
|
Invoke-AllChanges
|
||||||
|
|
||||||
$registryImportFailureCount = [int]$script:RegistryImportFailures
|
$failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures
|
||||||
|
$appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable
|
||||||
|
|
||||||
# Restart explorer if requested
|
# Restart explorer if requested
|
||||||
if ($RestartExplorer -and -not $script:CancelRequested) {
|
if ($InvokeRestartExplorer -and -not $script:CancelRequested) {
|
||||||
RestartExplorer
|
Invoke-RestartExplorer
|
||||||
|
|
||||||
# Wait for Explorer to finish relaunching, then reclaim focus.
|
# Wait for Explorer to finish relaunching, then reclaim focus.
|
||||||
Start-Sleep -Milliseconds 800
|
Start-Sleep -Milliseconds 800
|
||||||
@@ -118,11 +129,6 @@ function Show-ApplyModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
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
|
# Show completion state
|
||||||
$script:ApplyProgressBarEl.Value = 100
|
$script:ApplyProgressBarEl.Value = 100
|
||||||
@@ -130,20 +136,34 @@ function Show-ApplyModal {
|
|||||||
$script:ApplyCompletionPanel.Visibility = 'Visible'
|
$script:ApplyCompletionPanel.Visibility = 'Visible'
|
||||||
|
|
||||||
if ($script:CancelRequested) {
|
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.Text = [char]0xE7BA
|
||||||
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
|
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
|
||||||
$script:ApplyCompletionTitleEl.Text = "Cancelled"
|
$script:ApplyCompletionTitleEl.Text = "Cancelled"
|
||||||
$script:ApplyCompletionMessageEl.Text = "Script execution was cancelled by the user."
|
$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.Text = [char]0xE7BA
|
||||||
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
|
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
|
||||||
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors"
|
if ($failureCount -eq 0 -and $appRemovalVerificationUnavailable) {
|
||||||
$script:ApplyCompletionMessageEl.Text = "$registryImportFailureCount registry change(s) failed. See console for details."
|
$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 = "$failureCount change(s) failed. See console for details."
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Write-Host "All changes have been applied successfully!"
|
||||||
|
|
||||||
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
|
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
|
||||||
|
|
||||||
# Show completion message with reboot instructions if any applied features require reboot
|
# Show completion message with reboot instructions if any applied features require reboot
|
||||||
if ($RestartExplorer) {
|
if ($InvokeRestartExplorer) {
|
||||||
$rebootFeatures = Get-RebootFeatureLabels
|
$rebootFeatures = Get-RebootFeatureLabels
|
||||||
|
|
||||||
if ($rebootFeatures.Count -gt 0) {
|
if ($rebootFeatures.Count -gt 0) {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Shows a modal category-selection dialog for importing or exporting configuration.
|
||||||
|
#>
|
||||||
function Show-ImportExportConfigWindow {
|
function Show-ImportExportConfigWindow {
|
||||||
param (
|
param (
|
||||||
[System.Windows.Window]$Owner,
|
[System.Windows.Window]$Owner,
|
||||||
@@ -45,7 +49,7 @@ function Show-ImportExportConfigWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$dlg.Owner = $Owner
|
$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
|
# Copy the CheckBox default style from the main window so checkboxes get the themed template
|
||||||
try {
|
try {
|
||||||
@@ -215,6 +219,11 @@ function Get-DeploymentSettings {
|
|||||||
$deploySettings += @{ Name = 'CreateRestorePoint'; Value = [bool]$restorePointCheckBox.IsChecked }
|
$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')
|
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
|
||||||
if ($restartExplorerCheckBox) {
|
if ($restartExplorerCheckBox) {
|
||||||
$deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked }
|
$deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked }
|
||||||
@@ -268,6 +277,7 @@ function Get-DeploymentCategoryDetailString {
|
|||||||
|
|
||||||
$options = @()
|
$options = @()
|
||||||
if ($lookup.ContainsKey('CreateRestorePoint') -and [bool]$lookup['CreateRestorePoint']) { $options += 'Restore Point' }
|
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' }
|
if ($lookup.ContainsKey('RestartExplorer') -and [bool]$lookup['RestartExplorer']) { $options += 'Restart Explorer' }
|
||||||
|
|
||||||
$lines = @()
|
$lines = @()
|
||||||
@@ -308,7 +318,11 @@ function Build-CategoryDetails {
|
|||||||
return $details
|
return $details
|
||||||
}
|
}
|
||||||
|
|
||||||
function Apply-ImportedApplications {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Applies imported application selections to the application checkboxes.
|
||||||
|
#>
|
||||||
|
function Set-ImportedApplications {
|
||||||
param (
|
param (
|
||||||
[System.Windows.Controls.Panel]$AppsPanel,
|
[System.Windows.Controls.Panel]$AppsPanel,
|
||||||
[string[]]$AppIds
|
[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 (
|
param (
|
||||||
[System.Windows.Window]$Owner,
|
[System.Windows.Window]$Owner,
|
||||||
[hashtable]$UiControlMappings,
|
[hashtable]$UiControlMappings,
|
||||||
@@ -329,10 +347,14 @@ function Apply-ImportedTweakSettings {
|
|||||||
)
|
)
|
||||||
|
|
||||||
$settingsJson = [PSCustomObject]@{ Settings = @($TweakSettings) }
|
$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 (
|
param (
|
||||||
[System.Windows.Window]$Owner,
|
[System.Windows.Window]$Owner,
|
||||||
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
|
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
|
||||||
@@ -362,12 +384,23 @@ function Apply-ImportedDeploymentSettings {
|
|||||||
$restorePointCheckBox.IsChecked = [bool]$lookup['CreateRestorePoint']
|
$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')
|
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
|
||||||
if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) {
|
if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) {
|
||||||
$restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer']
|
$restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports selected application, tweak, and deployment settings to a configuration file.
|
||||||
|
#>
|
||||||
function Export-Configuration {
|
function Export-Configuration {
|
||||||
param (
|
param (
|
||||||
[System.Windows.Window]$Owner,
|
[System.Windows.Window]$Owner,
|
||||||
@@ -427,7 +460,7 @@ function Export-Configuration {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SaveToFile -Config $config -FilePath $saveDialog.FileName) {
|
if (Save-ToFile -Config $config -FilePath $saveDialog.FileName) {
|
||||||
Write-Host "Configuration exported successfully: $($saveDialog.FileName)"
|
Write-Host "Configuration exported successfully: $($saveDialog.FileName)"
|
||||||
Show-MessageBox -Message "Configuration exported successfully." -Title 'Export Configuration' -Button 'OK' -Icon 'Information' | Out-Null
|
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 {
|
function Import-Configuration {
|
||||||
param (
|
param (
|
||||||
[System.Windows.Window]$Owner,
|
[System.Windows.Window]$Owner,
|
||||||
@@ -462,27 +499,22 @@ function Import-Configuration {
|
|||||||
|
|
||||||
Write-Host "Importing configuration from '$($openDialog.FileName)'..."
|
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) {
|
if (-not $config) {
|
||||||
Write-Error "Failed to read configuration file '$($openDialog.FileName)'"
|
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
|
Show-MessageBox -Message "Failed to read configuration file" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $config.Version) {
|
$consistencyError = Test-ConfigConsistency -Config $config
|
||||||
Write-Error "Invalid configuration file format: '$($openDialog.FileName)'"
|
if ($consistencyError) {
|
||||||
Show-MessageBox -Message "Invalid configuration file format." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
$availableCategories = Get-AvailableImportExportCategories -Config $config
|
$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 ', ')"
|
Write-Host "Available categories in config: $($availableCategories -join ', ')"
|
||||||
|
|
||||||
$appCount = @($config.Apps | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }).Count
|
$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)."
|
Write-Host "Importing $($appIds.Count) app selection(s)."
|
||||||
Apply-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
|
Set-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
|
||||||
|
|
||||||
if ($OnAppsImported) {
|
if ($OnAppsImported) {
|
||||||
& $OnAppsImported
|
& $OnAppsImported
|
||||||
@@ -513,11 +545,11 @@ function Import-Configuration {
|
|||||||
if ($categories -contains 'System Tweaks' -and $config.Tweaks) {
|
if ($categories -contains 'System Tweaks' -and $config.Tweaks) {
|
||||||
$tweakCount = @($config.Tweaks).Count
|
$tweakCount = @($config.Tweaks).Count
|
||||||
Write-Host "Importing $tweakCount tweak(s)."
|
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) {
|
if ($categories -contains 'Deployment Settings' -and $config.Deployment) {
|
||||||
Write-Host 'Importing deployment settings.'
|
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.'
|
Write-Host 'Configuration imported successfully.'
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
function Show-MainWindow {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Creates and displays the main Win11Debloat window.
|
||||||
|
#>
|
||||||
|
function Show-MainWindow {
|
||||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null
|
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null
|
||||||
|
|
||||||
$WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
|
$WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
|
||||||
$usesDarkMode = GetSystemUsesDarkMode
|
$usesDarkMode = Get-SystemUsesDarkMode
|
||||||
|
|
||||||
# ---- Load XAML ----
|
# ---- Load XAML ----
|
||||||
$xaml = Get-Content -Path $script:MainWindowSchema -Raw
|
$xaml = Get-Content -Path $script:MainWindowSchema -Raw
|
||||||
@@ -14,7 +18,7 @@
|
|||||||
$reader.Close()
|
$reader.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||||
|
|
||||||
$mainBorder = $window.FindName('MainBorder')
|
$mainBorder = $window.FindName('MainBorder')
|
||||||
$titleBarBackground = $window.FindName('TitleBarBackground')
|
$titleBarBackground = $window.FindName('TitleBarBackground')
|
||||||
@@ -223,7 +227,7 @@
|
|||||||
if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false }
|
if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false }
|
||||||
|
|
||||||
# ---- Build JSON-defined app presets ----
|
# ---- Build JSON-defined app presets ----
|
||||||
foreach ($preset in (LoadAppPresetsFromJson)) {
|
foreach ($preset in (Import-AppPresetsFromJson)) {
|
||||||
$checkbox = New-Object System.Windows.Controls.CheckBox
|
$checkbox = New-Object System.Windows.Controls.CheckBox
|
||||||
$checkbox.Content = $preset.Name
|
$checkbox.Content = $preset.Name
|
||||||
$checkbox.IsThreeState = $true
|
$checkbox.IsThreeState = $true
|
||||||
@@ -301,8 +305,8 @@
|
|||||||
|
|
||||||
# ---- Load apps ----
|
# ---- Load apps ----
|
||||||
$appLoadStatusCallback = { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo }
|
$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_Checked({ Initialize-MainWindowApps -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_Unchecked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
|
||||||
|
|
||||||
# ---- App presets popup ----
|
# ---- App presets popup ----
|
||||||
$presetsPopup.Add_Opened({
|
$presetsPopup.Add_Opened({
|
||||||
@@ -573,6 +577,7 @@
|
|||||||
# ---- App removal scope combo ----
|
# ---- App removal scope combo ----
|
||||||
$appRemovalScopeCombo.Add_SelectionChanged({
|
$appRemovalScopeCombo.Add_SelectionChanged({
|
||||||
Update-AppRemovalScopeDescription -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeDescription $appRemovalScopeDescription
|
Update-AppRemovalScopeDescription -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeDescription $appRemovalScopeDescription
|
||||||
|
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- Other username text box ----
|
# ---- Other username text box ----
|
||||||
@@ -584,12 +589,12 @@
|
|||||||
$usernameTextBoxPlaceholder.Visibility = 'Collapsed'
|
$usernameTextBoxPlaceholder.Visibility = 'Collapsed'
|
||||||
}
|
}
|
||||||
Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription
|
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 ----
|
# ---- Validate target user helper ----
|
||||||
$ensureValidTargetUserOrWarn = {
|
$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)) {
|
$validationMessage = if (-not [string]::IsNullOrWhiteSpace($usernameValidationMessage.Text)) {
|
||||||
$usernameValidationMessage.Text
|
$usernameValidationMessage.Text
|
||||||
}
|
}
|
||||||
@@ -617,9 +622,9 @@
|
|||||||
$ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false
|
$ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||||
if ($defaultsJson) {
|
if ($defaultsJson) {
|
||||||
ApplySettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
|
Apply-SettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($script:IsLoadingApps) {
|
if ($script:IsLoadingApps) {
|
||||||
@@ -663,25 +668,23 @@
|
|||||||
$hasAppSelection = ($selectedApps.Count -gt 0)
|
$hasAppSelection = ($selectedApps.Count -gt 0)
|
||||||
|
|
||||||
if ($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'
|
$scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox
|
||||||
AddParameter 'Apps' ($selectedApps -join ',')
|
if ([string]::IsNullOrWhiteSpace($scopeTarget)) {
|
||||||
|
Write-Warning 'App removal was cancelled because the selected removal scope is invalid.'
|
||||||
$selectedScopeItem = $appRemovalScopeCombo.SelectedItem
|
return
|
||||||
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
|
# Apply dynamic tweaks
|
||||||
foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) {
|
foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) {
|
||||||
if ($tweakAction.Action -eq 'Apply') {
|
if ($tweakAction.Action -eq 'Apply') {
|
||||||
AddParameter $tweakAction.FeatureId
|
Add-Parameter $tweakAction.FeatureId
|
||||||
$null = $selectedForwardFeatureIds.Add([string]$tweakAction.FeatureId)
|
$null = $selectedForwardFeatureIds.Add([string]$tweakAction.FeatureId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -695,27 +698,32 @@
|
|||||||
|
|
||||||
$restorePointCheckBox = $window.FindName('RestorePointCheckBox')
|
$restorePointCheckBox = $window.FindName('RestorePointCheckBox')
|
||||||
if ($restorePointCheckBox -and $restorePointCheckBox.IsChecked) {
|
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) {
|
switch ($userSelectionCombo.SelectedIndex) {
|
||||||
0 { Write-Host "Selected user mode: current user ($(GetUserName))" }
|
0 { Write-Host "Selected user mode: current user ($(Get-UserName))" }
|
||||||
1 {
|
1 {
|
||||||
Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())"
|
Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())"
|
||||||
AddParameter User ($otherUsernameTextBox.Text.Trim())
|
Add-Parameter User ($otherUsernameTextBox.Text.Trim())
|
||||||
}
|
}
|
||||||
2 {
|
2 {
|
||||||
Write-Host "Selected user mode: default user profile (Sysprep)"
|
Write-Host "Selected user mode: default user profile (Sysprep)"
|
||||||
AddParameter Sysprep
|
Add-Parameter Sysprep
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SaveSettings
|
Save-Settings
|
||||||
|
|
||||||
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
|
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
|
||||||
$shouldRestartExplorer = $restartExplorerCheckBox -and $restartExplorerCheckBox.IsChecked
|
$shouldRestartExplorer = $restartExplorerCheckBox -and $restartExplorerCheckBox.IsChecked
|
||||||
|
|
||||||
Show-ApplyModal -Owner $window -RestartExplorer $shouldRestartExplorer
|
Show-ApplyModal -Owner $window -InvokeRestartExplorer $shouldRestartExplorer
|
||||||
$window.Close()
|
$window.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -737,12 +745,12 @@
|
|||||||
$window.Add_Loaded({
|
$window.Add_Loaded({
|
||||||
try {
|
try {
|
||||||
& $updateHomeContentPosition
|
& $updateHomeContentPosition
|
||||||
Build-DynamicTweaks -Window $window -WinVersion $WinVersion
|
New-DynamicTweakControls -Window $window -WinVersion $WinVersion
|
||||||
Load-CurrentTweakStateIntoUI -Window $window
|
Set-CurrentTweakStateInUi -Window $window
|
||||||
Update-TweaksResponsiveColumns -Window $window
|
Update-TweaksResponsiveColumns -Window $window
|
||||||
|
|
||||||
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
$lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
||||||
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||||
|
|
||||||
$script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson
|
$script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson
|
||||||
|
|
||||||
@@ -750,13 +758,13 @@
|
|||||||
Register-TweakPresetControlStateHandlers -Window $window
|
Register-TweakPresetControlStateHandlers -Window $window
|
||||||
Update-TweakPresetStates -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
|
# Update Current User label
|
||||||
if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) {
|
if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) {
|
||||||
$currentUserItem = $userSelectionCombo.Items[0]
|
$currentUserItem = $userSelectionCombo.Items[0]
|
||||||
if ($currentUserItem -is [System.Windows.Controls.ComboBoxItem]) {
|
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')
|
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
|
||||||
if ($restartExplorerCheckBox -and $script:Params.ContainsKey("NoRestartExplorer")) {
|
if ($restartExplorerCheckBox -and $script:Params.ContainsKey('SkipExplorerRestart')) {
|
||||||
$restartExplorerCheckBox.IsChecked = $false
|
$restartExplorerCheckBox.IsChecked = $false
|
||||||
$restartExplorerCheckBox.IsEnabled = $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")) {
|
if ($script:Params.ContainsKey("Sysprep")) {
|
||||||
$userSelectionCombo.SelectedIndex = 2
|
$userSelectionCombo.SelectedIndex = 2
|
||||||
$userSelectionCombo.IsEnabled = $false
|
$userSelectionCombo.IsEnabled = $false
|
||||||
@@ -809,8 +823,8 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ---- Tweak presets wiring ----
|
# ---- Tweak presets wiring ----
|
||||||
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
$lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
||||||
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||||
$script:DefaultTweakPresetMap = @{}
|
$script:DefaultTweakPresetMap = @{}
|
||||||
$script:LastUsedTweakPresetMap = @{}
|
$script:LastUsedTweakPresetMap = @{}
|
||||||
$script:PrivacyTweakPresetMap = @{}
|
$script:PrivacyTweakPresetMap = @{}
|
||||||
@@ -869,7 +883,7 @@
|
|||||||
|
|
||||||
# ---- Preload app data ----
|
# ---- Preload app data ----
|
||||||
try {
|
try {
|
||||||
$script:PreloadedAppData = LoadAppsDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false
|
$script:PreloadedAppData = Import-AppDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning "Failed to preload apps list: $_"
|
Write-Warning "Failed to preload apps list: $_"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# Shows a Windows 11 styled custom message box
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Shows a themed Windows 11-style message box.
|
||||||
|
#>
|
||||||
function Show-MessageBox {
|
function Show-MessageBox {
|
||||||
param (
|
param (
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
@@ -24,7 +27,7 @@ function Show-MessageBox {
|
|||||||
|
|
||||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
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
|
# Determine owner window - use provided Owner, or fall back to main GUI window
|
||||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||||
@@ -69,7 +72,7 @@ function Show-MessageBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Apply theme resources
|
# Apply theme resources
|
||||||
SetWindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
|
Set-WindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
|
||||||
|
|
||||||
# Get UI elements
|
# Get UI elements
|
||||||
$titleText = $msgWindow.FindName('TitleText')
|
$titleText = $msgWindow.FindName('TitleText')
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
Hashtable
|
Hashtable
|
||||||
Returns a Hashtable describing the user's choice. Possible shapes:
|
Returns a Hashtable describing the user's choice. Possible shapes:
|
||||||
RestoreRegistry - @{ Result='RestoreRegistry'; Backup=<normalizedBackup> }
|
RestoreRegistry - @{ Result='RestoreRegistry'; Backup=<normalizedBackup> }
|
||||||
RestoreStartMenu - @{ Result='RestoreStartMenu'; StartMenuScope=<scope>;
|
Restore-StartMenu - @{ Result='Restore-StartMenu'; StartMenuScope=<scope>;
|
||||||
UseManualBackupFile=<bool>; BackupFilePath=<path|string> }
|
UseManualBackupFile=<bool>; BackupFilePath=<path|string> }
|
||||||
Cancelled - @{ Result='Cancelled' } (from New-RestoreDialogState)
|
Cancelled - @{ Result='Cancelled' } (from New-RestoreDialogState)
|
||||||
#>
|
#>
|
||||||
@@ -26,7 +26,7 @@ function Show-RestoreBackupDialog {
|
|||||||
|
|
||||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||||
|
|
||||||
$usesDarkMode = GetSystemUsesDarkMode
|
$usesDarkMode = Get-SystemUsesDarkMode
|
||||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||||
|
|
||||||
$overlay = $null
|
$overlay = $null
|
||||||
@@ -67,7 +67,7 @@ function Show-RestoreBackupDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ function Show-RestoreBackupDialog {
|
|||||||
param([string]$BackupFilePath)
|
param([string]$BackupFilePath)
|
||||||
|
|
||||||
$scopeInfo = & $getStartMenuScopeInfo
|
$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."
|
$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
|
$backupFileText.Text = Split-Path -Path $BackupFilePath -Leaf
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ function Show-RestoreBackupDialog {
|
|||||||
|
|
||||||
$backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf
|
$backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf
|
||||||
$backupCreatedText.Text = $createdText
|
$backupCreatedText.Text = $createdText
|
||||||
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
|
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
|
||||||
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
|
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
|
||||||
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
|
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
|
||||||
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
|
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
|
||||||
@@ -318,7 +318,7 @@ function Show-RestoreBackupDialog {
|
|||||||
Write-Host "Backup file selected: $($openDialog.FileName)"
|
Write-Host "Backup file selected: $($openDialog.FileName)"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$selectedBackup = Load-RegistryBackupFromFile -FilePath $openDialog.FileName
|
$selectedBackup = Import-RegistryBackup -FilePath $openDialog.FileName
|
||||||
|
|
||||||
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
|
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
|
||||||
return
|
return
|
||||||
@@ -366,7 +366,7 @@ function Show-RestoreBackupDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$window.Tag = @{
|
$window.Tag = @{
|
||||||
Result = 'RestoreStartMenu'
|
Result = 'Restore-StartMenu'
|
||||||
StartMenuScope = $scope
|
StartMenuScope = $scope
|
||||||
UseManualBackupFile = $useManualBackupFile
|
UseManualBackupFile = $useManualBackupFile
|
||||||
BackupFilePath = $state.SelectedStartMenuBackupFilePath
|
BackupFilePath = $state.SelectedStartMenuBackupFilePath
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Shows the backup-restore dialog and performs the selected restore.
|
||||||
|
#>
|
||||||
function Show-RestoreBackupWindow {
|
function Show-RestoreBackupWindow {
|
||||||
param(
|
param(
|
||||||
[System.Windows.Window]$Owner = $null
|
[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
|
$scope = $dialogResult.StartMenuScope
|
||||||
$useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true)
|
$useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true)
|
||||||
$backupFilePath = $null
|
$backupFilePath = $null
|
||||||
@@ -54,10 +58,10 @@ function Show-RestoreBackupWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$result = if ($scope -eq 'AllUsers') {
|
$result = if ($scope -eq 'AllUsers') {
|
||||||
RestoreStartMenuForAllUsers -BackupFilePath $backupFilePath
|
Restore-StartMenuForAllUsers -BackupFilePath $backupFilePath
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
RestoreStartMenu -BackupFilePath $backupFilePath
|
Restore-StartMenu -BackupFilePath $backupFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
$resultEntries = @($result)
|
$resultEntries = @($result)
|
||||||
|
|||||||
+24
-13
@@ -7,8 +7,10 @@ param (
|
|||||||
[switch]$Sysprep,
|
[switch]$Sysprep,
|
||||||
[string]$LogPath,
|
[string]$LogPath,
|
||||||
[string]$User,
|
[string]$User,
|
||||||
[switch]$NoRestartExplorer,
|
[Alias('NoRestartExplorer')]
|
||||||
|
[switch]$SkipExplorerRestart,
|
||||||
[switch]$CreateRestorePoint,
|
[switch]$CreateRestorePoint,
|
||||||
|
[switch]$SkipRegistryBackup,
|
||||||
[switch]$RunDefaults,
|
[switch]$RunDefaults,
|
||||||
[switch]$RunDefaultsLite,
|
[switch]$RunDefaultsLite,
|
||||||
[switch]$RunSavedSettings,
|
[switch]$RunSavedSettings,
|
||||||
@@ -103,13 +105,12 @@ param (
|
|||||||
[switch]$HideDriveLetters
|
[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") {
|
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-Error "Win11Debloat is unable to run on your system, PowerShell execution is restricted by security policies"
|
||||||
Write-Output ""
|
Write-Output "Press any key to exit..."
|
||||||
Write-Output "Press enter to exit..."
|
$null = [System.Console]::ReadKey()
|
||||||
Read-Host | Out-Null
|
Exit 1
|
||||||
Exit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Clear-Host
|
Clear-Host
|
||||||
@@ -121,13 +122,13 @@ $tempRootPath = $env:TEMP
|
|||||||
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
|
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
|
||||||
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
|
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
|
||||||
|
|
||||||
Write-Output "> Downloading Win11Debloat..."
|
|
||||||
|
|
||||||
# Download Win11Debloat from GitHub as a zip archive.
|
# Download Win11Debloat from GitHub as a zip archive.
|
||||||
try {
|
try {
|
||||||
if ($Dev) {
|
if ($Dev) {
|
||||||
|
Write-Output "> Downloading development version of Win11Debloat..."
|
||||||
$sourceUri = "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip"
|
$sourceUri = "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip"
|
||||||
} else {
|
} else {
|
||||||
|
Write-Output "> Downloading Win11Debloat..."
|
||||||
$sourceUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
|
$sourceUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
|
||||||
}
|
}
|
||||||
Invoke-RestMethod $sourceUri -OutFile $tempArchivePath
|
Invoke-RestMethod $sourceUri -OutFile $tempArchivePath
|
||||||
@@ -137,7 +138,7 @@ catch {
|
|||||||
Write-Output ""
|
Write-Output ""
|
||||||
Write-Output "Press enter to exit..."
|
Write-Output "Press enter to exit..."
|
||||||
Read-Host | Out-Null
|
Read-Host | Out-Null
|
||||||
Exit
|
Exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove old script folder if it exists, but keep configs, logs and backups
|
# 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 ""
|
||||||
Write-Output "> Launching Win11Debloat..."
|
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) {
|
if ($arguments.Count -eq 0) {
|
||||||
$windowStyle = "Minimized"
|
$windowStyle = "Minimized"
|
||||||
}
|
}
|
||||||
@@ -213,7 +214,7 @@ else {
|
|||||||
$windowStyle = "Normal"
|
$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) {
|
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
||||||
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
|
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
|
||||||
$env:PSModulePath = $NewPSModulePath -join ';'
|
$env:PSModulePath = $NewPSModulePath -join ';'
|
||||||
@@ -221,11 +222,20 @@ if ($PSVersionTable.PSVersion.Major -ge 7) {
|
|||||||
|
|
||||||
# Run Win11Debloat script with the provided arguments
|
# Run Win11Debloat script with the provided arguments
|
||||||
$debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1'
|
$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
|
# Wait for the process to finish before continuing
|
||||||
if ($null -ne $debloatProcess) {
|
if ($null -ne $debloatProcess) {
|
||||||
$debloatProcess.WaitForExit()
|
$debloatProcess.WaitForExit()
|
||||||
|
$exitCode = $debloatProcess.ExitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove all remaining script files, except for configs, logs and backups
|
# Remove all remaining script files, except for configs, logs and backups
|
||||||
@@ -238,3 +248,4 @@ if (Test-Path $tempWorkPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Output ""
|
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 (
|
param (
|
||||||
$parameterName,
|
$parameterName,
|
||||||
$value = $true
|
$value = $true
|
||||||
+29
-21
@@ -11,53 +11,51 @@ function Get-NormalizedRegistryValueName {
|
|||||||
return [string]$ValueName
|
return [string]$ValueName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Converts a parsed .reg operation into a Name/Kind/Value set for RegistryKey.SetValue.
|
||||||
|
#>
|
||||||
function Convert-RegOperationToValueKind {
|
function Convert-RegOperationToValueKind {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
$Operation
|
$Operation
|
||||||
)
|
)
|
||||||
|
|
||||||
$valueName = if ([string]::IsNullOrEmpty([string]$Operation.ValueName)) { '' } else { [string]$Operation.ValueName }
|
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
|
||||||
$valueType = [string]$Operation.ValueType
|
$valueType = [string]$Operation.ValueType
|
||||||
$operationKeyPath = [string]$Operation.KeyPath
|
$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) {
|
switch ($valueType) {
|
||||||
'DWord' {
|
'DWord' {
|
||||||
$unsigned = [uint32]$Operation.ValueData
|
$unsigned = [uint32]$Operation.ValueData
|
||||||
$value = [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
|
$value = [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
|
||||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Value = $value }
|
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' {
|
'String' {
|
||||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::String; Value = [string]$Operation.ValueData }
|
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' {
|
'Binary' {
|
||||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Value = [byte[]]$Operation.ValueData }
|
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 {
|
default {
|
||||||
throw "Unsupported value type '$valueType' while applying reg operation for '$operationKeyPath'"
|
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 {
|
function Get-RegistryKeyForOperation {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[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 {
|
function Invoke-RegistryOperationsFromRegFile {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -205,7 +210,7 @@ function Invoke-RegistryOperationsFromRegFile {
|
|||||||
|
|
||||||
if ($script:Params.ContainsKey("WhatIf")) {
|
if ($script:Params.ContainsKey("WhatIf")) {
|
||||||
Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan
|
Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan
|
||||||
return
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($operation in $operations) {
|
foreach ($operation in $operations) {
|
||||||
@@ -224,5 +229,8 @@ function Invoke-RegistryOperationsFromRegFile {
|
|||||||
|
|
||||||
if ($accessDeniedCount -gt 0) {
|
if ($accessDeniedCount -gt 0) {
|
||||||
Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'."
|
Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'."
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $true
|
||||||
}
|
}
|
||||||
+5
-4
@@ -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),
|
.SYNOPSIS
|
||||||
# $false if the user declined any warning.
|
Confirms removal of applications that require an extra safety warning.
|
||||||
function ConfirmUnsafeAppRemoval {
|
#>
|
||||||
|
function Confirm-UnsafeAppRemoval {
|
||||||
param (
|
param (
|
||||||
[string[]]$SelectedApps,
|
[string[]]$SelectedApps,
|
||||||
$Owner = $null
|
$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])) {
|
if (-not ($script:Params["Apps"] -and $script:Params["Apps"] -is [string])) {
|
||||||
return @()
|
return @()
|
||||||
}
|
}
|
||||||
@@ -8,12 +11,12 @@ function GenerateAppsList {
|
|||||||
|
|
||||||
switch ($appMode) {
|
switch ($appMode) {
|
||||||
'default' {
|
'default' {
|
||||||
$appsList = LoadAppsFromFile $script:AppsListFilePath
|
$appsList = Import-AppsFromFile $script:AppsListFilePath
|
||||||
return $appsList
|
return $appsList
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
$appsList = $script:Params["Apps"].Split(',') | ForEach-Object { $_.Trim() }
|
$appsList = $script:Params["Apps"].Split(',') | ForEach-Object { $_.Trim() }
|
||||||
$validatedAppsList = ValidateAppslist $appsList
|
$validatedAppsList = Get-ValidatedAppList $appsList
|
||||||
return $validatedAppsList
|
return $validatedAppsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
function GetFriendlyRegistryBackupTarget {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Converts a registry-backup target identifier into a user-friendly label.
|
||||||
|
#>
|
||||||
|
function Get-FriendlyRegistryBackupTarget {
|
||||||
param(
|
param(
|
||||||
[AllowNull()]
|
[AllowNull()]
|
||||||
[AllowEmptyString()]
|
[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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,10 @@ function Get-RegFileOperations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$parsedValue = Convert-RegValueData -valueData $matches.valueData.Trim()
|
$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]@{
|
$operations += [PSCustomObject]@{
|
||||||
OperationType = $parsedValue.OperationType
|
OperationType = $parsedValue.OperationType
|
||||||
@@ -88,6 +91,10 @@ function Get-RegFileOperations {
|
|||||||
return $operations
|
return $operations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Converts a .reg value literal into an operation type, registry value type, and data.
|
||||||
|
#>
|
||||||
function Convert-RegValueData {
|
function Convert-RegValueData {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -121,8 +128,13 @@ function Convert-RegValueData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($valueData -match '^hex(?:\((?<kind>[0-9a-fA-F]+)\))?:(?<bytes>[0-9a-fA-F,\s]+)$') {
|
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' }
|
$valueType = if ($matches.kind) { "Hex$($matches.kind)" } else { 'Binary' }
|
||||||
|
|
||||||
$value = switch ($matches.kind) {
|
$value = switch ($matches.kind) {
|
||||||
'2' { Convert-RegistryByteArrayToString -byteData $bytes }
|
'2' { Convert-RegistryByteArrayToString -byteData $bytes }
|
||||||
'7' { Convert-RegistryByteArrayToMultiString -byteData $bytes }
|
'7' { Convert-RegistryByteArrayToMultiString -byteData $bytes }
|
||||||
@@ -150,16 +162,29 @@ function Convert-RegValueData {
|
|||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Converts a comma-separated hexadecimal byte string into a byte array.
|
||||||
|
#>
|
||||||
function Convert-HexStringToByteArray {
|
function Convert-HexStringToByteArray {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$hexValue
|
[string]$hexValue
|
||||||
)
|
)
|
||||||
|
|
||||||
$parts = $hexValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
$parts = @($hexValue.Split(',') | ForEach-Object { $_.Trim() })
|
||||||
return [System.Linq.Enumerable]::Select($parts, [Func[object, byte]] {
|
if ($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }) {
|
||||||
param($h) [System.Convert]::ToByte($h, 16)
|
return $null
|
||||||
}) -as [byte[]]
|
}
|
||||||
|
|
||||||
|
$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 {
|
function Convert-RegistryByteArrayToString {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Target is determined from $script:Params["AppRemovalTarget"] or defaults to "AllUsers"
|
# 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
|
# Target values: "AllUsers" (removes for all users + from image), "CurrentUser", or a specific username
|
||||||
function GetTargetUserForAppRemoval {
|
function Get-TargetUserForAppRemoval {
|
||||||
if ($script:Params.ContainsKey("AppRemovalTarget")) {
|
if ($script:Params.ContainsKey("AppRemovalTarget")) {
|
||||||
return $script:Params["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
|
# Returns the directory path of the specified user, exits script if user path can't be found
|
||||||
function GetUserDirectory {
|
function Get-UserDirectory {
|
||||||
param (
|
param (
|
||||||
$userName,
|
$userName,
|
||||||
$fileName = "",
|
$fileName = "",
|
||||||
@@ -29,7 +29,7 @@ function GetUserDirectory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$userContext = ResolveUserProfileContext -UserName $userName
|
$userContext = Resolve-UserProfileContext -UserName $userName
|
||||||
$resolvedUserDirectory = if ($userContext) { $userContext.ProfilePath } else { $null }
|
$resolvedUserDirectory = if ($userContext) { $userContext.ProfilePath } else { $null }
|
||||||
if ($resolvedUserDirectory) {
|
if ($resolvedUserDirectory) {
|
||||||
$userPath = if ([string]::IsNullOrWhiteSpace($fileName)) {
|
$userPath = if ([string]::IsNullOrWhiteSpace($fileName)) {
|
||||||
@@ -46,9 +46,9 @@ function GetUserDirectory {
|
|||||||
}
|
}
|
||||||
catch {
|
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"
|
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"
|
Write-Error "Unable to find user directory path for user $userName"
|
||||||
AwaitKeyToExit
|
Wait-ForKeyPress -ExitCode 1
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Returns the explicitly targeted user name or the current process user name.
|
||||||
|
#>
|
||||||
|
function Get-UserName {
|
||||||
|
if ($script:Params.ContainsKey("User")) {
|
||||||
|
return $script:Params.Item("User")
|
||||||
|
}
|
||||||
|
|
||||||
|
return $env:USERNAME
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
function GetFriendlyTargetUserName {
|
|
||||||
$target = GetTargetUserForAppRemoval
|
|
||||||
|
|
||||||
switch ($target) {
|
|
||||||
"AllUsers" { return "all users" }
|
|
||||||
"CurrentUser" { return "the current user" }
|
|
||||||
default { return "user $target" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
function GetUserName {
|
|
||||||
if ($script:Params.ContainsKey("User")) {
|
|
||||||
return $script:Params.Item("User")
|
|
||||||
}
|
|
||||||
|
|
||||||
return $env:USERNAME
|
|
||||||
}
|
|
||||||
+26
-12
@@ -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 (
|
param (
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$ConfigPath,
|
[string]$ConfigPath,
|
||||||
@@ -22,11 +26,16 @@ function ImportConfigToParams {
|
|||||||
throw "Provided config file must be a .json file: $resolvedConfigPath"
|
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) {
|
if ($null -eq $configJson) {
|
||||||
throw "Failed to read config file: $resolvedConfigPath"
|
throw "Failed to read config file: $resolvedConfigPath"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$consistencyError = Test-ConfigConsistency -Config $configJson
|
||||||
|
if ($consistencyError) {
|
||||||
|
throw "Invalid config file '$resolvedConfigPath': $consistencyError"
|
||||||
|
}
|
||||||
|
|
||||||
$importedItems = 0
|
$importedItems = 0
|
||||||
|
|
||||||
if ($configJson.Apps) {
|
if ($configJson.Apps) {
|
||||||
@@ -38,8 +47,8 @@ function ImportConfigToParams {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ($appIds.Count -gt 0) {
|
if ($appIds.Count -gt 0) {
|
||||||
AddParameter 'RemoveApps'
|
Add-Parameter 'RemoveApps'
|
||||||
AddParameter 'Apps' ($appIds -join ',')
|
Add-Parameter 'Apps' ($appIds -join ',')
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +68,7 @@ function ImportConfigToParams {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
AddParameter $setting.Name $true
|
Add-Parameter $setting.Name $true
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,12 +82,17 @@ function ImportConfigToParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($deploymentLookup.ContainsKey('CreateRestorePoint') -and [bool]$deploymentLookup['CreateRestorePoint']) {
|
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++
|
$importedItems++
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) {
|
if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) {
|
||||||
AddParameter 'NoRestartExplorer'
|
Add-Parameter 'SkipExplorerRestart'
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +101,12 @@ function ImportConfigToParams {
|
|||||||
1 {
|
1 {
|
||||||
$otherUserName = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
|
$otherUserName = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
|
||||||
if (-not [string]::IsNullOrWhiteSpace($otherUserName)) {
|
if (-not [string]::IsNullOrWhiteSpace($otherUserName)) {
|
||||||
AddParameter 'User' $otherUserName
|
Add-Parameter 'User' $otherUserName
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2 {
|
2 {
|
||||||
AddParameter 'Sysprep'
|
Add-Parameter 'Sysprep'
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,17 +115,17 @@ function ImportConfigToParams {
|
|||||||
if ($deploymentLookup.ContainsKey('AppRemovalScopeIndex') -and $script:Params.ContainsKey('RemoveApps')) {
|
if ($deploymentLookup.ContainsKey('AppRemovalScopeIndex') -and $script:Params.ContainsKey('RemoveApps')) {
|
||||||
switch ([int]$deploymentLookup['AppRemovalScopeIndex']) {
|
switch ([int]$deploymentLookup['AppRemovalScopeIndex']) {
|
||||||
0 {
|
0 {
|
||||||
AddParameter 'AppRemovalTarget' 'AllUsers'
|
Add-Parameter 'AppRemovalTarget' 'AllUsers'
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
1 {
|
1 {
|
||||||
AddParameter 'AppRemovalTarget' 'CurrentUser'
|
Add-Parameter 'AppRemovalTarget' 'CurrentUser'
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
2 {
|
2 {
|
||||||
$targetUser = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
|
$targetUser = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
|
||||||
if (-not [string]::IsNullOrWhiteSpace($targetUser)) {
|
if (-not [string]::IsNullOrWhiteSpace($targetUser)) {
|
||||||
AddParameter 'AppRemovalTarget' $targetUser
|
Add-Parameter 'AppRemovalTarget' $targetUser
|
||||||
$importedItems++
|
$importedItems++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Normalizes a rooted registry path and returns its hive and subkey components.
|
||||||
|
#>
|
||||||
function Split-RegistryPath {
|
function Split-RegistryPath {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[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 {
|
function Get-RegistryRootKey {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[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 {
|
function Get-RegistryFilePathForFeature {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
+65
-65
@@ -12,7 +12,7 @@
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function NormalizeUserLookupValue {
|
function Normalize-UserLookupValue {
|
||||||
param(
|
param(
|
||||||
[string]$Value
|
[string]$Value
|
||||||
)
|
)
|
||||||
@@ -44,12 +44,12 @@ if (-not $script:ResolvedUserSidCache) {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function GetUserLookupCacheKey {
|
function Get-UserLookupCacheKey {
|
||||||
param(
|
param(
|
||||||
[string]$Value
|
[string]$Value
|
||||||
)
|
)
|
||||||
|
|
||||||
$normalizedValue = NormalizeUserLookupValue -Value $Value
|
$normalizedValue = Normalize-UserLookupValue -Value $Value
|
||||||
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
|
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -72,13 +72,13 @@ function GetUserLookupCacheKey {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String[]
|
System.String[]
|
||||||
#>
|
#>
|
||||||
function GetNormalizedLookupCandidates {
|
function Get-NormalizedLookupCandidates {
|
||||||
param(
|
param(
|
||||||
[string[]]$Candidates
|
[string[]]$Candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
$normalized = @($Candidates) |
|
$normalized = @($Candidates) |
|
||||||
ForEach-Object { NormalizeUserLookupValue -Value $_ } |
|
ForEach-Object { Normalize-UserLookupValue -Value $_ } |
|
||||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||||
Select-Object -Unique
|
Select-Object -Unique
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ function GetNormalizedLookupCandidates {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function EscapeWqlString {
|
function Escape-WqlString {
|
||||||
param(
|
param(
|
||||||
[string]$Value
|
[string]$Value
|
||||||
)
|
)
|
||||||
@@ -126,22 +126,22 @@ function EscapeWqlString {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function GetLocalUserNameSegment {
|
function Get-LocalUserNameSegment {
|
||||||
param(
|
param(
|
||||||
[string]$UserName
|
[string]$UserName
|
||||||
)
|
)
|
||||||
|
|
||||||
$normalizedName = NormalizeUserLookupValue -Value $UserName
|
$normalizedName = Normalize-UserLookupValue -Value $UserName
|
||||||
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizedName.Contains('\')) {
|
if ($normalizedName.Contains('\')) {
|
||||||
return NormalizeUserLookupValue -Value (($normalizedName -split '\\')[-1])
|
return Normalize-UserLookupValue -Value (($normalizedName -split '\\')[-1])
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizedName.Contains('@')) {
|
if ($normalizedName.Contains('@')) {
|
||||||
return NormalizeUserLookupValue -Value (($normalizedName -split '@')[0])
|
return Normalize-UserLookupValue -Value (($normalizedName -split '@')[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return $normalizedName
|
return $normalizedName
|
||||||
@@ -165,12 +165,12 @@ function GetLocalUserNameSegment {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function ResolveNetBiosDomainName {
|
function Resolve-NetBiosDomainName {
|
||||||
param(
|
param(
|
||||||
[string]$RawDomain
|
[string]$RawDomain
|
||||||
)
|
)
|
||||||
|
|
||||||
$trimmed = NormalizeUserLookupValue -Value $RawDomain
|
$trimmed = Normalize-UserLookupValue -Value $RawDomain
|
||||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ function ResolveNetBiosDomainName {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($ntDomainInstance -and -not [string]::IsNullOrWhiteSpace($ntDomainInstance.DomainName)) {
|
if ($ntDomainInstance -and -not [string]::IsNullOrWhiteSpace($ntDomainInstance.DomainName)) {
|
||||||
$fromNtDomain = NormalizeUserLookupValue -Value $ntDomainInstance.DomainName
|
$fromNtDomain = Normalize-UserLookupValue -Value $ntDomainInstance.DomainName
|
||||||
if (-not [string]::IsNullOrWhiteSpace($fromNtDomain)) {
|
if (-not [string]::IsNullOrWhiteSpace($fromNtDomain)) {
|
||||||
return $fromNtDomain
|
return $fromNtDomain
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ function ResolveNetBiosDomainName {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($trimmed.Contains('.')) {
|
if ($trimmed.Contains('.')) {
|
||||||
$leaf = NormalizeUserLookupValue -Value (($trimmed -split '\.')[0])
|
$leaf = Normalize-UserLookupValue -Value (($trimmed -split '\.')[0])
|
||||||
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
|
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
|
||||||
return $leaf
|
return $leaf
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ function ResolveNetBiosDomainName {
|
|||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Cached in script scope for the process lifetime. Returns $false on
|
Cached in script scope for the process lifetime. Returns $false on
|
||||||
error or workgroup. When joined, also caches the NetBIOS domain label
|
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
|
.OUTPUTS
|
||||||
System.Boolean
|
System.Boolean
|
||||||
@@ -239,7 +239,7 @@ function Test-MachineIsDomainJoined {
|
|||||||
$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||||
if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) {
|
if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) {
|
||||||
$script:MachineIsDomainJoined = $true
|
$script:MachineIsDomainJoined = $true
|
||||||
$script:MachineNetBiosDomain = ResolveNetBiosDomainName -RawDomain ([string]$computerSystem.Domain)
|
$script:MachineNetBiosDomain = Resolve-NetBiosDomainName -RawDomain ([string]$computerSystem.Domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
@@ -262,7 +262,7 @@ function Test-MachineIsDomainJoined {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function GetProfileFolderDomainSuffix {
|
function Get-ProfileFolderDomainSuffix {
|
||||||
if (-not (Test-MachineIsDomainJoined)) {
|
if (-not (Test-MachineIsDomainJoined)) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -301,12 +301,12 @@ function GetProfileFolderDomainSuffix {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String[]
|
System.String[]
|
||||||
#>
|
#>
|
||||||
function GetUserNameMatchCandidates {
|
function Get-UserNameMatchCandidates {
|
||||||
param(
|
param(
|
||||||
[string]$Value
|
[string]$Value
|
||||||
)
|
)
|
||||||
|
|
||||||
$normalized = NormalizeUserLookupValue -Value $Value
|
$normalized = Normalize-UserLookupValue -Value $Value
|
||||||
if ([string]::IsNullOrWhiteSpace($normalized)) {
|
if ([string]::IsNullOrWhiteSpace($normalized)) {
|
||||||
return @()
|
return @()
|
||||||
}
|
}
|
||||||
@@ -314,13 +314,13 @@ function GetUserNameMatchCandidates {
|
|||||||
$candidates = New-Object 'System.Collections.Generic.List[string]'
|
$candidates = New-Object 'System.Collections.Generic.List[string]'
|
||||||
[void]$candidates.Add($normalized)
|
[void]$candidates.Add($normalized)
|
||||||
|
|
||||||
$localSegment = GetLocalUserNameSegment -UserName $normalized
|
$localSegment = Get-LocalUserNameSegment -UserName $normalized
|
||||||
if (-not [string]::IsNullOrWhiteSpace($localSegment) -and ($localSegment -ine $normalized)) {
|
if (-not [string]::IsNullOrWhiteSpace($localSegment) -and ($localSegment -ine $normalized)) {
|
||||||
[void]$candidates.Add($localSegment)
|
[void]$candidates.Add($localSegment)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Domain-suffixed forms only apply where Windows writes them.
|
# Domain-suffixed forms only apply where Windows writes them.
|
||||||
$domainSuffix = GetProfileFolderDomainSuffix
|
$domainSuffix = Get-ProfileFolderDomainSuffix
|
||||||
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
||||||
# Prefer the local segment as the stem so DOMAIN\user still yields
|
# Prefer the local segment as the stem so DOMAIN\user still yields
|
||||||
# user.CONTOSO rather than the fully qualified string.
|
# user.CONTOSO rather than the fully qualified string.
|
||||||
@@ -340,7 +340,7 @@ function GetUserNameMatchCandidates {
|
|||||||
# (registry, backup metadata), so add that form too.
|
# (registry, backup metadata), so add that form too.
|
||||||
$suffixWithDot = ".$domainSuffix"
|
$suffixWithDot = ".$domainSuffix"
|
||||||
if ($normalized.Length -gt $suffixWithDot.Length -and $normalized.EndsWith($suffixWithDot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
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)) {
|
if (-not [string]::IsNullOrWhiteSpace($bareStem)) {
|
||||||
$alreadyPresent = $false
|
$alreadyPresent = $false
|
||||||
foreach ($existing in $candidates) {
|
foreach ($existing in $candidates) {
|
||||||
@@ -361,7 +361,7 @@ function GetUserNameMatchCandidates {
|
|||||||
Test whether a user name and a profile folder leaf share an account.
|
Test whether a user name and a profile folder leaf share an account.
|
||||||
|
|
||||||
.DESCRIPTION
|
.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.
|
raw strings, so different forms of the same account still match.
|
||||||
|
|
||||||
.PARAMETER UserName
|
.PARAMETER UserName
|
||||||
@@ -383,8 +383,8 @@ function Test-UserNameMatchesProfileLeaf {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
$leafCandidates = @(GetUserNameMatchCandidates -Value $ProfileLeaf)
|
$leafCandidates = @(Get-UserNameMatchCandidates -Value $ProfileLeaf)
|
||||||
$userCandidates = @(GetUserNameMatchCandidates -Value $UserName)
|
$userCandidates = @(Get-UserNameMatchCandidates -Value $UserName)
|
||||||
|
|
||||||
foreach ($leaf in $leafCandidates) {
|
foreach ($leaf in $leafCandidates) {
|
||||||
foreach ($user in $userCandidates) {
|
foreach ($user in $userCandidates) {
|
||||||
@@ -430,13 +430,13 @@ function Test-UserNameMatch {
|
|||||||
|
|
||||||
# Workgroup: strict equality (no suffix disambiguation available).
|
# Workgroup: strict equality (no suffix disambiguation available).
|
||||||
if (-not (Test-MachineIsDomainJoined)) {
|
if (-not (Test-MachineIsDomainJoined)) {
|
||||||
$normalizedA = NormalizeUserLookupValue -Value $UserNameA
|
$normalizedA = Normalize-UserLookupValue -Value $UserNameA
|
||||||
$normalizedB = NormalizeUserLookupValue -Value $UserNameB
|
$normalizedB = Normalize-UserLookupValue -Value $UserNameB
|
||||||
return ($normalizedA -ieq $normalizedB)
|
return ($normalizedA -ieq $normalizedB)
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidatesA = @(GetUserNameMatchCandidates -Value $UserNameA)
|
$candidatesA = @(Get-UserNameMatchCandidates -Value $UserNameA)
|
||||||
$candidatesB = @(GetUserNameMatchCandidates -Value $UserNameB)
|
$candidatesB = @(Get-UserNameMatchCandidates -Value $UserNameB)
|
||||||
|
|
||||||
foreach ($a in $candidatesA) {
|
foreach ($a in $candidatesA) {
|
||||||
foreach ($b in $candidatesB) {
|
foreach ($b in $candidatesB) {
|
||||||
@@ -463,7 +463,7 @@ function Test-UserNameMatch {
|
|||||||
.PARAMETER Sid
|
.PARAMETER Sid
|
||||||
Resolved SID to cache.
|
Resolved SID to cache.
|
||||||
#>
|
#>
|
||||||
function SetResolvedUserSidCache {
|
function Set-ResolvedUserSidCache {
|
||||||
param(
|
param(
|
||||||
[string[]]$Candidates,
|
[string[]]$Candidates,
|
||||||
[string]$Sid
|
[string]$Sid
|
||||||
@@ -474,7 +474,7 @@ function SetResolvedUserSidCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($candidate in @($Candidates)) {
|
foreach ($candidate in @($Candidates)) {
|
||||||
$cacheKey = GetUserLookupCacheKey -Value $candidate
|
$cacheKey = Get-UserLookupCacheKey -Value $candidate
|
||||||
if ($cacheKey) {
|
if ($cacheKey) {
|
||||||
$script:ResolvedUserSidCache[$cacheKey] = $Sid
|
$script:ResolvedUserSidCache[$cacheKey] = $Sid
|
||||||
}
|
}
|
||||||
@@ -494,13 +494,13 @@ function SetResolvedUserSidCache {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function GetCachedResolvedUserSid {
|
function Get-CachedResolvedUserSid {
|
||||||
param(
|
param(
|
||||||
[string[]]$Candidates
|
[string[]]$Candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($candidate in @($Candidates)) {
|
foreach ($candidate in @($Candidates)) {
|
||||||
$cacheKey = GetUserLookupCacheKey -Value $candidate
|
$cacheKey = Get-UserLookupCacheKey -Value $candidate
|
||||||
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
|
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
|
||||||
return $script:ResolvedUserSidCache[$cacheKey]
|
return $script:ResolvedUserSidCache[$cacheKey]
|
||||||
}
|
}
|
||||||
@@ -523,7 +523,7 @@ function GetCachedResolvedUserSid {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function TryResolveSidByNtAccount {
|
function Try-ResolveSidByNtAccount {
|
||||||
param(
|
param(
|
||||||
[string]$UserName
|
[string]$UserName
|
||||||
)
|
)
|
||||||
@@ -560,12 +560,12 @@ function TryResolveSidByNtAccount {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function TryResolveSidByLocalLookup {
|
function Try-ResolveSidByLocalLookup {
|
||||||
param(
|
param(
|
||||||
[string[]]$Candidates
|
[string[]]$Candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
$lookupCandidates = GetNormalizedLookupCandidates -Candidates $Candidates
|
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
|
||||||
if ($lookupCandidates.Count -eq 0) {
|
if ($lookupCandidates.Count -eq 0) {
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
@@ -586,8 +586,8 @@ function TryResolveSidByLocalLookup {
|
|||||||
|
|
||||||
foreach ($candidate in $lookupCandidates) {
|
foreach ($candidate in $lookupCandidates) {
|
||||||
try {
|
try {
|
||||||
$escapedCandidate = EscapeWqlString -Value $candidate
|
$escapedCandidate = Escape-WqlString -Value $candidate
|
||||||
$escapedComputerName = EscapeWqlString -Value $env:COMPUTERNAME
|
$escapedComputerName = Escape-WqlString -Value $env:COMPUTERNAME
|
||||||
$filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')"
|
$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
|
$matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1
|
||||||
|
|
||||||
@@ -617,12 +617,12 @@ function TryResolveSidByLocalLookup {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function TryResolveSidFromProfileList {
|
function Try-ResolveSidFromProfileList {
|
||||||
param(
|
param(
|
||||||
[string[]]$Candidates
|
[string[]]$Candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
$lookupCandidates = GetNormalizedLookupCandidates -Candidates $Candidates
|
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
|
||||||
if ($lookupCandidates.Count -eq 0) {
|
if ($lookupCandidates.Count -eq 0) {
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
@@ -635,7 +635,7 @@ function TryResolveSidFromProfileList {
|
|||||||
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
|
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
|
||||||
|
|
||||||
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
|
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
|
||||||
$leafName = NormalizeUserLookupValue -Value (Split-Path -Leaf $expandedPath)
|
$leafName = Normalize-UserLookupValue -Value (Split-Path -Leaf $expandedPath)
|
||||||
|
|
||||||
foreach ($candidate in $lookupCandidates) {
|
foreach ($candidate in $lookupCandidates) {
|
||||||
if (Test-MachineIsDomainJoined) {
|
if (Test-MachineIsDomainJoined) {
|
||||||
@@ -680,7 +680,7 @@ function TryResolveSidFromProfileList {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.Management.Automation.PSCustomObject
|
System.Management.Automation.PSCustomObject
|
||||||
#>
|
#>
|
||||||
function NewResolvedUserContext {
|
function New-ResolvedUserContext {
|
||||||
param(
|
param(
|
||||||
[string]$UserName,
|
[string]$UserName,
|
||||||
[string]$UserSid,
|
[string]$UserSid,
|
||||||
@@ -708,12 +708,12 @@ function NewResolvedUserContext {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function GetQualifiedProcessIdentityName {
|
function Get-QualifiedProcessIdentityName {
|
||||||
param(
|
param(
|
||||||
[string]$Candidate
|
[string]$Candidate
|
||||||
)
|
)
|
||||||
|
|
||||||
$normalizedCandidate = NormalizeUserLookupValue -Value $Candidate
|
$normalizedCandidate = Normalize-UserLookupValue -Value $Candidate
|
||||||
if ([string]::IsNullOrWhiteSpace($normalizedCandidate)) {
|
if ([string]::IsNullOrWhiteSpace($normalizedCandidate)) {
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
@@ -735,7 +735,7 @@ function GetQualifiedProcessIdentityName {
|
|||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentLocalSegment = GetLocalUserNameSegment -UserName $currentName
|
$currentLocalSegment = Get-LocalUserNameSegment -UserName $currentName
|
||||||
if (-not [string]::IsNullOrWhiteSpace($currentLocalSegment) -and $currentLocalSegment -ieq $normalizedCandidate) {
|
if (-not [string]::IsNullOrWhiteSpace($currentLocalSegment) -and $currentLocalSegment -ieq $normalizedCandidate) {
|
||||||
return $currentName
|
return $currentName
|
||||||
}
|
}
|
||||||
@@ -762,19 +762,19 @@ function GetQualifiedProcessIdentityName {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.String
|
System.String
|
||||||
#>
|
#>
|
||||||
function ResolveUserSid {
|
function Resolve-UserSid {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$UserName
|
[string]$UserName
|
||||||
)
|
)
|
||||||
|
|
||||||
$candidateUserName = NormalizeUserLookupValue -Value $UserName
|
$candidateUserName = Normalize-UserLookupValue -Value $UserName
|
||||||
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
|
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
|
||||||
$localNameSegment = GetLocalUserNameSegment -UserName $candidateUserName
|
$localNameSegment = Get-LocalUserNameSegment -UserName $candidateUserName
|
||||||
$leafNameCandidates = @()
|
$leafNameCandidates = @()
|
||||||
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
|
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
|
||||||
$leafNameCandidates = @($localNameSegment)
|
$leafNameCandidates = @($localNameSegment)
|
||||||
@@ -796,7 +796,7 @@ function ResolveUserSid {
|
|||||||
@($candidateUserName)
|
@($candidateUserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
$cachedSid = GetCachedResolvedUserSid -Candidates $lookupCandidates
|
$cachedSid = Get-CachedResolvedUserSid -Candidates $lookupCandidates
|
||||||
if ($cachedSid) {
|
if ($cachedSid) {
|
||||||
return $cachedSid
|
return $cachedSid
|
||||||
}
|
}
|
||||||
@@ -811,12 +811,12 @@ function ResolveUserSid {
|
|||||||
}
|
}
|
||||||
elseif (Test-MachineIsDomainJoined) {
|
elseif (Test-MachineIsDomainJoined) {
|
||||||
# Prefer process identity (authoritative), then USERDOMAIN\input.
|
# Prefer process identity (authoritative), then USERDOMAIN\input.
|
||||||
$processQualifiedName = GetQualifiedProcessIdentityName -Candidate $candidateUserName
|
$processQualifiedName = Get-QualifiedProcessIdentityName -Candidate $candidateUserName
|
||||||
if (-not [string]::IsNullOrWhiteSpace($processQualifiedName)) {
|
if (-not [string]::IsNullOrWhiteSpace($processQualifiedName)) {
|
||||||
[void]$qualifiedNamesToTry.Add($processQualifiedName)
|
[void]$qualifiedNamesToTry.Add($processQualifiedName)
|
||||||
}
|
}
|
||||||
|
|
||||||
$domainSuffix = GetProfileFolderDomainSuffix
|
$domainSuffix = Get-ProfileFolderDomainSuffix
|
||||||
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
||||||
$domainQualifiedName = "$domainSuffix\$candidateUserName"
|
$domainQualifiedName = "$domainSuffix\$candidateUserName"
|
||||||
if (-not ($qualifiedNamesToTry -contains $domainQualifiedName)) {
|
if (-not ($qualifiedNamesToTry -contains $domainQualifiedName)) {
|
||||||
@@ -831,10 +831,10 @@ function ResolveUserSid {
|
|||||||
|
|
||||||
# Step 2: resolve qualified form(s) via NTAccount.Translate.
|
# Step 2: resolve qualified form(s) via NTAccount.Translate.
|
||||||
foreach ($qualifiedName in $qualifiedNamesToTry) {
|
foreach ($qualifiedName in $qualifiedNamesToTry) {
|
||||||
$resolvedSid = TryResolveSidByNtAccount -UserName $qualifiedName
|
$resolvedSid = Try-ResolveSidByNtAccount -UserName $qualifiedName
|
||||||
if ($resolvedSid) {
|
if ($resolvedSid) {
|
||||||
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
||||||
SetResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
||||||
return $resolvedSid
|
return $resolvedSid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -842,20 +842,20 @@ function ResolveUserSid {
|
|||||||
# Step 3: local SAM fallback (workgroup only; skipped on domain to avoid
|
# Step 3: local SAM fallback (workgroup only; skipped on domain to avoid
|
||||||
# nameshare shadowing).
|
# nameshare shadowing).
|
||||||
if (-not (Test-MachineIsDomainJoined)) {
|
if (-not (Test-MachineIsDomainJoined)) {
|
||||||
$resolvedSid = TryResolveSidByLocalLookup -Candidates $lookupCandidates
|
$resolvedSid = Try-ResolveSidByLocalLookup -Candidates $lookupCandidates
|
||||||
if ($resolvedSid) {
|
if ($resolvedSid) {
|
||||||
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
||||||
SetResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
||||||
return $resolvedSid
|
return $resolvedSid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 4: ProfileList leaf heuristic (last resort; disambiguates by
|
# Step 4: ProfileList leaf heuristic (last resort; disambiguates by
|
||||||
# on-disk folder name, suffix-aware on domain boxes).
|
# on-disk folder name, suffix-aware on domain boxes).
|
||||||
$resolvedSid = TryResolveSidFromProfileList -Candidates $profileHeuristicCandidates
|
$resolvedSid = Try-ResolveSidFromProfileList -Candidates $profileHeuristicCandidates
|
||||||
if ($resolvedSid) {
|
if ($resolvedSid) {
|
||||||
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
||||||
SetResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
||||||
return $resolvedSid
|
return $resolvedSid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,13 +878,13 @@ function ResolveUserSid {
|
|||||||
.OUTPUTS
|
.OUTPUTS
|
||||||
System.Management.Automation.PSCustomObject
|
System.Management.Automation.PSCustomObject
|
||||||
#>
|
#>
|
||||||
function ResolveUserProfileContext {
|
function Resolve-UserProfileContext {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$UserName
|
[string]$UserName
|
||||||
)
|
)
|
||||||
|
|
||||||
$candidateUserName = NormalizeUserLookupValue -Value $UserName
|
$candidateUserName = Normalize-UserLookupValue -Value $UserName
|
||||||
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
@@ -902,14 +902,14 @@ function ResolveUserProfileContext {
|
|||||||
|
|
||||||
$defaultProfilePath = Join-Path $rootPath 'Default'
|
$defaultProfilePath = Join-Path $rootPath 'Default'
|
||||||
if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) {
|
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
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
$userSid = ResolveUserSid -UserName $candidateUserName
|
$userSid = Resolve-UserSid -UserName $candidateUserName
|
||||||
|
|
||||||
if ($userSid) {
|
if ($userSid) {
|
||||||
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
|
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
|
||||||
@@ -919,7 +919,7 @@ function ResolveUserProfileContext {
|
|||||||
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
|
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
|
||||||
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
|
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
|
||||||
if (Test-Path -LiteralPath $expandedPath -PathType Container) {
|
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)
|
$matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop)
|
||||||
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
|
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
|
||||||
if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) {
|
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 {
|
catch {
|
||||||
@@ -948,7 +948,7 @@ function ResolveUserProfileContext {
|
|||||||
# Exact leaf match first (common case; avoids an unnecessary scan).
|
# Exact leaf match first (common case; avoids an unnecessary scan).
|
||||||
$candidateUserPath = Join-Path $rootPath $candidateUserName
|
$candidateUserPath = Join-Path $rootPath $candidateUserName
|
||||||
if (Test-Path -LiteralPath $candidateUserPath -PathType Container) {
|
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
|
# Only domain-joined boxes write suffixed folders; scanning workgroup
|
||||||
@@ -957,7 +957,7 @@ function ResolveUserProfileContext {
|
|||||||
try {
|
try {
|
||||||
foreach ($child in @(Get-ChildItem -LiteralPath $rootPath -Directory -ErrorAction SilentlyContinue)) {
|
foreach ($child in @(Get-ChildItem -LiteralPath $rootPath -Directory -ErrorAction SilentlyContinue)) {
|
||||||
if (Test-UserNameMatchesProfileLeaf -UserName $candidateUserName -ProfileLeaf $child.Name) {
|
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
-1
@@ -1,5 +1,5 @@
|
|||||||
# Check if this machine supports S0 Modern Standby power state. Returns true if S0 Modern Standby is supported, false otherwise.
|
# 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
|
$count = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,7 +23,7 @@ function Test-TargetUserName {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
|
if (-not (Test-UserProfileExists -userName $normalizedUserName)) {
|
||||||
return [PSCustomObject]@{
|
return [PSCustomObject]@{
|
||||||
IsValid = $false
|
IsValid = $false
|
||||||
UserName = $normalizedUserName
|
UserName = $normalizedUserName
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function CheckIfUserExists {
|
function Test-UserProfileExists {
|
||||||
param (
|
param (
|
||||||
[string]$userName
|
[string]$userName
|
||||||
)
|
)
|
||||||
@@ -10,7 +10,7 @@ function CheckIfUserExists {
|
|||||||
$lookupName = $userName.Trim()
|
$lookupName = $userName.Trim()
|
||||||
|
|
||||||
# Validate special characters against the local username segment (user in DOMAIN\user or user@domain).
|
# 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) {
|
if ($localUserName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
|
||||||
return $false
|
return $false
|
||||||
@@ -22,7 +22,7 @@ function CheckIfUserExists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$userContext = ResolveUserProfileContext -UserName $lookupName
|
$userContext = Resolve-UserProfileContext -UserName $lookupName
|
||||||
if (-not $userContext -or [string]::IsNullOrWhiteSpace($userContext.ProfilePath)) {
|
if (-not $userContext -or [string]::IsNullOrWhiteSpace($userContext.ProfilePath)) {
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
@@ -31,12 +31,12 @@ function Resolve-TargetUserHiveContext {
|
|||||||
[string]$TargetUserName
|
[string]$TargetUserName
|
||||||
)
|
)
|
||||||
|
|
||||||
$normalizedTargetUserName = NormalizeUserLookupValue -Value $TargetUserName
|
$normalizedTargetUserName = Normalize-UserLookupValue -Value $TargetUserName
|
||||||
if ([string]::IsNullOrWhiteSpace($normalizedTargetUserName)) {
|
if ([string]::IsNullOrWhiteSpace($normalizedTargetUserName)) {
|
||||||
throw 'Target user name for registry hive resolution is empty.'
|
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)) {
|
if (-not $userContext -or [string]::IsNullOrWhiteSpace([string]$userContext.ProfilePath)) {
|
||||||
throw "Unable to resolve profile path for target user '$normalizedTargetUserName'."
|
throw "Unable to resolve profile path for target user '$normalizedTargetUserName'."
|
||||||
}
|
}
|
||||||
@@ -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
|
# 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().
|
# during long-running operations on the UI thread. Equivalent to Application.Invoke-DoEvents().
|
||||||
function DoEvents {
|
function Invoke-DoEvents {
|
||||||
if (-not $script:GuiWindow) { return }
|
if (-not $script:GuiWindow) { return }
|
||||||
$frame = [System.Windows.Threading.DispatcherFrame]::new()
|
$frame = [System.Windows.Threading.DispatcherFrame]::new()
|
||||||
$null = [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
|
$null = [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
|
||||||
@@ -31,7 +31,7 @@ function Invoke-NonBlocking {
|
|||||||
$ps.Stop()
|
$ps.Stop()
|
||||||
throw "Operation timed out after $TimeoutSeconds seconds"
|
throw "Operation timed out after $TimeoutSeconds seconds"
|
||||||
}
|
}
|
||||||
DoEvents
|
Invoke-DoEvents
|
||||||
Start-Sleep -Milliseconds 16
|
Start-Sleep -Milliseconds 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user