Compare commits

..
Author SHA1 Message Date
Jeffrey 20d206cb9b fix: ensure CommandNotFoundException is not handled as task not found 2026-08-23 00:34:52 +02:00
Jeffrey b7f612f36e fix: improve error handling for registry file imports and telemetry scheduled tasks 2026-08-22 19:29:54 +02:00
Jeffrey c921730703 Enhance output documentation and error handling 2026-08-22 17:14:09 +02:00
Jeffrey 80eadd531c fix: add logging for unrecognized app-removal scope items 2026-08-16 22:46:26 +02:00
Jeffrey 4c29fce469 feat: improve consistency checks and error reporting 2026-08-16 22:04:00 +02:00
Jeffrey 93d77d8034 fix: remove redundant Category field from UiControlMappings in dynamic tweak controls 2026-08-16 21:27:58 +02:00
Jeffrey 78e1d601b0 fix: make intent of scopeTarget check more clear 2026-08-16 21:27:40 +02:00
Jeffrey f763390d53 fix: remove redundant Category field from UiControlMappings in dynamic tweak controls 2026-08-16 21:27:13 +02:00
Jeffrey dded2da3a7 Clean up & simplify error handling and reporting 2026-08-16 21:21:10 +02:00
Jeffrey 492a374f5c Refactor feature management scripts to improve error handling 2026-08-16 21:21:05 +02:00
JeffreyandGitHub 1a26934499 Improve error handling in Set-StoreSearchSuggestionsDisabled (#739) 2026-08-16 18:49:15 +02:00
SashankandGitHub 5072958b10 fix: match tweak presets and app-removal scope by stable ID instead of translatable text (#737) 2026-08-16 17:52:58 +02:00
JeffreyandGitHub 31feaeb6f5 Fix ForceRemoveEdge switch (#736) 2026-08-12 21:10:47 +02:00
Jeffrey dd929f8eec Surface errors when writing to file 2026-08-12 17:31:50 +02:00
Jeffrey b21be418cd fix: force removal of empty LastUsedSettings.json file 2026-08-12 16:40:26 +02:00
Jeffrey 25fb9f3725 Improve GUI error messaging 2026-08-12 16:38:14 +02:00
JeffreyandGitHub d1338cd027 Enhance app removal verification and error reporting (#735) 2026-08-12 15:56:29 +02:00
Jeffrey 9505a5a374 Update README ExecutionPolicy override instructions 2026-08-12 12:14:26 +02:00
JeffreyandGitHub c0599cba1f feat: add timeout for WinGet uninstall to prevent hanging (#731) 2026-08-09 01:24:27 +02:00
5c838384d6 fix: remove Mark-of-the-Web from script files at startup (#724)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-08-09 01:24:14 +02:00
Jeffrey 6957ce4220 Update GUI logo 2026-08-01 16:16:24 +02:00
SashankandGitHub 0f30b62221 fix: support QWord, ExpandString and MultiString in the .reg fallback writer (#715) 2026-07-26 22:33:28 +02:00
JeffreyandGitHub 32cedaf65d Improve registry backup safety and add optional backup skipping (#710) 2026-07-25 20:05:49 +02:00
JeffreyandGitHub 68cacfce89 fix: force stop widget processes to prevent prompt (#713) 2026-07-23 22:23:00 +02:00
JeffreyandGitHub a9c1736e46 refactor: rename NoRestartExplorer parameter to SkipExplorerRestart (#709)
The old `NoRestartExplorer` parameter is kept as an alias and will
continue to work, but is considered deprecated
2026-07-19 22:58:03 +02:00
JeffreyandGitHub 9c033dbf98 Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708) 2026-07-19 22:06:07 +02:00
Hill PatelandGitHub a7292e4f35 fix(registry-restore): reject Data/Kind mismatches before restore begins (#704) 2026-07-19 01:53:04 +02:00
KushidaandGitHub f739a0e474 fix: escape Windows Terminal path (#702) 2026-07-18 15:16:39 +02:00
JeffreyandGitHub 63b1f61fd1 Update README.md 2026-07-17 12:45:45 +02:00
de817399e6 fix: honor cancellation during changes (#700)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-07-14 22:06:40 +02:00
Jeffrey 8fa1332ff0 Update download messages for development and stable versions of Win11Debloat 2026-07-14 21:18:39 +02:00
Jeffrey ce36b8edfb Bump version 2026-07-11 20:07:47 +02:00
JeffreyandGitHub 3b78e8d4ed Fix: Show reboot warning for undoing features that require it (#699) 2026-07-11 20:04:51 +02:00
Jeffrey 74dedc00e9 Add tooltip to user selection selectbox 2026-07-11 00:04:08 +02:00
Jeffrey 5571700634 Disabling Window Snapping now warns users that a reboot is required to apply 2026-07-09 22:02:18 +02:00
Jeffrey c2ea697552 Hide irrelevant Settings home page settings in Windows 10 2026-07-09 22:01:33 +02:00
JeffreyandGitHub ce8c10042e Fix issues with domain accounts & fix run.bat breaking when launched from path with certain characters (#693) 2026-07-08 23:18:04 +02:00
JeffreyandGitHub 89a5d508d8 Fix win10 icons by falling back to Segoe MDL2 Assets (#696) 2026-07-08 23:16:01 +02:00
Jeffrey e38aafe01e Update README.md 2026-07-08 21:40:33 +02:00
Jeffrey d336f98b39 Update feature wording 2026-07-08 21:38:48 +02:00
Raphire a70a2ccbd6 Update README.md 2026-07-08 19:30:25 +02:00
JeffreyandGitHub 0ef789cc3a Add option to prevent auto install of device apps & add LG monitor app removal (#695)
* Add support for removing LG monitor app

* Add option to prevent auto install of device companion apps via Windows Update

* Update CONTRIBUTING.md with missing Apps.json fields
2026-07-08 17:54:13 +02:00
HetCreepandGitHub 82f50d6d13 fix(startup): refuse to run under PowerShell 7 with a clear message (#676) 2026-07-08 15:22:36 +02:00
SqidyandGitHub 949515da3e Added Feature Disabling Notifications Through Win11Debloat (#690) 2026-07-06 19:29:09 +02:00
0eeb8f323a fix(elevation): quote script path and parameter values in admin relaunch (#683)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-07-04 17:35:24 +02:00
HetCreepandGitHub a9a89c1f13 fix(store-search): correct deny permission removal in undo path (#684) 2026-07-04 13:57:50 +02:00
HetCreepandGitHub 339764eb18 Fix broken contributing link in README.md (#688) 2026-07-02 23:03:44 +02:00
3b4a5f16ad fix(restore-gui): surface registry-backup load failures to the user (#685)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-07-02 21:16:47 +02:00
JeffreyandGitHub 9a700afaed Consolidate Get-Dev.ps1 into Get.ps1, add WhatIf support to Get.ps1 (#681) 2026-06-28 22:53:27 +02:00
Jeffrey 26b313372b Update README.md 2026-06-25 16:41:32 +02:00
156 changed files with 9948 additions and 2307 deletions
+39 -4
View File
@@ -18,7 +18,7 @@ You can help us test the latest changes and additions to the script. If you enco
You can launch the prerelease version of Win11Debloat by running this command:
```ps1
& ([scriptblock]::Create((irm "https://debloat.raphi.re/dev")))
& ([scriptblock]::Create((irm "https://debloat.raphi.re/"))) -Dev
```
# Contributing Code
@@ -58,6 +58,25 @@ You can launch the prerelease version of Win11Debloat by running this command:
.\Win11Debloat.ps1
```
### Running Automated Tests
The automated test cases use Pester 5 and do not modify the registry or other
system state. The optional bootstrap step installs Pester for your user account
when needed. To run the complete suite:
```powershell
.\Scripts\Run-Tests.ps1 -Bootstrap
```
After the initial setup, run the suite with:
```powershell
.\Scripts\Run-Tests.ps1
```
GitHub Actions runs the same test command with Windows PowerShell 5.1 for pull
requests and pushes to `master`.
## Implementation Guidelines
### Project Structure
@@ -72,7 +91,7 @@ Win11Debloat/
│ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
│ ├── AppRemoval/ # App package removal logic
│ ├── CLI/ # Command-line interface helpers
│ ├── Features/ # Feature apply/undo logic (e.g. InvokeChanges.ps1, ReplaceStartMenu.ps1)
│ ├── Features/ # Feature apply/undo logic (e.g. Invoke-Changes.ps1, Replace-StartMenu.ps1)
│ ├── FileIO/ # File input/output helpers
│ ├── GUI/ # GUI window definitions and logic
│ ├── Helpers/ # Shared helper functions
@@ -159,10 +178,26 @@ To add a new app that can be removed via Win11Debloat:
"FriendlyName": "Display Name",
"AppId": "AppPackageIdentifier",
"Description": "Brief description of the app",
"SelectedByDefault": true|false
"SelectedByDefault": false,
"Recommendation": "optional",
"RemovalMethod": "Appx"
}
```
**Field Descriptions**:
- `FriendlyName`: Display name shown in the GUI.
- `AppId`: The `AppPackageIdentifier` from `Get-AppxPackage` or the `Id` from `winget list`, depending on removal method.
- `Description`: Brief description of the app shown in the GUI.
- `SelectedByDefault`: Set to `true` only for apps that are largely considered bloatware, otherwise set to `false`.
- `Recommendation`: Indicates how strongly the app is recommended for removal. One of:
- `safe` — safe to remove for most users
- `optional` — can be safely removed if the user doesn't need the app
- `unsafe` — should only remove if the user knows what they are doing
- `RemovalMethod`: The method used to remove the app. One of:
- `Appx` — remove as a standard Appx package via `Remove-AppxPackage` (most apps)
- `WinGet` — remove via WinGet (`winget uninstall`). Use for non-Appx apps such as Microsoft Copilot.
3. **Follow the Guidelines**:
- Use clear, user-friendly names for `FriendlyName`
@@ -204,7 +239,7 @@ Windows Registry Editor Version 5.00
#### 1b. Implement the Feature Logic
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/InvokeChanges.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/Invoke-Changes.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
#### 2. Add Feature to Features.json
+26
View File
@@ -0,0 +1,26 @@
name: Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
pester:
name: Pester (Windows PowerShell 5.1)
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run tests
shell: powershell
run: ./Scripts/Run-Tests.ps1 -Bootstrap
+10 -1
View File
@@ -921,6 +921,14 @@
"Recommendation": "optional",
"RemovalMethod": "Appx"
},
{
"FriendlyName": "LG Monitor App",
"AppId": "LGElectronics.LGMonitorApp",
"Description": "LG OEM software for controlling your LG monitor, Windows Update may silently reinstall this app if you have an LG monitor connected",
"SelectedByDefault": false,
"Recommendation": "optional",
"RemovalMethod": "Appx"
},
{
"FriendlyName": "HP AI Experience Center",
"AppId": "AD2F1837.HPAIExperienceCenter",
@@ -1132,7 +1140,7 @@
]
},
{
"Name": "OEM software (Dell, HP, Lenovo)",
"Name": "OEM software (Dell, HP, Lenovo, LG)",
"AppIds": [
"AD2F1837.HPAIExperienceCenter",
"AD2F1837.HPConnectedMusic",
@@ -1156,6 +1164,7 @@
"AD2F1837.myHP",
"E046963F.LenovoCompanion",
"LenovoCompanyLimited.LenovoVantageService",
"LGElectronics.LGMonitorApp",
"DellInc.DellSupportAssistforPCs",
"DellInc.DellDigitalDelivery",
"DellInc.DellMobileConnect"
+1 -1
View File
@@ -70,4 +70,4 @@
"Value": true
}
]
}
}
+41 -2
View File
@@ -2,50 +2,62 @@
"Version": "1.0",
"Categories": [
{
"CategoryId": "PrivacySuggestedContent",
"Name": "Privacy & Suggested Content",
"Icon": "&#xE72E;"
},
{
"CategoryId": "System",
"Name": "System",
"Icon": "&#xe770;"
},
{
"CategoryId": "StartMenuSearch",
"Name": "Start Menu & Search",
"Icon": "&#xe8fc;"
},
{
"CategoryId": "AI",
"Name": "AI",
"Icon": "&#xe794;"
},
{
"CategoryId": "WindowsUpdate",
"Name": "Windows Update",
"Icon": "&#xe895;"
},
{
"CategoryId": "Taskbar",
"Name": "Taskbar",
"Icon": "&#xe75b;"
},
{
"CategoryId": "Appearance",
"Name": "Appearance",
"Icon": "&#xE771;"
},
{
"CategoryId": "FileExplorer",
"Name": "File Explorer",
"Icon": "&#xec50;"
},
{
"CategoryId": "Gaming",
"Name": "Gaming",
"Icon": "&#xE7FC;"
},
{
"CategoryId": "MultiTasking",
"Name": "Multi-tasking",
"Icon": "&#xE7C4;"
},
{
"CategoryId": "OptionalWindowsFeatures",
"Name": "Optional Windows Features",
"Icon": "&#xefda;"
},
{
"CategoryId": "Other",
"Name": "Other",
"Icon": "&#xE713;"
}
@@ -401,6 +413,19 @@
"MinVersion": null,
"MaxVersion": null
},
{
"FeatureId": "DisableNotifications",
"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.",
"Category": "Privacy & Suggested Content",
"RegistryKey": "Disable_Notifications.reg",
"ApplyText": "Disabling Windows notifications and reminders",
"UndoLabel": "Enable Windows notifications and reminders",
"ApplyUndoText": "Enabling Windows notifications and reminders",
"RegistryUndoKey": "Enable_Notifications.reg",
"MinVersion": null,
"MaxVersion": null
},
{
"FeatureId": "DisableLocationServices",
"Label": "Disable Windows location services & app location access",
@@ -667,7 +692,7 @@
"UndoLabel": "Show Microsoft 365 Copilot ads in Settings Home",
"ApplyUndoText": "Enabling Microsoft 365 Copilot ads in Settings Home",
"RegistryUndoKey": "Enable_Settings_365_Ads.reg",
"MinVersion": null,
"MinVersion": 22000,
"MaxVersion": null
},
{
@@ -680,7 +705,7 @@
"UndoLabel": "Show Settings 'Home' page",
"ApplyUndoText": "Enabling the Settings Home page",
"RegistryUndoKey": "Enable_Settings_Home.reg",
"MinVersion": null,
"MinVersion": 22000,
"MaxVersion": null
},
{
@@ -800,6 +825,7 @@
"UndoLabel": "Enable window snapping",
"ApplyUndoText": "Enabling window snapping",
"RegistryUndoKey": "Enable_Window_Snapping.reg",
"RequiresReboot": true,
"MinVersion": null,
"MaxVersion": null
},
@@ -1356,6 +1382,19 @@
"MinVersion": null,
"MaxVersion": null
},
{
"FeatureId": "DisableDeviceAutoAppDownload",
"Label": "Prevent Windows from auto-installing device companion apps",
"ToolTip": "Stops Windows from silently installing device companion apps via Windows Update when you connect certain devices, like monitors from LG and Alienware. These devices will still work as expected, this only stops Windows Update from automatically installing additional apps without consent.",
"Category": "Windows Update",
"RegistryKey": "Disable_Device_Auto_App_Download.reg",
"ApplyText": "Preventing automatic installation of device companion apps",
"UndoLabel": "Allow automatic installation of device companion apps",
"ApplyUndoText": "Allowing automatic installation of device companion apps",
"RegistryUndoKey": "Enable_Device_Auto_App_Download.reg",
"MinVersion": null,
"MaxVersion": null
},
{
"FeatureId": "ForceRemoveEdge",
"Label": "Forcefully uninstall Microsoft Edge. NOT RECOMMENDED!",
+24 -43
View File
@@ -6,7 +6,7 @@
Win11Debloat is a lightweight, easy to use PowerShell script that allows you to quickly declutter and customize your Windows experience, no installation required! You can use it to remove pre-installed apps, disable telemetry, remove intrusive interface elements and much more. No need to painstakingly go through all the settings yourself or remove apps one by one. Win11Debloat makes the process quick and easy!
The script also includes many features that system administrators and power users will enjoy. Such as a powerful command-line interface, support for Windows Audit mode and the ability to make changes to other Windows users. Please refer to our [wiki](https://github.com/Raphire/Win11Debloat/wiki/) for more details.
The script also includes many features that system administrators and power users will enjoy. Such as a powerful command-line interface, support for Windows Audit mode and the ability to make changes to other Windows users. You can also easily export & import your preferred settings, allowing you to quickly apply the same settings on all your systems. Please refer to our [wiki](https://github.com/Raphire/Win11Debloat/wiki) for more details.
![Win11Debloat Menu](/Assets/Images/menu.png)
@@ -23,14 +23,14 @@ The script also includes many features that system administrators and power user
Download & run the script automatically via PowerShell.
1. Open PowerShell or Terminal, preferably as an administrator.
1. Open PowerShell or Terminal.
2. Copy and paste the command below into PowerShell:
```PowerShell
& ([scriptblock]::Create((irm "https://debloat.raphi.re/")))
```
3. Wait for the script to automatically download Win11Debloat.
3. Wait for the script to automatically download and launch Win11Debloat.
4. Carefully read through and follow the on-screen instructions.
This method supports command-line parameters to customize the behaviour of the script. Please click [here](https://github.com/Raphire/Win11Debloat/wiki/Command%E2%80%90line-Interface#parameters) for more information.
@@ -57,7 +57,7 @@ This method supports command-line parameters to customize the behaviour of the s
3. Temporarily enable PowerShell execution by entering the following command:
```PowerShell
Set-ExecutionPolicy Unrestricted -Scope Process -Force
Set-ExecutionPolicy Bypass -Scope Process -Force
```
4. In PowerShell, navigate to the directory where the files were extracted. Example: `cd c:\Win11Debloat`
@@ -74,10 +74,10 @@ This method supports command-line parameters to customize the behaviour of the s
## Features
Below is an overview of the key features and functionality offered by Win11Debloat. Please refer to [the wiki](https://github.com/Raphire/Win11Debloat/wiki/Default-Settings) for more information about the default settings preset.
Below is an overview of the key features and functionality offered by Win11Debloat. You can visit the [the wiki](https://github.com/Raphire/Win11Debloat/wiki) for more details.
> [!Tip]
> All of the changes made by Win11Debloat can easily be reverted and almost all of the apps can be reinstalled through the Microsoft Store. A full guide on how to revert changes can be found [here](https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes).
> All of the changes made by Win11Debloat can easily be reverted and almost all of the apps can be reinstalled through the Microsoft Store. You can visit [the wiki](https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes) for more information on reverting changes.
#### App Removal
@@ -86,29 +86,21 @@ Below is an overview of the key features and functionality offered by Win11Deblo
#### Privacy & Suggested Content
- Disable telemetry, diagnostic data, activity history, app-launch tracking & targeted ads.
- Disable tips, tricks, suggestions & ads across Windows.
- Disable Windows location services & app location access.
- Disable Find My Device location tracking.
- Disable 'Windows Spotlight' and tips & tricks on the lock screen.
- Disable 'Windows Spotlight' desktop background option.
- Disable ads, suggestions and the MSN news feed in Microsoft Edge.
- Disable tips, tricks, suggestions & ads across Windows, the lock screen and Microsoft Edge.
- Disable Windows location services, app location access and Find My Device location tracking.
- Hide Microsoft 365 ads on the Settings 'Home' page, or hide the 'Home' page entirely.
#### AI Features
- Disable & remove Microsoft Copilot.
- Disable Windows Recall.
- Disable Click to Do, AI text & image analysis tool.
- Disable & remove Microsoft Copilot, Windows Recall and Click to Do.
- Prevent AI service (WSAIFabricSvc) from starting automatically.
- Disable AI Features in Edge.
- Disable AI Features in Paint.
- Disable AI Features in Notepad.
- Disable AI Features in Edge, Paint and Notepad.
#### System
- Disable the Drag Tray for sharing & moving files.
- Restore the old Windows 10 style context menu.
- Turn off Enhance Pointer Precision, also known as mouse acceleration.
- Turn off Enhance Pointer Precision (mouse acceleration).
- Disable the Sticky Keys keyboard shortcut.
- Disable Storage Sense automatic disk cleanup.
- Disable fast start-up to ensure a full shutdown.
@@ -120,54 +112,43 @@ Below is an overview of the key features and functionality offered by Win11Deblo
- Prevent Windows from getting updates as soon as they're available.
- Prevent automatic restarts after updates while signed in.
- Disable sharing of downloaded updates with other PCs, also known as Delivery Optimization.
- Prevent Windows from auto-installing device companion apps, like LG Monitor App, Alienware Command Center and more.
#### Appearance
- Enable dark mode for system and apps.
- Disable transparency effects
- Disable animations and visual effects.
- Disable transparency, animations and visual effects.
#### Start Menu & Search
- Remove or replace all pinned apps from the start menu.
- Hide the recommended section in the start menu.
- Hide the 'All Apps' section in the start menu.
- Customize the start menu by removing pinned apps, hiding recommendations, and customizing the 'All Apps' section.
- Disable the Phone Link mobile devices integration in the start menu.
- Disable Bing web search & Copilot integration in Windows search.
- Disable Microsoft Store app suggestions in Windows search.
- Disable Search Highlights (dynamic/branded content) in the taskbar search box.
- Disable local Windows search history.
- Disable Bing web search & Copilot integration and Microsoft Store app suggestions in Windows search.
#### Taskbar
- Align taskbar icons to the left.
- Hide or change the search icon/box on the taskbar.
- Hide the taskview button from the taskbar.
- Change taskbar alignment.
- Customize or hide taskbar buttons like the search bar, taskview and more.
- Disable widgets on the taskbar & lock screen.
- Hide the chat (meet now) icon from the taskbar.
- Enable the 'End Task' option in the taskbar right click menu.
- Enable the 'End Task' option in the taskbar right click menu to quickly force-close apps.
- Enable the 'Last Active Click' behavior in the taskbar app area. This allows you to repeatedly click on an application's icon in the taskbar to switch focus between the open windows of that application.
- Choose how app icons are shown on the taskbar when using multiple monitors.
- Choose combine mode for taskbar buttons and labels.
- Customize how app buttons are shown on the taskbar.
#### File Explorer
- Change the default location that File Explorer opens to.
- Show file extensions for known file types.
- Show hidden files, folders and drives.
- Hide the Home or Gallery section from the File Explorer navigation pane.
- Hide the Home, Gallery or OneDrive section from the File Explorer navigation pane.
- Hide duplicate removable drive entries from the File Explorer navigation pane, so only the entry under 'This PC' remains.
- Add all common folders (Desktop, Downloads, etc.) back to 'This PC' in File Explorer.
- Hide the 3D objects, music or OneDrive folder from the File Explorer navigation pane.
- Hide the 'Include in library', 'Give access to' and 'Share' options from the context menu.
- Change drive letter position or visibility in File Explorer.
#### Multi-tasking
- Disable window snapping.
- Disable Snap Assist suggestions when snapping a window.
- Disable Snap Layout suggestions when dragging windows to the top of screen and when hovering on the maximize button.
- Change if tabs are shown when snapping or pressing Alt+Tab.
- Disable Snap Assist and Snap Layout suggestions when dragging or snapping windows.
- Change whether tabs are shown when snapping windows or pressing Alt+Tab.
#### Optional Windows Features
@@ -181,12 +162,12 @@ Below is an overview of the key features and functionality offered by Win11Deblo
#### Advanced Features
- Option to [apply changes to a different user](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#running-as-another-user), instead of the currently logged in user.
- Ability to [apply changes to a different user](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#running-as-another-user), instead of the currently logged in user.
- [Sysprep mode](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#sysprep-mode) to apply changes to the Windows Default user profile. Which ensures, all new users will have the changes automatically applied to them.
## Contributing
We welcome contributions of all kinds! Please see our [Contributing Guidelines](/.github/CONTRIBUTING.md) for detailed instructions on how to get started and best practices for contributing.
We welcome contributions of all kinds! Please see our [Contributing Guidelines](https://github.com/Raphire/Win11Debloat/blob/master/.github/CONTRIBUTING.md) for detailed instructions on how to get started and best practices for contributing.
## License
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -11
View File
@@ -1,5 +1,5 @@
@echo off
setlocal EnableDelayedExpansion
setlocal
:: Set Windows Terminal installation paths. (Default and Scoop installation)
set "wtDefaultPath=%LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe"
@@ -15,22 +15,20 @@ if exist "%wtDefaultPath%" (
) else if exist "%wtScoopPath%" (
set "wtPath=%wtScoopPath%"
) else (
echo Windows Terminal not found. Using default PowerShell instead.
set "wtPath="
)
set "SCRIPT_PATH=\"%~dp0Win11Debloat.ps1\""
:: Interpolated into a PS single-quoted string below;
:: Apostrophes escaped via %:'=''% and -File arg uses [char]34 to avoid quote-parity bugs.
set "SCRIPT_PATH=%~dp0Win11Debloat.ps1"
:: Launch script
if defined wtPath (
call :Log Launching Win11Debloat.ps1 with Windows Terminal...
PowerShell -Command "Start-Process -FilePath '%wtPath%' -ArgumentList 'PowerShell -NoProfile -ExecutionPolicy Bypass -File %SCRIPT_PATH%' -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
call :Log Script execution passed successfully to Win11Debloat.ps1
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $w='%wtPath:'=''%'; $q=[char]34; Start-Process -FilePath $w -ArgumentList ('PowerShell -NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
) else (
echo Windows Terminal not found. Using default PowerShell instead...
echo Windows Terminal not found, using default PowerShell...
call :Log Windows Terminal not found. Using default PowerShell to launch Win11Debloat.ps1...
PowerShell -ExecutionPolicy Bypass -Command "& {Start-Process PowerShell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File %SCRIPT_PATH%' -Verb RunAs}" >> "%logFile%" || call :Error "PowerShell command failed"
call :Log Script execution passed successfully to Win11Debloat.ps1
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $q=[char]34; Start-Process PowerShell -ArgumentList ('-NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
)
echo.
@@ -40,11 +38,13 @@ goto :EOF
:: Logging Function
:Log
echo %* >> "%logFile%"
echo(%* >> "%logFile%"
goto :EOF
:: Error Handler
:Error
echo ERROR: %*
echo(ERROR: %*
call :Log ERROR: %*
echo Logged in %logFile%
pause
goto :EOF
+1 -1
View File
@@ -105,7 +105,7 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
Text="&#xEB52;"
Foreground="{DynamicResource TitleBarCloseHoverColor}"
Margin="0,0,8,0"/>
+1 -1
View File
@@ -16,7 +16,7 @@
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="Template">
<Setter.Value>
+5 -5
View File
@@ -44,7 +44,7 @@
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock Text="&#xE8BB;" FontFamily="Segoe Fluent Icons" FontSize="10"/>
<TextBlock Text="&#xE8BB;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10"/>
</Button>
<StackPanel Grid.Row="0" Grid.RowSpan="2">
@@ -54,7 +54,7 @@
<!-- Loading icon (spinning) -->
<TextBlock x:Name="ApplySpinnerIcon"
Text="&#xE895;"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="36"
Foreground="{DynamicResource ButtonBgColor}"
HorizontalAlignment="Center"
@@ -115,7 +115,7 @@
<!-- Success icon -->
<TextBlock x:Name="ApplyCompletionIcon"
Text="&#xE73E;"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="40"
Foreground="{DynamicResource ButtonBgColor}"
HorizontalAlignment="Center"
@@ -141,7 +141,7 @@
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE7BA;"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="14"
Foreground="#e8912d"
VerticalAlignment="Center"
@@ -163,7 +163,7 @@
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"
AutomationProperties.Name="Support the creator">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xEB52;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,-1"/>
<TextBlock Text="&#xEB52;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,-1"/>
<TextBlock Text="Support the creator" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,1"/>
</StackPanel>
</Button>
+40 -38
View File
@@ -65,7 +65,7 @@
<!-- Category header icon style -->
<Style x:Key="CategoryHeaderIcon" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe Fluent Icons"/>
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="LineHeight" Value="20"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
@@ -196,7 +196,7 @@
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="FontFamily" Value="Segoe Fluent Icons"/>
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="Template">
<Setter.Value>
@@ -362,8 +362,8 @@
</TextBlock.Style>
</TextBlock>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="KofiBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="&#xEB52;" FontFamily="Segoe Fluent Icons" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Support the creator" AutomationProperties.Name="Support the creator"/>
<Button x:Name="MenuBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="&#xE700;" FontFamily="Segoe Fluent Icons" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Options" AutomationProperties.Name="Options">
<Button x:Name="KofiBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="&#xEB52;" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Support the creator" AutomationProperties.Name="Support the creator"/>
<Button x:Name="MenuBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="&#xE700;" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Options" AutomationProperties.Name="Options">
<Button.ContextMenu>
<ContextMenu x:Name="MainMenu">
<ContextMenu.Resources>
@@ -380,38 +380,38 @@
</ContextMenu.Resources>
<MenuItem x:Name="ImportConfigBtn" Header="Import config" AutomationProperties.Name="Import configuration">
<MenuItem.Icon>
<TextBlock Text="&#xe838;" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xe838;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="ExportConfigBtn" Header="Export config" AutomationProperties.Name="Export configuration">
<MenuItem.Icon>
<TextBlock Text="&#xe74e;" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xe74e;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="RestoreBackupBtn" Header="Restore backup" AutomationProperties.Name="Restore registry backup">
<MenuItem.Icon>
<TextBlock Text="&#xe81c;" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xe81c;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="MenuDocumentation" Header="Documentation" AutomationProperties.Name="Documentation">
<MenuItem.Icon>
<TextBlock Text="&#xe736;" FontFamily="Segoe MDL2 Assets" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xe736;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="MenuReportBug" Header="Report a bug" AutomationProperties.Name="Report a bug">
<MenuItem.Icon>
<TextBlock Text="&#xEBE8;" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xEBE8;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="MenuLogs" Header="Logs" AutomationProperties.Name="Logs">
<MenuItem.Icon>
<TextBlock Text="&#xf000;" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xf000;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="MenuAbout" Header="About" AutomationProperties.Name="About">
<MenuItem.Icon>
<TextBlock Text="&#xE946;" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock Text="&#xE946;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
@@ -470,13 +470,13 @@
<Path x:Name="LogoFallback" Data="M0,0 L80,0 L80,80 L0,80 Z M90,0 L170,0 L170,80 L90,80 Z M0,90 L80,90 L80,170 L0,170 Z M90,90 L170,90 L170,170 L90,170 Z"
Fill="{DynamicResource ButtonBgColor}" Stretch="Uniform" Margin="10"/>
<!-- Sparkle effects -->
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="50" Height="50" Margin="0,0,2,2">
<Path Canvas.Left="10" Canvas.Top="16" Data="M12,0 L14,10 L24,12 L14,14 L12,24 L10,14 L0,12 L10,10 Z"
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" Stretch="Uniform"/>
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="80" Height="80" Margin="0,0,2,2">
<Path Canvas.Left="12" Canvas.Top="32" Data="M12,0 L14,10 L24,12 L14,14 L12,24 L10,14 L0,12 L10,10 Z"
Fill="{DynamicResource AppAccentColor}" Width="60" Height="60" Stretch="Uniform"/>
<Path Canvas.Left="0" Canvas.Top="0" Data="M6,0 L7,5 L12,6 L7,7 L6,12 L5,7 L0,6 L5,5 Z"
Fill="{DynamicResource AppAccentColor}" Width="22" Height="22" Stretch="Uniform"/>
<Path Canvas.Left="35" Canvas.Top="8" Data="M4,0 L5,3 L8,4 L5,5 L4,8 L3,5 L0,4 L3,3 Z"
Fill="{DynamicResource AppAccentColor}" Width="17" Height="17" Stretch="Uniform"/>
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" Stretch="Uniform"/>
<Path Canvas.Left="55" Canvas.Top="16" Data="M4,0 L5,3 L8,4 L5,5 L4,8 L3,5 L0,4 L3,3 Z"
Fill="{DynamicResource AppAccentColor}" Width="25" Height="25" Stretch="Uniform"/>
</Canvas>
</Grid>
</Viewbox>
@@ -490,7 +490,7 @@
<Border HorizontalAlignment="Center" BorderBrush="{DynamicResource AppBorderColor}" BorderThickness="1" CornerRadius="4" Background="{DynamicResource CardBgColor}" Padding="16,12" Width="500">
<StackPanel>
<TextBlock Text="What user do you want to apply changes to?" Style="{StaticResource CategoryHeaderTextBlock}"/>
<ComboBox x:Name="UserSelectionCombo" Margin="0,0,0,6" AutomationProperties.Name="Apply Changes To">
<ComboBox x:Name="UserSelectionCombo" Margin="0,0,0,6" AutomationProperties.Name="Apply Changes To" ToolTip="The currently logged-in user profile">
<ComboBoxItem Content="Current User" IsSelected="True"/>
<ComboBoxItem Content="Other User"/>
<ComboBoxItem Content="Windows Default User (Sysprep)"/>
@@ -506,7 +506,7 @@
</Grid.ColumnDefinitions>
<TextBlock x:Name="UsernameTextBoxPlaceholder" Grid.Column="0" Text="Enter username" Foreground="{DynamicResource AppFgColor}" Opacity="0.7" FontSize="13" Margin="3,0,0,1" VerticalAlignment="Center" IsHitTestVisible="False"/>
<TextBox x:Name="OtherUsernameTextBox" Grid.Column="0" Style="{DynamicResource TextBoxInputStyle}" Text="" AutomationProperties.Name="Enter username"/>
<TextBlock Grid.Column="1" Text="&#xE77B;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
<TextBlock Grid.Column="1" Text="&#xE77B;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
</Grid>
</Border>
</Border>
@@ -517,13 +517,13 @@
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,8,0,4">
<Button x:Name="HomeDefaultModeBtn" Width="227" Height="50" Style="{DynamicResource PrimaryButtonStyle}" Margin="0,0,12,0" AutomationProperties.Name="Default Mode">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xe735;" FontFamily="Segoe Fluent Icons" FontSize="16" VerticalAlignment="Center" Margin="0,0,8,-1"/>
<TextBlock Text="&#xe735;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" VerticalAlignment="Center" Margin="0,0,8,-1"/>
<TextBlock Text="Default Mode" ToolTip="Quickly select the recommended settings" FontWeight="SemiBold" VerticalAlignment="Center" FontSize="17" Margin="0,0,0,1"/>
</StackPanel>
</Button>
<Button x:Name="HomeStartBtn" Width="227" Height="50" Style="{DynamicResource SecondaryButtonStyle}" AutomationProperties.Name="Custom Setup">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xe90f;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,-1"/>
<TextBlock Text="&#xe90f;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,-1"/>
<TextBlock Text="Custom Setup" ToolTip="Manually select your preferred settings" FontWeight="SemiBold" VerticalAlignment="Center" FontSize="17" Margin="0,0,0,1"/>
</StackPanel>
</Button>
@@ -585,9 +585,9 @@
</Style>
</ToggleButton.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE735;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
<TextBlock Text="&#xE735;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
<TextBlock Text="Quick Select" FontSize="13" VerticalAlignment="Center" Margin="0,0,6,1"/>
<TextBlock x:Name="PresetsArrow" Text="&#xE70D;" FontFamily="Segoe Fluent Icons" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
<TextBlock x:Name="PresetsArrow" Text="&#xE70D;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform>
<RotateTransform x:Name="PresetsArrowRotation" Angle="0"/>
</TextBlock.RenderTransform>
@@ -596,7 +596,7 @@
</ToggleButton>
<Button x:Name="ClearAppSelectionBtn" ToolTip="Clear all selected apps" Style="{DynamicResource SecondaryButtonStyle}" Height="32" Padding="10,0" Margin="0,0,10,0" AutomationProperties.Name="Clear Selection">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE711;" FontFamily="Segoe Fluent Icons" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
<TextBlock Text="&#xE711;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
<TextBlock Text="Clear Selection" FontSize="13" VerticalAlignment="Center" Margin="0,0,0,1"/>
</StackPanel>
</Button>
@@ -627,7 +627,7 @@
</Grid.ColumnDefinitions>
<TextBlock x:Name="AppSearchPlaceholder" Grid.Column="0" Text="Search apps..." Foreground="{DynamicResource AppFgColor}" Opacity="0.7" FontSize="13" Margin="3,0,0,1" VerticalAlignment="Center" IsHitTestVisible="False"/>
<TextBox x:Name="AppSearchBox" Grid.Column="0" Style="{DynamicResource TextBoxInputStyle}" Text="" AutomationProperties.Name="Search app"/>
<TextBlock Grid.Column="1" Text="&#xE721;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
<TextBlock Grid.Column="1" Text="&#xE721;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
</Grid>
</Border>
</Border>
@@ -658,19 +658,19 @@
</Grid.ColumnDefinitions>
<StackPanel x:Name="HeaderNameBtn" Grid.Column="1" Orientation="Horizontal" Cursor="Hand" VerticalAlignment="Center" Style="{StaticResource SortHeaderBtnStyle}">
<TextBlock Text="Name" FontWeight="SemiBold" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock x:Name="SortArrowName" Text="&#xE70E;" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
<TextBlock x:Name="SortArrowName" Text="&#xE70E;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform><RotateTransform Angle="0"/></TextBlock.RenderTransform>
</TextBlock>
</StackPanel>
<StackPanel x:Name="HeaderDescriptionBtn" Grid.Column="2" Orientation="Horizontal" Cursor="Hand" VerticalAlignment="Center" Margin="8,0,0,0" Style="{StaticResource SortHeaderBtnStyle}">
<TextBlock Text="Description" FontWeight="SemiBold" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock x:Name="SortArrowDescription" Text="&#xE70E;" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
<TextBlock x:Name="SortArrowDescription" Text="&#xE70E;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform><RotateTransform Angle="0"/></TextBlock.RenderTransform>
</TextBlock>
</StackPanel>
<StackPanel x:Name="HeaderAppIdBtn" Grid.Column="3" Orientation="Horizontal" Cursor="Hand" VerticalAlignment="Center" Style="{StaticResource SortHeaderBtnStyle}">
<TextBlock Text="App ID" FontWeight="SemiBold" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
<TextBlock x:Name="SortArrowAppId" Text="&#xE70E;" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
<TextBlock x:Name="SortArrowAppId" Text="&#xE70E;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform><RotateTransform Angle="0"/></TextBlock.RenderTransform>
</TextBlock>
</StackPanel>
@@ -684,7 +684,7 @@
</ScrollViewer>
<Border x:Name="LoadingAppsIndicator" CornerRadius="0,0,4,4" Background="{DynamicResource CardBgColor}" Opacity="0.8" Visibility="Collapsed">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="&#xF16A;" FontFamily="Segoe Fluent Icons" FontSize="28" Foreground="{DynamicResource AppFgColor}" HorizontalAlignment="Center" Margin="0,0,0,8" RenderTransformOrigin="0.5,0.5">
<TextBlock Text="&#xF16A;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="28" Foreground="{DynamicResource AppFgColor}" HorizontalAlignment="Center" Margin="0,0,0,8" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform>
<RotateTransform Angle="0"/>
</TextBlock.RenderTransform>
@@ -789,9 +789,9 @@
</Style>
</ToggleButton.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE735;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
<TextBlock Text="&#xE735;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
<TextBlock Text="Quick Select" FontSize="13" VerticalAlignment="Center" Margin="0,0,6,1"/>
<TextBlock x:Name="TweaksPresetsArrow" Text="&#xE70D;" FontFamily="Segoe Fluent Icons" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
<TextBlock x:Name="TweaksPresetsArrow" Text="&#xE70D;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform>
<RotateTransform Angle="0"/>
</TextBlock.RenderTransform>
@@ -800,7 +800,7 @@
</ToggleButton>
<Button x:Name="ClearAllTweaksBtn" ToolTip="Clear all selected tweaks" Style="{DynamicResource SecondaryButtonStyle}" Padding="10,0" Height="32" Margin="0,0,10,0" AutomationProperties.Name="Clear Selection">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE711;" FontFamily="Segoe Fluent Icons" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
<TextBlock Text="&#xE711;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
<TextBlock Text="Clear Selection" FontSize="13" VerticalAlignment="Center" Margin="0,0,0,1"/>
</StackPanel>
</Button>
@@ -832,7 +832,7 @@
</Grid.ColumnDefinitions>
<TextBlock x:Name="TweakSearchPlaceholder" Grid.Column="0" Text="Search tweaks..." Foreground="{DynamicResource AppFgColor}" Opacity="0.7" FontSize="13" Margin="3,0,0,1" VerticalAlignment="Center" IsHitTestVisible="False"/>
<TextBox x:Name="TweakSearchBox" Grid.Column="0" Style="{DynamicResource TextBoxInputStyle}" Text="" AutomationProperties.Name="Search tweaks"/>
<TextBlock Grid.Column="1" Text="&#xE721;" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
<TextBlock Grid.Column="1" Text="&#xE721;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
</Grid>
</Border>
</Border>
@@ -933,12 +933,14 @@
<StackPanel>
<TextBlock Text="Options" Style="{StaticResource CategoryHeaderTextBlock}"/>
<!-- Restore Point Option -->
<StackPanel>
<CheckBox x:Name="RegistryBackupCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a registry backup (Recommended)" AutomationProperties.Name="Create a registry backup (Recommended)"/>
</StackPanel>
<StackPanel>
<CheckBox x:Name="RestorePointCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a system restore point (Recommended)" AutomationProperties.Name="Create a system restore point (Recommended)"/>
</StackPanel>
<!-- Restart Explorer Option -->
<StackPanel>
<CheckBox x:Name="RestartExplorerCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" Content="Restart the Windows Explorer process to apply all changes immediately" AutomationProperties.Name="Restart the Windows Explorer process to apply all changes immediately"/>
</StackPanel>
@@ -986,7 +988,7 @@
</Button>
<Button x:Name="DeploymentApplyBtn" Style="{DynamicResource PrimaryButtonStyle}" Width="200" Height="44" HorizontalAlignment="Center" AutomationProperties.Name="Apply Changes">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE73E;" FontFamily="Segoe Fluent Icons" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="&#xE73E;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="Apply Changes" VerticalAlignment="Center" FontSize="18" FontWeight="SemiBold" Margin="8,0,0,4"/>
</StackPanel>
</Button>
@@ -1006,7 +1008,7 @@
</Grid.ColumnDefinitions>
<Button x:Name="PreviousBtn" Grid.Column="0" Width="120" Height="36" Style="{DynamicResource SecondaryButtonStyle}" Visibility="Collapsed" AutomationProperties.Name="Back">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE72B;" FontFamily="Segoe Fluent Icons" FontSize="12" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="&#xE72B;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="12" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Back" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,3"/>
</StackPanel>
</Button>
@@ -1015,7 +1017,7 @@
<Button x:Name="NextBtn" Width="120" Height="36" Style="{DynamicResource PrimaryButtonStyle}" AutomationProperties.Name="Next">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Next" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,3"/>
<TextBlock Text="&#xE72A;" FontFamily="Segoe Fluent Icons" FontSize="12" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="&#xE72A;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="12" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
+1 -1
View File
@@ -49,7 +49,7 @@
<!-- Icon -->
<TextBlock x:Name="IconText"
Grid.Column="0"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="24"
Foreground="{DynamicResource AppFgColor}"
VerticalAlignment="Center"
+3 -3
View File
@@ -92,7 +92,7 @@
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock Text="&#xE8BB;" FontFamily="Segoe Fluent Icons" FontSize="10"/>
<TextBlock Text="&#xE8BB;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10"/>
</Button>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0">
@@ -135,7 +135,7 @@
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="&#xEF58;"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="24"
VerticalAlignment="Center"
Margin="14,0,14,0"/>
@@ -165,7 +165,7 @@
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="&#xE8FC;"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="24"
VerticalAlignment="Center"
Margin="14,0,14,0"/>
+3 -3
View File
@@ -272,7 +272,7 @@
<Border x:Name="Border" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4">
<TextBlock x:Name="Arrow"
Text="&#xE70D;"
FontFamily="Segoe Fluent Icons"
FontFamily="{DynamicResource AppIconFontFamily}"
FontSize="10"
HorizontalAlignment="Right"
VerticalAlignment="Center"
@@ -393,12 +393,12 @@
</Grid.ColumnDefinitions>
<Border x:Name="CheckBoxBorder" Grid.Column="0" Width="20" Height="20" Background="{DynamicResource CheckBoxBgColor}" BorderBrush="{DynamicResource CheckBoxBorderColor}" BorderThickness="1" CornerRadius="4" Margin="0,0,8,0">
<Grid>
<TextBlock x:Name="CheckMark" Text="&#xE73E;" FontFamily="Segoe Fluent Icons" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0">
<TextBlock x:Name="CheckMark" Text="&#xE73E;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0">
<TextBlock.Clip>
<RectangleGeometry x:Name="CheckMarkClip" Rect="0,0,0,16"/>
</TextBlock.Clip>
</TextBlock>
<TextBlock x:Name="IndeterminateMark" Text="&#xe9ae;" FontFamily="Segoe Fluent Icons" FontSize="13" FontWeight="Bold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" Margin="1,0,0,1">
<TextBlock x:Name="IndeterminateMark" Text="&#xe9ae;" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="13" FontWeight="Bold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" Margin="1,0,0,1">
<TextBlock.Clip>
<RectangleGeometry x:Name="IndeterminateMarkClip" Rect="0,0,0,12"/>
</TextBlock.Clip>
-57
View File
@@ -1,57 +0,0 @@
# Forcefully removes Microsoft Edge using its uninstaller
# Credit: Based on work from loadstring1 & ave9858
function ForceRemoveEdge {
Write-Host "> Forcefully uninstalling Microsoft Edge..."
$regView = [Microsoft.Win32.RegistryView]::Registry32
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
$hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev').SetValue('AllowUninstall', '')
# Create stub (This somehow allows uninstalling Edge)
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
New-Item $edgeStub -ItemType Directory | Out-Null
New-Item "$edgeStub\MicrosoftEdge.exe" | Out-Null
# Remove edge
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
if ($null -ne $uninstallRegKey) {
Write-Host "Running uninstaller..."
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
Invoke-NonBlocking -ScriptBlock {
param($cmd)
Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait
} -ArgumentList $uninstallString
Write-Host "Removing leftover files..."
$edgePaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
"$edgeStub"
)
foreach ($path in $edgePaths) {
if (Test-Path -Path $path) {
Remove-Item -Path $path -Force -Recurse -ErrorAction SilentlyContinue
Write-Host " Removed $path" -ForegroundColor DarkGray
}
}
Write-Host "Cleaning up registry..."
# Remove MS Edge from autostart
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "Microsoft Edge Update" /f *>$null
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "Microsoft Edge Update" /f *>$null
Write-Host "Microsoft Edge was uninstalled"
}
else {
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
}
}
@@ -17,7 +17,7 @@
PSCustomObject[] with Name and Id properties. Returns $null on
failure, or an empty array when winget succeeds but lists no apps.
#>
function GetInstalledAppsViaWinget {
function Get-WingetInstalledApps {
param (
[int]$TimeOut = 10,
[switch]$NonBlocking
@@ -66,7 +66,14 @@ function GetInstalledAppsViaWinget {
}
}
if ($dataStart -lt 0 -or $dataStart -ge $lines.Count) { return @() }
# A missing table separator means the output is malformed or empty
if ($dataStart -lt 0) {
return $null
}
if ($dataStart -ge $lines.Count) {
return ,@()
}
$apps = [System.Collections.Generic.List[object]]::new()
@@ -94,7 +101,7 @@ function GetInstalledAppsViaWinget {
}
}
return @($apps)
return ,@($apps)
}
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
@@ -107,4 +114,4 @@ function GetInstalledAppsViaWinget {
else {
return & $fetchBlock $TimeOut
}
}
}
@@ -0,0 +1,146 @@
<#
.SYNOPSIS
Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries.
.OUTPUTS
System.Boolean. $true when Edge is uninstalled and cleanup succeeds; otherwise $false.
#>
function Invoke-ForceRemoveEdge {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Forcefully uninstall Microsoft Edge" -ForegroundColor Cyan
return $true
}
try {
Write-Host "> Forcefully uninstalling Microsoft Edge..."
$regView = [Microsoft.Win32.RegistryView]::Registry32
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
$edgeUpdateKey = $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev')
$edgeUpdateKey.SetValue('AllowUninstall', '')
# Create stub (This somehow allows uninstalling Edge)
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
New-Item $edgeStub -ItemType Directory -Force -ErrorAction Stop | Out-Null
New-Item "$edgeStub\MicrosoftEdge.exe" -ItemType File -Force -ErrorAction Stop | Out-Null
# Remove edge
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
if ($null -eq $uninstallRegKey) {
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
return $false
}
Write-Host "Running uninstaller..."
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
$exitCode = Invoke-NonBlocking -ScriptBlock {
param($cmd)
$process = Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait -PassThru
return $process.ExitCode
} -ArgumentList $uninstallString
if ($exitCode -ne 0) {
Write-Warning "Microsoft Edge uninstaller failed with exit code $exitCode."
return $false
}
Write-Host "Removing leftover files..."
$cleanupSucceeded = $true
$edgePaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
"$edgeStub"
)
foreach ($path in $edgePaths) {
if (Test-Path -Path $path) {
try {
Remove-Item -Path $path -Force -Recurse -ErrorAction Stop
Write-Host " Removed $path" -ForegroundColor DarkGray
}
catch {
Write-Warning "Failed to remove Edge leftover '$path': $($_.Exception.Message)"
$cleanupSucceeded = $false
}
}
}
Write-Host "Cleaning up registry..."
$registryCleanupSucceeded = $true
# Remove MS Edge from autostart. Missing values are already-clean state,
# while failures to inspect or remove an existing value are reported.
$autostartValues = @(
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'Microsoft Edge Update' },
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'Microsoft Edge Update' }
)
foreach ($autostartValue in $autostartValues) {
if (-not (Remove-EdgeAutostartValue -Path $autostartValue.Path -Name $autostartValue.Name)) {
$registryCleanupSucceeded = $false
}
}
if (-not $cleanupSucceeded -or -not $registryCleanupSucceeded) {
Write-Warning "Microsoft Edge was uninstalled, but some leftover files or autostart entries could not be removed."
return $false
}
Write-Host "Microsoft Edge was uninstalled"
return $true
}
catch {
Write-Warning "Failed to forcefully uninstall Microsoft Edge: $($_.Exception.Message)"
return $false
}
finally {
if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() }
if ($uninstallRegKey) { $uninstallRegKey.Dispose() }
if ($hklm) { $hklm.Dispose() }
}
}
<#
.SYNOPSIS
Removes an Edge autostart registry value when it exists.
.OUTPUTS
System.Boolean. $true when the value is absent or removed; $false when inspection or removal fails.
#>
function Remove-EdgeAutostartValue {
param(
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[string]$Name
)
try {
$properties = Get-ItemProperty -Path $Path -ErrorAction Stop
}
catch [System.Management.Automation.ItemNotFoundException] {
return $true
}
catch {
Write-Warning "Failed to inspect Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
return $false
}
if (-not $properties.PSObject.Properties[$Name]) {
return $true
}
try {
Remove-ItemProperty -Path $Path -Name $Name -ErrorAction Stop
return $true
}
catch {
Write-Warning "Failed to remove Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
return $false
}
}
+400
View File
@@ -0,0 +1,400 @@
<#
.SYNOPSIS
Removes one or more Windows app packages based on the target scope.
.DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
The removal method (winget vs. Appx cmdlets) is determined per-app from
Apps.json. A scheduled task is only created when the User or Sysprep
parameter was passed. After winget removal, the system is checked to
confirm whether the app is still installed before reporting an error.
Returns early if the CancelRequested flag is set.
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
.EXAMPLE
Remove-SelectedApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
.EXAMPLE
Remove-SelectedApps -appsList (Generate-AppsList)
.OUTPUTS
System.Boolean. $true when all removals can be confirmed; otherwise $false.
#>
function Remove-SelectedApps {
param (
$appslist
)
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
return $true
}
$failuresBefore = $script:AppRemovalFailures
$targetUser = Get-TargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$wingetRemovedApps = @()
$wingetRemovalFailures = @{}
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return $false }
$appIndex++
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
}
Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
$removalSucceeded = Remove-WinGetApp -app $app
$wingetRemovedApps += $app
if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) {
$wingetRemovalFailures[$app] = $true
}
}
else {
if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) {
$script:AppRemovalFailures++
}
}
}
if ($script:CancelRequested) {
return $false
}
# Check whether any winget-removed apps are still present, and report errors for each one.
if ($wingetRemovedApps.Count -gt 0) {
$postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null }
$edgeForceRemoveRequested = $false
$edgeForceRemoveSucceeded = $false
if ($null -eq $postRemovalList) {
$script:AppRemovalVerificationUnavailable = $true
foreach ($app in $wingetRemovedApps) {
$wingetRemovalFailures[$app] = $true
}
}
else {
foreach ($app in $wingetRemovedApps) {
if (-not (Test-AppInWingetList -appId $app -InstalledList $postRemovalList)) {
continue
}
if ($edgeIds -contains $app) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
if (-not $edgeForceRemoveRequested) {
$edgeForceRemoveRequested = $true
$edgeForceRemoveSucceeded = Request-EdgeForceRemove
}
if ($edgeForceRemoveSucceeded) {
continue
}
}
else {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
$wingetRemovalFailures[$app] = $true
}
}
}
$script:AppRemovalFailures += $wingetRemovalFailures.Count
return ($script:AppRemovalFailures -eq $failuresBefore)
}
<#
.SYNOPSIS
Uninstalls an app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app, with a bounded execution time.
WinGet's own exit code/success reporting is unreliable and is only logged
for diagnostics; it never causes this function to report failure. Callers
verify removal with a post-removal inventory check instead. This function
only reports failure when the winget invocation itself throws a terminating
error (e.g. it times out or cannot be started). If the User or Sysprep
parameter was passed, also schedules removal for future logins.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
.PARAMETER TimeoutSeconds
Maximum time to allow the foreground WinGet uninstall to run. Defaults
to 120 seconds.
.OUTPUTS
System.Boolean. $true unless the winget invocation threw a terminating error
or any required RunOnce scheduling failed; otherwise $false.
#>
function Remove-WinGetApp {
param(
[string]$app,
[int]$TimeoutSeconds = 120
)
if (-not $script:WingetInstalled) {
Write-Error "WinGet is either not installed or is outdated; $app could not be removed"
return $false
}
$uninstallCommandSucceeded = $true
$exitCode = $null
try {
$uninstallResult = Invoke-NonBlocking -ScriptBlock {
param($appId)
$output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1)
return [PSCustomObject]@{
ExitCode = $LASTEXITCODE
Output = $output
}
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null })
$exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' }
Write-Verbose "WinGet uninstall for $app returned exit code $exitCode."
}
catch {
$uninstallCommandSucceeded = $false
if ($_.Exception.Message -like 'Operation timed out after *') {
Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_"
}
else {
Write-Verbose "WinGet uninstall for $app failed: $_"
}
}
$scheduleSucceeded = $true
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
}
return ($uninstallCommandSucceeded -and $scheduleSucceeded)
}
<#
.SYNOPSIS
Writes captured WinGet uninstall output to the verbose stream.
.OUTPUTS
None.
#>
function Write-WinGetUninstallOutput {
param(
[object[]]$Output
)
foreach ($line in @($Output)) {
if ($null -eq $line) { continue }
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
if ([string]::IsNullOrWhiteSpace($lineText)) { continue }
Write-Verbose $lineText
}
}
<#
.SYNOPSIS
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
.PARAMETER app
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
.PARAMETER targetUser
Target scope: "AllUsers", "CurrentUser", or a specific username.
#>
function Remove-AppxApp {
param([string]$app, [string]$targetUser)
$appPattern = '*' + $app + '*'
try {
$removalResult = Invoke-NonBlocking -ScriptBlock {
param($pattern, $target)
$removalErrors = @()
$getPackageParams = @{ Name = $pattern; ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
$removePackageParams = @{ ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
switch ($target) {
'AllUsers' {
$getPackageParams.AllUsers = $true
$removePackageParams.AllUsers = $true
}
'CurrentUser' { }
default {
$userAccount = New-Object System.Security.Principal.NTAccount($target)
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
$getPackageParams.User = $userSid
$removePackageParams.User = $userSid
}
}
foreach ($package in @(Get-AppxPackage @getPackageParams)) {
$removePackageParams.Package = $package.PackageFullName
$null = Remove-AppxPackage @removePackageParams
}
if ($target -eq 'AllUsers') {
$provisionedPackages = @(Get-AppxProvisionedPackage -Online -ErrorAction Continue -ErrorVariable +removalErrors | Where-Object { $_.PackageName -like $pattern })
foreach ($package in $provisionedPackages) {
$null = Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $package.PackageName -ErrorAction Continue -ErrorVariable +removalErrors
}
}
return [PSCustomObject]@{ Success = ($removalErrors.Count -eq 0) }
} -ArgumentList @($appPattern, $targetUser)
}
catch {
Write-Error "Unable to remove $app via Appx: $_"
return $false
}
return [bool]($removalResult -and $removalResult.Success)
}
<#
.SYNOPSIS
Returns the removal method for an app identifier.
.DESCRIPTION
Parses Apps.json once (cached in script scope) to build a lookup of
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
for unknown IDs.
.PARAMETER appId
The package identifier (e.g. 'Clipchamp.Clipchamp').
#>
function Get-AppRemovalMethod {
param([string]$appId)
if (-not $script:AppRemovalMethodCache) {
$script:AppRemovalMethodCache = @{}
try {
if (Test-Path $script:AppsListFilePath) {
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
foreach ($appData in $appsJson.Apps) {
$rawMethod = $appData.RemovalMethod
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
foreach ($id in @($appData.AppId)) {
if ($id -isnot [string]) { continue }
$normalizedId = $id.Trim()
if (-not [string]::IsNullOrWhiteSpace($normalizedId)) {
$script:AppRemovalMethodCache[$normalizedId] = $method
}
}
}
}
}
catch {
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
}
}
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
return $script:AppRemovalMethodCache[$appId]
}
return 'Appx'
}
<#
.SYNOPSIS
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
.DESCRIPTION
Only invoked after it has been confirmed that Edge is still present
following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package.
.OUTPUTS
System.Boolean. $true when Edge is forcefully removed; otherwise $false.
#>
function Request-EdgeForceRemove {
if ($script:GuiWindow) {
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
if ($result -eq 'Yes') {
Write-Host ""
return (Invoke-ForceRemoveEdge)
}
}
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
Write-Host ""
return (Invoke-ForceRemoveEdge)
}
return $false
}
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
even if future catalog updates introduce IDs containing those characters.
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
#>
function Set-RunOnceWingetTask {
param([string]$appId)
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId.Replace('\', '_')
$taskName = "Uninstall_$safeAppId"
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
$escapedAppId = $appId.Replace("'", "''")
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
$operation = [PSCustomObject]@{
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
ValueName = $taskName
ValueType = 'String'
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
return $true
}
catch {
Write-Error "Failed to schedule uninstall task for $($appId): $_"
return $false
}
}
-392
View File
@@ -1,392 +0,0 @@
<#
.SYNOPSIS
Removes one or more Windows app packages based on the target scope.
.DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
The removal method (winget vs. Appx cmdlets) is determined per-app from
Apps.json. Microsoft Edge is deferred to the end of the loop so that all
winget attempts run before any force-remove prompt. A scheduled task is
only created when the User or Sysprep parameter was passed.
After each winget removal, the system is checked to confirm whether the
app is still installed before reporting an error.
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
.EXAMPLE
RemoveApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
.EXAMPLE
RemoveApps -appsList (GenerateAppsList)
#>
function RemoveApps {
param (
$appslist
)
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
Write-Host ""
return
}
$targetUser = GetTargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$edgeAppsInList = @()
$wingetRemovedApps = @()
Foreach ($app in $appsList) {
if ($script:CancelRequested) { return }
$appIndex++
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
}
# Microsoft Edge is handled after the loop to avoid duplicate scheduled tasks and allow fallback if winget fails
if ($edgeIds -contains $app) {
$edgeAppsInList += $app
continue
}
Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
Remove-WinGetApp -app $app
$wingetRemovedApps += $app
}
else {
Remove-AppxApp -app $app -targetUser $targetUser
}
}
# Remove Microsoft Edge
if ($edgeAppsInList.Count -gt 0) {
Remove-EdgeApp -edgeAppsInList $edgeAppsInList
}
# Check whether any winget-removed apps are still present, and report errors for each one.
if ($wingetRemovedApps.Count -gt 0 -or $edgeAppsInList.Count -gt 0) {
$postRemovalList = if ($script:WingetInstalled) { GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking } else { $null }
foreach ($app in $wingetRemovedApps) {
if (Test-AppStillInstalled -appId $app -InstalledList $postRemovalList) {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
}
# Verify Edge separately (triggers its own force-remove path if still installed)
$edgeStillInstalled = $false
foreach ($edgeApp in $edgeAppsInList) {
if (Test-AppStillInstalled -appId $edgeApp -InstalledList $postRemovalList) {
$edgeStillInstalled = $true
break
}
}
if ($edgeStillInstalled) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
Request-EdgeForceRemove
}
}
Write-Host ""
}
<#
.SYNOPSIS
Uninstalls a non-Edge app via WinGet and/or schedules its removal.
.DESCRIPTION
Runs winget uninstall for a single app. If the User or Sysprep
parameter was passed, also schedules removal for future logins.
After uninstall, the system is checked to confirm whether the app
is still present — winget output is not trusted on its
own, as it sometimes reports failure after a successful removal.
Edge apps are handled separately after the main loop.
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
#>
function Remove-WinGetApp {
param([string]$app)
if (-not $script:WingetInstalled) {
Write-Host "ERROR: WinGet is either not installed or is outdated, $app could not be removed" -ForegroundColor Red
return
}
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall $app for user $(GetUserName)..."
Set-RunOnceWingetTask -appId $app
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
Set-RunOnceWingetTask -appId $app
}
Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $app
}
<#
.SYNOPSIS
Removes Microsoft Edge via WinGet (both AppIds), with fallback to force-remove.
.DESCRIPTION
Edge has multiple package IDs. Runs winget uninstall for each one,
then creates a single scheduled task if the User or Sysprep parameter
was passed. After all attempts, the system is checked to confirm
whether Edge is still present. The force-remove prompt only
appears if Edge remains installed — winget false positives are ignored.
.PARAMETER edgeAppsInList
The Edge AppIds that appear in the removal list (one or both).
#>
function Remove-EdgeApp {
param([string[]]$edgeAppsInList)
if (-not $script:WingetInstalled) {
Write-Host "ERROR: WinGet is either not installed or is outdated, Microsoft Edge could not be removed" -ForegroundColor Red
return
}
if ($script:Params.ContainsKey("User")) {
Write-Host "Adding scheduled task to uninstall Microsoft Edge for user $(GetUserName)..."
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
elseif ($script:Params.ContainsKey("Sysprep")) {
Write-Host "Adding scheduled task to uninstall Microsoft Edge for new users..."
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
foreach ($edgeApp in $edgeAppsInList) {
Write-Host "Removing $edgeApp"
Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $edgeApp
}
}
<#
.SYNOPSIS
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
.PARAMETER app
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
.PARAMETER targetUser
Target scope: "AllUsers", "CurrentUser", or a specific username.
#>
function Remove-AppxApp {
param([string]$app, [string]$targetUser)
$appPattern = '*' + $app + '*'
try {
switch ($targetUser) {
"AllUsers" {
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern -AllUsers | Remove-AppxPackage -AllUsers -ErrorAction Continue
Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like $pattern } | ForEach-Object { Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $_.PackageName }
} -ArgumentList $appPattern
}
"CurrentUser" {
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue
} -ArgumentList $appPattern
}
default {
Invoke-NonBlocking -ScriptBlock {
param($pattern, $user)
$userAccount = New-Object System.Security.Principal.NTAccount($user)
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
Get-AppxPackage -Name $pattern -User $userSid | Remove-AppxPackage -User $userSid -ErrorAction Continue
} -ArgumentList @($appPattern, $targetUser)
}
}
}
catch {
Write-Verbose "Something went wrong while trying to remove $($app): $_"
}
}
<#
.SYNOPSIS
Checks whether an app package is still installed after a removal attempt.
.DESCRIPTION
Checks Get-AppxPackage across all users first (fast, no process launch),
then falls back to a pre-fetched or live winget list for non-Appx packages.
Uses Test-AppInWingetList which provides exact-match-first with substring
fallback against the parsed winget objects.
Returns $true if the app is still present, $false otherwise.
.PARAMETER appId
The package identifier to check (e.g. 'Microsoft.BingNews').
.PARAMETER InstalledList
Optional pre-fetched array of winget objects from GetInstalledAppsViaWinget.
When provided, used directly; otherwise a live winget call is made.
#>
function Test-AppStillInstalled {
param(
[string]$appId,
[object[]]$InstalledList
)
# Check Get-AppxPackage for all users first (fast, covers all Store apps).
if (Get-AppxPackage -Name "$appId" -AllUsers -ErrorAction SilentlyContinue) {
return $true
}
# Use the pre-fetched list if provided; otherwise fall back to a live winget call.
if ($InstalledList) {
return (Test-AppInWingetList -appId $appId -InstalledList $InstalledList)
}
if ($script:WingetInstalled) {
$liveList = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
if (Test-AppInWingetList -appId $appId -InstalledList $liveList) {
return $true
}
}
else {
Write-Warning "Unable to verify whether '$appId' is still installed (WinGet is unavailable)"
}
return $false
}
<#
.SYNOPSIS
Returns the removal method for an app identifier.
.DESCRIPTION
Parses Apps.json once (cached in script scope) to build a lookup of
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
for unknown IDs.
.PARAMETER appId
The package identifier (e.g. 'Clipchamp.Clipchamp').
#>
function Get-AppRemovalMethod {
param([string]$appId)
if (-not $script:AppRemovalMethodCache) {
$script:AppRemovalMethodCache = @{}
try {
if (Test-Path $script:AppsListFilePath) {
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
foreach ($appData in $appsJson.Apps) {
$rawMethod = $appData.RemovalMethod
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
if ($appData.AppId -is [array]) {
foreach ($id in $appData.AppId) { $script:AppRemovalMethodCache[$id.Trim()] = $method }
}
else {
$script:AppRemovalMethodCache[$appData.AppId.Trim()] = $method
}
}
}
}
catch {
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
}
}
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
return $script:AppRemovalMethodCache[$appId]
}
return 'Appx'
}
<#
.SYNOPSIS
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
.DESCRIPTION
Only invoked after it has been confirmed that Edge is still present
following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package.
#>
function Request-EdgeForceRemove {
if ($script:GuiWindow) {
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
if ($result -eq 'Yes') {
Write-Host ""
ForceRemoveEdge
}
}
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
Write-Host ""
ForceRemoveEdge
}
}
<#
.SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
.DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
even if future catalog updates introduce IDs containing those characters.
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
#>
function Set-RunOnceWingetTask {
param([string]$appId)
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId.Replace('\', '_')
$taskName = "Uninstall_$safeAppId"
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
$escapedAppId = $appId.Replace("'", "''")
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
$operation = [PSCustomObject]@{
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
ValueName = $taskName
ValueType = 'String'
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param($op)
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
}
catch {
Write-Host "Failed to schedule uninstall task for $($appId): $_" -ForegroundColor Red
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
The identifier to search for (e.g. 'Microsoft.Copilot').
.PARAMETER InstalledList
An array of PSCustomObject from GetInstalledAppsViaWinget.
An array of PSCustomObject from Get-WingetInstalledApps.
#>
function Test-AppInWingetList {
param(
-11
View File
@@ -1,11 +0,0 @@
function AwaitKeyToExit {
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
Write-Output ""
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
}
Stop-Transcript
Exit
}
@@ -1,6 +1,6 @@
# Shows the CLI app removal menu and prompts the user to select which apps to remove.
function ShowCLIAppRemoval {
PrintHeader "App Removal"
function Show-CliAppRemoval {
Write-CliHeader "App Removal"
Write-Output "> Opening app selection form..."
@@ -8,10 +8,10 @@ function ShowCLIAppRemoval {
if ($result -eq $true) {
Write-Output "You have selected $($script:SelectedApps.Count) apps for removal"
AddParameter 'RemoveApps'
AddParameter 'Apps' ($script:SelectedApps -join ',')
Add-Parameter 'RemoveApps'
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
SaveSettings
Save-Settings
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
@@ -19,7 +19,7 @@ function ShowCLIAppRemoval {
Write-Output ""
Write-Output "Press enter to remove the selected apps or press CTRL+C to quit..."
Read-Host | Out-Null
PrintHeader "App Removal"
Write-CliHeader "App Removal"
}
}
else {
@@ -1,6 +1,6 @@
# Shows the CLI default mode app removal options. Loops until a valid option is selected.
function ShowCLIDefaultModeAppRemovalOptions {
PrintHeader 'Default Mode'
function Show-CliDefaultModeAppRemovalOptions {
Write-CliHeader 'Default Mode'
Write-Host "Please note: The default selection of apps includes Microsoft Teams, Spotify, Sticky Notes and more. Select option 2 to verify and change what apps are removed by the script" -ForegroundColor DarkGray
Write-Host ""
@@ -1,5 +1,5 @@
# Show CLI default mode options for removing apps, or set selection if RunDefaults or RunDefaultsLite parameter was passed
function ShowCLIDefaultModeOptions {
function Show-CliDefaultModeOptions {
if ($RunDefaults) {
$RemoveAppsInput = '1'
}
@@ -7,7 +7,7 @@ function ShowCLIDefaultModeOptions {
$RemoveAppsInput = '0'
}
else {
$RemoveAppsInput = ShowCLIDefaultModeAppRemovalOptions
$RemoveAppsInput = Show-CliDefaultModeAppRemovalOptions
if ($RemoveAppsInput -eq '2' -and ($script:SelectedApps.contains('Microsoft.XboxGameOverlay') -or $script:SelectedApps.contains('Microsoft.XboxGamingOverlay')) -and
$( Read-Host -Prompt "Disable Game Bar integration and game/screen recording? This also stops ms-gamingoverlay and ms-gamebar popups (y/n)" ) -eq 'y') {
@@ -15,40 +15,40 @@ function ShowCLIDefaultModeOptions {
}
}
PrintHeader 'Default Mode'
Write-CliHeader 'Default Mode'
try {
# Select app removal options based on user input
switch ($RemoveAppsInput) {
'1' {
AddParameter 'RemoveApps'
AddParameter 'Apps' 'Default'
Add-Parameter 'RemoveApps'
Add-Parameter 'Apps' 'Default'
}
'2' {
AddParameter 'RemoveApps'
AddParameter 'Apps' ($script:SelectedApps -join ',')
Add-Parameter 'RemoveApps'
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
if ($DisableGameBarIntegrationInput) {
AddParameter 'DisableDVR'
AddParameter 'DisableGameBarIntegration'
Add-Parameter 'DisableDVR'
Add-Parameter 'DisableGameBarIntegration'
}
}
}
LoadSettings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
Import-Settings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
}
catch {
Write-Error "Failed to load settings from DefaultSettings.json file: $_"
AwaitKeyToExit
Wait-ForKeyPress -ExitCode 1
}
SaveSettings
Save-Settings
if ($Silent) {
# Skip change summary and confirmation prompt
return
}
PrintPendingChanges
PrintHeader 'Default Mode'
}
Write-PendingChanges
Write-CliHeader 'Default Mode'
}
@@ -1,13 +1,13 @@
# Shows the CLI last used settings from LastUsedSettings.json file, displays pending changes and prompts the user to apply them.
function ShowCLILastUsedSettings {
PrintHeader 'Custom Mode'
function Show-CliLastUsedSettings {
Write-CliHeader 'Custom Mode'
try {
LoadSettings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
Import-Settings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
}
catch {
Write-Error "Failed to load settings from LastUsedSettings.json file: $_"
AwaitKeyToExit
Wait-ForKeyPress -ExitCode 1
}
if ($Silent) {
@@ -15,6 +15,6 @@ function ShowCLILastUsedSettings {
return
}
PrintPendingChanges
PrintHeader 'Custom Mode'
}
Write-PendingChanges
Write-CliHeader 'Custom Mode'
}
@@ -1,9 +1,9 @@
# Shows the CLI menu options and prompts the user to select one. Loops until a valid option is selected.
function ShowCLIMenuOptions {
function Show-CliMenuOptions {
Do {
$ModeSelectionMessage = "Please select an option (1/2)"
PrintHeader 'Menu'
Write-CliHeader 'Menu'
Write-Host "(1) Default mode: Quickly apply the recommended changes"
Write-Host "(2) App removal mode: Select & remove apps, without making other changes"
+22
View File
@@ -0,0 +1,22 @@
<#
.SYNOPSIS
Waits for user acknowledgement, then exits the script.
.PARAMETER ExitCode
Process exit code to return after acknowledgement. Defaults to 0.
#>
function Wait-ForKeyPress {
param(
[int]$ExitCode = 0
)
# Suppress prompt if Silent parameter was passed
if (-not $Silent) {
Write-Output ""
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
}
Stop-Transcript
Exit $ExitCode
}
@@ -1,5 +1,5 @@
# Prints the header for the script
function PrintHeader {
function Write-CliHeader {
param (
$title
)
@@ -10,11 +10,11 @@ function PrintHeader {
$fullTitle = "$fullTitle (Sysprep mode)"
}
else {
$fullTitle = "$fullTitle (User: $(GetUserName))"
$fullTitle = "$fullTitle (User: $(Get-UserName))"
}
Clear-Host
Write-Host "-------------------------------------------------------------------------------------------"
Write-Host $fullTitle
Write-Host "-------------------------------------------------------------------------------------------"
}
}
@@ -12,7 +12,7 @@
After printing the summary the function pauses until the user presses
Enter, giving them an opportunity to review and cancel via Ctrl+C.
#>
function PrintPendingChanges {
function Write-PendingChanges {
Write-Output "Win11Debloat will make the following changes:"
if ($script:Params['CreateRestorePoint']) {
@@ -32,7 +32,7 @@ function PrintPendingChanges {
continue
}
'RemoveApps' {
$appsList = GenerateAppsList
$appsList = Generate-AppsList
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
@@ -56,4 +56,4 @@ function PrintPendingChanges {
Write-Output ""
Write-Output "Press enter to execute the script or press CTRL+C to quit..."
Read-Host | Out-Null
}
}
@@ -195,10 +195,19 @@ function Get-RegistryKeySnapshot {
}
}
<#
.SYNOPSIS
Converts an open registry key into a backup snapshot.
.DESCRIPTION
Captures all values or selected value names, records missing selected values,
and recursively captures subkeys when requested. Throws if a requested subkey
cannot be read.
#>
function Convert-RegistryKeyToSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
$RegistryKey,
[Parameter(Mandatory)]
[string]$FullPath,
[bool]$CaptureAllValues = $false,
@@ -233,7 +242,9 @@ function Convert-RegistryKeyToSnapshot {
if ($IncludeSubKeys) {
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
if ($null -eq $childKey) { continue }
if ($null -eq $childKey) {
throw "Unable to read registry subkey '$($RegistryKey.Name)\$subKeyName' while creating a backup snapshot. The backup was not created."
}
try {
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
@@ -253,20 +264,34 @@ function Convert-RegistryKeyToSnapshot {
}
}
<#
.SYNOPSIS
Converts a registry value into a serializable backup snapshot.
.DESCRIPTION
Preserves the value kind and normalizes supported data types for JSON
serialization without expanding environment-string values. REG_NONE values
are rejected.
#>
function Convert-RegistryValueToSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
$RegistryKey,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string]$ValueName
)
$valueKind = $RegistryKey.GetValueKind($ValueName)
if ($valueKind -eq [Microsoft.Win32.RegistryValueKind]::None) {
throw "REG_NONE registry values are not supported for backup. Key='$($RegistryKey.Name)' Name='$ValueName'"
}
$value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
try {
$normalizedValue = switch ($valueKind) {
([Microsoft.Win32.RegistryValueKind]::Binary) { @($value | ForEach-Object { [int]$_ }) }
# Prevent an empty byte sequence from being unrolled to $null by the switch pipeline.
([Microsoft.Win32.RegistryValueKind]::Binary) { if ($null -eq $value) { ,@() } else { ,@($value | ForEach-Object { [int]$_ }) } }
([Microsoft.Win32.RegistryValueKind]::MultiString) { @($value) }
([Microsoft.Win32.RegistryValueKind]::DWord) { [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$value), 0) }
([Microsoft.Win32.RegistryValueKind]::QWord) { [BitConverter]::ToUInt64([BitConverter]::GetBytes([int64]$value), 0) }
@@ -287,12 +312,20 @@ function Convert-RegistryValueToSnapshot {
}
}
<#
.SYNOPSIS
Describes the user profile targeted by a registry backup.
.DESCRIPTION
Returns DefaultUserProfile for Sysprep, User:<name> for an explicit user,
or CurrentUser:<name> otherwise.
#>
function Get-RegistryBackupTargetDescription {
if ($script:Params.ContainsKey('Sysprep')) {
return 'DefaultUserProfile'
}
$resolvedUserName = [string](GetUserName)
$resolvedUserName = [string](Get-UserName)
if ($script:Params.ContainsKey('User')) {
return "User:$resolvedUserName"
@@ -37,7 +37,7 @@ function New-RegistrySettingsBackup {
$backupFilePath = Join-Path $backupDirectory $backupFileName
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -UndoFeatures $undoFeatures -CreatedAt $timestamp
if (-not (SaveToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
if (-not (Save-ToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
throw "Failed to save registry backup to '$backupFilePath'"
}
@@ -66,7 +66,7 @@ function Test-FeatureApplied {
return (Test-StoreSearchSuggestionsDisabledForAllUsers)
}
$storeDbPath = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
$storeDbPath = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
return (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDbPath)
}
+120
View File
@@ -0,0 +1,120 @@
<#
.SYNOPSIS
Imports and executes a registry file.
.OUTPUTS
System.Boolean. $true when the registry file is applied or previewed successfully; otherwise $false.
#>
function Import-RegistryFile {
param (
$message,
$path
)
try {
Write-Host $message
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
if (-not (Test-Path $regFilePath)) {
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
Write-Host "Error: $errorMessage" -ForegroundColor Red
return $false
}
$importScript = {
param($targetRegFilePath, $hiveContext)
if ($script:Params.ContainsKey("WhatIf")) {
return (Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath)
}
# When the target user's hive is already loaded under their SID, the .reg file's
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
# which remaps Default → SID via Split-RegistryPath.
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
if ($usePowerShellFallbackOnly) {
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
if ($fallbackSucceeded) {
Write-Host "The operation completed successfully via PowerShell registry writer."
}
return $fallbackSucceeded
}
$regResult = Invoke-NonBlocking -ScriptBlock {
param($targetRegFilePath)
$result = @{
Output = @()
ExitCode = 0
Error = $null
}
try {
$global:LASTEXITCODE = 0
$output = reg import $targetRegFilePath 2>&1
$importExitCode = $LASTEXITCODE
if ($output) {
$result.Output = @($output)
}
$result.ExitCode = $importExitCode
if ($importExitCode -ne 0) {
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
}
}
catch {
$result.Error = $_.Exception.Message
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
}
return $result
} -ArgumentList $targetRegFilePath
$regOutput = @($regResult.Output)
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
if ($regOutput) {
foreach ($line in $regOutput) {
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
if ($lineText -and $lineText.Length -gt 0) {
if ($hasSuccess) {
Write-Host $lineText
}
else {
Write-Host $lineText -ForegroundColor Red
}
}
}
}
if (-not $hasSuccess) {
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
if ($fallbackSucceeded) {
Write-Host "The operation completed successfully via PowerShell registry writer."
}
return $fallbackSucceeded
}
return $true
}
if ($usesOfflineHive) {
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
$succeeded = Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
}
else {
$succeeded = & $importScript $regFilePath $null
}
return [bool]$succeeded
}
catch {
Write-Host $_.Exception.Message -ForegroundColor Red
return $false
}
}
-114
View File
@@ -1,114 +0,0 @@
# Import & execute regfile
function ImportRegistryFile {
param (
$message,
$path
)
Write-Host $message
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
if (-not (Test-Path $regFilePath)) {
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
$script:RegistryImportFailures++
Write-Host "Error: $errorMessage" -ForegroundColor Red
Write-Host ""
throw $errorMessage
}
$importScript = {
param($targetRegFilePath, $hiveContext)
if ($script:Params.ContainsKey("WhatIf")) {
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
Write-Host ""
return
}
# When the target user's hive is already loaded under their SID, the .reg file's
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
# which remaps Default → SID via Split-RegistryPath.
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
if ($usePowerShellFallbackOnly) {
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
Write-Host "The operation completed successfully via PowerShell registry writer."
Write-Host ""
return
}
$regResult = Invoke-NonBlocking -ScriptBlock {
param($targetRegFilePath)
$result = @{
Output = @()
ExitCode = 0
Error = $null
}
try {
$global:LASTEXITCODE = 0
$output = reg import $targetRegFilePath 2>&1
$importExitCode = $LASTEXITCODE
if ($output) {
$result.Output = @($output)
}
$result.ExitCode = $importExitCode
if ($importExitCode -ne 0) {
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
}
}
catch {
$result.Error = $_.Exception.Message
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
}
return $result
} -ArgumentList $targetRegFilePath
$regOutput = @($regResult.Output)
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
if ($regOutput) {
foreach ($line in $regOutput) {
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
if ($lineText -and $lineText.Length -gt 0) {
if ($hasSuccess) {
Write-Host $lineText
}
else {
Write-Host $lineText -ForegroundColor Red
}
}
}
}
if (-not $hasSuccess) {
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
Write-Host "The operation completed successfully via PowerShell registry writer."
}
Write-Host ""
}
try {
if ($usesOfflineHive) {
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
}
else {
& $importScript $regFilePath $null
}
}
catch {
$script:RegistryImportFailures++
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host ""
}
}
@@ -4,10 +4,12 @@
.DESCRIPTION
Handles two categories of features:
- Registry-backed: imports the .reg file via ImportRegistryFile, then runs
- Registry-backed: imports the .reg file via Import-RegistryFile, then runs
any post-import side effects (e.g., removing companion app packages).
- Custom logic: app removal, Windows optional features, start menu
replacement, and other special-case features.
replacement, and other special-case features. Returns $true when the
feature completes successfully; otherwise writes a warning and returns
$false.
#>
function Invoke-FeatureApply {
param(
@@ -15,137 +17,138 @@ function Invoke-FeatureApply {
[string]$FeatureId
)
# Resolve feature metadata from Features.json
$feature = $script:Features[$FeatureId]
$applyText = $feature.ApplyText
try {
# Resolve feature metadata from Features.json
$feature = $script:Features[$FeatureId]
$applyText = $feature.ApplyText
# ---- Registry-backed features: import .reg file, then handle side effects ----
# ---- Registry-backed features: import .reg file, then handle additional tasks ----
if ($feature.RegistryKey) {
ImportRegistryFile "> $applyText..." $feature.RegistryKey
if (-not (Import-RegistryFile "> $applyText..." $feature.RegistryKey)) {
return $false
}
# Post-import side effects for specific features
switch ($FeatureId) {
'DisableBing' {
# Also remove the app package for Bing search
RemoveApps @('Microsoft.BingSearch')
return (Remove-SelectedApps @('Microsoft.BingSearch'))
}
'DisableCopilot' {
# Also remove the app packages for Copilot
RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')
return (Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX'))
}
'DisableTelemetry' {
# Also disable telemetry scheduled tasks
Disable-TelemetryScheduledTasks
return (Disable-TelemetryScheduledTasks)
}
}
return
return $true
}
# ---- Custom features (no registry backing, or special handling required) ----
switch ($FeatureId) {
'RemoveApps' {
Write-Host "> $applyText for $(GetFriendlyTargetUserName)..."
$appsList = GenerateAppsList
Write-Host "> $applyText for $(Get-FriendlyTargetUserName)..."
$appsList = Generate-AppsList
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Host ""
return
return $true
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
return
return (Remove-SelectedApps $appsList)
}
'RemoveGamingApps' {
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
Write-Host "> $applyText..."
RemoveApps $appsList
return
return (Remove-SelectedApps $appsList)
}
'RemoveHPApps' {
$appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl')
Write-Host "> $applyText..."
RemoveApps $appsList
return
return (Remove-SelectedApps $appsList)
}
'ForceRemoveEdge' {
Write-Host "> $applyText..."
return (Invoke-ForceRemoveEdge)
}
'DisableWidgets' {
Write-Host "> $applyText..."
# Stop widgets related processes before removing the app packages to prevent potential issues
if (-not $script:Params.ContainsKey("WhatIf")) {
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')
return
return (Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'))
}
'EnableWindowsSandbox' {
Write-Host "> $applyText..."
EnableWindowsFeature "Containers-DisposableClientVM"
Write-Host ""
return
return (Enable-WindowsFeature "Containers-DisposableClientVM")
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $applyText..."
EnableWindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host ""
return
if (-not (Enable-WindowsFeature "VirtualMachinePlatform")) { return $false }
return (Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux")
}
'ClearStart' {
Write-Host "> $applyText for user $(GetUserName)..."
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
Write-Host "> $applyText for user $(Get-UserName)..."
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
ReplaceStartMenu -startMenuBinFile $startMenuBinFile
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile)
}
Write-Host ""
return
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
return $false
}
'ReplaceStart' {
Write-Host "> $applyText for user $(GetUserName)..."
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
Write-Host "> $applyText for user $(Get-UserName)..."
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
ReplaceStartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart")
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart"))
}
Write-Host ""
return
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
return $false
}
'ClearStartAllUsers' {
ReplaceStartMenuForAllUsers
return
return (Replace-StartMenuForAllUsers)
}
'ReplaceStartAllUsers' {
ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")
return
return (Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers"))
}
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey("Sysprep")) {
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
DisableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
return (Set-StoreSearchSuggestionsDisabledForAllUsers)
}
Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..."
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
Write-Host "> Disabling Microsoft Store search suggestions for user $(Get-UserName)..."
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
if ($storeDb) {
DisableStoreSearchSuggestions -StoreAppsDatabase $storeDb
return (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb)
}
Write-Host ""
return
Write-Warning "Unable to disable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
return $false
}
}
}
catch {
Write-Warning "Failed to apply '$applyText': $($_.Exception.Message)"
return $false
}
Write-Warning "Unknown feature '$FeatureId' could not be applied."
return $false
}
<#
.SYNOPSIS
Undoes a single feature that has no RegistryUndoKey.
Undoes a single feature.
.DESCRIPTION
Handles undo for features that require custom logic rather than a simple
.reg file import. Features with a RegistryUndoKey are handled directly
via ImportRegistryFile in Invoke-UndoFeatures.
Handles registry-backed undo imports and custom undo logic. Returns
$true when the requested undo succeeds; otherwise writes a warning and
returns $false.
#>
function Invoke-FeatureUndo {
param(
@@ -154,43 +157,65 @@ function Invoke-FeatureUndo {
)
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
if (-not $feature) {
Write-Warning "Unknown feature '$FeatureId' could not be undone."
return $false
}
switch ($FeatureId) {
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) {
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
EnableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
$undoText = if ($feature.ApplyUndoText) { $feature.ApplyUndoText } elseif ($feature.UndoLabel) { $feature.UndoLabel } else { $FeatureId }
try {
# ---- Registry-backed features: import undo data, then handle additional tasks ----
if ($feature.RegistryUndoKey) {
if (-not (Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $feature.RegistryUndoKey))) {
return $false
}
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..."
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
if ($storeDb) {
EnableStoreSearchSuggestions -StoreAppsDatabase $storeDb
switch ($FeatureId) {
'DisableTelemetry' {
# Also re-enable telemetry scheduled tasks.
return (Enable-TelemetryScheduledTasks)
}
}
Write-Host ""
return
return $true
}
'EnableWindowsSandbox' {
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Containers-DisposableClientVM'
Write-Host ""
return
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
DisableWindowsFeature 'VirtualMachinePlatform'
Write-Host ""
return
}
'DisableTelemetry' {
# Also re-enable telemetry scheduled tasks
Enable-TelemetryScheduledTasks
return
# ---- Custom undo features (no registry backing) ----
switch ($FeatureId) {
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) {
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
return (Set-StoreSearchSuggestionsEnabledForAllUsers)
}
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..."
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
if ($storeDb) {
return (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb)
}
Write-Warning "Unable to re-enable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
return $false
}
'EnableWindowsSandbox' {
Write-Host "> $undoText..."
return (Disable-WindowsFeature 'Containers-DisposableClientVM')
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $undoText..."
if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false }
return (Disable-WindowsFeature 'VirtualMachinePlatform')
}
}
}
catch {
Write-Warning "Failed to undo '$undoText': $($_.Exception.Message)"
return $false
}
Write-Warning "Feature '$FeatureId' does not support undo."
return $false
}
@@ -246,7 +271,13 @@ function Invoke-ApplyFeatures {
& $script:ApplyProgressCallback $step $TotalSteps $displayName
}
Invoke-FeatureApply -FeatureId $featureId
# Compare app-removal failure counts so a feature that only fails due to
# app removal isn't also double-reported as a feature failure.
$appRemovalFailuresBefore = $script:AppRemovalFailures
if ((-not (Invoke-FeatureApply -FeatureId $featureId)) -and ($script:AppRemovalFailures -eq $appRemovalFailuresBefore)) {
$script:FeatureFailures++
}
Write-Host ""
$step++
}
}
@@ -257,9 +288,8 @@ function Invoke-ApplyFeatures {
Undoes a list of features, reporting progress for each.
.DESCRIPTION
Iterates through the provided feature IDs. Features with a RegistryUndoKey
are handled by importing the undo .reg file; all others delegate to
Invoke-FeatureUndo for custom undo logic.
Iterates through the provided feature IDs and delegates each to
Invoke-FeatureUndo, which handles registry-backed and custom undo logic.
This is called by Invoke-AllChanges during the undo phase.
#>
function Invoke-UndoFeatures {
@@ -286,11 +316,10 @@ function Invoke-UndoFeatures {
& $script:ApplyProgressCallback $step $TotalSteps $undoText
}
if ($f -and $f.RegistryUndoKey) {
ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey)
if (-not (Invoke-FeatureUndo -FeatureId $featureId)) {
$script:FeatureFailures++
}
Invoke-FeatureUndo -FeatureId $featureId
Write-Host ""
$step++
}
}
@@ -302,8 +331,8 @@ function Invoke-UndoFeatures {
.DESCRIPTION
Sequenced in four phases:
1. Registry backup
2. System restore point
1. Registry backup (skipped when SkipRegistryBackup is present)
2. System restore point (skipped when CreateRestorePoint is absent)
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
4. Undo phase - undoes selected features via Invoke-UndoFeatures
@@ -311,13 +340,17 @@ function Invoke-UndoFeatures {
(used by the GUI modal). Cancellation is checked between each step.
#>
function Invoke-AllChanges {
if ($script:CancelRequested) { return }
# Guard: prevent running as SYSTEM account without explicit target user
$isSystem = ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18')
$isSystem = Test-RunningAsSystem
if ($isSystem -and -not $script:Params.ContainsKey("User") -and -not $script:Params.ContainsKey("Sysprep")) {
throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user."
}
$script:RegistryImportFailures = 0
$script:AppRemovalFailures = 0
$script:FeatureFailures = 0
$script:AppRemovalVerificationUnavailable = $false
# ---- Gather work items ----
$applyIds = @()
@@ -347,14 +380,15 @@ function Invoke-AllChanges {
# ---- Calculate total progress steps ----
$totalSteps = $applyIds.Count + $undoIds.Count
if ($needsBackup) { $totalSteps++ }
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ }
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$step = 0
# ================================================================
# Phase 1: Registry backup
# ================================================================
if ($needsBackup) {
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) {
if ($script:CancelRequested) { return }
$step++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..."
@@ -384,6 +418,7 @@ function Invoke-AllChanges {
# Phase 2: System restore point
# ================================================================
if ($script:Params.ContainsKey("CreateRestorePoint")) {
if ($script:CancelRequested) { return }
$step++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..."
@@ -394,7 +429,11 @@ function Invoke-AllChanges {
}
else {
Write-Host "> Creating a system restore point..."
CreateSystemRestorePoint
$restorePointSucceeded = Invoke-SystemRestorePoint
if (-not $restorePointSucceeded) {
if ($script:CancelRequested) { return }
$script:FeatureFailures++
}
Write-Host ""
}
}
@@ -407,6 +446,8 @@ function Invoke-AllChanges {
$step += $applyIds.Count
}
if ($script:CancelRequested) { return }
# ================================================================
# Phase 4: Undo features
# ================================================================
@@ -416,10 +457,37 @@ function Invoke-AllChanges {
}
# ================================================================
# Final: Report registry import failures
# Final: Report failures
# ================================================================
if ($script:RegistryImportFailures -gt 0) {
if ($script:AppRemovalFailures -gt 0) {
Write-Host ""
Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow
Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details."
}
if ($script:FeatureFailures -gt 0) {
Write-Host ""
Write-Warning "$($script:FeatureFailures) feature change(s) failed. See output above for details."
}
if ($script:AppRemovalVerificationUnavailable) {
Write-Host ""
Write-Warning "Unable to verify if all apps were uninstalled successfully."
}
}
<#
.SYNOPSIS
Tests whether Win11Debloat is running under the SYSTEM account.
.DESCRIPTION
Compares the current Windows identity's security identifier (SID) with
the well-known Local System SID (S-1-5-18).
.OUTPUTS
System.Boolean
Returns $true when the current process runs as SYSTEM; otherwise, $false.
#>
function Test-RunningAsSystem {
return ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18')
}
@@ -1,30 +1,29 @@
# Restart the Windows Explorer process
function RestartExplorer {
# Restarting Explorer while running in Sysprep or User context is not necessary
if ($script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")) {
return
}
<#
.SYNOPSIS
Restarts Windows Explorer to apply system changes.
.DESCRIPTION
Restarts the Explorer process to ensure all UI modifications take effect. Shows a warning if any of the applied features require a reboot to take full effect.
#>
function Invoke-RestartExplorer {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Restart the Windows Explorer process" -ForegroundColor Cyan
return
}
Write-Host "> Attempting to restart the Windows Explorer process to apply all changes..."
if ($script:Params.ContainsKey("NoRestartExplorer")) {
if ($script:Params.ContainsKey('SkipExplorerRestart')) {
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
return
}
foreach ($paramKey in $script:Params.Keys) {
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
$feature = $script:Features[$paramKey]
Write-Host "Warning: '$($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
}
$rebootFeatures = Get-RebootFeatureLabels
foreach ($displayLabel in $rebootFeatures) {
Write-Host "Warning: '$displayLabel' requires a reboot to take full effect" -ForegroundColor Yellow
}
# Only restart if the powershell process matches the OS architecture.
# Only restart if the PowerShell process matches the OS architecture.
# Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
@@ -33,4 +32,4 @@ function RestartExplorer {
else {
Write-Host "Unable to restart Windows Explorer process, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
}
}
}
@@ -1,10 +1,25 @@
function CreateSystemRestorePoint {
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
$failed = $false
<#
.SYNOPSIS
Creates a system restore point.
if ($SysRestore.RPSessionInterval -eq 0) {
.OUTPUTS
System.Boolean. $true when a restore point is created; otherwise $false.
#>
function Invoke-SystemRestorePoint {
$failed = $false
$isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent
try {
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" -ErrorAction Stop
}
catch {
Write-Host "Error: Unable to determine whether System Restore is enabled: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
}
if (-not $failed -and $SysRestore.RPSessionInterval -eq 0) {
# In GUI mode, skip the prompt and just try to enable it
if ($script:GuiWindow -or $Silent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
if ($script:GuiWindow -or $isSilent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
try {
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
try {
@@ -26,7 +41,6 @@ function CreateSystemRestorePoint {
}
}
else {
Write-Host ""
$failed = $true
}
}
@@ -79,17 +93,20 @@ function CreateSystemRestorePoint {
if ($result -ne "Yes") {
$script:CancelRequested = $true
return
return $false
}
}
elseif (-not $Silent) {
elseif (-not $isSilent) {
Write-Host "Failed to create a system restore point. Do you want to continue without a restore point? (y/n)" -ForegroundColor Yellow
if ($( Read-Host ) -ne 'y') {
$script:CancelRequested = $true
return
return $false
}
}
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
return $false
}
return $true
}
@@ -248,6 +248,14 @@ function New-RegistryBackupAllowListPlanMap {
return $planMap
}
<#
.SYNOPSIS
Converts registry value names into a case-insensitive set.
.DESCRIPTION
Preserves empty names and prevents PowerShell from enumerating the returned
HashSet.
#>
function ConvertTo-RegistryValueNameSet {
param(
[AllowEmptyCollection()]
@@ -259,9 +267,18 @@ function ConvertTo-RegistryValueNameSet {
$null = $valueNameSet.Add([string]$valueName)
}
return $valueNameSet
# Prevent PowerShell from enumerating the HashSet into an array or single string
return ,$valueNameSet
}
<#
.SYNOPSIS
Validates a registry snapshot against the selected-feature allow list.
.DESCRIPTION
Recursively validates snapshot paths, value names, value kinds, and value
data, appending validation errors to the supplied list.
#>
function Test-RegistrySnapshotAgainstAllowList {
param(
[Parameter(Mandatory)]
@@ -300,6 +317,9 @@ function Test-RegistrySnapshotAgainstAllowList {
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
$Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
}
elseif (-not (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) {
$Errors.Add("Backup contains invalid registry data for kind '$kindName' at '$valueReference'.")
}
}
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
@@ -311,6 +331,64 @@ function Test-RegistrySnapshotAgainstAllowList {
}
}
<#
.SYNOPSIS
Tests whether backed-up registry data is valid for its declared value kind.
.DESCRIPTION
Rejects corrupted or hand-edited backup data that cannot be restored safely,
such as a DWord that overflows UInt32 or binary data containing an invalid byte.
This validation runs before Restore-RegistryKeySnapshot mutates the live
registry, preventing a failed conversion from leaving a partially restored key.
.PARAMETER KindName
The declared registry value kind name, such as DWord, QWord, or Binary.
.PARAMETER Data
The backed-up value data to validate against the declared kind.
.OUTPUTS
System.Boolean
#>
function Test-RegistryValueDataMatchesKind {
param(
[Parameter(Mandatory)]
[string]$KindName,
[AllowNull()]
$Data
)
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
switch ($kind) {
([Microsoft.Win32.RegistryValueKind]::DWord) {
$parsed = [uint32]0
return [uint32]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
}
([Microsoft.Win32.RegistryValueKind]::QWord) {
$parsed = [uint64]0
return [uint64]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
}
([Microsoft.Win32.RegistryValueKind]::Binary) {
if ($null -eq $Data -or $Data -isnot [array]) { return $false }
foreach ($item in @($Data)) {
if ($item -isnot [ValueType] -and $item -isnot [string]) { return $false }
$parsed = 0
if (-not [int]::TryParse([string]$item, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt 255) {
return $false
}
}
return $true
}
([Microsoft.Win32.RegistryValueKind]::MultiString) {
foreach ($item in @($Data)) {
if ($item -isnot [string]) { return $false }
}
return $true
}
default { return ($null -eq $Data -or $Data -is [string]) }
}
}
function Test-RegistryValueAllowedByPlan {
param(
[Parameter(Mandatory)]
@@ -428,6 +506,14 @@ function Get-NormalizedRegistryPathKey {
return "$normalizedHive\\$normalizedSubKey"
}
<#
.SYNOPSIS
Tests whether a registry value-kind name is supported in backups.
.DESCRIPTION
Parses kind names case-insensitively and rejects empty, invalid, Unknown,
and None values.
#>
function Test-RegistryValueKindNameSupported {
param(
[string]$KindName
@@ -439,9 +525,10 @@ function Test-RegistryValueKindNameSupported {
try {
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
return $kind -ne [Microsoft.Win32.RegistryValueKind]::Unknown
return $kind -notin @([Microsoft.Win32.RegistryValueKind]::Unknown, [Microsoft.Win32.RegistryValueKind]::None)
}
catch {
return $false
}
}
@@ -14,12 +14,15 @@
bundled with the script (Assets/Start/start2.bin).
.EXAMPLE
ReplaceStartMenuForAllUsers
Replace-StartMenuForAllUsers
.EXAMPLE
ReplaceStartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
.OUTPUTS
System.Boolean. $true when all resolved profiles are updated or the change is previewed; otherwise $false.
#>
function ReplaceStartMenuForAllUsers {
function Replace-StartMenuForAllUsers {
param (
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
)
@@ -29,37 +32,49 @@ function ReplaceStartMenuForAllUsers {
# Check if template bin file exists
if (-not (Test-Path $startMenuTemplate)) {
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
Write-Host ""
return
return $false
}
# Get path to start menu file for all users
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
# Go through all users and replace the start menu file
$success = $true
ForEach ($startMenuPath in $usersStartMenuPaths) {
ReplaceStartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate
if (-not (Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
$success = $false
}
}
# Also replace the start menu file for the default user profile
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
$defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan
return
return $true
}
# Create folder if it doesn't exist
if (-not (Test-Path $defaultStartMenuPath)) {
new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null
Write-Host "Created LocalState folder for default user profile"
try {
New-Item $defaultStartMenuPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
Write-Host "Created LocalState folder for default user profile"
}
catch {
Write-Warning "Failed to create the Default profile Start Menu directory: $($_.Exception.Message)"
return $false
}
}
# Copy template to default profile
ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate
Write-Host "Replaced start menu for the default user profile"
Write-Host ""
if (-not (Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
$success = $false
}
else {
Write-Host "Replaced start menu for the default user profile"
}
return $success
}
@@ -83,12 +98,15 @@ function ReplaceStartMenuForAllUsers {
bundled with the script (Assets/Start/start2.bin).
.EXAMPLE
ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
.EXAMPLE
ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin"
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin"
.OUTPUTS
System.Boolean. $true when the template is valid and copied, or the change is previewed; otherwise $false.
#>
function ReplaceStartMenu {
function Replace-StartMenu {
param (
[Parameter(Mandatory)]
[string]$startMenuBinFile,
@@ -98,19 +116,19 @@ function ReplaceStartMenu {
# Check if template bin file exists
if (-not (Test-Path $startMenuTemplate)) {
Write-Host "Error: Unable to replace start menu, template file not found" -ForegroundColor Red
return
return $false
}
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") {
Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red
return
return $false
}
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan
return
return $true
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
@@ -118,20 +136,27 @@ function ReplaceStartMenu {
$startMenuDir = Split-Path $startMenuBinFile -Parent
$backupBinFile = Join-Path $startMenuDir $backupFileName
if (Test-Path $startMenuBinFile) {
# Backup current start menu file
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force
Write-Verbose "Start menu backup for user $userName saved to $backupFileName"
}
else {
Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow
New-Item -ItemType File -Path $startMenuBinFile -Force
}
try {
if (Test-Path $startMenuBinFile) {
# Backup current start menu file
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force -ErrorAction Stop
Write-Verbose "Start menu backup for user $userName saved to $backupFileName"
}
else {
Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow
New-Item -ItemType File -Path $startMenuBinFile -Force -ErrorAction Stop | Out-Null
}
# Copy template file
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force
# Copy template file
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force -ErrorAction Stop
}
catch {
Write-Warning "Failed to replace Start Menu for user ${userName}: $($_.Exception.Message)"
return $false
}
Write-Host "Replaced start menu for user $userName"
return $true
}
<#
@@ -147,12 +172,12 @@ function ReplaceStartMenu {
The target username. Pass an empty string or omit to resolve for the current user.
.EXAMPLE
GetStartMenuBinPathForUser -UserName "Jeff"
Get-StartMenuBinPathForUser -UserName "Jeff"
.EXAMPLE
GetStartMenuBinPathForUser -UserName "Default"
Get-StartMenuBinPathForUser -UserName "Default"
#>
function GetStartMenuBinPathForUser {
function Get-StartMenuBinPathForUser {
param(
[string]$UserName
)
@@ -161,7 +186,7 @@ function GetStartMenuBinPathForUser {
return "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
}
return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false)
return (Get-UserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false)
}
<#
@@ -177,9 +202,9 @@ function GetStartMenuBinPathForUser {
The full path to a start2.bin file.
.EXAMPLE
GetStartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
Get-StartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
#>
function GetStartMenuUserNameFromPath {
function Get-StartMenuUserNameFromPath {
param(
[string]$StartMenuBinFile
)
@@ -230,7 +255,7 @@ function Get-StartMenuBackupPath {
return $null
}
else {
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
foreach ($startMenuPath in $usersStartMenuPaths) {
$latestBackup = Get-ChildItem -Path (Join-Path $startMenuPath.FullName 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue |
@@ -261,19 +286,19 @@ function Get-StartMenuBackupPath {
finds the latest Win11Debloat-StartBackup-*.bak file.
.EXAMPLE
RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
.EXAMPLE
RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
#>
function RestoreStartMenuFromBackup {
function Restore-StartMenuFromBackup {
param(
[Parameter(Mandatory)]
[string]$StartMenuBinFile,
[string]$BackupFilePath
)
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
# Auto-detect latest backup in the same folder as the start2.bin
$startMenuDir = Split-Path $StartMenuBinFile -Parent
@@ -342,19 +367,19 @@ function RestoreStartMenuFromBackup {
.DESCRIPTION
Resolves the start2.bin path for the currently logged-in user, then
delegates to RestoreStartMenuFromBackup.
delegates to Restore-StartMenuFromBackup.
.PARAMETER BackupFilePath
Path to the backup file to restore from. If omitted, automatically
finds the latest Win11Debloat-StartBackup-*.bak file.
.EXAMPLE
RestoreStartMenu
Restore-StartMenu
.EXAMPLE
RestoreStartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
Restore-StartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
#>
function RestoreStartMenu {
function Restore-StartMenu {
param(
[string]$BackupFilePath
)
@@ -364,7 +389,7 @@ function RestoreStartMenu {
Write-Host "Restoring start menu for user $targetUserName from backup..."
return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
return Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
}
<#
@@ -384,17 +409,17 @@ function RestoreStartMenu {
LocalState folder.
.EXAMPLE
RestoreStartMenuForAllUsers
Restore-StartMenuForAllUsers
.EXAMPLE
RestoreStartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
Restore-StartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
#>
function RestoreStartMenuForAllUsers {
function Restore-StartMenuForAllUsers {
param(
[string]$BackupFilePath
)
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
$results = @()
@@ -402,10 +427,10 @@ function RestoreStartMenuForAllUsers {
foreach ($startMenuPath in $usersStartMenuPaths) {
$startMenuBinFile = Join-Path $startMenuPath.FullName 'start2.bin'
$results += RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
$results += Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
}
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
$defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
if (Test-Path $defaultStartMenuPath) {
$defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin'
@@ -447,4 +472,4 @@ function RestoreStartMenuForAllUsers {
}
return $results
}
}
@@ -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
}
@@ -1,4 +1,20 @@
function Load-RegistryBackupFromFile {
<#
.SYNOPSIS
Loads a registry backup from a JSON file and normalizes its contents.
.DESCRIPTION
Loads a registry backup from disk and returns a normalized representation
of its contents suitable for use by the restore workflow. Throws if the
file is missing, unreadable, or not valid JSON.
.PARAMETER FilePath
The absolute path to the registry backup JSON file to load.
.OUTPUTS
PSCustomObject
A normalized registry backup object produced by ConvertTo-NormalizedRegistryBackup.
#>
function Import-RegistryBackup {
param(
[Parameter(Mandatory)]
[string]$FilePath
@@ -15,10 +31,28 @@ function Load-RegistryBackupFromFile {
throw "Failed to read backup file '$FilePath'. The file is not valid JSON."
}
return Normalize-RegistryBackup -Backup $rawBackup
return ConvertTo-NormalizedRegistryBackup -Backup $rawBackup
}
function Normalize-RegistryBackup {
<#
.SYNOPSIS
Validates and normalizes a raw registry backup object.
.DESCRIPTION
Validates the structure and content of the supplied backup and converts
it into a normalized representation that can be safely consumed by the
restore workflow. Throws if validation fails.
.PARAMETER Backup
The raw backup object (typically parsed from JSON) to normalize.
.OUTPUTS
PSCustomObject
A normalized backup with Version, BackupType, CreatedAt, CreatedBy,
ComputerName, Target, SelectedFeatures, SelectedUndoFeatures, and
RegistryKeys properties.
#>
function ConvertTo-NormalizedRegistryBackup {
param(
[Parameter(Mandatory)]
$Backup
@@ -59,7 +93,11 @@ function Normalize-RegistryBackup {
}
elseif ($normalizedTarget -like 'CurrentUser:*') {
$targetCurrentUserName = $normalizedTarget.Substring(12)
if ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or ($targetCurrentUserName -ne $env:USERNAME)) {
if (Test-RunningAsSystem) {
$errors.Add("Backup was made for '$targetCurrentUserName' and is user-scoped. Re-run as that user; SYSTEM cannot restore a CurrentUser backup.")
}
elseif ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or
-not (Test-UserNameMatch -UserNameA $targetCurrentUserName -UserNameB $env:USERNAME)) {
$errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$env:USERNAME'.")
}
}
@@ -97,9 +135,16 @@ function Normalize-RegistryBackup {
if ($allSelectedFeatures.Count -eq 0) {
$errors.Add('Backup must contain at least one feature ID in SelectedFeatures or SelectedUndoFeatures.')
}
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -SelectedUndoFeatureIds @($selectedUndoFeatures) -Target $normalizedTarget -RegistryKeys @($normalizedKeys))
foreach ($allowListValidationError in $allowListValidationErrors) {
$errors.Add([string]$allowListValidationError)
else {
try {
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -SelectedUndoFeatureIds @($selectedUndoFeatures) -Target $normalizedTarget -RegistryKeys @($normalizedKeys))
foreach ($allowListValidationError in $allowListValidationErrors) {
$errors.Add([string]$allowListValidationError)
}
}
catch {
$errors.Add("Failed to validate backup: $($_.Exception.Message)")
}
}
if ($errors.Count -gt 0) {
@@ -125,13 +170,30 @@ function Normalize-RegistryBackup {
}
}
<#
.SYNOPSIS
Restores registry state from a normalized backup object.
.DESCRIPTION
Applies the registry state described by the supplied backup back to the
registry, loading the appropriate user hive when required.
.PARAMETER Backup
A normalized backup object (as produced by ConvertTo-NormalizedRegistryBackup) whose
RegistryKeys snapshots should be restored.
.OUTPUTS
PSCustomObject
Returns an object with a Result property set to $true when the restore
completes successfully.
#>
function Restore-RegistryBackupState {
param(
[Parameter(Mandatory)]
$Backup
)
$friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
$friendlyTarget = Get-FriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Restore registry backup for $friendlyTarget" -ForegroundColor Cyan
@@ -1,224 +0,0 @@
function Invoke-WithLoadedRestoreHive {
param(
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
$ArgumentObject = $null
)
$targetUserName = if ($Target -eq 'DefaultUserProfile') {
'Default'
}
elseif ($Target -like 'User:*') {
$userName = $Target.Substring(5)
if ([string]::IsNullOrWhiteSpace($userName)) {
throw 'Invalid backup target format for user restore.'
}
$userName
}
else {
throw "Unsupported backup target '$Target'."
}
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject
}
function Restore-RegistryKeySnapshot {
param(
[Parameter(Mandatory)]
$Snapshot
)
$registryParts = Split-RegistryPath -path $Snapshot.Path
if (-not $registryParts) {
throw "Unsupported registry path in backup: $($Snapshot.Path)"
}
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
if (-not $rootKey) {
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
}
$subKeyPath = $registryParts.SubKey
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
}
if (-not $Snapshot.Exists) {
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
return
}
$forceFullTree = @($Snapshot.SubKeys).Count -gt 0
if ($forceFullTree) {
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
}
$key = $rootKey.CreateSubKey($subKeyPath)
if ($null -eq $key) {
throw "Unable to create or open registry key '$($Snapshot.Path)'"
}
try {
foreach ($valueSnapshot in @($Snapshot.Values)) {
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot $valueSnapshot
}
}
finally {
$key.Close()
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
Restore-RegistryKeySnapshot -Snapshot $subKeySnapshot
}
}
function Restore-RegistryValueSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
[Parameter(Mandatory)]
$Snapshot
)
$valueName = if ($null -ne $Snapshot.Name) { [string]$Snapshot.Name } else { '' }
if (-not [bool]$Snapshot.Exists) {
try {
$RegistryKey.DeleteValue($valueName, $false)
}
catch {
throw "Failed deleting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
}
return
}
$valueKind = Convert-RegistryValueKindFromBackup -KindName $Snapshot.Kind
$normalizedData = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $Snapshot.Data
try {
$RegistryKey.SetValue($valueName, $normalizedData, $valueKind)
}
catch {
$retryBytes = Convert-BackupDataToByteArray -Data $Snapshot.Data
if ($null -ne $retryBytes) {
try {
$RegistryKey.SetValue($valueName, $retryBytes, [Microsoft.Win32.RegistryValueKind]::Binary)
return
}
catch {
# Fall through to original error message for context.
}
}
throw "Failed setting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
}
}
function Convert-RegistryValueKindFromBackup {
param(
[string]$KindName
)
if ([string]::IsNullOrWhiteSpace($KindName)) {
return [Microsoft.Win32.RegistryValueKind]::String
}
try {
return [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
}
catch {
throw "Unsupported registry value kind in backup: $KindName"
}
}
function Convert-RegistryValueDataFromBackup {
param(
[Microsoft.Win32.RegistryValueKind]$Kind,
$Data
)
switch ($Kind) {
([Microsoft.Win32.RegistryValueKind]::DWord) {
$unsigned = [uint32]$Data
return [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
}
([Microsoft.Win32.RegistryValueKind]::QWord) {
$unsigned = [uint64]$Data
return [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
}
([Microsoft.Win32.RegistryValueKind]::MultiString) { return @($Data | ForEach-Object { [string]$_ }) }
([Microsoft.Win32.RegistryValueKind]::Binary) {
$bytes = Convert-BackupDataToByteArray -Data $Data
if ($null -eq $bytes) {
return (New-Object byte[] 0)
}
return $bytes
}
([Microsoft.Win32.RegistryValueKind]::None) { return $null }
default {
if ($null -ne $Data) {
return [string]$Data
}
return ''
}
}
}
function Convert-BackupDataToByteArray {
param(
$Data
)
if ($null -eq $Data) {
return $null
}
if ($Data -is [byte[]]) {
return ,$Data
}
$items = @($Data)
if ($items.Count -eq 0) {
return ,(New-Object byte[] 0)
}
foreach ($item in $items) {
if ($item -isnot [ValueType] -and $item -isnot [string]) {
return $null
}
$parsed = 0
if (-not [int]::TryParse([string]$item, [ref]$parsed)) {
return $null
}
if ($parsed -lt 0 -or $parsed -gt 255) {
return $null
}
}
$bytes = New-Object byte[] $items.Count
for ($i = 0; $i -lt $items.Count; $i++) {
$bytes[$i] = [byte][int]$items[$i]
}
return ,$bytes
}
function Remove-RegistrySubKeyTreeIfExists {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RootKey,
[Parameter(Mandatory)]
[string]$SubKeyPath
)
$existing = $RootKey.OpenSubKey($SubKeyPath, $false)
if ($existing) {
$existing.Close()
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
}
}
@@ -10,22 +10,41 @@
.EXAMPLE
DisableStoreSearchSuggestionsForAllUsers
.OUTPUTS
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
#>
function DisableStoreSearchSuggestionsForAllUsers {
function Set-StoreSearchSuggestionsDisabledForAllUsers {
$success = $true
$processedProfiles = 0
# Get path to Store app database for all users
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
# Go through all users and disable start search suggestions
foreach ($storeDbPath in $usersStoreDbPaths) {
DisableStoreSearchSuggestions -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
$success = $false
}
}
# Also disable start search suggestions for the default user profile
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
DisableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath)) {
$success = $false
}
}
if ($processedProfiles -eq 0) {
Write-Warning 'Unable to disable Microsoft Store search suggestions because no target user profiles could be resolved.'
return $false
}
return $success
}
@@ -44,8 +63,11 @@ function DisableStoreSearchSuggestionsForAllUsers {
.EXAMPLE
DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
.OUTPUTS
System.Boolean. $true when the database ACL is restricted or previewed; otherwise $false.
#>
function DisableStoreSearchSuggestions {
function Set-StoreSearchSuggestionsDisabled {
param (
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
@@ -56,30 +78,35 @@ function DisableStoreSearchSuggestions {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan
return
return $true
}
# This file doesn't exist in EEA (No Store app suggestions).
if (-not (Test-Path -Path $StoreAppsDatabase))
{
Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow
try {
# This file doesn't exist in EEA (No Store app suggestions).
if (-not (Test-Path -Path $StoreAppsDatabase)) {
Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow
$storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent
$storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent
if (-not (Test-Path -Path $storeDbDir)) {
New-Item -Path $storeDbDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
if (-not (Test-Path -Path $storeDbDir)) {
New-Item -Path $storeDbDir -ItemType Directory -Force | Out-Null
New-Item -Path $StoreAppsDatabase -ItemType File -Force -ErrorAction Stop | Out-Null
}
New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
$Acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
$Acl.SetAccessRule($Ace) | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl -ErrorAction Stop | Out-Null
}
catch {
Write-Warning "Failed to restrict ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
return $false
}
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
$Acl = Get-Acl -Path $StoreAppsDatabase
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
$Acl.SetAccessRule($Ace) | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl | Out-Null
Write-Host "Disabled Microsoft Store search suggestions for user $userName"
return $true
}
<#
@@ -94,22 +121,41 @@ function DisableStoreSearchSuggestions {
.EXAMPLE
EnableStoreSearchSuggestionsForAllUsers
.OUTPUTS
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
#>
function EnableStoreSearchSuggestionsForAllUsers {
function Set-StoreSearchSuggestionsEnabledForAllUsers {
$success = $true
$processedProfiles = 0
# Get path to Store app database for all users
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
# Go through all users and re-enable start search suggestions
foreach ($storeDbPath in $usersStoreDbPaths) {
EnableStoreSearchSuggestions -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
$success = $false
}
}
# Also re-enable for the default user profile
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
EnableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
$processedProfiles++
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath)) {
$success = $false
}
}
if ($processedProfiles -eq 0) {
Write-Warning 'Unable to re-enable Microsoft Store search suggestions because no target user profiles could be resolved.'
return $false
}
return $success
}
<#
@@ -127,8 +173,11 @@ function EnableStoreSearchSuggestionsForAllUsers {
.EXAMPLE
EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
.OUTPUTS
System.Boolean. $true when the deny ACL is removed, the database is absent, or the change is previewed; otherwise $false.
#>
function EnableStoreSearchSuggestions {
function Set-StoreSearchSuggestionsEnabled {
param (
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
@@ -139,28 +188,41 @@ function EnableStoreSearchSuggestions {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Re-enable Microsoft Store search suggestions for user $userName by restoring access to ${StoreAppsDatabase}" -ForegroundColor Cyan
return
return $true
}
if (-not (Test-Path -Path $StoreAppsDatabase)) {
Write-Host "Store app database not found for user $userName, nothing to undo"
return
return $true
}
# Ensure we can modify/delete the file even if restrictive ACLs were set.
$global:LASTEXITCODE = 0
takeown /F "$StoreAppsDatabase" /A | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to take ownership of store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
return $false
}
icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to grant Administrators access to store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
return $false
}
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
try {
$acl = Get-Acl -Path $StoreAppsDatabase
$acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
$denyRules = @(
$acl.Access | Where-Object {
$_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0) -and
(try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid } catch { $false })
if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false }
if (($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq 0) { return $false }
try {
return ($_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid)
}
catch {
return $false
}
}
)
@@ -168,7 +230,7 @@ function EnableStoreSearchSuggestions {
$null = $acl.RemoveAccessRuleSpecific($denyRule)
}
Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null
Set-Acl -Path $StoreAppsDatabase -AclObject $acl -ErrorAction Stop | Out-Null
}
catch {
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
@@ -177,9 +239,11 @@ function EnableStoreSearchSuggestions {
try {
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
return $true
}
catch {
throw "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
Write-Warning "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
return $false
}
}
@@ -196,12 +260,12 @@ function EnableStoreSearchSuggestions {
The target username. Pass an empty string or omit to resolve for the current user.
.EXAMPLE
GetStoreAppsDatabasePathForUser -UserName "Jeff"
Get-StoreAppsDatabasePathForUser -UserName "Jeff"
.EXAMPLE
GetStoreAppsDatabasePathForUser -UserName "Default"
Get-StoreAppsDatabasePathForUser -UserName "Default"
#>
function GetStoreAppsDatabasePathForUser {
function Get-StoreAppsDatabasePathForUser {
param(
[string]$UserName
)
@@ -210,7 +274,7 @@ function GetStoreAppsDatabasePathForUser {
return "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
}
return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false)
return (Get-UserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false)
}
<#
@@ -282,13 +346,13 @@ function Test-StoreSearchSuggestionsDisabled {
function Test-StoreSearchSuggestionsDisabledForAllUsers {
$paths = @()
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
foreach ($storeDbPath in $usersStoreDbPaths) {
$paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
}
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
$paths += $defaultStoreDbPath
}
@@ -304,4 +368,4 @@ function Test-StoreSearchSuggestionsDisabledForAllUsers {
}
return $true
}
}
@@ -34,21 +34,36 @@ function Get-TelemetryScheduledTasks {
.EXAMPLE
Disable-TelemetryScheduledTasks
.OUTPUTS
System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false.
#>
function Disable-TelemetryScheduledTasks {
Write-Host "> Disabling telemetry scheduled tasks..."
$tasks = Get-TelemetryScheduledTasks
$success = $true
foreach ($task in $tasks) {
if ($script:CancelRequested) { return $false }
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
continue
}
$result = Invoke-NonBlocking -ScriptBlock {
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($path, $name)
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
try {
Import-Module ScheduledTasks -ErrorAction Stop
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
}
catch {
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
return @{ Success = $true; Status = 'NotFound' }
}
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
}
if (-not $taskObj) {
return @{ Success = $true; Status = 'NotFound' }
}
@@ -62,17 +77,24 @@ function Disable-TelemetryScheduledTasks {
}
}
return @{ Success = $true; Status = 'AlreadyDisabled' }
} -ArgumentList @($task.Path, $task.Name)
} -ArgumentList @($task.Path, $task.Name)
}
catch {
Write-Warning "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
$success = $false
continue
}
switch ($result.Status) {
'Disabled' { Write-Host "Disabled Scheduled Task: $($task.Path)$($task.Name)" }
'AlreadyDisabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already disabled" -ForegroundColor DarkGray }
'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found" -ForegroundColor DarkGray }
'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow }
'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
default { Write-Warning "Unable to determine the result of disabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
}
}
Write-Host ""
return $success
}
<#
@@ -86,21 +108,36 @@ function Disable-TelemetryScheduledTasks {
.EXAMPLE
Enable-TelemetryScheduledTasks
.OUTPUTS
System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false.
#>
function Enable-TelemetryScheduledTasks {
Write-Host "> Enabling telemetry scheduled tasks..."
$tasks = Get-TelemetryScheduledTasks
$success = $true
foreach ($task in $tasks) {
if ($script:CancelRequested) { return $false }
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
continue
}
$result = Invoke-NonBlocking -ScriptBlock {
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($path, $name)
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
try {
Import-Module ScheduledTasks -ErrorAction Stop
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
}
catch {
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
return @{ Success = $true; Status = 'NotFound' }
}
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
}
if (-not $taskObj) {
return @{ Success = $true; Status = 'NotFound' }
}
@@ -114,15 +151,22 @@ function Enable-TelemetryScheduledTasks {
}
}
return @{ Success = $true; Status = 'AlreadyEnabled' }
} -ArgumentList @($task.Path, $task.Name)
} -ArgumentList @($task.Path, $task.Name)
}
catch {
Write-Warning "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
$success = $false
continue
}
switch ($result.Status) {
'Enabled' { Write-Host "Enabled Scheduled Task: $($task.Path)$($task.Name)" }
'AlreadyEnabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already enabled." -ForegroundColor DarkGray }
'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found." -ForegroundColor DarkGray }
'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow }
'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
default { Write-Warning "Unable to determine the result of enabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
}
}
Write-Host ""
return $success
}
@@ -0,0 +1,119 @@
<#
.SYNOPSIS
Enables a Windows optional feature and pipes its output to the console.
.OUTPUTS
System.Boolean. $true when enabling succeeds or is previewed; otherwise $false.
#>
function Enable-WindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
return $true
}
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
try {
$output = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -ErrorAction Stop
return [PSCustomObject]@{
Success = $true
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
Error = $null
}
}
catch {
return [PSCustomObject]@{
Success = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $FeatureName
}
catch {
Write-Warning "Failed to enable Windows feature '$FeatureName': $($_.Exception.Message)"
return $false
}
if (-not $result -or -not $result.Success) {
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
Write-Warning "Failed to enable Windows feature '$FeatureName'$details"
return $false
}
if ($result.Output) { Write-Host $result.Output }
return $true
}
<#
.SYNOPSIS
Disables a Windows optional feature and pipes its output to the console.
.OUTPUTS
System.Boolean. $true when disabling succeeds or is previewed; otherwise $false.
#>
function Disable-WindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
return $true
}
try {
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
try {
$output = Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart -ErrorAction Stop
return [PSCustomObject]@{
Success = $true
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
Error = $null
}
}
catch {
return [PSCustomObject]@{
Success = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $FeatureName
}
catch {
Write-Warning "Failed to disable Windows feature '$FeatureName': $($_.Exception.Message)"
return $false
}
if (-not $result -or -not $result.Success) {
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
Write-Warning "Failed to disable Windows feature '$FeatureName'$details"
return $false
}
if ($result.Output) { Write-Host $result.Output }
return $true
}
function Test-WindowsOptionalFeatureEnabled {
param (
[Parameter(Mandatory)]
[string]$FeatureName
)
try {
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
}
catch {
return $false
}
return ($feature.State -eq 'Enabled')
}
@@ -1,61 +0,0 @@
# Enables a Windows optional feature and pipes its output to the console
function EnableWindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
Write-Host ""
return
}
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}
# Disables a Windows optional feature and pipes its output to the console
function DisableWindowsFeature {
param (
[string]$FeatureName
)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
Write-Host ""
return
}
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}
function Test-WindowsOptionalFeatureEnabled {
param (
[Parameter(Mandatory)]
[string]$FeatureName
)
try {
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
}
catch {
return $false
}
return ($feature.State -eq 'Enabled')
}
@@ -1,10 +1,10 @@
# Returns a validated list of apps based on the provided appsList and the supported apps from Apps.json
function ValidateAppslist {
function Get-ValidatedAppList {
param (
$appsList
)
$supportedAppsList = @(LoadAppsDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }
$supportedAppsList = @(Import-AppDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }
$validatedAppsList = @()
# Validate provided appsList against supportedAppsList
@@ -1,5 +1,27 @@
# Read Apps.json and return list of app objects with optional filtering
function LoadAppsDetailsFromJson {
<#
.SYNOPSIS
Loads application details from Apps.json.
.DESCRIPTION
Reads the application definitions from Apps.json, optionally filters the
results to installed applications, and returns normalized app objects for
display and selection.
.PARAMETER OnlyInstalled
Filters the results to applications detected through Appx or the supplied
winget installation list.
.PARAMETER InstalledList
A pre-fetched winget installation list used when filtering installed apps.
.PARAMETER InitialCheckedFromJson
Sets each returned app's IsChecked value from its SelectedByDefault setting.
.OUTPUTS
System.Management.Automation.PSCustomObject[]
Application detail objects containing display, selection, and removal data.
#>
function Import-AppDetailsFromJson {
param (
[switch]$OnlyInstalled,
[object[]]$InstalledList = $null,
@@ -17,8 +39,13 @@ function LoadAppsDetailsFromJson {
foreach ($appData in $jsonContent.Apps) {
# Handle AppId as array (could be single or multiple IDs)
$appIdArray = if ($appData.AppId -is [array]) { $appData.AppId } else { @($appData.AppId) }
$appIdArray = $appIdArray | ForEach-Object { $_.Trim() } | Where-Object { $_.length -gt 0 }
$appIdArray = @(
foreach ($rawAppId in @($appData.AppId)) {
if ($rawAppId -isnot [string]) { continue }
$normalizedAppId = $rawAppId.Trim()
if ($normalizedAppId.Length -gt 0) { $normalizedAppId }
}
)
if ($appIdArray.Count -eq 0) { continue }
if ($OnlyInstalled) {
@@ -1,6 +1,8 @@
# Read Apps.json and return the list of preset objects (Name + AppIds).
# Returns an empty array if the file cannot be read or contains no presets.
function LoadAppPresetsFromJson {
<#
.SYNOPSIS
Returns preset names and application IDs from Apps.json, or an empty array when unavailable.
#>
function Import-AppPresetsFromJson {
try {
$jsonContent = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
}
@@ -14,7 +14,7 @@
System.String[]. An array of app ID strings, or an empty array if the
file does not exist or contains no selected-by-default apps.
#>
function LoadAppsFromFile {
function Import-AppsFromFile {
param (
$appsFilePath
)
@@ -41,6 +41,6 @@ function LoadAppsFromFile {
}
catch {
Write-Error "Unable to read apps list from file: $appsFilePath"
AwaitKeyToExit
Wait-ForKeyPress -ExitCode 1
}
}
@@ -1,6 +1,8 @@
# Loads a JSON file from the specified path and returns the parsed object
# Returns $null if the file doesn't exist or if parsing fails
function LoadJsonFile {
<#
.SYNOPSIS
Imports a JSON file, optionally validates its version, and returns $null on failure.
#>
function Import-JsonFile {
param (
[string]$filePath,
[string]$expectedVersion = $null,
@@ -1,11 +1,14 @@
# Loads settings from a JSON file and adds them to script params
function LoadSettings {
<#
.SYNOPSIS
Imports enabled, compatible feature settings from a JSON file into the active parameters.
#>
function Import-Settings {
param (
[string]$filePath,
[string]$expectedVersion = "1.0"
)
$settingsJson = LoadJsonFile -filePath $filePath -expectedVersion $expectedVersion
$settingsJson = Import-JsonFile -filePath $filePath -expectedVersion $expectedVersion
if (-not $settingsJson -or -not $settingsJson.Settings) {
throw "Failed to load settings from $(Split-Path $filePath -Leaf)"
@@ -29,6 +32,6 @@ function LoadSettings {
continue
}
AddParameter $setting.Name $setting.Value
Add-Parameter $setting.Name $setting.Value
}
}
@@ -1,5 +1,8 @@
# Saves the current settings, excluding control parameters, to 'LastUsedSettings.json' file
function SaveSettings {
<#
.SYNOPSIS
Saves active feature settings, excluding control parameters, unless running in WhatIf mode.
#>
function Save-Settings {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan
return
@@ -21,8 +24,8 @@ function SaveSettings {
}
}
if (-not (SaveToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) {
if (-not (Save-ToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) {
Write-Output ""
Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<#
.SYNOPSIS
Serializes a configuration hashtable to a UTF-8 JSON file.
.PARAMETER Config
The configuration data to serialize.
.PARAMETER FilePath
The destination file path.
.PARAMETER MaxDepth
The maximum object depth passed to ConvertTo-Json.
.OUTPUTS
System.Boolean. $true when the file is written; otherwise $false.
#>
function Save-ToFile {
param (
[Parameter(Mandatory=$true)]
[hashtable]$Config,
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$false)]
[int]$MaxDepth = 10
)
try {
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8 -ErrorAction Stop
return $true
}
catch {
Write-Error "Failed to write '$FilePath': $($_.Exception.Message)"
return $false
}
}
-22
View File
@@ -1,22 +0,0 @@
# Saves configuration JSON to a file.
# Returns $true on success, $false on failure.
function SaveToFile {
param (
[Parameter(Mandatory=$true)]
[hashtable]$Config,
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$false)]
[int]$MaxDepth = 10
)
try {
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8
return $true
}
catch {
return $false
}
}
@@ -1,6 +1,20 @@
# Applies settings from a JSON object to UI controls (checkboxes and comboboxes)
# Used by LoadDefaultsBtn and LoadLastUsedBtn in the UI
function ApplySettingsToUiControls {
<#
.SYNOPSIS
Applies enabled settings from JSON to mapped checkbox and combo-box controls.
.PARAMETER Window
The window that owns the mapped controls.
.PARAMETER SettingsJson
The settings object containing a Settings collection.
.PARAMETER UiControlMappings
The feature-to-control mapping used to locate and update controls.
.OUTPUTS
System.Boolean. $false for invalid settings input; otherwise $true.
#>
function Apply-SettingsToUiControls {
param (
$window,
$settingsJson,
@@ -1,10 +1,20 @@
# Attaches shift-click selection behavior to a checkbox in an apps panel
# Parameters:
# - $checkbox: The checkbox to attach the behavior to
# - $appsPanel: The StackPanel containing checkbox items
# - $lastSelectedCheckboxRef: A reference to a variable storing the last clicked checkbox
# - $updateStatusCallback: Optional callback to update selection status
function AttachShiftClickBehavior {
<#
.SYNOPSIS
Attaches shift-click range-selection behavior to an application checkbox.
.PARAMETER Checkbox
The checkbox that receives the mouse event handler.
.PARAMETER AppsPanel
The panel whose visible checkboxes participate in range selection.
.PARAMETER LastSelectedCheckboxRef
A reference that stores the previously clicked checkbox.
.PARAMETER UpdateStatusCallback
An optional callback invoked after a range selection changes.
#>
function Attach-ShiftClickBehavior {
param (
[System.Windows.Controls.CheckBox]$checkbox,
[System.Windows.Controls.StackPanel]$appsPanel,
@@ -1,5 +1,11 @@
# Checks if the system is set to use dark mode for apps
function GetSystemUsesDarkMode {
<#
.SYNOPSIS
Returns whether Windows apps are configured to use dark mode.
.OUTPUTS
System.Boolean. $false when the personalization setting cannot be read.
#>
function Get-SystemUsesDarkMode {
try {
$personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
+101 -16
View File
@@ -174,6 +174,10 @@ function Update-AppSelectionStatus {
}
}
<#
.SYNOPSIS
Updates the app-removal scope description to match the selected ComboBoxItem.
#>
function Update-AppRemovalScopeDescription {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
@@ -182,20 +186,60 @@ function Update-AppRemovalScopeDescription {
$selectedItem = $AppRemovalScopeCombo.SelectedItem
if ($selectedItem) {
switch ($selectedItem.Content) {
"All users" {
# Content is the display text and will change once translated; Name is stable.
switch ($selectedItem.Name) {
"AppRemovalScopeAllUsers" {
$AppRemovalScopeDescription.Text = "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users."
}
"Current user only" {
"AppRemovalScopeCurrentUser" {
$AppRemovalScopeDescription.Text = "Apps will only be removed for the current user."
}
"Target user only" {
"AppRemovalScopeTargetUser" {
$AppRemovalScopeDescription.Text = "Apps will only be removed for the specified target user."
}
}
}
}
<#
.SYNOPSIS
Tests whether the app-removal scope combo is currently set to "Target user only".
#>
function Test-AppRemovalScopeTargetsOtherUser {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
)
return ($AppRemovalScopeCombo -and $AppRemovalScopeCombo.SelectedItem -and $AppRemovalScopeCombo.SelectedItem.Name -eq 'AppRemovalScopeTargetUser')
}
<#
.SYNOPSIS
Resolves the -AppRemovalTarget value for the selected app-removal scope.
#>
function Get-AppRemovalScopeTarget {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
[System.Windows.Controls.TextBox]$OtherUsernameTextBox
)
$selectedItem = $AppRemovalScopeCombo.SelectedItem
if (-not $selectedItem) { return $null }
if (Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo) {
return $OtherUsernameTextBox.Text.Trim()
}
switch ($selectedItem.Name) {
"AppRemovalScopeAllUsers" { return 'AllUsers' }
"AppRemovalScopeCurrentUser" { return 'CurrentUser' }
default {
Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal."
return $null
}
}
}
function Invoke-AppPreset {
param(
[System.Windows.Controls.Panel]$AppsPanel,
@@ -223,7 +267,7 @@ function Update-AppPresetStates {
$script:UpdatingPresets = $true
try {
# Helper: count matching and checked apps, set checkbox state
function SetPresetState($CheckBox, [scriptblock]$MatchFilter) {
function Set-PresetState($CheckBox, [scriptblock]$MatchFilter) {
$total = 0; $checked = 0
foreach ($child in $AppsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
@@ -241,15 +285,15 @@ function Update-AppPresetStates {
$presetDefaultApps = $window.FindName('PresetDefaultApps')
$presetLastUsed = $window.FindName('PresetLastUsed')
SetPresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true }
Set-PresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true }
foreach ($jsonCb in $script:JsonPresetCheckboxes) {
$localIds = $jsonCb.PresetAppIds
SetPresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure()
Set-PresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure()
}
# Last used preset: only update if it's visible (has saved apps)
if ($presetLastUsed.Visibility -ne 'Collapsed' -and $script:SavedAppIds) {
SetPresetState $presetLastUsed { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 }
Set-PresetState $presetLastUsed { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 }
}
}
finally {
@@ -304,7 +348,29 @@ function Find-ParentScrollViewer {
return $null
}
function Load-AppsWithList {
<#
.SYNOPSIS
Loads application details and adds their interactive checkboxes to the main window.
.PARAMETER Window
The main application window and resource owner.
.PARAMETER AppsPanel
The panel populated with application checkboxes.
.PARAMETER OnlyInstalledAppsBox
The filter control that determines whether only installed apps are loaded.
.PARAMETER LoadingAppsIndicator
The loading indicator shown while application details are prepared.
.PARAMETER ImportConfigBtn
The optional import control re-enabled after loading completes.
.PARAMETER ListOfApps
An optional pre-fetched list of installed WinGet applications.
#>
function Add-AppsToMainWindow {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Panel]$AppsPanel,
@@ -335,7 +401,7 @@ function Load-AppsWithList {
$script:AppsListFilePath = $appsListFilePath
. $helperScript
. $loaderScript
LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
} -ArgumentList $loaderScriptPath, $helperScriptPath, $appsFilePath, $ListOfApps, $onlyInstalled
}
@@ -435,7 +501,7 @@ function Load-AppsWithList {
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
-UserSelectionCombo $w.FindName('UserSelectionCombo')
})
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
-lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) `
-updateStatusCallback {
$w = $script:MainWindow
@@ -449,7 +515,7 @@ function Load-AppsWithList {
$AppsPanel.Children.Add($checkbox) | Out-Null
if (($i + 1) % $batchSize -eq 0) { DoEvents }
if (($i + 1) % $batchSize -eq 0) { Invoke-DoEvents }
}
$sortArrowName = $Window.FindName('SortArrowName')
@@ -480,7 +546,26 @@ function Load-AppsWithList {
}
}
function Load-AppsIntoMainUI {
<#
.SYNOPSIS
Starts asynchronous loading of application checkboxes for the main window.
.PARAMETER Window
The main application window.
.PARAMETER AppsPanel
The panel that receives application checkboxes.
.PARAMETER OnlyInstalledAppsBox
The installed-applications filter control.
.PARAMETER LoadingAppsIndicator
The loading indicator shown until loading completes.
.PARAMETER ImportConfigBtn
The optional import control disabled while loading is in progress.
#>
function Initialize-MainWindowApps {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Panel]$AppsPanel,
@@ -511,7 +596,7 @@ function Load-AppsIntoMainUI {
# Force a render so the loading indicator is visible, then schedule the
# actual loading at Background priority so this call returns immediately.
# This is critical when called from Add_Loaded: the window must finish
# its initialization before we start a nested message pump via DoEvents.
# its initialization before we start a nested message pump via Invoke-DoEvents.
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action] {})
$Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action] {
try {
@@ -519,7 +604,7 @@ function Load-AppsIntoMainUI {
if ($OnlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
Write-Host "Retrieving installed apps via winget..."
$listOfApps = GetInstalledAppsViaWinget -TimeOut 20 -NonBlocking
$listOfApps = Get-WingetInstalledApps -TimeOut 20 -NonBlocking
if ($null -eq $listOfApps) {
Write-Warning "WinGet returned no data (command timed out or failed)"
@@ -528,7 +613,7 @@ function Load-AppsIntoMainUI {
}
}
Load-AppsWithList -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox `
Add-AppsToMainWindow -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox `
-LoadingAppsIndicator $LoadingAppsIndicator -ImportConfigBtn $ImportConfigBtn -ListOfApps $listOfApps
}
catch {
+53 -14
View File
@@ -13,6 +13,13 @@ function Get-UndoFeatureLabel {
return [string]$script:FeatureLabelLookup[$FeatureId]
}
<#
.SYNOPSIS
Returns the tweak actions that are pending based on the current UI state.
.OUTPUTS
[PSCustomObject[]] Objects with Action, FeatureId, and Label properties.
#>
function Get-PendingTweakActions {
param(
[System.Windows.Window]$Window,
@@ -54,7 +61,7 @@ function Get-PendingTweakActions {
$actions.Add([PSCustomObject]@{
Action = 'Undo'
FeatureId = [string]$mapping.FeatureId
Label = [string]$script:FeatureLabelLookup[$mapping.FeatureId]
Label = [string](Get-UndoFeatureLabel -FeatureId $mapping.FeatureId)
})
}
}
@@ -140,7 +147,20 @@ function Invoke-ShowChangesOverview {
Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600
}
function Build-TweakPresetControlMap {
<#
.SYNOPSIS
Builds the control values needed to apply a saved tweak preset.
.PARAMETER Window
The window that owns the visible tweak controls.
.PARAMETER SettingsJson
The saved settings object to translate into control values.
.OUTPUTS
System.Collections.Hashtable. Control metadata keyed by control name.
#>
function Get-TweakPresetControlMap {
param(
[System.Windows.Window]$Window,
$SettingsJson
@@ -151,7 +171,7 @@ function Build-TweakPresetControlMap {
return $presetMap
}
# FeatureId -> control metadata, similar to ApplySettingsToUiControls lookup.
# FeatureId -> control metadata, similar to Apply-SettingsToUiControls lookup.
$featureIdIndex = @{}
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
@@ -192,10 +212,23 @@ function Build-TweakPresetControlMap {
return $presetMap
}
function Build-CategoryTweakPresetMap {
<#
.SYNOPSIS
Builds the enabled state map for visible tweak controls in a category.
.PARAMETER Window
The window that owns the visible tweak controls.
.PARAMETER CategoryId
The stable CategoryId (from Features.json) whose mapped controls are included.
.OUTPUTS
System.Collections.Hashtable. Control metadata keyed by control name.
#>
function Get-CategoryTweakPresetMap {
param(
[System.Windows.Window]$Window,
[string]$Category
[string]$CategoryId
)
$presetMap = @{}
@@ -203,7 +236,7 @@ function Build-CategoryTweakPresetMap {
foreach ($controlName in $script:UiControlMappings.Keys) {
$mapping = $script:UiControlMappings[$controlName]
if ($mapping.Category -ne $Category) { continue }
if ($mapping.CategoryId -ne $CategoryId) { continue }
$control = $Window.FindName($controlName)
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
@@ -368,10 +401,10 @@ function Initialize-TweakPresetSources {
$LastUsedSettingsJson
)
$script:DefaultTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson
$script:LastUsedTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson
$script:PrivacyTweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'Privacy & Suggested Content'
$script:AITweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'AI'
$script:DefaultTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson
$script:LastUsedTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson
$script:PrivacyTweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId 'PrivacySuggestedContent'
$script:AITweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId 'AI'
$presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn')
if ($presetLastUsedTweaksBtn) {
@@ -414,7 +447,7 @@ function Update-UserSelectionDescription {
switch ($UserSelectionCombo.SelectedIndex) {
0 {
$currentUserName = GetUserName
$currentUserName = Get-UserName
if ([string]::IsNullOrWhiteSpace($currentUserName)) {
$UserSelectionDescription.Text = "The currently logged-in user profile"
}
@@ -435,6 +468,9 @@ function Update-UserSelectionDescription {
$UserSelectionDescription.Text = "The default user template, affecting all new users created after this point. Useful for Sysprep deployment."
}
}
# Mirror the description text on the combo's tooltip so the same context is shown on hover.
$UserSelectionCombo.ToolTip = $UserSelectionDescription.Text
}
function Test-OtherUsername {
@@ -442,11 +478,14 @@ function Test-OtherUsername {
[System.Windows.Window]$Window,
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
[System.Windows.Controls.TextBox]$OtherUsernameTextBox,
[System.Windows.Controls.TextBlock]$UsernameValidationMessage
[System.Windows.Controls.TextBlock]$UsernameValidationMessage,
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
)
# Only validate if "Other User" is selected
if ($UserSelectionCombo.SelectedIndex -ne 1) {
# Only validate if "Other User" is the deployment target, or "Target user only" is the app-removal scope
$isOtherUserSelected = ($UserSelectionCombo.SelectedIndex -eq 1)
$isAppRemovalTargetUserSelected = Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo
if (-not $isOtherUserSelected -and -not $isAppRemovalTargetUserSelected) {
return $true
}
+94 -20
View File
@@ -1,13 +1,26 @@
# MainWindow-TweaksBuilder.ps1
# Dynamic tweaks UI construction from Features.json, tweak state management, selection clear, and search/highlight.
function Build-DynamicTweaks {
<#
.SYNOPSIS
Builds the main window's dynamic tweak controls from Features.json.
.PARAMETER Window
The main window whose category columns receive the generated controls.
.PARAMETER WinVersion
The Windows build number used for the category-icon fallback.
.NOTES
Initializes script-scoped control and category mappings used by the tweak UI.
#>
function New-DynamicTweakControls {
param(
[System.Windows.Window]$Window,
[int]$WinVersion
)
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
$featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
if (-not $featuresJson) {
throw "Unable to load Features.json file. The GUI cannot continue without feature definitions."
@@ -29,7 +42,26 @@ function Build-DynamicTweaks {
$script:TweaksCompactMode = $null
$script:TweaksCardsMovedFromCol2 = @()
function CreateLabeledCombo($parent, $labelText, $comboName, $items) {
<#
.SYNOPSIS
Creates and registers a labeled combo box or a checkbox for a tweak.
.PARAMETER Parent
The panel that receives the generated control.
.PARAMETER LabelText
The display and automation label for the tweak.
.PARAMETER ComboName
The name used to register the generated control.
.PARAMETER Items
The available tweak options; two options produce a checkbox.
.OUTPUTS
System.Windows.Controls.Control. The generated checkbox or combo box.
#>
function New-LabeledCombo($parent, $labelText, $comboName, $items) {
# If only 2 items (No Change + one option), use a checkbox instead
if ($items.Count -eq 2) {
$checkbox = New-Object System.Windows.Controls.CheckBox
@@ -96,7 +128,17 @@ function Build-DynamicTweaks {
return $combo
}
function GetWikiUrlForCategory($category) {
<#
.SYNOPSIS
Returns the Features wiki URL for a tweak category.
.PARAMETER Category
The category name converted to a wiki anchor.
.OUTPUTS
System.String. The category URL, or the Features page for an empty category.
#>
function Get-WikiUrlForCategory($category) {
if (-not $category) { return 'https://github.com/Raphire/Win11Debloat/wiki/Features' }
$slug = $category.ToLowerInvariant()
@@ -107,7 +149,17 @@ function Build-DynamicTweaks {
return "https://github.com/Raphire/Win11Debloat/wiki/Features#$slug"
}
function GetOrCreateCategoryCard($categoryObj) {
<#
.SYNOPSIS
Returns the existing category panel or creates and registers a new one.
.PARAMETER CategoryObj
The category definition containing Name and Icon properties.
.OUTPUTS
System.Windows.Controls.StackPanel. The category's content panel.
#>
function Get-OrCreateCategoryCard($categoryObj) {
$categoryName = $categoryObj.Name
$categoryIcon = $categoryObj.Icon
@@ -132,6 +184,9 @@ function Build-DynamicTweaks {
# Convert HTML entity to character (e.g., &#xE72E; -> actual character)
if ($categoryIcon -match '&#x([0-9A-Fa-f]+);') {
$hexValue = [Convert]::ToInt32($matches[1], 16)
if ($WinVersion -lt 22000 -and $hexValue -eq 0xE794) {
$hexValue = 0xE734
}
$icon.Text = [char]$hexValue
}
$icon.Style = $Window.Resources['CategoryHeaderIcon']
@@ -149,7 +204,7 @@ function Build-DynamicTweaks {
$helpBtn = New-Object System.Windows.Controls.Button
$helpBtn.Content = $helpIcon
$helpBtn.ToolTip = "Open the wiki for more info on '$categoryName' tweaks"
$helpBtn.Tag = (GetWikiUrlForCategory -category $categoryName)
$helpBtn.Tag = (Get-WikiUrlForCategory -category $categoryName)
$helpBtn.Style = $Window.Resources['CategoryHelpLinkButtonStyle']
$helpBtn.Add_Click({
param($button, $e)
@@ -179,8 +234,17 @@ function Build-DynamicTweaks {
foreach ($c in $featuresJson.Categories) {
$categoryName = if ($c -is [string]) { $c } else { $c.Name }
if ($categoriesPresent.ContainsKey($categoryName)) {
# Store the full category object (or create one with default icon for string categories)
$categoryObj = if ($c -is [string]) { @{Name = $c; Icon = '&#xE712;' } } else { $c }
# Store the full category object (or create one with default icon for string categories).
# A category without its own CategoryId falls back to its Name, same as before CategoryId existed.
$categoryObj = if ($c -is [string]) {
@{Name = $c; CategoryId = $c; Icon = '&#xE712;' }
}
elseif (-not $c.CategoryId) {
@{Name = $c.Name; CategoryId = $c.Name; Icon = $c.Icon }
}
else {
$c
}
$orderedCategories += $categoryObj
}
}
@@ -188,7 +252,7 @@ function Build-DynamicTweaks {
else {
# For backward compatibility, create category objects from keys
foreach ($catName in $categoriesPresent.Keys) {
$orderedCategories += @{Name = $catName; Icon = '&#xE712;' }
$orderedCategories += @{Name = $catName; CategoryId = $catName; Icon = '&#xE712;' }
}
}
@@ -200,10 +264,10 @@ function Build-DynamicTweaks {
foreach ($categoryObj in $orderedCategories) {
$categoryName = $categoryObj.Name
$categoryId = $categoryObj.CategoryId
# Create/get card for this category
$panel = GetOrCreateCategoryCard -categoryObj $categoryObj
if (-not $panel) { continue }
# Card is created lazily on the first rendered item
$panel = $null
# Collect groups and features for this category, then sort by priority
$categoryItems = @()
@@ -287,7 +351,8 @@ function Build-DynamicTweaks {
if ($soleFeature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($soleFeature.FeatureId -match '^Enable') { $opt = 'Enable' }
$items = @('No Change', $opt)
$comboName = ("Feature_{0}_Combo" -f $soleFeature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
$combo = CreateLabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
$combo = New-LabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items
# attach tooltip from Features.json if present
if ($soleFeature.ToolTip -or $soleFeature.DisableWhenApplied -eq $true) {
$tooltipText = $soleFeature.ToolTip
@@ -304,14 +369,15 @@ function Build-DynamicTweaks {
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; Category = $categoryName }
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; CategoryId = $categoryId }
}
continue
}
$items = @('No Change') + ($filteredValues | ForEach-Object { $_.Label })
$comboName = 'Group_{0}Combo' -f $group.GroupId
$combo = CreateLabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
$combo = New-LabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items
# attach tooltip from UiGroups if present
if ($group.ToolTip) {
$tipBlock = New-Object System.Windows.Controls.TextBlock
@@ -323,7 +389,7 @@ function Build-DynamicTweaks {
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; Category = $categoryName }
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; CategoryId = $categoryId }
}
elseif ($item.Type -eq 'feature') {
$feature = $item.Data
@@ -331,7 +397,8 @@ function Build-DynamicTweaks {
if ($feature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($feature.FeatureId -match '^Enable') { $opt = 'Enable' }
$items = @('No Change', $opt)
$comboName = ("Feature_{0}_Combo" -f $feature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
$combo = CreateLabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
$combo = New-LabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items
# attach tooltip from Features.json if present, and include the disabled-state reason
if ($feature.ToolTip -or $feature.DisableWhenApplied -eq $true) {
$tooltipText = $feature.ToolTip
@@ -349,7 +416,7 @@ function Build-DynamicTweaks {
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; Category = $categoryName }
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; CategoryId = $categoryId }
}
}
}
@@ -372,7 +439,7 @@ function Update-CurrentTweakSystemState {
if (-not $script:UiControlMappings) { return }
if (-not $script:Features) { return }
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
$featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
if (-not $featuresJson) { return }
$groupMap = @{}
@@ -418,7 +485,14 @@ function Update-CurrentTweakSystemState {
}
}
function Load-CurrentTweakStateIntoUI {
<#
.SYNOPSIS
Updates tweak controls to reflect the current system state.
.PARAMETER Window
The window that owns the generated tweak controls.
#>
function Set-CurrentTweakStateInUi {
param([System.Windows.Window]$Window)
Update-CurrentTweakSystemState -Window $Window -ApplyToUi:$true
@@ -7,7 +7,8 @@
populates the window's Resources with SolidColorBrush entries keyed by
category and resource name (e.g. "AppAccentColor"). Additionally loads and
merges shared XAML styles from the script's SharedStylesSchema path if
available.
available. Also resolves the icon font: Segoe Fluent Icons on Windows 11
and Segoe MDL2 Assets on Windows 10.
.PARAMETER window
The WPF Window whose resource dictionary will be populated.
@@ -16,13 +17,13 @@
When $true, dark theme colors are applied; when $false, light theme colors.
.EXAMPLE
SetWindowThemeResources -window $MainWindow -usesDarkMode $true
Set-WindowThemeResources -window $MainWindow -usesDarkMode $true
.EXAMPLE
SetWindowThemeResources -window $Dialog -usesDarkMode $false
Set-WindowThemeResources -window $Dialog -usesDarkMode $false
#>
# Sets resource colors for a WPF window based on dark mode preference
function SetWindowThemeResources {
function Set-WindowThemeResources {
param (
$window,
[bool]$usesDarkMode
@@ -122,6 +123,12 @@ function SetWindowThemeResources {
}
}
# Segoe Fluent Icons ships only on Windows 11 (build >= 22000).
# On Windows 10, fall back to Segoe MDL2 Assets.
$winBuild = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
$iconFontName = if ($winBuild -ge 22000) { 'Segoe Fluent Icons' } else { 'Segoe MDL2 Assets' }
$window.Resources['AppIconFontFamily'] = [System.Windows.Media.FontFamily]::new($iconFontName)
# Load and merge shared styles
if ($script:SharedStylesSchema -and (Test-Path $script:SharedStylesSchema)) {
$sharedXaml = Get-Content -Path $script:SharedStylesSchema -Raw
+10 -3
View File
@@ -1,3 +1,10 @@
<#
.SYNOPSIS
Displays the themed About dialog for the application.
.PARAMETER Owner
The optional window that owns the dialog and its modal overlay.
#>
function Show-AboutDialog {
param (
[Parameter(Mandatory=$false)]
@@ -6,7 +13,7 @@ function Show-AboutDialog {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Determine owner window
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
@@ -42,7 +49,7 @@ function Show-AboutDialog {
}
# Apply theme resources
SetWindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
# Get UI elements
$titleBar = $aboutWindow.FindName('TitleBar')
@@ -95,4 +102,4 @@ function Show-AboutDialog {
catch { }
}
}
}
}
+24 -12
View File
@@ -1,8 +1,14 @@
# Shows application selection window that allows the user to select what apps they want to remove or keep
<#
.SYNOPSIS
Displays the application-selection dialog and records the confirmed selections.
.OUTPUTS
System.Nullable[System.Boolean]. The dialog result; confirmed application IDs are stored in $script:SelectedApps.
#>
function Show-AppSelectionWindow {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Show overlay if main window exists
$overlay = $null
@@ -34,7 +40,7 @@ function Show-AppSelectionWindow {
catch { }
}
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
$appsPanel = $window.FindName('AppsPanel')
$checkAllBox = $window.FindName('CheckAllBox')
@@ -46,8 +52,14 @@ function Show-AppSelectionWindow {
# Track the last selected checkbox for shift-click range selection
$script:AppSelectionWindowLastSelectedCheckbox = $null
# Loads apps into the apps UI
function LoadApps {
<#
.SYNOPSIS
Reloads the application-selection checkboxes using the current installed-apps filter.
.NOTES
Updates the dialog loading indicator and resets range-selection state.
#>
function Load-Apps {
# Show loading indicator
$loadingIndicator.Visibility = 'Visible'
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{})
@@ -57,7 +69,7 @@ function Show-AppSelectionWindow {
if ($onlyInstalledBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
# Attempt to get a list of installed apps via WinGet, times out after 10 seconds
$listOfApps = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
$listOfApps = Get-WingetInstalledApps -TimeOut 10 -NonBlocking
if ($null -eq $listOfApps) {
# Show error that the script was unable to get list of apps from WinGet
Show-MessageBox -Message 'Unable to load list of installed apps via WinGet.' -Title 'Error' -Button 'OK' -Icon 'Error' -Owner $window | Out-Null
@@ -65,7 +77,7 @@ function Show-AppSelectionWindow {
}
}
$appsToAdd = LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true
$appsToAdd = Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true
# Reset the last selected checkbox when loading a new list
$script:AppSelectionWindowLastSelectedCheckbox = $null
@@ -82,7 +94,7 @@ function Show-AppSelectionWindow {
$checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"]
# Attach shift-click behavior for range selection
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox)
Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox)
$appsPanel.Children.Add($checkbox) | Out-Null
}
@@ -112,8 +124,8 @@ function Show-AppSelectionWindow {
}
})
$onlyInstalledBox.Add_Checked({ LoadApps })
$onlyInstalledBox.Add_Unchecked({ LoadApps })
$onlyInstalledBox.Add_Checked({ Load-Apps })
$onlyInstalledBox.Add_Unchecked({ Load-Apps })
$confirmBtn.Add_Click({
$selectedApps = @()
@@ -130,7 +142,7 @@ function Show-AppSelectionWindow {
return
}
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
return
}
@@ -141,7 +153,7 @@ function Show-AppSelectionWindow {
# Load apps after window is shown (allows UI to render first)
$window.Add_ContentRendered({
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ LoadApps }) | Out-Null
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Load-Apps }) | Out-Null
})
# Show the window and return dialog result
+38 -24
View File
@@ -1,14 +1,24 @@
<#
.SYNOPSIS
Displays the modal progress window while selected changes are applied.
.PARAMETER Owner
The optional window that owns the modal and its overlay.
.PARAMETER InvokeRestartExplorer
Indicates whether the modal should run the Explorer-restart flow after applying changes.
#>
function Show-ApplyModal {
param (
[Parameter(Mandatory=$false)]
[System.Windows.Window]$Owner = $null,
[Parameter(Mandatory=$false)]
[bool]$RestartExplorer = $false
[bool]$InvokeRestartExplorer = $false
)
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Determine owner window
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
@@ -44,7 +54,7 @@ function Show-ApplyModal {
}
# Apply theme resources
SetWindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
# Get UI elements
$script:ApplyInProgressPanel = $applyWindow.FindName('ApplyInProgressPanel')
@@ -81,7 +91,7 @@ function Show-ApplyModal {
$pct = if ($totalSteps -gt 0) { [math]::Round((($currentStep - 1) / $totalSteps) * 100) } else { 0 }
$script:ApplyProgressBarEl.Value = $pct
# Process pending window messages to keep UI responsive
DoEvents
Invoke-DoEvents
}
# Sub-step callback updates step name and interpolates progress bar within the current step
@@ -96,7 +106,7 @@ function Show-ApplyModal {
$stepFraction = ($subIndex / $subCount) / $totalSteps
$script:ApplyProgressBarEl.Value = [math]::Round(($baseProgress + $stepFraction) * 100)
}
DoEvents
Invoke-DoEvents
}
# Run changes in background to keep UI responsive
@@ -104,11 +114,12 @@ function Show-ApplyModal {
try {
Invoke-AllChanges
$registryImportFailureCount = [int]$script:RegistryImportFailures
$failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures
$appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable
# Restart explorer if requested
if ($RestartExplorer -and -not $script:CancelRequested) {
RestartExplorer
if ($InvokeRestartExplorer -and -not $script:CancelRequested) {
Invoke-RestartExplorer
# Wait for Explorer to finish relaunching, then reclaim focus.
Start-Sleep -Milliseconds 800
@@ -118,11 +129,6 @@ function Show-ApplyModal {
}
Write-Host ""
if ($script:CancelRequested) {
Write-Host "Script execution was cancelled by the user. Some changes may not have been applied."
} elseif ($registryImportFailureCount -eq 0) {
Write-Host "All changes have been applied successfully!"
}
# Show completion state
$script:ApplyProgressBarEl.Value = 100
@@ -130,27 +136,35 @@ function Show-ApplyModal {
$script:ApplyCompletionPanel.Visibility = 'Visible'
if ($script:CancelRequested) {
Write-Warning "Script execution was cancelled by the user. Any remaining changes were not applied."
$script:ApplyCompletionIconEl.Text = [char]0xE7BA
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
$script:ApplyCompletionTitleEl.Text = "Cancelled"
$script:ApplyCompletionMessageEl.Text = "Script execution was cancelled by the user."
} elseif ($registryImportFailureCount -gt 0) {
} elseif ($failureCount -gt 0 -or $appRemovalVerificationUnavailable) {
if ($failureCount -gt 0) {
Write-Host "Script completed with $failureCount error(s)."
}
$script:ApplyCompletionIconEl.Text = [char]0xE7BA
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors"
$script:ApplyCompletionMessageEl.Text = "$registryImportFailureCount registry change(s) failed. See console for details."
if ($failureCount -eq 0 -and $appRemovalVerificationUnavailable) {
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
$script:ApplyCompletionMessageEl.Text = "All changes were applied without errors, but Win11Debloat could not confirm that all selected apps were successfully uninstalled."
}
else {
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors"
$script:ApplyCompletionMessageEl.Text = "$failureCount change(s) failed. See console for details."
}
} else {
Write-Host "All changes have been applied successfully!"
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
# Show completion message with reboot instructions if any applied features require reboot
if ($RestartExplorer) {
$rebootFeatures = @()
foreach ($paramKey in $script:Params.Keys) {
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
$feature = $script:Features[$paramKey]
$rebootFeatures += "$($feature.Label)"
}
}
if ($InvokeRestartExplorer) {
$rebootFeatures = Get-RebootFeatureLabels
if ($rebootFeatures.Count -gt 0) {
foreach ($featureName in $rebootFeatures) {
@@ -1,3 +1,7 @@
<#
.SYNOPSIS
Shows a modal category-selection dialog for importing or exporting configuration.
#>
function Show-ImportExportConfigWindow {
param (
[System.Windows.Window]$Owner,
@@ -45,7 +49,7 @@ function Show-ImportExportConfigWindow {
}
$dlg.Owner = $Owner
SetWindowThemeResources -window $dlg -usesDarkMode $UsesDarkMode
Set-WindowThemeResources -window $dlg -usesDarkMode $UsesDarkMode
# Copy the CheckBox default style from the main window so checkboxes get the themed template
try {
@@ -215,6 +219,11 @@ function Get-DeploymentSettings {
$deploySettings += @{ Name = 'CreateRestorePoint'; Value = [bool]$restorePointCheckBox.IsChecked }
}
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox) {
$deploySettings += @{ Name = 'SkipRegistryBackup'; Value = -not [bool]$registryBackupCheckBox.IsChecked }
}
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
if ($restartExplorerCheckBox) {
$deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked }
@@ -268,6 +277,7 @@ function Get-DeploymentCategoryDetailString {
$options = @()
if ($lookup.ContainsKey('CreateRestorePoint') -and [bool]$lookup['CreateRestorePoint']) { $options += 'Restore Point' }
if (-not ($lookup.ContainsKey('SkipRegistryBackup') -and [bool]$lookup['SkipRegistryBackup'])) { $options += 'Registry Backup' }
if ($lookup.ContainsKey('RestartExplorer') -and [bool]$lookup['RestartExplorer']) { $options += 'Restart Explorer' }
$lines = @()
@@ -308,7 +318,11 @@ function Build-CategoryDetails {
return $details
}
function Apply-ImportedApplications {
<#
.SYNOPSIS
Applies imported application selections to the application checkboxes.
#>
function Set-ImportedApplications {
param (
[System.Windows.Controls.Panel]$AppsPanel,
[string[]]$AppIds
@@ -321,7 +335,11 @@ function Apply-ImportedApplications {
}
}
function Apply-ImportedTweakSettings {
<#
.SYNOPSIS
Applies imported tweak settings to their mapped UI controls.
#>
function Set-ImportedTweakSettings {
param (
[System.Windows.Window]$Owner,
[hashtable]$UiControlMappings,
@@ -329,10 +347,14 @@ function Apply-ImportedTweakSettings {
)
$settingsJson = [PSCustomObject]@{ Settings = @($TweakSettings) }
ApplySettingsToUiControls -window $Owner -settingsJson $settingsJson -uiControlMappings $UiControlMappings
Apply-SettingsToUiControls -window $Owner -settingsJson $settingsJson -uiControlMappings $UiControlMappings
}
function Apply-ImportedDeploymentSettings {
<#
.SYNOPSIS
Applies imported deployment settings to the deployment controls.
#>
function Set-ImportedDeploymentSettings {
param (
[System.Windows.Window]$Owner,
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
@@ -362,12 +384,23 @@ function Apply-ImportedDeploymentSettings {
$restorePointCheckBox.IsChecked = [bool]$lookup['CreateRestorePoint']
}
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox) {
if ($lookup.ContainsKey('SkipRegistryBackup')) {
$registryBackupCheckBox.IsChecked = -not [bool]$lookup['SkipRegistryBackup']
}
}
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) {
$restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer']
}
}
<#
.SYNOPSIS
Exports selected application, tweak, and deployment settings to a configuration file.
#>
function Export-Configuration {
param (
[System.Windows.Window]$Owner,
@@ -427,7 +460,7 @@ function Export-Configuration {
return
}
if (SaveToFile -Config $config -FilePath $saveDialog.FileName) {
if (Save-ToFile -Config $config -FilePath $saveDialog.FileName) {
Write-Host "Configuration exported successfully: $($saveDialog.FileName)"
Show-MessageBox -Message "Configuration exported successfully." -Title 'Export Configuration' -Button 'OK' -Icon 'Information' | Out-Null
}
@@ -437,6 +470,10 @@ function Export-Configuration {
}
}
<#
.SYNOPSIS
Imports selected application, tweak, and deployment settings from a configuration file.
#>
function Import-Configuration {
param (
[System.Windows.Window]$Owner,
@@ -462,27 +499,22 @@ function Import-Configuration {
Write-Host "Importing configuration from '$($openDialog.FileName)'..."
$config = LoadJsonFile -filePath $openDialog.FileName -expectedVersion '1.0'
$config = Import-JsonFile -filePath $openDialog.FileName -expectedVersion '1.0'
if (-not $config) {
Write-Error "Failed to read configuration file '$($openDialog.FileName)'"
Show-MessageBox -Message "Failed to read configuration file" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
return
}
if (-not $config.Version) {
Write-Error "Invalid configuration file format: '$($openDialog.FileName)'"
Show-MessageBox -Message "Invalid configuration file format." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
$consistencyError = Test-ConfigConsistency -Config $config
if ($consistencyError) {
Write-Error "Invalid configuration file '$($openDialog.FileName)': $consistencyError"
Show-MessageBox -Message "Invalid configuration file: $consistencyError" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
return
}
$availableCategories = Get-AvailableImportExportCategories -Config $config
if ($availableCategories.Count -eq 0) {
Write-Warning "Configuration file '$($openDialog.FileName)' contains no importable data."
Show-MessageBox -Message "The selected file contains no importable data." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
return
}
Write-Host "Available categories in config: $($availableCategories -join ', ')"
$appCount = @($config.Apps | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }).Count
@@ -504,7 +536,7 @@ function Import-Configuration {
)
Write-Host "Importing $($appIds.Count) app selection(s)."
Apply-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
Set-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
if ($OnAppsImported) {
& $OnAppsImported
@@ -513,11 +545,11 @@ function Import-Configuration {
if ($categories -contains 'System Tweaks' -and $config.Tweaks) {
$tweakCount = @($config.Tweaks).Count
Write-Host "Importing $tweakCount tweak(s)."
Apply-ImportedTweakSettings -Owner $Owner -UiControlMappings $UiControlMappings -TweakSettings @($config.Tweaks)
Set-ImportedTweakSettings -Owner $Owner -UiControlMappings $UiControlMappings -TweakSettings @($config.Tweaks)
}
if ($categories -contains 'Deployment Settings' -and $config.Deployment) {
Write-Host 'Importing deployment settings.'
Apply-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment)
Set-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment)
}
Write-Host 'Configuration imported successfully.'
+52 -38
View File
@@ -1,8 +1,12 @@
function Show-MainWindow {
<#
.SYNOPSIS
Creates and displays the main Win11Debloat window.
#>
function Show-MainWindow {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null
$WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# ---- Load XAML ----
$xaml = Get-Content -Path $script:MainWindowSchema -Raw
@@ -14,7 +18,7 @@
$reader.Close()
}
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
$mainBorder = $window.FindName('MainBorder')
$titleBarBackground = $window.FindName('TitleBarBackground')
@@ -223,7 +227,7 @@
if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false }
# ---- Build JSON-defined app presets ----
foreach ($preset in (LoadAppPresetsFromJson)) {
foreach ($preset in (Import-AppPresetsFromJson)) {
$checkbox = New-Object System.Windows.Controls.CheckBox
$checkbox.Content = $preset.Name
$checkbox.IsThreeState = $true
@@ -301,8 +305,8 @@
# ---- Load apps ----
$appLoadStatusCallback = { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo }
$onlyInstalledAppsBox.Add_Checked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
$onlyInstalledAppsBox.Add_Unchecked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
$onlyInstalledAppsBox.Add_Checked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
$onlyInstalledAppsBox.Add_Unchecked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
# ---- App presets popup ----
$presetsPopup.Add_Opened({
@@ -573,6 +577,7 @@
# ---- App removal scope combo ----
$appRemovalScopeCombo.Add_SelectionChanged({
Update-AppRemovalScopeDescription -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeDescription $appRemovalScopeDescription
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
})
# ---- Other username text box ----
@@ -584,12 +589,12 @@
$usernameTextBoxPlaceholder.Visibility = 'Collapsed'
}
Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage | Out-Null
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
})
# ---- Validate target user helper ----
$ensureValidTargetUserOrWarn = {
if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage)) {
if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo)) {
$validationMessage = if (-not [string]::IsNullOrWhiteSpace($usernameValidationMessage.Text)) {
$usernameValidationMessage.Text
}
@@ -617,9 +622,9 @@
$ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false
}
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
if ($defaultsJson) {
ApplySettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
Apply-SettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
}
if ($script:IsLoadingApps) {
@@ -663,25 +668,23 @@
$hasAppSelection = ($selectedApps.Count -gt 0)
if ($selectedApps.Count -gt 0) {
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return }
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return }
AddParameter 'RemoveApps'
AddParameter 'Apps' ($selectedApps -join ',')
$selectedScopeItem = $appRemovalScopeCombo.SelectedItem
if ($selectedScopeItem) {
switch ($selectedScopeItem.Content) {
"All users" { AddParameter 'AppRemovalTarget' 'AllUsers' }
"Current user only" { AddParameter 'AppRemovalTarget' 'CurrentUser' }
"Target user only" { AddParameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) }
}
$scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox
if ([string]::IsNullOrWhiteSpace($scopeTarget)) {
Write-Warning 'App removal was cancelled because the selected removal scope is invalid.'
return
}
Add-Parameter 'RemoveApps'
Add-Parameter 'Apps' ($selectedApps -join ',')
Add-Parameter 'AppRemovalTarget' $scopeTarget
}
# Apply dynamic tweaks
foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) {
if ($tweakAction.Action -eq 'Apply') {
AddParameter $tweakAction.FeatureId
Add-Parameter $tweakAction.FeatureId
$null = $selectedForwardFeatureIds.Add([string]$tweakAction.FeatureId)
continue
}
@@ -695,27 +698,32 @@
$restorePointCheckBox = $window.FindName('RestorePointCheckBox')
if ($restorePointCheckBox -and $restorePointCheckBox.IsChecked) {
AddParameter 'CreateRestorePoint'
Add-Parameter 'CreateRestorePoint'
}
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox -and -not $registryBackupCheckBox.IsChecked) {
Add-Parameter 'SkipRegistryBackup'
}
switch ($userSelectionCombo.SelectedIndex) {
0 { Write-Host "Selected user mode: current user ($(GetUserName))" }
0 { Write-Host "Selected user mode: current user ($(Get-UserName))" }
1 {
Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())"
AddParameter User ($otherUsernameTextBox.Text.Trim())
Add-Parameter User ($otherUsernameTextBox.Text.Trim())
}
2 {
Write-Host "Selected user mode: default user profile (Sysprep)"
AddParameter Sysprep
Add-Parameter Sysprep
}
}
SaveSettings
Save-Settings
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
$shouldRestartExplorer = $restartExplorerCheckBox -and $restartExplorerCheckBox.IsChecked
Show-ApplyModal -Owner $window -RestartExplorer $shouldRestartExplorer
Show-ApplyModal -Owner $window -InvokeRestartExplorer $shouldRestartExplorer
$window.Close()
})
@@ -737,12 +745,12 @@
$window.Add_Loaded({
try {
& $updateHomeContentPosition
Build-DynamicTweaks -Window $window -WinVersion $WinVersion
Load-CurrentTweakStateIntoUI -Window $window
New-DynamicTweakControls -Window $window -WinVersion $WinVersion
Set-CurrentTweakStateInUi -Window $window
Update-TweaksResponsiveColumns -Window $window
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
$lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
$script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson
@@ -750,13 +758,13 @@
Register-TweakPresetControlStateHandlers -Window $window
Update-TweakPresetStates -Window $window
Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn
Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn
# Update Current User label
if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) {
$currentUserItem = $userSelectionCombo.Items[0]
if ($currentUserItem -is [System.Windows.Controls.ComboBoxItem]) {
$currentUserItem.Content = "Current User ($(GetUserName))"
$currentUserItem.Content = "Current User ($(Get-UserName))"
}
}
@@ -773,11 +781,17 @@
}
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
if ($restartExplorerCheckBox -and $script:Params.ContainsKey("NoRestartExplorer")) {
if ($restartExplorerCheckBox -and $script:Params.ContainsKey('SkipExplorerRestart')) {
$restartExplorerCheckBox.IsChecked = $false
$restartExplorerCheckBox.IsEnabled = $false
}
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox -and $script:Params.ContainsKey('SkipRegistryBackup')) {
$registryBackupCheckBox.IsChecked = $false
$registryBackupCheckBox.IsEnabled = $false
}
if ($script:Params.ContainsKey("Sysprep")) {
$userSelectionCombo.SelectedIndex = 2
$userSelectionCombo.IsEnabled = $false
@@ -809,8 +823,8 @@
})
# ---- Tweak presets wiring ----
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
$lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
$script:DefaultTweakPresetMap = @{}
$script:LastUsedTweakPresetMap = @{}
$script:PrivacyTweakPresetMap = @{}
@@ -869,7 +883,7 @@
# ---- Preload app data ----
try {
$script:PreloadedAppData = LoadAppsDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false
$script:PreloadedAppData = Import-AppDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false
}
catch {
Write-Warning "Failed to preload apps list: $_"
+6 -3
View File
@@ -1,4 +1,7 @@
# Shows a Windows 11 styled custom message box
<#
.SYNOPSIS
Shows a themed Windows 11-style message box.
#>
function Show-MessageBox {
param (
[Parameter(Mandatory=$true)]
@@ -24,7 +27,7 @@ function Show-MessageBox {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
# Determine owner window - use provided Owner, or fall back to main GUI window
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
@@ -69,7 +72,7 @@ function Show-MessageBox {
}
# Apply theme resources
SetWindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
# Get UI elements
$titleText = $msgWindow.FindName('TitleText')
+35 -7
View File
@@ -1,3 +1,24 @@
<#
.SYNOPSIS
Displays the Restore Backup wizard dialog.
.DESCRIPTION
Presents a modal wizard that lets the user choose and restore either a
registry backup or a Start Menu pinned-apps backup. Returns the user's
selection via $window.Tag.
.PARAMETER Owner
Optional parent WPF Window used to host this modal dialog. Defaults to the
shared $script:GuiWindow when not supplied.
.OUTPUTS
Hashtable
Returns a Hashtable describing the user's choice. Possible shapes:
RestoreRegistry - @{ Result='RestoreRegistry'; Backup=<normalizedBackup> }
Restore-StartMenu - @{ Result='Restore-StartMenu'; StartMenuScope=<scope>;
UseManualBackupFile=<bool>; BackupFilePath=<path|string> }
Cancelled - @{ Result='Cancelled' } (from New-RestoreDialogState)
#>
function Show-RestoreBackupDialog {
param(
[System.Windows.Window]$Owner = $null
@@ -5,7 +26,7 @@ function Show-RestoreBackupDialog {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$usesDarkMode = Get-SystemUsesDarkMode
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
$overlay = $null
@@ -46,7 +67,7 @@ function Show-RestoreBackupDialog {
}
try {
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
}
catch { }
@@ -108,7 +129,7 @@ function Show-RestoreBackupDialog {
param([string]$BackupFilePath)
$scopeInfo = & $getStartMenuScopeInfo
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target $scopeInfo.Target
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target $scopeInfo.Target
$overviewSummaryText.Text = "This will replace the current Start Menu pinned apps layout for $($scopeInfo.SummaryText) with the selected backup."
$backupFileText.Text = Split-Path -Path $BackupFilePath -Leaf
@@ -255,7 +276,7 @@ function Show-RestoreBackupDialog {
$backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf
$backupCreatedText.Text = $createdText
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
@@ -295,9 +316,16 @@ function Show-RestoreBackupDialog {
}
Write-Host "Backup file selected: $($openDialog.FileName)"
$selectedBackup = Load-RegistryBackupFromFile -FilePath $openDialog.FileName
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
try {
$selectedBackup = Import-RegistryBackup -FilePath $openDialog.FileName
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
return
}
}
catch {
Show-MessageBox -Owner $window -Title 'Invalid Backup File' -Message "The selected file could not be loaded:`n$($_.Exception.Message)" -Button 'OK' -Icon 'Error' | Out-Null
return
}
@@ -338,7 +366,7 @@ function Show-RestoreBackupDialog {
}
$window.Tag = @{
Result = 'RestoreStartMenu'
Result = 'Restore-StartMenu'
StartMenuScope = $scope
UseManualBackupFile = $useManualBackupFile
BackupFilePath = $state.SelectedStartMenuBackupFilePath
+7 -3
View File
@@ -1,3 +1,7 @@
<#
.SYNOPSIS
Shows the backup-restore dialog and performs the selected restore.
#>
function Show-RestoreBackupWindow {
param(
[System.Windows.Window]$Owner = $null
@@ -38,7 +42,7 @@ function Show-RestoreBackupWindow {
}
}
}
elseif ($dialogResult.Result -eq 'RestoreStartMenu') {
elseif ($dialogResult.Result -eq 'Restore-StartMenu') {
$scope = $dialogResult.StartMenuScope
$useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true)
$backupFilePath = $null
@@ -54,10 +58,10 @@ function Show-RestoreBackupWindow {
}
$result = if ($scope -eq 'AllUsers') {
RestoreStartMenuForAllUsers -BackupFilePath $backupFilePath
Restore-StartMenuForAllUsers -BackupFilePath $backupFilePath
}
else {
RestoreStartMenu -BackupFilePath $backupFilePath
Restore-StartMenu -BackupFilePath $backupFilePath
}
$resultEntries = @($result)
-231
View File
@@ -1,231 +0,0 @@
param (
[switch]$CLI,
[switch]$Silent,
[switch]$Verbose,
[switch]$Sysprep,
[string]$LogPath,
[string]$User,
[switch]$NoRestartExplorer,
[switch]$CreateRestorePoint,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
[string]$Config,
[string]$Apps,
[string]$AppRemovalTarget,
[switch]$RemoveApps,
[switch]$RemoveGamingApps,
[switch]$RemoveHPApps,
[switch]$ForceRemoveEdge,
[switch]$DisableDVR,
[switch]$DisableGameBarIntegration,
[switch]$EnableWindowsSandbox,
[switch]$EnableWindowsSubsystemForLinux,
[switch]$DisableTelemetry,
[switch]$DisableSearchHistory,
[switch]$DisableFastStartup,
[switch]$DisableBitlockerAutoEncryption,
[switch]$DisableModernStandbyNetworking,
[switch]$DisableStorageSense,
[switch]$DisableUpdateASAP,
[switch]$PreventUpdateAutoReboot,
[switch]$DisableDeliveryOptimization,
[switch]$DisableBing,
[switch]$DisableStoreSearchSuggestions,
[switch]$DisableDesktopSpotlight,
[switch]$DisableLockscreenTips,
[switch]$DisableSuggestions,
[switch]$DisableLocationServices,
[switch]$DisableFindMyDevice,
[switch]$DisableEdgeAds,
[switch]$DisableBraveBloat,
[switch]$DisableSettings365Ads,
[switch]$DisableSettingsHome,
[switch]$ShowHiddenFolders,
[switch]$ShowKnownFileExt,
[switch]$HideDupliDrive,
[switch]$EnableDarkMode,
[switch]$DisableTransparency,
[switch]$DisableAnimations,
[switch]$TaskbarAlignLeft,
[switch]$CombineTaskbarAlways, [switch]$CombineTaskbarWhenFull, [switch]$CombineTaskbarNever,
[switch]$CombineMMTaskbarAlways, [switch]$CombineMMTaskbarWhenFull, [switch]$CombineMMTaskbarNever,
[switch]$MMTaskbarModeAll, [switch]$MMTaskbarModeMainActive, [switch]$MMTaskbarModeActive,
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
[switch]$HideTaskview,
[switch]$DisableStartRecommended,
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
[switch]$DisableStartPhoneLink,
[switch]$DisableCopilot,
[switch]$DisableRecall,
[switch]$DisableClickToDo,
[switch]$DisableAISvcAutoStart,
[switch]$DisablePaintAI,
[switch]$DisableNotepadAI,
[switch]$DisableEdgeAI,
[switch]$DisableSearchHighlights,
[switch]$DisableWidgets,
[switch]$HideChat,
[switch]$EnableEndTask,
[switch]$EnableLastActiveClick,
[switch]$ClearStart,
[string]$ReplaceStart,
[switch]$ClearStartAllUsers,
[string]$ReplaceStartAllUsers,
[switch]$RevertContextMenu,
[switch]$DisableDragTray,
[switch]$DisableMouseAcceleration,
[switch]$DisableStickyKeys,
[switch]$DisableWindowSnapping,
[switch]$DisableSnapAssist,
[switch]$DisableSnapLayouts,
[switch]$HideTabsInAltTab, [switch]$Show3TabsInAltTab, [switch]$Show5TabsInAltTab, [switch]$Show20TabsInAltTab,
[switch]$HideHome,
[switch]$HideGallery,
[switch]$ExplorerToHome,
[switch]$ExplorerToThisPC,
[switch]$ExplorerToDownloads,
[switch]$ExplorerToOneDrive,
[switch]$AddFoldersToThisPC,
[switch]$HideOnedrive,
[switch]$Hide3dObjects,
[switch]$HideMusic,
[switch]$HideIncludeInLibrary,
[switch]$HideGiveAccessTo,
[switch]$HideShare,
[switch]$ShowDriveLettersFirst,
[switch]$ShowDriveLettersLast,
[switch]$ShowNetworkDriveLettersFirst,
[switch]$HideDriveLetters
)
# Show error if current powershell environment does not have LanguageMode set to 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-Output ""
Write-Output "Press enter to exit..."
Read-Host | Out-Null
Exit
}
Clear-Host
Write-Output "-------------------------------------------------------------------------------------------"
Write-Output " Win11Debloat Script - Get Dev"
Write-Output "-------------------------------------------------------------------------------------------"
$tempRootPath = $env:TEMP
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
Write-Output "> Downloading Win11Debloat for development..."
# Download latest version of Win11Debloat from GitHub master branch as zip archive
try {
Invoke-RestMethod "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip" -OutFile $tempArchivePath
}
catch {
Write-Host "Error: Unable to fetch master branch from GitHub. Please check your internet connection and try again." -ForegroundColor Red
Write-Output ""
Write-Output "Press enter to exit..."
Read-Host | Out-Null
Exit
}
# Remove old script folder if it exists, but keep configs, logs and backups
if (Test-Path $tempWorkPath) {
Write-Output ""
Write-Output "> Cleaning up old Win11Debloat folder..."
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
$configDir = Join-Path $tempWorkPath 'Config'
$backupDir = Join-Path $tempWorkPath 'ConfigOld'
# Temporarily move existing config files if they exist to prevent them from being overwritten by the new script files, will be moved back after the new script is unpacked
if (Test-Path "$configDir") {
Write-Output ""
Write-Output "> Backing up existing config files..."
New-Item -ItemType Directory -Path "$backupDir" -Force | Out-Null
$filesToKeep = @(
'LastUsedSettings.json'
)
Get-ChildItem -Path "$configDir" -Recurse | Where-Object { $_.Name -in $filesToKeep } | Move-Item -Destination "$backupDir"
Remove-Item "$configDir" -Recurse -Force
}
Write-Output ""
Write-Output "> Unpacking..."
# Unzip archive to Win11Debloat folder
Expand-Archive $tempArchivePath $tempWorkPath
# Remove archive
Remove-Item $tempArchivePath
# Move files
Get-ChildItem -Path (Join-Path $tempWorkPath '*Win11Debloat-*') -Recurse | Move-Item -Destination $tempWorkPath
# Add existing config files back to Config folder
if (Test-Path "$backupDir") {
if (-not (Test-Path "$configDir")) {
New-Item -ItemType Directory -Path "$configDir" -Force | Out-Null
}
Write-Output ""
Write-Output "> Restoring existing config files..."
Get-ChildItem -Path "$backupDir" -Recurse | Move-Item -Destination "$configDir"
Remove-Item "$backupDir" -Recurse -Force
}
# Make list of arguments to pass on to the script
$arguments = $($PSBoundParameters.GetEnumerator() | ForEach-Object {
if ($_.Value -eq $true) {
"-$($_.Key)"
}
else {
"-$($_.Key) ""$($_.Value)"""
}
})
Write-Output ""
Write-Output "> Launching Win11Debloat..."
# Minimize the powershell window when no parameters are provided
if ($arguments.Count -eq 0) {
$windowStyle = "Minimized"
}
else {
$windowStyle = "Normal"
}
# Remove Powershell 7 modules from path to prevent module loading issues in the script
if ($PSVersionTable.PSVersion.Major -ge 7) {
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
$env:PSModulePath = $NewPSModulePath -join ';'
}
# Run Win11Debloat script with the provided arguments
$debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1'
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs
# Wait for the process to finish before continuing
if ($null -ne $debloatProcess) {
$debloatProcess.WaitForExit()
}
# Remove all remaining script files, except for configs, logs and backups
if (Test-Path $tempWorkPath) {
Write-Output ""
Write-Output "> Cleaning up..."
# Cleanup, remove Win11Debloat directory
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
Write-Output ""
+41 -22
View File
@@ -1,12 +1,16 @@
param (
[switch]$Verbose,
[switch]$WhatIf,
[switch]$Dev,
[switch]$CLI,
[switch]$Silent,
[switch]$Verbose,
[switch]$Sysprep,
[string]$LogPath,
[string]$User,
[switch]$NoRestartExplorer,
[Alias('NoRestartExplorer')]
[switch]$SkipExplorerRestart,
[switch]$CreateRestorePoint,
[switch]$SkipRegistryBackup,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -26,10 +30,12 @@ param (
[switch]$DisableFastStartup,
[switch]$DisableBitlockerAutoEncryption,
[switch]$DisableModernStandbyNetworking,
[switch]$DisableNotifications,
[switch]$DisableStorageSense,
[switch]$DisableUpdateASAP,
[switch]$PreventUpdateAutoReboot,
[switch]$DisableDeliveryOptimization,
[switch]$DisableDeviceAutoAppDownload,
[switch]$DisableBing,
[switch]$DisableStoreSearchSuggestions,
[switch]$DisableDesktopSpotlight,
@@ -99,43 +105,46 @@ param (
[switch]$HideDriveLetters
)
# Show error if current powershell environment does not have LanguageMode set to FullLanguage
# Check if current PowerShell environment is limited by security policies
if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") {
Write-Host "Error: Win11Debloat is unable to run on your system. PowerShell execution is restricted by security policies" -ForegroundColor Red
Write-Output ""
Write-Output "Press enter to exit..."
Read-Host | Out-Null
Exit
Write-Error "Win11Debloat is unable to run on your system, PowerShell execution is restricted by security policies"
Write-Output "Press any key to exit..."
$null = [System.Console]::ReadKey()
Exit 1
}
Clear-Host
Write-Output "-------------------------------------------------------------------------------------------"
Write-Output " Win11Debloat Script - Get"
Write-Output " Win11Debloat Script"
Write-Output "-------------------------------------------------------------------------------------------"
$tempRootPath = $env:TEMP
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
Write-Output "> Downloading Win11Debloat..."
# Download latest version of Win11Debloat from GitHub as zip archive
# Download Win11Debloat from GitHub as a zip archive.
try {
$LatestReleaseUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
Invoke-RestMethod $LatestReleaseUri -OutFile $tempArchivePath
if ($Dev) {
Write-Output "> Downloading development version of Win11Debloat..."
$sourceUri = "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip"
} else {
Write-Output "> Downloading Win11Debloat..."
$sourceUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
}
Invoke-RestMethod $sourceUri -OutFile $tempArchivePath
}
catch {
Write-Host "Error: Unable to fetch latest release from GitHub. Please check your internet connection and try again." -ForegroundColor Red
Write-Host "Error: Unable to fetch required files from GitHub. Please check your internet connection and try again." -ForegroundColor Red
Write-Output ""
Write-Output "Press enter to exit..."
Read-Host | Out-Null
Exit
Exit 1
}
# Remove old script folder if it exists, but keep configs, logs and backups
if (Test-Path $tempWorkPath) {
Write-Output ""
Write-Output "> Cleaning up old Win11Debloat folder..."
Write-Output "> Cleaning up old script files..."
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
@@ -184,8 +193,8 @@ if (Test-Path "$backupDir") {
Remove-Item "$backupDir" -Recurse -Force
}
# Make list of arguments to pass on to the script
$arguments = $($PSBoundParameters.GetEnumerator() | ForEach-Object {
# Make list of arguments to pass on to the script (exclude the -Dev switch, which only affects this launcher)
$arguments = $($PSBoundParameters.GetEnumerator() | Where-Object { $_.Key -ne 'Dev' } | ForEach-Object {
if ($_.Value -eq $true) {
"-$($_.Key)"
}
@@ -197,7 +206,7 @@ $arguments = $($PSBoundParameters.GetEnumerator() | ForEach-Object {
Write-Output ""
Write-Output "> Launching Win11Debloat..."
# Minimize the powershell window when no parameters are provided
# Minimize the PowerShell window when no parameters are provided
if ($arguments.Count -eq 0) {
$windowStyle = "Minimized"
}
@@ -205,7 +214,7 @@ else {
$windowStyle = "Normal"
}
# Remove Powershell 7 modules from path to prevent module loading issues in the script
# Remove PowerShell 7 modules from path to prevent module loading issues in the script
if ($PSVersionTable.PSVersion.Major -ge 7) {
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
$env:PSModulePath = $NewPSModulePath -join ';'
@@ -213,11 +222,20 @@ if ($PSVersionTable.PSVersion.Major -ge 7) {
# Run Win11Debloat script with the provided arguments
$debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1'
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs
$exitCode = 0
$debloatProcess = $null
try {
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs -ErrorAction Stop
}
catch {
$exitCode = 1
Write-Error "Failed to start Win11Debloat: $_"
}
# Wait for the process to finish before continuing
if ($null -ne $debloatProcess) {
$debloatProcess.WaitForExit()
$exitCode = $debloatProcess.ExitCode
}
# Remove all remaining script files, except for configs, logs and backups
@@ -230,3 +248,4 @@ if (Test-Path $tempWorkPath) {
}
Write-Output ""
Exit $exitCode
@@ -1,5 +1,8 @@
# Add parameter to script and write to file
function AddParameter {
<#
.SYNOPSIS
Adds or updates a value in the active parameter collection.
#>
function Add-Parameter {
param (
$parameterName,
$value = $true
@@ -11,53 +11,51 @@ function Get-NormalizedRegistryValueName {
return [string]$ValueName
}
<#
.SYNOPSIS
Converts a parsed .reg operation into a Name/Kind/Value set for RegistryKey.SetValue.
#>
function Convert-RegOperationToValueKind {
param(
[Parameter(Mandatory)]
$Operation
)
$valueName = if ([string]::IsNullOrEmpty([string]$Operation.ValueName)) { '' } else { [string]$Operation.ValueName }
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
$valueType = [string]$Operation.ValueType
$operationKeyPath = [string]$Operation.KeyPath
# ValueType here is whatever Get-RegFileOperations parsed it as.
# Hex2/Hex7 are its names for REG_EXPAND_SZ/REG_MULTI_SZ, already decoded to string/string[].
switch ($valueType) {
'DWord' {
$unsigned = [uint32]$Operation.ValueData
$value = [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Value = $value }
}
'QWord' {
$unsigned = [uint64]$Operation.ValueData
$value = [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Value = $value }
}
'String' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::String; Value = [string]$Operation.ValueData }
}
'Hex2' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Value = [string]$Operation.ValueData }
}
'Binary' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Value = [byte[]]$Operation.ValueData }
}
'Hex7' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Value = [string[]]@($Operation.ValueData) }
}
default {
throw "Unsupported value type '$valueType' while applying reg operation for '$operationKeyPath'"
}
}
}
function Remove-RegistrySubKeyTreeIfExists {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RootKey,
[Parameter(Mandatory)]
[string]$SubKeyPath
)
try {
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
}
catch [System.UnauthorizedAccessException], [System.Security.SecurityException] {
throw
}
catch {
# Best-effort cleanup only; missing keys are fine.
}
}
function Get-RegistryKeyForOperation {
param(
[Parameter(Mandatory)]
@@ -193,6 +191,13 @@ function Invoke-RegistryOperation {
}
}
<#
.SYNOPSIS
Applies all parsed operations from a registry file.
.OUTPUTS
System.Boolean. $true when all operations complete, including WhatIf; otherwise $false.
#>
function Invoke-RegistryOperationsFromRegFile {
param(
[Parameter(Mandatory)]
@@ -205,7 +210,7 @@ function Invoke-RegistryOperationsFromRegFile {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan
return
return $true
}
foreach ($operation in $operations) {
@@ -224,5 +229,8 @@ function Invoke-RegistryOperationsFromRegFile {
if ($accessDeniedCount -gt 0) {
Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'."
return $false
}
return $true
}
@@ -1,7 +1,8 @@
# Shows confirmation dialogs for apps that require extra caution before removal.
# Returns $true if the user confirmed all warnings (or if no warnings were triggered),
# $false if the user declined any warning.
function ConfirmUnsafeAppRemoval {
<#
.SYNOPSIS
Confirms removal of applications that require an extra safety warning.
#>
function Confirm-UnsafeAppRemoval {
param (
[string[]]$SelectedApps,
$Owner = $null
@@ -1,5 +1,8 @@
# Generates a list of apps to remove based on the Apps parameter
function GenerateAppsList {
<#
.SYNOPSIS
Builds the validated application-removal list from the Apps parameter.
#>
function Generate-AppsList {
if (-not ($script:Params["Apps"] -and $script:Params["Apps"] -is [string])) {
return @()
}
@@ -8,12 +11,12 @@ function GenerateAppsList {
switch ($appMode) {
'default' {
$appsList = LoadAppsFromFile $script:AppsListFilePath
$appsList = Import-AppsFromFile $script:AppsListFilePath
return $appsList
}
default {
$appsList = $script:Params["Apps"].Split(',') | ForEach-Object { $_.Trim() }
$validatedAppsList = ValidateAppslist $appsList
$validatedAppsList = Get-ValidatedAppList $appsList
return $validatedAppsList
}
}
@@ -1,4 +1,8 @@
function GetFriendlyRegistryBackupTarget {
<#
.SYNOPSIS
Converts a registry-backup target identifier into a user-friendly label.
#>
function Get-FriendlyRegistryBackupTarget {
param(
[AllowNull()]
[AllowEmptyString()]
@@ -40,4 +44,4 @@ function GetFriendlyRegistryBackupTarget {
}
return $Target
}
}
@@ -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" }
}
}
@@ -0,0 +1,27 @@
<#
.SYNOPSIS
Resolves display labels for selected features that require reboot.
.DESCRIPTION
Combines parameter keys from both forward and undo selections, removes duplicates,
and returns the feature label that should be shown to users. Undo selections use
UndoLabel when available.
#>
function Get-RebootFeatureLabels {
$rebootFeatureLabels = [System.Collections.Generic.List[string]]::new()
$candidateParamKeys = (@($script:Params.Keys) + @($script:UndoParams.Keys)) | Select-Object -Unique
foreach ($paramKey in $candidateParamKeys) {
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
$feature = $script:Features[$paramKey]
$isUndo = $script:UndoParams.ContainsKey($paramKey)
$displayLabel = if ($isUndo -and $feature.UndoLabel) { $feature.UndoLabel } else { $feature.Label }
if (-not [string]::IsNullOrWhiteSpace([string]$displayLabel)) {
[void]$rebootFeatureLabels.Add([string]$displayLabel)
}
}
}
return $rebootFeatureLabels
}
+31 -6
View File
@@ -74,7 +74,10 @@ function Get-RegFileOperations {
}
$parsedValue = Convert-RegValueData -valueData $matches.valueData.Trim()
if (-not $parsedValue) { continue }
if (-not $parsedValue) {
Write-Warning "Skipping unsupported or malformed registry value '$valueName' in '$currentKeyPath'."
continue
}
$operations += [PSCustomObject]@{
OperationType = $parsedValue.OperationType
@@ -88,6 +91,10 @@ function Get-RegFileOperations {
return $operations
}
<#
.SYNOPSIS
Converts a .reg value literal into an operation type, registry value type, and data.
#>
function Convert-RegValueData {
param(
[Parameter(Mandatory)]
@@ -121,8 +128,13 @@ function Convert-RegValueData {
}
if ($valueData -match '^hex(?:\((?<kind>[0-9a-fA-F]+)\))?:(?<bytes>[0-9a-fA-F,\s]+)$') {
$bytes = Convert-HexStringToByteArray -hexValue $matches.bytes
$parsedBytes = Convert-HexStringToByteArray -hexValue $matches.bytes
if ($null -eq $parsedBytes) {
return $null
}
$bytes = [byte[]]@($parsedBytes)
$valueType = if ($matches.kind) { "Hex$($matches.kind)" } else { 'Binary' }
$value = switch ($matches.kind) {
'2' { Convert-RegistryByteArrayToString -byteData $bytes }
'7' { Convert-RegistryByteArrayToMultiString -byteData $bytes }
@@ -150,16 +162,29 @@ function Convert-RegValueData {
return $null
}
<#
.SYNOPSIS
Converts a comma-separated hexadecimal byte string into a byte array.
#>
function Convert-HexStringToByteArray {
param(
[Parameter(Mandatory)]
[string]$hexValue
)
$parts = $hexValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
return [System.Linq.Enumerable]::Select($parts, [Func[object, byte]] {
param($h) [System.Convert]::ToByte($h, 16)
}) -as [byte[]]
$parts = @($hexValue.Split(',') | ForEach-Object { $_.Trim() })
if ($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }) {
return $null
}
$bytes = New-Object byte[] $parts.Count
for ($i = 0; $i -lt $parts.Count; $i++) {
if ($parts[$i] -notmatch '^[0-9a-fA-F]{1,2}$') {
return $null
}
$bytes[$i] = [System.Convert]::ToByte($parts[$i], 16)
}
return ,$bytes
}
function Convert-RegistryByteArrayToString {
@@ -1,6 +1,6 @@
# Target is determined from $script:Params["AppRemovalTarget"] or defaults to "AllUsers"
# Target values: "AllUsers" (removes for all users + from image), "CurrentUser", or a specific username
function GetTargetUserForAppRemoval {
function Get-TargetUserForAppRemoval {
if ($script:Params.ContainsKey("AppRemovalTarget")) {
return $script:Params["AppRemovalTarget"]
}
@@ -1,5 +1,5 @@
# Returns the directory path of the specified user, exits script if user path can't be found
function GetUserDirectory {
function Get-UserDirectory {
param (
$userName,
$fileName = "",
@@ -29,7 +29,7 @@ function GetUserDirectory {
}
}
$userContext = ResolveUserProfileContext -UserName $userName
$userContext = Resolve-UserProfileContext -UserName $userName
$resolvedUserDirectory = if ($userContext) { $userContext.ProfilePath } else { $null }
if ($resolvedUserDirectory) {
$userPath = if ([string]::IsNullOrWhiteSpace($fileName)) {
@@ -46,9 +46,9 @@ function GetUserDirectory {
}
catch {
Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system"
AwaitKeyToExit
Wait-ForKeyPress -ExitCode 1
}
Write-Error "Unable to find user directory path for user $userName"
AwaitKeyToExit
Wait-ForKeyPress -ExitCode 1
}
+11
View File
@@ -0,0 +1,11 @@
<#
.SYNOPSIS
Returns the explicitly targeted user name or the current process user name.
#>
function Get-UserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}
@@ -1,9 +0,0 @@
function GetFriendlyTargetUserName {
$target = GetTargetUserForAppRemoval
switch ($target) {
"AllUsers" { return "all users" }
"CurrentUser" { return "the current user" }
default { return "user $target" }
}
}
-7
View File
@@ -1,7 +0,0 @@
function GetUserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}
@@ -1,4 +1,8 @@
function ImportConfigToParams {
<#
.SYNOPSIS
Imports valid application, tweak, and deployment selections from a configuration JSON file into active parameters.
#>
function Import-ConfigToParams {
param (
[Parameter(Mandatory = $true)]
[string]$ConfigPath,
@@ -22,11 +26,16 @@ function ImportConfigToParams {
throw "Provided config file must be a .json file: $resolvedConfigPath"
}
$configJson = LoadJsonFile -filePath $resolvedConfigPath -expectedVersion $ExpectedVersion
$configJson = Import-JsonFile -filePath $resolvedConfigPath -expectedVersion $ExpectedVersion
if ($null -eq $configJson) {
throw "Failed to read config file: $resolvedConfigPath"
}
$consistencyError = Test-ConfigConsistency -Config $configJson
if ($consistencyError) {
throw "Invalid config file '$resolvedConfigPath': $consistencyError"
}
$importedItems = 0
if ($configJson.Apps) {
@@ -38,8 +47,8 @@ function ImportConfigToParams {
)
if ($appIds.Count -gt 0) {
AddParameter 'RemoveApps'
AddParameter 'Apps' ($appIds -join ',')
Add-Parameter 'RemoveApps'
Add-Parameter 'Apps' ($appIds -join ',')
$importedItems++
}
}
@@ -59,7 +68,7 @@ function ImportConfigToParams {
continue
}
AddParameter $setting.Name $true
Add-Parameter $setting.Name $true
$importedItems++
}
}
@@ -73,12 +82,17 @@ function ImportConfigToParams {
}
if ($deploymentLookup.ContainsKey('CreateRestorePoint') -and [bool]$deploymentLookup['CreateRestorePoint']) {
AddParameter 'CreateRestorePoint'
Add-Parameter 'CreateRestorePoint'
$importedItems++
}
if ($deploymentLookup.ContainsKey('SkipRegistryBackup') -and [bool]$deploymentLookup['SkipRegistryBackup']) {
Add-Parameter 'SkipRegistryBackup'
$importedItems++
}
if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) {
AddParameter 'NoRestartExplorer'
Add-Parameter 'SkipExplorerRestart'
$importedItems++
}
@@ -87,12 +101,12 @@ function ImportConfigToParams {
1 {
$otherUserName = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
if (-not [string]::IsNullOrWhiteSpace($otherUserName)) {
AddParameter 'User' $otherUserName
Add-Parameter 'User' $otherUserName
$importedItems++
}
}
2 {
AddParameter 'Sysprep'
Add-Parameter 'Sysprep'
$importedItems++
}
}
@@ -101,17 +115,17 @@ function ImportConfigToParams {
if ($deploymentLookup.ContainsKey('AppRemovalScopeIndex') -and $script:Params.ContainsKey('RemoveApps')) {
switch ([int]$deploymentLookup['AppRemovalScopeIndex']) {
0 {
AddParameter 'AppRemovalTarget' 'AllUsers'
Add-Parameter 'AppRemovalTarget' 'AllUsers'
$importedItems++
}
1 {
AddParameter 'AppRemovalTarget' 'CurrentUser'
Add-Parameter 'AppRemovalTarget' 'CurrentUser'
$importedItems++
}
2 {
$targetUser = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
if (-not [string]::IsNullOrWhiteSpace($targetUser)) {
AddParameter 'AppRemovalTarget' $targetUser
Add-Parameter 'AppRemovalTarget' $targetUser
$importedItems++
}
}
@@ -1,3 +1,7 @@
<#
.SYNOPSIS
Normalizes a rooted registry path and returns its hive and subkey components.
#>
function Split-RegistryPath {
param(
[Parameter(Mandatory)]
@@ -51,6 +55,10 @@ function Split-RegistryPath {
}
}
<#
.SYNOPSIS
Returns the .NET registry root key for a supported registry hive name.
#>
function Get-RegistryRootKey {
param(
[Parameter(Mandatory)]
@@ -67,6 +75,39 @@ function Get-RegistryRootKey {
}
}
<#
.SYNOPSIS
Deletes a registry subkey tree and ignores a key that has already disappeared.
#>
function Remove-RegistrySubKeyTreeIfExists {
param(
[Parameter(Mandatory)]
$RootKey,
[Parameter(Mandatory)]
[string]$SubKeyPath
)
try {
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
}
catch {
$failure = $_.Exception
while ($failure.InnerException) {
$failure = $failure.InnerException
}
if ($failure -is [System.ArgumentException]) {
# The key can disappear between snapshot inspection and deletion.
return
}
throw
}
}
<#
.SYNOPSIS
Returns a feature's registry-file path, using the Sysprep layout when targeting another profile.
#>
function Get-RegistryFilePathForFeature {
param(
[Parameter(Mandatory)]
+971
View File
@@ -0,0 +1,971 @@
<#
.SYNOPSIS
Normalize a user-name string for lookup and comparison.
.DESCRIPTION
Strips zero-width chars and collapses whitespace so cosmetic input
differences don't break downstream lookups. Returns '' for blank input.
.PARAMETER Value
Raw user-supplied name to normalize.
.OUTPUTS
System.String
#>
function Normalize-UserLookupValue {
param(
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ''
}
$normalized = $Value -replace '[\u200B-\u200D\uFEFF]', ''
$normalized = $normalized.Trim() -replace '\s+', ' '
return $normalized
}
if (-not $script:ResolvedUserSidCache) {
$script:ResolvedUserSidCache = @{}
}
<#
.SYNOPSIS
Build a form-agnostic cache key for a user name.
.DESCRIPTION
Normalized + lower-cased so the same identity hits regardless of
case, whitespace, or qualifier form. Returns '' for blank input.
.PARAMETER Value
User name to derive a key from.
.OUTPUTS
System.String
#>
function Get-UserLookupCacheKey {
param(
[string]$Value
)
$normalizedValue = Normalize-UserLookupValue -Value $Value
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
return ''
}
return $normalizedValue.ToLowerInvariant()
}
<#
.SYNOPSIS
Normalize and de-duplicate a set of user-name candidates.
.DESCRIPTION
Centralizes the normalize/filter/dedupe step shared by the SID
resolution fallbacks so the dedupe semantic lives in one place.
Returns @() for empty or all-blank input.
.PARAMETER Candidates
Equivalent name forms to normalize.
.OUTPUTS
System.String[]
#>
function Get-NormalizedLookupCandidates {
param(
[string[]]$Candidates
)
$normalized = @($Candidates) |
ForEach-Object { Normalize-UserLookupValue -Value $_ } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique
# The unary comma prevents PowerShell from unwrapping a single-element array.
return ,@($normalized)
}
<#
.SYNOPSIS
Escape a string for safe embedding in a WQL single-quoted literal.
.DESCRIPTION
Doubles embedded single quotes; without this a user name containing
an apostrophe could break the WQL filter.
.PARAMETER Value
String to escape.
.OUTPUTS
System.String
#>
function Escape-WqlString {
param(
[string]$Value
)
if ($null -eq $Value) {
return ''
}
return $Value -replace "'", "''"
}
<#
.SYNOPSIS
Extract the local name segment from a possibly domain-qualified identity.
.DESCRIPTION
Reduces DOMAIN\user or user@domain to the bare leaf, since profile
folder leafs never carry the domain prefix. Returns '' for blank input.
.PARAMETER UserName
User name that may be domain-qualified.
.OUTPUTS
System.String
#>
function Get-LocalUserNameSegment {
param(
[string]$UserName
)
$normalizedName = Normalize-UserLookupValue -Value $UserName
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
return ''
}
if ($normalizedName.Contains('\')) {
return Normalize-UserLookupValue -Value (($normalizedName -split '\\')[-1])
}
if ($normalizedName.Contains('@')) {
return Normalize-UserLookupValue -Value (($normalizedName -split '@')[0])
}
return $normalizedName
}
<#
.SYNOPSIS
Reduce a Win32_ComputerSystem.Domain value to a NetBIOS label.
.DESCRIPTION
Win32_ComputerSystem.Domain may be DNS-style (e.g. contoso.com);
prefer Win32_NTDomain.DomainName and fall back to the first DNS label
so cached suffixes stay single-label (user.CONTOSO, not
user.contoso.com). Returns the trimmed value as-is when already a
single label. Falls back safely (no match) for FQDNs like
corp.contoso.com whose NetBIOS label is CONTOSO.
.PARAMETER RawDomain
Value reported by Win32_ComputerSystem.Domain.
.OUTPUTS
System.String
#>
function Resolve-NetBiosDomainName {
param(
[string]$RawDomain
)
$trimmed = Normalize-UserLookupValue -Value $RawDomain
if ([string]::IsNullOrWhiteSpace($trimmed)) {
return ''
}
try {
# Prefer the joined-domain instance over the local SAM shadow
# (DomainName == COMPUTERNAME); DomainControllerName may be $null.
$computerName = $env:COMPUTERNAME
$ntDomainInstances = @(Get-CimInstance -ClassName Win32_NTDomain -OperationTimeoutSec 5 -ErrorAction Stop |
Where-Object {
-not [string]::IsNullOrWhiteSpace($_.DomainName) -and
$_.DomainName -ine 'WORKGROUP' -and
$_.DomainName -ine $computerName
})
$ntDomainInstance = $ntDomainInstances |
Where-Object { -not [string]::IsNullOrWhiteSpace($_.DomainControllerName) } |
Select-Object -First 1
if (-not $ntDomainInstance -and $ntDomainInstances.Count -gt 0) {
$ntDomainInstance = $ntDomainInstances | Select-Object -First 1
}
if ($ntDomainInstance -and -not [string]::IsNullOrWhiteSpace($ntDomainInstance.DomainName)) {
$fromNtDomain = Normalize-UserLookupValue -Value $ntDomainInstance.DomainName
if (-not [string]::IsNullOrWhiteSpace($fromNtDomain)) {
return $fromNtDomain
}
}
}
catch {
# Fall through to DNS-label derivation.
}
if ($trimmed.Contains('.')) {
$leaf = Normalize-UserLookupValue -Value (($trimmed -split '\.')[0])
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
return $leaf
}
}
return $trimmed
}
<#
.SYNOPSIS
Determine whether the local machine is joined to a domain.
.DESCRIPTION
Cached in script scope for the process lifetime. Returns $false on
error or workgroup. When joined, also caches the NetBIOS domain label
(Resolve-NetBiosDomainName) for use as a profile-folder suffix.
.OUTPUTS
System.Boolean
#>
function Test-MachineIsDomainJoined {
if ($null -ne $script:MachineDomainJoinStateKnown) {
return [bool]$script:MachineIsDomainJoined
}
$script:MachineDomainJoinStateKnown = $true
$script:MachineIsDomainJoined = $false
$script:MachineNetBiosDomain = ''
try {
$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) {
$script:MachineIsDomainJoined = $true
$script:MachineNetBiosDomain = Resolve-NetBiosDomainName -RawDomain ([string]$computerSystem.Domain)
}
}
catch {
# Leave as $false; callers fall back to legacy matching.
}
return [bool]$script:MachineIsDomainJoined
}
<#
.SYNOPSIS
Return the NetBIOS domain suffix Windows appends to profile folders.
.DESCRIPTION
On domain-joined machines Windows writes profile folders as
user.CONTOSO; knowing the suffix lets a bare name match that folder.
Excluded on workgroup (USERDOMAIN == COMPUTERNAME) to avoid false
matches. Returns '' when not applicable.
.OUTPUTS
System.String
#>
function Get-ProfileFolderDomainSuffix {
if (-not (Test-MachineIsDomainJoined)) {
return ''
}
$domain = $env:USERDOMAIN
if ([string]::IsNullOrWhiteSpace($domain)) {
# USERDOMAIN can be empty in restricted contexts; fall back to the
# cached NetBIOS label (single-label, safe as a folder suffix).
$domain = $script:MachineNetBiosDomain
}
if ([string]::IsNullOrWhiteSpace($domain)) {
return ''
}
# USERDOMAIN == COMPUTERNAME means effectively standalone; a suffix here
# would produce false matches instead of disambiguation.
if ($domain -ieq $env:COMPUTERNAME) {
return ''
}
return $domain.Trim()
}
<#
.SYNOPSIS
Enumerate the name forms equivalent to a given identity.
.DESCRIPTION
One identity surfaces in different forms (bare, qualified,
domain-suffixed); enumerating equivalents lets cross-source matching
succeed without broadening workgroup validation. Returns @() for blank.
.PARAMETER Value
User name to expand into equivalent forms.
.OUTPUTS
System.String[]
#>
function Get-UserNameMatchCandidates {
param(
[string]$Value
)
$normalized = Normalize-UserLookupValue -Value $Value
if ([string]::IsNullOrWhiteSpace($normalized)) {
return @()
}
$candidates = New-Object 'System.Collections.Generic.List[string]'
[void]$candidates.Add($normalized)
$localSegment = Get-LocalUserNameSegment -UserName $normalized
if (-not [string]::IsNullOrWhiteSpace($localSegment) -and ($localSegment -ine $normalized)) {
[void]$candidates.Add($localSegment)
}
# Domain-suffixed forms only apply where Windows writes them.
$domainSuffix = Get-ProfileFolderDomainSuffix
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
# Prefer the local segment as the stem so DOMAIN\user still yields
# user.CONTOSO rather than the fully qualified string.
$stem = if (-not [string]::IsNullOrWhiteSpace($localSegment)) { $localSegment } else { $normalized }
if (-not [string]::IsNullOrWhiteSpace($stem)) {
$suffixedForm = "$stem.$domainSuffix"
$alreadyPresent = $false
foreach ($existing in $candidates) {
if ($existing -ieq $suffixedForm) { $alreadyPresent = $true; break }
}
if (-not $alreadyPresent) {
[void]$candidates.Add($suffixedForm)
}
}
# A suffixed input can also be referenced by its bare stem elsewhere
# (registry, backup metadata), so add that form too.
$suffixWithDot = ".$domainSuffix"
if ($normalized.Length -gt $suffixWithDot.Length -and $normalized.EndsWith($suffixWithDot, [System.StringComparison]::OrdinalIgnoreCase)) {
$bareStem = Normalize-UserLookupValue -Value ($normalized.Substring(0, $normalized.Length - $suffixWithDot.Length))
if (-not [string]::IsNullOrWhiteSpace($bareStem)) {
$alreadyPresent = $false
foreach ($existing in $candidates) {
if ($existing -ieq $bareStem) { $alreadyPresent = $true; break }
}
if (-not $alreadyPresent) {
[void]$candidates.Add($bareStem)
}
}
}
}
return $candidates.ToArray() | Select-Object -Unique
}
<#
.SYNOPSIS
Test whether a user name and a profile folder leaf share an account.
.DESCRIPTION
Compares candidate sets (via Get-UserNameMatchCandidates) instead of
raw strings, so different forms of the same account still match.
.PARAMETER UserName
User-supplied name to compare.
.PARAMETER ProfileLeaf
On-disk profile folder leaf name to compare.
.OUTPUTS
System.Boolean
#>
function Test-UserNameMatchesProfileLeaf {
param(
[string]$UserName,
[string]$ProfileLeaf
)
if ([string]::IsNullOrWhiteSpace($UserName) -or [string]::IsNullOrWhiteSpace($ProfileLeaf)) {
return $false
}
$leafCandidates = @(Get-UserNameMatchCandidates -Value $ProfileLeaf)
$userCandidates = @(Get-UserNameMatchCandidates -Value $UserName)
foreach ($leaf in $leafCandidates) {
foreach ($user in $userCandidates) {
if ($leaf -ieq $user) {
return $true
}
}
}
return $false
}
<#
.SYNOPSIS
Test whether two user-name strings refer to the same account.
.DESCRIPTION
Accepts any equivalent candidate form so restore works across
sessions and join states. Workgroup stays a strict normalized equality
check to avoid broadening validation where suffixes aren't meaningful.
.PARAMETER UserNameA
First user name to compare.
.PARAMETER UserNameB
Second user name to compare.
.OUTPUTS
System.Boolean
#>
function Test-UserNameMatch {
param(
[string]$UserNameA,
[string]$UserNameB
)
if ([string]::IsNullOrWhiteSpace($UserNameA) -and [string]::IsNullOrWhiteSpace($UserNameB)) {
return $true
}
if ([string]::IsNullOrWhiteSpace($UserNameA) -or [string]::IsNullOrWhiteSpace($UserNameB)) {
return $false
}
# Workgroup: strict equality (no suffix disambiguation available).
if (-not (Test-MachineIsDomainJoined)) {
$normalizedA = Normalize-UserLookupValue -Value $UserNameA
$normalizedB = Normalize-UserLookupValue -Value $UserNameB
return ($normalizedA -ieq $normalizedB)
}
$candidatesA = @(Get-UserNameMatchCandidates -Value $UserNameA)
$candidatesB = @(Get-UserNameMatchCandidates -Value $UserNameB)
foreach ($a in $candidatesA) {
foreach ($b in $candidatesB) {
if ($a -ieq $b) {
return $true
}
}
}
return $false
}
<#
.SYNOPSIS
Memoize a resolved SID under every equivalent name form.
.DESCRIPTION
Keyed under all equivalent forms so later lookups short-circuit
regardless of which variant the caller holds. No-op on blank SID.
.PARAMETER Candidates
Equivalent name forms to key the cache entry under.
.PARAMETER Sid
Resolved SID to cache.
#>
function Set-ResolvedUserSidCache {
param(
[string[]]$Candidates,
[string]$Sid
)
if ([string]::IsNullOrWhiteSpace($Sid)) {
return
}
foreach ($candidate in @($Candidates)) {
$cacheKey = Get-UserLookupCacheKey -Value $candidate
if ($cacheKey) {
$script:ResolvedUserSidCache[$cacheKey] = $Sid
}
}
}
<#
.SYNOPSIS
Retrieve a previously cached resolved SID.
.DESCRIPTION
Probes every equivalent form; returns $null on a miss.
.PARAMETER Candidates
Equivalent name forms to probe the cache with.
.OUTPUTS
System.String
#>
function Get-CachedResolvedUserSid {
param(
[string[]]$Candidates
)
foreach ($candidate in @($Candidates)) {
$cacheKey = Get-UserLookupCacheKey -Value $candidate
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
return $script:ResolvedUserSidCache[$cacheKey]
}
}
return $null
}
<#
.SYNOPSIS
Attempt SID resolution via NTAccount translation.
.DESCRIPTION
Most authoritative name->SID source; only resolves names reachable
through standard security APIs. Returns $null on failure.
.PARAMETER UserName
Name to translate.
.OUTPUTS
System.String
#>
function Try-ResolveSidByNtAccount {
param(
[string]$UserName
)
if ([string]::IsNullOrWhiteSpace($UserName)) {
return $null
}
try {
$ntAccount = [System.Security.Principal.NTAccount]::new($UserName)
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
if ($sid) {
return $sid.Value
}
}
catch {
# Fallback handled by caller.
}
return $null
}
<#
.SYNOPSIS
Attempt SID resolution against the local account database.
.DESCRIPTION
Prefers Get-LocalUser (typed, fast), falls back to Win32_UserAccount
CIM for older hosts or unavailable cmdlet. Returns $null if no match.
.PARAMETER Candidates
Equivalent name forms to try.
.OUTPUTS
System.String
#>
function Try-ResolveSidByLocalLookup {
param(
[string[]]$Candidates
)
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
if ($lookupCandidates.Count -eq 0) {
return $null
}
if (Get-Command -Name Get-LocalUser -ErrorAction SilentlyContinue) {
foreach ($candidate in $lookupCandidates) {
try {
$matchingLocalUser = Get-LocalUser -Name $candidate -ErrorAction Stop | Select-Object -First 1
if ($matchingLocalUser -and $matchingLocalUser.SID) {
return $matchingLocalUser.SID.Value
}
}
catch {
# Continue to next lookup strategy.
}
}
}
foreach ($candidate in $lookupCandidates) {
try {
$escapedCandidate = Escape-WqlString -Value $candidate
$escapedComputerName = Escape-WqlString -Value $env:COMPUTERNAME
$filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')"
$matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1
if ($matchingAccount -and $matchingAccount.SID) {
return $matchingAccount.SID
}
}
catch {
# Continue to next lookup strategy.
}
}
return $null
}
<#
.SYNOPSIS
Recover a SID from the ProfileList registry hive.
.DESCRIPTION
Last-resort heuristic: matches the profile folder leaf to recover a
SID when name-resolution APIs fail. Returns $null on failure.
.PARAMETER Candidates
Equivalent name forms to match against profile folder leafs.
.OUTPUTS
System.String
#>
function Try-ResolveSidFromProfileList {
param(
[string[]]$Candidates
)
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
if ($lookupCandidates.Count -eq 0) {
return $null
}
try {
$profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
foreach ($sidKey in @(Get-ChildItem -LiteralPath $profileListPath -ErrorAction Stop)) {
try {
$imagePath = Get-ItemPropertyValue -LiteralPath $sidKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
$leafName = Normalize-UserLookupValue -Value (Split-Path -Leaf $expandedPath)
foreach ($candidate in $lookupCandidates) {
if (Test-MachineIsDomainJoined) {
if (Test-UserNameMatchesProfileLeaf -UserName $candidate -ProfileLeaf $leafName) {
return $sidKey.PSChildName
}
}
elseif ($leafName -ieq $candidate) {
return $sidKey.PSChildName
}
}
}
catch {
continue
}
}
}
catch {
# Fallback handled by caller.
}
return $null
}
<#
.SYNOPSIS
Construct a resolved user context object.
.DESCRIPTION
Bundles UserName/UserSid/ProfilePath so callers don't re-derive or
thread three loose values.
.PARAMETER UserName
Normalized user name.
.PARAMETER UserSid
Resolved SID, if available.
.PARAMETER ProfilePath
Resolved profile folder path.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
function New-ResolvedUserContext {
param(
[string]$UserName,
[string]$UserSid,
[string]$ProfilePath
)
return [PSCustomObject]@{
UserName = $UserName
UserSid = $UserSid
ProfilePath = $ProfilePath
}
}
<#
.SYNOPSIS
Return the qualified (DOMAIN\user) name of the current process when it matches the input.
.DESCRIPTION
Used to qualify a bare name on domain-joined boxes; returns $null when
the input doesn't match the current process identity.
.PARAMETER Candidate
Bare user name to compare against the current process identity.
.OUTPUTS
System.String
#>
function Get-QualifiedProcessIdentityName {
param(
[string]$Candidate
)
$normalizedCandidate = Normalize-UserLookupValue -Value $Candidate
if ([string]::IsNullOrWhiteSpace($normalizedCandidate)) {
return $null
}
try {
$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
if ($null -eq $currentIdentity) {
return $null
}
# Skip service/SYSTEM identities (no user profile).
$currentSidString = [string]$currentIdentity.User.Value
if ($currentSidString -in @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')) {
return $null
}
$currentName = [string]$currentIdentity.Name
if ([string]::IsNullOrWhiteSpace($currentName)) {
return $null
}
$currentLocalSegment = Get-LocalUserNameSegment -UserName $currentName
if (-not [string]::IsNullOrWhiteSpace($currentLocalSegment) -and $currentLocalSegment -ieq $normalizedCandidate) {
return $currentName
}
}
catch {
# Fall through to name-based resolution.
}
return $null
}
<#
.SYNOPSIS
Resolve a user name to its SID.
.DESCRIPTION
Always qualifies the input first (DOMAIN\user) and resolves that form;
never guesses from a bare name on domain-joined boxes to avoid
same-named local SAM shadowing.
.PARAMETER UserName
User name to resolve. May be bare, DOMAIN\user, or user@domain.
.OUTPUTS
System.String
#>
function Resolve-UserSid {
param(
[Parameter(Mandatory)]
[string]$UserName
)
$candidateUserName = Normalize-UserLookupValue -Value $UserName
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
return $null
}
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
$localNameSegment = Get-LocalUserNameSegment -UserName $candidateUserName
$leafNameCandidates = @()
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
$leafNameCandidates = @($localNameSegment)
}
# Unqualified inputs probe both the bare name and (for qualified inputs) the
# local leaf segment; qualified inputs pin to the caller's full form only.
$lookupCandidates = if ($hasQualifiedIdentity) {
@($candidateUserName)
}
else {
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
}
$profileHeuristicCandidates = if ($leafNameCandidates.Count -gt 0) {
$leafNameCandidates
}
else {
@($candidateUserName)
}
$cachedSid = Get-CachedResolvedUserSid -Candidates $lookupCandidates
if ($cachedSid) {
return $cachedSid
}
# Step 1: derive the qualified form(s) to resolve; never guess from a bare
# name on domain-joined boxes (local SAM nameshare risk).
$qualifiedNamesToTry = New-Object 'System.Collections.Generic.List[string]'
if ($hasQualifiedIdentity) {
# Caller already qualified; honor verbatim.
[void]$qualifiedNamesToTry.Add($candidateUserName)
}
elseif (Test-MachineIsDomainJoined) {
# Prefer process identity (authoritative), then USERDOMAIN\input.
$processQualifiedName = Get-QualifiedProcessIdentityName -Candidate $candidateUserName
if (-not [string]::IsNullOrWhiteSpace($processQualifiedName)) {
[void]$qualifiedNamesToTry.Add($processQualifiedName)
}
$domainSuffix = Get-ProfileFolderDomainSuffix
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
$domainQualifiedName = "$domainSuffix\$candidateUserName"
if (-not ($qualifiedNamesToTry -contains $domainQualifiedName)) {
[void]$qualifiedNamesToTry.Add($domainQualifiedName)
}
}
}
else {
# Workgroup: bare name is unambiguous.
[void]$qualifiedNamesToTry.Add($candidateUserName)
}
# Step 2: resolve qualified form(s) via NTAccount.Translate.
foreach ($qualifiedName in $qualifiedNamesToTry) {
$resolvedSid = Try-ResolveSidByNtAccount -UserName $qualifiedName
if ($resolvedSid) {
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
return $resolvedSid
}
}
# Step 3: local SAM fallback (workgroup only; skipped on domain to avoid
# nameshare shadowing).
if (-not (Test-MachineIsDomainJoined)) {
$resolvedSid = Try-ResolveSidByLocalLookup -Candidates $lookupCandidates
if ($resolvedSid) {
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
return $resolvedSid
}
}
# Step 4: ProfileList leaf heuristic (last resort; disambiguates by
# on-disk folder name, suffix-aware on domain boxes).
$resolvedSid = Try-ResolveSidFromProfileList -Candidates $profileHeuristicCandidates
if ($resolvedSid) {
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
return $resolvedSid
}
return $null
}
<#
.SYNOPSIS
Resolve a user name to a full profile context (name, SID, path).
.DESCRIPTION
SID-keyed registry data first (authoritative), then on-disk path
probing so resolution still succeeds when the account or SID can't be
looked up (deleted account, restricted context). Returns $null if not
found.
.PARAMETER UserName
User name whose profile context is required.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
function Resolve-UserProfileContext {
param(
[Parameter(Mandatory)]
[string]$UserName
)
$candidateUserName = Normalize-UserLookupValue -Value $UserName
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
return $null
}
$rootPaths = @(
(Join-Path $env:SystemDrive 'Users')
(Split-Path -Path $env:USERPROFILE -Parent)
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
if ($candidateUserName -ieq 'Default') {
foreach ($rootPath in $rootPaths) {
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
continue
}
$defaultProfilePath = Join-Path $rootPath 'Default'
if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) {
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath)
}
}
return $null
}
$userSid = Resolve-UserSid -UserName $candidateUserName
if ($userSid) {
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
try {
if (Test-Path -LiteralPath $sidRegistryPath) {
$registryImagePath = Get-ItemPropertyValue -LiteralPath $sidRegistryPath -Name 'ProfileImagePath' -ErrorAction Stop
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
if (Test-Path -LiteralPath $expandedPath -PathType Container) {
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath)
}
}
}
}
catch {
# Try Win32_UserProfile fallback.
}
try {
$matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop)
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) {
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath)
}
}
catch {
# Fall through to legacy path probing.
}
}
foreach ($rootPath in $rootPaths) {
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
continue
}
# Exact leaf match first (common case; avoids an unnecessary scan).
$candidateUserPath = Join-Path $rootPath $candidateUserName
if (Test-Path -LiteralPath $candidateUserPath -PathType Container) {
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath)
}
# Only domain-joined boxes write suffixed folders; scanning workgroup
# roots would risk matching the wrong account.
if (Test-MachineIsDomainJoined) {
try {
foreach ($child in @(Get-ChildItem -LiteralPath $rootPath -Directory -ErrorAction SilentlyContinue)) {
if (Test-UserNameMatchesProfileLeaf -UserName $candidateUserName -ProfileLeaf $child.Name) {
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $child.FullName)
}
}
}
catch {
# Fall through to the next root path.
}
}
}
return $null
}
-382
View File
@@ -1,382 +0,0 @@
function NormalizeUserLookupValue {
param(
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ''
}
# Remove zero-width characters and normalize whitespace for robust comparisons.
$normalized = $Value -replace '[\u200B-\u200D\uFEFF]', ''
$normalized = $normalized.Trim() -replace '\s+', ' '
return $normalized
}
if (-not $script:ResolvedUserSidCache) {
$script:ResolvedUserSidCache = @{}
}
function GetUserLookupCacheKey {
param(
[string]$Value
)
$normalizedValue = NormalizeUserLookupValue -Value $Value
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
return ''
}
return $normalizedValue.ToLowerInvariant()
}
function EscapeWqlString {
param(
[string]$Value
)
if ($null -eq $Value) {
return ''
}
return $Value -replace "'", "''"
}
function GetLocalUserNameSegment {
param(
[string]$UserName
)
$normalizedName = NormalizeUserLookupValue -Value $UserName
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
return ''
}
if ($normalizedName.Contains('\')) {
return NormalizeUserLookupValue -Value (($normalizedName -split '\\')[-1])
}
if ($normalizedName.Contains('@')) {
return NormalizeUserLookupValue -Value (($normalizedName -split '@')[0])
}
return $normalizedName
}
function SetResolvedUserSidCache {
param(
[string[]]$Candidates,
[string]$Sid
)
if ([string]::IsNullOrWhiteSpace($Sid)) {
return
}
foreach ($candidate in @($Candidates)) {
$cacheKey = GetUserLookupCacheKey -Value $candidate
if ($cacheKey) {
$script:ResolvedUserSidCache[$cacheKey] = $Sid
}
}
}
function GetCachedResolvedUserSid {
param(
[string[]]$Candidates
)
foreach ($candidate in @($Candidates)) {
$cacheKey = GetUserLookupCacheKey -Value $candidate
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
return $script:ResolvedUserSidCache[$cacheKey]
}
}
return $null
}
function TryResolveSidByNtAccount {
param(
[string]$UserName
)
if ([string]::IsNullOrWhiteSpace($UserName)) {
return $null
}
try {
$ntAccount = [System.Security.Principal.NTAccount]::new($UserName)
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
if ($sid) {
return $sid.Value
}
}
catch {
# Fallback handled by caller.
}
return $null
}
function TryResolveSidByLocalLookup {
param(
[string[]]$Candidates
)
$lookupCandidates = @($Candidates) | ForEach-Object { NormalizeUserLookupValue -Value $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
if ($lookupCandidates.Count -eq 0) {
return $null
}
if (Get-Command -Name Get-LocalUser -ErrorAction SilentlyContinue) {
foreach ($candidate in $lookupCandidates) {
try {
$matchingLocalUser = Get-LocalUser -Name $candidate -ErrorAction Stop | Select-Object -First 1
if ($matchingLocalUser -and $matchingLocalUser.SID) {
return $matchingLocalUser.SID.Value
}
}
catch {
# Continue to next lookup strategy.
}
}
}
foreach ($candidate in $lookupCandidates) {
try {
$escapedCandidate = EscapeWqlString -Value $candidate
$escapedComputerName = EscapeWqlString -Value $env:COMPUTERNAME
$filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')"
$matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1
if ($matchingAccount -and $matchingAccount.SID) {
return $matchingAccount.SID
}
}
catch {
# Continue to next lookup strategy.
}
}
return $null
}
function TryResolveSidFromProfileList {
param(
[string[]]$Candidates
)
$lookupCandidates = @($Candidates) | ForEach-Object { NormalizeUserLookupValue -Value $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
if ($lookupCandidates.Count -eq 0) {
return $null
}
try {
$profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
foreach ($sidKey in @(Get-ChildItem -LiteralPath $profileListPath -ErrorAction Stop)) {
try {
$imagePath = Get-ItemPropertyValue -LiteralPath $sidKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
$leafName = NormalizeUserLookupValue -Value (Split-Path -Leaf $expandedPath)
foreach ($candidate in $lookupCandidates) {
if ($leafName -ieq $candidate) {
return $sidKey.PSChildName
}
}
}
catch {
continue
}
}
}
catch {
# Fallback handled by caller.
}
return $null
}
function NewResolvedUserContext {
param(
[string]$UserName,
[string]$UserSid,
[string]$ProfilePath
)
return [PSCustomObject]@{
UserName = $UserName
UserSid = $UserSid
ProfilePath = $ProfilePath
}
}
function ResolveUserSid {
param(
[Parameter(Mandatory)]
[string]$UserName
)
$candidateUserName = NormalizeUserLookupValue -Value $UserName
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
return $null
}
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
$localNameSegment = GetLocalUserNameSegment -UserName $candidateUserName
$leafNameCandidates = @()
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
$leafNameCandidates = @($localNameSegment)
}
$cacheCandidates = if ($hasQualifiedIdentity) {
@($candidateUserName)
}
else {
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
}
$localLookupCandidates = if ($hasQualifiedIdentity) {
@()
}
else {
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
}
$profileHeuristicCandidates = if ($leafNameCandidates.Count -gt 0) {
$leafNameCandidates
}
else {
@($candidateUserName)
}
$cachedSid = GetCachedResolvedUserSid -Candidates $cacheCandidates
if ($cachedSid) {
return $cachedSid
}
# Resolve fully-qualified identities first to avoid accidentally matching a local leaf account.
if ($hasQualifiedIdentity) {
$resolvedSid = TryResolveSidByNtAccount -UserName $candidateUserName
if ($resolvedSid) {
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
return $resolvedSid
}
}
$resolvedSid = TryResolveSidByLocalLookup -Candidates $localLookupCandidates
if ($resolvedSid) {
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
return $resolvedSid
}
# Last-ditch NTAccount translation for non-qualified names.
if (-not $hasQualifiedIdentity) {
$resolvedSid = TryResolveSidByNtAccount -UserName $candidateUserName
if ($resolvedSid) {
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
return $resolvedSid
}
}
$resolvedSid = TryResolveSidFromProfileList -Candidates $profileHeuristicCandidates
if ($resolvedSid) {
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
return $resolvedSid
}
return $null
}
function ResolveUserProfilePath {
param(
[Parameter(Mandatory)]
[string]$UserName
)
$userContext = ResolveUserProfileContext -UserName $UserName
if ($userContext) {
return $userContext.ProfilePath
}
return $null
}
function ResolveUserProfileContext {
param(
[Parameter(Mandatory)]
[string]$UserName
)
if ([string]::IsNullOrWhiteSpace($UserName)) {
return $null
}
$candidateUserName = NormalizeUserLookupValue -Value $UserName
$rootPaths = @(
(Join-Path $env:SystemDrive 'Users')
(Split-Path -Path $env:USERPROFILE -Parent)
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
if ($candidateUserName -ieq 'Default') {
foreach ($rootPath in $rootPaths) {
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
continue
}
$defaultProfilePath = Join-Path $rootPath 'Default'
if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) {
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath)
}
}
return $null
}
$userSid = ResolveUserSid -UserName $candidateUserName
if ($userSid) {
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
try {
if (Test-Path -LiteralPath $sidRegistryPath) {
$registryImagePath = Get-ItemPropertyValue -LiteralPath $sidRegistryPath -Name 'ProfileImagePath' -ErrorAction Stop
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
if (Test-Path -LiteralPath $expandedPath -PathType Container) {
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath)
}
}
}
}
catch {
# Try Win32_UserProfile fallback.
}
try {
$matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop)
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) {
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath)
}
}
catch {
# Fall through to legacy path probing.
}
}
foreach ($rootPath in $rootPaths) {
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
continue
}
$candidateUserPath = Join-Path $rootPath $candidateUserName
if (Test-Path -LiteralPath $candidateUserPath -PathType Container) {
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath)
}
}
return $null
}
@@ -0,0 +1,97 @@
<#
.SYNOPSIS
Validates that a configuration file is structurally consistent before it is applied.
.DESCRIPTION
Returns $null when the configuration is valid, otherwise a string describing the
first problem found. Used by both the CLI and GUI import paths to reject invalid
configs before any settings are applied.
.OUTPUTS
System.String. $null when valid, otherwise an error message.
#>
function Test-ConfigConsistency {
param($Config)
if (-not $Config) {
return 'Configuration is empty or could not be read.'
}
if (-not $Config.Version) {
return 'Configuration is missing a Version field.'
}
if (-not $Config.Apps -and -not $Config.Tweaks -and -not $Config.Deployment) {
return 'The configuration file contains no importable data.'
}
if ($null -ne $Config.Apps) {
if ($Config.Apps -isnot [string] -and $Config.Apps -isnot [System.Collections.IEnumerable]) {
return 'Configuration Apps entries must be strings.'
}
foreach ($app in @($Config.Apps)) {
if ($app -isnot [string]) {
return 'Configuration Apps entries must be strings.'
}
}
}
foreach ($categoryName in @('Tweaks', 'Deployment')) {
$category = $Config.$categoryName
if ($null -eq $category) { continue }
if ($category -is [string] -or $category -isnot [System.Collections.IEnumerable]) {
return "Configuration $categoryName entries must contain Name and Value properties."
}
foreach ($setting in @($category)) {
$hasName = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Name') } else { $null -ne $setting.PSObject.Properties['Name'] }
$hasValue = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Value') } else { $null -ne $setting.PSObject.Properties['Value'] }
if (-not $setting -or -not $hasName -or -not $hasValue -or $setting.Name -isnot [string] -or [string]::IsNullOrWhiteSpace($setting.Name)) {
return "Configuration $categoryName entries must contain Name and Value properties."
}
}
}
$lookup = @{}
foreach ($setting in @($Config.Deployment)) {
if ($setting -and $setting.Name) {
$lookup[$setting.Name] = $setting.Value
}
}
$hasScope = $lookup.ContainsKey('AppRemovalScopeIndex')
$hasUser = $lookup.ContainsKey('UserSelectionIndex')
$scopeIndex = $null
if ($hasScope) {
if (-not [int]::TryParse("$($lookup['AppRemovalScopeIndex'])", [ref]$scopeIndex) -or $scopeIndex -notin @(0, 1, 2)) {
return 'AppRemovalScopeIndex must be a supported numeric value (0, 1, or 2).'
}
}
$userIndex = $null
if ($hasUser) {
if (-not [int]::TryParse("$($lookup['UserSelectionIndex'])", [ref]$userIndex) -or $userIndex -notin @(0, 1, 2)) {
return 'UserSelectionIndex must be a supported numeric value (0, 1, or 2).'
}
}
# "Current user only" (index 1) is only valid together with "Current User" (index 0)
if ($hasScope -and $scopeIndex -eq 1) {
if (-not $hasUser -or $userIndex -ne 0) {
return "App removal scope 'Current user only' (AppRemovalScopeIndex 1) requires the deployment target 'Current User' (UserSelectionIndex 0)."
}
}
# "Target user only" (index 2) is only valid together with "Other User" (index 1)
if ($hasScope -and $scopeIndex -eq 2) {
if (-not $hasUser -or $userIndex -ne 1) {
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires the deployment target 'Other User' (UserSelectionIndex 1)."
}
if (-not $lookup.ContainsKey('OtherUsername') -or [string]::IsNullOrWhiteSpace("$($lookup['OtherUsername'])")) {
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires an 'OtherUsername' value."
}
}
return $null
}
@@ -1,5 +1,5 @@
# Check if this machine supports S0 Modern Standby power state. Returns true if S0 Modern Standby is supported, false otherwise.
function CheckModernStandbySupport {
function Test-ModernStandbySupport {
$count = 0
try {
+2 -2
View File
@@ -15,7 +15,7 @@ function Test-TargetUserName {
}
}
if ($normalizedUserName -eq $env:USERNAME) {
if (Test-UserNameMatch -UserNameA $normalizedUserName -UserNameB $env:USERNAME) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName
@@ -23,7 +23,7 @@ function Test-TargetUserName {
}
}
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
if (-not (Test-UserProfileExists -userName $normalizedUserName)) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName

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