Compare commits

...
25 Commits
Author SHA1 Message Date
Jeffrey fff1fcd0b2 update tooltips, labels and category for Desktop Spotlight feature 2026-08-22 02:31:59 +02:00
Ryan DuguidandGitHub 4a50e52be9 Add an option to hide the desktop Spotlight icon without disabling wallpaper rotation (#746) 2026-08-22 02:30:36 +02:00
JeffreyandGitHub 26d2bcb4a6 fix: improve error reporting when fetching files from GitHub (#753) 2026-08-21 22:15:05 +02:00
Ryan DuguidandGitHub 460eb75e1e Add FeatureId and launcher parameter contract tests (#751) 2026-08-21 21:43:55 +02:00
JeffreyandGitHub 1a26934499 Improve error handling in Set-StoreSearchSuggestionsDisabled (#739) 2026-08-16 18:49:15 +02:00
SashankandGitHub 5072958b10 fix: match tweak presets and app-removal scope by stable ID instead of translatable text (#737) 2026-08-16 17:52:58 +02:00
JeffreyandGitHub 31feaeb6f5 Fix ForceRemoveEdge switch (#736) 2026-08-12 21:10:47 +02:00
Jeffrey dd929f8eec Surface errors when writing to file 2026-08-12 17:31:50 +02:00
Jeffrey b21be418cd fix: force removal of empty LastUsedSettings.json file 2026-08-12 16:40:26 +02:00
Jeffrey 25fb9f3725 Improve GUI error messaging 2026-08-12 16:38:14 +02:00
JeffreyandGitHub d1338cd027 Enhance app removal verification and error reporting (#735) 2026-08-12 15:56:29 +02:00
Jeffrey 9505a5a374 Update README ExecutionPolicy override instructions 2026-08-12 12:14:26 +02:00
JeffreyandGitHub c0599cba1f feat: add timeout for WinGet uninstall to prevent hanging (#731) 2026-08-09 01:24:27 +02:00
5c838384d6 fix: remove Mark-of-the-Web from script files at startup (#724)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-08-09 01:24:14 +02:00
Jeffrey 6957ce4220 Update GUI logo 2026-08-01 16:16:24 +02:00
SashankandGitHub 0f30b62221 fix: support QWord, ExpandString and MultiString in the .reg fallback writer (#715) 2026-07-26 22:33:28 +02:00
JeffreyandGitHub 32cedaf65d Improve registry backup safety and add optional backup skipping (#710) 2026-07-25 20:05:49 +02:00
JeffreyandGitHub 68cacfce89 fix: force stop widget processes to prevent prompt (#713) 2026-07-23 22:23:00 +02:00
JeffreyandGitHub a9c1736e46 refactor: rename NoRestartExplorer parameter to SkipExplorerRestart (#709)
The old `NoRestartExplorer` parameter is kept as an alias and will
continue to work, but is considered deprecated
2026-07-19 22:58:03 +02:00
JeffreyandGitHub 9c033dbf98 Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708) 2026-07-19 22:06:07 +02:00
Hill PatelandGitHub a7292e4f35 fix(registry-restore): reject Data/Kind mismatches before restore begins (#704) 2026-07-19 01:53:04 +02:00
KushidaandGitHub f739a0e474 fix: escape Windows Terminal path (#702) 2026-07-18 15:16:39 +02:00
JeffreyandGitHub 63b1f61fd1 Update README.md 2026-07-17 12:45:45 +02:00
de817399e6 fix: honor cancellation during changes (#700)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-07-14 22:06:40 +02:00
Jeffrey 8fa1332ff0 Update download messages for development and stable versions of Win11Debloat 2026-07-14 21:18:39 +02:00
147 changed files with 7580 additions and 1272 deletions
+21 -2
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
name: Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
pester:
name: Pester (Windows PowerShell 5.1)
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run tests
shell: powershell
run: ./Scripts/Run-Tests.ps1 -Bootstrap
+80 -6
View File
@@ -2,55 +2,94 @@
"Version": "1.0", "Version": "1.0",
"Categories": [ "Categories": [
{ {
"CategoryId": "PrivacySuggestedContent",
"Name": "Privacy & Suggested Content", "Name": "Privacy & Suggested Content",
"Icon": "&#xE72E;" "Icon": "&#xE72E;"
}, },
{ {
"CategoryId": "System",
"Name": "System", "Name": "System",
"Icon": "&#xe770;" "Icon": "&#xe770;"
}, },
{ {
"CategoryId": "StartMenuSearch",
"Name": "Start Menu & Search", "Name": "Start Menu & Search",
"Icon": "&#xe8fc;" "Icon": "&#xe8fc;"
}, },
{ {
"CategoryId": "AI",
"Name": "AI", "Name": "AI",
"Icon": "&#xe794;" "Icon": "&#xe794;"
}, },
{ {
"CategoryId": "WindowsUpdate",
"Name": "Windows Update", "Name": "Windows Update",
"Icon": "&#xe895;" "Icon": "&#xe895;"
}, },
{ {
"CategoryId": "Taskbar",
"Name": "Taskbar", "Name": "Taskbar",
"Icon": "&#xe75b;" "Icon": "&#xe75b;"
}, },
{ {
"CategoryId": "Appearance",
"Name": "Appearance", "Name": "Appearance",
"Icon": "&#xE771;" "Icon": "&#xE771;"
}, },
{ {
"CategoryId": "FileExplorer",
"Name": "File Explorer", "Name": "File Explorer",
"Icon": "&#xec50;" "Icon": "&#xec50;"
}, },
{ {
"CategoryId": "Gaming",
"Name": "Gaming", "Name": "Gaming",
"Icon": "&#xE7FC;" "Icon": "&#xE7FC;"
}, },
{ {
"CategoryId": "MultiTasking",
"Name": "Multi-tasking", "Name": "Multi-tasking",
"Icon": "&#xE7C4;" "Icon": "&#xE7C4;"
}, },
{ {
"CategoryId": "OptionalWindowsFeatures",
"Name": "Optional Windows Features", "Name": "Optional Windows Features",
"Icon": "&#xefda;" "Icon": "&#xefda;"
}, },
{ {
"CategoryId": "Other",
"Name": "Other", "Name": "Other",
"Icon": "&#xE713;" "Icon": "&#xE713;"
} }
], ],
"UiGroups": [ "UiGroups": [
{
"GroupId": "DesktopSpotlight",
"Label": "Windows spotlight on the desktop",
"ToolTip": "This setting controls the Windows spotlight desktop background option. Keep the rotating wallpaper with or without the 'Learn about this picture' shortcut, or disable Windows spotlight entirely.",
"Category": "Appearance",
"Priority": 4,
"Values": [
{
"Label": "Show 'Learn about this picture' shortcut (Default)",
"FeatureIds": [
"EnableDesktopSpotlight"
]
},
{
"Label": "Hide 'Learn about this picture' shortcut",
"FeatureIds": [
"HideDesktopSpotlightIcon"
]
},
{
"Label": "Disable Windows spotlight desktop background",
"FeatureIds": [
"DisableDesktopSpotlight"
]
}
]
},
{ {
"GroupId": "SearchIcon", "GroupId": "SearchIcon",
"Label": "Taskbar search style", "Label": "Taskbar search style",
@@ -380,6 +419,7 @@
"Label": "Disable telemetry, tracking & targeted ads", "Label": "Disable telemetry, tracking & targeted ads",
"ToolTip": "This setting disables telemetry, diagnostic data collection, activity history, app-launch tracking, targeted ads and more. It limits the data that is sent to Microsoft about your device and usage. If you are a Windows Insider, updates may be blocked until optional diagnostic data collection is turned back on.", "ToolTip": "This setting disables telemetry, diagnostic data collection, activity history, app-launch tracking, targeted ads and more. It limits the data that is sent to Microsoft about your device and usage. If you are a Windows Insider, updates may be blocked until optional diagnostic data collection is turned back on.",
"Category": "Privacy & Suggested Content", "Category": "Privacy & Suggested Content",
"Priority": 1,
"RegistryKey": "Disable_Telemetry.reg", "RegistryKey": "Disable_Telemetry.reg",
"ApplyText": "Disabling telemetry and diagnostic data collection", "ApplyText": "Disabling telemetry and diagnostic data collection",
"UndoLabel": "Enable telemetry, tracking & targeted ads", "UndoLabel": "Enable telemetry, tracking & targeted ads",
@@ -393,6 +433,7 @@
"Label": "Disable tips, tricks & suggested content throughout Windows", "Label": "Disable tips, tricks & suggested content throughout Windows",
"ToolTip": "This setting removes many annoying distractions from Windows. This includes things like notifications, reminders and sync provider ads. It also prevents automated installation of suggested apps.", "ToolTip": "This setting removes many annoying distractions from Windows. This includes things like notifications, reminders and sync provider ads. It also prevents automated installation of suggested apps.",
"Category": "Privacy & Suggested Content", "Category": "Privacy & Suggested Content",
"Priority": 2,
"RegistryKey": "Disable_Windows_Suggestions.reg", "RegistryKey": "Disable_Windows_Suggestions.reg",
"ApplyText": "Disabling tips, tricks, suggestions and ads throughout Windows", "ApplyText": "Disabling tips, tricks, suggestions and ads throughout Windows",
"UndoLabel": "Enable tips, tricks & suggested content throughout Windows", "UndoLabel": "Enable tips, tricks & suggested content throughout Windows",
@@ -406,6 +447,7 @@
"Label": "Disable Windows Notifications (From apps and other senders)", "Label": "Disable Windows Notifications (From apps and other senders)",
"ToolTip": "Disables native Windows notifications from apps and other senders, this includes notifications from apps like Discord, WhatsApp, Teams, and Slack.", "ToolTip": "Disables native Windows notifications from apps and other senders, this includes notifications from apps like Discord, WhatsApp, Teams, and Slack.",
"Category": "Privacy & Suggested Content", "Category": "Privacy & Suggested Content",
"Priority": 3,
"RegistryKey": "Disable_Notifications.reg", "RegistryKey": "Disable_Notifications.reg",
"ApplyText": "Disabling Windows notifications and reminders", "ApplyText": "Disabling Windows notifications and reminders",
"UndoLabel": "Enable Windows notifications and reminders", "UndoLabel": "Enable Windows notifications and reminders",
@@ -419,6 +461,7 @@
"Label": "Disable Windows location services & app location access", "Label": "Disable Windows location services & app location access",
"ToolTip": "This will turn off Windows Location Services and deny apps access to your location. This feature uses policies, which will lock down certain settings.", "ToolTip": "This will turn off Windows Location Services and deny apps access to your location. This feature uses policies, which will lock down certain settings.",
"Category": "Privacy & Suggested Content", "Category": "Privacy & Suggested Content",
"Priority": 4,
"RegistryKey": "Disable_Location_Services.reg", "RegistryKey": "Disable_Location_Services.reg",
"ApplyText": "Disabling Windows location services and app location access", "ApplyText": "Disabling Windows location services and app location access",
"UndoLabel": "Enable Windows location services & app location access", "UndoLabel": "Enable Windows location services & app location access",
@@ -432,6 +475,7 @@
"Label": "Disable Find My Device location tracking", "Label": "Disable Find My Device location tracking",
"ToolTip": "This will turn off the 'Find My Device' feature, which periodically sends your device's location to Microsoft. This feature uses policies, which will lock down certain settings.", "ToolTip": "This will turn off the 'Find My Device' feature, which periodically sends your device's location to Microsoft. This feature uses policies, which will lock down certain settings.",
"Category": "Privacy & Suggested Content", "Category": "Privacy & Suggested Content",
"Priority": 5,
"RegistryKey": "Disable_Find_My_Device.reg", "RegistryKey": "Disable_Find_My_Device.reg",
"ApplyText": "Disabling Find My Device location tracking", "ApplyText": "Disabling Find My Device location tracking",
"UndoLabel": "Enable Find My Device location tracking", "UndoLabel": "Enable Find My Device location tracking",
@@ -445,6 +489,7 @@
"Label": "Disable tips & tricks on the lock screen", "Label": "Disable tips & tricks on the lock screen",
"ToolTip": "This will turn off the lockscreen spotlight option and disable the tips, tricks and fun facts that appear on the lock screen.", "ToolTip": "This will turn off the lockscreen spotlight option and disable the tips, tricks and fun facts that appear on the lock screen.",
"Category": "Privacy & Suggested Content", "Category": "Privacy & Suggested Content",
"Priority": 6,
"RegistryKey": "Disable_Lockscreen_Tips.reg", "RegistryKey": "Disable_Lockscreen_Tips.reg",
"ApplyText": "Disabling tips & tricks on the lock screen", "ApplyText": "Disabling tips & tricks on the lock screen",
"UndoLabel": "Enable tips & tricks on the lock screen", "UndoLabel": "Enable tips & tricks on the lock screen",
@@ -453,19 +498,45 @@
"MinVersion": null, "MinVersion": null,
"MaxVersion": null "MaxVersion": null
}, },
{
"FeatureId": "EnableDesktopSpotlight",
"Label": "Enable Windows spotlight desktop background and shortcut",
"ToolTip": "Restores the Windows spotlight desktop background and the 'Learn about this picture' shortcut.",
"Category": "Appearance",
"RegistryKey": "Enable_Desktop_Spotlight.reg",
"ApplyText": "Restoring the Windows spotlight desktop background and shortcut",
"UndoLabel": null,
"ApplyUndoText": null,
"RegistryUndoKey": null,
"MinVersion": null,
"MaxVersion": null
},
{ {
"FeatureId": "DisableDesktopSpotlight", "FeatureId": "DisableDesktopSpotlight",
"Label": "Disable Windows Spotlight for desktop", "Label": "Disable Windows spotlight desktop background",
"ToolTip": "This will turn off the 'Windows Spotlight' feature for the desktop background, which shows different background images and occasionally tips and fun facts on the desktop. This feature uses policies, which will lock down certain settings.", "ToolTip": "Turns off Windows spotlight for the desktop background, which displays rotating images and may show tips and fun facts. This feature uses policies, which will lock down certain settings.",
"Category": "Privacy & Suggested Content", "Category": "Appearance",
"RegistryKey": "Disable_Desktop_Spotlight.reg", "RegistryKey": "Disable_Desktop_Spotlight.reg",
"ApplyText": "Disabling the 'Windows Spotlight' desktop background option", "ApplyText": "Disabling the Windows spotlight desktop background",
"UndoLabel": "Enable Windows Spotlight for desktop", "UndoLabel": "Enable Windows spotlight desktop background",
"ApplyUndoText": "Enabling the 'Windows Spotlight' desktop background option", "ApplyUndoText": "Restoring the Windows spotlight desktop background and shortcut",
"RegistryUndoKey": "Enable_Desktop_Spotlight.reg", "RegistryUndoKey": "Enable_Desktop_Spotlight.reg",
"MinVersion": null, "MinVersion": null,
"MaxVersion": null "MaxVersion": null
}, },
{
"FeatureId": "HideDesktopSpotlightIcon",
"Label": "Hide the 'Learn about this picture' desktop shortcut",
"ToolTip": "Hides the 'Learn about this picture' desktop shortcut while keeping Windows spotlight wallpaper rotation enabled. It does not disable Windows spotlight.",
"Category": "Appearance",
"RegistryKey": "Hide_Desktop_Spotlight_Icon.reg",
"ApplyText": "Hiding the Windows spotlight 'Learn about this picture' desktop shortcut",
"UndoLabel": "Show the 'Learn about this picture' desktop shortcut",
"ApplyUndoText": "Showing the Windows spotlight 'Learn about this picture' desktop shortcut",
"RegistryUndoKey": "Show_Desktop_Spotlight_Icon.reg",
"MinVersion": null,
"MaxVersion": null
},
{ {
"FeatureId": "DisableEdgeAds", "FeatureId": "DisableEdgeAds",
"Label": "Disable ads, suggestions and newsfeed in Edge", "Label": "Disable ads, suggestions and newsfeed in Edge",
@@ -745,6 +816,7 @@
"UndoLabel": "Disable dark theme for system and apps", "UndoLabel": "Disable dark theme for system and apps",
"ApplyUndoText": "Disabling dark mode for system and apps", "ApplyUndoText": "Disabling dark mode for system and apps",
"RegistryUndoKey": "Enable_Light_Mode.reg", "RegistryUndoKey": "Enable_Light_Mode.reg",
"Priority": 1,
"MinVersion": null, "MinVersion": null,
"MaxVersion": null "MaxVersion": null
}, },
@@ -1314,6 +1386,7 @@
"UndoLabel": "Enable transparency effects", "UndoLabel": "Enable transparency effects",
"ApplyUndoText": "Enabling transparency effects", "ApplyUndoText": "Enabling transparency effects",
"RegistryUndoKey": "Enable_Transparency.reg", "RegistryUndoKey": "Enable_Transparency.reg",
"Priority": 2,
"MinVersion": null, "MinVersion": null,
"MaxVersion": null "MaxVersion": null
}, },
@@ -1327,6 +1400,7 @@
"UndoLabel": "Enable animations and visual effects", "UndoLabel": "Enable animations and visual effects",
"ApplyUndoText": "Enabling animations and visual effects", "ApplyUndoText": "Enabling animations and visual effects",
"RegistryUndoKey": "Enable_Animations.reg", "RegistryUndoKey": "Enable_Animations.reg",
"Priority": 3,
"RequiresReboot": true, "RequiresReboot": true,
"MinVersion": null, "MinVersion": null,
"MaxVersion": null "MaxVersion": null
+3 -2
View File
@@ -57,7 +57,7 @@ This method supports command-line parameters to customize the behaviour of the s
3. Temporarily enable PowerShell execution by entering the following command: 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,12 +112,13 @@ 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
- Enable dark mode for system and apps. - Enable dark mode for system and apps.
- Disable transparency, animations and visual effects. - Disable transparency, animations and visual effects.
- Hide the 'Learn about this picture' shortcut for desktop spotlight, or disable the Windows spotlight background option entirely.
#### Start Menu & Search #### Start Menu & Search
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Policies\Microsoft\Windows\CloudContent]
"DisableSpotlightCollectionOnDesktop"=-
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=-
+7
View File
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Policies\Microsoft\Windows\CloudContent]
"DisableSpotlightCollectionOnDesktop"=-
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=dword:00000001
Binary file not shown.
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Policies\Microsoft\Windows\CloudContent]
"DisableSpotlightCollectionOnDesktop"=-
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=-
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Policies\Microsoft\Windows\CloudContent]
"DisableSpotlightCollectionOnDesktop"=-
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=dword:00000001
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Policies\Microsoft\Windows\CloudContent]
"DisableSpotlightCollectionOnDesktop"=-
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=-
@@ -0,0 +1,4 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=-
Binary file not shown.
@@ -0,0 +1,4 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel]
"{2cc5ca98-6485-489a-920e-b3e88a6ccce3}"=-
+1 -1
View File
@@ -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
View File
@@ -470,13 +470,13 @@
<Path x:Name="LogoFallback" Data="M0,0 L80,0 L80,80 L0,80 Z M90,0 L170,0 L170,80 L90,80 Z M0,90 L80,90 L80,170 L0,170 Z M90,90 L170,90 L170,170 L90,170 Z" <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>
@@ -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
@@ -1,6 +1,14 @@
# Forcefully removes Microsoft Edge using its uninstaller <#
# Credit: Based on work from loadstring1 & ave9858 .SYNOPSIS
function ForceRemoveEdge { Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries.
#>
function Invoke-ForceRemoveEdge {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Forcefully uninstall Microsoft Edge" -ForegroundColor Cyan
Write-Host ""
return
}
Write-Host "> Forcefully uninstalling Microsoft Edge..." Write-Host "> Forcefully uninstalling Microsoft Edge..."
$regView = [Microsoft.Win32.RegistryView]::Registry32 $regView = [Microsoft.Win32.RegistryView]::Registry32
+349
View File
@@ -0,0 +1,349 @@
<#
.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)
#>
function Remove-SelectedApps {
param (
$appslist
)
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
Write-Host ""
return
}
$targetUser = Get-TargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$wingetRemovedApps = @()
$wingetRemovalFailures = @{}
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return }
$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') {
if (-not (Remove-WinGetApp -app $app)) {
$wingetRemovalFailures[$app] = $true
}
$wingetRemovedApps += $app
}
else {
if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) {
$script:AppRemovalFailures++
}
}
}
if ($script:CancelRequested) {
Write-Host ""
return
}
# 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
if ($null -eq $postRemovalList) {
$script:AppRemovalVerificationUnavailable = $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) {
Request-EdgeForceRemove
$edgeForceRemoveRequested = $true
}
}
else {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
$wingetRemovalFailures[$app] = $true
}
}
}
$script:AppRemovalFailures += $wingetRemovalFailures.Count
Write-Host ""
}
<#
.SYNOPSIS
Uninstalls an app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app, with a bounded execution time.
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.
#>
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
}
$uninstallSucceeded = $true
try {
$uninstallSucceeded = Invoke-NonBlocking -ScriptBlock {
param($appId)
$null = & winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1
return $true
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
$uninstallSucceeded = [bool]$uninstallSucceeded
}
catch {
$uninstallSucceeded = $false
if ($_.Exception.Message -like 'Operation timed out after *') {
Write-Error "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_"
}
else {
Write-Error "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 ($uninstallSucceeded -and $scheduleSucceeded)
}
<#
.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.
#>
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 ""
Invoke-ForceRemoveEdge
}
}
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
Write-Host ""
Invoke-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
return $true
}
catch {
Write-Error "Failed to schedule uninstall task for $($appId): $_"
return $false
}
}
-392
View File
@@ -1,392 +0,0 @@
<#
.SYNOPSIS
Removes one or more Windows app packages based on the target scope.
.DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
The removal method (winget vs. Appx cmdlets) is determined per-app from
Apps.json. Microsoft Edge is deferred to the end of the loop so that all
winget attempts run before any force-remove prompt. A scheduled task is
only created when the User or Sysprep parameter was passed.
After each winget removal, the system is checked to confirm whether the
app is still installed before reporting an error.
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
.EXAMPLE
RemoveApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
.EXAMPLE
RemoveApps -appsList (GenerateAppsList)
#>
function RemoveApps {
param (
$appslist
)
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
Write-Host ""
return
}
$targetUser = GetTargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$edgeAppsInList = @()
$wingetRemovedApps = @()
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return }
$appIndex++
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
}
# Microsoft Edge is handled after the loop to avoid duplicate scheduled tasks and allow fallback if winget fails
if ($edgeIds -contains $app) {
$edgeAppsInList += $app
continue
}
Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
Remove-WinGetApp -app $app
$wingetRemovedApps += $app
}
else {
Remove-AppxApp -app $app -targetUser $targetUser
}
}
# Remove Microsoft Edge
if ($edgeAppsInList.Count -gt 0) {
Remove-EdgeApp -edgeAppsInList $edgeAppsInList
}
# Check whether any winget-removed apps are still present, and report errors for each one.
if ($wingetRemovedApps.Count -gt 0 -or $edgeAppsInList.Count -gt 0) {
$postRemovalList = if ($script:WingetInstalled) { GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking } else { $null }
foreach ($app in $wingetRemovedApps) {
if (Test-AppStillInstalled -appId $app -InstalledList $postRemovalList) {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
}
# Verify Edge separately (triggers its own force-remove path if still installed)
$edgeStillInstalled = $false
foreach ($edgeApp in $edgeAppsInList) {
if (Test-AppStillInstalled -appId $edgeApp -InstalledList $postRemovalList) {
$edgeStillInstalled = $true
break
}
}
if ($edgeStillInstalled) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
Request-EdgeForceRemove
}
}
Write-Host ""
}
<#
.SYNOPSIS
Uninstalls a non-Edge app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app. If the User or Sysprep
parameter was passed, also schedules removal for future logins.
After uninstall, the system is checked to confirm whether the app
is still present — winget output is not trusted on its
own, as it sometimes reports failure after a successful removal.
Edge apps are handled separately after the main loop.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
#>
function Remove-WinGetApp {
param([string]$app)
if (-not $script:WingetInstalled) {
Write-Host "ERROR: WinGet is either not installed or is outdated, $app could not be removed" -ForegroundColor Red
return
}
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(GetUserName)..."
Set-RunOnceWingetTask -appId $app
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
Set-RunOnceWingetTask -appId $app
}
Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $app
}
<#
.SYNOPSIS
Removes Microsoft Edge via WinGet (both AppIds), with fallback to force-remove.
.DESCRIPTION
Edge has multiple package IDs. Runs winget uninstall for each one,
then creates a single scheduled task if the User or Sysprep parameter
was passed. After all attempts, the system is checked to confirm
whether Edge is still present. The force-remove prompt only
appears if Edge remains installed — winget false positives are ignored.
.PARAMETER edgeAppsInList
The Edge AppIds that appear in the removal list (one or both).
#>
function Remove-EdgeApp {
param([string[]]$edgeAppsInList)
if (-not $script:WingetInstalled) {
Write-Host "ERROR: WinGet is either not installed or is outdated, Microsoft Edge could not be removed" -ForegroundColor Red
return
}
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall Microsoft Edge for user $(GetUserName)..."
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall Microsoft Edge for new users..."
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
foreach ($edgeApp in $edgeAppsInList) {
Write-Host "Removing $edgeApp"
Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $edgeApp
}
}
<#
.SYNOPSIS
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
.PARAMETER app
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
.PARAMETER targetUser
Target scope: "AllUsers", "CurrentUser", or a specific username.
#>
function Remove-AppxApp {
param([string]$app, [string]$targetUser)
$appPattern = '*' + $app + '*'
try {
switch ($targetUser) {
"AllUsers" {
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern -AllUsers | Remove-AppxPackage -AllUsers -ErrorAction Continue
Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like $pattern } | ForEach-Object { Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $_.PackageName }
} -ArgumentList $appPattern
}
"CurrentUser" {
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue
} -ArgumentList $appPattern
}
default {
Invoke-NonBlocking -ScriptBlock {
param($pattern, $user)
$userAccount = New-Object System.Security.Principal.NTAccount($user)
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
Get-AppxPackage -Name $pattern -User $userSid | Remove-AppxPackage -User $userSid -ErrorAction Continue
} -ArgumentList @($appPattern, $targetUser)
}
}
}
catch {
Write-Verbose "Something went wrong while trying to remove $($app): $_"
}
}
<#
.SYNOPSIS
Checks whether an app package is still installed after a removal attempt.
.DESCRIPTION
Checks Get-AppxPackage across all users first (fast, no process launch),
then falls back to a pre-fetched or live winget list for non-Appx packages.
Uses Test-AppInWingetList which provides exact-match-first with substring
fallback against the parsed winget objects.
Returns $true if the app is still present, $false otherwise.
.PARAMETER appId
The package identifier to check (e.g. 'Microsoft.BingNews').
.PARAMETER InstalledList
Optional pre-fetched array of winget objects from GetInstalledAppsViaWinget.
When provided, used directly; otherwise a live winget call is made.
#>
function Test-AppStillInstalled {
param(
[string]$appId,
[object[]]$InstalledList
)
# Check Get-AppxPackage for all users first (fast, covers all Store apps).
if (Get-AppxPackage -Name "$appId" -AllUsers -ErrorAction SilentlyContinue) {
return $true
}
# Use the pre-fetched list if provided; otherwise fall back to a live winget call.
if ($InstalledList) {
return (Test-AppInWingetList -appId $appId -InstalledList $InstalledList)
}
if ($script:WingetInstalled) {
$liveList = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
if (Test-AppInWingetList -appId $appId -InstalledList $liveList) {
return $true
}
}
else {
Write-Warning "Unable to verify whether '$appId' is still installed (WinGet is unavailable)"
}
return $false
}
<#
.SYNOPSIS
Returns the removal method for an app identifier.
.DESCRIPTION
Parses Apps.json once (cached in script scope) to build a lookup of
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
for unknown IDs.
.PARAMETER appId
The package identifier (e.g. 'Clipchamp.Clipchamp').
#>
function Get-AppRemovalMethod {
param([string]$appId)
if (-not $script:AppRemovalMethodCache) {
$script:AppRemovalMethodCache = @{}
try {
if (Test-Path $script:AppsListFilePath) {
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
foreach ($appData in $appsJson.Apps) {
$rawMethod = $appData.RemovalMethod
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
if ($appData.AppId -is [array]) {
foreach ($id in $appData.AppId) { $script:AppRemovalMethodCache[$id.Trim()] = $method }
}
else {
$script:AppRemovalMethodCache[$appData.AppId.Trim()] = $method
}
}
}
}
catch {
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
}
}
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
return $script:AppRemovalMethodCache[$appId]
}
return 'Appx'
}
<#
.SYNOPSIS
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
.DESCRIPTION
Only invoked after it has been confirmed that Edge is still present
following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package.
#>
function Request-EdgeForceRemove {
if ($script:GuiWindow) {
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
if ($result -eq 'Yes') {
Write-Host ""
ForceRemoveEdge
}
}
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
Write-Host ""
ForceRemoveEdge
}
}
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
even if future catalog updates introduce IDs containing those characters.
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
#>
function Set-RunOnceWingetTask {
param([string]$appId)
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId.Replace('\', '_')
$taskName = "Uninstall_$safeAppId"
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
$escapedAppId = $appId.Replace("'", "''")
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
$operation = [PSCustomObject]@{
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
ValueName = $taskName
ValueType = 'String'
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
}
catch {
Write-Host "Failed to schedule uninstall task for $($appId): $_" -ForegroundColor Red
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
The identifier to search for (e.g. 'Microsoft.Copilot'). 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(
-11
View File
@@ -1,11 +0,0 @@
function AwaitKeyToExit {
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
Write-Output ""
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
}
Stop-Transcript
Exit
}
@@ -1,6 +1,6 @@
# Shows the CLI app removal menu and prompts the user to select which apps to remove. # 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 {
@@ -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 ""
@@ -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"
+22
View File
@@ -0,0 +1,22 @@
<#
.SYNOPSIS
Waits for user acknowledgement, then exits the script.
.PARAMETER ExitCode
Process exit code to return after acknowledgement. Defaults to 0.
#>
function Wait-ForKeyPress {
param(
[int]$ExitCode = 0
)
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
Write-Output ""
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
}
Stop-Transcript
Exit $ExitCode
}
@@ -1,5 +1,5 @@
# Prints the header for the script # 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
@@ -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"
@@ -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'"
} }
@@ -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)
} }
@@ -1,5 +1,5 @@
# Import & execute regfile # Import & execute regfile
function ImportRegistryFile { function Import-RegistryFile {
param ( param (
$message, $message,
$path $path
@@ -4,7 +4,7 @@
.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.
@@ -21,17 +21,17 @@ function Invoke-FeatureApply {
# ---- Registry-backed features: import .reg file, then handle side effects ---- # ---- Registry-backed features: import .reg file, then handle side effects ----
if ($feature.RegistryKey) { if ($feature.RegistryKey) {
ImportRegistryFile "> $applyText..." $feature.RegistryKey Import-RegistryFile "> $applyText..." $feature.RegistryKey
# Post-import side effects for specific features # 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') Remove-SelectedApps @('Microsoft.BingSearch')
} }
'DisableCopilot' { 'DisableCopilot' {
# Also remove the app packages for Copilot # Also remove the app packages for Copilot
RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX') Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')
} }
'DisableTelemetry' { 'DisableTelemetry' {
# Also disable telemetry scheduled tasks # Also disable telemetry scheduled tasks
@@ -44,8 +44,8 @@ function Invoke-FeatureApply {
# ---- 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
@@ -54,82 +54,87 @@ function Invoke-FeatureApply {
} }
Write-Host "$($appsList.Count) apps selected for removal" Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList Remove-SelectedApps $appsList
return 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 Remove-SelectedApps $appsList
return 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 Remove-SelectedApps $appsList
return
}
'ForceRemoveEdge' {
Write-Host "> $applyText..."
Invoke-ForceRemoveEdge
return return
} }
'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') Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')
return return
} }
'EnableWindowsSandbox' { 'EnableWindowsSandbox' {
Write-Host "> $applyText..." Write-Host "> $applyText..."
EnableWindowsFeature "Containers-DisposableClientVM" Enable-WindowsFeature "Containers-DisposableClientVM"
Write-Host "" Write-Host ""
return return
} }
'EnableWindowsSubsystemForLinux' { 'EnableWindowsSubsystemForLinux' {
Write-Host "> $applyText..." Write-Host "> $applyText..."
EnableWindowsFeature "VirtualMachinePlatform" Enable-WindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux" Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host "" Write-Host ""
return 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 Replace-StartMenu -startMenuBinFile $startMenuBinFile
} }
Write-Host "" Write-Host ""
return return
} }
'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") Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart")
} }
Write-Host "" Write-Host ""
return return
} }
'ClearStartAllUsers' { 'ClearStartAllUsers' {
ReplaceStartMenuForAllUsers Replace-StartMenuForAllUsers
return return
} }
'ReplaceStartAllUsers' { 'ReplaceStartAllUsers' {
ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers") Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")
return 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 Set-StoreSearchSuggestionsDisabledForAllUsers
Write-Host "" Write-Host ""
return 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 Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb
} }
Write-Host "" Write-Host ""
return return
@@ -145,7 +150,7 @@ function Invoke-FeatureApply {
.DESCRIPTION .DESCRIPTION
Handles undo for features that require custom logic rather than a simple Handles undo for features that require custom logic rather than a simple
.reg file import. Features with a RegistryUndoKey are handled directly .reg file import. Features with a RegistryUndoKey are handled directly
via ImportRegistryFile in Invoke-UndoFeatures. via Import-RegistryFile in Invoke-UndoFeatures.
#> #>
function Invoke-FeatureUndo { function Invoke-FeatureUndo {
param( param(
@@ -159,29 +164,29 @@ function Invoke-FeatureUndo {
'DisableStoreSearchSuggestions' { 'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) { if ($script:Params.ContainsKey('Sysprep')) {
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..." Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
EnableStoreSearchSuggestionsForAllUsers Set-StoreSearchSuggestionsEnabledForAllUsers
Write-Host "" Write-Host ""
return return
} }
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..." Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..."
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName) $storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
if ($storeDb) { if ($storeDb) {
EnableStoreSearchSuggestions -StoreAppsDatabase $storeDb Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb
} }
Write-Host "" Write-Host ""
return return
} }
'EnableWindowsSandbox' { 'EnableWindowsSandbox' {
Write-Host "> $($feature.ApplyUndoText)..." Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Containers-DisposableClientVM' Disable-WindowsFeature 'Containers-DisposableClientVM'
Write-Host "" Write-Host ""
return return
} }
'EnableWindowsSubsystemForLinux' { 'EnableWindowsSubsystemForLinux' {
Write-Host "> $($feature.ApplyUndoText)..." Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux' Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux'
DisableWindowsFeature 'VirtualMachinePlatform' Disable-WindowsFeature 'VirtualMachinePlatform'
Write-Host "" Write-Host ""
return return
} }
@@ -287,7 +292,7 @@ function Invoke-UndoFeatures {
} }
if ($f -and $f.RegistryUndoKey) { if ($f -and $f.RegistryUndoKey) {
ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey) Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey)
} }
Invoke-FeatureUndo -FeatureId $featureId Invoke-FeatureUndo -FeatureId $featureId
@@ -302,8 +307,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 +316,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:RegistryImportFailures = 0
$script:AppRemovalFailures = 0
$script:AppRemovalVerificationUnavailable = $false
# ---- Gather work items ---- # ---- Gather work items ----
$applyIds = @() $applyIds = @()
@@ -347,14 +356,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 +394,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 +405,7 @@ function Invoke-AllChanges {
} }
else { else {
Write-Host "> Creating a system restore point..." Write-Host "> Creating a system restore point..."
CreateSystemRestorePoint Invoke-SystemRestorePoint
Write-Host "" Write-Host ""
} }
} }
@@ -407,6 +418,8 @@ function Invoke-AllChanges {
$step += $applyIds.Count $step += $applyIds.Count
} }
if ($script:CancelRequested) { return }
# ================================================================ # ================================================================
# Phase 4: Undo features # Phase 4: Undo features
# ================================================================ # ================================================================
@@ -416,10 +429,36 @@ function Invoke-AllChanges {
} }
# ================================================================ # ================================================================
# Final: Report registry import failures # Final: Report registry import and app removal failures
# ================================================================ # ================================================================
if ($script:RegistryImportFailures -gt 0) { if ($script:RegistryImportFailures -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:RegistryImportFailures) registry import change(s) failed. See output above for details."
} }
if ($script:AppRemovalFailures -gt 0) {
Write-Host ""
Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details."
}
if ($script:AppRemovalVerificationUnavailable) {
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)"
@@ -1,4 +1,4 @@
function CreateSystemRestorePoint { function Invoke-SystemRestorePoint {
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" $SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
$failed = $false $failed = $false
@@ -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,12 @@
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"
#> #>
function ReplaceStartMenuForAllUsers { function Replace-StartMenuForAllUsers {
param ( param (
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin" [string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
) )
@@ -34,16 +34,16 @@ function ReplaceStartMenuForAllUsers {
} }
# 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
ForEach ($startMenuPath in $usersStartMenuPaths) { ForEach ($startMenuPath in $usersStartMenuPaths) {
ReplaceStartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate
} }
# Also replace the start menu file for the default user profile # 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
@@ -57,7 +57,7 @@ function ReplaceStartMenuForAllUsers {
} }
# Copy template to default profile # Copy template to default profile
ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate
Write-Host "Replaced start menu for the default user profile" Write-Host "Replaced start menu for the default user profile"
Write-Host "" Write-Host ""
} }
@@ -83,12 +83,12 @@ 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"
#> #>
function ReplaceStartMenu { function Replace-StartMenu {
param ( param (
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$startMenuBinFile, [string]$startMenuBinFile,
@@ -106,7 +106,7 @@ function ReplaceStartMenu {
return return
} }
$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
@@ -147,12 +147,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 +161,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 +177,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 +230,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 +261,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 +342,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 +364,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 +384,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 +402,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
}
@@ -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)
}
}
@@ -11,20 +11,20 @@
.EXAMPLE .EXAMPLE
DisableStoreSearchSuggestionsForAllUsers DisableStoreSearchSuggestionsForAllUsers
#> #>
function DisableStoreSearchSuggestionsForAllUsers { function Set-StoreSearchSuggestionsDisabledForAllUsers {
# 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") Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
} }
# 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 Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath
} }
} }
@@ -45,7 +45,7 @@ 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"
#> #>
function DisableStoreSearchSuggestions { function Set-StoreSearchSuggestionsDisabled {
param ( param (
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$StoreAppsDatabase [string]$StoreAppsDatabase
@@ -73,11 +73,17 @@ function DisableStoreSearchSuggestions {
New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null
} }
try {
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group $AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
$Acl = Get-Acl -Path $StoreAppsDatabase $Acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny') $Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
$Acl.SetAccessRule($Ace) | Out-Null $Acl.SetAccessRule($Ace) | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl | Out-Null Set-Acl -Path $StoreAppsDatabase -AclObject $Acl -ErrorAction Stop | Out-Null
}
catch {
Write-Warning "Failed to restrict ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
return
}
Write-Host "Disabled Microsoft Store search suggestions for user $userName" Write-Host "Disabled Microsoft Store search suggestions for user $userName"
} }
@@ -95,20 +101,20 @@ function DisableStoreSearchSuggestions {
.EXAMPLE .EXAMPLE
EnableStoreSearchSuggestionsForAllUsers EnableStoreSearchSuggestionsForAllUsers
#> #>
function EnableStoreSearchSuggestionsForAllUsers { function Set-StoreSearchSuggestionsEnabledForAllUsers {
# 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") Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
} }
# 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 Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath
} }
} }
@@ -128,7 +134,7 @@ 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"
#> #>
function EnableStoreSearchSuggestions { function Set-StoreSearchSuggestionsEnabled {
param ( param (
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$StoreAppsDatabase [string]$StoreAppsDatabase
@@ -201,12 +207,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 +221,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 +293,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
} }
@@ -40,6 +40,8 @@ function Disable-TelemetryScheduledTasks {
$tasks = Get-TelemetryScheduledTasks $tasks = Get-TelemetryScheduledTasks
foreach ($task in $tasks) { foreach ($task in $tasks) {
if ($script:CancelRequested) { return }
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
@@ -92,6 +94,8 @@ function Enable-TelemetryScheduledTasks {
$tasks = Get-TelemetryScheduledTasks $tasks = Get-TelemetryScheduledTasks
foreach ($task in $tasks) { foreach ($task in $tasks) {
if ($script:CancelRequested) { return }
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
@@ -1,5 +1,5 @@
# Enables a Windows optional feature and pipes its output to the console # Enables a Windows optional feature and pipes its output to the console
function EnableWindowsFeature { function Enable-WindowsFeature {
param ( param (
[string]$FeatureName [string]$FeatureName
) )
@@ -22,7 +22,7 @@ function EnableWindowsFeature {
} }
# Disables a Windows optional feature and pipes its output to the console # Disables a Windows optional feature and pipes its output to the console
function DisableWindowsFeature { function Disable-WindowsFeature {
param ( param (
[string]$FeatureName [string]$FeatureName
) )
@@ -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
@@ -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) {
@@ -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
} }
+37
View File
@@ -0,0 +1,37 @@
<#
.SYNOPSIS
Serializes a configuration hashtable to a UTF-8 JSON file.
.PARAMETER Config
The configuration data to serialize.
.PARAMETER FilePath
The destination file path.
.PARAMETER MaxDepth
The maximum object depth passed to ConvertTo-Json.
.OUTPUTS
System.Boolean. $true when the file is written; otherwise $false.
#>
function Save-ToFile {
param (
[Parameter(Mandatory=$true)]
[hashtable]$Config,
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$false)]
[int]$MaxDepth = 10
)
try {
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8 -ErrorAction Stop
return $true
}
catch {
Write-Error "Failed to write '$FilePath': $($_.Exception.Message)"
return $false
}
}
-22
View File
@@ -1,22 +0,0 @@
# Saves configuration JSON to a file.
# Returns $true on success, $false on failure.
function SaveToFile {
param (
[Parameter(Mandatory=$true)]
[hashtable]$Config,
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$false)]
[int]$MaxDepth = 10
)
try {
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8
return $true
}
catch {
return $false
}
}
@@ -1,6 +1,20 @@
# Applies settings from a JSON object to UI controls (checkboxes and comboboxes) <#
# Used by LoadDefaultsBtn and LoadLastUsedBtn in the UI .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,
@@ -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'
+99 -16
View File
@@ -174,6 +174,10 @@ function Update-AppSelectionStatus {
} }
} }
<#
.SYNOPSIS
Updates the app-removal scope description to match the selected ComboBoxItem.
#>
function Update-AppRemovalScopeDescription { function Update-AppRemovalScopeDescription {
param( param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo, [System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
@@ -182,20 +186,58 @@ 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' }
}
return $null
}
function Invoke-AppPreset { function Invoke-AppPreset {
param( param(
[System.Windows.Controls.Panel]$AppsPanel, [System.Windows.Controls.Panel]$AppsPanel,
@@ -223,7 +265,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 +283,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 +346,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 +399,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 +499,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 +513,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 +544,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 +594,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 +602,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 +611,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 {
+42 -13
View File
@@ -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
} }
+89 -20
View File
@@ -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 = '&#xE712;' } } 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 = '&#xE712;' }
}
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 = '&#xE712;' } $orderedCategories += @{Name = $catName; CategoryId = $catName; Icon = '&#xE712;' }
} }
} }
@@ -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; Category = $categoryName; 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; Category = $categoryName; 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; Category = $categoryName; 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
+9 -2
View File
@@ -1,3 +1,10 @@
<#
.SYNOPSIS
Displays the themed About dialog for the application.
.PARAMETER Owner
The optional window that owns the dialog and its modal overlay.
#>
function Show-AboutDialog { 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')
+24 -12
View File
@@ -1,8 +1,14 @@
# Shows application selection window that allows the user to select what apps they want to remove or keep <#
.SYNOPSIS
Displays the application-selection dialog and records the confirmed selections.
.OUTPUTS
System.Nullable[System.Boolean]. The dialog result; confirmed application IDs are stored in $script:SelectedApps.
#>
function Show-AppSelectionWindow { 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
+41 -15
View File
@@ -1,14 +1,24 @@
<#
.SYNOPSIS
Displays the modal progress window while selected changes are applied.
.PARAMETER Owner
The optional window that owns the modal and its overlay.
.PARAMETER InvokeRestartExplorer
Indicates whether the modal should run the Explorer-restart flow after applying changes.
#>
function Show-ApplyModal { 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
@@ -105,10 +115,13 @@ function Show-ApplyModal {
Invoke-AllChanges Invoke-AllChanges
$registryImportFailureCount = [int]$script:RegistryImportFailures $registryImportFailureCount = [int]$script:RegistryImportFailures
$appRemovalFailureCount = [int]$script:AppRemovalFailures
$failureCount = $registryImportFailureCount + $appRemovalFailureCount
$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 +131,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 +138,38 @@ 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"))
if ($failureCount -eq 0 -and $appRemovalVerificationUnavailable) {
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
$script:ApplyCompletionMessageEl.Text = "All changes were applied without errors, but Win11Debloat could not confirm that all selected apps were successfully uninstalled."
}
else {
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors" $script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors"
$script:ApplyCompletionMessageEl.Text = "$registryImportFailureCount registry change(s) failed. See console for details." $failureMessages = @()
if ($registryImportFailureCount -gt 0) { $failureMessages += "$registryImportFailureCount registry change(s) failed" }
if ($appRemovalFailureCount -gt 0) { $failureMessages += "$appRemovalFailureCount app removal(s) failed" }
if ($appRemovalVerificationUnavailable) { $failureMessages += "Unable to verify if all apps were uninstalled successfully" }
$script:ApplyCompletionMessageEl.Text = "$($failureMessages -join '; '). 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,7 +499,7 @@ 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
@@ -504,7 +541,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 +550,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.'
+49 -37
View File
@@ -1,8 +1,12 @@
function Show-MainWindow { <#
.SYNOPSIS
Creates and displays the main Win11Debloat window.
#>
function Show-MainWindow {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null 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,21 @@
$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' Add-Parameter 'RemoveApps'
AddParameter 'Apps' ($selectedApps -join ',') Add-Parameter 'Apps' ($selectedApps -join ',')
$selectedScopeItem = $appRemovalScopeCombo.SelectedItem $scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox
if ($selectedScopeItem) { if ($scopeTarget) {
switch ($selectedScopeItem.Content) { Add-Parameter 'AppRemovalTarget' $scopeTarget
"All users" { AddParameter 'AppRemovalTarget' 'AllUsers' }
"Current user only" { AddParameter 'AppRemovalTarget' 'CurrentUser' }
"Target user only" { AddParameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) }
}
} }
} }
# 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 +696,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 +743,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 +756,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 +779,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 +821,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 +881,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: $_"
+6 -3
View File
@@ -1,4 +1,7 @@
# Shows a Windows 11 styled custom message box <#
.SYNOPSIS
Shows a themed Windows 11-style message box.
#>
function Show-MessageBox { 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')
+7 -7
View File
@@ -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
+7 -3
View File
@@ -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)
+28 -14
View File
@@ -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,
@@ -37,6 +39,8 @@ param (
[switch]$DisableBing, [switch]$DisableBing,
[switch]$DisableStoreSearchSuggestions, [switch]$DisableStoreSearchSuggestions,
[switch]$DisableDesktopSpotlight, [switch]$DisableDesktopSpotlight,
[switch]$HideDesktopSpotlightIcon,
[switch]$EnableDesktopSpotlight,
[switch]$DisableLockscreenTips, [switch]$DisableLockscreenTips,
[switch]$DisableSuggestions, [switch]$DisableSuggestions,
[switch]$DisableLocationServices, [switch]$DisableLocationServices,
@@ -103,13 +107,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,23 +124,24 @@ $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
} }
catch { catch {
Write-Host "Error: Unable to fetch required files from GitHub. Please check your internet connection and try again." -ForegroundColor Red Write-Host "Unable to fetch required files from GitHub. Please check your internet connection and try again." -ForegroundColor Red
Write-Error -ErrorRecord $_
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 +209,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 +217,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 +225,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 +251,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
@@ -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)]
@@ -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
} }
} }
@@ -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" }
}
}
+31 -6
View File
@@ -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,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
} }
+11
View File
@@ -0,0 +1,11 @@
<#
.SYNOPSIS
Returns the explicitly targeted user name or the current process user name.
#>
function Get-UserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}
@@ -1,9 +0,0 @@
function GetFriendlyTargetUserName {
$target = GetTargetUserForAppRemoval
switch ($target) {
"AllUsers" { return "all users" }
"CurrentUser" { return "the current user" }
default { return "user $target" }
}
}
-7
View File
@@ -1,7 +0,0 @@
function GetUserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}
@@ -1,4 +1,8 @@
function ImportConfigToParams { <#
.SYNOPSIS
Imports valid application, tweak, and deployment selections from a configuration JSON file into active parameters.
#>
function Import-ConfigToParams {
param ( param (
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ConfigPath, [string]$ConfigPath,
@@ -22,7 +26,7 @@ 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"
} }
@@ -38,8 +42,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 +63,7 @@ function ImportConfigToParams {
continue continue
} }
AddParameter $setting.Name $true Add-Parameter $setting.Name $true
$importedItems++ $importedItems++
} }
} }
@@ -73,12 +77,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 +96,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 +110,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)]
@@ -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)
} }
} }
} }
@@ -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 {
+1 -1
View File
@@ -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'."
} }
+49
View File
@@ -0,0 +1,49 @@
<#
.SYNOPSIS
Runs the Win11Debloat Pester test suite locally.
.PARAMETER Bootstrap
Installs a compatible Pester 5 release for the current user when Pester is
not already available.
#>
[CmdletBinding()]
param(
[switch]$Bootstrap
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$testPath = Join-Path $repositoryRoot 'Tests'
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 })) {
if (-not $Bootstrap) {
Write-Error 'Pester 5 is required. Install it with: .\Scripts\Run-Tests.ps1 -Bootstrap'
exit 1
}
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force -Confirm:$false | Out-Null
}
Install-Module -Name Pester -RequiredVersion 5.9.0 -Scope CurrentUser -Force -AllowClobber -Confirm:$false
}
catch {
Write-Error "Unable to install Pester 5: $($_.Exception.Message)"
exit 1
}
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 })) {
Write-Error 'Pester 5 was not installed. Update PowerShellGet or install Pester 5 manually, then run the tests again.'
exit 1
}
}
Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.999.999 -Force
$result = Invoke-Pester -Path $testPath -Output Detailed -PassThru
if ($result.Result -ne 'Passed' -or $result.FailedContainersCount -gt 0 -or $result.TotalCount -eq 0) {
exit 1
}
@@ -1,6 +1,6 @@
# Processes all pending WPF window messages (input, render, etc.) to keep the UI responsive # 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(
+1 -1
View File
@@ -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
} }
} }
+180
View File
@@ -0,0 +1,180 @@
BeforeAll {
function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) }
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Apply-RegistryRegFile.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1')
}
Describe 'Convert-RegOperationToValueKind' {
It 'converts <Case> to a registry-compatible value' -ForEach @(
@{ Case = 'an unsigned DWord'; ValueName = $null; ValueType = 'DWord'; ValueData = [uint32]::MaxValue; ExpectedName = ''; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::DWord; ExpectedValue = -1 }
@{ Case = 'an unsigned QWord'; ValueName = 'Big'; ValueType = 'QWord'; ValueData = [uint64]::MaxValue; ExpectedName = 'Big'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::QWord; ExpectedValue = -1L }
@{ Case = 'a string value'; ValueName = 'Name'; ValueType = 'String'; ValueData = 42; ExpectedName = 'Name'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::String; ExpectedValue = '42' }
@{ Case = 'an expandable string value'; ValueName = 'Path'; ValueType = 'Hex2'; ValueData = 'test%PATH%'; ExpectedName = 'Path'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::ExpandString; ExpectedValue = 'test%PATH%' }
@{ Case = 'a binary value'; ValueName = 'Bytes'; ValueType = 'Binary'; ValueData = @(1, 255); ExpectedName = 'Bytes'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::Binary; ExpectedValue = [byte[]](1, 255) }
@{ Case = 'a multi-string value'; ValueName = 'List'; ValueType = 'Hex7'; ValueData = @('a', 'b', 'c'); ExpectedName = 'List'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::MultiString; ExpectedValue = [string[]]@('a', 'b', 'c') }
) {
$result = Convert-RegOperationToValueKind -Operation ([PSCustomObject]@{
KeyPath = 'HK'; ValueName = $ValueName; ValueType = $ValueType; ValueData = $ValueData
})
$result.Name | Should -Be $ExpectedName
$result.Kind | Should -Be $ExpectedKind
$result.Value | Should -Be $ExpectedValue
}
It 'throws for unsupported value types' {
{ Convert-RegOperationToValueKind -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\X'; ValueType = 'Hex9'; ValueData = 1 }) } |
Should -Throw "Unsupported value type 'Hex9' while applying reg operation for 'HKCU\X'"
}
}
Describe 'Get-RegistryKeyForOperation' {
It 'rejects <Case>' -ForEach @(
@{ Case = 'an unsupported path format'; RegistryPath = 'HKCU\Software\Example'; ExpectedError = 'Unsupported registry path:*' }
@{ Case = 'an unsupported registry hive'; RegistryPath = 'HKEY_UNKNOWN\Software\Example'; ExpectedError = "Unsupported registry hive 'HKEY_UNKNOWN'*" }
) {
{ Get-RegistryKeyForOperation -RegistryPath $RegistryPath } | Should -Throw $ExpectedError
}
}
Describe 'Invoke-RegistryOperation' {
BeforeEach {
Mock Get-RegistryKeyForOperation { [PSCustomObject]@{ RootKey = [Microsoft.Win32.Registry]::CurrentUser; SubKeyPath = 'Software\Example'; Key = 'key' } }
Mock Remove-RegistrySubKeyTreeIfExists {}
Mock Invoke-RegistryDeleteValueOperation {}
Mock Invoke-RegistrySetValueOperation {}
}
It 'dispatches <Type> to <Expected>' -ForEach @(
@{ Type = 'DeleteKey'; Expected = 'Remove-RegistrySubKeyTreeIfExists' }
@{ Type = 'DeleteValue'; Expected = 'Invoke-RegistryDeleteValueOperation' }
@{ Type = 'SetValue'; Expected = 'Invoke-RegistrySetValueOperation' }
) {
$operation = [PSCustomObject]@{ OperationType = $Type; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Value' }
Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg'
Should -Invoke $Expected -Times 1 -Exactly
}
It 'opens keys with create=<Create> and open=<Open> for <Type>' -ForEach @(
@{ Type = 'DeleteKey'; Create = $false; Open = $false }
@{ Type = 'DeleteValue'; Create = $false; Open = $true }
@{ Type = 'SetValue'; Create = $true; Open = $true }
) {
$operation = [PSCustomObject]@{ OperationType = $Type; KeyPath = 'HKEY_CURRENT_USER\Software\Example' }
Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg'
Should -Invoke Get-RegistryKeyForOperation -Times 1 -Exactly -ParameterFilter {
[bool]$CreateIfMissing -eq $Create -and [bool]$OpenKey -eq $Open
}
}
It 'rejects unknown operation types with file context' {
$operation = [PSCustomObject]@{ OperationType = 'Unknown'; KeyPath = 'HKEY_CURRENT_USER\Software\Example' }
{ Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg' } |
Should -Throw "Unsupported reg operation type 'Unknown' in 'feature.reg'"
}
}
Describe 'Invoke-RegistryOperationsFromRegFile' {
BeforeEach {
$script:Params = @{}
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKCU\One' }, [PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Two' }) }
Mock Invoke-RegistryOperation {}
Mock Write-RegistryOperationAccessDeniedWarning {}
Mock Write-Warning {}
Mock Write-Host {}
}
It 'honors WhatIf without dispatching operations' {
$script:Params = @{ WhatIf = $true }
Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg'
Should -Invoke Invoke-RegistryOperation -Times 0 -Exactly
}
It 'continues after one access-denied operation and emits a summary warning' {
$script:calls = 0
Mock Invoke-RegistryOperation {
$script:calls++
if ($script:calls -eq 1) { throw [System.UnauthorizedAccessException]::new('denied') }
}
{ Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' } | Should -Not -Throw
Should -Invoke Write-RegistryOperationAccessDeniedWarning -Times 1 -Exactly
Should -Invoke Write-Warning -Times 1 -Exactly
}
It 'throws when every operation is blocked by access restrictions' {
Mock Invoke-RegistryOperation { throw [System.Security.SecurityException]::new('blocked') }
{ Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' } |
Should -Throw "Registry fallback import could not apply any operations in 'feature.reg' because all 2 operation(s) were blocked*"
}
}
Describe 'Invoke-WithLoadedRestoreHive' {
BeforeEach { Mock Invoke-WithTargetUserHive { param($TargetUserName) $TargetUserName } }
It 'maps <Target> to <ExpectedUser>' -ForEach @(
@{ Target = 'DefaultUserProfile'; ExpectedUser = 'Default' }
@{ Target = 'User:Alice'; ExpectedUser = 'Alice' }
) {
Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} | Should -Be $ExpectedUser
}
It 'rejects <Case>' -ForEach @(
@{ Case = 'an empty user target'; Target = 'User:'; ExpectedError = 'Invalid backup target format for user restore.' }
@{ Case = 'a current-user target'; Target = 'CurrentUser:Alice'; ExpectedError = "Unsupported backup target 'CurrentUser:Alice'." }
) {
{ Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} } | Should -Throw $ExpectedError
}
}
Describe 'Restore-RegistryKeySnapshot - validation' {
It 'rejects <Case> before registry mutation' -ForEach @(
@{ Case = 'an unsupported snapshot path'; Path = 'HKCU\Software'; ExpectedError = 'Unsupported registry path in backup:*' }
@{ Case = 'a root-level snapshot path'; Path = 'HKEY_CURRENT_USER'; ExpectedError = 'Unsupported root-level registry path in backup:*' }
) {
{ Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Path = $Path; Exists = $true }) } |
Should -Throw $ExpectedError
}
}
Describe 'Invoke-RegistryDeleteValueOperation' {
It 'deletes the default value and always closes an opened registry key' {
$calls = [System.Collections.Generic.List[string]]::new()
$key = [PSCustomObject]@{}
$key | Add-Member -MemberType ScriptMethod -Name DeleteValue -Value { param($Name, $ThrowOnMissing) $calls.Add("delete:${Name}:$ThrowOnMissing") }
$key | Add-Member -MemberType ScriptMethod -Name Close -Value { $calls.Add('close') }
Invoke-RegistryDeleteValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -KeyInfo ([PSCustomObject]@{ Key = $key })
$calls | Should -Be @('delete::False', 'close')
}
}
Describe 'Invoke-RegistrySetValueOperation' {
It 'throws for an unavailable set-value key before attempting conversion' {
Mock Convert-RegOperationToValueKind { throw 'conversion should not run' }
{ Invoke-RegistrySetValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test' }) -KeyInfo ([PSCustomObject]@{ Key = $null }) } |
Should -Throw "Unable to open or create registry key*"
Should -Invoke Convert-RegOperationToValueKind -Times 0 -Exactly
}
}
Describe 'Write-RegistryOperationAccessDeniedWarning' {
It 'formats the default registry value in access-denied warnings' {
Mock Write-Warning {}
Write-RegistryOperationAccessDeniedWarning -Operation ([PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -ExceptionMessage 'denied'
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "value '\(Default\)'" -and $Message -match 'denied' }
}
}
+37
View File
@@ -0,0 +1,37 @@
BeforeAll {
function Show-MessageBox { param($Message, $Title, $Button, $Icon, $Owner) 'Yes' }
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Confirm-UnsafeAppRemoval.ps1')
}
Describe 'Confirm-UnsafeAppRemoval' {
BeforeEach {
$global:Silent = $false
Mock Show-MessageBox { 'Yes' }
}
AfterEach { Remove-Variable -Name Silent -Scope Global -ErrorAction SilentlyContinue }
It 'returns true without prompting for ordinary applications' {
Confirm-UnsafeAppRemoval -SelectedApps @('Contoso.App') | Should -BeTrue
Should -Invoke Show-MessageBox -Times 0 -Exactly
}
It 'skips all prompts in silent mode' {
$global:Silent = $true
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue
Should -Invoke Show-MessageBox -Times 0 -Exactly
}
It 'stops when Microsoft Store removal is declined' {
Mock Show-MessageBox { 'No' }
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeFalse
Should -Invoke Show-MessageBox -Times 1 -Exactly
}
It 'requires confirmation for both dangerous applications' {
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue
Should -Invoke Show-MessageBox -Times 2 -Exactly
}
}
+92
View File
@@ -0,0 +1,92 @@
BeforeAll {
$script:RepoRoot = Join-Path $PSScriptRoot '..'
$script:FeaturesPath = Join-Path $script:RepoRoot 'Config\Features.json'
$script:Win11DebloatPath = Join-Path $script:RepoRoot 'Win11Debloat.ps1'
$script:GetScriptPath = Join-Path $script:RepoRoot 'Scripts\Get.ps1'
$script:RegfilesPath = Join-Path $script:RepoRoot 'Regfiles'
$script:Features = @((Get-Content -LiteralPath $script:FeaturesPath -Raw | ConvertFrom-Json).Features)
$script:LauncherOnlyParameters = @('Dev', 'Verbose', 'WhatIf')
function Get-ScriptParameterNames {
param(
[Parameter(Mandatory)]
[string]$Path
)
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors)
$parseErrors | Should -BeNullOrEmpty
@($ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath })
}
}
Describe 'FeatureId parameter contracts' {
It 'declares every FeatureId as a parameter on Win11Debloat.ps1' {
$parameterNames = Get-ScriptParameterNames -Path $script:Win11DebloatPath
$missing = @($script:Features.FeatureId | Where-Object { $parameterNames -notcontains $_ })
$missing | Should -HaveCount 0 -Because ($missing -join ', ')
}
It 'declares every FeatureId as a parameter on Scripts/Get.ps1' {
$parameterNames = Get-ScriptParameterNames -Path $script:GetScriptPath
$missing = @($script:Features.FeatureId | Where-Object { $parameterNames -notcontains $_ })
$missing | Should -HaveCount 0 -Because ($missing -join ', ')
}
It 'keeps Scripts/Get.ps1 aligned with Win11Debloat.ps1 plus launcher-only switches' {
$win11DebloatParameters = Get-ScriptParameterNames -Path $script:Win11DebloatPath
$getParameters = Get-ScriptParameterNames -Path $script:GetScriptPath
$missingFromGet = @($win11DebloatParameters | Where-Object { $getParameters -notcontains $_ })
$missingLauncherOnly = @(
$script:LauncherOnlyParameters |
Where-Object { $getParameters -notcontains $_ }
)
$unexpectedOnGet = @(
$getParameters |
Where-Object { $win11DebloatParameters -notcontains $_ -and $script:LauncherOnlyParameters -notcontains $_ }
)
$missingFromGet | Should -HaveCount 0 -Because ($missingFromGet -join ', ')
$missingLauncherOnly | Should -HaveCount 0 -Because ($missingLauncherOnly -join ', ')
$unexpectedOnGet | Should -HaveCount 0 -Because ($unexpectedOnGet -join ', ')
}
It 'keeps every RegistryKey file in Regfiles and Regfiles/Sysprep' {
$missing = @(
foreach ($feature in $script:Features) {
if ([string]::IsNullOrWhiteSpace($feature.RegistryKey)) { continue }
$applyPath = Join-Path $script:RegfilesPath $feature.RegistryKey
$sysprepPath = Join-Path (Join-Path $script:RegfilesPath 'Sysprep') $feature.RegistryKey
if (-not (Test-Path -LiteralPath $applyPath -PathType Leaf)) {
"$($feature.FeatureId) missing Regfiles\$($feature.RegistryKey)"
}
if (-not (Test-Path -LiteralPath $sysprepPath -PathType Leaf)) {
"$($feature.FeatureId) missing Regfiles\Sysprep\$($feature.RegistryKey)"
}
}
)
$missing | Should -HaveCount 0 -Because ($missing -join '; ')
}
It 'keeps every RegistryUndoKey file in Regfiles/Undo or Regfiles' {
$missing = @(
foreach ($feature in $script:Features) {
if ([string]::IsNullOrWhiteSpace($feature.RegistryUndoKey)) { continue }
$undoPath = Join-Path (Join-Path $script:RegfilesPath 'Undo') $feature.RegistryUndoKey
$rootPath = Join-Path $script:RegfilesPath $feature.RegistryUndoKey
if (-not ((Test-Path -LiteralPath $undoPath -PathType Leaf) -or (Test-Path -LiteralPath $rootPath -PathType Leaf))) {
"$($feature.FeatureId) missing undo file $($feature.RegistryUndoKey)"
}
}
)
$missing | Should -HaveCount 0 -Because ($missing -join '; ')
}
}
+186
View File
@@ -0,0 +1,186 @@
BeforeAll {
function Test-StoreSearchSuggestionsDisabledForAllUsers { $false }
function Test-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) $false }
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
function Get-UserName { 'Alice' }
function Test-WindowsOptionalFeatureEnabled { param($FeatureName) $false }
function Get-RegFileOperations { param($regFilePath) @() }
function Split-RegistryPath { param($path) $null }
function Get-RegistryRootKey { param($hiveName) $null }
function New-CurrentStateRegistryKey {
param([hashtable]$Values = @{}, [hashtable]$Kinds = @{})
$key = [PSCustomObject]@{ Values = $Values; Kinds = $Kinds; Closed = $false }
$key | Add-Member ScriptMethod GetValueNames { @($this.Kinds.Keys) }
$key | Add-Member ScriptMethod GetValueKind { param($name) $this.Kinds[$name] }
$key | Add-Member ScriptMethod GetValue { param($name, $defaultValue, $options) $this.Values[$name] }
$key | Add-Member ScriptMethod Close { $this.Closed = $true }
return $key
}
. (Join-Path $PSScriptRoot '..\Scripts\Features\Get-CurrentTweakState.ps1')
}
Describe 'Get-ExpectedRegistryValueKind' {
It 'maps <ValueType> to <Expected>' -ForEach @(
@{ ValueType = 'DWord'; Expected = [Microsoft.Win32.RegistryValueKind]::DWord }
@{ ValueType = 'QWord'; Expected = [Microsoft.Win32.RegistryValueKind]::QWord }
@{ ValueType = 'String'; Expected = [Microsoft.Win32.RegistryValueKind]::String }
@{ ValueType = 'Binary'; Expected = [Microsoft.Win32.RegistryValueKind]::Binary }
@{ ValueType = 'Hex2'; Expected = [Microsoft.Win32.RegistryValueKind]::ExpandString }
@{ ValueType = 'Hex7'; Expected = [Microsoft.Win32.RegistryValueKind]::MultiString }
) {
$operation = [PSCustomObject]@{ ValueType = $ValueType }
Get-ExpectedRegistryValueKind -Operation $operation | Should -Be $Expected
}
It 'returns null for unsupported operation types' {
Get-ExpectedRegistryValueKind -Operation ([PSCustomObject]@{ ValueType = 'Hex11' }) | Should -BeNullOrEmpty
}
}
Describe 'Test-FeatureApplied - special features' {
BeforeEach {
$script:Params = @{}
$script:Features = @{
DisableWidgets = [PSCustomObject]@{}
DisableStoreSearchSuggestions = [PSCustomObject]@{}
EnableWindowsSandbox = [PSCustomObject]@{}
EnableWindowsSubsystemForLinux = [PSCustomObject]@{}
}
Mock Get-AppxPackage { $null }
Mock Test-StoreSearchSuggestionsDisabledForAllUsers { $true }
Mock Test-StoreSearchSuggestionsDisabled { $true }
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-UserName { 'Alice' }
Mock Test-WindowsOptionalFeatureEnabled { $true }
}
It '<Case>' -ForEach @(
@{ Case = 'treats Widgets as disabled when all related packages are absent'; PresentPackage = $null; Expected = $true; ExpectedCalls = 3 }
@{ Case = 'treats Widgets as enabled when a related package is present'; PresentPackage = 'MicrosoftWindows.Client.WebExperience'; Expected = $false; ExpectedCalls = 2 }
) {
Mock Get-AppxPackage { param($Name) if ($Name -eq $PresentPackage) { [PSCustomObject]@{ Name = $Name } } }
Test-FeatureApplied -FeatureId 'DisableWidgets' | Should -Be $Expected
Should -Invoke Get-AppxPackage -Times $ExpectedCalls -Exactly
}
It 'uses <Case> Store detection' -ForEach @(
@{ Case = 'all-user'; Params = @{ Sysprep = $true }; AllUsersCalls = 1; UserCalls = 0 }
@{ Case = 'user-specific'; Params = @{}; AllUsersCalls = 0; UserCalls = 1 }
) {
$script:Params = $Params
Test-FeatureApplied -FeatureId 'DisableStoreSearchSuggestions' | Should -BeTrue
Should -Invoke Test-StoreSearchSuggestionsDisabledForAllUsers -Times $AllUsersCalls -Exactly
Should -Invoke Test-StoreSearchSuggestionsDisabled -Times $UserCalls -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' }
}
It 'checks the expected optional feature for Windows Sandbox' {
Test-FeatureApplied -FeatureId 'EnableWindowsSandbox' | Should -BeTrue
Should -Invoke Test-WindowsOptionalFeatureEnabled -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' }
}
It '<Case>' -ForEach @(
@{ Case = 'reports WSL applied when both optional features are enabled'; DisabledFeature = $null; Expected = $true }
@{ Case = 'reports WSL not applied when VirtualMachinePlatform is disabled'; DisabledFeature = 'VirtualMachinePlatform'; Expected = $false }
) {
Mock Test-WindowsOptionalFeatureEnabled { param($FeatureName) $FeatureName -ne $DisabledFeature }
Test-FeatureApplied -FeatureId 'EnableWindowsSubsystemForLinux' | Should -Be $Expected
}
}
Describe 'Test-FeatureApplied - registry preconditions' {
BeforeEach {
$script:Params = @{}
$script:RegfilesPath = $TestDrive
$script:Features = @{
NoRegistry = [PSCustomObject]@{ RegistryKey = '' }
MissingFile = [PSCustomObject]@{ RegistryKey = 'missing.reg' }
EmptyOperations = [PSCustomObject]@{ RegistryKey = 'empty.reg' }
}
}
It 'returns false for <Case>' -ForEach @(
@{ Case = 'a feature without registry data'; FeatureId = 'NoRegistry'; ParserBehavior = 'None' }
@{ Case = 'a missing registry file'; FeatureId = 'MissingFile'; ParserBehavior = 'None' }
@{ Case = 'an empty registry operation set'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Empty' }
@{ Case = 'a registry operation parse failure'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Throw' }
) {
if ($ParserBehavior -ne 'None') {
'' | Set-Content -LiteralPath (Join-Path $TestDrive 'empty.reg')
if ($ParserBehavior -eq 'Empty') { Mock Get-RegFileOperations { @() } }
if ($ParserBehavior -eq 'Throw') { Mock Get-RegFileOperations { throw 'parse failed' } }
}
Test-FeatureApplied -FeatureId $FeatureId | Should -BeFalse
}
}
Describe 'Test-FeatureApplied - registry state comparison' {
BeforeEach {
$script:Params = @{}
$script:RegfilesPath = $TestDrive
$script:Features = @{ RegistryFeature = [PSCustomObject]@{ RegistryKey = 'feature.reg' } }
'' | Set-Content -LiteralPath (Join-Path $TestDrive 'feature.reg')
Mock Split-RegistryPath { [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = 'Software\Example' } }
}
It 'matches set values by kind and normalized unsigned data and closes the key' {
$key = New-CurrentStateRegistryKey -Values @{ Large = -1L } -Kinds @{ Large = [Microsoft.Win32.RegistryValueKind]::QWord }
$root = [PSCustomObject]@{ Key = $key }
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key }
Mock Get-RegistryRootKey { $root }
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Large'; ValueType = 'QWord'; ValueData = [uint64]::MaxValue }) }
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue
$key.Closed | Should -BeTrue
}
It 'returns false for a value-kind or data mismatch' -ForEach @(
@{ ActualKind = [Microsoft.Win32.RegistryValueKind]::String; ActualData = '1'; ExpectedType = 'DWord'; ExpectedData = 1 }
@{ ActualKind = [Microsoft.Win32.RegistryValueKind]::DWord; ActualData = 2; ExpectedType = 'DWord'; ExpectedData = 1 }
) {
$key = New-CurrentStateRegistryKey -Values @{ Enabled = $ActualData } -Kinds @{ Enabled = $ActualKind }
$root = [PSCustomObject]@{ Key = $key }
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key }
Mock Get-RegistryRootKey { $root }
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Enabled'; ValueType = $ExpectedType; ValueData = $ExpectedData }) }
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeFalse
$key.Closed | Should -BeTrue
}
It 'treats missing keys and values as successful delete operations' -ForEach @(
@{ OperationType = 'DeleteKey'; ReturnKey = $false }
@{ OperationType = 'DeleteValue'; ReturnKey = $true }
) {
$key = New-CurrentStateRegistryKey
$root = [PSCustomObject]@{ Key = $key; ReturnKey = $ReturnKey }
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) if ($this.ReturnKey) { $this.Key } else { $null } }
Mock Get-RegistryRootKey { $root }
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = $OperationType; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Gone' }) }
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue
}
}
Describe 'Get-CurrentGroupActiveIndex' {
BeforeEach { Mock Test-FeatureApplied { $false } }
It 'returns the one-based index of the first fully applied option' {
$group = [PSCustomObject]@{ Values = @(
[PSCustomObject]@{ FeatureIds = @('One', 'Missing') }
[PSCustomObject]@{ FeatureIds = @('Two', 'Three') }
) }
Mock Test-FeatureApplied { param($FeatureId) $FeatureId -in @('Two', 'Three') }
Get-CurrentGroupActiveIndex -Group $group | Should -Be 2
}
It 'returns zero when no option is fully applied' {
$group = [PSCustomObject]@{ Values = @([PSCustomObject]@{ FeatureIds = @('One') }) }
Get-CurrentGroupActiveIndex -Group $group | Should -Be 0
}
}
@@ -0,0 +1,21 @@
BeforeAll {
$friendlyTargetScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-FriendlyRegistryBackupTarget.ps1'
. $friendlyTargetScriptPath
}
Describe 'Get-FriendlyRegistryBackupTarget' {
It 'formats <Case> as <Expected>' -ForEach @(
@{ Case = 'a null target'; Target = $null; Expected = 'Unknown' }
@{ Case = 'the default profile'; Target = 'DefaultUserProfile'; Expected = 'Default user profile' }
@{ Case = 'the current-user marker'; Target = 'CurrentUser'; Expected = 'Current user' }
@{ Case = 'the all-users marker'; Target = 'AllUsers'; Expected = 'All users' }
@{ Case = 'a named current user'; Target = 'CurrentUser:Alice'; Expected = 'Current user (Alice)' }
@{ Case = 'a named target user'; Target = 'User:Bob'; Expected = 'User (Bob)' }
) {
Get-FriendlyRegistryBackupTarget -Target $Target | Should -Be $Expected
}
It 'keeps unrecognized target text visible to the user' {
Get-FriendlyRegistryBackupTarget -Target 'Custom:Value' | Should -Be 'Custom:Value'
}
}
+136
View File
@@ -0,0 +1,136 @@
BeforeAll {
$rebootFeatureLabelsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RebootFeatureLabels.ps1'
. $rebootFeatureLabelsScriptPath
}
Describe 'Get-RebootFeatureLabels' {
BeforeEach {
$script:Params = @{
ApplyFeature = $true
NoRebootFeature = $true
}
$script:UndoParams = @{
UndoFeature = $true
ApplyFeature = $true
}
$script:Features = @{
ApplyFeature = [PSCustomObject]@{ RequiresReboot = $true; Label = 'Apply feature'; UndoLabel = 'Undo apply feature' }
UndoFeature = [PSCustomObject]@{ RequiresReboot = $true; Label = 'Undoable feature'; UndoLabel = 'Undo feature' }
NoRebootFeature = [PSCustomObject]@{ RequiresReboot = $false; Label = 'No reboot'; UndoLabel = 'Undo no reboot' }
}
}
It 'includes reboot-required selections once and uses undo labels for undo operations' {
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
$result | Should -Contain 'Undo apply feature'
$result | Should -Contain 'Undo feature'
$result | Should -Not -Contain 'No reboot'
}
It 'uses the regular label for a forward-only selection' {
$script:Params = @{ ApplyFeature = $true }
$script:UndoParams = @{}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when an undo selection has no undo label' {
$script:Params = @{}
$script:UndoParams = @{ ApplyFeature = $true }
$script:Features.ApplyFeature.UndoLabel = $null
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when the feature has no UndoLabel property' {
$script:Params = @{}
$script:UndoParams = @{ MissingUndoLabelFeature = $true }
$script:Features.MissingUndoLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Feature without undo label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Feature without undo label'
}
It 'returns no labels when there are no selected parameters' {
$script:Params = @{}
$script:UndoParams = @{}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
It 'keeps one label for each distinct reboot feature when their labels match' {
$script:Params = @{
FirstMatchingLabelFeature = $true
SecondMatchingLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.FirstMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo first shared label'
}
$script:Features.SecondMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo second shared label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
@($result | Where-Object { $_ -eq 'Shared label' }).Count | Should -Be 2
}
It 'accepts truthy reboot flags' {
$script:Params = @{
StringRebootFeature = $true
NumericRebootFeature = $true
}
$script:UndoParams = @{}
$script:Features.StringRebootFeature = [PSCustomObject]@{
RequiresReboot = 'true'
Label = 'String reboot'
UndoLabel = 'Undo string reboot'
}
$script:Features.NumericRebootFeature = [PSCustomObject]@{
RequiresReboot = 1
Label = 'Numeric reboot'
UndoLabel = 'Undo numeric reboot'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
$result | Should -Contain 'String reboot'
$result | Should -Contain 'Numeric reboot'
}
It 'excludes unknown, non-reboot, and blank-label selections' {
$script:Params = @{
UnknownFeature = $true
NoRebootFeature = $true
BlankLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.BlankLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = ' '
UndoLabel = $null
}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
}

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