Compare commits

..

58 Commits

Author SHA1 Message Date
Jeffrey
9ee0126259 Bump version 2026-06-24 22:13:48 +02:00
Jeffrey
e23ecf36d6 Update minimum window sizing 2026-06-24 22:00:44 +02:00
Jeffrey
32dc3d6bdf Fix maximized window sizing (#673) 2026-06-24 21:45:53 +02:00
Jeffrey
f76adc5054 Add docstrings 2026-06-24 20:55:17 +02:00
Jeffrey
95b583606d Update start menu backup/restore with timestamped filenames (#672) 2026-06-24 17:32:45 +02:00
Jeffrey
693b805114 Simplify Window management (#671) 2026-06-24 14:40:48 +02:00
Jeffrey
5ebc50d36a Guard against loading, saving & executing undefined features (#665) 2026-06-23 00:41:33 +02:00
Jeffrey
d1fe541b62 Refactor: Cleanup app removal, remove legacy app list generator and CustomAppsList file support (#662)
* remove support for uninstalling old sunset apps

* Add color legend on app removal screen

* Remove legacy app list generator and custom apps file support
Replaced by GUI config export/import, dynamic RemovalMethod, and
CLI app removal settings saved to LastUsedSettings.json.

* Verify app removal by checking actual installation state instead of trusting winget output
2026-06-22 22:13:01 +02:00
Jeffrey
71e3f2e44d Refactor: ExecuteChanges to InvokeChanges, clean up for readability (#641) 2026-06-22 21:43:53 +02:00
HetCreep
4891aa401a fix(app-removal): detect installed OneDrive in the "Only show installed" filter (#656) 2026-06-22 15:10:53 +02:00
HetCreep
a6d59c0dc1 fix(app-removal): detect WinGet uninstall failures by exit code, not English text (#658) 2026-06-22 14:53:45 +02:00
Jeffrey
ac54bde383 Replace FocusHelper C# compilation with WPF Activate() to fix temp DLL access-denied errors (#661) 2026-06-22 02:07:26 +02:00
HetCreep
dfe7810346 feat(registry): add GPO override warning and WhatIf dry-run previews (#611)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-06-21 21:30:31 +02:00
HetCreep
82894176d9 fix(restore): correct sub-key path matching in backup allow-list validation (#645) 2026-06-21 20:37:29 +02:00
HetCreep
87b3035eda fix(threading): surface runspace errors instead of swallowing them in GUI mode (#655) 2026-06-21 19:08:21 +02:00
Jeffrey
a89b53504c Clean up styling to better match Windows fluent design guidelines (#638) 2026-06-21 18:47:52 +02:00
HetCreep
91a6266d50 fix(gui): treat dismissed unsafe-removal confirmation as decline (#651) 2026-06-21 18:42:47 +02:00
HetCreep
469751f8e8 fix(app-removal): don't treat AllUsers/CurrentUser as a username at startup (#647) 2026-06-21 18:39:10 +02:00
Jeffrey
908274a500 Fix store suggestions not getting disabled correctly for all users when running as other user (#642) 2026-06-21 01:56:28 +02:00
HetCreep
6e4a616f1c feat(telemetry): disable telemetry-related scheduled tasks under Microsoft\Windows (#615)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-06-20 19:04:46 +02:00
Jeffrey
535b62db40 Fix: Respect Feature min/max version for comboboxes (#639) 2026-06-19 18:20:25 +02:00
Jeffrey
c039b04717 Fix Start Menu apps not being set correctly for all users when running script for other user (#637) 2026-06-18 23:02:25 +02:00
Jeffrey
a7a46bb5bf Clean up app removal target user descriptions 2026-06-16 23:43:07 +02:00
Jeffrey
a95b5adee8 Update import/export window descriptions 2026-06-16 19:47:48 +02:00
Jeffrey
2b97021341 Merge branch 'master' of https://github.com/Raphire/Win11Debloat 2026-06-14 22:06:22 +02:00
Jeffrey
1235306f80 Bump version 2026-06-14 22:06:16 +02:00
Jeffrey
1a69d19f30 Refactor Get-RegFileOperations.ps1 (#626)
Feels weird to have to do this, but I have refactored the functions in Get-RegFileOperations.ps1 to avoid false positives in Windows Security (Windows Defender) and Bitdefender.

Related issues: #621, #624
2026-06-14 22:05:19 +02:00
Jeffrey
5628f6e0b7 Add logging around winget app retrieval and increase timeout to 20s 2026-06-12 17:41:30 +02:00
Jeffrey
6f349b4992 Update bug_report template 2026-06-12 17:34:17 +02:00
Jeffrey
2193591448 Bump version 2026-06-11 22:16:45 +02:00
Jeffrey
e9269c5501 Fix lockscreen spotlight option being locked when start recommended section is disabled (#619) 2026-06-11 22:09:06 +02:00
Jeffrey
fdac0a6d14 Move trailing ellipsis out of config 2026-06-10 21:00:07 +02:00
Jeffrey
2aa9afaa2c Update CONTRIBUTING.md 2026-06-10 20:55:26 +02:00
Jeffrey
67c9cc6ba3 Bump version 2026-06-10 17:41:13 +02:00
Jeffrey
157d26bb22 Add option to show & undo applied tweaks + more (#599)
* Remove RemoveCommApps and RemoveW11Outlook presets. These are largely redundant. Use -RemoveApps parameter instead

* Add additional options to change the `All Apps` view in the start menu (Hide, Grid, Category, List)

* Add clean start menu backup validation to start menu restore function

* Resolve nested quoting bug in Run.bat when path has spaces, see #583

* Fix desync issue when toggling "Only Show Installed" checkbox too fast

* Fix: add missing keys in Sysprep/Undo regfiles for Disabling Recall and Windows Suggested content

* Fix 'Disable Animations' Sysprep settings not being set for new users

* Update README.md

* Update CONTRIBUTING.md
2026-06-10 17:40:31 +02:00
HetCreep
53ca51dffd fix(appx): expose swallowed exceptions during Appx Package uninstallation via Write-Verbose (#617)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-06-10 16:43:37 +02:00
soccerzockt
db24865051 Add Support for "-user" Parameter running under SYSTEM (#609)
Co-authored-by: Jeffrey <9938813+Raphire@users.noreply.github.com>
2026-06-07 22:51:01 +02:00
Jeffrey
33b77f19a0 Add confirmation dialogs & warning for Windows Terminal Removal
Removal of this app can cause Win11Debloat to fail, if the script is launched via Windows Terminal
2026-06-01 22:53:28 +02:00
Jeffrey
37872b2030 Update CONTRIBUTING.md 2026-05-26 15:57:34 +02:00
Jeffrey
abfc5db2c3 Improve log output in Get.ps1 and clean up file exclusions 2026-05-25 14:35:39 +02:00
Jeffrey
1d828d6a78 Fix typo in Disable_Game_Bar_Integration Sysprep registry file 2026-05-24 14:53:12 +02:00
Jeffrey
4d9da4749b Merge branch 'master' of https://github.com/Raphire/Win11Debloat 2026-05-20 16:29:41 +02:00
Jeffrey
5cf9ac4082 Bump version 2026-05-20 16:29:33 +02:00
Jeffrey
924c192ca5 Add Registry write fall-back in case applying registry file fails (#592)
* Continue on registry failures and show details after execution

* Temporarily remove DisableSearchHighlights and DisableSearchHistory settings

* Remove widget-related registry changes as they're no longer required for disabling widgets

* Update tooltip for DisableTelemetry feature to clarify impact on Windows Insider updates
2026-05-20 16:29:06 +02:00
Jeffrey
2a5cb986c9 Merge branch 'master' of https://github.com/Raphire/Win11Debloat 2026-05-17 17:56:01 +02:00
Jeffrey
66982ada28 Limit backup restore files to json only 2026-05-17 17:55:59 +02:00
Ahmad Z. Shatnawi
489af33a8b Fix: Increase System Restore point creation timeout to 90 seconds (#586) 2026-05-17 17:50:36 +02:00
Jeffrey
51aa288dfd Bump version 2026-05-12 00:01:57 +02:00
Jeffrey
24a6f1bcf8 Fix capture and restore of signed dword/qword registry values
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 19:14:08 +02:00
Jeffrey
8ac664e45f Add restart instructions to registry restore success message 2026-05-10 23:26:22 +02:00
Jeffrey
85aa67b5d2 Bump version 2026-05-10 22:27:07 +02:00
Jeffrey
c8b4563954 Fix registry backup validation for sysprep keys 2026-05-09 21:56:58 +02:00
Jeffrey
22f3144c0f Remove > from applytext 2026-05-08 21:26:51 +02:00
Jeffrey
2c360961e3 Add registry backup & restore (#566)
Starting from this commit, Win11Debloat will automatically create a registry backup every time the script is run. This registry backup can be used to revert any registry changes made by the script.
2026-05-08 21:19:52 +02:00
Jeffrey
11a324365d Improve user validation (#568) 2026-05-06 15:54:03 +02:00
Jeffrey
5daa922148 Make tweak columns responsive 2026-04-29 17:05:53 +02:00
Jeffrey
1826d6d8be Refactor app removal scope handling & styling 2026-04-27 15:37:09 +02:00
Jeffrey
c15309bcf6 Update feature labels to include default indicators 2026-04-27 15:30:09 +02:00
96 changed files with 10152 additions and 3933 deletions

View File

@@ -1,6 +1,7 @@
# How to Contribute?
We welcome contributions from the community. You can contribute to Win11Debloat by:
- Reporting issues and bugs [here](https://github.com/Raphire/Win11Debloat/issues/new?template=bug_report.yml)
- Submitting feature requests [here](https://github.com/Raphire/Win11Debloat/issues/new?template=feature_request.yml)
- Testing Win11Debloat
@@ -15,6 +16,7 @@ You can help us test the latest changes and additions to the script. If you enco
> The prerelease version of Win11Debloat is meant for developers to test the script. Don't use this in production environments!
You can launch the prerelease version of Win11Debloat by running this command:
```ps1
& ([scriptblock]::Create((irm "https://debloat.raphi.re/dev")))
```
@@ -28,12 +30,14 @@ You can launch the prerelease version of Win11Debloat by running this command:
1. **Fork the project** on GitHub by clicking the "Fork" button at the top right of the repository page.
2. **Clone the repository** to your local machine:
```powershell
git clone https://github.com/YOUR-USERNAME/Win11Debloat.git
cd Win11Debloat
```
3. **Create a new branch** for your contribution:
```powershell
git checkout -b feature/your-feature-name
```
@@ -42,11 +46,14 @@ You can launch the prerelease version of Win11Debloat by running this command:
1. Open PowerShell as an administrator
2. Enable script execution if necessary:
```powershell
Set-ExecutionPolicy Unrestricted -Scope Process -Force
```
3. Navigate to your Win11Debloat directory
4. Run the script:
```powershell
.\Win11Debloat.ps1
```
@@ -57,18 +64,31 @@ You can launch the prerelease version of Win11Debloat by running this command:
Understanding the project structure is essential for contributing effectively:
```
```text
Win11Debloat/
├── Win11Debloat.ps1 # Main PowerShell script
├── Run.bat # Batch launcher for the quick launch method
├── Scripts/ # Additional PowerShell scripts and functions
── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
│ ├── AppRemoval/ # App package removal logic
│ ├── CLI/ # Command-line interface helpers
│ ├── Features/ # Feature apply/undo logic (e.g. InvokeChanges.ps1, ReplaceStartMenu.ps1)
│ ├── FileIO/ # File input/output helpers
│ ├── GUI/ # GUI window definitions and logic
│ ├── Helpers/ # Shared helper functions
│ └── Threading/ # Threading utilities
├── Config/
│ ├── Apps.json # List of supported apps for removal
│ ├── DefaultSettings.json # Default configuration preset
│ ├── Features.json # All features with metadata
│ └── LastUsedSettings.json # Last used configuration (generated during use)
├── Regfiles/ # Registry files for each feature
└── Schemas/ # XAML Schemas for GUI elements
├── Regfiles/ # Registry files for all features
│ ├── Undo/ # Registry files for reverting features
│ └── Sysprep/ # Registry files for Sysprep mode
├── Schemas/ # XAML Schemas for GUI elements
├── Assets/ # Static assets (icons, start menu templates)
├── Backups/ # Registry backups (generated during use)
└── Logs/ # Script logs (generated during use)
```
### Best Practices
@@ -98,20 +118,20 @@ Avoid these common mistakes when contributing:
1. **Forgetting Get.ps1**: When adding a new command-line parameter, contributors often remember to add it to `Win11Debloat.ps1` but forget to add the same parameter to `Scripts/Get.ps1`. Both files **must** have matching parameters.
2. **Missing Registry Files**: Always create an `Undo` registry file for reversibility, aswell as a `Sysprep` registry file for Sysprep mode.
2. **Missing Registry Files**: Always create an `Undo` registry file for reversibility, aswell as a `Sysprep` registry file for applying changes to other users and Sysprep mode.
3. **Incorrect Registry Hives for Sysprep**: Sysprep registry files apply changes to Windows' default user, registry keys in the `HKEY_CURRENT_USER` hive must use `hkey_users\default` instead. Ensure you update **all** registry keys in the file.
3. **Incorrect Registry Hives for Sysprep**: Sysprep registry files are meant to apply changes to a different user. Registry keys in the `HKEY_CURRENT_USER` hive must use `hkey_users\default` instead. Ensure you update **all** registry keys in the file.
4. **Wrong Registry File Location**:
- Main action files go in `Regfiles/`
- Undo files go in `Regfiles/Undo/`
- Sysprep files go in `Regfiles/Sysprep/`
Placing files in the wrong directory will cause the script to fail when trying to apply or undo changes.
Placing files in the wrong directory may cause the script to fail when trying to apply or undo changes.
6. **Not Testing Undo Functionality**: Always test that your undo registry file properly reverts all changes. A feature that can't be undone will frustrate users.
5. **Not Testing Undo Functionality**: Always test that your undo registry file properly reverts all changes.
7. **Not Testing User/Sysprep Functionality**: Always test that your feature works when applied to another user or to the Windows default user with Sysprep. Sysprep changes can be tested by creating new users after running the script.
6. **Not Testing User/Sysprep Functionality**: Always test that your feature works when applied to another user or to the Windows default user with Sysprep. Sysprep changes can be tested by creating new users after running the script.
7. **Missing Category**: Features without a `Category` field (set to `null`) won't appear in the GUI. This is intentional for command-line-only features, make sure this is what you want before submitting.
@@ -127,11 +147,13 @@ Avoid these common mistakes when contributing:
To add a new app that can be removed via Win11Debloat:
1. **Find the AppId**: To find the correct AppId for an app:
```powershell
Get-AppxPackage | Select-Object Name, PackageFullName
```
2. **Edit `Config/Apps.json`**: Add a new entry to the `"Apps"` array:
```json
{
"FriendlyName": "Display Name",
@@ -142,9 +164,10 @@ To add a new app that can be removed via Win11Debloat:
```
3. **Follow the Guidelines**:
- Use clear, user-friendly names for `FriendlyName`
- Set `SelectedByDefault` to `true` only for apps that are largely considered bloatware, otherwise set to `false`
- Provide a concise description explaining what the app does
- Use clear, user-friendly names for `FriendlyName`
- Set `SelectedByDefault` to `true` only for apps that are largely considered bloatware, otherwise set to `false`
- Provide a concise description explaining what the app does
### Adding a New Feature
@@ -162,6 +185,7 @@ Create new registry files in the `Regfiles/` directory:
- **Sysprep file**: `Sysprep/Disable_YourFeature.reg` (for Sysprep mode)
Example registry file structure:
```reg
Windows Registry Editor Version 5.00
@@ -170,6 +194,7 @@ Windows Registry Editor Version 5.00
```
A Sysprep registry file should apply the same changes as the normal action. Replace the hive of registry keys that start with `HKEY_CURRENT_USER` with `hkey_users\default`. For example:
```reg
Windows Registry Editor Version 5.00
@@ -179,7 +204,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 `ExecuteParameter` function for your new feature.
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.
#### 2. Add Feature to Features.json
@@ -192,35 +217,39 @@ Add your feature to the `"Features"` array in `Config/Features.json`:
"ToolTip": "Detailed explanation of what this feature does and its impact.",
"Category": "Privacy & Suggested Content",
"Priority": 1,
"Action": "Disable",
"RegistryKey": "Disable_YourFeature.reg",
"ApplyText": "Disabling your feature...",
"UndoAction": "Enable",
"ApplyText": "Disabling your feature",
"UndoLabel": "Short description for the undo",
"ApplyUndoText": "Enabling your feature",
"RegistryUndoKey": "Enable_YourFeature.reg",
"RequiresReboot": false,
"DisableWhenApplied": false,
"MinVersion": null,
"MaxVersion": null
}
```
**Field Descriptions**:
- `FeatureId`: Unique identifier (must match parameter name in Win11Debloat.ps1 and Get.ps1)
- `Label`: Short description shown in the UI, written in a way to fit with the Action or UndoAction prefixed
- `ToolTip`: Detailed explanation of what the feature does, used for tooltips in the GUI
- `FeatureId`: Unique identifier, this must match parameter name in the Win11Debloat.ps1 and Get.ps1 files.
- `Label`: Short description shown in the UI and wiki documentation.
- `ToolTip`: Detailed explanation of what the feature does, used for tooltips in the GUI.
- `Category`: One of the predefined categories (see Categories array in Features.json), features without a category won't be loaded into the GUI.
- `Priority`: Optional. The priority value (int) is used to sort features within a category. If this field is omitted the feature will be sorted based on the order in the Features.json file.
- `Action`: Action word for the feature (e.g., "Disable", "Enable", "Hide", "Show")
- `RegistryKey`: Filename of the registry file to apply (in Regfiles/ directory) or null if feature does not require registry changes
- `ApplyText`: Message shown when applying the feature
- `UndoAction`: Action word for reverting (e.g., "Enable", "Show")
- `RegistryUndoKey`: Filename of the registry file to revert changes or null if feature does not require registry changes
- `RequiresReboot`: Optional boolean. Set to `true` if the feature requires a system reboot to take effect
- `MinVersion`: Minimum Windows build version (e.g., "22000") or null
- `MaxVersion`: Maximum Windows version or null
- `RegistryKey`: Filename of the registry file to apply (in Regfiles/ directory) or null if feature does not require registry changes.
- `ApplyText`: Message shown when applying the feature.
- `UndoLabel`: Short description for the undo shown in the UI.
- `ApplyUndoText`: Message shown when undoing the feature.
- `RegistryUndoKey`: Filename of the registry file to revert changes or null if feature does not require registry changes.
- `RequiresReboot`: Optional boolean. Set to `true` if the feature requires a system reboot to take effect.
- `DisableWhenApplied`: Optional boolean. Set to `true` if the feature has no supported undo method.
- `MinVersion`: Minimum Windows build version (e.g., "22000") or null.
- `MaxVersion`: Maximum Windows version or null.
#### 3. Add Command-Line Parameter
Add a corresponding parameter to both `Win11Debloat.ps1` AND `Scripts/Get.ps1`, the parameter name should match the FeatureId you have defined in `Features.json`. In most cases this will be a switch parameter, example:
```powershell
[switch]$YourFeatureId,
```
@@ -233,12 +262,14 @@ Add a corresponding parameter to both `Win11Debloat.ps1` AND `Scripts/Get.ps1`,
The default preset (`Config/DefaultSettings.json`) defines which features are automatically applied when users run Win11Debloat in "Default Mode" or with the `-RunDefaults` parameter. This preset should include features that are widely considered to improve the Windows experience without breaking functionality.
**When to add a feature to the default preset:**
- The feature removes obvious bloatware or distractions
- The feature enhances privacy without breaking core functionality
- The feature is generally non-controversial and beneficial to most users
- The change can be easily reverted if needed
**When NOT to add a feature to the default preset:**
- The feature significantly changes core Windows behavior
- The feature might break applications or workflows for some users
- The feature is highly opinionated or preference-based
@@ -254,10 +285,12 @@ To add your feature to the default preset, edit `Config/DefaultSettings.json` an
```
**Field Descriptions**:
- `Name`: Must exactly match the `FeatureId` from Features.json
- `Value`: Set to `true` to enable the feature in default mode
**Example:**
```json
{
"Version": "1.0",
@@ -283,12 +316,13 @@ To add your feature to the default preset, edit `Config/DefaultSettings.json` an
To add a new category for organizing features:
- Add a new category entry to the `"Categories"` array in `Config/Features.json`:
```json
{
"Name": "Your Category Name",
"Icon": "&#xE#### ;"
}
```
```json
{
"Name": "Your Category Name",
"Icon": "&#xE#### ;"
}
```
> [!TIP]
> Use [Segoe Fluent Icon Assets](https://learn.microsoft.com/en-us/windows/apps/design/iconography/segoe-fluent-icons-font) for icon codes.
@@ -320,17 +354,20 @@ UI Groups allow features to be grouped together in the GUI with a combobox (drop
## Submitting a Pull Request
1. **Commit your changes** with clear, descriptive commit messages:
```powershell
git add .
git commit -m "Add feature: Description of your changes"
```
2. **Push to your fork**:
```powershell
git push origin feature/your-feature-name
```
3. **Create a Pull Request** on GitHub:
- Go to the original Win11Debloat repository
- Click "New Pull Request"
- Select your fork and branch
@@ -342,6 +379,7 @@ UI Groups allow features to be grouped together in the GUI with a combobox (drop
# Questions?
If you have questions about contributing, feel free to:
- Open a [discussion](https://github.com/Raphire/Win11Debloat/discussions)
- Comment on an existing issue
- Ask in your pull request
- Ask in your pull request

View File

@@ -1,6 +1,6 @@
name: "🐞 Bug report"
description: "Report an issue you encountered"
labels: ["bug"]
labels: ["bug", "unconfirmed"]
body:
- type: markdown

5
.gitignore vendored
View File

@@ -1,6 +1,3 @@
LastSettings
SavedSettings
LastUsedSettings.json
CustomAppsList
Logs/*
Win11Debloat.log
Backups/*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@
[![Join the Discussion](https://img.shields.io/badge/Join-the%20Discussion-2D9F2D?style=for-the-badge&logo=github&logoColor=white)](https://github.com/Raphire/Win11Debloat/discussions)
[![Static Badge](https://img.shields.io/badge/Documentation-_?style=for-the-badge&logo=bookstack&color=grey)](https://github.com/Raphire/Win11Debloat/wiki/)
Win11Debloat is a lightweight, easy to use PowerShell script that allows you to quickly declutter and customize your Windows experience. It can remove pre-installed bloatware 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!
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 option 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. Please refer to our [wiki](https://github.com/Raphire/Win11Debloat/wiki/) for more details.
![Win11Debloat Menu](/Assets/Images/menu.png)

Binary file not shown.

View File

@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Start]
"AllAppsViewMode"=dword:00000000
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoStartMenuMorePrograms"=-

View File

@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Start]
"AllAppsViewMode"=dword:00000001
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoStartMenuMorePrograms"=-

View File

@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Start]
"AllAppsViewMode"=dword:00000002
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoStartMenuMorePrograms"=-

View File

@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Start]
"AllAppsViewMode"=dword:00000000
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoStartMenuMorePrograms"=-

View File

@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Start]
"AllAppsViewMode"=dword:00000001
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoStartMenuMorePrograms"=-

View File

@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Start]
"AllAppsViewMode"=dword:00000002
[hkey_users\default\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoStartMenuMorePrograms"=-

Binary file not shown.

View File

@@ -19,15 +19,17 @@ if exist "%wtDefaultPath%" (
set "wtPath="
)
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 ""%~dp0Win11Debloat.ps1""' -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
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
) else (
echo Windows Terminal not found. Using default PowerShell instead...
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 ""%~dp0Win11Debloat.ps1""' -Verb RunAs}" >> "%logFile%" || call :Error "PowerShell command failed"
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
)

View File

@@ -11,18 +11,7 @@
Topmost="False"
ShowInTaskbar="False">
<Border BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="8"
Background="{DynamicResource CardBgColor}"
Margin="25">
<Border.Effect>
<DropShadowEffect Color="Black"
Opacity="0.15"
BlurRadius="20"
ShadowDepth="0"
Direction="0"/>
</Border.Effect>
<Border Style="{DynamicResource ModalCardBorderStyle}">
<Grid Margin="0">
<Grid.RowDefinitions>
@@ -32,13 +21,9 @@
</Grid.RowDefinitions>
<!-- Title Bar -->
<Grid Grid.Row="0" x:Name="TitleBar" Height="48" Background="Transparent">
<Grid Grid.Row="0" x:Name="TitleBar" Style="{DynamicResource ModalTitleBarStyle}">
<TextBlock Text="About Win11Debloat"
Foreground="{DynamicResource FgColor}"
FontSize="18"
FontWeight="SemiBold"
VerticalAlignment="Center"
Margin="20,0,0,0"/>
Style="{DynamicResource ModalTitleTextStyle}"/>
</Grid>
<!-- Message Content -->
@@ -50,7 +35,7 @@
</Grid.RowDefinitions>
<!-- Message Text -->
<Grid Grid.Row="0" Margin="24,12,24,20">
<Grid Grid.Row="0" Margin="20,4,20,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
@@ -62,43 +47,31 @@
</Grid.RowDefinitions>
<!-- Version -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="Version:"
FontSize="14"
Foreground="{DynamicResource FgColor}"
FontWeight="SemiBold"
Margin="0,0,16,8"/>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="Version:"
Style="{DynamicResource ModalInfoLabelTextStyle}"/>
<TextBlock x:Name="VersionText"
Grid.Row="0" Grid.Column="1"
Text="0.0.0"
FontSize="14"
Foreground="{DynamicResource FgColor}"
Margin="0,0,0,8"/>
Text="0.0.0"
Style="{DynamicResource ModalInfoValueTextStyle}"/>
<!-- Author -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="Author:"
FontSize="14"
Foreground="{DynamicResource FgColor}"
FontWeight="SemiBold"
Margin="0,0,16,8"/>
<TextBlock Grid.Row="1" Grid.Column="0"
Text="Author:"
Style="{DynamicResource ModalInfoLabelTextStyle}"/>
<TextBlock Grid.Row="1" Grid.Column="1"
Text="Raphire"
FontSize="14"
Foreground="{DynamicResource FgColor}"
Margin="0,0,0,8"/>
Text="Raphire"
Style="{DynamicResource ModalInfoValueTextStyle}"/>
<!-- Project Link -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Project:"
FontSize="14"
Foreground="{DynamicResource FgColor}"
FontWeight="SemiBold"
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Project:"
Style="{DynamicResource ModalInfoLabelTextStyle}"
Margin="0,0,16,0"/>
<TextBlock x:Name="ProjectLink"
Grid.Row="2" Grid.Column="1"
Text="https://github.com/Raphire/Win11Debloat"
FontSize="14"
FontSize="13"
Style="{DynamicResource HyperlinkStyle}"
Margin="0,0,0,0"/>
</Grid>
@@ -106,15 +79,15 @@
<!-- Separator -->
<Border Grid.Row="1"
Height="1"
Background="{DynamicResource BorderColor}"
Margin="10,0"/>
Background="{DynamicResource AppBorderColor}"
Margin="20,0"/>
<!-- Content -->
<StackPanel Grid.Row="2" Margin="24,20">
<StackPanel Grid.Row="2" Margin="20,16,20,16">
<!-- Donation Message -->
<TextBlock Text="Win11Debloat is a passion project that I maintain in my free time. If you've found this tool useful, please consider making a small donation to support its development. I really appreciate it!"
FontSize="14"
Foreground="{DynamicResource FgColor}"
Foreground="{DynamicResource AppFgColor}"
TextWrapping="Wrap"
Margin="0,0,0,15"/>
@@ -134,7 +107,7 @@
FontWeight="SemiBold"
FontFamily="Segoe Fluent Icons"
Text="&#xEB52;"
Foreground="{DynamicResource CloseHover}"
Foreground="{DynamicResource TitleBarCloseHoverColor}"
Margin="0,0,8,0"/>
<TextBlock x:Name="KofiLink"
@@ -150,20 +123,18 @@
</Grid>
<!-- Button Panel -->
<Border Grid.Row="2"
Background="{DynamicResource BgColor}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="0,1,0,0"
Padding="16,12"
CornerRadius="0,0,8,8">
<StackPanel x:Name="ButtonPanel"
Orientation="Horizontal"
HorizontalAlignment="Right">
<Button x:Name="CloseButton"
Content="Close"
Height="32" MinWidth="80" Margin="4,0"
Style="{DynamicResource SecondaryButtonStyle}"/>
</StackPanel>
<Border Grid.Row="2" Style="{DynamicResource ModalFooterBorderStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button x:Name="CloseButton"
Grid.Column="1"
Content="Close"
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"/>
</Grid>
</Border>
</Grid>
</Border>

View File

@@ -7,12 +7,12 @@
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Foreground="{DynamicResource FgColor}">
Foreground="{DynamicResource AppFgColor}">
<Window.Resources>
<!-- Title Bar Button Style -->
<Style x:Key="TitleBarButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
@@ -31,17 +31,17 @@
<Style x:Key="CloseButton" TargetType="Button" BasedOn="{StaticResource TitleBarButton}">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource CloseHover}"/>
<Setter Property="Background" Value="{DynamicResource TitleBarCloseHoverColor}"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Border BorderBrush="{DynamicResource BorderColor}"
<Border BorderBrush="{DynamicResource AppBorderColor}"
BorderThickness="1"
CornerRadius="8"
Background="{DynamicResource BgColor}"
Background="{DynamicResource AppBgColor}"
Margin="25">
<Border.Effect>
<DropShadowEffect Color="Black"
@@ -58,7 +58,7 @@
<!-- Custom Title Bar -->
<Grid Grid.Row="0" x:Name="TitleBar">
<Border Background="{DynamicResource BgColor}" CornerRadius="8,8,0,0">
<Border Background="{DynamicResource AppBgColor}" CornerRadius="8,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -66,7 +66,7 @@
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Win11Debloat Application Selection"
Foreground="{DynamicResource FgColor}"
Foreground="{DynamicResource AppFgColor}"
VerticalAlignment="Center"
Margin="12,0,0,0"
FontSize="12"/>
@@ -90,27 +90,27 @@
<TextBlock Grid.Row="0"
Text="Check apps that you wish to remove, uncheck apps that you wish to keep"
TextWrapping="Wrap"
Foreground="{DynamicResource FgColor}"
Foreground="{DynamicResource AppFgColor}"
Margin="0,0,0,10"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox x:Name="CheckAllBox" Content="Check/Uncheck all" Margin="8,0,15,0" Foreground="{DynamicResource FgColor}" AutomationProperties.Name="Check or Uncheck all"/>
<CheckBox x:Name="CheckAllBox" Content="Check/Uncheck all" Margin="8,0,15,0" Foreground="{DynamicResource AppFgColor}" AutomationProperties.Name="Check or Uncheck all"/>
</StackPanel>
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderColor}" CornerRadius="4" BorderThickness="1" Margin="0,0,0,10" Background="{DynamicResource CardBgColor}">
<Border Grid.Row="2" BorderBrush="{DynamicResource AppBorderColor}" CornerRadius="4" BorderThickness="1" Margin="0,0,0,10" Background="{DynamicResource CardBgColor}">
<Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="AppsPanel" Margin="5"/>
</ScrollViewer>
<Border x:Name="LoadingAppsIndicator" CornerRadius="4" Background="{DynamicResource ScrollBarThumbColor}" Opacity="0.8" Visibility="Collapsed">
<TextBlock Text="Loading apps..." FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource FgColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Text="Loading apps..." FontSize="16" FontWeight="SemiBold" Foreground="{DynamicResource AppFgColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</Grid>
</Border>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Grid Margin="0,8,10,10">
<CheckBox x:Name="OnlyInstalledBox" Content="Only show installed apps" Foreground="{DynamicResource FgColor}" AutomationProperties.Name="Only show installed apps"/>
<CheckBox x:Name="OnlyInstalledBox" Content="Only show installed apps" Foreground="{DynamicResource AppFgColor}" AutomationProperties.Name="Only show installed apps"/>
</Grid>
<Button x:Name="ConfirmBtn" Width="80" Height="32" Margin="0,0,10,0" Content="Confirm" Style="{DynamicResource PrimaryButtonStyle}" AutomationProperties.Name="Confirm"/>
<Button x:Name="CancelBtn" Width="80" Height="32" Content="Cancel" Style="{DynamicResource SecondaryButtonStyle}" IsCancel="True" AutomationProperties.Name="Cancel"/>

View File

@@ -11,18 +11,7 @@
Topmost="False"
ShowInTaskbar="False">
<Border BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="8"
Background="{DynamicResource CardBgColor}"
Margin="25">
<Border.Effect>
<DropShadowEffect Color="Black"
Opacity="0.15"
BlurRadius="20"
ShadowDepth="0"
Direction="0"/>
</Border.Effect>
<Border Style="{DynamicResource ModalCardBorderStyle}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@@ -47,10 +36,10 @@
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource CloseHover}"/>
<Setter Property="Background" Value="{DynamicResource TitleBarCloseHoverColor}"/>
</Trigger>
</Style.Triggers>
</Style>
@@ -67,7 +56,7 @@
Text="&#xE895;"
FontFamily="Segoe Fluent Icons"
FontSize="36"
Foreground="{DynamicResource ButtonBg}"
Foreground="{DynamicResource ButtonBgColor}"
HorizontalAlignment="Center"
Margin="0,0,0,16"
RenderTransformOrigin="0.5,0.5">
@@ -100,11 +89,7 @@
MaxWidth="430"/>
</StackPanel>
<Border Background="{DynamicResource BgColor}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="0,1,0,0"
Padding="16,12"
CornerRadius="0,0,8,8">
<Border Style="{DynamicResource ModalFooterBorderStyle}">
<StackPanel>
<!-- Progress bar -->
@@ -117,7 +102,7 @@
<TextBlock x:Name="ApplyStepCounter"
Text="Step 0 of 0"
FontSize="12"
Foreground="{DynamicResource FgColor}"
Foreground="{DynamicResource AppFgColor}"
HorizontalAlignment="Right"
Opacity="0.8"/>
</StackPanel>
@@ -132,7 +117,7 @@
Text="&#xE73E;"
FontFamily="Segoe Fluent Icons"
FontSize="40"
Foreground="{DynamicResource ButtonBg}"
Foreground="{DynamicResource ButtonBgColor}"
HorizontalAlignment="Center"
Margin="0,0,0,12"/>
@@ -149,7 +134,7 @@
<!-- Reboot required section -->
<Border x:Name="ApplyRebootPanel"
Visibility="Collapsed"
BorderBrush="{DynamicResource BorderColor}"
BorderBrush="{DynamicResource AppBorderColor}"
HorizontalAlignment="Center"
BorderThickness="0,1,0,1"
Padding="24,12,24,14">
@@ -164,7 +149,7 @@
<TextBlock Text="A reboot is required for these changes to take effect:"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource FgColor}"
Foreground="{DynamicResource AppFgColor}"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel x:Name="ApplyRebootList" Margin="22,0,0,0"/>
@@ -172,29 +157,22 @@
</Border>
<!-- Button Panel -->
<Border Background="{DynamicResource BgColor}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="0,1,0,0"
Padding="16,12"
CornerRadius="0,0,8,8">
<StackPanel x:Name="ButtonPanel"
Orientation="Horizontal"
HorizontalAlignment="Center">
<Button x:Name="ApplyKofiBtn" Width="210" Height="32"
Style="{DynamicResource SecondaryButtonStyle}"
Margin="0,0,12,0"
<Border Style="{DynamicResource ModalFooterBorderStyle}">
<UniformGrid x:Name="ButtonPanel" Rows="1">
<Button x:Name="ApplyKofiBtn"
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="Support the creator" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,1"/>
</StackPanel>
</Button>
<Button x:Name="ApplyCloseBtn" Width="100" Height="32"
Style="{DynamicResource PrimaryButtonStyle}"
<Button x:Name="ApplyCloseBtn"
Style="{DynamicResource ModalPrimaryStretchedButtonStyle}"
AutomationProperties.Name="Close">
<TextBlock Text="Close" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,1"/>
</Button>
</StackPanel>
</UniformGrid>
</Border>
</StackPanel>
</StackPanel>

View File

@@ -26,7 +26,7 @@
Text="View the selected changes here"
TextWrapping="Wrap"
MaxWidth="260"
Foreground="{DynamicResource FgColor}"/>
Foreground="{DynamicResource AppFgColor}"/>
</Border>
</Grid>

View File

@@ -12,18 +12,7 @@
Topmost="False"
ShowInTaskbar="False">
<Border BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="8"
Background="{DynamicResource CardBgColor}"
Margin="25">
<Border.Effect>
<DropShadowEffect Color="Black"
Opacity="0.15"
BlurRadius="20"
ShadowDepth="0"
Direction="0"/>
</Border.Effect>
<Border Style="{DynamicResource ModalCardBorderStyle}">
<Grid>
<Grid.RowDefinitions>
@@ -33,23 +22,19 @@
</Grid.RowDefinitions>
<!-- Title Bar -->
<Grid Grid.Row="0" x:Name="TitleBar" Height="40" Background="Transparent">
<Grid Grid.Row="0" x:Name="TitleBar" Style="{DynamicResource ModalTitleBarStyle}">
<TextBlock x:Name="TitleText"
Foreground="{DynamicResource FgColor}"
FontSize="16"
FontWeight="SemiBold"
VerticalAlignment="Center"
Margin="16,0,0,0"/>
Style="{DynamicResource ModalTitleTextStyle}"/>
</Grid>
<!-- Content -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="0,0,8,0">
<StackPanel x:Name="ContentPanel" Margin="20,12,20,9">
<StackPanel x:Name="ContentPanel" Margin="20,4">
<TextBlock x:Name="PromptText"
TextWrapping="Wrap"
FontSize="14"
LineHeight="20"
Foreground="{DynamicResource FgColor}"
Foreground="{DynamicResource AppFgColor}"
Margin="0,0,0,14"/>
<!-- Checkboxes are added dynamically at runtime -->
<StackPanel x:Name="CheckboxPanel"/>
@@ -57,22 +42,15 @@
</ScrollViewer>
<!-- Button Footer -->
<Border Grid.Row="2"
Background="{DynamicResource BgColor}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="0,1,0,0"
Padding="16,12"
CornerRadius="0,0,8,8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Border Grid.Row="2" Style="{DynamicResource ModalFooterBorderStyle}">
<UniformGrid Rows="1">
<Button x:Name="OkButton"
Content="OK"
Height="32" MinWidth="80" Margin="4,0"
Style="{DynamicResource PrimaryButtonStyle}"/>
Style="{DynamicResource ModalPrimaryStretchedButtonStyle}"/>
<Button x:Name="CancelButton"
Content="Cancel"
Height="32" MinWidth="80" Margin="4,0"
Style="{DynamicResource SecondaryButtonStyle}"/>
</StackPanel>
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"/>
</UniformGrid>
</Border>
</Grid>
</Border>

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
Topmost="False"
ShowInTaskbar="False">
<Border BorderBrush="{DynamicResource BorderColor}"
<Border BorderBrush="{DynamicResource AppBorderColor}"
BorderThickness="1"
CornerRadius="8"
Background="{DynamicResource CardBgColor}"
@@ -33,66 +33,65 @@
</Grid.RowDefinitions>
<!-- Title Bar -->
<Grid Grid.Row="0" x:Name="TitleBar" Height="40" Background="Transparent">
<Grid Grid.Row="0" x:Name="TitleBar" Style="{DynamicResource ModalTitleBarStyle}">
<TextBlock x:Name="TitleText"
Text="Message"
Foreground="{DynamicResource FgColor}"
FontSize="16"
FontWeight="SemiBold"
VerticalAlignment="Center"
Margin="16,0,0,0"/>
Style="{DynamicResource ModalTitleTextStyle}"/>
</Grid>
<!-- Message Content -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" MaxHeight="500" Padding="0" Margin="20,12,1,20">
<Grid Margin="0,0,20,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid Grid.Row="1" Margin="20,12,1,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Icon -->
<TextBlock x:Name="IconText"
Grid.Column="0"
FontFamily="Segoe Fluent Icons"
FontSize="24"
Foreground="{DynamicResource FgColor}"
VerticalAlignment="Center"
Margin="4,2,14,0"
Visibility="Collapsed"/>
<!-- Icon -->
<TextBlock x:Name="IconText"
Grid.Column="0"
FontFamily="Segoe Fluent Icons"
FontSize="24"
Foreground="{DynamicResource AppFgColor}"
VerticalAlignment="Center"
Margin="4,0,14,0"
Visibility="Collapsed"/>
<!-- Message Text -->
<!-- Message Text -->
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" MaxHeight="300" Padding="0">
<TextBlock x:Name="MessageText"
Grid.Column="1"
Text="Message content goes here"
TextWrapping="Wrap"
FontSize="14"
LineHeight="20"
Foreground="{DynamicResource FgColor}"
VerticalAlignment="Center"/>
</Grid>
</ScrollViewer>
Foreground="{DynamicResource AppFgColor}"
VerticalAlignment="Center"
Margin="0,0,20,0"/>
</ScrollViewer>
</Grid>
<!-- Button Panel -->
<Border Grid.Row="2"
Background="{DynamicResource BgColor}"
BorderBrush="{DynamicResource BorderColor}"
Background="{DynamicResource AppBgColor}"
BorderBrush="{DynamicResource AppBorderColor}"
BorderThickness="0,1,0,0"
Padding="16,12"
CornerRadius="0,0,8,8">
<StackPanel x:Name="ButtonPanel"
Orientation="Horizontal"
HorizontalAlignment="Right">
<Button x:Name="Button1"
Content="OK"
Height="32" MinWidth="80" Margin="4,0"
Style="{DynamicResource PrimaryButtonStyle}"/>
<Button x:Name="Button2"
Content="Cancel"
Height="32" MinWidth="80" Margin="4,0"
Style="{DynamicResource SecondaryButtonStyle}"
<Grid x:Name="ButtonPanel">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button x:Name="Button1"
Grid.Column="0"
Content="OK"
Style="{DynamicResource ModalPrimaryStretchedButtonStyle}"/>
<Button x:Name="Button2"
Grid.Column="1"
Content="Cancel"
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"
Visibility="Collapsed"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>

View File

@@ -0,0 +1,427 @@
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Restore Backup"
Width="500"
SizeToContent="Height"
MaxHeight="560"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Topmost="False"
ShowInTaskbar="False">
<Window.Resources>
<Style x:Key="RestoreOptionTileStyle" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,10"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Left" VerticalAlignment="Center" Margin="0,0,0,1"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonDisabledColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextDisabledColor}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonHoverColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderColor}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonPressedColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderColor}"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Border Style="{DynamicResource ModalCardBorderStyle}">
<Grid Margin="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" x:Name="TitleBar" Style="{DynamicResource ModalTitleBarStyle}">
<TextBlock x:Name="TitleText"
Text="Restore Backup"
Style="{DynamicResource ModalTitleTextStyle}"/>
</Grid>
<Button x:Name="CloseBtn" Grid.Row="0"
HorizontalAlignment="Right" VerticalAlignment="Top"
Width="36" Height="32"
BorderThickness="0"
Cursor="Hand"
ToolTip="Close"
AutomationProperties.Name="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" BorderThickness="0" CornerRadius="0,8,0,0">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Button.Template>
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource TitleBarCloseHoverColor}"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
<TextBlock Text="&#xE8BB;" FontFamily="Segoe Fluent Icons" FontSize="10"/>
</Button>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0">
<Grid Margin="20,4,20,16">
<TabControl x:Name="RestoreModeTabs"
SelectedIndex="0"
Background="Transparent"
BorderThickness="0"
Padding="0">
<TabControl.ItemContainerStyle>
<Style TargetType="TabItem">
<Setter Property="Visibility" Value="Collapsed"/>
<Setter Property="Height" Value="0"/>
</Style>
</TabControl.ItemContainerStyle>
<TabItem x:Name="SelectTypeTab" Header="SelectType">
<Grid x:Name="SelectTypePanel">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
TextWrapping="Wrap"
FontSize="14"
LineHeight="20"
Foreground="{DynamicResource AppFgColor}"
Text="Choose what changes you want to restore."/>
<Button x:Name="ChooseRegistryBtn"
Grid.Row="1"
Margin="0,14,0,0"
Style="{StaticResource RestoreOptionTileStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="&#xEF58;"
FontFamily="Segoe Fluent Icons"
FontSize="24"
VerticalAlignment="Center"
Margin="14,0,14,0"/>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="Restore Registry Backup"
FontSize="16"
FontWeight="SemiBold"/>
<TextBlock Text="Restore system registry configuration from a backup"
FontSize="12"
Opacity="0.75"
TextWrapping="Wrap"
Margin="0,2,0,0"/>
</StackPanel>
</Grid>
</Button>
<Button x:Name="ChooseStartMenuBtn"
Grid.Row="2"
Margin="0,10,0,0"
Style="{StaticResource RestoreOptionTileStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="&#xE8FC;"
FontFamily="Segoe Fluent Icons"
FontSize="24"
VerticalAlignment="Center"
Margin="14,0,14,0"/>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Text="Restore Start Menu Backup"
FontSize="16"
FontWeight="SemiBold"/>
<TextBlock Text="Restore the Start Menu pinned apps layout from a backup"
FontSize="12"
Opacity="0.75"
TextWrapping="Wrap"
Margin="0,2,0,0"/>
</StackPanel>
</Grid>
</Button>
</Grid>
</TabItem>
<TabItem x:Name="RegistryTab" Header="Registry">
<Grid x:Name="RegistryPanel">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid x:Name="IntroInfoPanel" Grid.Row="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
TextWrapping="Wrap"
FontSize="14"
LineHeight="20"
Foreground="{DynamicResource AppFgColor}"
Text="This will restore any system registry changes made by Win11Debloat to the previous state. You can review the changes after selecting a backup file. Removed applications will need to be reinstalled manually."/>
<TextBlock Grid.Row="1"
Margin="0,12,0,2"
TextWrapping="Wrap"
FontSize="13"
Foreground="{DynamicResource AppFgColor}"
Opacity="0.75"
Text="Warning: Only use backup files generated by Win11Debloat."/>
</Grid>
<Grid x:Name="OverviewPanel" Grid.Row="1" Margin="0" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="File:" Style="{DynamicResource ModalInfoLabelTextStyle}"/>
<TextBlock x:Name="BackupFileText" Grid.Row="0" Grid.Column="1" Text="Not selected" Style="{DynamicResource ModalInfoValueTextStyle}" TextWrapping="Wrap" Margin="0,0,0,8"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Created:" Style="{DynamicResource ModalInfoLabelTextStyle}"/>
<TextBlock x:Name="BackupCreatedText" Grid.Row="1" Grid.Column="1" Text="N/A" Style="{DynamicResource ModalInfoValueTextStyle}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Target:" Style="{DynamicResource ModalInfoLabelTextStyle}" Margin="0,0,16,0"/>
<TextBlock x:Name="BackupTargetText" Grid.Row="2" Grid.Column="1" Text="N/A" Style="{DynamicResource ModalInfoValueTextStyle}" Margin="0"/>
</Grid>
<Border Grid.Row="1" Height="1" Background="{DynamicResource AppBorderColor}" Margin="0,0,0,12"/>
<Grid x:Name="OverviewFeaturesSection" Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="The following changes will be reverted:"
FontSize="13"
Foreground="{DynamicResource AppFgColor}"
FontWeight="SemiBold"
Margin="0,0,0,8"/>
<ItemsControl x:Name="FeaturesItemsControl" Grid.Row="1">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" IsItemsHost="True"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource AppFgColor}" TextWrapping="Wrap" Margin="0,0,0,6"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<TextBlock x:Name="OverviewSummaryText"
Grid.Row="2"
FontSize="13"
Foreground="{DynamicResource AppFgColor}"
TextWrapping="Wrap"
Visibility="Collapsed"
Text="This will restore the Start Menu pinned apps layout for the current user."/>
<Border x:Name="ReappliedSeparator" Grid.Row="3" Height="1" Background="{DynamicResource AppBorderColor}" Margin="0,12,0,12" Visibility="Collapsed"/>
<Grid x:Name="ReappliedPanel" Grid.Row="4" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="The following changes will be re-applied:"
FontSize="13"
Foreground="{DynamicResource AppFgColor}"
FontWeight="SemiBold"
Margin="0,0,0,8"/>
<ItemsControl x:Name="ReappliedFeaturesItemsControl" Grid.Row="1">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" IsItemsHost="True"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource AppFgColor}" TextWrapping="Wrap" Margin="0,0,0,6"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<Border x:Name="NonRevertibleSeparator" Grid.Row="5" Height="1" Background="{DynamicResource AppBorderColor}" Margin="0,12,0,12" Visibility="Collapsed"/>
<Grid x:Name="NonRevertiblePanel" Grid.Row="6" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="The following changes won't be reverted:"
FontSize="13"
Foreground="{DynamicResource AppFgColor}"
FontWeight="SemiBold"
Margin="0,0,0,8"/>
<ItemsControl x:Name="NonRevertibleFeaturesItemsControl" Grid.Row="1">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" IsItemsHost="True"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource AppFgColor}" Opacity="0.85" TextWrapping="Wrap" Margin="0,0,0,6"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock x:Name="NonRevertibleWikiLink"
Grid.Row="2"
Margin="0,8,0,0"
Text="Visit the wiki for more information"
FontSize="13"
FontWeight="SemiBold"
Style="{DynamicResource HyperlinkStyle}"
TextWrapping="Wrap"/>
</Grid>
</Grid>
</Grid>
</TabItem>
<TabItem x:Name="StartMenuTab" Header="StartMenu">
<Grid x:Name="StartMenuPanel">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid x:Name="StartMenuIntroPanel" Grid.Row="0" Visibility="Visible">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
TextWrapping="Wrap"
FontSize="14"
LineHeight="20"
Margin="0,0,0,12"
Foreground="{DynamicResource AppFgColor}"
Text="This will restore the Start Menu pinned apps layout for the selected user(s) using a backup that is automatically created by Win11Debloat. Manually created backups can also be used."/>
<ComboBox x:Name="StartMenuScopeCombo"
Grid.Row="1"
SelectedIndex="0"
Margin="0,0,0,2"
MinWidth="408"
HorizontalAlignment="Left">
<ComboBoxItem Content="Current user" Tag="CurrentUser"/>
<ComboBoxItem Content="All users" Tag="AllUsers"/>
</ComboBox>
<CheckBox x:Name="StartMenuAutoBackupCheck"
Grid.Row="2"
Content="Automatically find Start Menu backup"
IsChecked="True"
FontSize="12"
Margin="0,10,0,0"
Foreground="{DynamicResource AppFgColor}"
Opacity="0.85"/>
</Grid>
</Grid>
</TabItem>
</TabControl>
</Grid>
</ScrollViewer>
<Border Grid.Row="3" Style="{DynamicResource ModalFooterBorderStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button x:Name="PrimaryActionBtn"
Grid.Column="0"
Content="Next"
Visibility="Collapsed"
Style="{DynamicResource ModalPrimaryStretchedButtonStyle}"/>
<Button x:Name="BackBtn"
Grid.Column="1"
Content="Back"
Visibility="Collapsed"
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -3,9 +3,9 @@
<!-- Primary Button Style -->
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource ButtonBg}"/>
<Setter Property="Background" Value="{DynamicResource ButtonBgColor}"/>
<Setter Property="Foreground" Value="white"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBgColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FontSize" Value="14"/>
@@ -18,32 +18,32 @@
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,0,0,1"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{DynamicResource ButtonDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonDisabled}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonTextDisabled}"/>
<Setter Property="Background" Value="{DynamicResource ButtonDisabledColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonDisabledColor}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonTextDisabledColor}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ButtonHover}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonHover}"/>
<Setter Property="Background" Value="{DynamicResource ButtonHoverColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonHoverColor}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{DynamicResource ButtonPressed}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonPressed}"/>
<Setter Property="Background" Value="{DynamicResource ButtonPressedColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonPressedColor}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Secondary Button Style -->
<Style x:Key="SecondaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBg}"/>
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="0"/>
@@ -64,16 +64,16 @@
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextDisabled}"/>
<Setter Property="Background" Value="{DynamicResource SecondaryButtonDisabledColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextDisabledColor}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonHover}"/>
<Setter Property="Background" Value="{DynamicResource SecondaryButtonHoverColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderColor}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonPressed}"/>
<Setter Property="Background" Value="{DynamicResource SecondaryButtonPressedColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderColor}"/>
</Trigger>
</Style.Triggers>
@@ -81,11 +81,11 @@
<!-- Hyperlink Style -->
<Style x:Key="HyperlinkStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource ButtonBg}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonBgColor}"/>
<Setter Property="Cursor" Value="Hand"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{DynamicResource ButtonHover}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonHoverColor}"/>
</Trigger>
</Style.Triggers>
</Style>
@@ -93,7 +93,7 @@
<!-- ProgressBar Style -->
<Style x:Key="ApplyProgressBarStyle" TargetType="ProgressBar">
<Setter Property="Background" Value="{DynamicResource ButtonBorderColor}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonBg}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonBgColor}"/>
<Setter Property="Height" Value="6"/>
<Setter Property="Template">
<Setter.Value>
@@ -109,9 +109,9 @@
<!-- Modal Title Style -->
<Style x:Key="ModalTitleStyle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
@@ -119,16 +119,268 @@
<!-- Modal Subtext Style -->
<Style x:Key="ModalSubtextStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="Opacity" Value="0.8"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="TextAlignment" Value="Center"/>
</Style>
<!-- Shared modal window shell styles -->
<Style x:Key="ModalCardBorderStyle" TargetType="Border">
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Background" Value="{DynamicResource CardBgColor}"/>
<Setter Property="Margin" Value="25"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="Black"
Opacity="0.15"
BlurRadius="20"
ShadowDepth="0"
Direction="0"/>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ModalTitleBarStyle" TargetType="Grid">
<Setter Property="Height" Value="48"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style x:Key="ModalTitleTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="16,0,0,0"/>
</Style>
<Style x:Key="ModalFooterBorderStyle" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource AppBgColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="Padding" Value="16,12"/>
<Setter Property="CornerRadius" Value="0,0,8,8"/>
</Style>
<Style x:Key="ModalFooterButtonsRightStyle" TargetType="StackPanel">
<Setter Property="Orientation" Value="Horizontal"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
<Style x:Key="ModalPrimaryActionButtonStyle" TargetType="Button" BasedOn="{StaticResource PrimaryButtonStyle}">
<Setter Property="Height" Value="32"/>
<Setter Property="MinWidth" Value="80"/>
<Setter Property="Margin" Value="4,0"/>
</Style>
<Style x:Key="ModalSecondaryActionButtonStyle" TargetType="Button" BasedOn="{StaticResource SecondaryButtonStyle}">
<Setter Property="Height" Value="32"/>
<Setter Property="MinWidth" Value="80"/>
<Setter Property="Margin" Value="4,0"/>
</Style>
<Style x:Key="ModalPrimaryStretchedButtonStyle" TargetType="Button" BasedOn="{StaticResource PrimaryButtonStyle}">
<Setter Property="Height" Value="32"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="Margin" Value="4,0"/>
</Style>
<Style x:Key="ModalSecondaryStretchedButtonStyle" TargetType="Button" BasedOn="{StaticResource SecondaryButtonStyle}">
<Setter Property="Height" Value="32"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="Margin" Value="4,0"/>
</Style>
<Style x:Key="ModalInfoLabelTextStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,0,16,8"/>
</Style>
<Style x:Key="ModalInfoValueTextStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<!-- Shared ComboBox style used across windows -->
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Margin" Value="0,0,0,4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Grid>
<Border x:Name="ItemBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Margin="0,0,0,1"/>
</Border>
<Border x:Name="AccentLine"
Width="3"
Height="15"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"
Background="{DynamicResource ButtonBgColor}"
CornerRadius="1.5"
Margin="0"
Visibility="Collapsed"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="AccentLine" Property="Visibility" Value="Visible"/>
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource ComboBoxItemSelectedColor}"/>
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource ComboBoxItemHoverColor}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{DynamicResource ComboBoxBgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Margin" Value="0,4,0,12"/>
<Setter Property="MinHeight" Value="33"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="ClosedAccentLine" Width="3" Height="18" HorizontalAlignment="Left" VerticalAlignment="Stretch" Background="{DynamicResource ButtonBgColor}" CornerRadius="1.5" Panel.ZIndex="2"/>
<ToggleButton x:Name="ToggleButton" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Focusable="False" IsChecked="{Binding Path=IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" ClickMode="Press">
<ToggleButton.Style>
<Style TargetType="ToggleButton">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<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"
FontSize="10"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,8,0"
Foreground="{DynamicResource AppFgColor}"
RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform>
<RotateTransform Angle="0"/>
</TextBlock.RenderTransform>
</TextBlock>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource ComboBoxHoverColor}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Arrow" Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)" To="180" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Arrow" Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)" To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ToggleButton.Style>
</ToggleButton>
<ContentPresenter x:Name="ContentPresenter"
IsHitTestVisible="False"
Margin="10,0,20,2"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"/>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Fade"
StaysOpen="False"
PlacementTarget="{Binding ElementName=ToggleButton}"
VerticalOffset="1"
HorizontalOffset="0">
<Grid x:Name="DropDown" MinWidth="{TemplateBinding ActualWidth}" MaxHeight="{TemplateBinding MaxDropDownHeight}" Margin="12">
<Border x:Name="DropDownBorder"
Background="{DynamicResource ComboBoxItemBgColor}"
BorderBrush="{DynamicResource AppBorderColor}"
BorderThickness="1"
CornerRadius="4"
Padding="5,4,5,1">
<Border.Effect>
<DropShadowEffect BlurRadius="12" Opacity="0.25" ShadowDepth="4"/>
</Border.Effect>
<ScrollViewer Margin="0,2,0,0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsPresenter Margin="0,0,0,1"/>
</ScrollViewer>
</Border>
</Grid>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="SelectedIndex" Value="0">
<Setter TargetName="ClosedAccentLine" Property="Visibility" Value="Collapsed"/>
</Trigger>
<Trigger Property="SelectedIndex" Value="-1">
<Setter TargetName="ClosedAccentLine" Property="Visibility" Value="Collapsed"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="ClosedAccentLine" Property="Visibility" Value="Collapsed"/>
<Setter Property="Background" Value="{DynamicResource ButtonDisabledColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonTextDisabledColor}"/>
<Setter Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Base CheckBox style used across windows -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="4,2"/>
<Setter Property="Template">
<Setter.Value>
@@ -139,16 +391,16 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border x:Name="CheckBoxBorder" Grid.Column="0" Width="18" Height="18" Background="{DynamicResource CheckBoxBgColor}" BorderBrush="{DynamicResource CheckBoxBorderColor}" BorderThickness="1" CornerRadius="4" Margin="0,0,8,0">
<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="12" Foreground="{DynamicResource ButtonBg}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0">
<TextBlock x:Name="CheckMark" Text="&#xE73E;" FontFamily="Segoe Fluent Icons" 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="&#xE738;" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource ButtonBg}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" Margin="1,1,0,0">
<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.Clip>
<RectangleGeometry x:Name="IndeterminateMarkClip" Rect="0,0,0,16"/>
<RectangleGeometry x:Name="IndeterminateMarkClip" Rect="0,0,0,12"/>
</TextBlock.Clip>
</TextBlock>
</Grid>
@@ -161,8 +413,8 @@
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource CheckBoxHoverColor}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonBg}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonBg}"/>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonBgColor}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonBgColor}"/>
<Setter TargetName="CheckMark" Property="Foreground" Value="White"/>
<Setter TargetName="CheckMark" Property="Opacity" Value="1"/>
<Setter TargetName="IndeterminateMark" Property="Opacity" Value="0"/>
@@ -184,9 +436,8 @@
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsChecked" Value="{x:Null}">
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonBg}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonBg}"/>
<Setter TargetName="CheckBoxBorder" Property="Opacity" Value="0.8"/>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonBgColor}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonBgColor}"/>
<Setter TargetName="IndeterminateMark" Property="Foreground" Value="White"/>
<Setter TargetName="IndeterminateMark" Property="Opacity" Value="1"/>
<Setter TargetName="CheckMark" Property="Opacity" Value="0"/>
@@ -212,8 +463,8 @@
<Setter TargetName="IndeterminateMark" Property="Opacity" Value="0"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonDisabled}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonDisabledColor}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
<MultiTrigger>
@@ -221,16 +472,16 @@
<Condition Property="IsMouseOver" Value="True"/>
<Condition Property="IsChecked" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonHover}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonHover}"/>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonHoverColor}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonHoverColor}"/>
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True"/>
<Condition Property="IsChecked" Value="{x:Null}"/>
</MultiTrigger.Conditions>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonHover}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonHover}"/>
<Setter TargetName="CheckBoxBorder" Property="Background" Value="{DynamicResource ButtonHoverColor}"/>
<Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="{DynamicResource ButtonHoverColor}"/>
<Setter TargetName="CheckBoxBorder" Property="Opacity" Value="0.8"/>
</MultiTrigger>
</ControlTemplate.Triggers>
@@ -294,4 +545,47 @@
</Setter>
</Style>
<!-- TextBox outer border: side/top borders in side color, background with hover/focus states -->
<Style x:Key="TextBoxBorderStyle" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource TextBoxBgColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource TextBoxSideBorderColor}"/>
<Setter Property="BorderThickness" Value="1,1,1,0"/>
<Setter Property="CornerRadius" Value="4"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource TextBoxHoverColor}"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="Background" Value="{DynamicResource TextBoxFocusColor}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- TextBox inner border: bottom accent line that thickens and changes color on focus -->
<Style x:Key="TextBoxBottomBorderStyle" TargetType="Border">
<Setter Property="BorderBrush" Value="{DynamicResource TextBoxBorderColor}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CornerRadius" Value="0,0,4,4"/>
<Style.Triggers>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBgColor}"/>
<Setter Property="BorderThickness" Value="0,0,0,2"/>
<Setter Property="Padding" Value="8,6,8,5"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- TextBox input (transparent, used inside TextBoxBorderStyle) -->
<Style x:Key="TextBoxInputStyle" TargetType="TextBox">
<Setter Property="CaretBrush" Value="{DynamicResource AppFgColor}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Margin" Value="1,0,0,1"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Padding" Value="0"/>
</Style>
</ResourceDictionary>

View File

@@ -1,6 +1,22 @@
<#
.SYNOPSIS
Returns a list of installed apps from winget as structured objects.
# Run winget list and return installed apps.
# Use -NonBlocking to keep the UI responsive (GUI mode) via Invoke-NonBlocking.
.DESCRIPTION
Runs `winget list` and parses the output into PSCustomObject arrays.
Use -NonBlocking to keep the UI responsive in GUI mode; otherwise
runs synchronously with an optional timeout.
.PARAMETER TimeOut
Maximum seconds to wait for winget to complete. Default is 10.
.PARAMETER NonBlocking
When set, runs via Invoke-NonBlocking so the GUI thread stays responsive.
.OUTPUTS
PSCustomObject[] with Name and Id properties. Returns $null on
failure, or an empty array when winget succeeds but lists no apps.
#>
function GetInstalledAppsViaWinget {
param (
[int]$TimeOut = 10,
@@ -11,13 +27,76 @@ function GetInstalledAppsViaWinget {
$fetchBlock = {
param($timeOut)
$job = Start-Job { return winget list --accept-source-agreements --disable-interactivity }
$job = Start-Job {
$rawOutput = $null
try {
$originalEncoding = [Console]::OutputEncoding
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
try {
$rawOutput = winget list --accept-source-agreements --disable-interactivity
}
finally {
[Console]::OutputEncoding = $originalEncoding
}
return $rawOutput
}
catch {
return $null
}
}
$done = $job | Wait-Job -Timeout $timeOut
if ($done) {
$result = Receive-Job -Job $job
Remove-Job -Job $job -ErrorAction SilentlyContinue
return $result
if (-not $result) { return $null }
# winget list outputs:
# [progress line] / [blank] / header / --- separator / data rows
$textOutput = $result -join "`n"
$lines = $textOutput -split "`r`n|`n"
# Find the separator line to know where data starts
$dataStart = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^-{3,}') {
$dataStart = $i + 1
break
}
}
if ($dataStart -lt 0 -or $dataStart -ge $lines.Count) { return @() }
$apps = [System.Collections.Generic.List[object]]::new()
for ($i = $dataStart; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line.Trim() -eq '') { continue }
try {
# Split on 2+ spaces; extract Name and Id columns.
$fields = [regex]::Split($line.Trim(), '\s{2,}')
if ($fields.Count -lt 2) { continue }
$name = $fields[0].Trim()
$id = $fields[1].Trim()
if (-not $id) { continue }
$null = $apps.Add([PSCustomObject]@{
Name = $name
Id = $id
})
}
catch {
# Skip lines that can't be parsed
}
}
return @($apps)
}
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
return $null
}

View File

@@ -1,135 +1,392 @@
# Removes apps specified during function call based on the target scope.
<#
.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
)
# Determine target from script-level params, defaulting to AllUsers
$targetUser = GetTargetUserForAppRemoval
if ($script:Params.ContainsKey("WhatIf")) {
foreach ($app in $appslist) {
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
}
$appIndex = 0
Write-Host ""
return
}
$targetUser = GetTargetUserForAppRemoval
$appCount = @($appsList).Count
$appIndex = 0
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
$edgeUninstallSucceeded = $false
$edgeScheduledTaskAdded = $false
$edgeAppsInList = @()
$wingetRemovedApps = @()
Foreach ($app in $appsList) {
if ($script:CancelRequested) {
return
}
if ($script:CancelRequested) { return }
$appIndex++
# Update step name and sub-progress to show which app is being removed (only for bulk removal)
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
}
Write-Host "Attempting to remove $app..."
# Use WinGet only to remove OneDrive and Edge
if (($app -eq "Microsoft.OneDrive") -or ($edgeIds -contains $app)) {
if ($script:WingetInstalled -eq $false) {
Write-Host "WinGet is either not installed or is outdated, $app could not be removed" -ForegroundColor Red
continue
}
$isEdgeId = $edgeIds -contains $app
$appName = if ($isEdgeId) { 'Microsoft_Edge' } else { $app -replace '\.', '_' }
# Uninstall app via WinGet, or create a scheduled task to uninstall it later
if ($script:Params.ContainsKey("User")) {
if (-not ($isEdgeId -and $edgeScheduledTaskAdded)) {
ImportRegistryFile "Adding scheduled task to uninstall $app for user $(GetUserName)..." "Uninstall_$($appName).reg"
if ($isEdgeId) { $edgeScheduledTaskAdded = $true }
}
}
elseif ($script:Params.ContainsKey("Sysprep")) {
if (-not ($isEdgeId -and $edgeScheduledTaskAdded)) {
ImportRegistryFile "Adding scheduled task to uninstall $app after for new users..." "Uninstall_$($appName).reg"
if ($isEdgeId) { $edgeScheduledTaskAdded = $true }
}
}
else {
# Uninstall app via WinGet
$wingetOutput = Invoke-NonBlocking -ScriptBlock {
param($appId)
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
} -ArgumentList $app
$wingetFailed = Select-String -InputObject $wingetOutput -Pattern "Uninstall failed with exit code|No installed package found matching input criteria|No package found matching input criteria" -SimpleMatch:$false
if ($isEdgeId) {
if (-not $wingetFailed) {
$edgeUninstallSucceeded = $true
}
# Prompt immediately after the final selected Edge ID attempt (if all attempts failed)
$hasRemainingEdgeIds = $false
if ($appIndex -lt $appCount) {
$remainingApps = @($appsList)[($appIndex)..($appCount - 1)]
$hasRemainingEdgeIds = @($remainingApps | Where-Object { $edgeIds -contains $_ }).Count -gt 0
}
if (-not $hasRemainingEdgeIds -and -not $edgeUninstallSucceeded) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
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
}
}
}
}
# 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
}
# Use Remove-AppxPackage to remove all other apps
$appPattern = '*' + $app + '*'
Write-Host "Removing $app"
try {
switch ($targetUser) {
"AllUsers" {
# Remove installed app for all existing users, and from OS image
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" {
# Remove installed app for current user only
Invoke-NonBlocking -ScriptBlock {
param($pattern)
Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue
} -ArgumentList $appPattern
}
default {
# Target is a specific username - remove app for that user only
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)
}
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
}
}
catch {
if ($DebugPreference -ne "SilentlyContinue") {
Write-Host "Something went wrong while trying to remove $app" -ForegroundColor Yellow
Write-Host $psitem.Exception.StackTrace -ForegroundColor Gray
# 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
}
}

View File

@@ -0,0 +1,43 @@
<#
.SYNOPSIS
Checks whether an app ID appears in a parsed winget installed list.
.DESCRIPTION
Tries an exact match against the .Id property first. When that
fails, falls back to a substring search guarded by a word-boundary
regex so that short IDs don't accidentally match longer ones
(e.g. 'Microsoft.Edge' will not match 'Microsoft.EdgeDev').
.PARAMETER appId
The identifier to search for (e.g. 'Microsoft.Copilot').
.PARAMETER InstalledList
An array of PSCustomObject from GetInstalledAppsViaWinget.
#>
function Test-AppInWingetList {
param(
[string]$appId,
[object[]]$InstalledList
)
if (-not $InstalledList) { return $false }
# Normalize to array
$list = @($InstalledList)
# Exact match first (fast and precise)
if ($list.Id -contains $appId) {
return $true
}
# Substring fallback with word-boundary guard
$boundaryPattern = '(?<![a-zA-Z0-9])' + [regex]::Escape($appId) + '(?![a-zA-Z0-9])'
foreach ($entry in $list) {
if ($entry.Id -like "*$appId*" -and $entry.Id -match $boundaryPattern) {
return $true
}
}
return $false
}

View File

@@ -1,4 +1,17 @@
# Prints all pending changes that will be made by the script
<#
.SYNOPSIS
Prints a summary of all pending changes to the console for the user to review.
.DESCRIPTION
Iterates over every non-control parameter in $script:Params and emits a
human-readable line for each change that will be applied. For the
'RemoveApps' parameter the list of targeted app names is displayed
inline. Feature labels are resolved from Features.json when available;
otherwise the raw parameter name is used as a fallback.
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 {
Write-Output "Win11Debloat will make the following changes:"
@@ -31,29 +44,9 @@ function PrintPendingChanges {
Write-Host $appsList -ForegroundColor DarkGray
continue
}
'RemoveAppsCustom' {
$appsList = LoadAppsFromFile $script:CustomAppsListFilePath
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Output ""
continue
}
Write-Output "- Remove $($appsList.Count) apps:"
Write-Host $appsList -ForegroundColor DarkGray
continue
}
default {
if ($script:Features -and $script:Features.ContainsKey($parameterName)) {
$action = $script:Features[$parameterName].Action
$message = $script:Features[$parameterName].Label
Write-Output "- $action $message"
}
else {
# Fallback: show the parameter name if no feature description is available
Write-Output "- $parameterName"
}
$message = $script:Features[$parameterName].Label
Write-Output "- $message"
continue
}
}

View File

@@ -8,7 +8,8 @@ function ShowCLIAppRemoval {
if ($result -eq $true) {
Write-Output "You have selected $($script:SelectedApps.Count) apps for removal"
AddParameter 'RemoveAppsCustom'
AddParameter 'RemoveApps'
AddParameter 'Apps' ($script:SelectedApps -join ',')
SaveSettings

View File

@@ -25,7 +25,8 @@ function ShowCLIDefaultModeOptions {
AddParameter 'Apps' 'Default'
}
'2' {
AddParameter 'RemoveAppsCustom'
AddParameter 'RemoveApps'
AddParameter 'Apps' ($script:SelectedApps -join ',')
if ($DisableGameBarIntegrationInput) {
AddParameter 'DisableDVR'

View File

@@ -0,0 +1,14 @@
<#
.SYNOPSIS
Filters a list of features to those that have a non-empty RegistryKey.
.PARAMETER Features
An array of feature objects to filter.
#>
function Get-RegistryBackedFeatures {
param(
[object[]]$Features = @()
)
return @($Features | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) })
}

View File

@@ -0,0 +1,302 @@
function Get-RegistryBackupCapturePlans {
param(
[object[]]$SelectedRegistryFeatures = @(),
[object[]]$UndoRegistryFeatures = @(),
[switch]$UseSysprepRegFiles
)
$planMap = @{}
foreach ($feature in $SelectedRegistryFeatures) {
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $feature.RegistryKey -UseSysprepRegFiles:$UseSysprepRegFiles
if (-not (Test-Path $regFilePath)) {
throw "Unable to find registry file for backup: $($feature.RegistryKey) ($regFilePath)"
}
foreach ($operation in @(Get-RegFileOperations -regFilePath $regFilePath)) {
if (-not $operation.KeyPath) { continue }
Add-RegistryPlanOperation -PlanMap $planMap -Operation $operation
}
}
foreach ($feature in $UndoRegistryFeatures) {
$regFilePath = Resolve-RegistryBackupUndoFilePath -Feature $feature
if ([string]::IsNullOrWhiteSpace($regFilePath)) {
continue
}
if (-not (Test-Path $regFilePath)) {
$undoKeyDescription = if (-not [string]::IsNullOrWhiteSpace([string]$feature.RegistryUndoKey)) {
[string]$feature.RegistryUndoKey
}
else {
[string]$feature.RegistryKey
}
throw "Unable to find registry undo file for backup: $undoKeyDescription ($regFilePath)"
}
foreach ($operation in @(Get-RegFileOperations -regFilePath $regFilePath)) {
if (-not $operation.KeyPath) { continue }
Add-RegistryPlanOperation -PlanMap $planMap -Operation $operation
}
}
return @(
foreach ($entry in $planMap.Values) {
[PSCustomObject]@{
Path = $entry.Path
IncludeSubKeys = [bool]$entry.IncludeSubKeys
CaptureAllValues = [bool]$entry.CaptureAllValues
ValueNames = @($entry.ValueNames)
}
}
)
}
function Add-RegistryPlanOperation {
param(
[hashtable]$PlanMap,
[PSCustomObject]$Operation
)
$mapKey = $Operation.KeyPath.ToLowerInvariant()
if (-not $PlanMap.ContainsKey($mapKey)) {
$PlanMap[$mapKey] = [PSCustomObject]@{
Path = $Operation.KeyPath
IncludeSubKeys = $false
CaptureAllValues = $false
ValueNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
}
}
$plan = $PlanMap[$mapKey]
switch ($Operation.OperationType) {
'DeleteKey' {
$plan.IncludeSubKeys = $true
$plan.CaptureAllValues = $true
}
'SetValue' {
if (-not $plan.CaptureAllValues) {
$null = $plan.ValueNames.Add([string]$Operation.ValueName)
}
}
'DeleteValue' {
if (-not $plan.CaptureAllValues) {
$null = $plan.ValueNames.Add([string]$Operation.ValueName)
}
}
}
}
function Resolve-RegistryBackupUndoFilePath {
param(
[Parameter(Mandatory)]
$Feature
)
$undoRegistryKey = [string]$Feature.RegistryUndoKey
if (-not [string]::IsNullOrWhiteSpace($undoRegistryKey)) {
$resolvedUndoPath = Resolve-UndoRegFilePath -FileName $undoRegistryKey
return Join-Path $script:RegfilesPath $resolvedUndoPath
}
$resolvedRegistryKey = [string]$Feature.RegistryKey
if ([string]::IsNullOrWhiteSpace($resolvedRegistryKey)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($resolvedRegistryKey)) {
return $resolvedRegistryKey
}
return Join-Path $script:RegfilesPath $resolvedRegistryKey
}
function Get-RegistrySnapshotsForBackup {
param(
[object[]]$CapturePlans = @()
)
if ($CapturePlans.Count -eq 0) {
return @()
}
$snapshotScript = {
param($plans)
$snapshots = @()
foreach ($plan in $plans) {
$snapshots += Get-RegistryKeySnapshot -KeyPath $plan.Path -CaptureAllValues:$plan.CaptureAllValues -ValueNames @($plan.ValueNames) -IncludeSubKeys:$plan.IncludeSubKeys
}
return @($snapshots)
}
if ($script:Params.ContainsKey('Sysprep') -or $script:Params.ContainsKey('User')) {
return Invoke-WithLoadedBackupHive -ScriptBlock $snapshotScript -ArgumentObject @($CapturePlans)
}
return & $snapshotScript $CapturePlans
}
function Invoke-WithLoadedBackupHive {
param(
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
$ArgumentObject = $null
)
$targetUserName = if ($script:Params.ContainsKey('Sysprep')) {
'Default'
}
else {
$script:Params.Item('User')
}
return Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject
}
function Get-RegistryKeySnapshot {
param(
[Parameter(Mandatory)]
[string]$KeyPath,
[bool]$CaptureAllValues = $false,
[string[]]$ValueNames = @(),
[bool]$IncludeSubKeys = $false
)
$registryParts = Split-RegistryPath -path $KeyPath
if (-not $registryParts) {
throw "Unsupported registry path in backup: $KeyPath"
}
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
if (-not $rootKey) {
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
}
$subKeyPath = $registryParts.SubKey
$key = $rootKey.OpenSubKey($subKeyPath, $false)
if ($null -eq $key) {
return @{
Path = $KeyPath
Exists = $false
Values = @()
SubKeys = @()
}
}
try {
return (Convert-RegistryKeyToSnapshot -RegistryKey $key -FullPath $KeyPath -CaptureAllValues:$CaptureAllValues -ValueNames $ValueNames -IncludeSubKeys:$IncludeSubKeys)
}
finally {
$key.Close()
}
}
function Convert-RegistryKeyToSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
[Parameter(Mandatory)]
[string]$FullPath,
[bool]$CaptureAllValues = $false,
[string[]]$ValueNames = @(),
[bool]$IncludeSubKeys = $false
)
$values = @()
if ($CaptureAllValues) {
foreach ($valueName in @($RegistryKey.GetValueNames())) {
$values += @(Convert-RegistryValueToSnapshot -RegistryKey $RegistryKey -ValueName $valueName)
}
}
else {
foreach ($valueName in @($ValueNames | Sort-Object -Unique)) {
$exists = ($RegistryKey.GetValueNames() -contains $valueName)
if ($exists) {
$values += @(Convert-RegistryValueToSnapshot -RegistryKey $RegistryKey -ValueName $valueName)
}
else {
$values += @{
Name = $valueName
Exists = $false
Kind = $null
Data = $null
}
}
}
}
$subKeys = @()
if ($IncludeSubKeys) {
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
if ($null -eq $childKey) { continue }
try {
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
$subKeys += @(Convert-RegistryKeyToSnapshot -RegistryKey $childKey -FullPath $childPath -CaptureAllValues:$true -IncludeSubKeys:$true)
}
finally {
$childKey.Close()
}
}
}
return @{
Path = $FullPath
Exists = $true
Values = $values
SubKeys = $subKeys
}
}
function Convert-RegistryValueToSnapshot {
param(
[Parameter(Mandatory)]
[Microsoft.Win32.RegistryKey]$RegistryKey,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string]$ValueName
)
$valueKind = $RegistryKey.GetValueKind($ValueName)
$value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
try {
$normalizedValue = switch ($valueKind) {
([Microsoft.Win32.RegistryValueKind]::Binary) { @($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) }
default { if ($null -ne $value) { [string]$value } else { $null } }
}
}
catch {
$valueType = if ($null -ne $value) { $value.GetType().FullName } else { '<null>' }
$valueForLog = if ($null -eq $value) { '<null>' } elseif ($value -is [array]) { ($value -join ',') } else { [string]$value }
throw "Failed to normalize registry value for backup. Key='$($RegistryKey.Name)' Name='$ValueName' Kind='$valueKind' RawType='$valueType' RawValue='$valueForLog'. InnerError: $($_.Exception.Message)"
}
return @{
Name = $ValueName
Exists = $true
Kind = $valueKind.ToString()
Data = $normalizedValue
}
}
function Get-RegistryBackupTargetDescription {
if ($script:Params.ContainsKey('Sysprep')) {
return 'DefaultUserProfile'
}
$resolvedUserName = [string](GetUserName)
if ($script:Params.ContainsKey('User')) {
return "User:$resolvedUserName"
}
return "CurrentUser:$resolvedUserName"
}

View File

@@ -0,0 +1,146 @@
<#
.SYNOPSIS
Creates a timestamped JSON backup of registry state for selected features.
.DESCRIPTION
Resolves selected and undo features from the provided keys, captures their
registry state, and saves the result as a JSON file in the Backups/ folder.
Returns the file path on success, $null if no registry-backed features exist.
.PARAMETER ActionableKeys
Param keys from $script:Params to resolve into apply features.
.PARAMETER ExtraFeatures
Additional synthetic feature objects (e.g. undo features) to include.
#>
function New-RegistrySettingsBackup {
param(
[string[]]$ActionableKeys,
[object[]]$ExtraFeatures = @()
)
$ActionableKeys = @($ActionableKeys)
$selectedFeatures = @(Get-SelectedFeatures -ActionableKeys $ActionableKeys)
$undoFeatures = @($ExtraFeatures | Where-Object { $_ -ne $null })
$allFeatures = @($selectedFeatures) + @($undoFeatures)
if (@($allFeatures | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) }).Count -eq 0) {
return $null
}
$timestamp = Get-Date
$backupDirectory = $script:RegistryBackupsPath
if (-not (Test-Path $backupDirectory)) {
New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null
}
$backupFileName = 'Win11Debloat-RegistryBackup-{0}.json' -f $timestamp.ToString('yyyyMMdd_HHmmss')
$backupFilePath = Join-Path $backupDirectory $backupFileName
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -UndoFeatures $undoFeatures -CreatedAt $timestamp
if (-not (SaveToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
throw "Failed to save registry backup to '$backupFilePath'"
}
Write-Host "Backup successfully created: $backupFilePath"
Write-Host ""
return $backupFilePath
}
<#
.SYNOPSIS
Resolves param keys into deduplicated feature objects from the catalog.
.PARAMETER ActionableKeys
Param keys to look up in $script:Features.
#>
function Get-SelectedFeatures {
param(
[string[]]$ActionableKeys
)
$selectedFeatures = New-Object System.Collections.Generic.List[object]
$selectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($paramKey in $ActionableKeys) {
if (-not $script:Features.ContainsKey($paramKey)) { continue }
$feature = $script:Features[$paramKey]
if (-not $feature) { continue }
$featureId = [string]$feature.FeatureId
if ($selectedFeatureIds.Add($featureId)) {
$selectedFeatures.Add($feature)
}
}
return @($selectedFeatures.ToArray())
}
<#
.SYNOPSIS
Builds the full backup payload object from selected and undo features.
.DESCRIPTION
Deduplicates feature IDs, resolves registry capture plans, snapshots all
registry keys, and assembles the final backup hashtable with metadata.
.PARAMETER SelectedFeatures
Feature objects from the apply side.
.PARAMETER UndoFeatures
Synthetic feature objects from the undo side.
.PARAMETER CreatedAt
Timestamp recorded in the backup metadata.
#>
function Get-RegistryBackupPayload {
param(
[object[]]$SelectedFeatures = @(),
[object[]]$UndoFeatures = @(),
[Parameter(Mandatory)]
[datetime]$CreatedAt
)
$selectedFeatureIds = New-Object System.Collections.Generic.List[string]
$seenSelectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($feature in $SelectedFeatures) {
$featureId = [string]$feature.FeatureId
if ($seenSelectedFeatureIds.Add($featureId)) {
$selectedFeatureIds.Add($featureId)
}
}
$selectedUndoFeatureIds = New-Object System.Collections.Generic.List[string]
$seenUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($feature in $UndoFeatures) {
$featureId = [string]$feature.FeatureId
if ($seenUndoFeatureIds.Add($featureId)) {
$selectedUndoFeatureIds.Add($featureId)
}
}
$selectedRegistryFeatures = @(Get-RegistryBackedFeatures -Features $SelectedFeatures)
$undoRegistryFeatures = @($UndoFeatures | Where-Object {
-not [string]::IsNullOrWhiteSpace([string]$_.RegistryUndoKey) -or -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey)
})
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures $selectedRegistryFeatures -UndoRegistryFeatures $undoRegistryFeatures)
$registryKeys = @(Get-RegistrySnapshotsForBackup -CapturePlans $capturePlans)
$backupPayload = @{
Version = '1.0'
BackupType = 'RegistryState'
CreatedAt = $CreatedAt.ToString('o')
CreatedBy = 'Win11Debloat'
Target = (Get-RegistryBackupTargetDescription)
ComputerName = $env:COMPUTERNAME
SelectedFeatures = @($selectedFeatureIds)
RegistryKeys = @($registryKeys)
}
if ($selectedUndoFeatureIds.Count -gt 0) {
$backupPayload['SelectedUndoFeatures'] = @($selectedUndoFeatureIds)
}
return $backupPayload
}

View File

@@ -6,7 +6,7 @@ function CreateSystemRestorePoint {
# 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') {
try {
$enableResult = Invoke-NonBlocking -TimeoutSeconds 20 -ScriptBlock {
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
try {
Enable-ComputerRestore -Drive "$env:SystemDrive"
return $null
@@ -33,7 +33,7 @@ function CreateSystemRestorePoint {
if (-not $failed) {
try {
$result = Invoke-NonBlocking -TimeoutSeconds 20 -ScriptBlock {
$result = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
try {
$recentRestorePoints = Get-ComputerRestorePoint | Where-Object { (Get-Date) - [System.Management.ManagementDateTimeConverter]::ToDateTime($_.CreationTime) -le (New-TimeSpan -Hours 24) }
}
@@ -92,4 +92,4 @@ function CreateSystemRestorePoint {
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
}
}
}

View File

@@ -1,52 +0,0 @@
# Disables Microsoft Store search suggestions in the start menu for all users by denying access to the Store app database file for each user
function DisableStoreSearchSuggestionsForAllUsers {
# Get path to Store app database for all users
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
$usersStoreDbPaths = get-childitem -path $userPathString
# Go through all users and disable start search suggestions
ForEach ($storeDbPath in $usersStoreDbPaths) {
DisableStoreSearchSuggestions ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
}
# Also disable start search suggestions for the default user profile
$defaultStoreDbPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false
DisableStoreSearchSuggestions $defaultStoreDbPath
}
# Disables Microsoft Store search suggestions in the start menu by denying access to the Store app database file
function DisableStoreSearchSuggestions {
param (
$StoreAppsDatabase = "$env:LocalAppData\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
)
# Change path to correct user if a user was specified
if ($script:Params.ContainsKey("User")) {
$StoreAppsDatabase = GetUserDirectory -userName "$(GetUserName)" -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false
}
$userName = [regex]::Match($StoreAppsDatabase, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
# 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
if (-not (Test-Path -Path $storeDbDir)) {
New-Item -Path $storeDbDir -ItemType Directory -Force | Out-Null
}
New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null
}
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
$Acl = Get-Acl -Path $StoreAppsDatabase
$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"
}

View File

@@ -1,16 +0,0 @@
# Enables a Windows optional feature and pipes its output to the console
function EnableWindowsFeature {
param (
[string]$FeatureName
)
$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()
}
}

View File

@@ -1,196 +0,0 @@
# Executes a single parameter/feature based on its key
# Parameters:
# $paramKey - The parameter name to execute
function ExecuteParameter {
param (
[string]$paramKey
)
# Check if this feature has metadata in Features.json
$feature = $null
if ($script:Features.ContainsKey($paramKey)) {
$feature = $script:Features[$paramKey]
}
# If feature has RegistryKey and ApplyText, use dynamic ImportRegistryFile
if ($feature -and $feature.RegistryKey -and $feature.ApplyText) {
ImportRegistryFile "> $($feature.ApplyText)" $feature.RegistryKey
# Handle special cases that have additional logic after ImportRegistryFile
switch ($paramKey) {
'DisableBing' {
# Also remove the app package for Bing search
RemoveApps 'Microsoft.BingSearch'
}
'DisableCopilot' {
# Also remove the app package for Copilot
RemoveApps 'Microsoft.Copilot'
}
'DisableWidgets' {
# Also remove the app packages for Widgets
RemoveApps 'Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'
}
}
return
}
# Handle features without RegistryKey or with special logic
switch ($paramKey) {
'RemoveApps' {
Write-Host "> Removing selected apps for $(GetFriendlyTargetUserName)..."
$appsList = GenerateAppsList
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Host ""
return
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
}
'RemoveAppsCustom' {
Write-Host "> Removing selected apps..."
$appsList = LoadAppsFromFile $script:CustomAppsListFilePath
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Host ""
return
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
}
'RemoveCommApps' {
$appsList = 'Microsoft.windowscommunicationsapps', 'Microsoft.People'
Write-Host "> Removing Mail, Calendar and People apps..."
RemoveApps $appsList
return
}
'RemoveW11Outlook' {
$appsList = 'Microsoft.OutlookForWindows'
Write-Host "> Removing new Outlook for Windows app..."
RemoveApps $appsList
return
}
'RemoveGamingApps' {
$appsList = 'Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay'
Write-Host "> Removing gaming related apps..."
RemoveApps $appsList
return
}
'RemoveHPApps' {
$appsList = 'AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl'
Write-Host "> Removing HP apps..."
RemoveApps $appsList
return
}
"EnableWindowsSandbox" {
Write-Host "> Enabling Windows Sandbox..."
EnableWindowsFeature "Containers-DisposableClientVM"
Write-Host ""
return
}
"EnableWindowsSubsystemForLinux" {
Write-Host "> Enabling Windows Subsystem for Linux..."
EnableWindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host ""
return
}
'ClearStart' {
Write-Host "> Removing all pinned apps from the start menu for user $(GetUserName)..."
ReplaceStartMenu
Write-Host ""
return
}
'ReplaceStart' {
Write-Host "> Replacing the start menu for user $(GetUserName)..."
ReplaceStartMenu $script:Params.Item("ReplaceStart")
Write-Host ""
return
}
'ClearStartAllUsers' {
ReplaceStartMenuForAllUsers
return
}
'ReplaceStartAllUsers' {
ReplaceStartMenuForAllUsers $script:Params.Item("ReplaceStartAllUsers")
return
}
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey("Sysprep")) {
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
DisableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
}
Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..."
DisableStoreSearchSuggestions
Write-Host ""
return
}
}
}
# Executes all selected parameters/features
function ExecuteAllChanges {
# Build list of actionable parameters (skip control params and data-only params)
$actionableKeys = @()
foreach ($paramKey in $script:Params.Keys) {
if ($script:ControlParams -contains $paramKey) { continue }
if ($paramKey -eq 'Apps') { continue }
if ($paramKey -eq 'CreateRestorePoint') { continue }
$actionableKeys += $paramKey
}
$totalSteps = $actionableKeys.Count
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$currentStep = 0
# Create restore point if requested (CLI only - GUI handles this separately)
if ($script:Params.ContainsKey("CreateRestorePoint")) {
$currentStep++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $currentStep $totalSteps "Creating system restore point"
}
Write-Host "> Attempting to create a system restore point..."
CreateSystemRestorePoint
Write-Host ""
}
# Execute all parameters
foreach ($paramKey in $actionableKeys) {
if ($script:CancelRequested) {
return
}
$currentStep++
# Get friendly name for the step
$stepName = $paramKey
if ($script:Features.ContainsKey($paramKey)) {
$feature = $script:Features[$paramKey]
if ($feature.ApplyText) {
# Prefer explicit ApplyText when provided
$stepName = $feature.ApplyText
} elseif ($feature.Label) {
# Fallback: construct a name from Action and Label, or just Label
if ($feature.Action) {
$stepName = "$($feature.Action) $($feature.Label)"
} else {
$stepName = $feature.Label
}
}
}
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $currentStep $totalSteps $stepName
}
ExecuteParameter -paramKey $paramKey
}
}

View File

@@ -0,0 +1,198 @@
<#
.SYNOPSIS
Maps a .reg file value type string to its RegistryValueKind enum.
.PARAMETER Operation
A parsed .reg operation object containing a ValueType property.
#>
function Get-ExpectedRegistryValueKind {
param(
[Parameter(Mandatory)]
$Operation
)
switch ([string]$Operation.ValueType) {
'DWord' { return [Microsoft.Win32.RegistryValueKind]::DWord }
'QWord' { return [Microsoft.Win32.RegistryValueKind]::QWord }
'String' { return [Microsoft.Win32.RegistryValueKind]::String }
'Binary' { return [Microsoft.Win32.RegistryValueKind]::Binary }
'Hex2' { return [Microsoft.Win32.RegistryValueKind]::ExpandString }
'Hex7' { return [Microsoft.Win32.RegistryValueKind]::MultiString }
default { return $null }
}
}
<#
.SYNOPSIS
Tests whether a feature's registry operations currently match the live registry.
.DESCRIPTION
Returns $true when ALL operations in the apply .reg file match current system
state. Returns $false if the feature has no RegistryKey, the reg file is
missing, or any operation mismatches. Special-cased features (Widgets, Store
suggestions, Windows Sandbox, WSL) bypass .reg checking entirely.
.PARAMETER FeatureId
The feature identifier to test.
#>
function Test-FeatureApplied {
param (
[Parameter(Mandatory)]
[string]$FeatureId
)
$feature = $script:Features[$FeatureId]
switch ($FeatureId) {
'DisableWidgets' {
# Widgets packages cannot be reinstalled automatically, so we treat their
# absence as the applied state (checked) and presence as not-yet-applied.
$widgetAppIds = @(
'Microsoft.StartExperiencesApp',
'MicrosoftWindows.Client.WebExperience',
'Microsoft.WidgetsPlatformRuntime'
)
foreach ($appId in $widgetAppIds) {
if (Get-AppxPackage -Name $appId -AllUsers -ErrorAction SilentlyContinue) {
return $false
}
}
return $true
}
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) {
return (Test-StoreSearchSuggestionsDisabledForAllUsers)
}
$storeDbPath = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
return (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDbPath)
}
'EnableWindowsSandbox' {
return (Test-WindowsOptionalFeatureEnabled -FeatureName 'Containers-DisposableClientVM')
}
'EnableWindowsSubsystemForLinux' {
$wslEnabled = Test-WindowsOptionalFeatureEnabled -FeatureName 'Microsoft-Windows-Subsystem-Linux'
$vmpEnabled = Test-WindowsOptionalFeatureEnabled -FeatureName 'VirtualMachinePlatform'
return ($wslEnabled -and $vmpEnabled)
}
}
if (-not $feature.RegistryKey) { return $false }
$regFilePath = Join-Path $script:RegfilesPath $feature.RegistryKey
if (-not (Test-Path $regFilePath)) { return $false }
try {
$operations = @(Get-RegFileOperations -regFilePath $regFilePath)
}
catch { return $false }
if ($operations.Count -eq 0) { return $false }
foreach ($op in $operations) {
$parts = Split-RegistryPath -path $op.KeyPath
if (-not $parts) { return $false }
$rootKey = Get-RegistryRootKey -hiveName $parts.Hive
if (-not $rootKey) { return $false }
$key = $null
try {
$key = $rootKey.OpenSubKey($parts.SubKey, $false)
switch ($op.OperationType) {
'DeleteKey' {
if ($null -ne $key) { return $false }
}
'DeleteValue' {
if ($null -ne $key) {
$names = @($key.GetValueNames())
if ($names -icontains $op.ValueName) { return $false }
}
# key missing = value also gone = operation matches
}
'SetValue' {
if ($null -eq $key) { return $false }
$names = @($key.GetValueNames())
if (-not ($names -icontains $op.ValueName)) { return $false }
$actualKind = $key.GetValueKind($op.ValueName)
$expectedKind = Get-ExpectedRegistryValueKind -Operation $op
if ($null -eq $expectedKind -or $actualKind -ne $expectedKind) { return $false }
$actualRaw = $key.GetValue($op.ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
$actual = switch ($actualKind) {
([Microsoft.Win32.RegistryValueKind]::DWord) {
[BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$actualRaw), 0)
}
([Microsoft.Win32.RegistryValueKind]::QWord) {
[BitConverter]::ToUInt64([BitConverter]::GetBytes([int64]$actualRaw), 0)
}
([Microsoft.Win32.RegistryValueKind]::Binary) {
@($actualRaw | ForEach-Object { [int]$_ })
}
([Microsoft.Win32.RegistryValueKind]::MultiString) {
@($actualRaw)
}
default {
if ($null -ne $actualRaw) { [string]$actualRaw } else { $null }
}
}
$expected = $op.ValueData
$match = if (($actual -is [array]) -and ($expected -is [array])) {
(Compare-Object $actual $expected).Count -eq 0
} else {
$actual -eq $expected
}
if (-not $match) { return $false }
}
}
}
catch { return $false }
finally {
if ($null -ne $key) { $key.Close() }
}
}
return $true
}
<#
.SYNOPSIS
Returns the 1-based index of the UiGroup option whose features all match
current system state.
.DESCRIPTION
Returns 0 if no option fully matches, meaning the current state is unknown
or represents "No Change".
.PARAMETER Group
A UiGroup object whose Values array contains options with FeatureIds.
#>
function Get-CurrentGroupActiveIndex {
param (
[Parameter(Mandatory)]
[object]$Group
)
$i = 1
foreach ($val in $Group.Values) {
$allApplied = $true
foreach ($fid in $val.FeatureIds) {
if (-not (Test-FeatureApplied -FeatureId $fid)) {
$allApplied = $false
break
}
}
if ($allApplied) { return $i }
$i++
}
return 0
}

View File

@@ -8,33 +8,39 @@ function ImportRegistryFile {
Write-Host $message
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
$regFilePath = if ($usesOfflineHive) {
"$script:RegfilesPath\Sysprep\$path"
}
else {
"$script:RegfilesPath\$path"
}
$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
}
# Reset exit code before running reg.exe for reliable success detection
$global:LASTEXITCODE = 0
$importScript = {
param($targetRegFilePath, $hiveContext)
if ($usesOfflineHive) {
# Sysprep targets Default user, User targets the specified user
$hiveDatPath = if ($script:Params.ContainsKey("Sysprep")) {
GetUserDirectory -userName "Default" -fileName "NTUSER.DAT"
} else {
GetUserDirectory -userName $script:Params.Item("User") -fileName "NTUSER.DAT"
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($hivePath, $targetRegFilePath)
param($targetRegFilePath)
$result = @{
Output = @()
ExitCode = 0
@@ -43,13 +49,6 @@ function ImportRegistryFile {
try {
$global:LASTEXITCODE = 0
reg load "HKU\Default" $hivePath | Out-Null
$loadExitCode = $LASTEXITCODE
if ($loadExitCode -ne 0) {
throw "Failed to load user hive at '$hivePath' (exit code: $loadExitCode)"
}
$output = reg import $targetRegFilePath 2>&1
$importExitCode = $LASTEXITCODE
@@ -66,52 +65,50 @@ function ImportRegistryFile {
$result.Error = $_.Exception.Message
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
}
finally {
$global:LASTEXITCODE = 0
reg unload "HKU\Default" | Out-Null
$unloadExitCode = $LASTEXITCODE
if ($unloadExitCode -ne 0 -and -not $result.Error) {
$result.Error = "Failed to unload temporary hive HKU\\Default (exit code: $unloadExitCode)"
$result.ExitCode = $unloadExitCode
}
}
return $result
} -ArgumentList @($hiveDatPath, $regFilePath)
}
else {
$regResult = Invoke-NonBlocking -ScriptBlock {
param($targetRegFilePath)
$global:LASTEXITCODE = 0
$output = reg import $targetRegFilePath 2>&1
return @{ Output = @($output); ExitCode = $LASTEXITCODE; Error = $null }
} -ArgumentList $regFilePath
}
} -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
$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)" }
$errorMessage = "Failed importing registry file '$path'. $details"
Write-Host $errorMessage -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 ""
throw $errorMessage
}
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 ""
}
}

View File

@@ -0,0 +1,425 @@
<#
.SYNOPSIS
Applies a single feature/debloat operation.
.DESCRIPTION
Handles two categories of features:
- Registry-backed: imports the .reg file via ImportRegistryFile, 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.
#>
function Invoke-FeatureApply {
param(
[Parameter(Mandatory)]
[string]$FeatureId
)
# Resolve feature metadata from Features.json
$feature = $script:Features[$FeatureId]
$applyText = $feature.ApplyText
# ---- Registry-backed features: import .reg file, then handle side effects ----
if ($feature.RegistryKey) {
ImportRegistryFile "> $applyText..." $feature.RegistryKey
# Post-import side effects for specific features
switch ($FeatureId) {
'DisableBing' {
# Also remove the app package for Bing search
RemoveApps @('Microsoft.BingSearch')
}
'DisableCopilot' {
# Also remove the app packages for Copilot
RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')
}
'DisableTelemetry' {
# Also disable telemetry scheduled tasks
Disable-TelemetryScheduledTasks
}
}
return
}
# ---- Custom features (no registry backing, or special handling required) ----
switch ($FeatureId) {
'RemoveApps' {
Write-Host "> $applyText for $(GetFriendlyTargetUserName)..."
$appsList = GenerateAppsList
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Host ""
return
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
return
}
'RemoveGamingApps' {
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
Write-Host "> $applyText..."
RemoveApps $appsList
return
}
'RemoveHPApps' {
$appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl')
Write-Host "> $applyText..."
RemoveApps $appsList
return
}
'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
}
RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')
return
}
'EnableWindowsSandbox' {
Write-Host "> $applyText..."
EnableWindowsFeature "Containers-DisposableClientVM"
Write-Host ""
return
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $applyText..."
EnableWindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host ""
return
}
'ClearStart' {
Write-Host "> $applyText for user $(GetUserName)..."
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
ReplaceStartMenu -startMenuBinFile $startMenuBinFile
}
Write-Host ""
return
}
'ReplaceStart' {
Write-Host "> $applyText for user $(GetUserName)..."
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
ReplaceStartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart")
}
Write-Host ""
return
}
'ClearStartAllUsers' {
ReplaceStartMenuForAllUsers
return
}
'ReplaceStartAllUsers' {
ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")
return
}
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey("Sysprep")) {
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
DisableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
}
Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..."
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
if ($storeDb) {
DisableStoreSearchSuggestions -StoreAppsDatabase $storeDb
}
Write-Host ""
return
}
}
}
<#
.SYNOPSIS
Undoes a single feature that has no RegistryUndoKey.
.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.
#>
function Invoke-FeatureUndo {
param(
[Parameter(Mandatory)]
[string]$FeatureId
)
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
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
}
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..."
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
if ($storeDb) {
EnableStoreSearchSuggestions -StoreAppsDatabase $storeDb
}
Write-Host ""
return
}
'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
}
}
}
<#
.SYNOPSIS
Resolves the path of an undo .reg file relative to $script:RegfilesPath.
.DESCRIPTION
Checks the Undo/ subfolder first, then falls back to the root Regfiles/
folder. This allows undo files to be organized separately from apply files.
#>
function Resolve-UndoRegFilePath {
param([string]$FileName)
$undoSubPath = Join-Path 'Undo' $FileName
if (Test-Path (Join-Path $script:RegfilesPath $undoSubPath)) {
return $undoSubPath
}
return $FileName
}
<#
.SYNOPSIS
Applies a list of features, reporting progress for each.
.DESCRIPTION
Iterates through the provided feature IDs and calls Invoke-FeatureApply
for each. Handles progress callbacks (GUI mode) and cancellation checks.
This is called by Invoke-AllChanges during the apply phase.
#>
function Invoke-ApplyFeatures {
param(
[Parameter(Mandatory)]
[string[]]$FeatureIds,
[Parameter(Mandatory)]
[int]$StartStep,
[Parameter(Mandatory)]
[int]$TotalSteps
)
if ($FeatureIds.Count -eq 0) { return }
$step = $StartStep
foreach ($featureId in $FeatureIds) {
if ($script:CancelRequested) { return }
# Resolve display name for the progress indicator
$f = $script:Features[$featureId]
$displayName = $f.ApplyText
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $TotalSteps $displayName
}
Invoke-FeatureApply -FeatureId $featureId
$step++
}
}
<#
.SYNOPSIS
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.
This is called by Invoke-AllChanges during the undo phase.
#>
function Invoke-UndoFeatures {
param(
[Parameter(Mandatory)]
[string[]]$FeatureIds,
[Parameter(Mandatory)]
[int]$StartStep,
[Parameter(Mandatory)]
[int]$TotalSteps
)
if ($FeatureIds.Count -eq 0) { return }
$step = $StartStep
foreach ($featureId in $FeatureIds) {
if ($script:CancelRequested) { return }
$f = if ($script:Features.ContainsKey($featureId)) { $script:Features[$featureId] } else { $null }
$undoLabel = if ($f -and $f.UndoLabel) { $f.UndoLabel } else { $featureId }
$undoText = if ($f -and $f.ApplyUndoText) { $f.ApplyUndoText } else { $undoLabel }
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $TotalSteps $undoText
}
if ($f -and $f.RegistryUndoKey) {
ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey)
}
Invoke-FeatureUndo -FeatureId $featureId
$step++
}
}
<#
.SYNOPSIS
Main orchestrator: applies and undoes all selected features.
.DESCRIPTION
Sequenced in four phases:
1. Registry backup
2. System restore point
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
4. Undo phase - undoes selected features via Invoke-UndoFeatures
Progress is reported through $script:ApplyProgressCallback when set
(used by the GUI modal). Cancellation is checked between each step.
#>
function Invoke-AllChanges {
# Guard: prevent running as SYSTEM account without explicit target user
$isSystem = ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18')
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
# ---- Gather work items ----
$applyIds = @()
foreach ($key in $script:Params.Keys) {
if ($script:ControlParams -contains $key) { continue }
if ($key -eq 'Apps') { continue }
if ($key -eq 'CreateRestorePoint') { continue }
$applyIds += $key
}
$undoIds = @($script:UndoParams.Keys)
# ---- Determine if registry backup is needed ----
$needsBackup = $false
foreach ($id in $applyIds) {
$f = $script:Features[$id]
if ($f -and -not [string]::IsNullOrWhiteSpace([string]$f.RegistryKey)) {
$needsBackup = $true
break
}
}
if (-not $needsBackup) {
foreach ($id in $undoIds) {
$f = if ($script:Features.ContainsKey($id)) { $script:Features[$id] } else { $null }
if ($f -and $f.RegistryUndoKey) { $needsBackup = $true; break }
}
}
# ---- Calculate total progress steps ----
$totalSteps = $applyIds.Count + $undoIds.Count
if ($needsBackup) { $totalSteps++ }
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$step = 0
# ================================================================
# Phase 1: Registry backup
# ================================================================
if ($needsBackup) {
$step++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..."
}
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Create registry backup" -ForegroundColor Cyan
}
else {
Write-Host "> Creating registry backup..."
try {
$undoSyntheticFeatures = @($undoIds | ForEach-Object {
$f = if ($script:Features.ContainsKey($_)) { $script:Features[$_] } else { $null }
if ($f -and $f.RegistryUndoKey) {
[PSCustomObject]@{ FeatureId = $_; RegistryKey = (Resolve-UndoRegFilePath $f.RegistryUndoKey) }
}
} | Where-Object { $_ })
New-RegistrySettingsBackup -ActionableKeys $applyIds -ExtraFeatures $undoSyntheticFeatures | Out-Null
}
catch {
throw "Registry backup failed before applying changes. $($_.Exception.Message)"
}
}
}
# ================================================================
# Phase 2: System restore point
# ================================================================
if ($script:Params.ContainsKey("CreateRestorePoint")) {
$step++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..."
}
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Create system restore point" -ForegroundColor Cyan
Write-Host ""
}
else {
Write-Host "> Creating a system restore point..."
CreateSystemRestorePoint
Write-Host ""
}
}
# ================================================================
# Phase 3: Apply features
# ================================================================
if ($applyIds.Count -gt 0) {
Invoke-ApplyFeatures -FeatureIds $applyIds -StartStep ($step + 1) -TotalSteps $totalSteps
$step += $applyIds.Count
}
# ================================================================
# Phase 4: Undo features
# ================================================================
if ($undoIds.Count -gt 0) {
Invoke-UndoFeatures -FeatureIds $undoIds -StartStep ($step + 1) -TotalSteps $totalSteps
$step += $undoIds.Count
}
# ================================================================
# Final: Report registry import failures
# ================================================================
if ($script:RegistryImportFailures -gt 0) {
Write-Host ""
Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow
}
}

View File

@@ -0,0 +1,447 @@
function Get-NormalizedSelectedFeatureIdsFromBackup {
param(
[Parameter(Mandatory)]
$Backup
)
$selectedFeatures = New-Object System.Collections.Generic.List[string]
$selectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$errors = New-Object System.Collections.Generic.List[string]
$hasInvalidSelectedFeatureId = $false
if (-not $Backup.PSObject.Properties['SelectedFeatures']) {
$errors.Add('Missing property: SelectedFeatures')
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
Errors = $errors.ToArray()
}
}
foreach ($featureId in @($Backup.SelectedFeatures)) {
if ($featureId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$featureId)) {
$hasInvalidSelectedFeatureId = $true
continue
}
$normalizedFeatureId = [string]$featureId
if ($selectedFeatureIds.Add($normalizedFeatureId)) {
$selectedFeatures.Add($normalizedFeatureId)
}
}
if ($hasInvalidSelectedFeatureId) {
$errors.Add('SelectedFeatures must contain non-empty string feature IDs.')
}
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
Errors = $errors.ToArray()
}
}
function Get-NormalizedSelectedUndoFeatureIdsFromBackup {
param(
[Parameter(Mandatory)]
$Backup
)
$selectedUndoFeatures = New-Object System.Collections.Generic.List[string]
$selectedUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$errors = New-Object System.Collections.Generic.List[string]
# SelectedUndoFeatures is optional - only process if present
if (-not $Backup.PSObject.Properties['SelectedUndoFeatures']) {
return [PSCustomObject]@{
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
Errors = $errors.ToArray()
}
}
$hasInvalidSelectedUndoFeatureId = $false
foreach ($featureId in @($Backup.SelectedUndoFeatures)) {
if ($featureId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$featureId)) {
$hasInvalidSelectedUndoFeatureId = $true
continue
}
$normalizedFeatureId = [string]$featureId
if ($selectedUndoFeatureIds.Add($normalizedFeatureId)) {
$selectedUndoFeatures.Add($normalizedFeatureId)
}
}
if ($hasInvalidSelectedUndoFeatureId) {
$errors.Add('SelectedUndoFeatures must contain non-empty string feature IDs.')
}
return [PSCustomObject]@{
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
Errors = $errors.ToArray()
}
}
function Normalize-RegistryKeySnapshot {
param(
[Parameter(Mandatory)]
$Snapshot
)
if (-not $Snapshot.PSObject.Properties['Path'] -or [string]::IsNullOrWhiteSpace([string]$Snapshot.Path)) {
throw 'Backup validation failed: Registry key snapshot is missing Path.'
}
$exists = $false
if ($Snapshot.PSObject.Properties['Exists']) {
$exists = [bool]$Snapshot.Exists
}
$values = @()
if ($Snapshot.PSObject.Properties['Values']) {
foreach ($valueSnapshot in @($Snapshot.Values)) {
$valueExists = $true
if ($valueSnapshot.PSObject.Properties['Exists']) {
$valueExists = [bool]$valueSnapshot.Exists
}
$values += [PSCustomObject]@{
Name = [string]$valueSnapshot.Name
Exists = $valueExists
Kind = if ($valueSnapshot.PSObject.Properties['Kind']) { [string]$valueSnapshot.Kind } else { $null }
Data = if ($valueSnapshot.PSObject.Properties['Data']) { $valueSnapshot.Data } else { $null }
}
}
}
$subKeys = @()
if ($Snapshot.PSObject.Properties['SubKeys']) {
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
$subKeys += @(Normalize-RegistryKeySnapshot -Snapshot $subKeySnapshot)
}
}
return [PSCustomObject]@{
Path = [string]$Snapshot.Path
Exists = $exists
Values = @($values)
SubKeys = @($subKeys)
}
}
function Test-RegistryBackupMatchesSelectedFeatures {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedUndoFeatureIds,
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]]$RegistryKeys
)
$errors = New-Object System.Collections.Generic.List[string]
if (-not $script:Features -or $script:Features.Count -eq 0) {
$errors.Add('Unable to validate registry backup allowlist because feature definitions are not loaded.')
return $errors.ToArray()
}
$selectedRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedFeatureIds) -IsUndoFeature:$false -Errors $errors)
$undoRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedUndoFeatureIds) -IsUndoFeature:$true -Errors $errors)
$useSysprepRegFiles = ($Target -eq 'DefaultUserProfile') -or ($Target -like 'User:*')
$capturePlans = @()
if ($errors.Count -eq 0 -and ($selectedRegistryFeatures.Count -gt 0 -or $undoRegistryFeatures.Count -gt 0)) {
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @($selectedRegistryFeatures) -UndoRegistryFeatures @($undoRegistryFeatures) -UseSysprepRegFiles:$useSysprepRegFiles)
}
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @($capturePlans)
if ($planMap.Count -eq 0 -and @($RegistryKeys).Count -gt 0) {
$errors.Add('Backup contains registry snapshots but no allowed registry paths were derived from the selected features.')
}
foreach ($rootSnapshot in @($RegistryKeys)) {
Test-RegistrySnapshotAgainstAllowList -Snapshot $rootSnapshot -PlanMap $planMap -Errors $errors
}
return $errors.ToArray()
}
function Get-SelectedRegistryFeaturesForBackupValidation {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[bool]$IsUndoFeature,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
$Errors
)
if ($null -eq $Errors -or -not ($Errors -is [System.Collections.IList])) {
throw 'Get-SelectedRegistryFeaturesForBackupValidation requires Errors to be a mutable list collection.'
}
$selectedRegistryFeatures = New-Object System.Collections.Generic.List[object]
foreach ($featureId in @($SelectedFeatureIds)) {
if (-not $script:Features.ContainsKey($featureId)) {
$Errors.Add("Selected feature '$featureId' was not found in the current feature catalog.")
continue
}
$feature = $script:Features[$featureId]
if (-not $feature) {
continue
}
# For undo features, check RegistryUndoKey if present (real features)
# Otherwise check RegistryKey (for synthetic features from backup capture)
$registryKeyToUse = if ($IsUndoFeature) {
$key = [string]$feature.RegistryUndoKey
if (-not [string]::IsNullOrWhiteSpace($key)) {
$key
}
else {
[string]$feature.RegistryKey
}
}
else {
[string]$feature.RegistryKey
}
if (-not [string]::IsNullOrWhiteSpace($registryKeyToUse)) {
$selectedRegistryFeatures.Add($feature)
}
}
return $selectedRegistryFeatures.ToArray()
}
function New-RegistryBackupAllowListPlanMap {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]]$CapturePlans
)
$planMap = @{}
foreach ($plan in @($CapturePlans)) {
$normalizedPath = Get-NormalizedRegistryPathKey -Path $plan.Path
if ([string]::IsNullOrWhiteSpace($normalizedPath)) {
continue
}
$planMap[$normalizedPath] = [PSCustomObject]@{
Path = $plan.Path
NormalizedPath = $normalizedPath
IncludeSubKeys = [bool]$plan.IncludeSubKeys
CaptureAllValues = [bool]$plan.CaptureAllValues
ValueNames = ConvertTo-RegistryValueNameSet -ValueNames @($plan.ValueNames)
}
}
return $planMap
}
function ConvertTo-RegistryValueNameSet {
param(
[AllowEmptyCollection()]
[string[]]$ValueNames
)
$valueNameSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($valueName in @($ValueNames)) {
$null = $valueNameSet.Add([string]$valueName)
}
return $valueNameSet
}
function Test-RegistrySnapshotAgainstAllowList {
param(
[Parameter(Mandatory)]
$Snapshot,
[Parameter(Mandatory)]
[hashtable]$PlanMap,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.Generic.List[string]]$Errors
)
$snapshotPath = [string]$Snapshot.Path
$normalizedPath = Get-NormalizedRegistryPathKey -Path $snapshotPath
if ([string]::IsNullOrWhiteSpace($normalizedPath)) {
$Errors.Add("Backup contains unsupported registry path '$snapshotPath'.")
return
}
$planMatch = Find-RegistryAllowListPlanMatch -NormalizedPath $normalizedPath -PlanMap $PlanMap
if ($null -eq $planMatch) {
$Errors.Add("Backup contains unexpected registry path '$snapshotPath' that is not allowed by SelectedFeatures.")
return
}
foreach ($valueSnapshot in @($Snapshot.Values)) {
$valueName = Get-NormalizedRegistryValueName -ValueName $valueSnapshot.Name
$valueExists = [bool]$valueSnapshot.Exists
if (-not (Test-RegistryValueAllowedByPlan -PlanMatch $planMatch -ValueName $valueName)) {
$Errors.Add("Backup contains unexpected value '$valueName' under '$snapshotPath'.")
}
$kindName = if ($valueSnapshot.PSObject.Properties['Kind']) { [string]$valueSnapshot.Kind } else { '' }
$valueReference = Get-RegistryValueReferenceForError -SnapshotPath $snapshotPath -ValueName $valueName
if ($valueExists) {
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
$Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
}
}
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
}
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
Test-RegistrySnapshotAgainstAllowList -Snapshot $subKeySnapshot -PlanMap $PlanMap -Errors $Errors
}
}
function Test-RegistryValueAllowedByPlan {
param(
[Parameter(Mandatory)]
$PlanMatch,
[Parameter(Mandatory)]
[AllowNull()]
[AllowEmptyString()]
[string]$ValueName
)
$ValueName = Get-NormalizedRegistryValueName -ValueName $ValueName
if ($PlanMatch.CaptureAllValues -or $PlanMatch.IsDescendant) {
return $true
}
return $PlanMatch.ValueNames.Contains($ValueName)
}
function Get-RegistryValueReferenceForError {
param(
[Parameter(Mandatory)]
[string]$SnapshotPath,
[Parameter(Mandatory)]
[AllowNull()]
[AllowEmptyString()]
[string]$ValueName
)
$ValueName = Get-NormalizedRegistryValueName -ValueName $ValueName
if ([string]::IsNullOrWhiteSpace($ValueName)) {
return "$SnapshotPath\\(Default)"
}
return "$SnapshotPath\\$ValueName"
}
function Get-NormalizedRegistryValueName {
param(
[AllowNull()]
[AllowEmptyString()]
[object]$ValueName
)
if ($null -eq $ValueName) {
return ''
}
return [string]$ValueName
}
function Find-RegistryAllowListPlanMatch {
param(
[Parameter(Mandatory)]
[string]$NormalizedPath,
[Parameter(Mandatory)]
[hashtable]$PlanMap
)
if ($PlanMap.ContainsKey($NormalizedPath)) {
$plan = $PlanMap[$NormalizedPath]
return [PSCustomObject]@{
IsDescendant = $false
CaptureAllValues = [bool]$plan.CaptureAllValues
ValueNames = $plan.ValueNames
}
}
foreach ($plan in @($PlanMap.Values)) {
if (-not [bool]$plan.IncludeSubKeys) {
continue
}
$subKeyPrefix = "$($plan.NormalizedPath)\"
if ($NormalizedPath.StartsWith($subKeyPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
return [PSCustomObject]@{
IsDescendant = $true
CaptureAllValues = $true
ValueNames = $plan.ValueNames
}
}
}
return $null
}
function Get-NormalizedRegistryPathKey {
param(
[Parameter(Mandatory)]
[string]$Path
)
$parts = Split-RegistryPath -path $Path
if (-not $parts) {
return $null
}
$hiveName = [string]$parts.Hive
if ([string]::IsNullOrWhiteSpace($hiveName)) {
return $null
}
$normalizedHive = $hiveName.ToUpperInvariant()
$subKey = [string]$parts.SubKey
if ([string]::IsNullOrWhiteSpace($subKey)) {
return $normalizedHive
}
$normalizedSubKey = ($subKey -replace '/', '\\').Trim('\')
if ([string]::IsNullOrWhiteSpace($normalizedSubKey)) {
return $normalizedHive
}
return "$normalizedHive\\$normalizedSubKey"
}
function Test-RegistryValueKindNameSupported {
param(
[string]$KindName
)
if ([string]::IsNullOrWhiteSpace($KindName)) {
return $false
}
try {
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
return $kind -ne [Microsoft.Win32.RegistryValueKind]::Unknown
}
catch {
return $false
}
}

View File

@@ -1,8 +1,27 @@
# Replace the startmenu for all users, when using the default startmenuTemplate this clears all pinned apps
# Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/
<#
.SYNOPSIS
Replaces the start menu layout for all user profiles.
.DESCRIPTION
Iterates over every existing user profile and the Default user profile,
replacing each user's start2.bin file with the specified template. When
using the default template, this clears all pinned apps from the start menu.
Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/
.PARAMETER startMenuTemplate
Path to the .bin template file to apply. Defaults to the blank template
bundled with the script (Assets/Start/start2.bin).
.EXAMPLE
ReplaceStartMenuForAllUsers
.EXAMPLE
ReplaceStartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
#>
function ReplaceStartMenuForAllUsers {
param (
$startMenuTemplate = "$script:AssetsPath/Start/start2.bin"
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
)
Write-Host "> Removing all pinned apps from the start menu for all users..."
@@ -16,16 +35,21 @@ function ReplaceStartMenuForAllUsers {
# Get path to start menu file for all users
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$usersStartMenuPaths = get-childitem -path $userPathString
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
# Go through all users and replace the start menu file
ForEach ($startMenuPath in $usersStartMenuPaths) {
ReplaceStartMenu $startMenuTemplate "$($startMenuPath.Fullname)\start2.bin"
ReplaceStartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate
}
# Also replace the start menu file for the default user profile
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan
return
}
# Create folder if it doesn't exist
if (-not (Test-Path $defaultStartMenuPath)) {
new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null
@@ -33,43 +57,71 @@ function ReplaceStartMenuForAllUsers {
}
# Copy template to default profile
Copy-Item -Path $startMenuTemplate -Destination $defaultStartMenuPath -Force
ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate
Write-Host "Replaced start menu for the default user profile"
Write-Host ""
}
# Replace the startmenu for all users, when using the default startmenuTemplate this clears all pinned apps
# Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/
<#
.SYNOPSIS
Replaces the start menu layout for a single user.
.DESCRIPTION
Backs up the current start2.bin file (if it exists), then copies the
specified template over it. When using the default template this clears
all pinned apps from the start menu. Validates that the template file
exists and has a .bin extension before proceeding.
Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/
.PARAMETER startMenuBinFile
The full path to the user's start2.bin file to replace.
.PARAMETER startMenuTemplate
Path to the .bin template file to apply. Defaults to the blank template
bundled with the script (Assets/Start/start2.bin).
.EXAMPLE
ReplaceStartMenu -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"
#>
function ReplaceStartMenu {
param (
$startMenuTemplate = "$script:AssetsPath/Start/start2.bin",
$startMenuBinFile = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
[Parameter(Mandatory)]
[string]$startMenuBinFile,
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
)
# Change path to correct user if a user was specified
if ($script:Params.ContainsKey("User")) {
$startMenuBinFile = GetUserDirectory -userName "$(GetUserName)" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false
}
# 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
}
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin" ) {
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") {
Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red
return
}
$userName = [regex]::Match($startMenuBinFile, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
$backupBinFile = $startMenuBinFile + ".bak"
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan
return
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$backupFileName = "Win11Debloat-StartBackup-$timestamp.bak"
$startMenuDir = Split-Path $startMenuBinFile -Parent
$backupBinFile = Join-Path $startMenuDir $backupFileName
if (Test-Path $startMenuBinFile) {
# Backup current start menu file
Move-Item -Path $startMenuBinFile -Destination $backupBinFile -Force
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
@@ -80,4 +132,319 @@ function ReplaceStartMenu {
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force
Write-Host "Replaced start menu for user $userName"
}
<#
.SYNOPSIS
Returns the full path to the start menu bin file for a given user.
.DESCRIPTION
Resolves the path to the start2.bin file for the specified username.
When no username is provided or the value is empty, falls back to
the current user's local app data path via $env:LOCALAPPDATA.
.PARAMETER UserName
The target username. Pass an empty string or omit to resolve for the current user.
.EXAMPLE
GetStartMenuBinPathForUser -UserName "Jeff"
.EXAMPLE
GetStartMenuBinPathForUser -UserName "Default"
#>
function GetStartMenuBinPathForUser {
param(
[string]$UserName
)
if ([string]::IsNullOrWhiteSpace($UserName)) {
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)
}
<#
.SYNOPSIS
Extracts the username from a start2.bin file path.
.DESCRIPTION
Parses a typical C:\Users\<UserName>\AppData\... path and returns the
username portion. Returns 'unknown' if the path does not match the
expected pattern.
.PARAMETER StartMenuBinFile
The full path to a start2.bin file.
.EXAMPLE
GetStartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
#>
function GetStartMenuUserNameFromPath {
param(
[string]$StartMenuBinFile
)
$resolvedUserName = [regex]::Match($StartMenuBinFile, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
if ([string]::IsNullOrWhiteSpace($resolvedUserName)) {
return 'unknown'
}
return $resolvedUserName
}
<#
.SYNOPSIS
Returns the path to the latest start menu backup file for the given scope.
.DESCRIPTION
Resolves the LocalState folder for the specified scope and returns the
full path to the most recent Win11Debloat-StartBackup-*.bak file, or
$null if no backup exists.
For CurrentUser, uses $env:LOCALAPPDATA directly. For AllUsers, scans
every user profile.
.PARAMETER Scope
The scope to check: CurrentUser or AllUsers.
.EXAMPLE
$backupPath = Get-StartMenuBackupPath -Scope 'CurrentUser'
.EXAMPLE
$backupPath = Get-StartMenuBackupPath -Scope 'AllUsers'
#>
function Get-StartMenuBackupPath {
param(
[Parameter(Mandatory)]
[ValidateSet('CurrentUser', 'AllUsers')]
[string]$Scope
)
if ($Scope -eq 'CurrentUser') {
$localStateDir = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$latestBackup = Get-ChildItem -Path (Join-Path $localStateDir 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1
if ($latestBackup) { return $latestBackup.FullName }
return $null
}
else {
$userPathString = GetUserDirectory -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 |
Sort-Object Name -Descending |
Select-Object -First 1
if ($latestBackup) { return $latestBackup.FullName }
}
return $null
}
}
<#
.SYNOPSIS
Restores a user's start menu from a backup file.
.DESCRIPTION
Moves the current start2.bin to a .restore.bak safety copy, then copies
the specified backup file into place. Returns a PSCustomObject with
UserName, Result ($true/$false), and Message properties describing
the outcome.
.PARAMETER StartMenuBinFile
The full path to the user's start2.bin file to restore.
.PARAMETER BackupFilePath
Path to the backup file to restore from. If omitted, automatically
finds the latest Win11Debloat-StartBackup-*.bak file.
.EXAMPLE
RestoreStartMenuFromBackup -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"
#>
function RestoreStartMenuFromBackup {
param(
[Parameter(Mandatory)]
[string]$StartMenuBinFile,
[string]$BackupFilePath
)
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
# Auto-detect latest backup in the same folder as the start2.bin
$startMenuDir = Split-Path $StartMenuBinFile -Parent
$latestBackup = Get-ChildItem -Path (Join-Path $startMenuDir 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1
if ($latestBackup) { $latestBackup.FullName } else { $null }
}
else {
$BackupFilePath
}
$restoreTimestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$restoreBackupFileName = "Win11Debloat-StartRestore-$restoreTimestamp.bak"
$currentBinBackup = Join-Path (Split-Path $StartMenuBinFile -Parent) $restoreBackupFileName
if ([string]::IsNullOrWhiteSpace($backupBinFile)) {
return [PSCustomObject]@{
UserName = $userName
Result = $false
Message = "No start menu backup file found for user $userName."
}
}
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Restore start menu for user $userName from backup $backupBinFile" -ForegroundColor Cyan
return [PSCustomObject]@{
UserName = $userName
Result = $true
Message = "[WhatIf] Restored start menu for user $userName."
}
}
if (-not (Test-Path -LiteralPath $backupBinFile)) {
return [PSCustomObject]@{
UserName = $userName
Result = $false
Message = "No start menu backup file found for user $userName."
}
}
try {
if (Test-Path -LiteralPath $StartMenuBinFile) {
Move-Item -Path $StartMenuBinFile -Destination $currentBinBackup -Force
}
Copy-Item -Path $backupBinFile -Destination $StartMenuBinFile -Force
return [PSCustomObject]@{
UserName = $userName
Result = $true
Message = "Restored start menu for user $userName."
}
}
catch {
return [PSCustomObject]@{
UserName = $userName
Result = $false
Message = "Failed to restore start menu for user $userName. $($_.Exception.Message)"
}
}
}
<#
.SYNOPSIS
Restores the start menu for the current target user from a backup.
.DESCRIPTION
Resolves the start2.bin path for the currently logged-in user, then
delegates to RestoreStartMenuFromBackup.
.PARAMETER BackupFilePath
Path to the backup file to restore from. If omitted, automatically
finds the latest Win11Debloat-StartBackup-*.bak file.
.EXAMPLE
RestoreStartMenu
.EXAMPLE
RestoreStartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
#>
function RestoreStartMenu {
param(
[string]$BackupFilePath
)
$targetUserName = $env:USERNAME
$startMenuBinFile = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
Write-Host "Restoring start menu for user $targetUserName from backup..."
return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
}
<#
.SYNOPSIS
Restores the start menu for all user profiles from a backup.
.DESCRIPTION
Iterates over every existing user profile and restores each user's
start2.bin from the latest backup in their LocalState folder. For the
Default user profile, removes the start2.bin file (which was previously
copied from a template) so that new profiles revert to the system
default start menu.
.PARAMETER BackupFilePath
Path to the backup file to restore from. If omitted, automatically
finds the latest Win11Debloat-StartBackup-*.bak in each user's
LocalState folder.
.EXAMPLE
RestoreStartMenuForAllUsers
.EXAMPLE
RestoreStartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
#>
function RestoreStartMenuForAllUsers {
param(
[string]$BackupFilePath
)
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
$results = @()
Write-Host "Restoring start menu for all users from backup..."
foreach ($startMenuPath in $usersStartMenuPaths) {
$startMenuBinFile = Join-Path $startMenuPath.FullName 'start2.bin'
$results += RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
}
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
if (Test-Path $defaultStartMenuPath) {
$defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin'
if (Test-Path -LiteralPath $defaultStartMenuBinFile) {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Remove start2.bin for the default user profile" -ForegroundColor Cyan
$results += [PSCustomObject]@{
UserName = 'Default'
Result = $true
Message = '[WhatIf] Removed start2.bin for the default user profile.'
}
}
else {
try {
Remove-Item -LiteralPath $defaultStartMenuBinFile -Force
$results += [PSCustomObject]@{
UserName = 'Default'
Result = $true
Message = 'Removed start2.bin for the default user profile.'
}
}
catch {
$results += [PSCustomObject]@{
UserName = 'Default'
Result = $false
Message = "Failed to remove start2.bin for the default user profile. $($_.Exception.Message)"
}
}
}
}
}
if ($results.Count -eq 0) {
$results += [PSCustomObject]@{
UserName = 'unknown'
Result = $false
Message = 'No user start menu locations were found.'
}
}
return $results
}

View File

@@ -1,8 +1,18 @@
# 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
}
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("Sysprep") -or $script:Params.ContainsKey("User") -or $script:Params.ContainsKey("NoRestartExplorer")) {
if ($script:Params.ContainsKey("NoRestartExplorer")) {
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
return
}
@@ -10,7 +20,7 @@ function RestartExplorer {
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.Action) $($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
Write-Host "Warning: '$($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
}
}

View File

@@ -0,0 +1,224 @@
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)
}
}

View File

@@ -0,0 +1,162 @@
function Load-RegistryBackupFromFile {
param(
[Parameter(Mandatory)]
[string]$FilePath
)
if (-not (Test-Path -LiteralPath $FilePath)) {
throw "Backup file was not found: $FilePath"
}
try {
$rawBackup = Get-Content -LiteralPath $FilePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Failed to read backup file '$FilePath'. The file is not valid JSON."
}
return Normalize-RegistryBackup -Backup $rawBackup
}
function Normalize-RegistryBackup {
param(
[Parameter(Mandatory)]
$Backup
)
$errors = New-Object System.Collections.Generic.List[string]
if (-not $Backup.PSObject.Properties['Version']) {
$errors.Add('Missing property: Version')
}
elseif ([string]$Backup.Version -ne '1.0') {
$errors.Add("Unsupported backup version '$($Backup.Version)'.")
}
if (-not $Backup.PSObject.Properties['BackupType']) {
$errors.Add('Missing property: BackupType')
}
elseif ([string]$Backup.BackupType -ne 'RegistryState') {
$errors.Add("Unsupported BackupType '$($Backup.BackupType)'.")
}
$normalizedTarget = ''
if (-not $Backup.PSObject.Properties['Target'] -or [string]::IsNullOrWhiteSpace([string]$Backup.Target)) {
$errors.Add('Missing property: Target')
}
else {
$normalizedTarget = [string]$Backup.Target
if ($normalizedTarget -eq 'DefaultUserProfile') {
# Valid target format.
}
elseif ($normalizedTarget -like 'User:*') {
$targetUserName = $normalizedTarget.Substring(5)
$targetValidation = Test-TargetUserName -UserName $targetUserName
if (-not $targetValidation.IsValid) {
$errors.Add("Invalid user '$normalizedTarget'")
}
}
elseif ($normalizedTarget -like 'CurrentUser:*') {
$targetCurrentUserName = $normalizedTarget.Substring(12)
if ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or ($targetCurrentUserName -ne $env:USERNAME)) {
$errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$env:USERNAME'.")
}
}
else {
$errors.Add("Unsupported Target '$normalizedTarget'.")
}
}
$registryKeys = @()
if (-not $Backup.PSObject.Properties['RegistryKeys']) {
$errors.Add('Missing property: RegistryKeys')
}
else {
$registryKeys = @($Backup.RegistryKeys)
}
$normalizedKeys = @()
foreach ($keySnapshot in $registryKeys) {
$normalizedKeys += @(Normalize-RegistryKeySnapshot -Snapshot $keySnapshot)
}
$selectedFeatureParseResult = Get-NormalizedSelectedFeatureIdsFromBackup -Backup $Backup
$selectedFeatures = @($selectedFeatureParseResult.SelectedFeatures)
foreach ($selectedFeatureParseError in @($selectedFeatureParseResult.Errors)) {
$errors.Add([string]$selectedFeatureParseError)
}
$selectedUndoFeatureParseResult = Get-NormalizedSelectedUndoFeatureIdsFromBackup -Backup $Backup
$selectedUndoFeatures = @($selectedUndoFeatureParseResult.SelectedUndoFeatures)
foreach ($selectedUndoFeatureParseError in @($selectedUndoFeatureParseResult.Errors)) {
$errors.Add([string]$selectedUndoFeatureParseError)
}
$allSelectedFeatures = @($selectedFeatures) + @($selectedUndoFeatures)
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)
}
if ($errors.Count -gt 0) {
Write-Error "Backup validation failed: $($errors -join ' ')"
if ($errors.Count -eq 1) {
throw ("Validation failed: $($errors[0])")
}
else {
throw ("Validation failed with $($errors.Count) errors. See console output for details.")
}
}
return [PSCustomObject]@{
Version = [string]$Backup.Version
BackupType = [string]$Backup.BackupType
CreatedAt = [string]$Backup.CreatedAt
CreatedBy = [string]$Backup.CreatedBy
ComputerName = [string]$Backup.ComputerName
Target = $normalizedTarget
SelectedFeatures = @($selectedFeatures)
SelectedUndoFeatures = @($selectedUndoFeatures)
RegistryKeys = @($normalizedKeys)
}
}
function Restore-RegistryBackupState {
param(
[Parameter(Mandatory)]
$Backup
)
$friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Restore registry backup for $friendlyTarget" -ForegroundColor Cyan
return [PSCustomObject]@{ Result = $true }
}
$restoreAction = {
param($normalizedBackup)
Write-Host "Applying registry restore from $(@($normalizedBackup.RegistryKeys).Count) root snapshot(s)."
foreach ($rootSnapshot in @($normalizedBackup.RegistryKeys)) {
Restore-RegistryKeySnapshot -Snapshot $rootSnapshot
}
}
Write-Host "Starting restore for $friendlyTarget."
if ($Backup.Target -eq 'DefaultUserProfile' -or $Backup.Target -like 'User:*') {
Write-Host "Restore requires loading target user hive."
Invoke-WithLoadedRestoreHive -Target $Backup.Target -ScriptBlock $restoreAction -ArgumentObject $Backup
Write-Host "Restore completed for $friendlyTarget."
return [PSCustomObject]@{ Result = $true }
}
& $restoreAction $Backup
Write-Host "Restore completed for $friendlyTarget."
return [PSCustomObject]@{ Result = $true }
}

View File

@@ -0,0 +1,307 @@
<#
.SYNOPSIS
Disables Microsoft Store search suggestions in the start menu for all user profiles.
.DESCRIPTION
Iterates over every existing user profile and the Default user profile,
denying the EVERYONE group FullControl access to each user's Store app
database file (store.db). This prevents Windows from showing Store search
suggestions in the start menu search pane.
.EXAMPLE
DisableStoreSearchSuggestionsForAllUsers
#>
function DisableStoreSearchSuggestionsForAllUsers {
# Get path to Store app database for all users
$userPathString = GetUserDirectory -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")
}
# Also disable start search suggestions for the default user profile
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
DisableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
}
}
<#
.SYNOPSIS
Disables Microsoft Store search suggestions for a single user.
.DESCRIPTION
Denies the EVERYONE group FullControl access to the specified Store app
database file (store.db). If the file does not exist (e.g. on EEA systems
where Store app suggestions are absent by default), it creates the file
and its parent directory first to prevent Windows from recreating it later.
.PARAMETER StoreAppsDatabase
The full path to the user's store.db file.
.EXAMPLE
DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
#>
function DisableStoreSearchSuggestions {
param (
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
)
$userName = [regex]::Match($StoreAppsDatabase, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
if (-not $userName) { $userName = '<unknown>' }
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan
return
}
# 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
if (-not (Test-Path -Path $storeDbDir)) {
New-Item -Path $storeDbDir -ItemType Directory -Force | Out-Null
}
New-Item -Path $StoreAppsDatabase -ItemType File -Force | Out-Null
}
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
$Acl = Get-Acl -Path $StoreAppsDatabase
$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"
}
<#
.SYNOPSIS
Re-enables Microsoft Store search suggestions in the start menu for all user profiles.
.DESCRIPTION
Iterates over every existing user profile and the Default user profile,
removing the deny ACL from each user's Store app database file (store.db)
and then deleting the file. This restores the default Windows behavior
where Store search suggestions appear in the start menu.
.EXAMPLE
EnableStoreSearchSuggestionsForAllUsers
#>
function EnableStoreSearchSuggestionsForAllUsers {
# Get path to Store app database for all users
$userPathString = GetUserDirectory -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")
}
# Also re-enable for the default user profile
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
if ($defaultStoreDbPath) {
EnableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
}
}
<#
.SYNOPSIS
Re-enables Microsoft Store search suggestions for a single user.
.DESCRIPTION
Takes ownership of the specified Store app database file, removes any
EVERYONE deny FullControl ACL entries, and deletes the file. If the file
does not exist, no action is taken. Callers should handle the case where
the file is absent gracefully.
.PARAMETER StoreAppsDatabase
The full path to the user's store.db file.
.EXAMPLE
EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
#>
function EnableStoreSearchSuggestions {
param (
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
)
$userName = [regex]::Match($StoreAppsDatabase, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
if (-not $userName) { $userName = '<unknown>' }
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
}
if (-not (Test-Path -Path $StoreAppsDatabase)) {
Write-Host "Store app database not found for user $userName, nothing to undo"
return
}
# Ensure we can modify/delete the file even if restrictive ACLs were set.
$global:LASTEXITCODE = 0
takeown /F "$StoreAppsDatabase" /A | Out-Null
icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
try {
$acl = Get-Acl -Path $StoreAppsDatabase
$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 })
}
)
foreach ($denyRule in $denyRules) {
$null = $acl.RemoveAccessRuleSpecific($denyRule)
}
Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null
}
catch {
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
}
try {
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
}
catch {
throw "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
}
}
<#
.SYNOPSIS
Returns the full path to the Store app database file for a given user.
.DESCRIPTION
Resolves the path to the Microsoft Store app database (store.db) for the
specified username. When no username is provided or the value is empty,
falls back to the current user's local app data path via $env:LOCALAPPDATA.
.PARAMETER UserName
The target username. Pass an empty string or omit to resolve for the current user.
.EXAMPLE
GetStoreAppsDatabasePathForUser -UserName "Jeff"
.EXAMPLE
GetStoreAppsDatabasePathForUser -UserName "Default"
#>
function GetStoreAppsDatabasePathForUser {
param(
[string]$UserName
)
if ([string]::IsNullOrWhiteSpace($UserName)) {
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)
}
<#
.SYNOPSIS
Tests whether Store search suggestions are disabled for a single user.
.DESCRIPTION
Checks whether the specified store.db file has an EVERYONE deny
FullControl ACL entry applied. Returns $true if the deny rule is present,
$false otherwise (including when the file or directory does not exist).
.PARAMETER StoreAppsDatabase
The full path to the user's store.db file.
.EXAMPLE
Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase "C:\Users\Jeff\AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
#>
function Test-StoreSearchSuggestionsDisabled {
param(
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
)
if (-not (Test-Path -Path $StoreAppsDatabase)) {
return $false
}
try {
$acl = Get-Acl -Path $StoreAppsDatabase
}
catch {
return $false
}
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0')
foreach ($accessRule in @($acl.Access)) {
$isDenyFullControl = $accessRule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($accessRule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0)
if (-not $isDenyFullControl) { continue }
$isEveryone = $false
try {
$isEveryone = $accessRule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid
}
catch { }
if ($isEveryone) {
return $true
}
}
return $false
}
<#
.SYNOPSIS
Tests whether Store search suggestions are disabled for all user profiles.
.DESCRIPTION
Collects the store.db paths for all existing user profiles and the Default
user profile, then verifies that every one of them has the EVERYONE deny
FullControl ACL applied. Returns $true only if ALL paths pass the check.
Returns $false immediately if any user's store.db is not disabled.
.EXAMPLE
Test-StoreSearchSuggestionsDisabledForAllUsers
#>
function Test-StoreSearchSuggestionsDisabledForAllUsers {
$paths = @()
$userPathString = GetUserDirectory -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"
if ($defaultStoreDbPath) {
$paths += $defaultStoreDbPath
}
if ($paths.Count -eq 0) {
return $false
}
foreach ($path in $paths) {
if (-not (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $path)) {
return $false
}
}
return $true
}

View File

@@ -0,0 +1,128 @@
# List of known Windows telemetry-related scheduled tasks
<#
.SYNOPSIS
Returns the list of known Windows telemetry-related scheduled tasks.
.DESCRIPTION
Returns an array of hashtables, each with a Path and Name key, representing
scheduled tasks that collect or report telemetry data on Windows.
.EXAMPLE
Get-TelemetryScheduledTasks
#>
function Get-TelemetryScheduledTasks {
return @(
@{ Path = "\Microsoft\Windows\Application Experience\"; Name = "Microsoft Compatibility Appraiser" },
@{ Path = "\Microsoft\Windows\Application Experience\"; Name = "Microsoft Compatibility Appraiser Exp" },
@{ Path = "\Microsoft\Windows\Application Experience\"; Name = "ProgramDataUpdater" },
@{ Path = "\Microsoft\Windows\Application Experience\"; Name = "StartupAppTask" },
@{ Path = "\Microsoft\Windows\Customer Experience Improvement Program\"; Name = "Consolidator" },
@{ Path = "\Microsoft\Windows\Customer Experience Improvement Program\"; Name = "UsbCeip" },
@{ Path = "\Microsoft\Windows\DiskDiagnostic\"; Name = "Microsoft-Windows-DiskDiagnosticDataCollector" },
@{ Path = "\Microsoft\Windows\Autochk\"; Name = "Proxy" }
)
}
<#
.SYNOPSIS
Disables known Windows telemetry-related scheduled tasks.
.DESCRIPTION
Iterates over a predefined list of Windows scheduled tasks associated with
telemetry and disables each one that exists and is not already disabled.
Supports -WhatIf to preview changes without applying them.
.EXAMPLE
Disable-TelemetryScheduledTasks
#>
function Disable-TelemetryScheduledTasks {
Write-Host "> Disabling telemetry scheduled tasks..."
$tasks = Get-TelemetryScheduledTasks
foreach ($task in $tasks) {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
continue
}
$result = Invoke-NonBlocking -ScriptBlock {
param($path, $name)
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
if (-not $taskObj) {
return @{ Success = $true; Status = 'NotFound' }
}
if ($taskObj.State -ne 'Disabled') {
try {
Disable-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop | Out-Null
return @{ Success = $true; Status = 'Disabled' }
}
catch {
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
}
}
return @{ Success = $true; Status = 'AlreadyDisabled' }
} -ArgumentList @($task.Path, $task.Name)
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 }
}
}
Write-Host ""
}
<#
.SYNOPSIS
Enables known Windows telemetry-related scheduled tasks.
.DESCRIPTION
Iterates over a predefined list of Windows scheduled tasks associated with
telemetry and enables each one that exists and is currently disabled.
Supports -WhatIf to preview changes without applying them.
.EXAMPLE
Enable-TelemetryScheduledTasks
#>
function Enable-TelemetryScheduledTasks {
Write-Host "> Enabling telemetry scheduled tasks..."
$tasks = Get-TelemetryScheduledTasks
foreach ($task in $tasks) {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
continue
}
$result = Invoke-NonBlocking -ScriptBlock {
param($path, $name)
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
if (-not $taskObj) {
return @{ Success = $true; Status = 'NotFound' }
}
if ($taskObj.State -eq 'Disabled') {
try {
Enable-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop | Out-Null
return @{ Success = $true; Status = 'Enabled' }
}
catch {
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
}
}
return @{ Success = $true; Status = 'AlreadyEnabled' }
} -ArgumentList @($task.Path, $task.Name)
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 }
}
}
Write-Host ""
}

View File

@@ -0,0 +1,61 @@
# 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')
}

View File

@@ -2,7 +2,7 @@
function LoadAppsDetailsFromJson {
param (
[switch]$OnlyInstalled,
[string]$InstalledList = "",
[object[]]$InstalledList = $null,
[switch]$InitialCheckedFromJson
)
@@ -24,15 +24,19 @@ function LoadAppsDetailsFromJson {
if ($OnlyInstalled) {
$isInstalled = $false
foreach ($appId in $appIdArray) {
if (($InstalledList -like ("*$appId*")) -or (Get-AppxPackage -Name $appId)) {
# Check Get-AppxPackage first (fast, no process launch)
if (Get-AppxPackage -Name $appId) {
$isInstalled = $true
break
}
if (($appId -eq "Microsoft.Edge") -and ($InstalledList -like "* Microsoft.Edge *")) {
# Then check the pre-fetched winget list
if ($InstalledList -and (Test-AppInWingetList -appId $appId -InstalledList $InstalledList)) {
$isInstalled = $true
break
}
}
if (-not $isInstalled) { continue }
}
@@ -52,6 +56,7 @@ function LoadAppsDetailsFromJson {
Description = $appData.Description
SelectedByDefault = $appData.SelectedByDefault
Recommendation = $appData.Recommendation
RemovalMethod = if ($appData.RemovalMethod -and $appData.RemovalMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
}
}

View File

@@ -1,4 +1,19 @@
# Returns list of apps from the specified file, it trims the app names and removes any comments
<#
.SYNOPSIS
Returns a list of app IDs from the specified JSON file.
.DESCRIPTION
Reads an Apps.json file and returns the AppIds for every entry where
SelectedByDefault is $true. Each app entry may declare a single AppId
or an array of AppIds; both forms are handled transparently.
.PARAMETER appsFilePath
Path to a JSON file in the Config/Apps.json format.
.OUTPUTS
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 {
param (
$appsFilePath
@@ -11,30 +26,14 @@ function LoadAppsFromFile {
}
try {
# Check if file is JSON or text format
if ($appsFilePath -like "*.json") {
# JSON file format
$jsonContent = Get-Content -Path $appsFilePath -Raw | ConvertFrom-Json
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 }
$selectedByDefault = $appData.SelectedByDefault
if ($selectedByDefault -and $appIdArray.Count -gt 0) {
$appsList += $appIdArray
}
}
}
else {
# Legacy text file format
Foreach ($app in (Get-Content -Path $appsFilePath | Where-Object { $_ -notmatch '^#.*' -and $_ -notmatch '^\s*$' } )) {
if (-not ($app.IndexOf('#') -eq -1)) {
$app = $app.Substring(0, $app.IndexOf('#'))
}
$app = $app.Trim()
$appString = $app.Trim('*')
$appsList += $appString
$jsonContent = Get-Content -Path $appsFilePath -Raw | ConvertFrom-Json
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 }
$selectedByDefault = $appData.SelectedByDefault
if ($selectedByDefault -and $appIdArray.Count -gt 0) {
$appsList += $appIdArray
}
}

View File

@@ -21,6 +21,9 @@ function LoadSettings {
$feature = $script:Features[$setting.Name]
# Skip unknown settings that aren't defined in Features.json
if (-not $feature) { continue }
# Check version and feature compatibility using Features.json
if (($feature.MinVersion -and $WinVersion -lt $feature.MinVersion) -or ($feature.MaxVersion -and $WinVersion -gt $feature.MaxVersion) -or ($feature.FeatureId -eq 'DisableModernStandbyNetworking' -and (-not $script:ModernStandbySupported))) {
continue

View File

@@ -1,15 +0,0 @@
# Saves the provided appsList to the CustomAppsList file
function SaveCustomAppsListToFile {
param (
$appsList
)
$script:SelectedApps = $appsList
# Create file that stores selected apps if it doesn't exist
if (-not (Test-Path $script:CustomAppsListFilePath)) {
$null = New-Item $script:CustomAppsListFilePath -ItemType File
}
Set-Content -Path $script:CustomAppsListFilePath -Value $script:SelectedApps
}

View File

@@ -1,12 +1,17 @@
# Saves the current settings, excluding control parameters, to 'LastUsedSettings.json' file
function SaveSettings {
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan
return
}
$settings = @{
"Version" = "1.0"
"Settings" = @()
}
foreach ($param in $script:Params.Keys) {
if ($script:ControlParams -notcontains $param) {
if ($script:ControlParams -notcontains $param -and $script:Features.ContainsKey($param)) {
$value = $script:Params[$param]
$settings.Settings += @{

View File

@@ -6,11 +6,14 @@ function SaveToFile {
[hashtable]$Config,
[Parameter(Mandatory=$true)]
[string]$FilePath
[string]$FilePath,
[Parameter(Mandatory=$false)]
[int]$MaxDepth = 10
)
try {
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $FilePath -Encoding UTF8
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8
return $true
}
catch {

View File

@@ -1,7 +1,14 @@
# Checks if the system is set to use dark mode for apps
function GetSystemUsesDarkMode {
try {
return (Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' -Name 'AppsUseLightTheme').AppsUseLightTheme -eq 0
$personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
if ($null -eq $personalizeKey) {
Write-Host "WARNING: Unable to retrieve personalization settings." -ForegroundColor Yellow
return $false
}
return $personalizeKey.AppsUseLightTheme -eq 0
}
catch {
return $false

View File

@@ -0,0 +1,545 @@
# MainWindow-AppSelection.ps1
# App-selection panel functions: tri-state helpers, sorting, search/highlight, app loading, preset management, and removal scope.
function Add-TriStateClickBehavior {
param([System.Windows.Controls.CheckBox]$CheckBox)
if (-not $CheckBox -or -not $CheckBox.IsThreeState) { return }
if (-not $CheckBox.PSObject.Properties['WasIndeterminateBeforeClick']) {
Add-Member -InputObject $CheckBox -MemberType NoteProperty -Name 'WasIndeterminateBeforeClick' -Value $false
}
$CheckBox.Add_PreviewMouseLeftButtonDown({
$this.WasIndeterminateBeforeClick = ($this.IsChecked -eq [System.Nullable[bool]]$null)
})
}
function ConvertTo-NormalizedCheckboxState {
param([System.Windows.Controls.CheckBox]$CheckBox)
if ($CheckBox.PSObject.Properties['WasIndeterminateBeforeClick'] -and $CheckBox.WasIndeterminateBeforeClick) {
# WPF toggles null -> false before Click handlers fire; restore desired mixed -> checked behavior.
$CheckBox.WasIndeterminateBeforeClick = $false
$CheckBox.IsChecked = $true
return $true
}
return ($CheckBox.IsChecked -eq $true)
}
function Set-TriStatePresetCheckBoxState {
param(
[System.Windows.Controls.CheckBox]$CheckBox,
[int]$Total,
[int]$Selected
)
if (-not $CheckBox) { return }
if ($Total -eq 0) {
$CheckBox.IsEnabled = $false
$CheckBox.IsChecked = $false
return
}
$CheckBox.IsEnabled = $true
if ($Selected -eq 0) {
$CheckBox.IsChecked = $false
}
elseif ($Selected -eq $Total) {
$CheckBox.IsChecked = $true
}
else {
$CheckBox.IsChecked = [System.Nullable[bool]]$null
}
}
function Update-SortArrows {
param(
[System.Windows.Controls.TextBlock]$SortArrowName,
[System.Windows.Controls.TextBlock]$SortArrowDescription,
[System.Windows.Controls.TextBlock]$SortArrowAppId
)
$ease = New-Object System.Windows.Media.Animation.CubicEase
$ease.EasingMode = 'EaseOut'
$arrows = @{
'Name' = $SortArrowName
'Description' = $SortArrowDescription
'AppId' = $SortArrowAppId
}
foreach ($col in $arrows.Keys) {
$tb = $arrows[$col]
# Active column: full opacity, rotate to indicate direction (0 = up/asc, 180 = down/desc)
# Inactive columns: dim, reset to 0
if ($col -eq $script:SortColumn) {
$targetAngle = if ($script:SortAscending) { 0 } else { 180 }
$tb.Opacity = 1.0
}
else {
$targetAngle = 0
$tb.Opacity = 0.3
}
$anim = New-Object System.Windows.Media.Animation.DoubleAnimation
$anim.To = $targetAngle
$anim.Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromMilliseconds(200))
$anim.EasingFunction = $ease
$tb.RenderTransform.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $anim)
}
}
function Update-AppsPanelRebuildSearchIndex {
param(
[System.Windows.Controls.Panel]$AppsPanel,
$ActiveMatch = $null
)
$newMatches = @()
$newActiveIndex = -1
$i = 0
foreach ($child in $AppsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox] -and $child.Background -ne [System.Windows.Media.Brushes]::Transparent) {
$newMatches += $child
if ($null -ne $ActiveMatch -and [System.Object]::ReferenceEquals($child, $ActiveMatch)) {
$newActiveIndex = $i
}
$i++
}
}
$script:AppSearchMatches = $newMatches
$script:AppSearchMatchIndex = if ($newActiveIndex -ge 0) { $newActiveIndex } elseif ($newMatches.Count -gt 0) { 0 } else { -1 }
}
function Update-AppsPanelSort {
param(
[System.Windows.Controls.Panel]$AppsPanel,
[System.Windows.Controls.TextBlock]$SortArrowName,
[System.Windows.Controls.TextBlock]$SortArrowDescription,
[System.Windows.Controls.TextBlock]$SortArrowAppId
)
$children = @($AppsPanel.Children)
$key = switch ($script:SortColumn) {
'Name' { { $_.AppName } }
'Description' { { $_.AppDescription } }
'AppId' { { $_.AppIdDisplay } }
}
$sorted = $children | Sort-Object $key -Descending:(-not $script:SortAscending)
$AppsPanel.Children.Clear()
foreach ($checkbox in $sorted) {
$AppsPanel.Children.Add($checkbox) | Out-Null
}
Update-SortArrows -SortArrowName $SortArrowName -SortArrowDescription $SortArrowDescription -SortArrowAppId $SortArrowAppId
# Rebuild search match list in new sorted order so keyboard navigation stays correct
if ($script:AppSearchMatches.Count -gt 0) {
$activeMatch = if ($script:AppSearchMatchIndex -ge 0 -and $script:AppSearchMatchIndex -lt $script:AppSearchMatches.Count) {
$script:AppSearchMatches[$script:AppSearchMatchIndex]
}
else { $null }
Update-AppsPanelRebuildSearchIndex -AppsPanel $AppsPanel -ActiveMatch $activeMatch
}
}
function Update-AppSelectionStatus {
param(
[System.Windows.Controls.Panel]$AppsPanel,
[System.Windows.Controls.TextBlock]$AppSelectionStatus,
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
[System.Windows.Controls.Border]$AppRemovalScopeSection,
[System.Windows.Controls.TextBlock]$AppRemovalScopeDescription,
[System.Windows.Controls.ComboBox]$UserSelectionCombo
)
$selectedCount = 0
foreach ($child in $AppsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox] -and $child.IsChecked) {
$selectedCount++
}
}
$AppSelectionStatus.Text = "$selectedCount app(s) selected for removal"
if ($AppRemovalScopeCombo -and $AppRemovalScopeSection -and $AppRemovalScopeDescription) {
if ($selectedCount -gt 0) {
$AppRemovalScopeSection.Visibility = 'Visible'
if ($UserSelectionCombo.SelectedIndex -ne 2) {
$AppRemovalScopeCombo.IsEnabled = $true
}
Update-AppRemovalScopeDescription -AppRemovalScopeCombo $AppRemovalScopeCombo -AppRemovalScopeDescription $AppRemovalScopeDescription
}
else {
$AppRemovalScopeSection.Visibility = 'Collapsed'
}
}
}
function Update-AppRemovalScopeDescription {
param(
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
[System.Windows.Controls.TextBlock]$AppRemovalScopeDescription
)
$selectedItem = $AppRemovalScopeCombo.SelectedItem
if ($selectedItem) {
switch ($selectedItem.Content) {
"All users" {
$AppRemovalScopeDescription.Text = "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users."
}
"Current user only" {
$AppRemovalScopeDescription.Text = "Apps will only be removed for the current user."
}
"Target user only" {
$AppRemovalScopeDescription.Text = "Apps will only be removed for the specified target user."
}
}
}
}
function Invoke-AppPreset {
param(
[System.Windows.Controls.Panel]$AppsPanel,
[scriptblock]$MatchFilter,
[bool]$Check,
[switch]$Exclusive
)
foreach ($child in $AppsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
if ($Exclusive) {
$child.IsChecked = (& $MatchFilter $child)
}
elseif (& $MatchFilter $child) {
$child.IsChecked = $Check
}
}
}
Update-AppPresetStates -AppsPanel $AppsPanel
}
function Update-AppPresetStates {
param([System.Windows.Controls.Panel]$AppsPanel)
$script:UpdatingPresets = $true
try {
# Helper: count matching and checked apps, set checkbox state
function SetPresetState($CheckBox, [scriptblock]$MatchFilter) {
$total = 0; $checked = 0
foreach ($child in $AppsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
if (& $MatchFilter $child) {
$total++
if ($child.IsChecked) { $checked++ }
}
}
}
Set-TriStatePresetCheckBoxState -CheckBox $CheckBox -Total $total -Selected $checked
}
# Find preset checkboxes via window
$window = $script:MainWindow
$presetDefaultApps = $window.FindName('PresetDefaultApps')
$presetLastUsed = $window.FindName('PresetLastUsed')
SetPresetState $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()
}
# 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 }
}
}
finally {
$script:UpdatingPresets = $false
}
}
function Scroll-ToItemIfNotVisible {
param (
[System.Windows.Controls.ScrollViewer]$ScrollViewer,
[System.Windows.UIElement]$Item,
[System.Windows.UIElement]$Container
)
if (-not $ScrollViewer -or -not $Item -or -not $Container) { return }
try {
$itemPosition = $Item.TransformToAncestor($Container).Transform([System.Windows.Point]::new(0, 0)).Y
$viewportHeight = $ScrollViewer.ViewportHeight
$itemHeight = $Item.ActualHeight
$currentOffset = $ScrollViewer.VerticalOffset
# Check if the item is currently visible in the viewport
$itemTop = $itemPosition - $currentOffset
$itemBottom = $itemTop + $itemHeight
$isVisible = ($itemTop -ge 0) -and ($itemBottom -le $viewportHeight)
# Only scroll if the item is not visible
if (-not $isVisible) {
# Center the item in the viewport
$targetOffset = $itemPosition - ($viewportHeight / 2) + ($itemHeight / 2)
$ScrollViewer.ScrollToVerticalOffset([Math]::Max(0, $targetOffset))
}
}
catch {
# Fallback to simple bring into view
$Item.BringIntoView()
}
}
function Find-ParentScrollViewer {
param ([System.Windows.UIElement]$Element)
$parent = [System.Windows.Media.VisualTreeHelper]::GetParent($Element)
while ($null -ne $parent) {
if ($parent -is [System.Windows.Controls.ScrollViewer]) {
return $parent
}
$parent = [System.Windows.Media.VisualTreeHelper]::GetParent($parent)
}
return $null
}
function Load-AppsWithList {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Panel]$AppsPanel,
[System.Windows.Controls.CheckBox]$OnlyInstalledAppsBox,
[System.Windows.Controls.Border]$LoadingAppsIndicator,
[System.Windows.Controls.MenuItem]$ImportConfigBtn,
[object[]]$ListOfApps
)
$script:MainWindowLastSelectedCheckbox = $null
$loaderScriptPath = $script:LoadAppsDetailsScriptPath
$helperScriptPath = $script:TestAppInWingetListScriptPath
$appsFilePath = $script:AppsListFilePath
$onlyInstalled = [bool]$OnlyInstalledAppsBox.IsChecked
# Use preloaded data if available; otherwise load in background job
if (-not $onlyInstalled -and $script:PreloadedAppData) {
$rawAppData = $script:PreloadedAppData
$script:PreloadedAppData = $null
}
else {
# Load apps details in a background job to keep the UI responsive.
# The helper is dot-sourced inside the job because the runspace
# does not inherit the parent scope's dot-sourced functions.
$rawAppData = Invoke-NonBlocking -ScriptBlock {
param($loaderScript, $helperScript, $appsListFilePath, $installedList, $onlyInstalled)
$script:AppsListFilePath = $appsListFilePath
. $helperScript
. $loaderScript
LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
} -ArgumentList $loaderScriptPath, $helperScriptPath, $appsFilePath, $ListOfApps, $onlyInstalled
}
$appsToAdd = @($rawAppData | Where-Object { $_ -and ($_.AppId -or $_.FriendlyName) } | Sort-Object -Property FriendlyName)
$LoadingAppsIndicator.Visibility = 'Collapsed'
if ($appsToAdd.Count -eq 0) {
$OnlyInstalledAppsBox.IsHitTestVisible = $true
$Window.FindName('DeploymentApplyBtn').IsEnabled = $true
if ($ImportConfigBtn) {
$ImportConfigBtn.IsEnabled = $true
}
return
}
$brushSafe = $Window.Resources['AppRecommendationSafeColor']
$brushDefault = $Window.Resources['AppRecommendationOptionalColor']
$brushUnsafe = $Window.Resources['AppRecommendationUnsafeColor']
# Create WPF controls; pump the Dispatcher every batch so the spinner keeps animating.
$batchSize = 20
for ($i = 0; $i -lt $appsToAdd.Count; $i++) {
$app = $appsToAdd[$i]
$checkbox = New-Object System.Windows.Controls.CheckBox
$automationName = if ($app.FriendlyName) { $app.FriendlyName } elseif ($app.AppIdDisplay) { $app.AppIdDisplay } else { $null }
if ($automationName) { $checkbox.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $automationName) }
$checkbox.Tag = $app.AppIdDisplay
$checkbox.IsChecked = $app.IsChecked
$checkbox.Style = $Window.Resources['AppsPanelCheckBoxStyle']
# Build table row: Recommendation dot | Name | Description | App ID
$row = New-Object System.Windows.Controls.Grid
$row.Style = $Window.Resources['AppTableRowStyle']
$c0 = New-Object System.Windows.Controls.ColumnDefinition; $c0.Width = $Window.Resources['AppTableDotColWidth']
$c1 = New-Object System.Windows.Controls.ColumnDefinition; $c1.Width = $Window.Resources['AppTableNameColWidth']
$c2 = New-Object System.Windows.Controls.ColumnDefinition; $c2.Width = $Window.Resources['AppTableDescColWidth']
$c3 = New-Object System.Windows.Controls.ColumnDefinition; $c3.Width = $Window.Resources['AppTableIdColWidth']
$row.ColumnDefinitions.Add($c0); $row.ColumnDefinitions.Add($c1)
$row.ColumnDefinitions.Add($c2); $row.ColumnDefinitions.Add($c3)
$dot = New-Object System.Windows.Shapes.Ellipse
$dot.Style = $Window.Resources['AppRecommendationDotStyle']
$dot.Fill = switch ($app.Recommendation) { 'safe' { $brushSafe } 'unsafe' { $brushUnsafe } default { $brushDefault } }
$dot.ToolTip = switch ($app.Recommendation) {
'safe' { '[Recommended] Safe to remove for most users' }
'unsafe' { '[Not Recommended] Only remove if you know what you are doing' }
default { "[Optional] Can be safely removed if you don't need this app" }
}
[System.Windows.Controls.Grid]::SetColumn($dot, 0)
$tbName = New-Object System.Windows.Controls.TextBlock
$tbName.Text = $app.FriendlyName
$tbName.Style = $Window.Resources['AppNameTextStyle']
[System.Windows.Controls.Grid]::SetColumn($tbName, 1)
$tbDesc = New-Object System.Windows.Controls.TextBlock
$tbDesc.Text = $app.Description
$tbDesc.Style = $Window.Resources['AppDescTextStyle']
$tbDesc.ToolTip = $app.Description
[System.Windows.Controls.Grid]::SetColumn($tbDesc, 2)
$tbId = New-Object System.Windows.Controls.TextBlock
$tbId.Text = $app.AppIdDisplay
$tbId.Style = $Window.Resources["AppIdTextStyle"]
$tbId.ToolTip = $app.AppIdDisplay
[System.Windows.Controls.Grid]::SetColumn($tbId, 3)
$row.Children.Add($dot) | Out-Null
$row.Children.Add($tbName) | Out-Null
$row.Children.Add($tbDesc) | Out-Null
$row.Children.Add($tbId) | Out-Null
$checkbox.Content = $row
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'AppName' -Value $app.FriendlyName
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'AppDescription' -Value $app.Description
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'SelectedByDefault' -Value $app.SelectedByDefault
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'AppIds' -Value @($app.AppId)
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'AppIdDisplay' -Value $app.AppIdDisplay
$checkbox.Add_Checked({
$w = $script:MainWindow
Update-AppSelectionStatus -AppsPanel $w.FindName('AppSelectionPanel') `
-AppSelectionStatus $w.FindName('AppSelectionStatus') `
-AppRemovalScopeCombo $w.FindName('AppRemovalScopeCombo') `
-AppRemovalScopeSection $w.FindName('AppRemovalScopeSection') `
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
-UserSelectionCombo $w.FindName('UserSelectionCombo')
})
$checkbox.Add_Unchecked({
$w = $script:MainWindow
Update-AppSelectionStatus -AppsPanel $w.FindName('AppSelectionPanel') `
-AppSelectionStatus $w.FindName('AppSelectionStatus') `
-AppRemovalScopeCombo $w.FindName('AppRemovalScopeCombo') `
-AppRemovalScopeSection $w.FindName('AppRemovalScopeSection') `
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
-UserSelectionCombo $w.FindName('UserSelectionCombo')
})
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
-lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) `
-updateStatusCallback {
$w = $script:MainWindow
Update-AppSelectionStatus -AppsPanel $w.FindName('AppSelectionPanel') `
-AppSelectionStatus $w.FindName('AppSelectionStatus') `
-AppRemovalScopeCombo $w.FindName('AppRemovalScopeCombo') `
-AppRemovalScopeSection $w.FindName('AppRemovalScopeSection') `
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
-UserSelectionCombo $w.FindName('UserSelectionCombo')
}
$AppsPanel.Children.Add($checkbox) | Out-Null
if (($i + 1) % $batchSize -eq 0) { DoEvents }
}
$sortArrowName = $Window.FindName('SortArrowName')
$sortArrowDescription = $Window.FindName('SortArrowDescription')
$sortArrowAppId = $Window.FindName('SortArrowAppId')
Update-AppsPanelSort -AppsPanel $AppsPanel -SortArrowName $sortArrowName -SortArrowDescription $sortArrowDescription -SortArrowAppId $sortArrowAppId
# If Default Mode was clicked while apps were still loading, apply defaults now
if ($script:PendingDefaultMode) {
$script:PendingDefaultMode = $false
Invoke-AppPreset -AppsPanel $AppsPanel -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Exclusive
}
$appSelectionStatusText = $Window.FindName('AppSelectionStatus')
$appRemovalScopeCombo = $Window.FindName('AppRemovalScopeCombo')
$appRemovalScopeSection = $Window.FindName('AppRemovalScopeSection')
$appRemovalScopeDescription = $Window.FindName('AppRemovalScopeDescription')
$userSelectionCombo = $Window.FindName('UserSelectionCombo')
Update-AppSelectionStatus -AppsPanel $AppsPanel -AppSelectionStatus $appSelectionStatusText `
-AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection `
-AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo
# Re-enable controls now that the full, correctly-checked app list is ready
$OnlyInstalledAppsBox.IsHitTestVisible = $true
$Window.FindName('DeploymentApplyBtn').IsEnabled = $true
if ($ImportConfigBtn) {
$ImportConfigBtn.IsEnabled = $true
}
}
function Load-AppsIntoMainUI {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Panel]$AppsPanel,
[System.Windows.Controls.CheckBox]$OnlyInstalledAppsBox,
[System.Windows.Controls.Border]$LoadingAppsIndicator,
[System.Windows.Controls.MenuItem]$ImportConfigBtn
)
# Prevent concurrent loads
if ($script:IsLoadingApps) { return }
$script:IsLoadingApps = $true
if ($ImportConfigBtn) {
$ImportConfigBtn.IsEnabled = $false
}
# Show loading indicator and clear existing apps
$LoadingAppsIndicator.Visibility = 'Visible'
$AppsPanel.Children.Clear()
# Disable controls while apps are loading so they can't be interacted with mid-load
$Window.FindName('DeploymentApplyBtn').IsEnabled = $false
$OnlyInstalledAppsBox.IsHitTestVisible = $false
# Update navigation buttons to disable Next/Previous
Update-NavigationButtons -Window $Window -TabControl $Window.FindName('MainTabControl')
# 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.
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action] {})
$Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action] {
try {
$listOfApps = $null
if ($OnlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
Write-Host "Retrieving installed apps via winget..."
$listOfApps = GetInstalledAppsViaWinget -TimeOut 20 -NonBlocking
if ($null -eq $listOfApps) {
Write-Warning "WinGet returned no data (command timed out or failed)"
Show-MessageBox -Message 'Unable to load list of installed apps via WinGet.' -Title 'Error' -Button 'OK' -Icon 'Error' | Out-Null
$OnlyInstalledAppsBox.IsChecked = $false
}
}
Load-AppsWithList -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox `
-LoadingAppsIndicator $LoadingAppsIndicator -ImportConfigBtn $ImportConfigBtn -ListOfApps $listOfApps
}
catch {
Write-Warning "Failed to load apps list: $($_.Exception.Message)"
$LoadingAppsIndicator.Visibility = 'Collapsed'
$OnlyInstalledAppsBox.IsHitTestVisible = $true
$Window.FindName('DeploymentApplyBtn').IsEnabled = $true
if ($ImportConfigBtn) { $ImportConfigBtn.IsEnabled = $true }
}
finally {
$script:IsLoadingApps = $false
}
}) | Out-Null
}

View File

@@ -0,0 +1,465 @@
# MainWindow-Deployment.ps1
# Overview generation, pending tweak actions, feature labels, tweak preset maps, apply logic, user mode state, user selection, and validation.
function Get-UndoFeatureLabel {
param([string]$FeatureId)
$undoLabel = $script:UndoFeatureLabelLookup[$FeatureId]
if (-not [string]::IsNullOrWhiteSpace([string]$undoLabel)) {
return [string]$undoLabel
}
# Fall back to the regular label (prefixed for undo context)
return [string]$script:FeatureLabelLookup[$FeatureId]
}
function Get-PendingTweakActions {
param(
[System.Windows.Window]$Window,
[bool]$ShowAppliedTweaksMode
)
$actions = New-Object System.Collections.Generic.List[object]
if (-not $script:UiControlMappings) {
return @($actions.ToArray())
}
foreach ($mappingKey in $script:UiControlMappings.Keys) {
$control = $Window.FindName($mappingKey)
if (-not $control) { continue }
$mapping = $script:UiControlMappings[$mappingKey]
if ($control -is [System.Windows.Controls.CheckBox] -and $mapping.Type -eq 'feature') {
$wasApplied = $false
if ($ShowAppliedTweaksMode -and $null -ne $control.PSObject.Properties['SystemState']) {
$wasApplied = [bool]$control.SystemState
}
elseif ($null -ne $control.PSObject.Properties['InitialState']) {
$wasApplied = [bool]$control.InitialState
}
elseif ($null -ne $control.PSObject.Properties['SystemState']) {
$wasApplied = [bool]$control.SystemState
}
$isNowChecked = $control.IsChecked -eq $true
if (-not $wasApplied -and $isNowChecked) {
$actions.Add([PSCustomObject]@{
Action = 'Apply'
FeatureId = [string]$mapping.FeatureId
Label = [string]$script:FeatureLabelLookup[$mapping.FeatureId]
})
}
elseif ($wasApplied -and -not $isNowChecked) {
$actions.Add([PSCustomObject]@{
Action = 'Undo'
FeatureId = [string]$mapping.FeatureId
Label = [string]$script:FeatureLabelLookup[$mapping.FeatureId]
})
}
}
elseif ($control -is [System.Windows.Controls.ComboBox] -and $mapping.Type -eq 'group') {
$wasIndex = 0
if ($ShowAppliedTweaksMode -and $null -ne $control.PSObject.Properties['SystemIndex']) {
$wasIndex = [int]$control.SystemIndex
}
elseif ($null -ne $control.PSObject.Properties['InitialIndex']) {
$wasIndex = [int]$control.InitialIndex
}
elseif ($null -ne $control.PSObject.Properties['SystemIndex']) {
$wasIndex = [int]$control.SystemIndex
}
$isNowIndex = $control.SelectedIndex
if ($wasIndex -eq $isNowIndex) { continue }
if ($isNowIndex -gt 0 -and $isNowIndex -le $mapping.Values.Count) {
$selectedValue = $mapping.Values[$isNowIndex - 1]
foreach ($fid in $selectedValue.FeatureIds) {
$actions.Add([PSCustomObject]@{
Action = 'Apply'
FeatureId = [string]$fid
Label = [string]$script:FeatureLabelLookup[$fid]
})
}
}
}
}
return @($actions.ToArray())
}
function New-Overview {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.StackPanel]$AppsPanel,
$ShowCurrentlyAppliedTweaksCheckBox
)
$changesList = @()
$showAppliedTweaksMode = ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true)
# Collect selected apps
$selectedAppsCount = 0
foreach ($child in $AppsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox] -and $child.IsChecked) {
$selectedAppsCount++
}
}
if ($selectedAppsCount -gt 0) {
$changesList += "Remove $selectedAppsCount application(s)"
}
foreach ($tweakAction in @(Get-PendingTweakActions -Window $Window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) {
if ($tweakAction.Action -eq 'Undo') {
$changesList += "Undo: $($tweakAction.Label)"
}
else {
$changesList += $tweakAction.Label
}
}
return $changesList
}
function Invoke-ShowChangesOverview {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.StackPanel]$AppsPanel,
$ShowCurrentlyAppliedTweaksCheckBox
)
$changesList = New-Overview -Window $Window -AppsPanel $AppsPanel -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox
if ($changesList.Count -eq 0) {
Show-MessageBox -Message 'No changes have been selected.' -Title 'Selected Changes' -Button 'OK' -Icon 'Information'
return
}
$message = ($changesList | ForEach-Object { "$([char]0x2022) $_" }) -join "`n"
Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600
}
function Build-TweakPresetControlMap {
param(
[System.Windows.Window]$Window,
$SettingsJson
)
$presetMap = @{}
if (-not $SettingsJson -or -not $SettingsJson.Settings -or -not $script:UiControlMappings) {
return $presetMap
}
# FeatureId -> control metadata, similar to ApplySettingsToUiControls lookup.
$featureIdIndex = @{}
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
$mapping = $script:UiControlMappings[$controlName]
if ($mapping.Type -eq 'group') {
$i = 1
foreach ($val in $mapping.Values) {
foreach ($fid in $val.FeatureIds) {
$featureIdIndex[$fid] = @{ ControlName = $controlName; Control = $control; MappingType = 'group'; Index = $i }
}
$i++
}
}
elseif ($mapping.Type -eq 'feature') {
$featureIdIndex[$mapping.FeatureId] = @{ ControlName = $controlName; Control = $control; MappingType = 'feature' }
}
}
foreach ($setting in $SettingsJson.Settings) {
if ($setting.Value -ne $true) { continue }
if ($setting.Name -eq 'CreateRestorePoint') { continue }
$entry = $featureIdIndex[$setting.Name]
if (-not $entry) { continue }
if ($presetMap.ContainsKey($entry.ControlName)) { continue }
$controlType = if ($entry.Control -is [System.Windows.Controls.CheckBox]) { 'CheckBox' } else { 'ComboBox' }
$desiredValue = switch ($entry.MappingType) {
'group' { $entry.Index }
default { if ($controlType -eq 'CheckBox') { $true } else { 1 } }
}
$presetMap[$entry.ControlName] = @{ Control = $entry.Control; ControlType = $controlType; DesiredValue = $desiredValue }
}
return $presetMap
}
function Build-CategoryTweakPresetMap {
param(
[System.Windows.Window]$Window,
[string]$Category
)
$presetMap = @{}
if (-not $script:UiControlMappings) { return $presetMap }
foreach ($controlName in $script:UiControlMappings.Keys) {
$mapping = $script:UiControlMappings[$controlName]
if ($mapping.Category -ne $Category) { continue }
$control = $Window.FindName($controlName)
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
$controlType = if ($control -is [System.Windows.Controls.CheckBox]) { 'CheckBox' } else { 'ComboBox' }
$desiredValue = if ($controlType -eq 'CheckBox') { $true } else { 1 }
$presetMap[$controlName] = @{ Control = $control; ControlType = $controlType; DesiredValue = $desiredValue }
}
return $presetMap
}
function Get-SavedAppIdsFromSettingsJson {
param($SettingsJson)
if (-not $SettingsJson -or -not $SettingsJson.Settings) {
return $null
}
$appsValue = $null
foreach ($setting in $SettingsJson.Settings) {
if ($setting.Name -eq 'Apps' -and $setting.Value) {
$appsValue = $setting.Value
break
}
}
if (-not $appsValue) {
return $null
}
$savedAppIds = @()
if ($appsValue -is [string]) {
$savedAppIds = $appsValue.Split(',')
}
elseif ($appsValue -is [array]) {
$savedAppIds = $appsValue
}
$savedAppIds = $savedAppIds | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
if ($savedAppIds.Count -eq 0) {
return $null
}
return $savedAppIds
}
function Invoke-ApplyTweakPresetMap {
param(
[hashtable]$PresetMap,
[bool]$Check
)
if (-not $PresetMap) {
$PresetMap = @{}
}
$wasUpdatingTweakPresets = [bool]$script:UpdatingTweakPresets
$script:UpdatingTweakPresets = $true
try {
foreach ($target in $PresetMap.Values) {
$control = $target.Control
if (-not $control) { continue }
if ($target.ControlType -eq 'CheckBox') {
$control.IsChecked = $Check
}
elseif ($target.ControlType -eq 'ComboBox') {
$desiredIndex = [int]$target.DesiredValue
if ($Check) {
$control.SelectedIndex = $desiredIndex
}
elseif ($control.SelectedIndex -eq $desiredIndex) {
$control.SelectedIndex = 0
}
}
}
}
finally {
$script:UpdatingTweakPresets = $wasUpdatingTweakPresets
}
if (-not $wasUpdatingTweakPresets) {
Update-TweakPresetStates -Window $script:MainWindow
}
}
function Set-TweakPresetCheckBoxState {
param(
[System.Windows.Controls.CheckBox]$PresetCheckBox,
[hashtable]$PresetMap
)
if (-not $PresetCheckBox) { return }
if (-not $PresetMap) {
$PresetMap = @{}
}
$total = $PresetMap.Count
$selected = 0
foreach ($target in $PresetMap.Values) {
$control = $target.Control
if (-not $control) { continue }
if ($target.ControlType -eq 'CheckBox' -and $control.IsChecked -eq $true) {
$selected++
}
elseif ($target.ControlType -eq 'ComboBox' -and $control.SelectedIndex -eq [int]$target.DesiredValue) {
$selected++
}
}
Set-TriStatePresetCheckBoxState -CheckBox $PresetCheckBox -Total $total -Selected $selected
}
function Update-TweakPresetStates {
param([System.Windows.Window]$Window)
$script:UpdatingTweakPresets = $true
try {
$presetDefaultTweaksBtn = $Window.FindName('PresetDefaultTweaksBtn')
$presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn')
$presetPrivacyTweaksBtn = $Window.FindName('PresetPrivacyTweaksBtn')
$presetAITweaksBtn = $Window.FindName('PresetAITweaksBtn')
Set-TweakPresetCheckBoxState -PresetCheckBox $presetDefaultTweaksBtn -PresetMap $script:DefaultTweakPresetMap
if ($presetLastUsedTweaksBtn -and $presetLastUsedTweaksBtn.Visibility -ne 'Collapsed') {
Set-TweakPresetCheckBoxState -PresetCheckBox $presetLastUsedTweaksBtn -PresetMap $script:LastUsedTweakPresetMap
}
Set-TweakPresetCheckBoxState -PresetCheckBox $presetPrivacyTweaksBtn -PresetMap $script:PrivacyTweakPresetMap
Set-TweakPresetCheckBoxState -PresetCheckBox $presetAITweaksBtn -PresetMap $script:AITweakPresetMap
}
finally {
$script:UpdatingTweakPresets = $false
}
}
function Register-TweakPresetControlStateHandlers {
param([System.Windows.Window]$Window)
if (-not $script:UiControlMappings) { return }
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
if (-not $control) { continue }
if ($control -is [System.Windows.Controls.CheckBox]) {
$control.Add_Checked({ if (-not $script:UpdatingTweakPresets) { Update-TweakPresetStates -Window $script:MainWindow } })
$control.Add_Unchecked({ if (-not $script:UpdatingTweakPresets) { Update-TweakPresetStates -Window $script:MainWindow } })
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$control.Add_SelectionChanged({ if (-not $script:UpdatingTweakPresets) { Update-TweakPresetStates -Window $script:MainWindow } })
}
}
}
function Initialize-TweakPresetSources {
param(
[System.Windows.Window]$Window,
$DefaultSettingsJson,
$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'
$presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn')
if ($presetLastUsedTweaksBtn) {
$presetLastUsedTweaksBtn.Visibility = if ($script:LastUsedTweakPresetMap.Count -gt 0) { 'Visible' } else { 'Collapsed' }
}
}
function Update-AppliedTweaksUserModeState {
param(
[System.Windows.Controls.CheckBox]$ShowCurrentlyAppliedTweaksCheckBox,
[System.Windows.Controls.ComboBox]$UserSelectionCombo
)
# Show/hide detect applied tweaks checkbox based on user mode
if ($ShowCurrentlyAppliedTweaksCheckBox) {
if ($UserSelectionCombo.SelectedIndex -eq 0) {
$ShowCurrentlyAppliedTweaksCheckBox.Visibility = 'Visible'
}
else {
$ShowCurrentlyAppliedTweaksCheckBox.Visibility = 'Collapsed'
}
}
# Enable/disable user mode combo based on params only (not checkbox)
if ($script:Params.ContainsKey('Sysprep') -or $script:Params.ContainsKey('User')) {
$UserSelectionCombo.IsEnabled = $false
}
else {
$UserSelectionCombo.IsEnabled = $true
}
}
function Update-UserSelectionDescription {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
[System.Windows.Controls.TextBox]$OtherUsernameTextBox,
[System.Windows.Controls.TextBlock]$UserSelectionDescription
)
switch ($UserSelectionCombo.SelectedIndex) {
0 {
$currentUserName = GetUserName
if ([string]::IsNullOrWhiteSpace($currentUserName)) {
$UserSelectionDescription.Text = "The currently logged-in user profile"
}
else {
$UserSelectionDescription.Text = "The currently logged-in user profile: $currentUserName"
}
}
1 {
$targetUserName = $OtherUsernameTextBox.Text.Trim()
if ([string]::IsNullOrWhiteSpace($targetUserName)) {
$UserSelectionDescription.Text = "A different user profile on this system"
}
else {
$UserSelectionDescription.Text = "A different user profile on this system: $targetUserName"
}
}
default {
$UserSelectionDescription.Text = "The default user template, affecting all new users created after this point. Useful for Sysprep deployment."
}
}
}
function Test-OtherUsername {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
[System.Windows.Controls.TextBox]$OtherUsernameTextBox,
[System.Windows.Controls.TextBlock]$UsernameValidationMessage
)
# Only validate if "Other User" is selected
if ($UserSelectionCombo.SelectedIndex -ne 1) {
return $true
}
$errorBrush = $Window.Resources['ValidationErrorColor']
$successBrush = $Window.Resources['ValidationSuccessColor']
$validationResult = Test-TargetUserName -UserName $OtherUsernameTextBox.Text
$UsernameValidationMessage.Text = $validationResult.Message
if ($validationResult.IsValid) {
$UsernameValidationMessage.Foreground = $successBrush
return $true
}
$UsernameValidationMessage.Foreground = $errorBrush
return $false
}

View File

@@ -0,0 +1,72 @@
# MainWindow-Navigation.ps1
# Wizard navigation helpers: tab navigation buttons and progress indicators.
function Update-NavigationButtons {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.TabControl]$TabControl
)
$currentIndex = $TabControl.SelectedIndex
$totalTabs = $TabControl.Items.Count
$previousBtn = $Window.FindName('PreviousBtn')
$nextBtn = $Window.FindName('NextBtn')
$homeIndex = 0
$overviewIndex = $totalTabs - 1
# Navigation button visibility
if ($currentIndex -eq $homeIndex) {
$nextBtn.Visibility = 'Collapsed'
$previousBtn.Visibility = 'Collapsed'
}
elseif ($currentIndex -eq $overviewIndex) {
$nextBtn.Visibility = 'Collapsed'
$previousBtn.Visibility = 'Visible'
}
else {
$nextBtn.Visibility = 'Visible'
$previousBtn.Visibility = 'Visible'
}
# Update progress indicators
# Tab indices: 0=Home, 1=App Removal, 2=Tweaks, 3=Deployment Settings
$progressIndicator1 = $Window.FindName('ProgressIndicator1') # App Removal
$progressIndicator2 = $Window.FindName('ProgressIndicator2') # Tweaks
$progressIndicator3 = $Window.FindName('ProgressIndicator3') # Deployment Settings
$bottomNavGrid = $Window.FindName('BottomNavGrid')
# Hide bottom navigation on home page
if ($currentIndex -eq 0) {
$bottomNavGrid.Visibility = 'Collapsed'
}
else {
$bottomNavGrid.Visibility = 'Visible'
}
# Update indicator colors based on current tab
# Indicator 1 (App Removal) - tab index 1
if ($currentIndex -ge 1) {
$progressIndicator1.Fill = $Window.Resources['ProgressActiveColor']
}
else {
$progressIndicator1.Fill = $Window.Resources['ProgressInactiveColor']
}
# Indicator 2 (Tweaks) - tab index 2
if ($currentIndex -ge 2) {
$progressIndicator2.Fill = $Window.Resources['ProgressActiveColor']
}
else {
$progressIndicator2.Fill = $Window.Resources['ProgressInactiveColor']
}
# Indicator 3 (Deployment Settings) - tab index 3
if ($currentIndex -ge 3) {
$progressIndicator3.Fill = $Window.Resources['ProgressActiveColor']
}
else {
$progressIndicator3.Fill = $Window.Resources['ProgressInactiveColor']
}
}

View File

@@ -0,0 +1,570 @@
# MainWindow-TweaksBuilder.ps1
# Dynamic tweaks UI construction from Features.json, tweak state management, selection clear, and search/highlight.
function Build-DynamicTweaks {
param(
[System.Windows.Window]$Window,
[int]$WinVersion
)
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
if (-not $featuresJson) {
throw "Unable to load Features.json file. The GUI cannot continue without feature definitions."
}
# Column containers
$col0 = $Window.FindName('Column0Panel')
$col1 = $Window.FindName('Column1Panel')
$col2 = $Window.FindName('Column2Panel')
$columns = @($col0, $col1, $col2) | Where-Object { $_ -ne $null }
# Clear all columns for fully dynamic panel creation
foreach ($col in $columns) {
if ($col) { $col.Children.Clear() }
}
$script:UiControlMappings = @{}
$script:CategoryCardMap = @{}
$script:TweaksCompactMode = $null
$script:TweaksCardsMovedFromCol2 = @()
function CreateLabeledCombo($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
$checkbox.Content = $labelText
$checkbox.Name = $comboName
$checkbox.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $labelText)
$checkbox.IsChecked = $false
$checkbox.Style = $Window.Resources["FeatureCheckboxStyle"]
$parent.Children.Add($checkbox) | Out-Null
# Register the checkbox with the window's name scope
try {
[System.Windows.NameScope]::SetNameScope($checkbox, [System.Windows.NameScope]::GetNameScope($Window))
$Window.RegisterName($comboName, $checkbox)
}
catch {
# Name might already be registered, ignore
}
return $checkbox
}
# Otherwise use a combobox for multiple options
# Wrap label in a Border for search highlighting
$lblBorder = New-Object System.Windows.Controls.Border
$lblBorder.Style = $Window.Resources['LabelBorderStyle']
$lblBorderName = "$comboName`_LabelBorder"
$lblBorder.Name = $lblBorderName
$lbl = New-Object System.Windows.Controls.TextBlock
$lbl.Text = $labelText
$lbl.Style = $Window.Resources['LabelStyle']
$labelName = "$comboName`_Label"
$lbl.Name = $labelName
$lblBorder.Child = $lbl
$parent.Children.Add($lblBorder) | Out-Null
# Register the label border with the window's name scope
try {
[System.Windows.NameScope]::SetNameScope($lblBorder, [System.Windows.NameScope]::GetNameScope($Window))
$Window.RegisterName($lblBorderName, $lblBorder)
}
catch {
# Name might already be registered, ignore
}
$combo = New-Object System.Windows.Controls.ComboBox
$combo.Name = $comboName
$combo.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $labelText)
foreach ($item in $items) { $comboItem = New-Object System.Windows.Controls.ComboBoxItem; $comboItem.Content = $item; $combo.Items.Add($comboItem) | Out-Null }
$combo.SelectedIndex = 0
$parent.Children.Add($combo) | Out-Null
# Register the combo box with the window's name scope
try {
[System.Windows.NameScope]::SetNameScope($combo, [System.Windows.NameScope]::GetNameScope($Window))
$Window.RegisterName($comboName, $combo)
}
catch {
# Name might already be registered, ignore
}
return $combo
}
function GetWikiUrlForCategory($category) {
if (-not $category) { return 'https://github.com/Raphire/Win11Debloat/wiki/Features' }
$slug = $category.ToLowerInvariant()
$slug = $slug -replace '&', ''
$slug = $slug -replace '[^a-z0-9\s-]', ''
$slug = $slug -replace '\s', '-'
return "https://github.com/Raphire/Win11Debloat/wiki/Features#$slug"
}
function GetOrCreateCategoryCard($categoryObj) {
$categoryName = $categoryObj.Name
$categoryIcon = $categoryObj.Icon
if ($script:CategoryCardMap.ContainsKey($categoryName)) { return $script:CategoryCardMap[$categoryName] }
# Create a new card Border + StackPanel and add to shortest column
$target = $columns | Sort-Object @{Expression = { $_.Children.Count }; Ascending = $true }, @{Expression = { $columns.IndexOf($_) }; Ascending = $true } | Select-Object -First 1
$border = New-Object System.Windows.Controls.Border
$border.Style = $Window.Resources['CategoryCardBorderStyle']
$border.Tag = 'DynamicCategory'
$panel = New-Object System.Windows.Controls.StackPanel
$safe = ($categoryName -replace '[^a-zA-Z0-9_]', '_')
$panel.Name = "Category_{0}_Panel" -f $safe
$headerRow = New-Object System.Windows.Controls.StackPanel
$headerRow.Orientation = 'Horizontal'
# Add category icon
$icon = New-Object System.Windows.Controls.TextBlock
# Convert HTML entity to character (e.g., &#xE72E; -> actual character)
if ($categoryIcon -match '&#x([0-9A-Fa-f]+);') {
$hexValue = [Convert]::ToInt32($matches[1], 16)
$icon.Text = [char]$hexValue
}
$icon.Style = $Window.Resources['CategoryHeaderIcon']
$headerRow.Children.Add($icon) | Out-Null
$header = New-Object System.Windows.Controls.TextBlock
$header.Text = $categoryName
$header.Style = $Window.Resources['CategoryHeaderTextBlock']
$headerRow.Children.Add($header) | Out-Null
$helpIcon = New-Object System.Windows.Controls.TextBlock
$helpIcon.Text = '(?)'
$helpIcon.Style = $Window.Resources['CategoryHelpLinkTextStyle']
$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.Style = $Window.Resources['CategoryHelpLinkButtonStyle']
$helpBtn.Add_Click({
param($button, $e)
if ($button.Tag) { Start-Process $button.Tag }
})
$headerRow.Children.Add($helpBtn) | Out-Null
$panel.Children.Add($headerRow) | Out-Null
$border.Child = $panel
$target.Children.Add($border) | Out-Null
$script:CategoryCardMap[$categoryName] = $panel
return $panel
}
# Determine categories present (from lists and features)
$categoriesPresent = @{}
if ($featuresJson.UiGroups) {
foreach ($g in $featuresJson.UiGroups) { if ($g.Category) { $categoriesPresent[$g.Category] = $true } }
}
foreach ($f in $featuresJson.Features) { if ($f.Category) { $categoriesPresent[$f.Category] = $true } }
# Create cards in the order defined in Features.json Categories (if present)
$orderedCategories = @()
if ($featuresJson.Categories) {
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 }
$orderedCategories += $categoryObj
}
}
}
else {
# For backward compatibility, create category objects from keys
foreach ($catName in $categoriesPresent.Keys) {
$orderedCategories += @{Name = $catName; Icon = '&#xE712;' }
}
}
# Build a FeatureId -> feature lookup for version filtering in groups
$featureMap = @{}
foreach ($f in $featuresJson.Features) {
$featureMap[$f.FeatureId] = $f
}
foreach ($categoryObj in $orderedCategories) {
$categoryName = $categoryObj.Name
# Create/get card for this category
$panel = GetOrCreateCategoryCard -categoryObj $categoryObj
if (-not $panel) { continue }
# Collect groups and features for this category, then sort by priority
$categoryItems = @()
# Add any groups for this category
if ($featuresJson.UiGroups) {
$groupIndex = 0
foreach ($group in $featuresJson.UiGroups) {
if ($group.Category -ne $categoryName) { $groupIndex++; continue }
$categoryItems += [PSCustomObject]@{
Type = 'group'
Data = $group
Priority = if ($null -ne $group.Priority) { $group.Priority } else { [int]::MaxValue }
OriginalIndex = $groupIndex
}
$groupIndex++
}
}
# Add individual features for this category
$featureIndex = 0
foreach ($feature in $featuresJson.Features) {
if ($feature.Category -ne $categoryName) { $featureIndex++; continue }
# Check version and feature compatibility using Features.json
if (($feature.MinVersion -and $WinVersion -lt $feature.MinVersion) -or ($feature.MaxVersion -and $WinVersion -gt $feature.MaxVersion) -or ($feature.FeatureId -eq 'DisableModernStandbyNetworking' -and (-not $script:ModernStandbySupported))) {
$featureIndex++; continue
}
# Skip if feature part of a group
$inGroup = $false
if ($featuresJson.UiGroups) {
foreach ($g in $featuresJson.UiGroups) { foreach ($val in $g.Values) { if ($val.FeatureIds -contains $feature.FeatureId) { $inGroup = $true; break } }; if ($inGroup) { break } }
}
if ($inGroup) { $featureIndex++; continue }
$categoryItems += [PSCustomObject]@{
Type = 'feature'
Data = $feature
Priority = if ($null -ne $feature.Priority) { $feature.Priority } else { [int]::MaxValue }
OriginalIndex = $featureIndex
}
$featureIndex++
}
# Sort by priority first, then by original index for items with same/no priority
$sortedItems = $categoryItems | Sort-Object -Property Priority, OriginalIndex
# Render sorted items
foreach ($item in $sortedItems) {
if ($item.Type -eq 'group') {
$group = $item.Data
# Filter values by Windows version compatibility of their referenced features
$filteredValues = @($group.Values | Where-Object {
$allCompatible = $true
foreach ($fid in $_.FeatureIds) {
if ($featureMap.ContainsKey($fid)) {
$f = $featureMap[$fid]
if (($f.MinVersion -and $WinVersion -lt $f.MinVersion) -or ($f.MaxVersion -and $WinVersion -gt $f.MaxVersion)) {
$allCompatible = $false
break
}
}
}
$allCompatible
})
# Skip the group entirely if all values are incompatible with this Windows version
if ($filteredValues.Count -eq 0) { continue }
# When only 1 value remains, render as the underlying feature directly
if ($filteredValues.Count -eq 1) {
$featureIds = $filteredValues[0].FeatureIds
if (-not $featureIds -or $featureIds.Count -eq 0) { continue }
$soleFid = $featureIds[0]
if ($featureMap.ContainsKey($soleFid)) {
$soleFeature = $featureMap[$soleFid]
$opt = 'Apply'
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
# attach tooltip from Features.json if present
if ($soleFeature.ToolTip -or $soleFeature.DisableWhenApplied -eq $true) {
$tooltipText = $soleFeature.ToolTip
if ($soleFeature.DisableWhenApplied -eq $true) {
$tooltipText = "This tweak is already applied and cannot be undone automatically. Visit the Win11Debloat wiki for instructions on how to manually revert this change."
}
$tipBlock = New-Object System.Windows.Controls.TextBlock
$tipBlock.Text = $tooltipText
$tipBlock.TextWrapping = 'Wrap'
$tipBlock.MaxWidth = 420
$combo.ToolTip = $tipBlock
[System.Windows.Controls.ToolTipService]::SetShowOnDisabled($combo, $true)
$lblBorderObj = $null
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 }
}
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
# attach tooltip from UiGroups if present
if ($group.ToolTip) {
$tipBlock = New-Object System.Windows.Controls.TextBlock
$tipBlock.Text = $group.ToolTip
$tipBlock.TextWrapping = 'Wrap'
$tipBlock.MaxWidth = 420
$combo.ToolTip = $tipBlock
$lblBorderObj = $null
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; Category = $categoryName }
}
elseif ($item.Type -eq 'feature') {
$feature = $item.Data
$opt = 'Apply'
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
# attach tooltip from Features.json if present, and include the disabled-state reason
if ($feature.ToolTip -or $feature.DisableWhenApplied -eq $true) {
$tooltipText = $feature.ToolTip
if ($feature.DisableWhenApplied -eq $true) {
$tooltipText = "This tweak is already applied and cannot be undone automatically. Visit the Win11Debloat wiki for instructions on how to manually revert this change."
}
$tipBlock = New-Object System.Windows.Controls.TextBlock
$tipBlock.Text = $tooltipText
$tipBlock.TextWrapping = 'Wrap'
$tipBlock.MaxWidth = 420
$combo.ToolTip = $tipBlock
[System.Windows.Controls.ToolTipService]::SetShowOnDisabled($combo, $true)
$lblBorderObj = $null
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 }
}
}
}
# Build a feature-label lookup so GenerateOverview can resolve feature IDs without reloading JSON
$script:FeatureLabelLookup = @{}
$script:UndoFeatureLabelLookup = @{}
foreach ($f in $featuresJson.Features) {
$script:FeatureLabelLookup[$f.FeatureId] = $f.Label
$script:UndoFeatureLabelLookup[$f.FeatureId] = $f.UndoLabel
}
}
function Update-CurrentTweakSystemState {
param(
[System.Windows.Window]$Window,
[bool]$ApplyToUi
)
if (-not $script:UiControlMappings) { return }
if (-not $script:Features) { return }
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
if (-not $featuresJson) { return }
$groupMap = @{}
if ($featuresJson.UiGroups) {
foreach ($g in $featuresJson.UiGroups) {
$groupMap[$g.GroupId] = $g
}
}
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
if (-not $control) { continue }
$mapping = $script:UiControlMappings[$controlName]
if ($control -is [System.Windows.Controls.CheckBox] -and $mapping.Type -eq 'feature') {
$applied = $false
try { $applied = [bool](Test-FeatureApplied -FeatureId $mapping.FeatureId) } catch {}
$featureObj = $script:Features[$mapping.FeatureId]
$disableWhenApplied = $featureObj -and $featureObj.DisableWhenApplied -eq $true
Add-Member -InputObject $control -MemberType NoteProperty -Name 'SystemState' -Value $applied -Force
Add-Member -InputObject $control -MemberType NoteProperty -Name 'DisableWhenApplied' -Value $disableWhenApplied -Force
if ($ApplyToUi) {
$control.IsChecked = $applied
$control.IsEnabled = -not ($applied -and $disableWhenApplied)
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialState' -Value $applied -Force
}
}
elseif ($control -is [System.Windows.Controls.ComboBox] -and $mapping.Type -eq 'group') {
$groupId = $null
if ($controlName -match '^Group_(.+)Combo$') { $groupId = $matches[1] }
$activeIndex = 0
if ($groupId -and $groupMap.ContainsKey($groupId)) {
try { $activeIndex = Get-CurrentGroupActiveIndex -Group $groupMap[$groupId] } catch {}
}
Add-Member -InputObject $control -MemberType NoteProperty -Name 'SystemIndex' -Value $activeIndex -Force
if ($ApplyToUi) {
$control.SelectedIndex = $activeIndex
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialIndex' -Value $activeIndex -Force
}
}
}
}
function Load-CurrentTweakStateIntoUI {
param([System.Windows.Window]$Window)
Update-CurrentTweakSystemState -Window $Window -ApplyToUi:$true
}
function Reset-TweaksToSystemState {
param(
[System.Windows.Window]$Window,
[bool]$LoadSystemState
)
if (-not $script:UiControlMappings) { return }
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
if (-not $control) { continue }
if ($control -is [System.Windows.Controls.CheckBox]) {
if ($LoadSystemState) {
# Set checkbox to the currently applied state from registry
$applied = if ($null -ne $control.PSObject.Properties['SystemState']) { [bool]$control.SystemState } else { $false }
$disableWhenApplied = $null -ne $control.PSObject.Properties['DisableWhenApplied'] -and [bool]$control.DisableWhenApplied
$control.IsChecked = $applied
$control.IsEnabled = -not ($applied -and $disableWhenApplied)
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialState' -Value $applied -Force
}
else {
# Clear the checkbox
$control.IsChecked = $false
$control.IsEnabled = $true
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialState' -Value $false -Force
}
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
if ($LoadSystemState) {
# Set combobox to the currently applied state from registry
$idx = if ($null -ne $control.PSObject.Properties['SystemIndex']) { [int]$control.SystemIndex } else { 0 }
$control.SelectedIndex = $idx
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialIndex' -Value $idx -Force
}
else {
# Reset to first item (No Change)
$control.SelectedIndex = 0
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialIndex' -Value 0 -Force
}
}
}
}
function Update-TweaksResponsiveColumns {
param([System.Windows.Window]$Window)
$tweaksGrid = $Window.FindName('TweaksGrid')
$col0 = $Window.FindName('Column0Panel')
$col1 = $Window.FindName('Column1Panel')
$col2 = $Window.FindName('Column2Panel')
if (-not $tweaksGrid -or -not $col0 -or -not $col1 -or -not $col2) { return }
if ($tweaksGrid.ColumnDefinitions.Count -lt 3) { return }
if ($null -eq $script:TweaksCardsMovedFromCol2) { $script:TweaksCardsMovedFromCol2 = @() }
$useTwoColumns = $Window.ActualWidth -lt 1200
if ($script:TweaksCompactMode -eq $useTwoColumns) { return }
$script:TweaksCompactMode = $useTwoColumns
if ($useTwoColumns) {
$tweaksGrid.ColumnDefinitions[0].Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
$tweaksGrid.ColumnDefinitions[1].Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
$tweaksGrid.ColumnDefinitions[2].Width = [System.Windows.GridLength]::new(0)
$col2.Visibility = 'Collapsed'
# Move third-column cards once when entering compact mode.
$cardsToMove = @($col2.Children) | Where-Object { $_ -is [System.Windows.UIElement] }
$script:TweaksCardsMovedFromCol2 = @($cardsToMove)
$col2.Children.Clear()
$targetColumns = @($col0, $col1)
foreach ($card in $cardsToMove) {
$target = $targetColumns |
Sort-Object @{Expression = { $_.Children.Count }; Ascending = $true }, @{Expression = { $targetColumns.IndexOf($_) }; Ascending = $true } |
Select-Object -First 1
$target.Children.Add($card) | Out-Null
}
return
}
$tweaksGrid.ColumnDefinitions[0].Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
$tweaksGrid.ColumnDefinitions[1].Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
$tweaksGrid.ColumnDefinitions[2].Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
$col2.Visibility = 'Visible'
foreach ($card in (@($script:TweaksCardsMovedFromCol2) | Where-Object { $_ -is [System.Windows.UIElement] })) {
if ($col0.Children.Contains($card)) {
$col0.Children.Remove($card) | Out-Null
}
elseif ($col1.Children.Contains($card)) {
$col1.Children.Remove($card) | Out-Null
}
$col2.Children.Add($card) | Out-Null
}
$script:TweaksCardsMovedFromCol2 = @()
}
function Clear-TweakSelections {
param([System.Windows.Window]$Window)
if (-not $script:UiControlMappings) { return }
foreach ($controlName in $script:UiControlMappings.Keys) {
$control = $Window.FindName($controlName)
if ($control -is [System.Windows.Controls.CheckBox]) {
$control.IsChecked = $false
$control.IsEnabled = $true
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = 0
}
}
}
function Clear-TweakHighlights {
param([System.Windows.Window]$Window)
$col0 = $Window.FindName('Column0Panel')
$col1 = $Window.FindName('Column1Panel')
$col2 = $Window.FindName('Column2Panel')
$columns = @($col0, $col1, $col2) | Where-Object { $_ -ne $null }
foreach ($column in $columns) {
foreach ($card in $column.Children) {
if ($card -is [System.Windows.Controls.Border] -and $card.Child -is [System.Windows.Controls.StackPanel]) {
foreach ($control in $card.Child.Children) {
if ($control -is [System.Windows.Controls.CheckBox] -or
($control -is [System.Windows.Controls.Border] -and $control.Name -like '*_LabelBorder')) {
$control.Background = [System.Windows.Media.Brushes]::Transparent
}
}
}
}
}
}
function Test-ComboBoxContainsMatch {
param ([System.Windows.Controls.ComboBox]$ComboBox, [string]$SearchText)
foreach ($item in $ComboBox.Items) {
$itemText = if ($item -is [System.Windows.Controls.ComboBoxItem]) { $item.Content.ToString().ToLower() } else { $item.ToString().ToLower() }
if ($itemText.Contains($SearchText)) { return $true }
}
return $false
}

View File

@@ -0,0 +1,167 @@
# MainWindow-WindowChrome.ps1
# Window sizing, DPI-aware coordinate conversion, and UI animations.
# Convert screen-pixel coordinates to WPF device-independent pixels (DIP)
function ConvertTo-ScreenPointToDip {
param(
[System.Windows.Window]$Window,
[double]$X,
[double]$Y
)
$source = [System.Windows.PresentationSource]::FromVisual($Window)
if ($null -eq $source -or $null -eq $source.CompositionTarget) {
return [System.Windows.Point]::new($X, $Y)
}
return $source.CompositionTarget.TransformFromDevice.Transform([System.Windows.Point]::new($X, $Y))
}
# Convert screen-pixel size to WPF device-independent size
function ConvertTo-ScreenPixelsToDip {
param(
[System.Windows.Window]$Window,
[double]$Width,
[double]$Height
)
$topLeft = ConvertTo-ScreenPointToDip -Window $Window -X 0 -Y 0
$bottomRight = ConvertTo-ScreenPointToDip -Window $Window -X $Width -Y $Height
return [System.Windows.Size]::new($bottomRight.X - $topLeft.X, $bottomRight.Y - $topLeft.Y)
}
# Get the screen that currently contains the window
function Get-WindowScreen {
param([System.Windows.Window]$Window)
$hwnd = (New-Object System.Windows.Interop.WindowInteropHelper($Window)).Handle
if ($hwnd -eq [IntPtr]::Zero) {
return $null
}
return [System.Windows.Forms.Screen]::FromHandle($hwnd)
}
# Update window border/corner chrome when transitioning between Normal and Maximized
function Update-MainWindowChrome {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Border]$MainBorder,
[System.Windows.Controls.Border]$TitleBarBackground,
[object]$NormalWindowShadow
)
$windowStateMaximized = [System.Windows.WindowState]::Maximized
if ($Window.WindowState -eq $windowStateMaximized) {
$chrome = [System.Windows.Shell.WindowChrome]::GetWindowChrome($Window)
$resizeBorder = if ($chrome) { $chrome.ResizeBorderThickness } else { [System.Windows.SystemParameters]::WindowResizeBorderThickness }
# Compute margins using screen bounds vs working area
$marginLeft = $resizeBorder.Left
$marginTop = $resizeBorder.Top
$marginRight = $resizeBorder.Right
$marginBottom = $resizeBorder.Bottom
$screen = Get-WindowScreen -Window $Window
if ($screen) {
$workTL = ConvertTo-ScreenPointToDip -Window $Window -X $screen.WorkingArea.Left -Y $screen.WorkingArea.Top
$workSize = ConvertTo-ScreenPixelsToDip -Window $Window -Width $screen.WorkingArea.Width -Height $screen.WorkingArea.Height
$screenTL = ConvertTo-ScreenPointToDip -Window $Window -X $screen.Bounds.Left -Y $screen.Bounds.Top
$screenSize = ConvertTo-ScreenPixelsToDip -Window $Window -Width $screen.Bounds.Width -Height $screen.Bounds.Height
$marginLeft += ($workTL.X - $screenTL.X)
$marginTop += ($workTL.Y - $screenTL.Y)
$marginRight += ($screenTL.X + $screenSize.Width) - ($workTL.X + $workSize.Width)
$marginBottom += ($screenTL.Y + $screenSize.Height) - ($workTL.Y + $workSize.Height)
}
$MainBorder.Margin = [System.Windows.Thickness]::new($marginLeft, $marginTop, $marginRight, $marginBottom)
$MainBorder.BorderThickness = [System.Windows.Thickness]::new(0)
$MainBorder.CornerRadius = [System.Windows.CornerRadius]::new(0)
$MainBorder.Effect = $null
$TitleBarBackground.CornerRadius = [System.Windows.CornerRadius]::new(0)
}
else {
$MainBorder.Margin = [System.Windows.Thickness]::new(0)
$MainBorder.BorderThickness = [System.Windows.Thickness]::new(1)
$MainBorder.CornerRadius = [System.Windows.CornerRadius]::new(8)
$MainBorder.Effect = $NormalWindowShadow
$TitleBarBackground.CornerRadius = [System.Windows.CornerRadius]::new(8, 8, 0, 0)
}
}
# Set the initial window size and center on screen (normal state only)
function Set-MainWindowInitialSize {
param(
[System.Windows.Window]$Window,
[double]$InitialNormalMaxWidth = 1400.0
)
if ($Window.WindowState -ne [System.Windows.WindowState]::Normal) {
return
}
$screen = Get-WindowScreen -Window $Window
if ($null -eq $screen) {
return
}
$workingAreaTopLeftDip = ConvertTo-ScreenPointToDip -Window $Window -X $screen.WorkingArea.Left -Y $screen.WorkingArea.Top
$workingAreaDip = ConvertTo-ScreenPixelsToDip -Window $Window -Width $screen.WorkingArea.Width -Height $screen.WorkingArea.Height
$Window.Width = [Math]::Min($InitialNormalMaxWidth, $workingAreaDip.Width)
$Window.Left = $workingAreaTopLeftDip.X + (($workingAreaDip.Width - $Window.Width) / 2)
}
# Update the content grid margin to constrain max content width
function Update-MainWindowContentMargin {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Grid]$ContentGrid,
[double]$MaxContentWidth = 1600.0
)
$w = $Window.ActualWidth
if ($w -gt $MaxContentWidth) {
$gutter = [Math]::Floor(($w - $MaxContentWidth) / 2)
$ContentGrid.Margin = [System.Windows.Thickness]::new($gutter, 0, $gutter, 0)
}
else {
$ContentGrid.Margin = [System.Windows.Thickness]::new(0)
}
}
# Vertically center the home content panel
function Update-MainWindowHomeContentPosition {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Panel]$HomeContentPanel
)
if ($HomeContentPanel) {
$availableHeight = $Window.ActualHeight - 32 # subtract title bar height
if ($availableHeight -gt 0) {
$topMargin = ($availableHeight - 584) * 0.5
$HomeContentPanel.Margin = [System.Windows.Thickness]::new(0, $topMargin, 0, 0)
}
}
}
function Start-DropdownArrowAnimation {
param(
[System.Windows.Controls.TextBlock]$Arrow,
[double]$Angle
)
if (-not $Arrow) { return }
$animation = New-Object System.Windows.Media.Animation.DoubleAnimation
$animation.To = $Angle
$animation.Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromMilliseconds(200))
$ease = New-Object System.Windows.Media.Animation.CubicEase
$ease.EasingMode = 'EaseOut'
$animation.EasingFunction = $ease
$Arrow.RenderTransform.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $animation)
}

View File

@@ -0,0 +1,280 @@
<#
.SYNOPSIS
Creates a lightweight state object for the restore-backup dialog result.
.DESCRIPTION
Encapsulates the user's dialog choice (Result), the selected backup file
path, and the parsed backup payload so callers receive a single object.
#>
function New-RestoreDialogState {
param(
[string]$Result = 'Cancel',
[string]$SelectedFile = $null,
$Backup = $null
)
return @{ Result = $Result; SelectedFile = $SelectedFile; Backup = $Backup }
}
<#
.SYNOPSIS
Looks up a feature definition by ID from the provided feature catalog.
.PARAMETER FeatureId
The identifier to search for (e.g. 'DisableTelemetry').
.PARAMETER Features
A hashtable loaded from Features.json (FeatureId -> feature object).
#>
function Get-RestoreDialogFeatureDefinition {
param(
[string]$FeatureId,
[hashtable]$Features
)
if ([string]::IsNullOrWhiteSpace($FeatureId) -or -not $Features) {
return $null
}
if ($Features.ContainsKey($FeatureId)) {
return $Features[$FeatureId]
}
return $null
}
<#
.SYNOPSIS
Determines whether a feature can be automatically reverted via registry restore.
.DESCRIPTION
Returns $true when the feature has a non-empty RegistryKey, indicating
an apply .reg file exists that can be undone automatically. Features
with custom logic (no RegistryKey) must be manually reverted.
.PARAMETER FeatureId
The feature identifier to check.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Test-RestoreDialogFeatureCanAutoRevert {
param(
[string]$FeatureId,
[hashtable]$Features
)
if ([string]::IsNullOrWhiteSpace($FeatureId)) {
return $false
}
$featureDefinition = Get-RestoreDialogFeatureDefinition -FeatureId $FeatureId -Features $Features
if ($featureDefinition) {
return -not [string]::IsNullOrWhiteSpace([string]$featureDefinition.RegistryKey)
}
return $false
}
<#
.SYNOPSIS
Resolves a human-readable label for a feature shown in the restore dialog.
.DESCRIPTION
Returns the feature's Label from Features.json when found, falling back
to the raw FeatureId string. For null/empty FeatureIds returns 'Unknown feature'.
.PARAMETER FeatureId
The feature identifier to resolve a label for.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Get-RestoreDialogFeatureDisplayLabel {
param(
[string]$FeatureId,
[hashtable]$Features
)
if ([string]::IsNullOrWhiteSpace($FeatureId)) {
return 'Unknown feature'
}
$featureDefinition = Get-RestoreDialogFeatureDefinition -FeatureId $FeatureId -Features $Features
if ($featureDefinition) {
return [string]$featureDefinition.Label
}
return $FeatureId
}
<#
.SYNOPSIS
Checks whether a feature should appear in the restore dialog's overview list.
.DESCRIPTION
A feature is considered visible when it exists in the catalog and has
a non-empty Category (meaning it belongs to a UI grouping). Features
without a Category are hidden from the overview.
.PARAMETER FeatureId
The feature identifier to check.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Test-RestoreDialogFeatureVisibleInOverview {
param(
[string]$FeatureId,
[hashtable]$Features
)
if ([string]::IsNullOrWhiteSpace($FeatureId)) {
return $false
}
$featureDefinition = Get-RestoreDialogFeatureDefinition -FeatureId $FeatureId -Features $Features
if (-not $featureDefinition) {
return $false
}
return -not [string]::IsNullOrWhiteSpace([string]$featureDefinition.Category)
}
<#
.SYNOPSIS
Extracts deduplicated forward (apply) feature IDs from a backup payload.
.PARAMETER SelectedBackup
The parsed backup object containing a SelectedFeatures property.
#>
function Get-SelectedForwardFeatureIdsFromBackup {
param($SelectedBackup)
$selectedFeatureIds = New-Object System.Collections.Generic.List[string]
$seenSelectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @($SelectedBackup.SelectedFeatures)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
$normalizedId = [string]$featureId
if ($seenSelectedFeatureIds.Add($normalizedId)) {
$selectedFeatureIds.Add($normalizedId)
}
}
return @($selectedFeatureIds.ToArray())
}
<#
.SYNOPSIS
Extracts deduplicated undo feature IDs from a backup payload.
.PARAMETER SelectedBackup
The parsed backup object containing a SelectedUndoFeatures property.
#>
function Get-SelectedUndoFeatureIdsFromBackup {
param($SelectedBackup)
$selectedUndoFeatureIds = New-Object System.Collections.Generic.List[string]
$seenUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @($SelectedBackup.SelectedUndoFeatures)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
$normalizedId = [string]$featureId
if ($seenUndoFeatureIds.Add($normalizedId)) {
$selectedUndoFeatureIds.Add($normalizedId)
}
}
return @($selectedUndoFeatureIds.ToArray())
}
<#
.SYNOPSIS
Merges forward and undo feature IDs from a backup into a single deduplicated list.
.PARAMETER SelectedBackup
The parsed backup object containing SelectedFeatures and SelectedUndoFeatures.
#>
function Get-CombinedSelectedFeatureIdsFromBackup {
param($SelectedBackup)
$featureIds = New-Object System.Collections.Generic.List[string]
$seenIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @(Get-SelectedForwardFeatureIdsFromBackup -SelectedBackup $SelectedBackup) + @(Get-SelectedUndoFeatureIdsFromBackup -SelectedBackup $SelectedBackup)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
$normalizedId = [string]$featureId
if ($seenIds.Add($normalizedId)) {
$featureIds.Add($normalizedId)
}
}
return @($featureIds.ToArray())
}
<#
.SYNOPSIS
Convenience wrapper that returns all combined feature IDs from a backup.
.PARAMETER SelectedBackup
The parsed backup object.
#>
function Get-SelectedFeatureIdsFromBackup {
param($SelectedBackup)
return @(Get-CombinedSelectedFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
}
<#
.SYNOPSIS
Splits selected feature IDs into revertible and non-revertible lists for display.
.DESCRIPTION
Iterates the provided feature IDs, filters to those visible in the overview,
and separates them into auto-revertible (has a RegistryKey) and non-revertible
(requires manual undo) buckets. Each entry includes a display label.
.PARAMETER SelectedFeatureIds
The list of feature IDs to categorize.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Get-RestoreBackupFeatureLists {
param(
[string[]]$SelectedFeatureIds,
[hashtable]$Features
)
$revertibleFeaturesList = @()
$nonRevertibleFeaturesList = @()
foreach ($featureId in $SelectedFeatureIds) {
if (-not (Test-RestoreDialogFeatureVisibleInOverview -FeatureId $featureId -Features $Features)) {
continue
}
$displayItem = [PSCustomObject]@{ DisplayText = "- $(Get-RestoreDialogFeatureDisplayLabel -FeatureId $featureId -Features $Features)" }
if (Test-RestoreDialogFeatureCanAutoRevert -FeatureId $featureId -Features $Features) {
$revertibleFeaturesList += $displayItem
}
else {
$nonRevertibleFeaturesList += $displayItem
}
}
return [PSCustomObject]@{
Revertible = @($revertibleFeaturesList)
NonRevertible = @($nonRevertibleFeaturesList)
}
}

View File

@@ -1,3 +1,26 @@
<#
.SYNOPSIS
Applies light or dark theme colors to a WPF window's resource dictionary.
.DESCRIPTION
Iterates over a predefined set of theme color categories and
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.
.PARAMETER window
The WPF Window whose resource dictionary will be populated.
.PARAMETER usesDarkMode
When $true, dark theme colors are applied; when $false, light theme colors.
.EXAMPLE
SetWindowThemeResources -window $MainWindow -usesDarkMode $true
.EXAMPLE
SetWindowThemeResources -window $Dialog -usesDarkMode $false
#>
# Sets resource colors for a WPF window based on dark mode preference
function SetWindowThemeResources {
param (
@@ -5,79 +28,99 @@ function SetWindowThemeResources {
[bool]$usesDarkMode
)
if ($usesDarkMode) {
$window.Resources.Add("BgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#202020")))
$window.Resources.Add("FgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFFFFF")))
$window.Resources.Add("CardBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#2b2b2b")))
$window.Resources.Add("BorderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#404040")))
$window.Resources.Add("ButtonBorderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#404040")))
$window.Resources.Add("CheckBoxBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#272727")))
$window.Resources.Add("CheckBoxBorderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#808080")))
$window.Resources.Add("CheckBoxHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#343434")))
$window.Resources.Add("ComboBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#373737")))
$window.Resources.Add("ComboHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#434343")))
$window.Resources.Add("ComboItemBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#2c2c2c")))
$window.Resources.Add("ComboItemHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#383838")))
$window.Resources.Add("ComboItemSelectedColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#343434")))
$window.Resources.Add("AccentColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFD700")))
$window.Resources.Add("ButtonDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#434343")))
$window.Resources.Add("ButtonTextDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#989898")))
$window.Resources.Add("SecondaryButtonBg", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#393939")))
$window.Resources.Add("SecondaryButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#2a2a2a")))
$window.Resources.Add("SecondaryButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#1e1e1e")))
$window.Resources.Add("SecondaryButtonDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#3b3b3b")))
$window.Resources.Add("SecondaryButtonTextDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#787878")))
$window.Resources.Add("InputFocusColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#1f1f1f")))
$window.Resources.Add("ScrollBarThumbColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#3d3d3d")))
$window.Resources.Add("ScrollBarThumbHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#4b4b4b")))
$window.Resources.Add("TitlebarButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#2d2d2d")))
$window.Resources.Add("TitlebarButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#292929")))
$window.Resources.Add("AppIdColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#afafaf")))
$window.Resources.Add("SearchHighlightColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#4A4A2A")))
$window.Resources.Add("SearchHighlightActiveColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#8A7000")))
$window.Resources.Add("TableHeaderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#333333")))
}
else {
$window.Resources.Add("BgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f3f3f3")))
$window.Resources.Add("FgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#000000")))
$window.Resources.Add("CardBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#fbfbfb")))
$window.Resources.Add("BorderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#ededed")))
$window.Resources.Add("ButtonBorderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#d3d3d3")))
$window.Resources.Add("CheckBoxBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f5f5f5")))
$window.Resources.Add("CheckBoxBorderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#898989")))
$window.Resources.Add("CheckBoxHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#ececec")))
$window.Resources.Add("ComboBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFFFFF")))
$window.Resources.Add("ComboHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f8f8f8")))
$window.Resources.Add("ComboItemBgColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f9f9f9")))
$window.Resources.Add("ComboItemHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f0f0f0")))
$window.Resources.Add("ComboItemSelectedColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f3f3f3")))
$window.Resources.Add("AccentColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#ffae00")))
$window.Resources.Add("ButtonDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#bfbfbf")))
$window.Resources.Add("ButtonTextDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#ffffff")))
$window.Resources.Add("SecondaryButtonBg", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#fbfbfb")))
$window.Resources.Add("SecondaryButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f6f6f6")))
$window.Resources.Add("SecondaryButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f0f0f0")))
$window.Resources.Add("SecondaryButtonDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#f7f7f7")))
$window.Resources.Add("SecondaryButtonTextDisabled", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#b7b7b7")))
$window.Resources.Add("InputFocusColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#fbfbfb")))
$window.Resources.Add("ScrollBarThumbColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#b9b9b9")))
$window.Resources.Add("ScrollBarThumbHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#8b8b8b")))
$window.Resources.Add("TitlebarButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e1e1e1")))
$window.Resources.Add("TitlebarButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e6e6e6")))
$window.Resources.Add("AppIdColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#666666")))
$window.Resources.Add("SearchHighlightColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFF4CE")))
$window.Resources.Add("SearchHighlightActiveColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFD966")))
$window.Resources.Add("TableHeaderColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#ffffff")))
$ThemeColor = @{
App = @{
AccentColor = @{ Light = '#ffae00'; Dark = '#ffd700' }
BorderColor = @{ Light = '#ededed'; Dark = '#404040' }
BgColor = @{ Light = '#f3f3f3'; Dark = '#202020' }
FgColor = @{ Light = '#000000'; Dark = '#ffffff' }
IdColor = @{ Light = '#666666'; Dark = '#afafaf' }
}
Card = @{
BgColor = @{ Light = '#fbfbfb'; Dark = '#2b2b2b' }
}
Button = @{
BorderColor = @{ Light = '#d3d3d3'; Dark = '#404040' }
BgColor = @{ Light = '#0067c0'; Dark = '#0067c0' }
DisabledColor = @{ Light = '#bfbfbf'; Dark = '#434343' }
HoverColor = @{ Light = '#1975c5'; Dark = '#1975c5' }
PressedColor = @{ Light = '#3183ca'; Dark = '#3183ca' }
TextDisabledColor = @{ Light = '#ffffff'; Dark = '#989898' }
}
SecondaryButton = @{
BgColor = @{ Light = '#fbfbfb'; Dark = '#393939' }
DisabledColor = @{ Light = '#f7f7f7'; Dark = '#3b3b3b' }
HoverColor = @{ Light = '#f6f6f6'; Dark = '#2a2a2a' }
PressedColor = @{ Light = '#f0f0f0'; Dark = '#1e1e1e' }
TextDisabledColor = @{ Light = '#b7b7b7'; Dark = '#787878' }
}
CheckBox = @{
BgColor = @{ Light = '#f5f5f5'; Dark = '#272727' }
BorderColor = @{ Light = '#898989'; Dark = '#808080' }
HoverColor = @{ Light = '#ececec'; Dark = '#343434' }
}
ComboBox = @{
BgColor = @{ Light = '#ffffff'; Dark = '#373737' }
HoverColor = @{ Light = '#f8f8f8'; Dark = '#434343' }
ItemBgColor = @{ Light = '#f9f9f9'; Dark = '#2c2c2c' }
ItemHoverColor = @{ Light = '#f0f0f0'; Dark = '#383838' }
ItemSelectedColor = @{ Light = '#f3f3f3'; Dark = '#343434' }
}
TextBox = @{
BorderColor = @{ Light = '#bdbdbd'; Dark = '#989a9d' }
BgColor = @{ Light = '#fbfbfb'; Dark = '#2d2d2d' }
FocusColor = @{ Light = '#ffffff'; Dark = '#1f1f1f' }
HoverColor = @{ Light = '#f6f6f6'; Dark = '#323232' }
SideBorderColor = @{ Light = '#ececec'; Dark = '#343434' }
}
ScrollBar = @{
ThumbColor = @{ Light = '#b9b9b9'; Dark = '#3d3d3d' }
ThumbHoverColor = @{ Light = '#8b8b8b'; Dark = '#4b4b4b' }
}
TitleBar = @{
ButtonHoverColor = @{ Light = '#dcdcdc'; Dark = '#353535' }
ButtonPressedColor = @{ Light = '#cccccc'; Dark = '#333333' }
CloseHoverColor = @{ Light = '#e81123'; Dark = '#e81123' }
ClosePressedColor = @{ Light = '#f1707a'; Dark = '#f1707a' }
UnfocusedFgColor = @{ Light = '#868686'; Dark = '#969696' }
}
Search = @{
HighlightActiveColor = @{ Light = '#ffd966'; Dark = '#8a7000' }
HighlightColor = @{ Light = '#fff4ce'; Dark = '#4a4a2a' }
}
Table = @{
HeaderColor = @{ Light = '#ffffff'; Dark = '#303030' }
}
Icon = @{
ErrorColor = @{ Light = '#e81123'; Dark = '#e81123' }
InformationColor = @{ Light = '#0078d4'; Dark = '#0078d4' }
QuestionColor = @{ Light = '#0078d4'; Dark = '#0078d4' }
SuccessColor = @{ Light = '#107c10'; Dark = '#107c10' }
WarningColor = @{ Light = '#ffb900'; Dark = '#ffb900' }
}
}
$window.Resources.Add("ButtonBg", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#0067c0")))
$window.Resources.Add("ButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#1E88E5")))
$window.Resources.Add("ButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#3284cc")))
$window.Resources.Add("CloseHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#c42b1c")))
$window.Resources.Add("InformationIconColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#0078D4")))
$window.Resources.Add("WarningIconColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFB900")))
$window.Resources.Add("ErrorIconColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#E81123")))
$window.Resources.Add("QuestionIconColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#0078D4")))
$Theme = if ($usesDarkMode) { 'Dark' } else { 'Light' }
foreach ($Group in $ThemeColor.GetEnumerator()) {
foreach ($Resource in $Group.Value.GetEnumerator()) {
$ResourceName = $Group.Key + $Resource.Key
$window.Resources[$ResourceName] = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($Resource.Value[$Theme])
)
}
}
# Load and merge shared styles
if ($script:SharedStylesSchema -and (Test-Path $script:SharedStylesSchema)) {

View File

@@ -53,12 +53,12 @@ function Show-AppSelectionWindow {
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{})
$appsPanel.Children.Clear()
$listOfApps = ""
$listOfApps = $null
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
if (-not $listOfApps) {
$listOfApps = GetInstalledAppsViaWinget -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
$onlyInstalledBox.IsChecked = $false
@@ -130,15 +130,11 @@ function Show-AppSelectionWindow {
return
}
if ($selectedApps -contains "Microsoft.WindowsStore" -and -not $Silent) {
$result = Show-MessageBox -Message 'Are you sure you wish to uninstall the Microsoft Store? This app cannot easily be reinstalled.' -Title 'Are you sure?' -Button 'YesNo' -Icon 'Warning' -Owner $window
if ($result -eq 'No') {
return
}
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
return
}
SaveCustomAppsListToFile -appsList $selectedApps
$script:SelectedApps = $selectedApps
$window.DialogResult = $true
})

View File

@@ -7,26 +7,6 @@ function Show-ApplyModal {
)
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
# P/Invoke helpers for forcing focus back after Explorer restart
if (-not ([System.Management.Automation.PSTypeName]'Win11Debloat.FocusHelper').Type) {
Add-Type -Namespace Win11Debloat -Name FocusHelper -MemberDefinition @'
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
public static void ForceActivate(IntPtr hwnd) {
IntPtr fg = GetForegroundWindow();
uint fgThread = GetWindowThreadProcessId(fg, IntPtr.Zero);
uint myThread = GetCurrentThreadId();
if (fgThread != myThread) AttachThreadInput(myThread, fgThread, true);
SetForegroundWindow(hwnd);
if (fgThread != myThread) AttachThreadInput(myThread, fgThread, false);
}
'@
}
$usesDarkMode = GetSystemUsesDarkMode
@@ -89,7 +69,7 @@ function Show-ApplyModal {
$script:ApplyProgressBarEl.Value = 0
$script:ApplyModalInErrorState = $false
# Set up progress callback for ExecuteAllChanges
# Set up progress callback for Invoke-AllChanges
$script:ApplyProgressCallback = {
param($currentStep, $totalSteps, $stepName)
$script:ApplyStepNameEl.Text = $stepName
@@ -122,7 +102,9 @@ function Show-ApplyModal {
# Run changes in background to keep UI responsive
$applyWindow.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{
try {
ExecuteAllChanges
Invoke-AllChanges
$registryImportFailureCount = [int]$script:RegistryImportFailures
# Restart explorer if requested
if ($RestartExplorer -and -not $script:CancelRequested) {
@@ -131,15 +113,14 @@ function Show-ApplyModal {
# Wait for Explorer to finish relaunching, then reclaim focus.
Start-Sleep -Milliseconds 800
$applyWindow.Dispatcher.Invoke([action]{
$hwnd = (New-Object System.Windows.Interop.WindowInteropHelper($applyWindow)).Handle
[Win11Debloat.FocusHelper]::ForceActivate($hwnd)
$applyWindow.Activate()
})
}
Write-Host ""
if ($script:CancelRequested) {
Write-Host "Script execution was cancelled by the user. Some changes may not have been applied."
} else {
} elseif ($registryImportFailureCount -eq 0) {
Write-Host "All changes have been applied successfully!"
}
@@ -153,6 +134,11 @@ function Show-ApplyModal {
$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) {
$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."
} else {
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
@@ -162,7 +148,7 @@ function Show-ApplyModal {
foreach ($paramKey in $script:Params.Keys) {
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
$feature = $script:Features[$paramKey]
$rebootFeatures += "$($feature.Action) $($feature.Label)"
$rebootFeatures += "$($feature.Label)"
}
}
@@ -171,7 +157,7 @@ function Show-ApplyModal {
$tb = [System.Windows.Controls.TextBlock]::new()
$tb.Text = "$([char]0x2022) $featureName"
$tb.FontSize = 12
$tb.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'FgColor')
$tb.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, "AppFgColor")
$tb.Opacity = 0.85
$tb.Margin = [System.Windows.Thickness]::new(0, 2, 0, 0)
$applyRebootList.Children.Add($tb) | Out-Null
@@ -179,7 +165,7 @@ function Show-ApplyModal {
$applyRebootPanel.Visibility = 'Visible'
}
else {
$script:ApplyCompletionMessageEl.Text = "Your clean system is ready. Thanks for using Win11Debloat!"
$script:ApplyCompletionMessageEl.Text = "Your system is ready. Thanks for using Win11Debloat!"
}
}
}

View File

@@ -1,3 +1,23 @@
<#
.SYNOPSIS
Hides the currently displayed bubble popup.
.DESCRIPTION
Closes the bubble popup with a smooth fade-out animation (220ms). If the
-Immediate switch is used, the popup is closed instantly without animation.
This function is called automatically by Show-Bubble's timer and can also
be invoked manually to dismiss the bubble early.
.PARAMETER Immediate
If specified, the bubble popup is closed instantly without a fade-out
animation. Any pending close timer is also stopped.
.EXAMPLE
Hide-Bubble
.EXAMPLE
Hide-Bubble -Immediate
#>
function Hide-Bubble {
param (
[Parameter(Mandatory=$false)]
@@ -37,6 +57,34 @@ function Hide-Bubble {
$bubblePanel.BeginAnimation([System.Windows.UIElement]::OpacityProperty, $fadeOut)
}
<#
.SYNOPSIS
Displays a transient bubble popup hint anchored above a target control.
.DESCRIPTION
Shows a WPF popup styled as a speech bubble above the specified target
control. The bubble fades in with a animation, displays for a configurable
duration, then fades out. Any previously shown bubble is dismissed
immediately before showing the new one.
.PARAMETER TargetControl
The WPF Control above which the bubble popup will be placed. This
parameter is mandatory.
.PARAMETER Message
The text message to display inside the bubble. Defaults to
'View the selected changes here'.
.PARAMETER DurationSeconds
The number of seconds the bubble remains visible before auto-hiding.
The minimum value is 1 second. Defaults to 5 seconds.
.EXAMPLE
Show-Bubble -TargetControl $myButton
.EXAMPLE
Show-Bubble -TargetControl $myButton -Message 'Changes saved!' -DurationSeconds 3
#>
function Show-Bubble {
param (
[Parameter(Mandatory=$true)]

View File

@@ -6,7 +6,8 @@ function Show-ImportExportConfigWindow {
[string]$Prompt,
[string[]]$Categories = @('Applications', 'System Tweaks', 'Deployment Settings'),
[string[]]$DisabledCategories = @(),
[hashtable]$CategoryDetails = @()
[hashtable]$CategoryDetails = @(),
[string]$ActionLabel = 'OK'
)
# Show overlay on owner window
@@ -20,7 +21,8 @@ function Show-ImportExportConfigWindow {
$Owner.Dispatcher.Invoke([action]{ $overlay.Visibility = 'Visible' })
}
}
} catch { }
}
catch { }
# Load XAML from schema file
$schemaPath = $script:ImportExportConfigSchema
@@ -51,7 +53,8 @@ function Show-ImportExportConfigWindow {
if ($mainCheckBoxStyle) {
$dlg.Resources.Add([type][System.Windows.Controls.CheckBox], $mainCheckBoxStyle)
}
} catch { }
}
catch { }
# Populate named elements
$dlg.Title = $Title
@@ -77,7 +80,7 @@ function Show-ImportExportConfigWindow {
$cb.Margin = [System.Windows.Thickness]::new(0,0,0,4)
$cb.FontSize = 14
$cb.FontWeight = [System.Windows.FontWeights]::Medium
$cb.Foreground = $dlg.FindResource('FgColor')
$cb.Foreground = $dlg.FindResource("AppFgColor")
if ($DisabledCategories -contains $cat) {
$cb.IsChecked = $false
$cb.IsEnabled = $false
@@ -92,9 +95,9 @@ function Show-ImportExportConfigWindow {
$detailsText = New-Object System.Windows.Controls.TextBlock
$detailsText.Text = $CategoryDetails[$cat]
$detailsText.FontSize = 12
$detailsText.Foreground = $dlg.FindResource('FgColor')
$detailsText.Margin = [System.Windows.Thickness]::new(30,0,0,0)
$detailsText.Opacity = 0.75
$detailsText.Foreground = $dlg.FindResource("AppFgColor")
$detailsText.Margin = [System.Windows.Thickness]::new(32,0,0,0)
$detailsText.Opacity = if ($DisabledCategories -contains $cat) { 0.45 } else { 0.75 }
$detailsText.TextWrapping = [System.Windows.TextWrapping]::Wrap
$container.Children.Add($detailsText) | Out-Null
}
@@ -105,6 +108,7 @@ function Show-ImportExportConfigWindow {
$okBtn = $dlg.FindName('OkButton')
$cancelBtn = $dlg.FindName('CancelButton')
$okBtn.Content = $ActionLabel
$okBtn.Add_Click({ $dlg.Tag = 'OK'; $dlg.Close() })
$cancelBtn.Add_Click({ $dlg.Tag = 'Cancel'; $dlg.Close() })
@@ -284,11 +288,17 @@ function Build-CategoryDetails {
$details = @{}
if ($AppCount -gt 0) {
$details['Applications'] = "$AppCount app$(if ($AppCount -ne 1) { 's' })"
$details['Applications'] = "$AppCount app$(if ($AppCount -ne 1) { 's' }) selected"
}
else {
$details['Applications'] = 'No apps selected'
}
if ($TweakCount -gt 0) {
$details['System Tweaks'] = "$TweakCount tweak$(if ($TweakCount -ne 1) { 's' })"
$details['System Tweaks'] = "$TweakCount tweak$(if ($TweakCount -ne 1) { 's' }) selected"
}
else {
$details['System Tweaks'] = 'No tweaks selected'
}
if ($DeploymentSettings) {
@@ -379,8 +389,11 @@ function Export-Configuration {
$deploymentSettings = Get-DeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox
$categoryDetails = Build-CategoryDetails -AppCount $selectedApps.Count -TweakCount $tweakSettings.Count -DeploymentSettings $deploymentSettings
$categories = Show-ImportExportConfigWindow -Owner $Owner -UsesDarkMode $UsesDarkMode -Title 'Export Configuration' -Prompt 'Select which settings to include in the export:' -DisabledCategories $disabledCategories -CategoryDetails $categoryDetails
if (-not $categories) { return }
$categories = Show-ImportExportConfigWindow -Owner $Owner -UsesDarkMode $UsesDarkMode -Title 'Export Configuration' -Prompt 'Create a configuration file based on the currently selected settings. You can choose which settings categories you wish to include in the export.' -DisabledCategories $disabledCategories -CategoryDetails $categoryDetails -ActionLabel 'Export Settings'
if (-not $categories) {
Write-Host 'Export canceled.'
return
}
$config = @{ Version = '1.0' }
@@ -401,12 +414,25 @@ function Export-Configuration {
$saveDialog.DefaultExt = '.json'
$saveDialog.FileName = "Win11Debloat-Config-$(Get-Date -Format 'yyyyMMdd').json"
if ($saveDialog.ShowDialog($Owner) -ne $true) { return }
if ($saveDialog.ShowDialog($Owner) -ne $true) {
Write-Host 'Export save dialog canceled.'
return
}
Write-Host "Exporting configuration to '$($saveDialog.FileName)'... (Categories: $($categories -join ', '))"
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Export configuration to '$($saveDialog.FileName)'" -ForegroundColor Cyan
Show-MessageBox -Message "[WhatIf] Configuration would be exported to this file (no file written)." -Title 'Export Configuration' -Button 'OK' -Icon 'Information' | Out-Null
return
}
if (SaveToFile -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
}
else {
Write-Error "Failed to export configuration to '$($saveDialog.FileName)'"
Show-MessageBox -Message "Failed to export configuration" -Title 'Error' -Button 'OK' -Icon 'Error' | Out-Null
}
}
@@ -425,36 +451,49 @@ function Import-Configuration {
# Show native open-file dialog
$openDialog = New-Object Microsoft.Win32.OpenFileDialog
$openDialog.Title = 'Import Configuration'
$openDialog.Title = 'Select Configuration File'
$openDialog.Filter = 'JSON files (*.json)|*.json|All files (*.*)|*.*'
$openDialog.DefaultExt = '.json'
if ($openDialog.ShowDialog($Owner) -ne $true) { return }
if ($openDialog.ShowDialog($Owner) -ne $true) {
Write-Host 'Import file dialog canceled.'
return
}
Write-Host "Importing configuration from '$($openDialog.FileName)'..."
$config = LoadJsonFile -filePath $openDialog.FileName -expectedVersion '1.0'
if (-not $config) {
Show-MessageBox -Message "Failed to read configuration file" -Title 'Error' -Button 'OK' -Icon 'Error' | Out-Null
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) {
Show-MessageBox -Message "Invalid configuration file format." -Title 'Error' -Button 'OK' -Icon 'Error' | Out-Null
Write-Error "Invalid configuration file format: '$($openDialog.FileName)'"
Show-MessageBox -Message "Invalid configuration file format." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
return
}
$availableCategories = Get-AvailableImportExportCategories -Config $config
if ($availableCategories.Count -eq 0) {
Show-MessageBox -Message "The configuration file contains no importable data." -Title 'Import Configuration' -Button 'OK' -Icon 'Information' | Out-Null
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
$tweakCount = @($config.Tweaks | Where-Object { $_ -and $_.Name -and $_.Value -eq $true }).Count
$categoryDetails = Build-CategoryDetails -AppCount $appCount -TweakCount $tweakCount -DeploymentSettings @($config.Deployment)
$categories = Show-ImportExportConfigWindow -Owner $Owner -UsesDarkMode $UsesDarkMode -Title 'Import Configuration' -Prompt 'Select which settings to import:' -Categories $availableCategories -CategoryDetails $categoryDetails
if (-not $categories) { return }
$categories = Show-ImportExportConfigWindow -Owner $Owner -UsesDarkMode $UsesDarkMode -Title 'Import Configuration' -Prompt 'Choose the settings categories that you wish to import. You can review and modify the imported settings before they are applied.' -Categories $availableCategories -CategoryDetails $categoryDetails -ActionLabel 'Import Settings'
if (-not $categories) {
Write-Host 'Import canceled.'
return
}
if ($categories -contains 'Applications' -and $config.Apps) {
$appIds = @(
@@ -464,6 +503,7 @@ function Import-Configuration {
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
Write-Host "Importing $($appIds.Count) app selection(s)."
Apply-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
if ($OnAppsImported) {
@@ -471,12 +511,16 @@ 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)
}
if ($categories -contains 'Deployment Settings' -and $config.Deployment) {
Write-Host 'Importing deployment settings.'
Apply-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment)
}
Write-Host 'Configuration imported successfully.'
Show-MessageBox -Message "Configuration imported successfully." -Title 'Import Configuration' -Button 'OK' -Icon 'Information' | Out-Null
if ($OnImportCompleted) {

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ function Show-MessageBox {
[string]$Button = 'OK',
[Parameter(Mandatory=$false)]
[ValidateSet('None', 'Information', 'Warning', 'Error', 'Question')]
[ValidateSet('None', 'Information', 'Success', 'Warning', 'Error', 'Question')]
[string]$Icon = 'None',
[Parameter(Mandatory=$false)]
@@ -87,22 +87,27 @@ function Show-MessageBox {
switch ($Icon) {
'Information' {
$iconText.Text = [char]0xE946
$iconText.Foreground = $msgWindow.FindResource('InformationIconColor')
$iconText.Foreground = $msgWindow.FindResource('IconInformationColor')
$iconText.Visibility = 'Visible'
}
'Success' {
$iconText.Text = [char]0xE73E
$iconText.Foreground = $msgWindow.FindResource('IconSuccessColor')
$iconText.Visibility = 'Visible'
}
'Warning' {
$iconText.Text = [char]0xE7BA
$iconText.Foreground = $msgWindow.FindResource('WarningIconColor')
$iconText.Foreground = $msgWindow.FindResource('IconWarningColor')
$iconText.Visibility = 'Visible'
}
'Error' {
$iconText.Text = [char]0xEA39
$iconText.Foreground = $msgWindow.FindResource('ErrorIconColor')
$iconText.Foreground = $msgWindow.FindResource('IconErrorColor')
$iconText.Visibility = 'Visible'
}
'Question' {
$iconText.Text = [char]0xE897
$iconText.Foreground = $msgWindow.FindResource('QuestionIconColor')
$iconText.Foreground = $msgWindow.FindResource('IconQuestionColor')
$iconText.Visibility = 'Visible'
}
default {
@@ -116,6 +121,8 @@ function Show-MessageBox {
$button1.Content = 'OK'
$button1.Add_Click({ $msgWindow.Tag = 'OK'; $msgWindow.Close() })
$button2.Visibility = 'Collapsed'
# Right-align sole button by moving it to column 1
[System.Windows.Controls.Grid]::SetColumn($button1, 1)
}
'OKCancel' {
$button1.Content = 'OK'

View File

@@ -0,0 +1,446 @@
function Show-RestoreBackupDialog {
param(
[System.Windows.Window]$Owner = $null
)
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
$usesDarkMode = GetSystemUsesDarkMode
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
$overlay = $null
$overlayWasAlreadyVisible = $false
if ($ownerWindow) {
try {
$overlay = $ownerWindow.FindName('ModalOverlay')
if ($overlay) {
$overlayWasAlreadyVisible = ($overlay.Visibility -eq 'Visible')
if (-not $overlayWasAlreadyVisible) {
$ownerWindow.Dispatcher.Invoke([action]{ $overlay.Visibility = 'Visible' })
}
}
}
catch { }
}
$schemaPath = $script:RestoreBackupWindowSchema
if (-not $schemaPath -or -not (Test-Path $schemaPath)) {
throw 'Restore backup window schema file could not be found.'
}
$xaml = Get-Content -Path $schemaPath -Raw
$reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml))
try {
$window = [System.Windows.Markup.XamlReader]::Load($reader)
}
finally {
$reader.Close()
}
if ($ownerWindow) {
try {
$window.Owner = $ownerWindow
}
catch { }
}
try {
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
}
catch { }
$titleBar = $window.FindName('TitleBar')
$titleText = $window.FindName('TitleText')
$closeBtn = $window.FindName('CloseBtn')
$backBtn = $window.FindName('BackBtn')
$primaryActionBtn = $window.FindName('PrimaryActionBtn')
$chooseRegistryBtn = $window.FindName('ChooseRegistryBtn')
$chooseStartMenuBtn = $window.FindName('ChooseStartMenuBtn')
$restoreModeTabs = $window.FindName('RestoreModeTabs')
$startMenuIntroPanel = $window.FindName('StartMenuIntroPanel')
$startMenuScopeCombo = $window.FindName('StartMenuScopeCombo')
$startMenuAutoBackupCheck = $window.FindName('StartMenuAutoBackupCheck')
$introInfoPanel = $window.FindName('IntroInfoPanel')
$overviewPanel = $window.FindName('OverviewPanel')
$overviewFeaturesSection = $window.FindName('OverviewFeaturesSection')
$overviewSummaryText = $window.FindName('OverviewSummaryText')
$backupFileText = $window.FindName('BackupFileText')
$backupCreatedText = $window.FindName('BackupCreatedText')
$backupTargetText = $window.FindName('BackupTargetText')
$featuresItemsControl = $window.FindName('FeaturesItemsControl')
$reappliedSeparator = $window.FindName('ReappliedSeparator')
$reappliedPanel = $window.FindName('ReappliedPanel')
$reappliedFeaturesItemsControl = $window.FindName('ReappliedFeaturesItemsControl')
$nonRevertibleSeparator = $window.FindName('NonRevertibleSeparator')
$nonRevertiblePanel = $window.FindName('NonRevertiblePanel')
$nonRevertibleFeaturesItemsControl = $window.FindName('NonRevertibleFeaturesItemsControl')
$nonRevertibleWikiLink = $window.FindName('NonRevertibleWikiLink')
$titleBar.Add_MouseLeftButtonDown({ $window.DragMove() })
$window.Tag = New-RestoreDialogState
$chooseRegistryBtn.IsDefault = $true
$state = @{ WizardStep = 'SelectType'; SelectedRegistryBackup = $null; SelectedStartMenuBackupFilePath = $null }
$getStartMenuScopeInfo = {
$isAllUsersScope = ($startMenuScopeCombo.SelectedItem.Tag -eq 'AllUsers')
$scopeValue = if ($isAllUsersScope) { 'AllUsers' } else { 'CurrentUser' }
$summaryScopeText = if ($isAllUsersScope) { 'all users' } else { 'the current user' }
return [PSCustomObject]@{
Scope = $scopeValue
Target = $scopeValue
SummaryText = $summaryScopeText
}
}
$showStartMenuIntroState = {
$backupFileText.Text = 'Not selected'
$backupCreatedText.Text = 'N/A'
$overviewSummaryText.Visibility = 'Collapsed'
$overviewPanel.Visibility = 'Collapsed'
$startMenuIntroPanel.Visibility = 'Visible'
$restoreModeTabs.SelectedIndex = 2
}
$showStartMenuOverviewState = {
param([string]$BackupFilePath)
$scopeInfo = & $getStartMenuScopeInfo
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -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
$createdText = 'Unknown'
try {
$createdText = (Get-Item -LiteralPath $BackupFilePath -ErrorAction Stop).LastWriteTime.ToString('yyyy-MM-dd HH:mm')
}
catch { }
$backupCreatedText.Text = $createdText
$overviewFeaturesSection.Visibility = 'Collapsed'
$overviewSummaryText.Visibility = 'Visible'
$reappliedSeparator.Visibility = 'Collapsed'
$reappliedPanel.Visibility = 'Collapsed'
$nonRevertibleSeparator.Visibility = 'Collapsed'
$nonRevertiblePanel.Visibility = 'Collapsed'
$introInfoPanel.Visibility = 'Collapsed'
$overviewPanel.Visibility = 'Visible'
$restoreModeTabs.SelectedIndex = 1
}
$updateStartMenuOverviewPanel = {
if ($state.WizardStep -ne 'StartMenu') {
return
}
if ([string]::IsNullOrWhiteSpace($state.SelectedStartMenuBackupFilePath)) {
& $showStartMenuIntroState
return
}
& $showStartMenuOverviewState $state.SelectedStartMenuBackupFilePath
}
$updateStartMenuPrimaryActionText = {
if ($state.WizardStep -ne 'StartMenu') {
return
}
$isAutoBackupEnabled = ($startMenuAutoBackupCheck.IsChecked -eq $true)
$hasSelectedManualFile = -not [string]::IsNullOrWhiteSpace($state.SelectedStartMenuBackupFilePath)
if ($isAutoBackupEnabled -or $hasSelectedManualFile) {
$primaryActionBtn.Content = 'Restore backup'
}
else {
$primaryActionBtn.Content = 'Select backup file'
}
}
$refreshStartMenuUi = {
& $updateStartMenuOverviewPanel
& $updateStartMenuPrimaryActionText
}
$enterSelectTypeStep = {
$titleText.Text = 'Restore Backup'
$restoreModeTabs.SelectedIndex = 0
$backBtn.Visibility = 'Visible'
$backBtn.Content = 'Cancel'
$primaryActionBtn.Visibility = 'Collapsed'
$chooseRegistryBtn.IsDefault = $true
$primaryActionBtn.IsDefault = $false
}
$enterRegistryStep = {
$titleText.Text = 'Restore Registry Backup'
$restoreModeTabs.SelectedIndex = 1
$introInfoPanel.Visibility = 'Visible'
$overviewPanel.Visibility = 'Collapsed'
$overviewFeaturesSection.Visibility = 'Visible'
$overviewSummaryText.Visibility = 'Collapsed'
$backBtn.Visibility = 'Visible'
$backBtn.Content = 'Back'
$primaryActionBtn.Visibility = 'Visible'
$primaryActionBtn.Content = 'Select backup file'
$primaryActionBtn.IsDefault = $true
$chooseRegistryBtn.IsDefault = $false
}
$enterStartMenuStep = {
$titleText.Text = 'Restore Start Menu Backup'
$restoreModeTabs.SelectedIndex = 2
$backBtn.Visibility = 'Visible'
$backBtn.Content = 'Back'
$primaryActionBtn.Visibility = 'Visible'
$primaryActionBtn.IsDefault = $true
$chooseRegistryBtn.IsDefault = $false
# Show intro panel so user can configure scope & auto-detect
$startMenuAutoBackupCheck.IsChecked = $true
$state.SelectedStartMenuBackupFilePath = $null
& $refreshStartMenuUi
}
$showRegistryOverview = {
param(
[Parameter(Mandatory = $true)]
$SelectedBackup,
[Parameter(Mandatory = $true)]
[string]$SelectedBackupFilePath
)
$createdText = if ([string]::IsNullOrWhiteSpace($SelectedBackup.CreatedAt)) {
'Unknown'
}
else {
try {
[DateTime]::Parse($SelectedBackup.CreatedAt).ToString('yyyy-MM-dd HH:mm')
}
catch {
$SelectedBackup.CreatedAt
}
}
$selectedForwardFeatureIds = @(Get-SelectedForwardFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
$selectedUndoFeatureIds = @(Get-SelectedUndoFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
$seenForwardFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in $selectedForwardFeatureIds) {
[void]$seenForwardFeatureIds.Add([string]$featureId)
}
$filteredUndoFeatureIds = New-Object System.Collections.Generic.List[string]
foreach ($featureId in $selectedUndoFeatureIds) {
if ($seenForwardFeatureIds.Contains([string]$featureId)) {
continue
}
$filteredUndoFeatureIds.Add([string]$featureId)
}
$forwardFeatureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds $selectedForwardFeatureIds -Features $script:Features
$undoFeatureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds @($filteredUndoFeatureIds.ToArray()) -Features $script:Features
$combinedFeatureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds (Get-SelectedFeatureIdsFromBackup -SelectedBackup $SelectedBackup) -Features $script:Features
$revertibleFeaturesList = @($forwardFeatureLists.Revertible)
$reappliedFeaturesList = @($undoFeatureLists.Revertible)
$nonRevertibleFeaturesList = @($combinedFeatureLists.NonRevertible)
Write-Host "Backup overview prepared. Reverted=$($revertibleFeaturesList.Count), ReApplied=$($reappliedFeaturesList.Count), NonRevertible=$($nonRevertibleFeaturesList.Count)"
if ($revertibleFeaturesList.Count -eq 0 -and $reappliedFeaturesList.Count -eq 0) {
throw 'The selected backup does not contain any changes that can be restored.'
}
$backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf
$backupCreatedText.Text = $createdText
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
if ($reappliedFeaturesList.Count -gt 0) { $reappliedPanel.Visibility = 'Visible' } else { $reappliedPanel.Visibility = 'Collapsed' }
if ($revertibleFeaturesList.Count -gt 0 -and $reappliedFeaturesList.Count -gt 0) { $reappliedSeparator.Visibility = 'Visible' } else { $reappliedSeparator.Visibility = 'Collapsed' }
$overviewSummaryText.Visibility = 'Collapsed'
$nonRevertibleFeaturesItemsControl.ItemsSource = $nonRevertibleFeaturesList
$hasNonRevertibleItems = ($nonRevertibleFeaturesList.Count -gt 0)
if ($hasNonRevertibleItems) { $nonRevertiblePanel.Visibility = 'Visible' } else { $nonRevertiblePanel.Visibility = 'Collapsed' }
if ($hasNonRevertibleItems -and ($revertibleFeaturesList.Count -gt 0 -or $reappliedFeaturesList.Count -gt 0)) { $nonRevertibleSeparator.Visibility = 'Visible' } else { $nonRevertibleSeparator.Visibility = 'Collapsed' }
$introInfoPanel.Visibility = 'Collapsed'
$overviewPanel.Visibility = 'Visible'
return $true
}
$handleRegistryPrimaryAction = {
if ($state.SelectedRegistryBackup) {
$window.Tag = @{
Result = 'RestoreRegistry'
Backup = $state.SelectedRegistryBackup
}
$window.DialogResult = $true
$window.Close()
return
}
$openDialog = New-Object Microsoft.Win32.OpenFileDialog
$openDialog.Title = 'Select Registry Backup File'
$openDialog.Filter = 'Registry backup (*.json)|*.json'
$openDialog.DefaultExt = '.json'
$openDialog.InitialDirectory = $script:RegistryBackupsPath
if ($openDialog.ShowDialog($window) -ne $true) {
return
}
Write-Host "Backup file selected: $($openDialog.FileName)"
$selectedBackup = Load-RegistryBackupFromFile -FilePath $openDialog.FileName
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
return
}
$state.SelectedRegistryBackup = $selectedBackup
$primaryActionBtn.Content = 'Restore from backup'
}
$handleStartMenuPrimaryAction = {
$scope = (& $getStartMenuScopeInfo).Scope
$useManualBackupFile = -not ($startMenuAutoBackupCheck.IsChecked -eq $true)
if ($useManualBackupFile -and [string]::IsNullOrWhiteSpace($state.SelectedStartMenuBackupFilePath)) {
$openDialog = New-Object Microsoft.Win32.OpenFileDialog
$openDialog.Title = 'Select Start Menu Backup File'
$openDialog.Filter = 'Start Menu backup (*.bak)|*.bak'
$openDialog.InitialDirectory = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$openDialog.DefaultExt = '.bak'
if ($openDialog.ShowDialog($window) -ne $true) {
return
}
$state.SelectedStartMenuBackupFilePath = $openDialog.FileName
Write-Host "Selected Start Menu backup file: $($state.SelectedStartMenuBackupFilePath)"
& $refreshStartMenuUi
return
}
if (-not $useManualBackupFile) {
$scopeInfo = & $getStartMenuScopeInfo
$autoBackupPath = Get-StartMenuBackupPath -Scope $scopeInfo.Scope
if ($null -eq $autoBackupPath) {
$scopeText = $scopeInfo.SummaryText
Show-MessageBox -Owner $window -Title 'No Backup Found' -Message "No Start Menu backup file was found for $scopeText. Uncheck 'Automatically find Start Menu backup' to select a backup file manually." -Button 'OK' -Icon 'Warning' | Out-Null
return
}
$state.SelectedStartMenuBackupFilePath = if ($scopeInfo.Scope -eq 'CurrentUser') { $autoBackupPath } else { $null }
}
$window.Tag = @{
Result = 'RestoreStartMenu'
StartMenuScope = $scope
UseManualBackupFile = $useManualBackupFile
BackupFilePath = $state.SelectedStartMenuBackupFilePath
}
$window.DialogResult = $true
$window.Close()
}
$setWizardStep = {
param([string]$step)
$state.WizardStep = $step
switch ($step) {
'SelectType' { & $enterSelectTypeStep }
'Registry' { & $enterRegistryStep }
'StartMenu' { & $enterStartMenuStep }
}
}
$startMenuAutoBackupCheck.Add_Checked({
$state.SelectedStartMenuBackupFilePath = $null
& $refreshStartMenuUi
})
$startMenuAutoBackupCheck.Add_Unchecked({
& $refreshStartMenuUi
})
$startMenuScopeCombo.Add_SelectionChanged({
$state.SelectedStartMenuBackupFilePath = $null
& $refreshStartMenuUi
})
$nonRevertibleWikiLink.Add_MouseLeftButtonUp({
try {
Start-Process 'https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes' | Out-Null
}
catch { }
})
$closeBtn.Add_Click({
$window.Tag = New-RestoreDialogState
$window.DialogResult = $false
$window.Close()
})
$chooseRegistryBtn.Add_Click({ & $setWizardStep 'Registry' })
$chooseStartMenuBtn.Add_Click({ & $setWizardStep 'StartMenu' })
$backBtn.Add_Click({
if ($state.WizardStep -eq 'SelectType') {
$window.Tag = New-RestoreDialogState
$window.DialogResult = $false
$window.Close()
return
}
if ($state.WizardStep -eq 'Registry') {
$state.SelectedRegistryBackup = $null
}
if ($state.WizardStep -eq 'StartMenu') {
$state.SelectedStartMenuBackupFilePath = $null
$startMenuAutoBackupCheck.IsChecked = $true
}
& $setWizardStep 'SelectType'
})
$primaryActionBtn.Add_Click({
switch ($state.WizardStep) {
'Registry' { & $handleRegistryPrimaryAction }
'StartMenu' { & $handleStartMenuPrimaryAction }
}
})
$window.Add_KeyDown({
param($source, $e)
if ($e.Key -eq 'Escape') {
$window.Tag = New-RestoreDialogState
$window.DialogResult = $false
$window.Close()
}
})
& $setWizardStep 'SelectType'
try {
$null = $window.ShowDialog()
}
catch {
$innerMessage = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { 'None' }
throw "Failed to show restore backup dialog. Error: $($_.Exception.Message) Inner: $innerMessage"
}
finally {
if ($overlay -and -not $overlayWasAlreadyVisible) {
try {
$ownerWindow.Dispatcher.Invoke([action]{ $overlay.Visibility = 'Collapsed' })
}
catch { }
}
}
return $window.Tag
}

View File

@@ -0,0 +1,111 @@
function Show-RestoreBackupWindow {
param(
[System.Windows.Window]$Owner = $null
)
try {
Write-Host 'Opening restore backup dialog.'
$restoreResult = [PSCustomObject]@{
RestoredRegistry = $false
RestoredStartMenu = $false
}
$dialogResult = Show-RestoreBackupDialog -Owner $Owner
if (-not $dialogResult -or $dialogResult.Result -eq 'Cancel') {
Write-Host 'Restore canceled by user.'
return $restoreResult
}
$successMessage = $null
$warningMessage = $null
if ($dialogResult.Result -eq 'RestoreRegistry') {
$backup = $dialogResult.Backup
if (-not $backup) {
throw 'Registry backup restore requested without a selected backup.'
}
Write-Host "User confirmed registry restore for $($backup.Target)."
$restoreOpResult = Restore-RegistryBackupState -Backup $backup
if ($restoreOpResult -and $restoreOpResult.Result) {
$restoreResult.RestoredRegistry = $true
if ($script:Params.ContainsKey("WhatIf")) {
$successMessage = '[WhatIf] Registry backup would be restored (no changes made).'
}
else {
$successMessage = 'Registry backup restored successfully. Some changes may require a restart to take effect.'
}
}
}
elseif ($dialogResult.Result -eq 'RestoreStartMenu') {
$scope = $dialogResult.StartMenuScope
$useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true)
$backupFilePath = $null
if ($dialogResult -is [hashtable] -and $dialogResult.ContainsKey('BackupFilePath')) {
$backupFilePath = $dialogResult['BackupFilePath']
}
elseif ($dialogResult.PSObject.Properties.Match('BackupFilePath').Count -gt 0) {
$backupFilePath = $dialogResult.BackupFilePath
}
if ($useManualBackupFile -and [string]::IsNullOrWhiteSpace($backupFilePath)) {
throw 'Start Menu restore canceled: no backup file selected.'
}
$result = if ($scope -eq 'AllUsers') {
RestoreStartMenuForAllUsers -BackupFilePath $backupFilePath
}
else {
RestoreStartMenu -BackupFilePath $backupFilePath
}
$resultEntries = @($result)
$successCount = @($resultEntries | Where-Object { $_.Result -eq $true }).Count
$failedEntries = @($resultEntries | Where-Object { $_.Result -ne $true })
if ($successCount -eq 0) {
$errorSummary = ($resultEntries | ForEach-Object { $_.Message }) -join [Environment]::NewLine
throw "Failed to restore the Start Menu backup.`n$errorSummary"
}
if ($failedEntries.Count -gt 0) {
$failureSummary = ($failedEntries | ForEach-Object { $_.Message }) -join [Environment]::NewLine
$warningMessage = "The Start Menu backup was successfully restored for $successCount user(s).`nSome users could not be restored:`n$failureSummary"
}
else {
if ($script:Params.ContainsKey("WhatIf")) {
$successMessage = '[WhatIf] Start Menu backup would be restored (no changes made).'
}
elseif ($scope -eq 'AllUsers') {
$successMessage = "The Start Menu backup was successfully restored for all users. The changes will apply the next time users sign in."
}
else {
$successMessage = "The Start Menu backup was successfully restored for the current user. The changes will apply the next time you sign in."
}
}
$restoreResult.RestoredStartMenu = $true
}
if ($warningMessage) {
Write-Host "$warningMessage"
Show-MessageBox -Title 'Backup Restored' -Message $warningMessage -Icon Warning
}
elseif ($successMessage) {
Write-Host "$successMessage"
Show-MessageBox -Title 'Backup Restored' -Message $successMessage -Icon Success
}
return $restoreResult
}
catch {
$errorMessage = if ($_.Exception.Message) { $_.Exception.Message } else { 'An unexpected error occurred.' }
Write-Error "Restore operation failed: $errorMessage"
Show-MessageBox -Title 'Error' -Message "Restore failed: $errorMessage" -Icon Error
return [PSCustomObject]@{
RestoredRegistry = $false
RestoredStartMenu = $false
}
}
}

View File

@@ -7,7 +7,6 @@ param (
[string]$User,
[switch]$NoRestartExplorer,
[switch]$CreateRestorePoint,
[switch]$RunAppsListGenerator,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -15,11 +14,8 @@ param (
[string]$Apps,
[string]$AppRemovalTarget,
[switch]$RemoveApps,
[switch]$RemoveAppsCustom,
[switch]$RemoveGamingApps,
[switch]$RemoveCommApps,
[switch]$RemoveHPApps,
[switch]$RemoveW11Outlook,
[switch]$ForceRemoveEdge,
[switch]$DisableDVR,
[switch]$DisableGameBarIntegration,
@@ -58,7 +54,7 @@ param (
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
[switch]$HideTaskview,
[switch]$DisableStartRecommended,
[switch]$DisableStartAllApps,
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
[switch]$DisableStartPhoneLink,
[switch]$DisableCopilot,
[switch]$DisableRecall,
@@ -117,11 +113,15 @@ 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 "$env:TEMP/win11debloat.zip"
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
@@ -131,23 +131,25 @@ catch {
Exit
}
Write-Output ""
Write-Output "> Cleaning up old Win11Debloat folder..."
# 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..."
# Remove old script folder if it exists, but keep config and log files
if (Test-Path "$env:TEMP/Win11Debloat") {
Get-ChildItem -Path "$env:TEMP/Win11Debloat" -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Config,Logs | Remove-Item -Recurse -Force
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
$configDir = "$env:TEMP/Win11Debloat/Config"
$backupDir = "$env:TEMP/Win11Debloat/ConfigOld"
$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 = @(
'CustomAppsList',
'LastUsedSettings.json'
)
@@ -160,13 +162,13 @@ Write-Output ""
Write-Output "> Unpacking..."
# Unzip archive to Win11Debloat folder
Expand-Archive "$env:TEMP/win11debloat.zip" "$env:TEMP/Win11Debloat"
Expand-Archive $tempArchivePath $tempWorkPath
# Remove archive
Remove-Item "$env:TEMP/win11debloat.zip"
Remove-Item $tempArchivePath
# Move files
Get-ChildItem -Path "$env:TEMP/Win11Debloat/*Win11Debloat-*" -Recurse | Move-Item -Destination "$env:TEMP/Win11Debloat"
Get-ChildItem -Path (Join-Path $tempWorkPath '*Win11Debloat-*') -Recurse | Move-Item -Destination $tempWorkPath
# Add existing config files back to Config folder
if (Test-Path "$backupDir") {
@@ -174,6 +176,9 @@ if (Test-Path "$backupDir") {
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
}
@@ -206,20 +211,21 @@ if ($PSVersionTable.PSVersion.Major -ge 7) {
}
# Run Win11Debloat script with the provided arguments
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File $env:TEMP\Win11Debloat\Win11Debloat.ps1 $arguments" -Verb RunAs
$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 CustomAppsList and LastUsedSettings.json files
if (Test-Path "$env:TEMP/Win11Debloat") {
# 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 "$env:TEMP/Win11Debloat" -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Win11Debloat-Run.log,Config,Logs | Remove-Item -Recurse -Force
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
Write-Output ""

View File

@@ -7,7 +7,6 @@ param (
[string]$User,
[switch]$NoRestartExplorer,
[switch]$CreateRestorePoint,
[switch]$RunAppsListGenerator,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -15,11 +14,8 @@ param (
[string]$Apps,
[string]$AppRemovalTarget,
[switch]$RemoveApps,
[switch]$RemoveAppsCustom,
[switch]$RemoveGamingApps,
[switch]$RemoveCommApps,
[switch]$RemoveHPApps,
[switch]$RemoveW11Outlook,
[switch]$ForceRemoveEdge,
[switch]$DisableDVR,
[switch]$DisableGameBarIntegration,
@@ -58,7 +54,7 @@ param (
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
[switch]$HideTaskview,
[switch]$DisableStartRecommended,
[switch]$DisableStartAllApps,
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
[switch]$DisableStartPhoneLink,
[switch]$DisableCopilot,
[switch]$DisableRecall,
@@ -117,12 +113,16 @@ Write-Output "------------------------------------------------------------------
Write-Output " Win11Debloat Script - Get"
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
try {
$LatestReleaseUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
Invoke-RestMethod $LatestReleaseUri -OutFile "$env:TEMP/win11debloat.zip"
Invoke-RestMethod $LatestReleaseUri -OutFile $tempArchivePath
}
catch {
Write-Host "Error: Unable to fetch latest release from GitHub. Please check your internet connection and try again." -ForegroundColor Red
@@ -132,23 +132,25 @@ catch {
Exit
}
Write-Output ""
Write-Output "> Cleaning up old Win11Debloat folder..."
# 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..."
# Remove old script folder if it exists, but keep config and log files
if (Test-Path "$env:TEMP/Win11Debloat") {
Get-ChildItem -Path "$env:TEMP/Win11Debloat" -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Config,Logs | Remove-Item -Recurse -Force
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
$configDir = "$env:TEMP/Win11Debloat/Config"
$backupDir = "$env:TEMP/Win11Debloat/ConfigOld"
$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 = @(
'CustomAppsList',
'LastUsedSettings.json'
)
@@ -161,13 +163,13 @@ Write-Output ""
Write-Output "> Unpacking..."
# Unzip archive to Win11Debloat folder
Expand-Archive "$env:TEMP/win11debloat.zip" "$env:TEMP/Win11Debloat"
Expand-Archive $tempArchivePath $tempWorkPath
# Remove archive
Remove-Item "$env:TEMP/win11debloat.zip"
Remove-Item $tempArchivePath
# Move files
Get-ChildItem -Path "$env:TEMP/Win11Debloat/Raphire-Win11Debloat-*" -Recurse | Move-Item -Destination "$env:TEMP/Win11Debloat"
Get-ChildItem -Path (Join-Path $tempWorkPath '*Win11Debloat-*') -Recurse | Move-Item -Destination $tempWorkPath
# Add existing config files back to Config folder
if (Test-Path "$backupDir") {
@@ -175,6 +177,9 @@ if (Test-Path "$backupDir") {
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
}
@@ -207,20 +212,21 @@ if ($PSVersionTable.PSVersion.Major -ge 7) {
}
# Run Win11Debloat script with the provided arguments
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File $env:TEMP\Win11Debloat\Win11Debloat.ps1 $arguments" -Verb RunAs
$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 CustomAppsList and LastUsedSettings.json files
if (Test-Path "$env:TEMP/Win11Debloat") {
# 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 "$env:TEMP/Win11Debloat" -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Win11Debloat-Run.log,Config,Logs | Remove-Item -Recurse -Force
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
Write-Output ""

View File

@@ -0,0 +1,228 @@
function Get-NormalizedRegistryValueName {
param(
[AllowNull()]
$ValueName
)
if ([string]::IsNullOrEmpty([string]$ValueName)) {
return ''
}
return [string]$ValueName
}
function Convert-RegOperationToValueKind {
param(
[Parameter(Mandatory)]
$Operation
)
$valueName = if ([string]::IsNullOrEmpty([string]$Operation.ValueName)) { '' } else { [string]$Operation.ValueName }
$valueType = [string]$Operation.ValueType
$operationKeyPath = [string]$Operation.KeyPath
switch ($valueType) {
'DWord' {
$unsigned = [uint32]$Operation.ValueData
$value = [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Value = $value }
}
'String' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::String; Value = [string]$Operation.ValueData }
}
'Binary' {
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Value = [byte[]]$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)]
[string]$RegistryPath,
[switch]$CreateIfMissing,
[bool]$OpenKey = $true
)
$parts = Split-RegistryPath -path $RegistryPath
if (-not $parts) {
throw "Unsupported registry path: $RegistryPath"
}
$rootKey = Get-RegistryRootKey -hiveName $parts.Hive
if (-not $rootKey) {
throw "Unsupported registry hive '$($parts.Hive)' in path '$RegistryPath'"
}
$subKeyPath = $parts.SubKey
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
return [PSCustomObject]@{ RootKey = $rootKey; SubKeyPath = $null; Key = $rootKey }
}
if (-not $OpenKey) {
return [PSCustomObject]@{ RootKey = $rootKey; SubKeyPath = $subKeyPath; Key = $null }
}
$key = if ($CreateIfMissing) {
$rootKey.CreateSubKey($subKeyPath)
}
else {
$rootKey.OpenSubKey($subKeyPath, $true)
}
return [PSCustomObject]@{ RootKey = $rootKey; SubKeyPath = $subKeyPath; Key = $key }
}
function Invoke-RegistryDeleteValueOperation {
param(
[Parameter(Mandatory)]
$Operation,
[Parameter(Mandatory)]
$KeyInfo
)
if ($null -eq $KeyInfo.Key) {
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
$displayValueName = if ([string]::IsNullOrEmpty($valueName)) { '(Default)' } else { $valueName }
Write-Verbose "Unable to find or open key '$($Operation.KeyPath)' and value '$displayValueName'"
return
}
try {
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
$KeyInfo.Key.DeleteValue($valueName, $false)
}
finally {
$KeyInfo.Key.Close()
}
}
function Invoke-RegistrySetValueOperation {
param(
[Parameter(Mandatory)]
$Operation,
[Parameter(Mandatory)]
$KeyInfo
)
if ($null -eq $KeyInfo.Key) {
throw [System.UnauthorizedAccessException]::new("Unable to open or create registry key '$($Operation.KeyPath)'")
}
try {
$setArgs = Convert-RegOperationToValueKind -Operation $Operation
$KeyInfo.Key.SetValue($setArgs.Name, $setArgs.Value, $setArgs.Kind)
}
finally {
$KeyInfo.Key.Close()
}
}
function Write-RegistryOperationAccessDeniedWarning {
param(
[Parameter(Mandatory)]
$Operation,
[Parameter(Mandatory)]
[string]$ExceptionMessage
)
$keyPath = [string]$Operation.KeyPath
$operationType = [string]$Operation.OperationType
if ($operationType -eq 'SetValue' -or $operationType -eq 'DeleteValue') {
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
$displayValueName = if ([string]::IsNullOrEmpty($valueName)) { '(Default)' } else { $valueName }
Write-Warning "Skipping operation '$operationType' on key '$keyPath' value '$displayValueName' due to access restrictions: $ExceptionMessage"
return
}
Write-Warning "Skipping operation '$operationType' on key '$keyPath' due to access restrictions: $ExceptionMessage"
}
function Invoke-RegistryOperation {
param(
[Parameter(Mandatory)]
$Operation,
[Parameter(Mandatory)]
[string]$RegFilePath
)
$operationType = [string]$Operation.OperationType
$isSetValueOperation = $operationType -eq 'SetValue'
$isDeleteKeyOperation = $operationType -eq 'DeleteKey'
$keyInfo = Get-RegistryKeyForOperation -RegistryPath $Operation.KeyPath -CreateIfMissing:$isSetValueOperation -OpenKey:(-not $isDeleteKeyOperation)
switch ($operationType) {
'DeleteKey' {
if ($null -ne $keyInfo.SubKeyPath) {
Remove-RegistrySubKeyTreeIfExists -RootKey $keyInfo.RootKey -SubKeyPath $keyInfo.SubKeyPath
}
}
'DeleteValue' {
Invoke-RegistryDeleteValueOperation -Operation $Operation -KeyInfo $keyInfo
}
'SetValue' {
Invoke-RegistrySetValueOperation -Operation $Operation -KeyInfo $keyInfo
}
default {
throw "Unsupported reg operation type '$($Operation.OperationType)' in '$RegFilePath'"
}
}
}
function Invoke-RegistryOperationsFromRegFile {
param(
[Parameter(Mandatory)]
[string]$RegFilePath
)
$accessDeniedCount = 0
$operations = @(Get-RegFileOperations -regFilePath $RegFilePath)
$totalOperations = $operations.Count
if ($script:Params.ContainsKey("WhatIf")) {
Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan
return
}
foreach ($operation in $operations) {
try {
Invoke-RegistryOperation -Operation $operation -RegFilePath $RegFilePath
}
catch [System.UnauthorizedAccessException], [System.Security.SecurityException] {
$accessDeniedCount++
Write-RegistryOperationAccessDeniedWarning -Operation $operation -ExceptionMessage $_.Exception.Message
}
}
if ($totalOperations -gt 0 -and $accessDeniedCount -eq $totalOperations) {
throw "Registry fallback import could not apply any operations in '$RegFilePath' because all $accessDeniedCount operation(s) were blocked by access restrictions."
}
if ($accessDeniedCount -gt 0) {
Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'."
}
}

View File

@@ -1,31 +1,41 @@
function CheckIfUserExists {
param (
$userName
[string]$userName
)
if ($userName -match '[<>:"|?*]') {
return $false
}
if ([string]::IsNullOrWhiteSpace($userName)) {
return $false
}
$lookupName = $userName.Trim()
# Validate special characters against the local username segment (user in DOMAIN\user or user@domain).
$localUserName = GetLocalUserNameSegment -UserName $lookupName
if ($localUserName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
return $false
}
# PowerShell treats [] as wildcard chars in non-literal paths; disallow them explicitly.
if ($localUserName -match '[\[\]]') {
return $false
}
try {
$userExists = Test-Path "$env:SystemDrive\Users\$userName"
$userContext = ResolveUserProfileContext -UserName $lookupName
if (-not $userContext -or [string]::IsNullOrWhiteSpace($userContext.ProfilePath)) {
return $false
}
if ($userExists) {
if ($lookupName -ieq 'Default') {
return $true
}
$userExists = Test-Path ($env:USERPROFILE -Replace ('\\' + $env:USERNAME + '$'), "\$userName")
return -not [string]::IsNullOrWhiteSpace($userContext.UserSid)
if ($userExists) {
return $true
}
}
catch {
Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system"
Write-Error "Something went wrong when trying to find the user directory path for user $lookupName. Please ensure the user exists on this system"
}
return $false

View File

@@ -0,0 +1,34 @@
# 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 {
param (
[string[]]$SelectedApps,
$Owner = $null
)
# Skip all warnings in Silent mode
if ($Silent) {
return $true
}
# Microsoft Store warning
if ($SelectedApps -contains "Microsoft.WindowsStore") {
$result = Show-MessageBox -Message 'Are you sure that you wish to uninstall the Microsoft Store? This app cannot easily be reinstalled.' -Title 'Are you sure?' -Button 'YesNo' -Icon 'Warning' -Owner $Owner
if ($result -ne 'Yes') {
return $false
}
}
# Windows Terminal warning
if ($SelectedApps -contains "Microsoft.WindowsTerminal") {
$result = Show-MessageBox -Message 'Are you sure that you wish to remove Windows Terminal? Windows Terminal is the default command-line app for Windows. Ensure you are not running Win11Debloat via Windows Terminal before proceeding to avoid a mid-process failure.' -Title 'Are you sure?' -Button 'YesNo' -Icon 'Warning' -Owner $Owner
if ($result -ne 'Yes') {
return $false
}
}
return $true
}

View File

@@ -0,0 +1,181 @@
# Operation type constants, used to indicate the type of operation for each registry entry
$script:OpType_RemoveKey = 'DeleteKey'
$script:OpType_RemoveValue = 'DeleteValue'
$script:OpType_Store = 'SetValue'
function Get-RegFileOperations {
param(
[Parameter(Mandatory)]
[string]$regFilePath
)
$content = Get-Content -Path $regFilePath -Raw -ErrorAction Stop
$rawLines = $content -split "`r?`n"
# Join continuation lines (lines ending with \)
$lines = @()
$i = 0
while ($i -lt $rawLines.Count) {
$line = $rawLines[$i]
# Join lines that end with backslash to the next line(s)
while ($line.EndsWith("\") -and $i + 1 -lt $rawLines.Count) {
$line = $line.Substring(0, $line.Length - 1) + $rawLines[$i + 1]
$i++
}
$lines += $line
$i++
}
$operations = @()
$currentKeyPath = $null
$isDeletedKey = $false
$opRef = $script:OpType_RemoveKey
foreach ($rawLine in $lines) {
$line = $rawLine.Trim()
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith(';')) {
continue
}
if ($line -match '^Windows Registry Editor Version') {
continue
}
if ($line -match '^\[(?<deleted>-)?(?<keyPath>[^\]]+)\]$') {
$currentKeyPath = $matches.keyPath.Trim()
$isDeletedKey = $matches.deleted -eq '-'
if ($isDeletedKey) {
$operations += [PSCustomObject]@{
OperationType = $opRef
KeyPath = $currentKeyPath
}
}
continue
}
if (-not $currentKeyPath -or $isDeletedKey) {
continue
}
if ($line -notmatch '^(?<valueName>@|"[^"]+")=(?<valueData>.*)$') {
continue
}
$valueNameToken = $matches.valueName
$valueName = if ($valueNameToken -eq '@') {
''
}
else {
$valueNameToken.Trim('"')
}
$parsedValue = Convert-RegValueData -valueData $matches.valueData.Trim()
if (-not $parsedValue) { continue }
$operations += [PSCustomObject]@{
OperationType = $parsedValue.OperationType
KeyPath = $currentKeyPath
ValueName = $valueName
ValueType = $parsedValue.ValueType
ValueData = $parsedValue.ValueData
}
}
return $operations
}
function Convert-RegValueData {
param(
[Parameter(Mandatory)]
[string]$valueData
)
$opStore = $script:OpType_Store
$opRemove = $script:OpType_RemoveValue
if ($valueData -eq '-') {
return [PSCustomObject]@{
OperationType = $opRemove
ValueType = $null
ValueData = $null
}
}
if ($valueData -match '^dword:(?<value>[0-9a-fA-F]{1,8})$') {
return [PSCustomObject]@{
OperationType = $opStore
ValueType = 'DWord'
ValueData = [uint32]::Parse($matches.value, [System.Globalization.NumberStyles]::HexNumber)
}
}
if ($valueData -match '^qword:(?<value>[0-9a-fA-F]{1,16})$') {
return [PSCustomObject]@{
OperationType = $opStore
ValueType = 'QWord'
ValueData = [uint64]::Parse($matches.value, [System.Globalization.NumberStyles]::HexNumber)
}
}
if ($valueData -match '^hex(?:\((?<kind>[0-9a-fA-F]+)\))?:(?<bytes>[0-9a-fA-F,\s]+)$') {
$bytes = Convert-HexStringToByteArray -hexValue $matches.bytes
$valueType = if ($matches.kind) { "Hex$($matches.kind)" } else { 'Binary' }
$value = switch ($matches.kind) {
'2' { Convert-RegistryByteArrayToString -byteData $bytes }
'7' { Convert-RegistryByteArrayToMultiString -byteData $bytes }
default { $bytes }
}
return [PSCustomObject]@{
OperationType = $opStore
ValueType = $valueType
ValueData = $value
}
}
if ($valueData -match '^"(?<value>.*)"$') {
$stringValue = $matches.value
# Unescape registry string escape sequences
$stringValue = $stringValue -replace '\\"', '"' -replace '\\\\', '\'
return [PSCustomObject]@{
OperationType = $opStore
ValueType = 'String'
ValueData = $stringValue
}
}
return $null
}
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[]]
}
function Convert-RegistryByteArrayToString {
param(
[Parameter(Mandatory)]
[byte[]]$byteData
)
return ([System.Text.Encoding]::Unicode.GetString($byteData)).TrimEnd([char]0)
}
function Convert-RegistryByteArrayToMultiString {
param(
[Parameter(Mandatory)]
[byte[]]$byteData
)
return @(([System.Text.Encoding]::Unicode.GetString($byteData)).TrimEnd([char]0) -split "`0" | Where-Object { $_ -ne '' })
}

View File

@@ -0,0 +1,43 @@
function GetFriendlyRegistryBackupTarget {
param(
[AllowNull()]
[AllowEmptyString()]
[string]$Target
)
if ([string]::IsNullOrWhiteSpace($Target)) {
return 'Unknown'
}
if ($Target -eq 'DefaultUserProfile') {
return 'Default user profile'
}
if ($Target -eq 'CurrentUser') {
return 'Current user'
}
if ($Target -eq 'AllUsers') {
return 'All users'
}
if ($Target -like 'CurrentUser:*') {
$userName = $Target.Substring(12)
if ([string]::IsNullOrWhiteSpace($userName)) {
return 'Current user'
}
return "Current user ($userName)"
}
if ($Target -like 'User:*') {
$userName = $Target.Substring(5)
if ([string]::IsNullOrWhiteSpace($userName)) {
return 'User'
}
return "User ($userName)"
}
return $Target
}

View File

@@ -7,23 +7,41 @@ function GetUserDirectory {
)
try {
if (-not (CheckIfUserExists -userName $userName) -and $userName -ne "*") {
Write-Error "User $userName does not exist on this system"
AwaitKeyToExit
if ($userName -eq "*") {
$rootPaths = @(
(Join-Path $env:SystemDrive 'Users')
(Split-Path -Path $env:USERPROFILE -Parent)
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
foreach ($rootPath in $rootPaths) {
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
continue
}
$wildcardPath = if ([string]::IsNullOrWhiteSpace($fileName)) {
Join-Path $rootPath '*'
}
else {
Join-Path (Join-Path $rootPath '*') $fileName
}
return $wildcardPath
}
}
$userDirectoryExists = Test-Path "$env:SystemDrive\Users\$userName"
$userPath = "$env:SystemDrive\Users\$userName\$fileName"
$userContext = ResolveUserProfileContext -UserName $userName
$resolvedUserDirectory = if ($userContext) { $userContext.ProfilePath } else { $null }
if ($resolvedUserDirectory) {
$userPath = if ([string]::IsNullOrWhiteSpace($fileName)) {
$resolvedUserDirectory
}
else {
Join-Path $resolvedUserDirectory $fileName
}
if ((Test-Path $userPath) -or ($userDirectoryExists -and (-not $exitIfPathNotFound))) {
return $userPath
}
$userDirectoryExists = Test-Path ($env:USERPROFILE -Replace ('\\' + $env:USERNAME + '$'), "\$userName")
$userPath = $env:USERPROFILE -Replace ('\\' + $env:USERNAME + '$'), "\$userName\$fileName"
if ((Test-Path $userPath) -or ($userDirectoryExists -and (-not $exitIfPathNotFound))) {
return $userPath
if ((Test-Path -LiteralPath $userPath) -or ((Test-Path -LiteralPath $resolvedUserDirectory -PathType Container) -and (-not $exitIfPathNotFound))) {
return $userPath
}
}
}
catch {

View File

@@ -0,0 +1,84 @@
function Split-RegistryPath {
param(
[Parameter(Mandatory)]
[string]$path
)
$normalizedPath = [string]$path
if ([string]::IsNullOrWhiteSpace($normalizedPath)) {
return $null
}
$normalizedPath = $normalizedPath.Trim().Replace('/', '\')
if ([string]::IsNullOrWhiteSpace($normalizedPath)) {
return $null
}
if ($normalizedPath -notmatch '^(?<hive>HKEY_[^\\]+)(?:\\(?<subKey>.*))?$') {
return $null
}
$hiveName = [string]$matches.hive
$normalizedSubKey = if ($null -ne $matches.subKey) {
([string]$matches.subKey).Trim('\\')
}
else {
$null
}
if ($hiveName.Equals('HKEY_USERS', [System.StringComparison]::OrdinalIgnoreCase) -and
-not [string]::IsNullOrWhiteSpace($normalizedSubKey) -and
-not [string]::IsNullOrWhiteSpace([string]$script:RegistryTargetHiveMountName)) {
if ($normalizedSubKey -match '^(?<mount>[^\\]+)(?:\\(?<rest>.*))?$') {
$mountName = [string]$matches.mount
if ($mountName.Equals('Default', [System.StringComparison]::OrdinalIgnoreCase)) {
$remainingSubKey = if ($matches.rest) { [string]$matches.rest } else { '' }
$targetMountName = [string]$script:RegistryTargetHiveMountName
if ([string]::IsNullOrWhiteSpace($remainingSubKey)) {
$normalizedSubKey = $targetMountName
}
else {
$normalizedSubKey = "$targetMountName\$remainingSubKey"
}
}
}
}
return [PSCustomObject]@{
Hive = $hiveName
SubKey = $normalizedSubKey
}
}
function Get-RegistryRootKey {
param(
[Parameter(Mandatory)]
[string]$hiveName
)
switch ($hiveName.ToUpperInvariant()) {
'HKEY_CURRENT_USER' { return [Microsoft.Win32.Registry]::CurrentUser }
'HKEY_LOCAL_MACHINE' { return [Microsoft.Win32.Registry]::LocalMachine }
'HKEY_CLASSES_ROOT' { return [Microsoft.Win32.Registry]::ClassesRoot }
'HKEY_USERS' { return [Microsoft.Win32.Registry]::Users }
'HKEY_CURRENT_CONFIG' { return [Microsoft.Win32.Registry]::CurrentConfig }
default { return $null }
}
}
function Get-RegistryFilePathForFeature {
param(
[Parameter(Mandatory)]
[string]$RegistryKey,
[switch]$UseSysprepRegFiles
)
$useSysprepLayout = $UseSysprepRegFiles -or $script:Params.ContainsKey('Sysprep') -or $script:Params.ContainsKey('User')
if ($useSysprepLayout) {
return Join-Path (Join-Path $script:RegfilesPath 'Sysprep') $RegistryKey
}
return Join-Path $script:RegfilesPath $RegistryKey
}

View File

@@ -0,0 +1,382 @@
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
}

View File

@@ -0,0 +1,39 @@
function Test-TargetUserName {
param(
[AllowNull()]
[AllowEmptyString()]
[string]$UserName
)
$normalizedUserName = if ($null -ne $UserName) { $UserName.Trim() } else { '' }
if ([string]::IsNullOrWhiteSpace($normalizedUserName)) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName
Message = 'Please enter a username'
}
}
if ($normalizedUserName -eq $env:USERNAME) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName
Message = "Cannot enter your own username, use 'Current User' option instead"
}
}
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
return [PSCustomObject]@{
IsValid = $false
UserName = $normalizedUserName
Message = 'User not found, please enter a valid username'
}
}
return [PSCustomObject]@{
IsValid = $true
UserName = $normalizedUserName
Message = "User found: $normalizedUserName"
}
}

View File

@@ -1,42 +0,0 @@
function TestIfUserIsLoggedIn {
param(
[Parameter(Mandatory)]
[string]$Username
)
try {
$quserOutput = @(& quser 2>$null)
if ($LASTEXITCODE -ne 0 -or -not $quserOutput) {
return $false
}
foreach ($line in ($quserOutput | Select-Object -Skip 1)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
# Remove current-session marker and split columns.
$normalizedLine = $line.TrimStart('>', ' ')
$parts = $normalizedLine -split '\s+'
if ($parts.Count -eq 0) { continue }
$sessionUser = $parts[0]
if ([string]::IsNullOrWhiteSpace($sessionUser)) { continue }
# Normalize possible DOMAIN\user or user@domain formats.
if ($sessionUser.Contains('\')) {
$sessionUser = ($sessionUser -split '\\')[-1]
}
if ($sessionUser.Contains('@')) {
$sessionUser = ($sessionUser -split '@')[0]
}
if ($sessionUser.Equals($Username, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
}
catch {
return $false
}
return $false
}

View File

@@ -0,0 +1,152 @@
function New-TargetUserHiveContext {
param(
[Parameter(Mandatory)]
[string]$TargetUserName,
[AllowNull()]
[object]$UserContext,
[Parameter(Mandatory)]
[string]$HiveDatPath,
[AllowNull()]
[string]$MountName,
[bool]$WasAlreadyLoaded = $false,
[bool]$WasLoadedByScript = $false
)
$effectiveMountName = if ([string]::IsNullOrWhiteSpace($MountName)) { 'Default' } else { $MountName }
return [PSCustomObject]@{
TargetUserName = $TargetUserName
UserSid = if ($UserContext) { $UserContext.UserSid } else { $null }
ProfilePath = if ($UserContext) { $UserContext.ProfilePath } else { $null }
HiveDatPath = $HiveDatPath
MountName = $effectiveMountName
WasAlreadyLoaded = $WasAlreadyLoaded
WasLoadedByScript = $WasLoadedByScript
}
}
function Resolve-TargetUserHiveContext {
param(
[Parameter(Mandatory)]
[string]$TargetUserName
)
$normalizedTargetUserName = NormalizeUserLookupValue -Value $TargetUserName
if ([string]::IsNullOrWhiteSpace($normalizedTargetUserName)) {
throw 'Target user name for registry hive resolution is empty.'
}
$userContext = ResolveUserProfileContext -UserName $normalizedTargetUserName
if (-not $userContext -or [string]::IsNullOrWhiteSpace([string]$userContext.ProfilePath)) {
throw "Unable to resolve profile path for target user '$normalizedTargetUserName'."
}
$hiveDatPath = Join-Path $userContext.ProfilePath 'NTUSER.DAT'
if (-not (Test-Path -LiteralPath $hiveDatPath)) {
throw "Unable to find target user hive at '$hiveDatPath'."
}
$isDefaultProfile = $normalizedTargetUserName.Equals('Default', [System.StringComparison]::OrdinalIgnoreCase)
$userSid = if ($userContext) { [string]$userContext.UserSid } else { '' }
if ((-not $isDefaultProfile) -and (-not [string]::IsNullOrWhiteSpace($userSid))) {
$loadedHivePath = "Registry::HKEY_USERS\$userSid"
if (Test-Path -LiteralPath $loadedHivePath) {
return (New-TargetUserHiveContext `
-TargetUserName $normalizedTargetUserName `
-UserContext $userContext `
-HiveDatPath $hiveDatPath `
-MountName $userSid `
-WasAlreadyLoaded $true `
-WasLoadedByScript $false)
}
}
return (New-TargetUserHiveContext `
-TargetUserName $normalizedTargetUserName `
-UserContext $userContext `
-HiveDatPath $hiveDatPath `
-MountName 'Default' `
-WasAlreadyLoaded $false `
-WasLoadedByScript $false)
}
function Resolve-LoadedTargetUserHiveContext {
param(
[Parameter(Mandatory)]
$HiveContext
)
$userSid = [string]$HiveContext.UserSid
if ([string]::IsNullOrWhiteSpace($userSid)) {
return $null
}
$loadedHivePath = "Registry::HKEY_USERS\$userSid"
if (-not (Test-Path -LiteralPath $loadedHivePath)) {
return $null
}
return (New-TargetUserHiveContext `
-TargetUserName $HiveContext.TargetUserName `
-UserContext ([PSCustomObject]@{ UserSid = $HiveContext.UserSid; ProfilePath = $HiveContext.ProfilePath }) `
-HiveDatPath $HiveContext.HiveDatPath `
-MountName $userSid `
-WasAlreadyLoaded $true `
-WasLoadedByScript $false)
}
function Invoke-WithTargetUserHive {
param(
[Parameter(Mandatory)]
[string]$TargetUserName,
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
$ArgumentObject = $null,
[switch]$PassHiveContext
)
$hiveContext = Resolve-TargetUserHiveContext -TargetUserName $TargetUserName
$previousHiveMountName = $script:RegistryTargetHiveMountName
try {
if (-not $hiveContext.WasAlreadyLoaded) {
$global:LASTEXITCODE = 0
reg load "HKU\$($hiveContext.MountName)" "$($hiveContext.HiveDatPath)" | Out-Null
$loadExitCode = $LASTEXITCODE
if ($loadExitCode -ne 0) {
$loadedSidContext = Resolve-LoadedTargetUserHiveContext -HiveContext $hiveContext
if ($loadedSidContext) {
$hiveContext = $loadedSidContext
}
else {
throw "Failed to load target user hive '$($hiveContext.HiveDatPath)' (exit code: $loadExitCode)."
}
}
else {
$hiveContext.WasLoadedByScript = $true
}
}
$script:RegistryTargetHiveMountName = [string]$hiveContext.MountName
if ($PassHiveContext) {
return & $ScriptBlock $ArgumentObject $hiveContext
}
return & $ScriptBlock $ArgumentObject
}
finally {
$script:RegistryTargetHiveMountName = $previousHiveMountName
if ($hiveContext -and $hiveContext.WasLoadedByScript) {
$global:LASTEXITCODE = 0
reg unload "HKU\$($hiveContext.MountName)" | Out-Null
$unloadExitCode = $LASTEXITCODE
if ($unloadExitCode -ne 0) {
Write-Warning "Failed to unload registry hive 'HKU\$($hiveContext.MountName)' (exit code: $unloadExitCode)"
}
}
}
}

View File

@@ -45,6 +45,15 @@ function Invoke-NonBlocking {
$result = $ps.EndInvoke($handle)
# Surface non-terminating errors raised inside the runspace so GUI-mode operations
# (e.g. failed app removals) don't fail silently - the runspace keeps its own error
# stream that is otherwise discarded on Dispose.
if ($ps.HadErrors) {
foreach ($runspaceError in $ps.Streams.Error) {
Write-Error -ErrorRecord $runspaceError
}
}
if ($result.Count -eq 0) { return $null }
if ($result.Count -eq 1) { return $result[0] }
return @($result)

View File

@@ -7,7 +7,6 @@ param (
[string]$User,
[switch]$NoRestartExplorer,
[switch]$CreateRestorePoint,
[switch]$RunAppsListGenerator,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -15,11 +14,8 @@ param (
[string]$Apps,
[string]$AppRemovalTarget,
[switch]$RemoveApps,
[switch]$RemoveAppsCustom,
[switch]$RemoveGamingApps,
[switch]$RemoveCommApps,
[switch]$RemoveHPApps,
[switch]$RemoveW11Outlook,
[switch]$ForceRemoveEdge,
[switch]$DisableDVR,
[switch]$DisableGameBarIntegration,
@@ -59,7 +55,7 @@ param (
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
[switch]$HideTaskview,
[switch]$DisableStartRecommended,
[switch]$DisableStartAllApps,
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
[switch]$DisableStartPhoneLink,
[switch]$DisableCopilot,
[switch]$DisableRecall,
@@ -141,26 +137,33 @@ if (-not $isAdmin) {
}
# Define script-level variables & paths
$script:Version = "2026.04.26"
$script:AppsListFilePath = "$PSScriptRoot/Config/Apps.json"
$script:DefaultSettingsFilePath = "$PSScriptRoot/Config/DefaultSettings.json"
$script:FeaturesFilePath = "$PSScriptRoot/Config/Features.json"
$script:SavedSettingsFilePath = "$PSScriptRoot/Config/LastUsedSettings.json"
$script:CustomAppsListFilePath = "$PSScriptRoot/Config/CustomAppsList"
$script:DefaultLogPath = "$PSScriptRoot/Logs/Win11Debloat.log"
$script:RegfilesPath = "$PSScriptRoot/Regfiles"
$script:AssetsPath = "$PSScriptRoot/Assets"
$script:AppSelectionSchema = "$PSScriptRoot/Schemas/AppSelectionWindow.xaml"
$script:MainWindowSchema = "$PSScriptRoot/Schemas/MainWindow.xaml"
$script:MessageBoxSchema = "$PSScriptRoot/Schemas/MessageBoxWindow.xaml"
$script:AboutWindowSchema = "$PSScriptRoot/Schemas/AboutWindow.xaml"
$script:ApplyChangesWindowSchema = "$PSScriptRoot/Schemas/ApplyChangesWindow.xaml"
$script:SharedStylesSchema = "$PSScriptRoot/Schemas/SharedStyles.xaml"
$script:BubbleHintSchema = "$PSScriptRoot/Schemas/BubbleHint.xaml"
$script:ImportExportConfigSchema = "$PSScriptRoot/Schemas/ImportExportConfigWindow.xaml"
$script:LoadAppsDetailsScriptPath = "$PSScriptRoot/Scripts/FileIO/LoadAppsDetailsFromJson.ps1"
$script:Version = "2026.06.24"
$configPath = Join-Path $PSScriptRoot 'Config'
$logsPath = Join-Path $PSScriptRoot 'Logs'
$schemasPath = Join-Path $PSScriptRoot 'Schemas'
$scriptsPath = Join-Path $PSScriptRoot 'Scripts'
$script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'NoRestartExplorer', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'RunAppsListGenerator', 'CLI', 'AppRemovalTarget'
$script:AppsListFilePath = Join-Path $configPath 'Apps.json'
$script:DefaultSettingsFilePath = Join-Path $configPath 'DefaultSettings.json'
$script:FeaturesFilePath = Join-Path $configPath 'Features.json'
$script:SavedSettingsFilePath = Join-Path $configPath 'LastUsedSettings.json'
$script:DefaultLogPath = Join-Path $logsPath 'Win11Debloat.log'
$script:RegfilesPath = Join-Path $PSScriptRoot 'Regfiles'
$script:RegistryBackupsPath = Join-Path $PSScriptRoot 'Backups'
$script:AssetsPath = Join-Path $PSScriptRoot 'Assets'
$script:AppSelectionSchema = Join-Path $schemasPath 'AppSelectionWindow.xaml'
$script:MainWindowSchema = Join-Path $schemasPath 'MainWindow.xaml'
$script:MessageBoxSchema = Join-Path $schemasPath 'MessageBox.xaml'
$script:AboutWindowSchema = Join-Path $schemasPath 'AboutWindow.xaml'
$script:ApplyChangesWindowSchema = Join-Path $schemasPath 'ApplyChangesWindow.xaml'
$script:SharedStylesSchema = Join-Path $schemasPath 'SharedStyles.xaml'
$script:BubbleHintSchema = Join-Path $schemasPath 'BubbleHint.xaml'
$script:ImportExportConfigSchema = Join-Path $schemasPath 'ImportExportConfigWindow.xaml'
$script:RestoreBackupWindowSchema = Join-Path $schemasPath 'RestoreBackupWindow.xaml'
$script:LoadAppsDetailsScriptPath = Join-Path (Join-Path $scriptsPath 'FileIO') 'LoadAppsDetailsFromJson.ps1'
$script:TestAppInWingetListScriptPath = Join-Path (Join-Path $scriptsPath 'AppRemoval') 'Test-AppInWingetList.ps1'
$script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'NoRestartExplorer', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'CLI', 'AppRemovalTarget'
# Script-level variables for GUI elements
$script:GuiWindow = $null
@@ -204,19 +207,29 @@ Write-Host " " -NoNewline; Write-Host " (" -ForegroundColor
Write-Host " " -NoNewline; Write-Host " ( " -ForegroundColor DarkYellow -NoNewline; Write-Host "'" -ForegroundColor Red -NoNewline; Write-Host " ) " -ForegroundColor DarkYellow -NoNewline; Write-Host "*" -ForegroundColor Yellow
Write-Host ""
Write-Host " Win11Debloat is launching..." -ForegroundColor White
Write-Host " Leave this window open" -ForegroundColor DarkGray
Write-Host " Keep this window open" -ForegroundColor DarkGray
Write-Host ""
Write-Host ""
# Log script output to 'Win11Debloat.log' at the specified path
if ($LogPath -and (Test-Path $LogPath)) {
Start-Transcript -Path "$LogPath/Win11Debloat.log" -Append -IncludeInvocationHeader -Force | Out-Null
Start-Transcript -Path (Join-Path $LogPath 'Win11Debloat.log') -Append -IncludeInvocationHeader -Force | Out-Null
}
else {
Start-Transcript -Path $script:DefaultLogPath -Append -IncludeInvocationHeader -Force | Out-Null
}
# Check if the device is domain-joined and warn the user (Group Policy may override changes)
try {
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) {
Write-Warning "This machine is domain-joined. Group Policy may override changes made by Win11Debloat."
}
}
catch { }
# Check if script has all required files
if (-not ((Test-Path $script:DefaultSettingsFilePath) -and (Test-Path $script:AppsListFilePath) -and (Test-Path $script:RegfilesPath) -and (Test-Path $script:AssetsPath) -and (Test-Path $script:AppSelectionSchema) -and (Test-Path $script:ApplyChangesWindowSchema) -and (Test-Path $script:SharedStylesSchema) -and (Test-Path $script:BubbleHintSchema) -and (Test-Path $script:FeaturesFilePath))) {
if (-not ((Test-Path $script:DefaultSettingsFilePath) -and (Test-Path $script:AppsListFilePath) -and (Test-Path $script:RegfilesPath) -and (Test-Path $script:AssetsPath) -and (Test-Path $script:AppSelectionSchema) -and (Test-Path $script:ApplyChangesWindowSchema) -and (Test-Path $script:SharedStylesSchema) -and (Test-Path $script:BubbleHintSchema) -and (Test-Path $script:RestoreBackupWindowSchema) -and (Test-Path $script:FeaturesFilePath))) {
Write-Error "Win11Debloat is unable to find required files, please ensure all script files are present"
Write-Output ""
Write-Output "Press any key to exit..."
@@ -229,6 +242,10 @@ $script:Features = @{}
try {
$featuresData = Get-Content -Path $script:FeaturesFilePath -Raw | ConvertFrom-Json
foreach ($feature in $featuresData.Features) {
if ([string]::IsNullOrWhiteSpace([string]$feature.FeatureId) -or [string]::IsNullOrWhiteSpace([string]$feature.Label) -or [string]::IsNullOrWhiteSpace([string]$feature.ApplyText)) {
Write-Warning "Feature '$($feature.FeatureId)' is missing a FeatureId, Label, or ApplyText in Features.json and will be skipped."
continue
}
$script:Features[$feature.FeatureId] = $feature
}
}
@@ -274,6 +291,7 @@ if (-not $script:WingetInstalled -and -not $Silent) {
. "$PSScriptRoot/Scripts/AppRemoval/ForceRemoveEdge.ps1"
. "$PSScriptRoot/Scripts/AppRemoval/RemoveApps.ps1"
. "$PSScriptRoot/Scripts/AppRemoval/GetInstalledAppsViaWinget.ps1"
. "$PSScriptRoot/Scripts/AppRemoval/Test-AppInWingetList.ps1"
# CLI functions
. "$PSScriptRoot/Scripts/CLI/AwaitKeyToExit.ps1"
@@ -286,10 +304,18 @@ if (-not $script:WingetInstalled -and -not $Silent) {
. "$PSScriptRoot/Scripts/CLI/PrintHeader.ps1"
# Features functions
. "$PSScriptRoot/Scripts/Features/ExecuteChanges.ps1"
. "$PSScriptRoot/Scripts/Features/GetCurrentTweakState.ps1"
. "$PSScriptRoot/Scripts/Features/InvokeChanges.ps1"
. "$PSScriptRoot/Scripts/Features/CreateSystemRestorePoint.ps1"
. "$PSScriptRoot/Scripts/Features/DisableStoreSearchSuggestions.ps1"
. "$PSScriptRoot/Scripts/Features/EnableWindowsFeature.ps1"
. "$PSScriptRoot/Scripts/Features/BackupRegistryFeatureSelection.ps1"
. "$PSScriptRoot/Scripts/Features/BackupRegistrySnapshotCapture.ps1"
. "$PSScriptRoot/Scripts/Features/BackupRegistryState.ps1"
. "$PSScriptRoot/Scripts/Features/RegistryBackupValidation.ps1"
. "$PSScriptRoot/Scripts/Features/RestoreRegistryApplyState.ps1"
. "$PSScriptRoot/Scripts/Features/RestoreRegistryBackup.ps1"
. "$PSScriptRoot/Scripts/Features/StoreSearchSuggestions.ps1"
. "$PSScriptRoot/Scripts/Features/TelemetryScheduledTasks.ps1"
. "$PSScriptRoot/Scripts/Features/WindowsOptionalFeatures.ps1"
. "$PSScriptRoot/Scripts/Features/ImportRegistryFile.ps1"
. "$PSScriptRoot/Scripts/Features/ReplaceStartMenu.ps1"
. "$PSScriptRoot/Scripts/Features/RestartExplorer.ps1"
@@ -299,7 +325,6 @@ if (-not $script:WingetInstalled -and -not $Silent) {
. "$PSScriptRoot/Scripts/FileIO/SaveToFile.ps1"
. "$PSScriptRoot/Scripts/FileIO/SaveSettings.ps1"
. "$PSScriptRoot/Scripts/FileIO/LoadSettings.ps1"
. "$PSScriptRoot/Scripts/FileIO/SaveCustomAppsListToFile.ps1"
. "$PSScriptRoot/Scripts/FileIO/ValidateAppslist.ps1"
. "$PSScriptRoot/Scripts/FileIO/LoadAppsFromFile.ps1"
. "$PSScriptRoot/Scripts/FileIO/LoadAppsDetailsFromJson.ps1"
@@ -314,21 +339,36 @@ if (-not $script:WingetInstalled -and -not $Silent) {
. "$PSScriptRoot/Scripts/GUI/Show-ConfigWindow.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-ApplyModal.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-AppSelectionWindow.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-RestoreBackupWindow.ps1"
. "$PSScriptRoot/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-RestoreBackupDialog.ps1"
. "$PSScriptRoot/Scripts/GUI/MainWindow-WindowChrome.ps1"
. "$PSScriptRoot/Scripts/GUI/MainWindow-AppSelection.ps1"
. "$PSScriptRoot/Scripts/GUI/MainWindow-TweaksBuilder.ps1"
. "$PSScriptRoot/Scripts/GUI/MainWindow-Navigation.ps1"
. "$PSScriptRoot/Scripts/GUI/MainWindow-Deployment.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-MainWindow.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-AboutDialog.ps1"
. "$PSScriptRoot/Scripts/GUI/Show-Bubble.ps1"
# Helper functions
. "$PSScriptRoot/Scripts/Helpers/AddParameter.ps1"
. "$PSScriptRoot/Scripts/Helpers/ResolveUserProfilePath.ps1"
. "$PSScriptRoot/Scripts/Helpers/UserHiveHelpers.ps1"
. "$PSScriptRoot/Scripts/Helpers/CheckIfUserExists.ps1"
. "$PSScriptRoot/Scripts/Helpers/CheckModernStandbySupport.ps1"
. "$PSScriptRoot/Scripts/Helpers/GenerateAppsList.ps1"
. "$PSScriptRoot/Scripts/Helpers/GetFriendlyRegistryBackupTarget.ps1"
. "$PSScriptRoot/Scripts/Helpers/GetFriendlyTargetUserName.ps1"
. "$PSScriptRoot/Scripts/Helpers/ImportConfigToParams.ps1"
. "$PSScriptRoot/Scripts/Helpers/GetTargetUserForAppRemoval.ps1"
. "$PSScriptRoot/Scripts/Helpers/Get-RegFileOperations.ps1"
. "$PSScriptRoot/Scripts/Helpers/Test-TargetUserName.ps1"
. "$PSScriptRoot/Scripts/Helpers/GetUserDirectory.ps1"
. "$PSScriptRoot/Scripts/Helpers/GetUserName.ps1"
. "$PSScriptRoot/Scripts/Helpers/TestIfUserIsLoggedIn.ps1"
. "$PSScriptRoot/Scripts/Helpers/RegistryPathHelpers.ps1"
. "$PSScriptRoot/Scripts/Helpers/ApplyRegistryRegFile.ps1"
. "$PSScriptRoot/Scripts/Helpers/ConfirmUnsafeAppRemoval.ps1"
# Threading functions
. "$PSScriptRoot/Scripts/Threading/DoEvents.ps1"
@@ -351,6 +391,7 @@ $WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\Current
$script:ModernStandbySupported = CheckModernStandbySupport
$script:Params = $PSBoundParameters
$script:UndoParams = @{}
# Add default Apps parameter when RemoveApps is requested and Apps was not explicitly provided
if ((-not $script:Params.ContainsKey("Apps")) -and $script:Params.ContainsKey("RemoveApps")) {
@@ -380,7 +421,7 @@ else {
}
if ($script:Params.ContainsKey("Sysprep")) {
$defaultUserPath = GetUserDirectory -userName "Default"
GetUserDirectory -userName "Default" | Out-Null
# Exit script if run in Sysprep mode on Windows 10
if ($WinVersion -lt 22000) {
@@ -391,10 +432,14 @@ if ($script:Params.ContainsKey("Sysprep")) {
# Ensure that target user exists, if User or AppRemovalTarget parameter was provided
if ($script:Params.ContainsKey("User")) {
$userPath = GetUserDirectory -userName $script:Params.Item("User")
GetUserDirectory -userName $script:Params.Item("User") | Out-Null
}
if ($script:Params.ContainsKey("AppRemovalTarget")) {
$userPath = GetUserDirectory -userName $script:Params.Item("AppRemovalTarget")
$appRemovalTargetValue = $script:Params.Item("AppRemovalTarget")
# 'AllUsers' / 'CurrentUser' are sentinel scope values, not real usernames - don't resolve them as a profile
if ($appRemovalTargetValue -notin @('AllUsers', 'CurrentUser')) {
GetUserDirectory -userName $appRemovalTargetValue | Out-Null
}
}
# Remove LastUsedSettings.json file if it exists and is empty
@@ -405,24 +450,6 @@ if ((Test-Path $script:SavedSettingsFilePath) -and ([String]::IsNullOrWhiteSpace
# Default to CLI mode for deployment-targeted parameters.
$launchInCLI = $CLI -or $script:Params.ContainsKey("User") -or $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("AppRemovalTarget")
# Only run the app selection form if the 'RunAppsListGenerator' parameter was passed to the script
if ($RunAppsListGenerator) {
PrintHeader "Custom Apps List Generator"
$result = Show-AppSelectionWindow
# Show different message based on whether the app selection was saved or cancelled
if ($result -ne $true) {
Write-Host "Application selection window was closed without saving." -ForegroundColor Red
}
else {
Write-Output "Your app selection was saved to the 'CustomAppsList' file, found at:"
Write-Host "$PSScriptRoot" -ForegroundColor Yellow
}
AwaitKeyToExit
}
# Change script execution based on provided parameters or user input
if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSavedSettings -or $Config -or ($controlParamsCount -eq $script:Params.Count)) {
if ($RunDefaults -or $RunDefaultsLite) {
@@ -460,7 +487,11 @@ if ((-not $script:Params.Count) -or $RunDefaults -or $RunDefaultsLite -or $RunSa
try {
$result = Show-MainWindow
Stop-Transcript
try {
Stop-Transcript
}
catch { }
Exit
}
catch {
@@ -507,7 +538,7 @@ if (($controlParamsCount -eq $script:Params.Keys.Count) -or ($script:Params.Keys
# Execute all selected/provided parameters using the consolidated function
# (This also handles restore point creation if requested)
ExecuteAllChanges
Invoke-AllChanges
RestartExplorer