mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-08-23 16:12:08 +00:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d206cb9b | ||
|
|
b7f612f36e | ||
|
|
c921730703 | ||
|
|
80eadd531c | ||
|
|
4c29fce469 | ||
|
|
93d77d8034 | ||
|
|
78e1d601b0 | ||
|
|
f763390d53 | ||
|
|
dded2da3a7 | ||
|
|
492a374f5c | ||
|
|
1a26934499 | ||
|
|
5072958b10 | ||
|
|
31feaeb6f5 | ||
|
|
dd929f8eec | ||
|
|
b21be418cd | ||
|
|
25fb9f3725 | ||
|
|
d1338cd027 | ||
|
|
9505a5a374 | ||
|
|
c0599cba1f | ||
|
|
5c838384d6 | ||
|
|
6957ce4220 | ||
|
|
0f30b62221 | ||
|
|
32cedaf65d | ||
|
|
68cacfce89 | ||
|
|
a9c1736e46 | ||
|
|
9c033dbf98 | ||
|
|
a7292e4f35 | ||
|
|
f739a0e474 | ||
|
|
63b1f61fd1 | ||
|
|
de817399e6 | ||
|
|
8fa1332ff0 | ||
|
|
ce36b8edfb | ||
|
|
3b78e8d4ed | ||
|
|
74dedc00e9 | ||
|
|
5571700634 | ||
|
|
c2ea697552 | ||
|
|
ce8c10042e | ||
|
|
89a5d508d8 | ||
|
|
e38aafe01e | ||
|
|
d336f98b39 | ||
|
|
a70a2ccbd6 | ||
|
|
0ef789cc3a | ||
|
|
82f50d6d13 | ||
|
|
949515da3e | ||
|
|
0eeb8f323a | ||
|
|
a9a89c1f13 | ||
|
|
339764eb18 | ||
|
|
3b4a5f16ad | ||
|
|
9a700afaed | ||
|
|
26b313372b | ||
|
|
9ee0126259 | ||
|
|
e23ecf36d6 | ||
|
|
32dc3d6bdf | ||
|
|
f76adc5054 | ||
|
|
95b583606d | ||
|
|
693b805114 | ||
|
|
5ebc50d36a | ||
|
|
d1fe541b62 | ||
|
|
71e3f2e44d | ||
|
|
4891aa401a | ||
|
|
a6d59c0dc1 | ||
|
|
ac54bde383 | ||
|
|
dfe7810346 | ||
|
|
82894176d9 | ||
|
|
87b3035eda | ||
|
|
a89b53504c | ||
|
|
91a6266d50 | ||
|
|
469751f8e8 | ||
|
|
908274a500 | ||
|
|
6e4a616f1c | ||
|
|
535b62db40 | ||
|
|
c039b04717 | ||
|
|
a7a46bb5bf | ||
|
|
a95b5adee8 | ||
|
|
2b97021341 | ||
|
|
1235306f80 | ||
|
|
1a69d19f30 | ||
|
|
5628f6e0b7 | ||
|
|
6f349b4992 | ||
|
|
2193591448 | ||
|
|
e9269c5501 | ||
|
|
fdac0a6d14 | ||
|
|
2aa9afaa2c | ||
|
|
67c9cc6ba3 | ||
|
|
157d26bb22 | ||
|
|
53ca51dffd | ||
|
|
db24865051 | ||
|
|
33b77f19a0 | ||
|
|
37872b2030 | ||
|
|
abfc5db2c3 | ||
|
|
1d828d6a78 | ||
|
|
4d9da4749b | ||
|
|
5cf9ac4082 | ||
|
|
924c192ca5 | ||
|
|
2a5cb986c9 | ||
|
|
66982ada28 | ||
|
|
489af33a8b |
+109
-36
@@ -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,8 +16,9 @@ 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")))
|
||||
& ([scriptblock]::Create((irm "https://debloat.raphi.re/"))) -Dev
|
||||
```
|
||||
|
||||
# Contributing Code
|
||||
@@ -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,33 +46,68 @@ 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
|
||||
```
|
||||
|
||||
### Running Automated Tests
|
||||
|
||||
The automated test cases use Pester 5 and do not modify the registry or other
|
||||
system state. The optional bootstrap step installs Pester for your user account
|
||||
when needed. To run the complete suite:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Run-Tests.ps1 -Bootstrap
|
||||
```
|
||||
|
||||
After the initial setup, run the suite with:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Run-Tests.ps1
|
||||
```
|
||||
|
||||
GitHub Actions runs the same test command with Windows PowerShell 5.1 for pull
|
||||
requests and pushes to `master`.
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Project Structure
|
||||
|
||||
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. Invoke-Changes.ps1, Replace-StartMenu.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 +137,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,24 +166,43 @@ 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",
|
||||
"AppId": "AppPackageIdentifier",
|
||||
"Description": "Brief description of the app",
|
||||
"SelectedByDefault": true|false
|
||||
"SelectedByDefault": false,
|
||||
"Recommendation": "optional",
|
||||
"RemovalMethod": "Appx"
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions**:
|
||||
|
||||
- `FriendlyName`: Display name shown in the GUI.
|
||||
- `AppId`: The `AppPackageIdentifier` from `Get-AppxPackage` or the `Id` from `winget list`, depending on removal method.
|
||||
- `Description`: Brief description of the app shown in the GUI.
|
||||
- `SelectedByDefault`: Set to `true` only for apps that are largely considered bloatware, otherwise set to `false`.
|
||||
- `Recommendation`: Indicates how strongly the app is recommended for removal. One of:
|
||||
- `safe` — safe to remove for most users
|
||||
- `optional` — can be safely removed if the user doesn't need the app
|
||||
- `unsafe` — should only remove if the user knows what they are doing
|
||||
- `RemovalMethod`: The method used to remove the app. One of:
|
||||
- `Appx` — remove as a standard Appx package via `Remove-AppxPackage` (most apps)
|
||||
- `WinGet` — remove via WinGet (`winget uninstall`). Use for non-Appx apps such as Microsoft Copilot.
|
||||
|
||||
3. **Follow the Guidelines**:
|
||||
- Use clear, user-friendly names for `FriendlyName`
|
||||
- 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 +220,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 +229,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 +239,7 @@ Windows Registry Editor Version 5.00
|
||||
|
||||
#### 1b. Implement the Feature Logic
|
||||
|
||||
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `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/Invoke-Changes.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
|
||||
|
||||
#### 2. Add Feature to Features.json
|
||||
|
||||
@@ -192,35 +252,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 +297,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 +320,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 +351,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": "#### ;"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "Your Category Name",
|
||||
"Icon": "#### ;"
|
||||
}
|
||||
```
|
||||
|
||||
> [!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 +389,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 +414,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: "🐞 Bug report"
|
||||
description: "Report an issue you encountered"
|
||||
labels: ["bug"]
|
||||
labels: ["bug", "unconfirmed"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Pester (Windows PowerShell 5.1)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run tests
|
||||
shell: powershell
|
||||
run: ./Scripts/Run-Tests.ps1 -Bootstrap
|
||||
@@ -1,7 +1,3 @@
|
||||
LastSettings
|
||||
SavedSettings
|
||||
LastUsedSettings.json
|
||||
CustomAppsList
|
||||
Logs/*
|
||||
Win11Debloat.log
|
||||
Backups/*
|
||||
+304
-204
File diff suppressed because it is too large
Load Diff
@@ -70,4 +70,4 @@
|
||||
"Value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+407
-156
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,9 @@
|
||||
[](https://github.com/Raphire/Win11Debloat/discussions)
|
||||
[](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. You can also easily export & import your preferred settings, allowing you to quickly apply the same settings on all your systems. Please refer to our [wiki](https://github.com/Raphire/Win11Debloat/wiki) for more details.
|
||||
|
||||

|
||||
|
||||
@@ -23,14 +23,14 @@ The script also includes many features that system administrators and power user
|
||||
|
||||
Download & run the script automatically via PowerShell.
|
||||
|
||||
1. Open PowerShell or Terminal, preferably as an administrator.
|
||||
1. Open PowerShell or Terminal.
|
||||
2. Copy and paste the command below into PowerShell:
|
||||
|
||||
```PowerShell
|
||||
& ([scriptblock]::Create((irm "https://debloat.raphi.re/")))
|
||||
```
|
||||
|
||||
3. Wait for the script to automatically download Win11Debloat.
|
||||
3. Wait for the script to automatically download and launch Win11Debloat.
|
||||
4. Carefully read through and follow the on-screen instructions.
|
||||
|
||||
This method supports command-line parameters to customize the behaviour of the script. Please click [here](https://github.com/Raphire/Win11Debloat/wiki/Command%E2%80%90line-Interface#parameters) for more information.
|
||||
@@ -57,7 +57,7 @@ This method supports command-line parameters to customize the behaviour of the s
|
||||
3. Temporarily enable PowerShell execution by entering the following command:
|
||||
|
||||
```PowerShell
|
||||
Set-ExecutionPolicy Unrestricted -Scope Process -Force
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
```
|
||||
|
||||
4. In PowerShell, navigate to the directory where the files were extracted. Example: `cd c:\Win11Debloat`
|
||||
@@ -74,10 +74,10 @@ This method supports command-line parameters to customize the behaviour of the s
|
||||
|
||||
## Features
|
||||
|
||||
Below is an overview of the key features and functionality offered by Win11Debloat. Please refer to [the wiki](https://github.com/Raphire/Win11Debloat/wiki/Default-Settings) for more information about the default settings preset.
|
||||
Below is an overview of the key features and functionality offered by Win11Debloat. You can visit the [the wiki](https://github.com/Raphire/Win11Debloat/wiki) for more details.
|
||||
|
||||
> [!Tip]
|
||||
> All of the changes made by Win11Debloat can easily be reverted and almost all of the apps can be reinstalled through the Microsoft Store. A full guide on how to revert changes can be found [here](https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes).
|
||||
> All of the changes made by Win11Debloat can easily be reverted and almost all of the apps can be reinstalled through the Microsoft Store. You can visit [the wiki](https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes) for more information on reverting changes.
|
||||
|
||||
#### App Removal
|
||||
|
||||
@@ -86,29 +86,21 @@ Below is an overview of the key features and functionality offered by Win11Deblo
|
||||
#### Privacy & Suggested Content
|
||||
|
||||
- Disable telemetry, diagnostic data, activity history, app-launch tracking & targeted ads.
|
||||
- Disable tips, tricks, suggestions & ads across Windows.
|
||||
- Disable Windows location services & app location access.
|
||||
- Disable Find My Device location tracking.
|
||||
- Disable 'Windows Spotlight' and tips & tricks on the lock screen.
|
||||
- Disable 'Windows Spotlight' desktop background option.
|
||||
- Disable ads, suggestions and the MSN news feed in Microsoft Edge.
|
||||
- Disable tips, tricks, suggestions & ads across Windows, the lock screen and Microsoft Edge.
|
||||
- Disable Windows location services, app location access and Find My Device location tracking.
|
||||
- Hide Microsoft 365 ads on the Settings 'Home' page, or hide the 'Home' page entirely.
|
||||
|
||||
#### AI Features
|
||||
|
||||
- Disable & remove Microsoft Copilot.
|
||||
- Disable Windows Recall.
|
||||
- Disable Click to Do, AI text & image analysis tool.
|
||||
- Disable & remove Microsoft Copilot, Windows Recall and Click to Do.
|
||||
- Prevent AI service (WSAIFabricSvc) from starting automatically.
|
||||
- Disable AI Features in Edge.
|
||||
- Disable AI Features in Paint.
|
||||
- Disable AI Features in Notepad.
|
||||
- Disable AI Features in Edge, Paint and Notepad.
|
||||
|
||||
#### System
|
||||
|
||||
- Disable the Drag Tray for sharing & moving files.
|
||||
- Restore the old Windows 10 style context menu.
|
||||
- Turn off Enhance Pointer Precision, also known as mouse acceleration.
|
||||
- Turn off Enhance Pointer Precision (mouse acceleration).
|
||||
- Disable the Sticky Keys keyboard shortcut.
|
||||
- Disable Storage Sense automatic disk cleanup.
|
||||
- Disable fast start-up to ensure a full shutdown.
|
||||
@@ -120,54 +112,43 @@ Below is an overview of the key features and functionality offered by Win11Deblo
|
||||
- Prevent Windows from getting updates as soon as they're available.
|
||||
- Prevent automatic restarts after updates while signed in.
|
||||
- Disable sharing of downloaded updates with other PCs, also known as Delivery Optimization.
|
||||
- Prevent Windows from auto-installing device companion apps, like LG Monitor App, Alienware Command Center and more.
|
||||
|
||||
#### Appearance
|
||||
|
||||
- Enable dark mode for system and apps.
|
||||
- Disable transparency effects
|
||||
- Disable animations and visual effects.
|
||||
- Disable transparency, animations and visual effects.
|
||||
|
||||
#### Start Menu & Search
|
||||
|
||||
- Remove or replace all pinned apps from the start menu.
|
||||
- Hide the recommended section in the start menu.
|
||||
- Hide the 'All Apps' section in the start menu.
|
||||
- Customize the start menu by removing pinned apps, hiding recommendations, and customizing the 'All Apps' section.
|
||||
- Disable the Phone Link mobile devices integration in the start menu.
|
||||
- Disable Bing web search & Copilot integration in Windows search.
|
||||
- Disable Microsoft Store app suggestions in Windows search.
|
||||
- Disable Search Highlights (dynamic/branded content) in the taskbar search box.
|
||||
- Disable local Windows search history.
|
||||
- Disable Bing web search & Copilot integration and Microsoft Store app suggestions in Windows search.
|
||||
|
||||
#### Taskbar
|
||||
|
||||
- Align taskbar icons to the left.
|
||||
- Hide or change the search icon/box on the taskbar.
|
||||
- Hide the taskview button from the taskbar.
|
||||
- Change taskbar alignment.
|
||||
- Customize or hide taskbar buttons like the search bar, taskview and more.
|
||||
- Disable widgets on the taskbar & lock screen.
|
||||
- Hide the chat (meet now) icon from the taskbar.
|
||||
- Enable the 'End Task' option in the taskbar right click menu.
|
||||
- Enable the 'End Task' option in the taskbar right click menu to quickly force-close apps.
|
||||
- Enable the 'Last Active Click' behavior in the taskbar app area. This allows you to repeatedly click on an application's icon in the taskbar to switch focus between the open windows of that application.
|
||||
- Choose how app icons are shown on the taskbar when using multiple monitors.
|
||||
- Choose combine mode for taskbar buttons and labels.
|
||||
- Customize how app buttons are shown on the taskbar.
|
||||
|
||||
#### File Explorer
|
||||
|
||||
- Change the default location that File Explorer opens to.
|
||||
- Show file extensions for known file types.
|
||||
- Show hidden files, folders and drives.
|
||||
- Hide the Home or Gallery section from the File Explorer navigation pane.
|
||||
- Hide the Home, Gallery or OneDrive section from the File Explorer navigation pane.
|
||||
- Hide duplicate removable drive entries from the File Explorer navigation pane, so only the entry under 'This PC' remains.
|
||||
- Add all common folders (Desktop, Downloads, etc.) back to 'This PC' in File Explorer.
|
||||
- Hide the 3D objects, music or OneDrive folder from the File Explorer navigation pane.
|
||||
- Hide the 'Include in library', 'Give access to' and 'Share' options from the context menu.
|
||||
- Change drive letter position or visibility in File Explorer.
|
||||
|
||||
#### Multi-tasking
|
||||
|
||||
- Disable window snapping.
|
||||
- Disable Snap Assist suggestions when snapping a window.
|
||||
- Disable Snap Layout suggestions when dragging windows to the top of screen and when hovering on the maximize button.
|
||||
- Change if tabs are shown when snapping or pressing Alt+Tab.
|
||||
- Disable Snap Assist and Snap Layout suggestions when dragging or snapping windows.
|
||||
- Change whether tabs are shown when snapping windows or pressing Alt+Tab.
|
||||
|
||||
#### Optional Windows Features
|
||||
|
||||
@@ -181,12 +162,12 @@ Below is an overview of the key features and functionality offered by Win11Deblo
|
||||
|
||||
#### Advanced Features
|
||||
|
||||
- Option to [apply changes to a different user](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#running-as-another-user), instead of the currently logged in user.
|
||||
- Ability to [apply changes to a different user](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#running-as-another-user), instead of the currently logged in user.
|
||||
- [Sysprep mode](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#sysprep-mode) to apply changes to the Windows Default user profile. Which ensures, all new users will have the changes automatically applied to them.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions of all kinds! Please see our [Contributing Guidelines](/.github/CONTRIBUTING.md) for detailed instructions on how to get started and best practices for contributing.
|
||||
We welcome contributions of all kinds! Please see our [Contributing Guidelines](https://github.com/Raphire/Win11Debloat/blob/master/.github/CONTRIBUTING.md) for detailed instructions on how to get started and best practices for contributing.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"=-
|
||||
@@ -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"=-
|
||||
@@ -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"=-
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"=-
|
||||
@@ -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"=-
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
setlocal
|
||||
|
||||
:: Set Windows Terminal installation paths. (Default and Scoop installation)
|
||||
set "wtDefaultPath=%LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe"
|
||||
@@ -15,20 +15,20 @@ if exist "%wtDefaultPath%" (
|
||||
) else if exist "%wtScoopPath%" (
|
||||
set "wtPath=%wtScoopPath%"
|
||||
) else (
|
||||
echo Windows Terminal not found. Using default PowerShell instead.
|
||||
set "wtPath="
|
||||
)
|
||||
|
||||
:: Launch script
|
||||
:: Interpolated into a PS single-quoted string below;
|
||||
:: Apostrophes escaped via %:'=''% and -File arg uses [char]34 to avoid quote-parity bugs.
|
||||
set "SCRIPT_PATH=%~dp0Win11Debloat.ps1"
|
||||
|
||||
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"
|
||||
call :Log Script execution passed successfully to Win11Debloat.ps1
|
||||
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $w='%wtPath:'=''%'; $q=[char]34; Start-Process -FilePath $w -ArgumentList ('PowerShell -NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
) else (
|
||||
echo Windows Terminal not found. Using default PowerShell instead...
|
||||
echo Windows Terminal not found, using default PowerShell...
|
||||
call :Log Windows Terminal not found. Using default PowerShell to launch Win11Debloat.ps1...
|
||||
PowerShell -ExecutionPolicy Bypass -Command "& {Start-Process PowerShell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File ""%~dp0Win11Debloat.ps1""' -Verb RunAs}" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
call :Log Script execution passed successfully to Win11Debloat.ps1
|
||||
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $q=[char]34; Start-Process PowerShell -ArgumentList ('-NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
)
|
||||
|
||||
echo.
|
||||
@@ -38,11 +38,13 @@ goto :EOF
|
||||
|
||||
:: Logging Function
|
||||
:Log
|
||||
echo %* >> "%logFile%"
|
||||
echo(%* >> "%logFile%"
|
||||
goto :EOF
|
||||
|
||||
:: Error Handler
|
||||
:Error
|
||||
echo ERROR: %*
|
||||
echo(ERROR: %*
|
||||
call :Log ERROR: %*
|
||||
echo Logged in %logFile%
|
||||
pause
|
||||
goto :EOF
|
||||
|
||||
+17
-11
@@ -35,7 +35,7 @@
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Message Text -->
|
||||
<Grid Grid.Row="0" Margin="20,4,20,20">
|
||||
<Grid Grid.Row="0" Margin="20,4,20,16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -79,15 +79,15 @@
|
||||
<!-- Separator -->
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Background="{DynamicResource BorderColor}"
|
||||
Background="{DynamicResource AppBorderColor}"
|
||||
Margin="20,0"/>
|
||||
|
||||
<!-- Content -->
|
||||
<StackPanel Grid.Row="2" Margin="20,18,20,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"/>
|
||||
|
||||
@@ -105,9 +105,9 @@
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
Text=""
|
||||
Foreground="{DynamicResource CloseHover}"
|
||||
Foreground="{DynamicResource TitleBarCloseHoverColor}"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<TextBlock x:Name="KofiLink"
|
||||
@@ -124,11 +124,17 @@
|
||||
|
||||
<!-- Button Panel -->
|
||||
<Border Grid.Row="2" Style="{DynamicResource ModalFooterBorderStyle}">
|
||||
<StackPanel x:Name="ButtonPanel" Style="{DynamicResource ModalFooterButtonsRightStyle}">
|
||||
<Button x:Name="CloseButton"
|
||||
Content="Close"
|
||||
Style="{DynamicResource ModalSecondaryActionButtonStyle}"/>
|
||||
</StackPanel>
|
||||
<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>
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
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"/>
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -36,15 +36,15 @@
|
||||
<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>
|
||||
</Button.Style>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="10"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10"/>
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.RowSpan="2">
|
||||
@@ -54,9 +54,9 @@
|
||||
<!-- Loading icon (spinning) -->
|
||||
<TextBlock x:Name="ApplySpinnerIcon"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="36"
|
||||
Foreground="{DynamicResource ButtonBg}"
|
||||
Foreground="{DynamicResource ButtonBgColor}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,16"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
@@ -102,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>
|
||||
@@ -115,9 +115,9 @@
|
||||
<!-- Success icon -->
|
||||
<TextBlock x:Name="ApplyCompletionIcon"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource ButtonBg}"
|
||||
Foreground="{DynamicResource ButtonBgColor}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,12"/>
|
||||
|
||||
@@ -134,14 +134,14 @@
|
||||
<!-- 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">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||
<TextBlock Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="14"
|
||||
Foreground="#e8912d"
|
||||
VerticalAlignment="Center"
|
||||
@@ -149,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"/>
|
||||
@@ -163,7 +163,7 @@
|
||||
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"
|
||||
AutomationProperties.Name="Support the creator">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,-1"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,-1"/>
|
||||
<TextBlock Text="Support the creator" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,1"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
Text="View the selected changes here"
|
||||
TextWrapping="Wrap"
|
||||
MaxWidth="260"
|
||||
Foreground="{DynamicResource FgColor}"/>
|
||||
Foreground="{DynamicResource AppFgColor}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
|
||||
<!-- Content -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="0,0,8,0">
|
||||
<StackPanel x:Name="ContentPanel" Margin="20,4,20,8">
|
||||
<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"/>
|
||||
|
||||
+270
-277
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
Topmost="False"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<Border BorderBrush="{DynamicResource BorderColor}"
|
||||
<Border BorderBrush="{DynamicResource AppBorderColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Background="{DynamicResource CardBgColor}"
|
||||
@@ -33,14 +33,10 @@
|
||||
</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 -->
|
||||
@@ -53,9 +49,9 @@
|
||||
<!-- Icon -->
|
||||
<TextBlock x:Name="IconText"
|
||||
Grid.Column="0"
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="4,0,14,0"
|
||||
Visibility="Collapsed"/>
|
||||
@@ -67,7 +63,7 @@
|
||||
TextWrapping="Wrap"
|
||||
FontSize="14"
|
||||
LineHeight="20"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,20,0"/>
|
||||
</ScrollViewer>
|
||||
@@ -75,24 +71,27 @@
|
||||
|
||||
<!-- 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>
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
<Window.Resources>
|
||||
<Style x:Key="RestoreOptionTileStyle" 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="12,10"/>
|
||||
@@ -36,16 +36,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>
|
||||
@@ -84,19 +84,19 @@
|
||||
<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>
|
||||
</Button.Style>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="10"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10"/>
|
||||
</Button>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0">
|
||||
<Grid Margin="20,4,20,18">
|
||||
<Grid Margin="20,4,20,16">
|
||||
|
||||
<TabControl x:Name="RestoreModeTabs"
|
||||
SelectedIndex="0"
|
||||
@@ -121,7 +121,7 @@
|
||||
TextWrapping="Wrap"
|
||||
FontSize="14"
|
||||
LineHeight="20"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
Text="Choose what changes you want to restore."/>
|
||||
|
||||
<Button x:Name="ChooseRegistryBtn"
|
||||
@@ -135,7 +135,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="24"
|
||||
VerticalAlignment="Center"
|
||||
Margin="14,0,14,0"/>
|
||||
@@ -165,7 +165,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="24"
|
||||
VerticalAlignment="Center"
|
||||
Margin="14,0,14,0"/>
|
||||
@@ -203,14 +203,14 @@
|
||||
TextWrapping="Wrap"
|
||||
FontSize="14"
|
||||
LineHeight="20"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Text="This will restore any system registry changes made by Win11Debloat to their previous state. You can review the changes after selecting a backup file. Apps will need to be reinstalled manually."/>
|
||||
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 FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
Opacity="0.75"
|
||||
Text="Warning: Only use backup files generated by Win11Debloat."/>
|
||||
</Grid>
|
||||
@@ -222,6 +222,8 @@
|
||||
<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">
|
||||
@@ -245,7 +247,7 @@
|
||||
<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 BorderColor}" Margin="0,0,0,12"/>
|
||||
<Border Grid.Row="1" Height="1" Background="{DynamicResource AppBorderColor}" Margin="0,0,0,12"/>
|
||||
|
||||
<Grid x:Name="OverviewFeaturesSection" Grid.Row="2">
|
||||
<Grid.RowDefinitions>
|
||||
@@ -256,7 +258,7 @@
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="The following changes will be reverted:"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
FontWeight="SemiBold"
|
||||
Margin="0,0,0,8"/>
|
||||
|
||||
@@ -268,7 +270,7 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource FgColor}" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource AppFgColor}" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
@@ -277,14 +279,43 @@
|
||||
<TextBlock x:Name="OverviewSummaryText"
|
||||
Grid.Row="2"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
TextWrapping="Wrap"
|
||||
Visibility="Collapsed"
|
||||
Text="This will restore the Start Menu pinned apps layout for the current user."/>
|
||||
|
||||
<Border x:Name="NonRevertibleSeparator" Grid.Row="3" Height="1" Background="{DynamicResource BorderColor}" Margin="0,12,0,12" Visibility="Collapsed"/>
|
||||
<Border x:Name="ReappliedSeparator" Grid.Row="3" Height="1" Background="{DynamicResource AppBorderColor}" Margin="0,12,0,12" Visibility="Collapsed"/>
|
||||
|
||||
<Grid x:Name="NonRevertiblePanel" Grid.Row="4" 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="*"/>
|
||||
@@ -294,7 +325,7 @@
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="The following changes won't be reverted:"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
FontWeight="SemiBold"
|
||||
Margin="0,0,0,8"/>
|
||||
|
||||
@@ -306,7 +337,7 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource FgColor}" Opacity="0.85" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="{Binding DisplayText}" FontSize="12" Foreground="{DynamicResource AppFgColor}" Opacity="0.85" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
@@ -342,8 +373,8 @@
|
||||
FontSize="14"
|
||||
LineHeight="20"
|
||||
Margin="0,0,0,12"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Text="This will restore the Start Menu pinned apps layout for the selected user(s) using a backup. Win11Debloat can automatically find the backup created by the script."/>
|
||||
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"
|
||||
@@ -361,7 +392,7 @@
|
||||
IsChecked="True"
|
||||
FontSize="12"
|
||||
Margin="0,10,0,0"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
Opacity="0.85"/>
|
||||
</Grid>
|
||||
|
||||
|
||||
+109
-64
@@ -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"/>
|
||||
@@ -25,25 +25,25 @@
|
||||
</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,7 +119,7 @@
|
||||
<!-- 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"/>
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<!-- Shared modal window shell styles -->
|
||||
<Style x:Key="ModalCardBorderStyle" TargetType="Border">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Background" Value="{DynamicResource CardBgColor}"/>
|
||||
@@ -149,16 +149,16 @@
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ModalTitleTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<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="20,0,0,0"/>
|
||||
<Setter Property="Margin" Value="16,0,0,0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ModalFooterBorderStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource BgColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<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"/>
|
||||
@@ -195,20 +195,20 @@
|
||||
|
||||
<Style x:Key="ModalInfoLabelTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
|
||||
<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 FgColor}"/>
|
||||
<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 FgColor}"/>
|
||||
<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"/>
|
||||
@@ -223,14 +223,15 @@
|
||||
Padding="{TemplateBinding Padding}"
|
||||
CornerRadius="4">
|
||||
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||
Margin="0,0,0,1"/>
|
||||
</Border>
|
||||
<Border x:Name="AccentLine"
|
||||
Width="3"
|
||||
Height="15"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Stretch"
|
||||
Background="{DynamicResource ButtonBg}"
|
||||
Background="{DynamicResource ButtonBgColor}"
|
||||
CornerRadius="1.5"
|
||||
Margin="0"
|
||||
Visibility="Collapsed"/>
|
||||
@@ -238,10 +239,10 @@
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="AccentLine" Property="Visibility" Value="Visible"/>
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource ComboItemSelectedColor}"/>
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource ComboBoxItemSelectedColor}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource ComboItemHoverColor}"/>
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="{DynamicResource ComboBoxItemHoverColor}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
@@ -250,17 +251,18 @@
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="{DynamicResource ComboBgColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<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 ButtonBg}" CornerRadius="1.5" Panel.ZIndex="2"/>
|
||||
<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">
|
||||
@@ -270,12 +272,12 @@
|
||||
<Border x:Name="Border" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4">
|
||||
<TextBlock x:Name="Arrow"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="10"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"
|
||||
Foreground="{DynamicResource FgColor}"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform>
|
||||
<RotateTransform Angle="0"/>
|
||||
@@ -284,7 +286,7 @@
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource ComboHoverColor}"/>
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource ComboBoxHoverColor}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Trigger.EnterActions>
|
||||
@@ -319,7 +321,7 @@
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="ContentPresenter"
|
||||
IsHitTestVisible="False"
|
||||
Margin="10,0,20,0"
|
||||
Margin="10,0,20,2"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
@@ -337,8 +339,8 @@
|
||||
HorizontalOffset="0">
|
||||
<Grid x:Name="DropDown" MinWidth="{TemplateBinding ActualWidth}" MaxHeight="{TemplateBinding MaxDropDownHeight}" Margin="12">
|
||||
<Border x:Name="DropDownBorder"
|
||||
Background="{DynamicResource ComboItemBgColor}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
Background="{DynamicResource ComboBoxItemBgColor}"
|
||||
BorderBrush="{DynamicResource AppBorderColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="5,4,5,1">
|
||||
@@ -363,9 +365,9 @@
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="ClosedAccentLine" Property="Visibility" Value="Collapsed"/>
|
||||
<Setter Property="Background" Value="{DynamicResource ButtonDisabled}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource ButtonTextDisabled}"/>
|
||||
<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>
|
||||
@@ -376,8 +378,9 @@
|
||||
|
||||
<!-- 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>
|
||||
@@ -388,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="" FontFamily="Segoe Fluent Icons" FontSize="12" Foreground="{DynamicResource ButtonBg}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0">
|
||||
<TextBlock x:Name="CheckMark" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0">
|
||||
<TextBlock.Clip>
|
||||
<RectangleGeometry x:Name="CheckMarkClip" Rect="0,0,0,16"/>
|
||||
</TextBlock.Clip>
|
||||
</TextBlock>
|
||||
<TextBlock x:Name="IndeterminateMark" Text="" 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="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="13" FontWeight="Bold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" Margin="1,0,0,1">
|
||||
<TextBlock.Clip>
|
||||
<RectangleGeometry x:Name="IndeterminateMarkClip" Rect="0,0,0,16"/>
|
||||
<RectangleGeometry x:Name="IndeterminateMarkClip" Rect="0,0,0,12"/>
|
||||
</TextBlock.Clip>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
@@ -410,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"/>
|
||||
@@ -433,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"/>
|
||||
@@ -461,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>
|
||||
@@ -470,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>
|
||||
@@ -543,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>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Forcefully removes Microsoft Edge using its uninstaller
|
||||
# Credit: Based on work from loadstring1 & ave9858
|
||||
function ForceRemoveEdge {
|
||||
Write-Host "> Forcefully uninstalling Microsoft Edge..."
|
||||
|
||||
$regView = [Microsoft.Win32.RegistryView]::Registry32
|
||||
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
|
||||
$hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev').SetValue('AllowUninstall', '')
|
||||
|
||||
# Create stub (This somehow allows uninstalling Edge)
|
||||
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
|
||||
New-Item $edgeStub -ItemType Directory | Out-Null
|
||||
New-Item "$edgeStub\MicrosoftEdge.exe" | Out-Null
|
||||
|
||||
# Remove edge
|
||||
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
|
||||
if ($null -ne $uninstallRegKey) {
|
||||
Write-Host "Running uninstaller..."
|
||||
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($cmd)
|
||||
Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait
|
||||
} -ArgumentList $uninstallString
|
||||
|
||||
Write-Host "Removing leftover files..."
|
||||
|
||||
$edgePaths = @(
|
||||
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
|
||||
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
|
||||
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
|
||||
"$edgeStub"
|
||||
)
|
||||
|
||||
foreach ($path in $edgePaths) {
|
||||
if (Test-Path -Path $path) {
|
||||
Remove-Item -Path $path -Force -Recurse -ErrorAction SilentlyContinue
|
||||
Write-Host " Removed $path" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Cleaning up registry..."
|
||||
|
||||
# Remove MS Edge from autostart
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "Microsoft Edge Update" /f *>$null
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "Microsoft Edge Update" /f *>$null
|
||||
|
||||
Write-Host "Microsoft Edge was uninstalled"
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a list of installed apps from winget as structured objects.
|
||||
|
||||
.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 Get-WingetInstalledApps {
|
||||
param (
|
||||
[int]$TimeOut = 10,
|
||||
[switch]$NonBlocking
|
||||
)
|
||||
|
||||
if (-not $script:WingetInstalled) { return $null }
|
||||
|
||||
$fetchBlock = {
|
||||
param($timeOut)
|
||||
$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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
# A missing table separator means the output is malformed or empty
|
||||
if ($dataStart -lt 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($dataStart -ge $lines.Count) {
|
||||
return ,@()
|
||||
}
|
||||
|
||||
$apps = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if ($NonBlocking) {
|
||||
return Invoke-NonBlocking -ScriptBlock $fetchBlock -ArgumentList $TimeOut
|
||||
}
|
||||
else {
|
||||
return & $fetchBlock $TimeOut
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
# Run winget list and return installed apps.
|
||||
# Use -NonBlocking to keep the UI responsive (GUI mode) via Invoke-NonBlocking.
|
||||
function GetInstalledAppsViaWinget {
|
||||
param (
|
||||
[int]$TimeOut = 10,
|
||||
[switch]$NonBlocking
|
||||
)
|
||||
|
||||
if (-not $script:WingetInstalled) { return $null }
|
||||
|
||||
$fetchBlock = {
|
||||
param($timeOut)
|
||||
$job = Start-Job { return winget list --accept-source-agreements --disable-interactivity }
|
||||
$done = $job | Wait-Job -Timeout $timeOut
|
||||
if ($done) {
|
||||
$result = Receive-Job -Job $job
|
||||
Remove-Job -Job $job -ErrorAction SilentlyContinue
|
||||
return $result
|
||||
}
|
||||
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($NonBlocking) {
|
||||
return Invoke-NonBlocking -ScriptBlock $fetchBlock -ArgumentList $TimeOut
|
||||
}
|
||||
else {
|
||||
return & $fetchBlock $TimeOut
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when Edge is uninstalled and cleanup succeeds; otherwise $false.
|
||||
#>
|
||||
function Invoke-ForceRemoveEdge {
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Forcefully uninstall Microsoft Edge" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "> Forcefully uninstalling Microsoft Edge..."
|
||||
|
||||
$regView = [Microsoft.Win32.RegistryView]::Registry32
|
||||
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
|
||||
$edgeUpdateKey = $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev')
|
||||
$edgeUpdateKey.SetValue('AllowUninstall', '')
|
||||
|
||||
# Create stub (This somehow allows uninstalling Edge)
|
||||
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
|
||||
New-Item $edgeStub -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
New-Item "$edgeStub\MicrosoftEdge.exe" -ItemType File -Force -ErrorAction Stop | Out-Null
|
||||
|
||||
# Remove edge
|
||||
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
|
||||
if ($null -eq $uninstallRegKey) {
|
||||
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Running uninstaller..."
|
||||
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
|
||||
$exitCode = Invoke-NonBlocking -ScriptBlock {
|
||||
param($cmd)
|
||||
$process = Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait -PassThru
|
||||
return $process.ExitCode
|
||||
} -ArgumentList $uninstallString
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Warning "Microsoft Edge uninstaller failed with exit code $exitCode."
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Removing leftover files..."
|
||||
$cleanupSucceeded = $true
|
||||
|
||||
$edgePaths = @(
|
||||
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
|
||||
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
|
||||
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
|
||||
"$edgeStub"
|
||||
)
|
||||
|
||||
foreach ($path in $edgePaths) {
|
||||
if (Test-Path -Path $path) {
|
||||
try {
|
||||
Remove-Item -Path $path -Force -Recurse -ErrorAction Stop
|
||||
Write-Host " Removed $path" -ForegroundColor DarkGray
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove Edge leftover '$path': $($_.Exception.Message)"
|
||||
$cleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Cleaning up registry..."
|
||||
$registryCleanupSucceeded = $true
|
||||
|
||||
# Remove MS Edge from autostart. Missing values are already-clean state,
|
||||
# while failures to inspect or remove an existing value are reported.
|
||||
$autostartValues = @(
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'Microsoft Edge Update' },
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'Microsoft Edge Update' }
|
||||
)
|
||||
foreach ($autostartValue in $autostartValues) {
|
||||
if (-not (Remove-EdgeAutostartValue -Path $autostartValue.Path -Name $autostartValue.Name)) {
|
||||
$registryCleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $cleanupSucceeded -or -not $registryCleanupSucceeded) {
|
||||
Write-Warning "Microsoft Edge was uninstalled, but some leftover files or autostart entries could not be removed."
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Microsoft Edge was uninstalled"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to forcefully uninstall Microsoft Edge: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
finally {
|
||||
if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() }
|
||||
if ($uninstallRegKey) { $uninstallRegKey.Dispose() }
|
||||
if ($hklm) { $hklm.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes an Edge autostart registry value when it exists.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the value is absent or removed; $false when inspection or removal fails.
|
||||
#>
|
||||
function Remove-EdgeAutostartValue {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
$properties = Get-ItemProperty -Path $Path -ErrorAction Stop
|
||||
}
|
||||
catch [System.Management.Automation.ItemNotFoundException] {
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to inspect Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not $properties.PSObject.Properties[$Name]) {
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-ItemProperty -Path $Path -Name $Name -ErrorAction Stop
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes one or more Windows app packages based on the target scope.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates over the provided list of app identifiers and removes each one.
|
||||
The removal method (winget vs. Appx cmdlets) is determined per-app from
|
||||
Apps.json. A scheduled task is only created when the User or Sysprep
|
||||
parameter was passed. After winget removal, the system is checked to
|
||||
confirm whether the app is still installed before reporting an error.
|
||||
Returns early if the CancelRequested flag is set.
|
||||
|
||||
.PARAMETER appsList
|
||||
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
|
||||
|
||||
.EXAMPLE
|
||||
Remove-SelectedApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
|
||||
|
||||
.EXAMPLE
|
||||
Remove-SelectedApps -appsList (Generate-AppsList)
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when all removals can be confirmed; otherwise $false.
|
||||
#>
|
||||
function Remove-SelectedApps {
|
||||
param (
|
||||
$appslist
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
foreach ($app in $appslist) {
|
||||
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
$failuresBefore = $script:AppRemovalFailures
|
||||
$targetUser = Get-TargetUserForAppRemoval
|
||||
$appCount = @($appsList).Count
|
||||
$appIndex = 0
|
||||
|
||||
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
|
||||
$wingetRemovedApps = @()
|
||||
$wingetRemovalFailures = @{}
|
||||
|
||||
Foreach ($app in $appsList) {
|
||||
if ($script:CancelRequested) { return $false }
|
||||
|
||||
$appIndex++
|
||||
|
||||
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
|
||||
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
|
||||
}
|
||||
|
||||
Write-Host "Removing $app"
|
||||
|
||||
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
|
||||
$removalSucceeded = Remove-WinGetApp -app $app
|
||||
$wingetRemovedApps += $app
|
||||
if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) {
|
||||
$wingetRemovalFailures[$app] = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) {
|
||||
$script:AppRemovalFailures++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:CancelRequested) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check whether any winget-removed apps are still present, and report errors for each one.
|
||||
if ($wingetRemovedApps.Count -gt 0) {
|
||||
$postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null }
|
||||
$edgeForceRemoveRequested = $false
|
||||
$edgeForceRemoveSucceeded = $false
|
||||
|
||||
if ($null -eq $postRemovalList) {
|
||||
$script:AppRemovalVerificationUnavailable = $true
|
||||
foreach ($app in $wingetRemovedApps) {
|
||||
$wingetRemovalFailures[$app] = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($app in $wingetRemovedApps) {
|
||||
if (-not (Test-AppInWingetList -appId $app -InstalledList $postRemovalList)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($edgeIds -contains $app) {
|
||||
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
|
||||
if (-not $edgeForceRemoveRequested) {
|
||||
$edgeForceRemoveRequested = $true
|
||||
$edgeForceRemoveSucceeded = Request-EdgeForceRemove
|
||||
}
|
||||
if ($edgeForceRemoveSucceeded) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
|
||||
}
|
||||
$wingetRemovalFailures[$app] = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$script:AppRemovalFailures += $wingetRemovalFailures.Count
|
||||
|
||||
return ($script:AppRemovalFailures -eq $failuresBefore)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Uninstalls an app via WinGet and/or schedules its removal.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs winget uninstall for a single app, with a bounded execution time.
|
||||
WinGet's own exit code/success reporting is unreliable and is only logged
|
||||
for diagnostics; it never causes this function to report failure. Callers
|
||||
verify removal with a post-removal inventory check instead. This function
|
||||
only reports failure when the winget invocation itself throws a terminating
|
||||
error (e.g. it times out or cannot be started). If the User or Sysprep
|
||||
parameter was passed, also schedules removal for future logins.
|
||||
|
||||
.PARAMETER app
|
||||
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
|
||||
|
||||
.PARAMETER TimeoutSeconds
|
||||
Maximum time to allow the foreground WinGet uninstall to run. Defaults
|
||||
to 120 seconds.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true unless the winget invocation threw a terminating error
|
||||
or any required RunOnce scheduling failed; otherwise $false.
|
||||
#>
|
||||
function Remove-WinGetApp {
|
||||
param(
|
||||
[string]$app,
|
||||
[int]$TimeoutSeconds = 120
|
||||
)
|
||||
|
||||
if (-not $script:WingetInstalled) {
|
||||
Write-Error "WinGet is either not installed or is outdated; $app could not be removed"
|
||||
return $false
|
||||
}
|
||||
|
||||
$uninstallCommandSucceeded = $true
|
||||
$exitCode = $null
|
||||
try {
|
||||
$uninstallResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($appId)
|
||||
$output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1)
|
||||
return [PSCustomObject]@{
|
||||
ExitCode = $LASTEXITCODE
|
||||
Output = $output
|
||||
}
|
||||
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
|
||||
Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null })
|
||||
$exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' }
|
||||
Write-Verbose "WinGet uninstall for $app returned exit code $exitCode."
|
||||
}
|
||||
catch {
|
||||
$uninstallCommandSucceeded = $false
|
||||
if ($_.Exception.Message -like 'Operation timed out after *') {
|
||||
Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "WinGet uninstall for $app failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
$scheduleSucceeded = $true
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..."
|
||||
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
|
||||
}
|
||||
elseif ($script:Params.ContainsKey("Sysprep")) {
|
||||
Write-Host "Adding scheduled task to uninstall $app for new users..."
|
||||
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
|
||||
}
|
||||
|
||||
return ($uninstallCommandSucceeded -and $scheduleSucceeded)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes captured WinGet uninstall output to the verbose stream.
|
||||
|
||||
.OUTPUTS
|
||||
None.
|
||||
#>
|
||||
function Write-WinGetUninstallOutput {
|
||||
param(
|
||||
[object[]]$Output
|
||||
)
|
||||
|
||||
foreach ($line in @($Output)) {
|
||||
if ($null -eq $line) { continue }
|
||||
|
||||
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
|
||||
if ([string]::IsNullOrWhiteSpace($lineText)) { continue }
|
||||
|
||||
Write-Verbose $lineText
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
|
||||
|
||||
.PARAMETER app
|
||||
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
|
||||
|
||||
.PARAMETER targetUser
|
||||
Target scope: "AllUsers", "CurrentUser", or a specific username.
|
||||
#>
|
||||
function Remove-AppxApp {
|
||||
param([string]$app, [string]$targetUser)
|
||||
|
||||
$appPattern = '*' + $app + '*'
|
||||
|
||||
try {
|
||||
$removalResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($pattern, $target)
|
||||
|
||||
$removalErrors = @()
|
||||
$getPackageParams = @{ Name = $pattern; ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
|
||||
$removePackageParams = @{ ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
|
||||
|
||||
switch ($target) {
|
||||
'AllUsers' {
|
||||
$getPackageParams.AllUsers = $true
|
||||
$removePackageParams.AllUsers = $true
|
||||
}
|
||||
'CurrentUser' { }
|
||||
default {
|
||||
$userAccount = New-Object System.Security.Principal.NTAccount($target)
|
||||
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
$getPackageParams.User = $userSid
|
||||
$removePackageParams.User = $userSid
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($package in @(Get-AppxPackage @getPackageParams)) {
|
||||
$removePackageParams.Package = $package.PackageFullName
|
||||
$null = Remove-AppxPackage @removePackageParams
|
||||
}
|
||||
|
||||
if ($target -eq 'AllUsers') {
|
||||
$provisionedPackages = @(Get-AppxProvisionedPackage -Online -ErrorAction Continue -ErrorVariable +removalErrors | Where-Object { $_.PackageName -like $pattern })
|
||||
foreach ($package in $provisionedPackages) {
|
||||
$null = Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $package.PackageName -ErrorAction Continue -ErrorVariable +removalErrors
|
||||
}
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{ Success = ($removalErrors.Count -eq 0) }
|
||||
} -ArgumentList @($appPattern, $targetUser)
|
||||
}
|
||||
catch {
|
||||
Write-Error "Unable to remove $app via Appx: $_"
|
||||
return $false
|
||||
}
|
||||
|
||||
return [bool]($removalResult -and $removalResult.Success)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the removal method for an app identifier.
|
||||
|
||||
.DESCRIPTION
|
||||
Parses Apps.json once (cached in script scope) to build a lookup of
|
||||
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
|
||||
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
|
||||
for unknown IDs.
|
||||
|
||||
.PARAMETER appId
|
||||
The package identifier (e.g. 'Clipchamp.Clipchamp').
|
||||
#>
|
||||
function Get-AppRemovalMethod {
|
||||
param([string]$appId)
|
||||
|
||||
if (-not $script:AppRemovalMethodCache) {
|
||||
$script:AppRemovalMethodCache = @{}
|
||||
try {
|
||||
if (Test-Path $script:AppsListFilePath) {
|
||||
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
|
||||
foreach ($appData in $appsJson.Apps) {
|
||||
$rawMethod = $appData.RemovalMethod
|
||||
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
|
||||
foreach ($id in @($appData.AppId)) {
|
||||
if ($id -isnot [string]) { continue }
|
||||
$normalizedId = $id.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($normalizedId)) {
|
||||
$script:AppRemovalMethodCache[$normalizedId] = $method
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
|
||||
return $script:AppRemovalMethodCache[$appId]
|
||||
}
|
||||
return 'Appx'
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
|
||||
|
||||
.DESCRIPTION
|
||||
Only invoked after it has been confirmed that Edge is still present
|
||||
following all winget uninstall attempts. In GUI mode, displays a
|
||||
warning message box; in CLI mode, prompts via Read-Host. On
|
||||
confirmation, performs a force-remove of the Edge package.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when Edge is forcefully removed; otherwise $false.
|
||||
#>
|
||||
function Request-EdgeForceRemove {
|
||||
if ($script:GuiWindow) {
|
||||
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
|
||||
if ($result -eq 'Yes') {
|
||||
Write-Host ""
|
||||
return (Invoke-ForceRemoveEdge)
|
||||
}
|
||||
}
|
||||
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
|
||||
Write-Host ""
|
||||
return (Invoke-ForceRemoveEdge)
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
|
||||
via the PowerShell registry API within Invoke-WithTargetUserHive,
|
||||
which handles hive loading and HKEY_USERS\Default → SID remapping.
|
||||
Used instead of static .reg files to avoid file dependency for each WinGet app.
|
||||
|
||||
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
|
||||
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
|
||||
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
|
||||
even if future catalog updates introduce IDs containing those characters.
|
||||
|
||||
.PARAMETER appId
|
||||
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
|
||||
#>
|
||||
function Set-RunOnceWingetTask {
|
||||
param([string]$appId)
|
||||
|
||||
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
|
||||
|
||||
# Sanitize appId for use in registry value names (backslashes are path separators)
|
||||
$safeAppId = $appId.Replace('\', '_')
|
||||
|
||||
$taskName = "Uninstall_$safeAppId"
|
||||
|
||||
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
|
||||
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
|
||||
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
|
||||
$escapedAppId = $appId.Replace("'", "''")
|
||||
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
|
||||
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
|
||||
|
||||
$operation = [PSCustomObject]@{
|
||||
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
ValueName = $taskName
|
||||
ValueType = 'String'
|
||||
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
|
||||
OperationType = 'SetValue'
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
|
||||
param($op)
|
||||
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
|
||||
} -ArgumentObject $operation
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to schedule uninstall task for $($appId): $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
# Removes apps specified during function call based on the target scope.
|
||||
function RemoveApps {
|
||||
param (
|
||||
$appslist
|
||||
)
|
||||
|
||||
# Determine target from script-level params, defaulting to AllUsers
|
||||
$targetUser = GetTargetUserForAppRemoval
|
||||
|
||||
$appIndex = 0
|
||||
$appCount = @($appsList).Count
|
||||
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
|
||||
$edgeUninstallSucceeded = $false
|
||||
$edgeScheduledTaskAdded = $false
|
||||
|
||||
Foreach ($app in $appsList) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
# Use Remove-AppxPackage to remove all other apps
|
||||
$appPattern = '*' + $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)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($DebugPreference -ne "SilentlyContinue") {
|
||||
Write-Host "Something went wrong while trying to remove $app" -ForegroundColor Yellow
|
||||
Write-Host $psitem.Exception.StackTrace -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
@@ -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 Get-WingetInstalledApps.
|
||||
#>
|
||||
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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
function AwaitKeyToExit {
|
||||
# Suppress prompt if Silent parameter was passed
|
||||
if (-not $Silent) {
|
||||
Write-Output ""
|
||||
Write-Output "Press any key to exit..."
|
||||
$null = [System.Console]::ReadKey()
|
||||
}
|
||||
|
||||
Stop-Transcript
|
||||
Exit
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Shows the CLI app removal menu and prompts the user to select which apps to remove.
|
||||
function ShowCLIAppRemoval {
|
||||
PrintHeader "App Removal"
|
||||
function Show-CliAppRemoval {
|
||||
Write-CliHeader "App Removal"
|
||||
|
||||
Write-Output "> Opening app selection form..."
|
||||
|
||||
@@ -8,9 +8,10 @@ function ShowCLIAppRemoval {
|
||||
|
||||
if ($result -eq $true) {
|
||||
Write-Output "You have selected $($script:SelectedApps.Count) apps for removal"
|
||||
AddParameter 'RemoveAppsCustom'
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
|
||||
|
||||
SaveSettings
|
||||
Save-Settings
|
||||
|
||||
# Suppress prompt if Silent parameter was passed
|
||||
if (-not $Silent) {
|
||||
@@ -18,7 +19,7 @@ function ShowCLIAppRemoval {
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to remove the selected apps or press CTRL+C to quit..."
|
||||
Read-Host | Out-Null
|
||||
PrintHeader "App Removal"
|
||||
Write-CliHeader "App Removal"
|
||||
}
|
||||
}
|
||||
else {
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Shows the CLI default mode app removal options. Loops until a valid option is selected.
|
||||
function ShowCLIDefaultModeAppRemovalOptions {
|
||||
PrintHeader 'Default Mode'
|
||||
function Show-CliDefaultModeAppRemovalOptions {
|
||||
Write-CliHeader 'Default Mode'
|
||||
|
||||
Write-Host "Please note: The default selection of apps includes Microsoft Teams, Spotify, Sticky Notes and more. Select option 2 to verify and change what apps are removed by the script" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
+15
-14
@@ -1,5 +1,5 @@
|
||||
# Show CLI default mode options for removing apps, or set selection if RunDefaults or RunDefaultsLite parameter was passed
|
||||
function ShowCLIDefaultModeOptions {
|
||||
function Show-CliDefaultModeOptions {
|
||||
if ($RunDefaults) {
|
||||
$RemoveAppsInput = '1'
|
||||
}
|
||||
@@ -7,7 +7,7 @@ function ShowCLIDefaultModeOptions {
|
||||
$RemoveAppsInput = '0'
|
||||
}
|
||||
else {
|
||||
$RemoveAppsInput = ShowCLIDefaultModeAppRemovalOptions
|
||||
$RemoveAppsInput = Show-CliDefaultModeAppRemovalOptions
|
||||
|
||||
if ($RemoveAppsInput -eq '2' -and ($script:SelectedApps.contains('Microsoft.XboxGameOverlay') -or $script:SelectedApps.contains('Microsoft.XboxGamingOverlay')) -and
|
||||
$( Read-Host -Prompt "Disable Game Bar integration and game/screen recording? This also stops ms-gamingoverlay and ms-gamebar popups (y/n)" ) -eq 'y') {
|
||||
@@ -15,39 +15,40 @@ function ShowCLIDefaultModeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
PrintHeader 'Default Mode'
|
||||
Write-CliHeader 'Default Mode'
|
||||
|
||||
try {
|
||||
# Select app removal options based on user input
|
||||
switch ($RemoveAppsInput) {
|
||||
'1' {
|
||||
AddParameter 'RemoveApps'
|
||||
AddParameter 'Apps' 'Default'
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' 'Default'
|
||||
}
|
||||
'2' {
|
||||
AddParameter 'RemoveAppsCustom'
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
|
||||
|
||||
if ($DisableGameBarIntegrationInput) {
|
||||
AddParameter 'DisableDVR'
|
||||
AddParameter 'DisableGameBarIntegration'
|
||||
Add-Parameter 'DisableDVR'
|
||||
Add-Parameter 'DisableGameBarIntegration'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadSettings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
Import-Settings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load settings from DefaultSettings.json file: $_"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
|
||||
SaveSettings
|
||||
Save-Settings
|
||||
|
||||
if ($Silent) {
|
||||
# Skip change summary and confirmation prompt
|
||||
return
|
||||
}
|
||||
|
||||
PrintPendingChanges
|
||||
PrintHeader 'Default Mode'
|
||||
}
|
||||
Write-PendingChanges
|
||||
Write-CliHeader 'Default Mode'
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
# Shows the CLI last used settings from LastUsedSettings.json file, displays pending changes and prompts the user to apply them.
|
||||
function ShowCLILastUsedSettings {
|
||||
PrintHeader 'Custom Mode'
|
||||
function Show-CliLastUsedSettings {
|
||||
Write-CliHeader 'Custom Mode'
|
||||
|
||||
try {
|
||||
LoadSettings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
|
||||
Import-Settings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load settings from LastUsedSettings.json file: $_"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
|
||||
if ($Silent) {
|
||||
@@ -15,6 +15,6 @@ function ShowCLILastUsedSettings {
|
||||
return
|
||||
}
|
||||
|
||||
PrintPendingChanges
|
||||
PrintHeader 'Custom Mode'
|
||||
}
|
||||
Write-PendingChanges
|
||||
Write-CliHeader 'Custom Mode'
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# Shows the CLI menu options and prompts the user to select one. Loops until a valid option is selected.
|
||||
function ShowCLIMenuOptions {
|
||||
function Show-CliMenuOptions {
|
||||
Do {
|
||||
$ModeSelectionMessage = "Please select an option (1/2)"
|
||||
|
||||
PrintHeader 'Menu'
|
||||
Write-CliHeader 'Menu'
|
||||
|
||||
Write-Host "(1) Default mode: Quickly apply the recommended changes"
|
||||
Write-Host "(2) App removal mode: Select & remove apps, without making other changes"
|
||||
@@ -0,0 +1,22 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Waits for user acknowledgement, then exits the script.
|
||||
|
||||
.PARAMETER ExitCode
|
||||
Process exit code to return after acknowledgement. Defaults to 0.
|
||||
#>
|
||||
function Wait-ForKeyPress {
|
||||
param(
|
||||
[int]$ExitCode = 0
|
||||
)
|
||||
|
||||
# Suppress prompt if Silent parameter was passed
|
||||
if (-not $Silent) {
|
||||
Write-Output ""
|
||||
Write-Output "Press any key to exit..."
|
||||
$null = [System.Console]::ReadKey()
|
||||
}
|
||||
|
||||
Stop-Transcript
|
||||
Exit $ExitCode
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Prints the header for the script
|
||||
function PrintHeader {
|
||||
function Write-CliHeader {
|
||||
param (
|
||||
$title
|
||||
)
|
||||
@@ -10,11 +10,11 @@ function PrintHeader {
|
||||
$fullTitle = "$fullTitle (Sysprep mode)"
|
||||
}
|
||||
else {
|
||||
$fullTitle = "$fullTitle (User: $(GetUserName))"
|
||||
$fullTitle = "$fullTitle (User: $(Get-UserName))"
|
||||
}
|
||||
|
||||
Clear-Host
|
||||
Write-Host "-------------------------------------------------------------------------------------------"
|
||||
Write-Host $fullTitle
|
||||
Write-Host "-------------------------------------------------------------------------------------------"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
# Prints all pending changes that will be made by the script
|
||||
function PrintPendingChanges {
|
||||
<#
|
||||
.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 Write-PendingChanges {
|
||||
Write-Output "Win11Debloat will make the following changes:"
|
||||
|
||||
if ($script:Params['CreateRestorePoint']) {
|
||||
@@ -19,20 +32,7 @@ function PrintPendingChanges {
|
||||
continue
|
||||
}
|
||||
'RemoveApps' {
|
||||
$appsList = GenerateAppsList
|
||||
|
||||
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
|
||||
}
|
||||
'RemoveAppsCustom' {
|
||||
$appsList = LoadAppsFromFile $script:CustomAppsListFilePath
|
||||
$appsList = Generate-AppsList
|
||||
|
||||
if ($appsList.Count -eq 0) {
|
||||
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
||||
@@ -45,14 +45,8 @@ function PrintPendingChanges {
|
||||
continue
|
||||
}
|
||||
default {
|
||||
if ($script:Features -and $script:Features.ContainsKey($parameterName)) {
|
||||
$message = $script:Features[$parameterName].Label
|
||||
Write-Output "- $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
|
||||
}
|
||||
}
|
||||
@@ -62,4 +56,4 @@ function PrintPendingChanges {
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to execute the script or press CTRL+C to quit..."
|
||||
Read-Host | Out-Null
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
+127
-56
@@ -1,47 +1,44 @@
|
||||
function Get-RegistryBackupCapturePlans {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$SelectedRegistryFeatures,
|
||||
[object[]]$SelectedRegistryFeatures = @(),
|
||||
[object[]]$UndoRegistryFeatures = @(),
|
||||
[switch]$UseSysprepRegFiles
|
||||
)
|
||||
|
||||
$planMap = @{}
|
||||
|
||||
foreach ($feature in $SelectedRegistryFeatures) {
|
||||
$regFilePath = Get-RegistryFilePathForFeature -Feature $feature -UseSysprepRegFiles:$UseSysprepRegFiles
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
$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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
$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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +54,68 @@ function Get-RegistryBackupCapturePlans {
|
||||
)
|
||||
}
|
||||
|
||||
function Get-RegistrySnapshotsForBackup {
|
||||
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)]
|
||||
[object[]]$CapturePlans
|
||||
$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) {
|
||||
@@ -92,31 +147,14 @@ function Invoke-WithLoadedBackupHive {
|
||||
$ArgumentObject = $null
|
||||
)
|
||||
|
||||
$hiveDatPath = if ($script:Params.ContainsKey('Sysprep')) {
|
||||
GetUserDirectory -userName 'Default' -fileName 'NTUSER.DAT'
|
||||
$targetUserName = if ($script:Params.ContainsKey('Sysprep')) {
|
||||
'Default'
|
||||
}
|
||||
else {
|
||||
GetUserDirectory -userName $script:Params.Item('User') -fileName 'NTUSER.DAT'
|
||||
$script:Params.Item('User')
|
||||
}
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
reg load 'HKU\Default' "$hiveDatPath" | Out-Null
|
||||
$loadExitCode = $LASTEXITCODE
|
||||
if ($loadExitCode -ne 0) {
|
||||
throw "Failed to load user hive for registry backup at '$hiveDatPath' (exit code: $loadExitCode)"
|
||||
}
|
||||
|
||||
try {
|
||||
return & $ScriptBlock $ArgumentObject
|
||||
}
|
||||
finally {
|
||||
$global:LASTEXITCODE = 0
|
||||
reg unload 'HKU\Default' | Out-Null
|
||||
$unloadExitCode = $LASTEXITCODE
|
||||
if ($unloadExitCode -ne 0) {
|
||||
throw "Failed to unload registry hive 'HKU\Default' (exit code: $unloadExitCode)"
|
||||
}
|
||||
}
|
||||
return Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject
|
||||
}
|
||||
|
||||
function Get-RegistryKeySnapshot {
|
||||
@@ -157,10 +195,19 @@ function Get-RegistryKeySnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts an open registry key into a backup snapshot.
|
||||
|
||||
.DESCRIPTION
|
||||
Captures all values or selected value names, records missing selected values,
|
||||
and recursively captures subkeys when requested. Throws if a requested subkey
|
||||
cannot be read.
|
||||
#>
|
||||
function Convert-RegistryKeyToSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FullPath,
|
||||
[bool]$CaptureAllValues = $false,
|
||||
@@ -195,7 +242,9 @@ function Convert-RegistryKeyToSnapshot {
|
||||
if ($IncludeSubKeys) {
|
||||
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
|
||||
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
|
||||
if ($null -eq $childKey) { continue }
|
||||
if ($null -eq $childKey) {
|
||||
throw "Unable to read registry subkey '$($RegistryKey.Name)\$subKeyName' while creating a backup snapshot. The backup was not created."
|
||||
}
|
||||
|
||||
try {
|
||||
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
|
||||
@@ -215,20 +264,34 @@ function Convert-RegistryKeyToSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a registry value into a serializable backup snapshot.
|
||||
|
||||
.DESCRIPTION
|
||||
Preserves the value kind and normalizes supported data types for JSON
|
||||
serialization without expanding environment-string values. REG_NONE values
|
||||
are rejected.
|
||||
#>
|
||||
function Convert-RegistryValueToSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string]$ValueName
|
||||
)
|
||||
|
||||
$valueKind = $RegistryKey.GetValueKind($ValueName)
|
||||
if ($valueKind -eq [Microsoft.Win32.RegistryValueKind]::None) {
|
||||
throw "REG_NONE registry values are not supported for backup. Key='$($RegistryKey.Name)' Name='$ValueName'"
|
||||
}
|
||||
|
||||
$value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
try {
|
||||
$normalizedValue = switch ($valueKind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) { @($value | ForEach-Object { [int]$_ }) }
|
||||
# Prevent an empty byte sequence from being unrolled to $null by the switch pipeline.
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) { if ($null -eq $value) { ,@() } else { ,@($value | ForEach-Object { [int]$_ }) } }
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) { @($value) }
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) { [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$value), 0) }
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) { [BitConverter]::ToUInt64([BitConverter]::GetBytes([int64]$value), 0) }
|
||||
@@ -249,12 +312,20 @@ function Convert-RegistryValueToSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Describes the user profile targeted by a registry backup.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns DefaultUserProfile for Sysprep, User:<name> for an explicit user,
|
||||
or CurrentUser:<name> otherwise.
|
||||
#>
|
||||
function Get-RegistryBackupTargetDescription {
|
||||
if ($script:Params.ContainsKey('Sysprep')) {
|
||||
return 'DefaultUserProfile'
|
||||
}
|
||||
|
||||
$resolvedUserName = [string](GetUserName)
|
||||
$resolvedUserName = [string](Get-UserName)
|
||||
|
||||
if ($script:Params.ContainsKey('User')) {
|
||||
return "User:$resolvedUserName"
|
||||
@@ -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 (Save-ToFile -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
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
function Get-FeatureId {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Feature
|
||||
)
|
||||
|
||||
$featureId = [string]$Feature.FeatureId
|
||||
if ([string]::IsNullOrWhiteSpace($featureId)) {
|
||||
throw 'Selected feature is missing required FeatureId.'
|
||||
}
|
||||
|
||||
return $featureId
|
||||
}
|
||||
|
||||
function Get-RegistryBackedFeatures {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$Features
|
||||
)
|
||||
|
||||
return @($Features | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) })
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
function New-RegistrySettingsBackup {
|
||||
param(
|
||||
[string[]]$ActionableKeys
|
||||
)
|
||||
|
||||
$ActionableKeys = @($ActionableKeys)
|
||||
$selectedFeatures = Get-SelectedFeatures -ActionableKeys $ActionableKeys
|
||||
if (@($selectedFeatures | 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 -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
|
||||
}
|
||||
|
||||
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 = Get-FeatureId -Feature $feature
|
||||
|
||||
if ($selectedFeatureIds.Add($featureId)) {
|
||||
$selectedFeatures.Add($feature)
|
||||
}
|
||||
}
|
||||
|
||||
return @($selectedFeatures.ToArray())
|
||||
}
|
||||
|
||||
function Get-RegistryBackupPayload {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$SelectedFeatures,
|
||||
[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 = Get-FeatureId -Feature $feature
|
||||
|
||||
if ($seenSelectedFeatureIds.Add($featureId)) {
|
||||
$selectedFeatureIds.Add($featureId)
|
||||
}
|
||||
}
|
||||
|
||||
$selectedRegistryFeatures = Get-RegistryBackedFeatures -Features $SelectedFeatures
|
||||
$capturePlans = Get-RegistryBackupCapturePlans -SelectedRegistryFeatures $SelectedRegistryFeatures
|
||||
$registryKeys = @(Get-RegistrySnapshotsForBackup -CapturePlans $capturePlans)
|
||||
|
||||
return @{
|
||||
Version = '1.0'
|
||||
BackupType = 'RegistryState'
|
||||
CreatedAt = $CreatedAt.ToString('o')
|
||||
CreatedBy = 'Win11Debloat'
|
||||
Target = (Get-RegistryBackupTargetDescription)
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
SelectedFeatures = @($selectedFeatureIds)
|
||||
RegistryKeys = @($registryKeys)
|
||||
}
|
||||
}
|
||||
@@ -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 -ErrorAction SilentlyContinue
|
||||
|
||||
# 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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,219 +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
|
||||
}
|
||||
|
||||
$hasRegistryBackedFeature = $false
|
||||
foreach ($paramKey in $actionableKeys) {
|
||||
if (-not $script:Features.ContainsKey($paramKey)) { continue }
|
||||
|
||||
$feature = $script:Features[$paramKey]
|
||||
if ($feature -and -not [string]::IsNullOrWhiteSpace([string]$feature.RegistryKey)) {
|
||||
$hasRegistryBackedFeature = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$totalSteps = $actionableKeys.Count
|
||||
if ($hasRegistryBackedFeature) { $totalSteps++ }
|
||||
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
|
||||
$currentStep = 0
|
||||
|
||||
if ($hasRegistryBackedFeature) {
|
||||
$currentStep++
|
||||
if ($script:ApplyProgressCallback) {
|
||||
& $script:ApplyProgressCallback $currentStep $totalSteps "Creating registry backup"
|
||||
}
|
||||
|
||||
Write-Host "> Creating registry backup..."
|
||||
try {
|
||||
New-RegistrySettingsBackup -ActionableKeys $actionableKeys | Out-Null
|
||||
}
|
||||
catch {
|
||||
throw "Registry backup failed before applying changes. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# 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: use label from Features.json
|
||||
$stepName = $feature.Label
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:ApplyProgressCallback) {
|
||||
& $script:ApplyProgressCallback $currentStep $totalSteps $stepName
|
||||
}
|
||||
|
||||
ExecuteParameter -paramKey $paramKey
|
||||
}
|
||||
}
|
||||
@@ -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 = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports and executes a registry file.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the registry file is applied or previewed successfully; otherwise $false.
|
||||
#>
|
||||
function Import-RegistryFile {
|
||||
param (
|
||||
$message,
|
||||
$path
|
||||
)
|
||||
|
||||
try {
|
||||
Write-Host $message
|
||||
|
||||
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
|
||||
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
|
||||
|
||||
if (-not (Test-Path $regFilePath)) {
|
||||
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
|
||||
Write-Host "Error: $errorMessage" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
$importScript = {
|
||||
param($targetRegFilePath, $hiveContext)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
return (Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath)
|
||||
}
|
||||
|
||||
# When the target user's hive is already loaded under their SID, the .reg file's
|
||||
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
|
||||
# which remaps Default → SID via Split-RegistryPath.
|
||||
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
|
||||
|
||||
if ($usePowerShellFallbackOnly) {
|
||||
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
if ($fallbackSucceeded) {
|
||||
Write-Host "The operation completed successfully via PowerShell registry writer."
|
||||
}
|
||||
return $fallbackSucceeded
|
||||
}
|
||||
|
||||
$regResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($targetRegFilePath)
|
||||
$result = @{
|
||||
Output = @()
|
||||
ExitCode = 0
|
||||
Error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$global:LASTEXITCODE = 0
|
||||
$output = reg import $targetRegFilePath 2>&1
|
||||
$importExitCode = $LASTEXITCODE
|
||||
|
||||
if ($output) {
|
||||
$result.Output = @($output)
|
||||
}
|
||||
$result.ExitCode = $importExitCode
|
||||
|
||||
if ($importExitCode -ne 0) {
|
||||
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$result.Error = $_.Exception.Message
|
||||
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
|
||||
}
|
||||
|
||||
return $result
|
||||
} -ArgumentList $targetRegFilePath
|
||||
|
||||
$regOutput = @($regResult.Output)
|
||||
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
|
||||
|
||||
if ($regOutput) {
|
||||
foreach ($line in $regOutput) {
|
||||
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
|
||||
if ($lineText -and $lineText.Length -gt 0) {
|
||||
if ($hasSuccess) {
|
||||
Write-Host $lineText
|
||||
}
|
||||
else {
|
||||
Write-Host $lineText -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $hasSuccess) {
|
||||
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
|
||||
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
|
||||
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
if ($fallbackSucceeded) {
|
||||
Write-Host "The operation completed successfully via PowerShell registry writer."
|
||||
}
|
||||
return $fallbackSucceeded
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($usesOfflineHive) {
|
||||
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
|
||||
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
|
||||
$succeeded = Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
|
||||
}
|
||||
else {
|
||||
$succeeded = & $importScript $regFilePath $null
|
||||
}
|
||||
return [bool]$succeeded
|
||||
}
|
||||
catch {
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
# Import & execute regfile
|
||||
function ImportRegistryFile {
|
||||
param (
|
||||
$message,
|
||||
$path
|
||||
)
|
||||
|
||||
Write-Host $message
|
||||
|
||||
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
|
||||
$regFilePath = if ($usesOfflineHive) {
|
||||
"$script:RegfilesPath\Sysprep\$path"
|
||||
}
|
||||
else {
|
||||
"$script:RegfilesPath\$path"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $regFilePath)) {
|
||||
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
|
||||
Write-Host "Error: $errorMessage" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
throw $errorMessage
|
||||
}
|
||||
|
||||
# Reset exit code before running reg.exe for reliable success detection
|
||||
$global:LASTEXITCODE = 0
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
$regResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($hivePath, $targetRegFilePath)
|
||||
$result = @{
|
||||
Output = @()
|
||||
ExitCode = 0
|
||||
Error = $null
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if ($output) {
|
||||
$result.Output = @($output)
|
||||
}
|
||||
$result.ExitCode = $importExitCode
|
||||
|
||||
if ($importExitCode -ne 0) {
|
||||
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$result.Error = $_.Exception.Message
|
||||
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
|
||||
}
|
||||
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 registry 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
|
||||
}
|
||||
|
||||
$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
|
||||
Write-Host ""
|
||||
throw $errorMessage
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies a single feature/debloat operation.
|
||||
|
||||
.DESCRIPTION
|
||||
Handles two categories of features:
|
||||
- Registry-backed: imports the .reg file via Import-RegistryFile, then runs
|
||||
any post-import side effects (e.g., removing companion app packages).
|
||||
- Custom logic: app removal, Windows optional features, start menu
|
||||
replacement, and other special-case features. Returns $true when the
|
||||
feature completes successfully; otherwise writes a warning and returns
|
||||
$false.
|
||||
#>
|
||||
function Invoke-FeatureApply {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FeatureId
|
||||
)
|
||||
|
||||
try {
|
||||
# Resolve feature metadata from Features.json
|
||||
$feature = $script:Features[$FeatureId]
|
||||
$applyText = $feature.ApplyText
|
||||
|
||||
# ---- Registry-backed features: import .reg file, then handle additional tasks ----
|
||||
if ($feature.RegistryKey) {
|
||||
if (-not (Import-RegistryFile "> $applyText..." $feature.RegistryKey)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
switch ($FeatureId) {
|
||||
'DisableBing' {
|
||||
# Also remove the app package for Bing search
|
||||
return (Remove-SelectedApps @('Microsoft.BingSearch'))
|
||||
}
|
||||
'DisableCopilot' {
|
||||
# Also remove the app packages for Copilot
|
||||
return (Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX'))
|
||||
}
|
||||
'DisableTelemetry' {
|
||||
# Also disable telemetry scheduled tasks
|
||||
return (Disable-TelemetryScheduledTasks)
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# ---- Custom features (no registry backing, or special handling required) ----
|
||||
switch ($FeatureId) {
|
||||
'RemoveApps' {
|
||||
Write-Host "> $applyText for $(Get-FriendlyTargetUserName)..."
|
||||
$appsList = Generate-AppsList
|
||||
|
||||
if ($appsList.Count -eq 0) {
|
||||
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Host "$($appsList.Count) apps selected for removal"
|
||||
return (Remove-SelectedApps $appsList)
|
||||
}
|
||||
'RemoveGamingApps' {
|
||||
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
|
||||
Write-Host "> $applyText..."
|
||||
return (Remove-SelectedApps $appsList)
|
||||
}
|
||||
'RemoveHPApps' {
|
||||
$appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl')
|
||||
Write-Host "> $applyText..."
|
||||
return (Remove-SelectedApps $appsList)
|
||||
}
|
||||
'ForceRemoveEdge' {
|
||||
Write-Host "> $applyText..."
|
||||
return (Invoke-ForceRemoveEdge)
|
||||
}
|
||||
'DisableWidgets' {
|
||||
Write-Host "> $applyText..."
|
||||
# Stop widgets related processes before removing the app packages to prevent potential issues
|
||||
if (-not $script:Params.ContainsKey("WhatIf")) {
|
||||
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
return (Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'))
|
||||
}
|
||||
'EnableWindowsSandbox' {
|
||||
Write-Host "> $applyText..."
|
||||
return (Enable-WindowsFeature "Containers-DisposableClientVM")
|
||||
}
|
||||
'EnableWindowsSubsystemForLinux' {
|
||||
Write-Host "> $applyText..."
|
||||
if (-not (Enable-WindowsFeature "VirtualMachinePlatform")) { return $false }
|
||||
return (Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux")
|
||||
}
|
||||
'ClearStart' {
|
||||
Write-Host "> $applyText for user $(Get-UserName)..."
|
||||
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
|
||||
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
||||
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile)
|
||||
}
|
||||
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
'ReplaceStart' {
|
||||
Write-Host "> $applyText for user $(Get-UserName)..."
|
||||
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
|
||||
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
||||
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart"))
|
||||
}
|
||||
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
'ClearStartAllUsers' {
|
||||
return (Replace-StartMenuForAllUsers)
|
||||
}
|
||||
'ReplaceStartAllUsers' {
|
||||
return (Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers"))
|
||||
}
|
||||
'DisableStoreSearchSuggestions' {
|
||||
if ($script:Params.ContainsKey("Sysprep")) {
|
||||
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
|
||||
return (Set-StoreSearchSuggestionsDisabledForAllUsers)
|
||||
}
|
||||
|
||||
Write-Host "> Disabling Microsoft Store search suggestions for user $(Get-UserName)..."
|
||||
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||
if ($storeDb) {
|
||||
return (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb)
|
||||
}
|
||||
Write-Warning "Unable to disable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to apply '$applyText': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Warning "Unknown feature '$FeatureId' could not be applied."
|
||||
return $false
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Undoes a single feature.
|
||||
|
||||
.DESCRIPTION
|
||||
Handles registry-backed undo imports and custom undo logic. Returns
|
||||
$true when the requested undo succeeds; otherwise writes a warning and
|
||||
returns $false.
|
||||
#>
|
||||
function Invoke-FeatureUndo {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FeatureId
|
||||
)
|
||||
|
||||
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
|
||||
if (-not $feature) {
|
||||
Write-Warning "Unknown feature '$FeatureId' could not be undone."
|
||||
return $false
|
||||
}
|
||||
|
||||
$undoText = if ($feature.ApplyUndoText) { $feature.ApplyUndoText } elseif ($feature.UndoLabel) { $feature.UndoLabel } else { $FeatureId }
|
||||
|
||||
try {
|
||||
# ---- Registry-backed features: import undo data, then handle additional tasks ----
|
||||
if ($feature.RegistryUndoKey) {
|
||||
if (-not (Import-RegistryFile "> $undoText" (Resolve-UndoRegFilePath $feature.RegistryUndoKey))) {
|
||||
return $false
|
||||
}
|
||||
|
||||
switch ($FeatureId) {
|
||||
'DisableTelemetry' {
|
||||
# Also re-enable telemetry scheduled tasks.
|
||||
return (Enable-TelemetryScheduledTasks)
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
# ---- Custom undo features (no registry backing) ----
|
||||
switch ($FeatureId) {
|
||||
'DisableStoreSearchSuggestions' {
|
||||
if ($script:Params.ContainsKey('Sysprep')) {
|
||||
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
|
||||
return (Set-StoreSearchSuggestionsEnabledForAllUsers)
|
||||
}
|
||||
|
||||
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..."
|
||||
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||
if ($storeDb) {
|
||||
return (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb)
|
||||
}
|
||||
Write-Warning "Unable to re-enable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
'EnableWindowsSandbox' {
|
||||
Write-Host "> $undoText..."
|
||||
return (Disable-WindowsFeature 'Containers-DisposableClientVM')
|
||||
}
|
||||
'EnableWindowsSubsystemForLinux' {
|
||||
Write-Host "> $undoText..."
|
||||
if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false }
|
||||
return (Disable-WindowsFeature 'VirtualMachinePlatform')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
catch {
|
||||
Write-Warning "Failed to undo '$undoText': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Warning "Feature '$FeatureId' does not support undo."
|
||||
return $false
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.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
|
||||
}
|
||||
|
||||
# Compare app-removal failure counts so a feature that only fails due to
|
||||
# app removal isn't also double-reported as a feature failure.
|
||||
$appRemovalFailuresBefore = $script:AppRemovalFailures
|
||||
if ((-not (Invoke-FeatureApply -FeatureId $featureId)) -and ($script:AppRemovalFailures -eq $appRemovalFailuresBefore)) {
|
||||
$script:FeatureFailures++
|
||||
}
|
||||
Write-Host ""
|
||||
$step++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Undoes a list of features, reporting progress for each.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates through the provided feature IDs and delegates each to
|
||||
Invoke-FeatureUndo, which handles registry-backed and custom undo logic.
|
||||
This is called by Invoke-AllChanges during the undo phase.
|
||||
#>
|
||||
function Invoke-UndoFeatures {
|
||||
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 (-not (Invoke-FeatureUndo -FeatureId $featureId)) {
|
||||
$script:FeatureFailures++
|
||||
}
|
||||
Write-Host ""
|
||||
$step++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Main orchestrator: applies and undoes all selected features.
|
||||
|
||||
.DESCRIPTION
|
||||
Sequenced in four phases:
|
||||
1. Registry backup (skipped when SkipRegistryBackup is present)
|
||||
2. System restore point (skipped when CreateRestorePoint is absent)
|
||||
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
|
||||
4. Undo phase - undoes selected features via Invoke-UndoFeatures
|
||||
|
||||
Progress is reported through $script:ApplyProgressCallback when set
|
||||
(used by the GUI modal). Cancellation is checked between each step.
|
||||
#>
|
||||
function Invoke-AllChanges {
|
||||
if ($script:CancelRequested) { return }
|
||||
|
||||
# Guard: prevent running as SYSTEM account without explicit target user
|
||||
$isSystem = Test-RunningAsSystem
|
||||
if ($isSystem -and -not $script:Params.ContainsKey("User") -and -not $script:Params.ContainsKey("Sysprep")) {
|
||||
throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user."
|
||||
}
|
||||
|
||||
$script:AppRemovalFailures = 0
|
||||
$script:FeatureFailures = 0
|
||||
$script:AppRemovalVerificationUnavailable = $false
|
||||
|
||||
# ---- 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 -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ }
|
||||
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
|
||||
$step = 0
|
||||
|
||||
# ================================================================
|
||||
# Phase 1: Registry backup
|
||||
# ================================================================
|
||||
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) {
|
||||
if ($script:CancelRequested) { return }
|
||||
$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")) {
|
||||
if ($script:CancelRequested) { return }
|
||||
$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..."
|
||||
$restorePointSucceeded = Invoke-SystemRestorePoint
|
||||
if (-not $restorePointSucceeded) {
|
||||
if ($script:CancelRequested) { return }
|
||||
$script:FeatureFailures++
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Phase 3: Apply features
|
||||
# ================================================================
|
||||
if ($applyIds.Count -gt 0) {
|
||||
Invoke-ApplyFeatures -FeatureIds $applyIds -StartStep ($step + 1) -TotalSteps $totalSteps
|
||||
$step += $applyIds.Count
|
||||
}
|
||||
|
||||
if ($script:CancelRequested) { return }
|
||||
|
||||
# ================================================================
|
||||
# Phase 4: Undo features
|
||||
# ================================================================
|
||||
if ($undoIds.Count -gt 0) {
|
||||
Invoke-UndoFeatures -FeatureIds $undoIds -StartStep ($step + 1) -TotalSteps $totalSteps
|
||||
$step += $undoIds.Count
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Final: Report failures
|
||||
# ================================================================
|
||||
if ($script:AppRemovalFailures -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details."
|
||||
}
|
||||
|
||||
if ($script:FeatureFailures -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Warning "$($script:FeatureFailures) feature change(s) failed. See output above for details."
|
||||
}
|
||||
|
||||
if ($script:AppRemovalVerificationUnavailable) {
|
||||
Write-Host ""
|
||||
Write-Warning "Unable to verify if all apps were uninstalled successfully."
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether Win11Debloat is running under the SYSTEM account.
|
||||
|
||||
.DESCRIPTION
|
||||
Compares the current Windows identity's security identifier (SID) with
|
||||
the well-known Local System SID (S-1-5-18).
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
Returns $true when the current process runs as SYSTEM; otherwise, $false.
|
||||
#>
|
||||
function Test-RunningAsSystem {
|
||||
return ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18')
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restarts Windows Explorer to apply system changes.
|
||||
|
||||
.DESCRIPTION
|
||||
Restarts the Explorer process to ensure all UI modifications take effect. Shows a warning if any of the applied features require a reboot to take full effect.
|
||||
#>
|
||||
function Invoke-RestartExplorer {
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Restart the Windows Explorer process" -ForegroundColor Cyan
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "> Attempting to restart the Windows Explorer process to apply all changes..."
|
||||
|
||||
if ($script:Params.ContainsKey('SkipExplorerRestart')) {
|
||||
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
$rebootFeatures = Get-RebootFeatureLabels
|
||||
foreach ($displayLabel in $rebootFeatures) {
|
||||
Write-Host "Warning: '$displayLabel' requires a reboot to take full effect" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Only restart if the PowerShell process matches the OS architecture.
|
||||
# Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS
|
||||
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
|
||||
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
|
||||
Stop-Process -processName: Explorer -Force
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to restart Windows Explorer process, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
+29
-12
@@ -1,12 +1,27 @@
|
||||
function CreateSystemRestorePoint {
|
||||
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
|
||||
$failed = $false
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a system restore point.
|
||||
|
||||
if ($SysRestore.RPSessionInterval -eq 0) {
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when a restore point is created; otherwise $false.
|
||||
#>
|
||||
function Invoke-SystemRestorePoint {
|
||||
$failed = $false
|
||||
$isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent
|
||||
|
||||
try {
|
||||
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Host "Error: Unable to determine whether System Restore is enabled: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed = $true
|
||||
}
|
||||
|
||||
if (-not $failed -and $SysRestore.RPSessionInterval -eq 0) {
|
||||
# In GUI mode, skip the prompt and just try to enable it
|
||||
if ($script:GuiWindow -or $Silent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
|
||||
if ($script:GuiWindow -or $isSilent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
|
||||
try {
|
||||
$enableResult = Invoke-NonBlocking -TimeoutSeconds 20 -ScriptBlock {
|
||||
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
|
||||
try {
|
||||
Enable-ComputerRestore -Drive "$env:SystemDrive"
|
||||
return $null
|
||||
@@ -26,14 +41,13 @@ function CreateSystemRestorePoint {
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
@@ -79,17 +93,20 @@ function CreateSystemRestorePoint {
|
||||
|
||||
if ($result -ne "Yes") {
|
||||
$script:CancelRequested = $true
|
||||
return
|
||||
return $false
|
||||
}
|
||||
}
|
||||
elseif (-not $Silent) {
|
||||
elseif (-not $isSilent) {
|
||||
Write-Host "Failed to create a system restore point. Do you want to continue without a restore point? (y/n)" -ForegroundColor Yellow
|
||||
if ($( Read-Host ) -ne 'y') {
|
||||
$script:CancelRequested = $true
|
||||
return
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
+160
-11
@@ -33,12 +33,49 @@ function Get-NormalizedSelectedFeatureIdsFromBackup {
|
||||
$errors.Add('SelectedFeatures must contain non-empty string feature IDs.')
|
||||
}
|
||||
|
||||
if ($selectedFeatures.Count -eq 0) {
|
||||
$errors.Add('SelectedFeatures must contain at least one feature ID.')
|
||||
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]@{
|
||||
SelectedFeatures = $selectedFeatures.ToArray()
|
||||
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
|
||||
Errors = $errors.ToArray()
|
||||
}
|
||||
}
|
||||
@@ -96,6 +133,9 @@ function Test-RegistryBackupMatchesSelectedFeatures {
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]$SelectedFeatureIds,
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]$SelectedUndoFeatureIds,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
@@ -109,18 +149,19 @@ function Test-RegistryBackupMatchesSelectedFeatures {
|
||||
return $errors.ToArray()
|
||||
}
|
||||
|
||||
$selectedRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedFeatureIds) -Errors $errors)
|
||||
$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) {
|
||||
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @($selectedRegistryFeatures) -UseSysprepRegFiles:$useSysprepRegFiles)
|
||||
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 SelectedFeatures.')
|
||||
$errors.Add('Backup contains registry snapshots but no allowed registry paths were derived from the selected features.')
|
||||
}
|
||||
|
||||
foreach ($rootSnapshot in @($RegistryKeys)) {
|
||||
@@ -136,6 +177,8 @@ function Get-SelectedRegistryFeaturesForBackupValidation {
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]$SelectedFeatureIds,
|
||||
[Parameter(Mandatory)]
|
||||
[bool]$IsUndoFeature,
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyCollection()]
|
||||
$Errors
|
||||
)
|
||||
@@ -152,7 +195,26 @@ function Get-SelectedRegistryFeaturesForBackupValidation {
|
||||
}
|
||||
|
||||
$feature = $script:Features[$featureId]
|
||||
if ($feature -and -not [string]::IsNullOrWhiteSpace([string]$feature.RegistryKey)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -186,6 +248,14 @@ function New-RegistryBackupAllowListPlanMap {
|
||||
return $planMap
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts registry value names into a case-insensitive set.
|
||||
|
||||
.DESCRIPTION
|
||||
Preserves empty names and prevents PowerShell from enumerating the returned
|
||||
HashSet.
|
||||
#>
|
||||
function ConvertTo-RegistryValueNameSet {
|
||||
param(
|
||||
[AllowEmptyCollection()]
|
||||
@@ -197,9 +267,18 @@ function ConvertTo-RegistryValueNameSet {
|
||||
$null = $valueNameSet.Add([string]$valueName)
|
||||
}
|
||||
|
||||
return $valueNameSet
|
||||
# Prevent PowerShell from enumerating the HashSet into an array or single string
|
||||
return ,$valueNameSet
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates a registry snapshot against the selected-feature allow list.
|
||||
|
||||
.DESCRIPTION
|
||||
Recursively validates snapshot paths, value names, value kinds, and value
|
||||
data, appending validation errors to the supplied list.
|
||||
#>
|
||||
function Test-RegistrySnapshotAgainstAllowList {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -238,6 +317,9 @@ function Test-RegistrySnapshotAgainstAllowList {
|
||||
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
|
||||
$Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
|
||||
}
|
||||
elseif (-not (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) {
|
||||
$Errors.Add("Backup contains invalid registry data for kind '$kindName' at '$valueReference'.")
|
||||
}
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
|
||||
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
|
||||
@@ -249,6 +331,64 @@ function Test-RegistrySnapshotAgainstAllowList {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether backed-up registry data is valid for its declared value kind.
|
||||
|
||||
.DESCRIPTION
|
||||
Rejects corrupted or hand-edited backup data that cannot be restored safely,
|
||||
such as a DWord that overflows UInt32 or binary data containing an invalid byte.
|
||||
This validation runs before Restore-RegistryKeySnapshot mutates the live
|
||||
registry, preventing a failed conversion from leaving a partially restored key.
|
||||
|
||||
.PARAMETER KindName
|
||||
The declared registry value kind name, such as DWord, QWord, or Binary.
|
||||
|
||||
.PARAMETER Data
|
||||
The backed-up value data to validate against the declared kind.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
function Test-RegistryValueDataMatchesKind {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$KindName,
|
||||
[AllowNull()]
|
||||
$Data
|
||||
)
|
||||
|
||||
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
switch ($kind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) {
|
||||
$parsed = [uint32]0
|
||||
return [uint32]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) {
|
||||
$parsed = [uint64]0
|
||||
return [uint64]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) {
|
||||
if ($null -eq $Data -or $Data -isnot [array]) { return $false }
|
||||
foreach ($item in @($Data)) {
|
||||
if ($item -isnot [ValueType] -and $item -isnot [string]) { return $false }
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse([string]$item, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt 255) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) {
|
||||
foreach ($item in @($Data)) {
|
||||
if ($item -isnot [string]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
default { return ($null -eq $Data -or $Data -is [string]) }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryValueAllowedByPlan {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -323,7 +463,7 @@ function Find-RegistryAllowListPlanMatch {
|
||||
continue
|
||||
}
|
||||
|
||||
$subKeyPrefix = "$($plan.NormalizedPath)\\"
|
||||
$subKeyPrefix = "$($plan.NormalizedPath)\"
|
||||
if ($NormalizedPath.StartsWith($subKeyPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return [PSCustomObject]@{
|
||||
IsDescendant = $true
|
||||
@@ -366,6 +506,14 @@ function Get-NormalizedRegistryPathKey {
|
||||
return "$normalizedHive\\$normalizedSubKey"
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether a registry value-kind name is supported in backups.
|
||||
|
||||
.DESCRIPTION
|
||||
Parses kind names case-insensitively and rejects empty, invalid, Unknown,
|
||||
and None values.
|
||||
#>
|
||||
function Test-RegistryValueKindNameSupported {
|
||||
param(
|
||||
[string]$KindName
|
||||
@@ -377,9 +525,10 @@ function Test-RegistryValueKindNameSupported {
|
||||
|
||||
try {
|
||||
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
return $kind -ne [Microsoft.Win32.RegistryValueKind]::Unknown
|
||||
return $kind -notin @([Microsoft.Win32.RegistryValueKind]::Unknown, [Microsoft.Win32.RegistryValueKind]::None)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
<#
|
||||
.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
|
||||
Replace-StartMenuForAllUsers
|
||||
|
||||
.EXAMPLE
|
||||
Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when all resolved profiles are updated or the change is previewed; otherwise $false.
|
||||
#>
|
||||
function Replace-StartMenuForAllUsers {
|
||||
param (
|
||||
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
||||
)
|
||||
|
||||
Write-Host "> Removing all pinned apps from the start menu for all users..."
|
||||
|
||||
# Check if template bin file exists
|
||||
if (-not (Test-Path $startMenuTemplate)) {
|
||||
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
# Get path to start menu file for all users
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and replace the start menu file
|
||||
$success = $true
|
||||
ForEach ($startMenuPath in $usersStartMenuPaths) {
|
||||
if (-not (Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Also replace the start menu file for the default user profile
|
||||
$defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
# Create folder if it doesn't exist
|
||||
if (-not (Test-Path $defaultStartMenuPath)) {
|
||||
try {
|
||||
New-Item $defaultStartMenuPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
Write-Host "Created LocalState folder for default user profile"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to create the Default profile Start Menu directory: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Copy template to default profile
|
||||
if (-not (Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
|
||||
$success = $false
|
||||
}
|
||||
else {
|
||||
Write-Host "Replaced start menu for the default user profile"
|
||||
}
|
||||
return $success
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.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
|
||||
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
|
||||
.EXAMPLE
|
||||
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the template is valid and copied, or the change is previewed; otherwise $false.
|
||||
#>
|
||||
function Replace-StartMenu {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$startMenuBinFile,
|
||||
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
||||
)
|
||||
|
||||
# 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 $false
|
||||
}
|
||||
|
||||
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") {
|
||||
Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$backupFileName = "Win11Debloat-StartBackup-$timestamp.bak"
|
||||
$startMenuDir = Split-Path $startMenuBinFile -Parent
|
||||
$backupBinFile = Join-Path $startMenuDir $backupFileName
|
||||
|
||||
try {
|
||||
if (Test-Path $startMenuBinFile) {
|
||||
# Backup current start menu file
|
||||
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force -ErrorAction Stop
|
||||
Write-Verbose "Start menu backup for user $userName saved to $backupFileName"
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow
|
||||
New-Item -ItemType File -Path $startMenuBinFile -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
# Copy template file
|
||||
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to replace Start Menu for user ${userName}: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Replaced start menu for user $userName"
|
||||
return $true
|
||||
}
|
||||
|
||||
<#
|
||||
.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
|
||||
Get-StartMenuBinPathForUser -UserName "Jeff"
|
||||
|
||||
.EXAMPLE
|
||||
Get-StartMenuBinPathForUser -UserName "Default"
|
||||
#>
|
||||
function Get-StartMenuBinPathForUser {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
return "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
}
|
||||
|
||||
return (Get-UserDirectory -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
|
||||
Get-StartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
#>
|
||||
function Get-StartMenuUserNameFromPath {
|
||||
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 = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
foreach ($startMenuPath in $usersStartMenuPaths) {
|
||||
$latestBackup = Get-ChildItem -Path (Join-Path $startMenuPath.FullName 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue |
|
||||
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
|
||||
Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
|
||||
.EXAMPLE
|
||||
Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
#>
|
||||
function Restore-StartMenuFromBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StartMenuBinFile,
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
||||
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
|
||||
# Auto-detect latest backup in the same folder as the start2.bin
|
||||
$startMenuDir = Split-Path $StartMenuBinFile -Parent
|
||||
$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 Restore-StartMenuFromBackup.
|
||||
|
||||
.PARAMETER BackupFilePath
|
||||
Path to the backup file to restore from. If omitted, automatically
|
||||
finds the latest Win11Debloat-StartBackup-*.bak file.
|
||||
|
||||
.EXAMPLE
|
||||
Restore-StartMenu
|
||||
|
||||
.EXAMPLE
|
||||
Restore-StartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
#>
|
||||
function Restore-StartMenu {
|
||||
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 Restore-StartMenuFromBackup -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
|
||||
Restore-StartMenuForAllUsers
|
||||
|
||||
.EXAMPLE
|
||||
Restore-StartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
#>
|
||||
function Restore-StartMenuForAllUsers {
|
||||
param(
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userPathString = Get-UserDirectory -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 += Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
}
|
||||
|
||||
$defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
|
||||
if (Test-Path $defaultStartMenuPath) {
|
||||
$defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin'
|
||||
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
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
# 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/
|
||||
function ReplaceStartMenuForAllUsers {
|
||||
param (
|
||||
$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
||||
)
|
||||
|
||||
Write-Host "> Removing all pinned apps from the start menu for all users..."
|
||||
|
||||
# Check if template bin file exists
|
||||
if (-not (Test-Path $startMenuTemplate)) {
|
||||
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
# 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 -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and replace the start menu file
|
||||
ForEach ($startMenuPath in $usersStartMenuPaths) {
|
||||
ReplaceStartMenu $startMenuTemplate "$($startMenuPath.Fullname)\start2.bin"
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# Create folder if it doesn't exist
|
||||
if (-not (Test-Path $defaultStartMenuPath)) {
|
||||
new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null
|
||||
Write-Host "Created LocalState folder for default user profile"
|
||||
}
|
||||
|
||||
# Copy template to default profile
|
||||
Copy-Item -Path $startMenuTemplate -Destination $defaultStartMenuPath -Force
|
||||
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/
|
||||
function ReplaceStartMenu {
|
||||
param (
|
||||
$startMenuTemplate = "$script:AssetsPath\Start\start2.bin",
|
||||
$startMenuBinFile = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
)
|
||||
|
||||
# Change path to correct user if a user was specified
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
|
||||
}
|
||||
|
||||
# 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" ) {
|
||||
Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
|
||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
|
||||
|
||||
$backupBinFile = $startMenuBinFile + ".bak"
|
||||
|
||||
if (Test-Path $startMenuBinFile) {
|
||||
# Backup current start menu file
|
||||
Move-Item -Path $startMenuBinFile -Destination $backupBinFile -Force
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to find original start2.bin file for user $userName, no backup was created for this user" -ForegroundColor Yellow
|
||||
New-Item -ItemType File -Path $startMenuBinFile -Force
|
||||
}
|
||||
|
||||
# Copy template file
|
||||
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force
|
||||
|
||||
Write-Host "Replaced start menu for user $userName"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function GetStartMenuUserNameFromPath {
|
||||
param(
|
||||
[string]$StartMenuBinFile
|
||||
)
|
||||
|
||||
$resolvedUserName = [regex]::Match($StartMenuBinFile, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
|
||||
if ([string]::IsNullOrWhiteSpace($resolvedUserName)) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
return $resolvedUserName
|
||||
}
|
||||
|
||||
|
||||
|
||||
function RestoreStartMenuFromBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StartMenuBinFile,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
||||
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
|
||||
$StartMenuBinFile + '.bak'
|
||||
}
|
||||
else {
|
||||
$BackupFilePath
|
||||
}
|
||||
$currentBinBackup = $StartMenuBinFile + '.restore.bak'
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function RestoreStartMenu {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$targetUserName = GetUserName
|
||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName $targetUserName
|
||||
|
||||
Write-Host "Restoring start menu for user $targetUserName from backup..."
|
||||
|
||||
return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
}
|
||||
|
||||
function RestoreStartMenuForAllUsers {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[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) {
|
||||
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
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Restart the Windows Explorer process
|
||||
function RestartExplorer {
|
||||
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")) {
|
||||
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($paramKey in $script:Params.Keys) {
|
||||
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
|
||||
$feature = $script:Features[$paramKey]
|
||||
Write-Host "Warning: '$($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Only restart if the powershell process matches the OS architecture.
|
||||
# Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS
|
||||
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
|
||||
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
|
||||
Stop-Process -processName: Explorer -Force
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to restart Windows Explorer process, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs a script block against the registry hive for a backup target.
|
||||
|
||||
.PARAMETER Target
|
||||
A supported backup target: DefaultUserProfile or User:<user name>.
|
||||
|
||||
.PARAMETER ScriptBlock
|
||||
The operation to run after the target user hive is available.
|
||||
|
||||
.PARAMETER ArgumentObject
|
||||
Optional object passed to the script block.
|
||||
#>
|
||||
function Invoke-WithLoadedRestoreHive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock,
|
||||
$ArgumentObject = $null
|
||||
)
|
||||
|
||||
$targetUserName = if ($Target -eq 'DefaultUserProfile') {
|
||||
'Default'
|
||||
}
|
||||
elseif ($Target -like 'User:*') {
|
||||
$userName = $Target.Substring(5)
|
||||
if ([string]::IsNullOrWhiteSpace($userName)) {
|
||||
throw 'Invalid backup target format for user restore.'
|
||||
}
|
||||
$userName
|
||||
}
|
||||
else {
|
||||
throw "Unsupported backup target '$Target'."
|
||||
}
|
||||
|
||||
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restores a registry key and its child keys from a backup snapshot.
|
||||
|
||||
.PARAMETER Snapshot
|
||||
The saved registry-key state, including existence, values, and subkeys.
|
||||
#>
|
||||
function Restore-RegistryKeySnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
$registryParts = Split-RegistryPath -path $Snapshot.Path
|
||||
if (-not $registryParts) {
|
||||
throw "Unsupported registry path in backup: $($Snapshot.Path)"
|
||||
}
|
||||
|
||||
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
|
||||
if (-not $rootKey) {
|
||||
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
|
||||
}
|
||||
|
||||
$subKeyPath = $registryParts.SubKey
|
||||
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
|
||||
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
|
||||
}
|
||||
|
||||
Test-RegistryKeySnapshotCanBeRestored -Snapshot $Snapshot
|
||||
Restore-RegistryKeySnapshotAtPath -Snapshot $Snapshot -RootKey $rootKey -SubKeyPath $subKeyPath
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates registry values and subkey paths in a snapshot before live registry state is changed.
|
||||
|
||||
.PARAMETER Snapshot
|
||||
The registry key snapshot to validate before it is restored.
|
||||
#>
|
||||
function Test-RegistryKeySnapshotCanBeRestored {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
if (-not [bool]$Snapshot.Exists) { return }
|
||||
|
||||
$childNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($valueSnapshot in @($Snapshot.Values)) {
|
||||
if ([bool]$valueSnapshot.Exists) {
|
||||
$valueKind = Convert-RegistryValueKindFromBackup -KindName $valueSnapshot.Kind
|
||||
$null = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $valueSnapshot.Data
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
|
||||
$childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path
|
||||
if ([string]::IsNullOrWhiteSpace($childName) -or -not $childNames.Add($childName)) {
|
||||
throw "Backup contains duplicate or unsupported registry child path: $($subKeySnapshot.Path)"
|
||||
}
|
||||
Test-RegistryKeySnapshotCanBeRestored -Snapshot $subKeySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a snapshot child's name only when it is directly below its parent.
|
||||
|
||||
.PARAMETER ParentPath
|
||||
The registry path of the expected parent snapshot.
|
||||
|
||||
.PARAMETER ChildPath
|
||||
The registry path of the child snapshot to validate.
|
||||
#>
|
||||
function Get-DirectRegistrySnapshotChildName {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ParentPath,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ChildPath
|
||||
)
|
||||
|
||||
$parentParts = Split-RegistryPath -path $ParentPath
|
||||
$childParts = Split-RegistryPath -path $ChildPath
|
||||
if (-not $parentParts -or -not $childParts -or
|
||||
-not $parentParts.Hive.Equals($childParts.Hive, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
[string]::IsNullOrWhiteSpace($parentParts.SubKey) -or
|
||||
[string]::IsNullOrWhiteSpace($childParts.SubKey)) {
|
||||
throw "Unsupported registry child path in backup: $ChildPath"
|
||||
}
|
||||
|
||||
$childName = Split-Path -Path $childParts.SubKey -Leaf
|
||||
$expectedSubKey = "$($parentParts.SubKey)\$childName"
|
||||
if ([string]::IsNullOrWhiteSpace($childName) -or
|
||||
-not $childParts.SubKey.Equals($expectedSubKey, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Registry child path '$ChildPath' is not directly below parent '$ParentPath'."
|
||||
}
|
||||
|
||||
return $childName
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restores a snapshot to a specific path below an already resolved registry root.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes only values and descendants represented by the backup. Existing keys are
|
||||
retained so their security descriptors and unrelated data are not destroyed.
|
||||
#>
|
||||
function Restore-RegistryKeySnapshotAtPath {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot,
|
||||
[Parameter(Mandatory)]
|
||||
$RootKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SubKeyPath
|
||||
)
|
||||
|
||||
if (-not $Snapshot.Exists) {
|
||||
Remove-RegistrySubKeyTreeIfExists -RootKey $RootKey -SubKeyPath $SubKeyPath
|
||||
return
|
||||
}
|
||||
|
||||
$key = $RootKey.CreateSubKey($SubKeyPath)
|
||||
if ($null -eq $key) {
|
||||
throw "Unable to create or open registry key '$($Snapshot.Path)'"
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($valueSnapshot in @($Snapshot.Values)) {
|
||||
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot $valueSnapshot
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$key.Close()
|
||||
}
|
||||
|
||||
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
|
||||
$childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path
|
||||
|
||||
Restore-RegistryKeySnapshotAtPath -Snapshot $subKeySnapshot -RootKey $RootKey -SubKeyPath "$SubKeyPath\$childName"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restores or removes a registry value from a backup snapshot.
|
||||
|
||||
.PARAMETER RegistryKey
|
||||
The open registry key that contains the value.
|
||||
|
||||
.PARAMETER Snapshot
|
||||
The saved registry-value state to apply.
|
||||
#>
|
||||
function Restore-RegistryValueSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
$valueName = if ($null -ne $Snapshot.Name) { [string]$Snapshot.Name } else { '' }
|
||||
|
||||
if (-not [bool]$Snapshot.Exists) {
|
||||
try {
|
||||
$RegistryKey.DeleteValue($valueName, $false)
|
||||
}
|
||||
catch {
|
||||
throw "Failed deleting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$valueKind = Convert-RegistryValueKindFromBackup -KindName $Snapshot.Kind
|
||||
$normalizedData = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $Snapshot.Data
|
||||
|
||||
try {
|
||||
$RegistryKey.SetValue($valueName, $normalizedData, $valueKind)
|
||||
}
|
||||
catch {
|
||||
throw "Failed setting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a backed-up registry value-kind name to its .NET enum value.
|
||||
|
||||
.PARAMETER KindName
|
||||
The registry value-kind name stored in the backup.
|
||||
|
||||
.OUTPUTS
|
||||
Microsoft.Win32.RegistryValueKind
|
||||
#>
|
||||
function Convert-RegistryValueKindFromBackup {
|
||||
param(
|
||||
[string]$KindName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($KindName)) {
|
||||
return [Microsoft.Win32.RegistryValueKind]::String
|
||||
}
|
||||
|
||||
try {
|
||||
return [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
}
|
||||
catch {
|
||||
throw "Unsupported registry value kind in backup: $KindName"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts backed-up data to a value suitable for registry restoration.
|
||||
|
||||
.PARAMETER Kind
|
||||
The registry value kind that determines how the data is converted.
|
||||
|
||||
.PARAMETER Data
|
||||
The serialized value data from the backup.
|
||||
#>
|
||||
function Convert-RegistryValueDataFromBackup {
|
||||
param(
|
||||
[Microsoft.Win32.RegistryValueKind]$Kind,
|
||||
$Data
|
||||
)
|
||||
|
||||
switch ($Kind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) {
|
||||
$unsigned = [uint32]$Data
|
||||
return [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) {
|
||||
$unsigned = [uint64]$Data
|
||||
return [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) { return ,([string[]]@($Data | ForEach-Object { [string]$_ })) }
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) {
|
||||
if ($null -eq $Data) {
|
||||
return ,(New-Object byte[] 0)
|
||||
}
|
||||
|
||||
$bytes = Convert-BackupDataToByteArray -Data $Data
|
||||
if ($null -eq $bytes) {
|
||||
throw 'Invalid binary registry data in backup. Expected byte values from 0 through 255.'
|
||||
}
|
||||
# Keep the byte array intact instead of writing each byte to the
|
||||
# pipeline. RegistryKey.SetValue requires a byte[] for Binary.
|
||||
return ,$bytes
|
||||
}
|
||||
default {
|
||||
if ($null -ne $Data) {
|
||||
return [string]$Data
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts serialized binary backup data to a byte array.
|
||||
|
||||
.PARAMETER Data
|
||||
A byte array or collection of integer byte values from the backup.
|
||||
|
||||
.OUTPUTS
|
||||
System.Byte[]
|
||||
Returns $null when the input contains invalid byte data.
|
||||
#>
|
||||
function Convert-BackupDataToByteArray {
|
||||
param(
|
||||
$Data
|
||||
)
|
||||
|
||||
if ($null -eq $Data) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Data -is [byte[]]) {
|
||||
return ,$Data
|
||||
}
|
||||
|
||||
$items = @($Data)
|
||||
if ($items.Count -eq 0) {
|
||||
return ,(New-Object byte[] 0)
|
||||
}
|
||||
|
||||
foreach ($item in $items) {
|
||||
if ($item -isnot [ValueType] -and $item -isnot [string]) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse([string]$item, [ref]$parsed)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($parsed -lt 0 -or $parsed -gt 255) {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
$bytes = New-Object byte[] $items.Count
|
||||
for ($i = 0; $i -lt $items.Count; $i++) {
|
||||
$bytes[$i] = [byte][int]$items[$i]
|
||||
}
|
||||
|
||||
return ,$bytes
|
||||
}
|
||||
+88
-9
@@ -1,4 +1,20 @@
|
||||
function Load-RegistryBackupFromFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads a registry backup from a JSON file and normalizes its contents.
|
||||
|
||||
.DESCRIPTION
|
||||
Loads a registry backup from disk and returns a normalized representation
|
||||
of its contents suitable for use by the restore workflow. Throws if the
|
||||
file is missing, unreadable, or not valid JSON.
|
||||
|
||||
.PARAMETER FilePath
|
||||
The absolute path to the registry backup JSON file to load.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject
|
||||
A normalized registry backup object produced by ConvertTo-NormalizedRegistryBackup.
|
||||
#>
|
||||
function Import-RegistryBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FilePath
|
||||
@@ -15,10 +31,28 @@ function Load-RegistryBackupFromFile {
|
||||
throw "Failed to read backup file '$FilePath'. The file is not valid JSON."
|
||||
}
|
||||
|
||||
return Normalize-RegistryBackup -Backup $rawBackup
|
||||
return ConvertTo-NormalizedRegistryBackup -Backup $rawBackup
|
||||
}
|
||||
|
||||
function Normalize-RegistryBackup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates and normalizes a raw registry backup object.
|
||||
|
||||
.DESCRIPTION
|
||||
Validates the structure and content of the supplied backup and converts
|
||||
it into a normalized representation that can be safely consumed by the
|
||||
restore workflow. Throws if validation fails.
|
||||
|
||||
.PARAMETER Backup
|
||||
The raw backup object (typically parsed from JSON) to normalize.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject
|
||||
A normalized backup with Version, BackupType, CreatedAt, CreatedBy,
|
||||
ComputerName, Target, SelectedFeatures, SelectedUndoFeatures, and
|
||||
RegistryKeys properties.
|
||||
#>
|
||||
function ConvertTo-NormalizedRegistryBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Backup
|
||||
@@ -59,7 +93,11 @@ function Normalize-RegistryBackup {
|
||||
}
|
||||
elseif ($normalizedTarget -like 'CurrentUser:*') {
|
||||
$targetCurrentUserName = $normalizedTarget.Substring(12)
|
||||
if ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or ($targetCurrentUserName -ne $env:USERNAME)) {
|
||||
if (Test-RunningAsSystem) {
|
||||
$errors.Add("Backup was made for '$targetCurrentUserName' and is user-scoped. Re-run as that user; SYSTEM cannot restore a CurrentUser backup.")
|
||||
}
|
||||
elseif ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or
|
||||
-not (Test-UserNameMatch -UserNameA $targetCurrentUserName -UserNameB $env:USERNAME)) {
|
||||
$errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$env:USERNAME'.")
|
||||
}
|
||||
}
|
||||
@@ -87,9 +125,26 @@ function Normalize-RegistryBackup {
|
||||
$errors.Add([string]$selectedFeatureParseError)
|
||||
}
|
||||
|
||||
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -Target $normalizedTarget -RegistryKeys @($normalizedKeys))
|
||||
foreach ($allowListValidationError in $allowListValidationErrors) {
|
||||
$errors.Add([string]$allowListValidationError)
|
||||
$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.')
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -SelectedUndoFeatureIds @($selectedUndoFeatures) -Target $normalizedTarget -RegistryKeys @($normalizedKeys))
|
||||
foreach ($allowListValidationError in $allowListValidationErrors) {
|
||||
$errors.Add([string]$allowListValidationError)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$errors.Add("Failed to validate backup: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors.Count -gt 0) {
|
||||
@@ -110,17 +165,40 @@ function Normalize-RegistryBackup {
|
||||
ComputerName = [string]$Backup.ComputerName
|
||||
Target = $normalizedTarget
|
||||
SelectedFeatures = @($selectedFeatures)
|
||||
SelectedUndoFeatures = @($selectedUndoFeatures)
|
||||
RegistryKeys = @($normalizedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restores registry state from a normalized backup object.
|
||||
|
||||
.DESCRIPTION
|
||||
Applies the registry state described by the supplied backup back to the
|
||||
registry, loading the appropriate user hive when required.
|
||||
|
||||
.PARAMETER Backup
|
||||
A normalized backup object (as produced by ConvertTo-NormalizedRegistryBackup) whose
|
||||
RegistryKeys snapshots should be restored.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject
|
||||
Returns an object with a Result property set to $true when the restore
|
||||
completes successfully.
|
||||
#>
|
||||
function Restore-RegistryBackupState {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Backup
|
||||
)
|
||||
|
||||
$friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
||||
$friendlyTarget = Get-FriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Restore registry backup for $friendlyTarget" -ForegroundColor Cyan
|
||||
return [PSCustomObject]@{ Result = $true }
|
||||
}
|
||||
|
||||
$restoreAction = {
|
||||
param($normalizedBackup)
|
||||
@@ -137,9 +215,10 @@ function Restore-RegistryBackupState {
|
||||
Write-Host "Restore requires loading target user hive."
|
||||
Invoke-WithLoadedRestoreHive -Target $Backup.Target -ScriptBlock $restoreAction -ArgumentObject $Backup
|
||||
Write-Host "Restore completed for $friendlyTarget."
|
||||
return
|
||||
return [PSCustomObject]@{ Result = $true }
|
||||
}
|
||||
|
||||
& $restoreAction $Backup
|
||||
Write-Host "Restore completed for $friendlyTarget."
|
||||
return [PSCustomObject]@{ Result = $true }
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
function Invoke-WithLoadedRestoreHive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock,
|
||||
$ArgumentObject = $null
|
||||
)
|
||||
|
||||
$hiveDatPath = if ($Target -eq 'DefaultUserProfile') {
|
||||
GetUserDirectory -userName 'Default' -fileName 'NTUSER.DAT'
|
||||
}
|
||||
elseif ($Target -like 'User:*') {
|
||||
$userName = $Target.Substring(5)
|
||||
if ([string]::IsNullOrWhiteSpace($userName)) {
|
||||
throw 'Invalid backup target format for user restore.'
|
||||
}
|
||||
GetUserDirectory -userName $userName -fileName 'NTUSER.DAT'
|
||||
}
|
||||
else {
|
||||
throw "Unsupported backup target '$Target'."
|
||||
}
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
reg load 'HKU\Default' "$hiveDatPath" | Out-Null
|
||||
$loadExitCode = $LASTEXITCODE
|
||||
if ($loadExitCode -ne 0) {
|
||||
throw "Failed to load target user hive '$hiveDatPath' (exit code: $loadExitCode)."
|
||||
}
|
||||
|
||||
try {
|
||||
& $ScriptBlock $ArgumentObject
|
||||
}
|
||||
finally {
|
||||
$global:LASTEXITCODE = 0
|
||||
reg unload 'HKU\Default' | Out-Null
|
||||
$unloadExitCode = $LASTEXITCODE
|
||||
if ($unloadExitCode -ne 0) {
|
||||
throw "Failed to unload registry hive 'HKU\Default' (exit code: $unloadExitCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<#
|
||||
.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
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
|
||||
#>
|
||||
function Set-StoreSearchSuggestionsDisabledForAllUsers {
|
||||
$success = $true
|
||||
$processedProfiles = 0
|
||||
|
||||
# Get path to Store app database for all users
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and disable start search suggestions
|
||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Also disable start search suggestions for the default user profile
|
||||
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
if ($defaultStoreDbPath) {
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath)) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($processedProfiles -eq 0) {
|
||||
Write-Warning 'Unable to disable Microsoft Store search suggestions because no target user profiles could be resolved.'
|
||||
return $false
|
||||
}
|
||||
|
||||
return $success
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
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"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the database ACL is restricted or previewed; otherwise $false.
|
||||
#>
|
||||
function Set-StoreSearchSuggestionsDisabled {
|
||||
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 $true
|
||||
}
|
||||
|
||||
try {
|
||||
# This file doesn't exist in EEA (No Store app suggestions).
|
||||
if (-not (Test-Path -Path $StoreAppsDatabase)) {
|
||||
Write-Host "Unable to find Store app database for user $userName, creating it now to prevent Windows from creating it later..." -ForegroundColor Yellow
|
||||
|
||||
$storeDbDir = Split-Path -Path $StoreAppsDatabase -Parent
|
||||
if (-not (Test-Path -Path $storeDbDir)) {
|
||||
New-Item -Path $storeDbDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
New-Item -Path $StoreAppsDatabase -ItemType File -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
$AccountSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
|
||||
$Acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
|
||||
$Ace = [System.Security.AccessControl.FileSystemAccessRule]::new($AccountSid, 'FullControl', 'Deny')
|
||||
$Acl.SetAccessRule($Ace) | Out-Null
|
||||
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to restrict ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Disabled Microsoft Store search suggestions for user $userName"
|
||||
return $true
|
||||
}
|
||||
|
||||
<#
|
||||
.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
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
|
||||
#>
|
||||
function Set-StoreSearchSuggestionsEnabledForAllUsers {
|
||||
$success = $true
|
||||
$processedProfiles = 0
|
||||
|
||||
# Get path to Store app database for all users
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and re-enable start search suggestions
|
||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Also re-enable for the default user profile
|
||||
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
if ($defaultStoreDbPath) {
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath)) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($processedProfiles -eq 0) {
|
||||
Write-Warning 'Unable to re-enable Microsoft Store search suggestions because no target user profiles could be resolved.'
|
||||
return $false
|
||||
}
|
||||
|
||||
return $success
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Re-enables Microsoft Store search suggestions for a single user.
|
||||
|
||||
.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"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the deny ACL is removed, the database is absent, or the change is previewed; otherwise $false.
|
||||
#>
|
||||
function Set-StoreSearchSuggestionsEnabled {
|
||||
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 $true
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $StoreAppsDatabase)) {
|
||||
Write-Host "Store app database not found for user $userName, nothing to undo"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Ensure we can modify/delete the file even if restrictive ACLs were set.
|
||||
$global:LASTEXITCODE = 0
|
||||
takeown /F "$StoreAppsDatabase" /A | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Failed to take ownership of store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
|
||||
return $false
|
||||
}
|
||||
icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Failed to grant Administrators access to store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
|
||||
return $false
|
||||
}
|
||||
|
||||
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
|
||||
|
||||
try {
|
||||
$acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
|
||||
$denyRules = @(
|
||||
$acl.Access | Where-Object {
|
||||
if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false }
|
||||
if (($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq 0) { return $false }
|
||||
try {
|
||||
return ($_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($denyRule in $denyRules) {
|
||||
$null = $acl.RemoveAccessRuleSpecific($denyRule)
|
||||
}
|
||||
|
||||
Set-Acl -Path $StoreAppsDatabase -AclObject $acl -ErrorAction Stop | 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"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.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
|
||||
Get-StoreAppsDatabasePathForUser -UserName "Jeff"
|
||||
|
||||
.EXAMPLE
|
||||
Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
#>
|
||||
function Get-StoreAppsDatabasePathForUser {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
return "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
||||
}
|
||||
|
||||
return (Get-UserDirectory -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 = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||
$paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
|
||||
}
|
||||
|
||||
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# 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
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false.
|
||||
#>
|
||||
function Disable-TelemetryScheduledTasks {
|
||||
Write-Host "> Disabling telemetry scheduled tasks..."
|
||||
$tasks = Get-TelemetryScheduledTasks
|
||||
|
||||
$success = $true
|
||||
foreach ($task in $tasks) {
|
||||
if ($script:CancelRequested) { return $false }
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($path, $name)
|
||||
try {
|
||||
Import-Module ScheduledTasks -ErrorAction Stop
|
||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
|
||||
}
|
||||
if (-not $taskObj) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
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)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
|
||||
$success = $false
|
||||
continue
|
||||
}
|
||||
|
||||
switch ($result.Status) {
|
||||
'Disabled' { Write-Host "Disabled Scheduled Task: $($task.Path)$($task.Name)" }
|
||||
'AlreadyDisabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already disabled" -ForegroundColor DarkGray }
|
||||
'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found" -ForegroundColor DarkGray }
|
||||
'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
|
||||
default { Write-Warning "Unable to determine the result of disabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
|
||||
}
|
||||
}
|
||||
|
||||
return $success
|
||||
}
|
||||
|
||||
<#
|
||||
.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
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false.
|
||||
#>
|
||||
function Enable-TelemetryScheduledTasks {
|
||||
Write-Host "> Enabling telemetry scheduled tasks..."
|
||||
$tasks = Get-TelemetryScheduledTasks
|
||||
|
||||
$success = $true
|
||||
foreach ($task in $tasks) {
|
||||
if ($script:CancelRequested) { return $false }
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($path, $name)
|
||||
try {
|
||||
Import-Module ScheduledTasks -ErrorAction Stop
|
||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
|
||||
}
|
||||
if (-not $taskObj) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
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)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($_.Exception.Message)"
|
||||
$success = $false
|
||||
continue
|
||||
}
|
||||
|
||||
switch ($result.Status) {
|
||||
'Enabled' { Write-Host "Enabled Scheduled Task: $($task.Path)$($task.Name)" }
|
||||
'AlreadyEnabled' { Write-Host "Scheduled Task $($task.Path)$($task.Name) is already enabled." -ForegroundColor DarkGray }
|
||||
'NotFound' { Write-Host "Scheduled Task $($task.Path)$($task.Name) not found." -ForegroundColor DarkGray }
|
||||
'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
|
||||
default { Write-Warning "Unable to determine the result of enabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
|
||||
}
|
||||
}
|
||||
|
||||
return $success
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enables a Windows optional feature and pipes its output to the console.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when enabling succeeds or is previewed; otherwise $false.
|
||||
#>
|
||||
function Enable-WindowsFeature {
|
||||
param (
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($name)
|
||||
try {
|
||||
$output = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -ErrorAction Stop
|
||||
return [PSCustomObject]@{
|
||||
Success = $true
|
||||
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
|
||||
Error = $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Output = $null
|
||||
Error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
} -ArgumentList $FeatureName
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to enable Windows feature '$FeatureName': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not $result -or -not $result.Success) {
|
||||
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
|
||||
Write-Warning "Failed to enable Windows feature '$FeatureName'$details"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($result.Output) { Write-Host $result.Output }
|
||||
return $true
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Disables a Windows optional feature and pipes its output to the console.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when disabling succeeds or is previewed; otherwise $false.
|
||||
#>
|
||||
function Disable-WindowsFeature {
|
||||
param (
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($name)
|
||||
try {
|
||||
$output = Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart -ErrorAction Stop
|
||||
return [PSCustomObject]@{
|
||||
Success = $true
|
||||
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
|
||||
Error = $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Output = $null
|
||||
Error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
} -ArgumentList $FeatureName
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to disable Windows feature '$FeatureName': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not $result -or -not $result.Success) {
|
||||
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
|
||||
Write-Warning "Failed to disable Windows feature '$FeatureName'$details"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($result.Output) { Write-Host $result.Output }
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-WindowsOptionalFeatureEnabled {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
try {
|
||||
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
return ($feature.State -eq 'Enabled')
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
# Returns a validated list of apps based on the provided appsList and the supported apps from Apps.json
|
||||
function ValidateAppslist {
|
||||
function Get-ValidatedAppList {
|
||||
param (
|
||||
$appsList
|
||||
)
|
||||
|
||||
$supportedAppsList = @(LoadAppsDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }
|
||||
$supportedAppsList = @(Import-AppDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }
|
||||
$validatedAppsList = @()
|
||||
|
||||
# Validate provided appsList against supportedAppsList
|
||||
+39
-7
@@ -1,8 +1,30 @@
|
||||
# Read Apps.json and return list of app objects with optional filtering
|
||||
function LoadAppsDetailsFromJson {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads application details from Apps.json.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads the application definitions from Apps.json, optionally filters the
|
||||
results to installed applications, and returns normalized app objects for
|
||||
display and selection.
|
||||
|
||||
.PARAMETER OnlyInstalled
|
||||
Filters the results to applications detected through Appx or the supplied
|
||||
winget installation list.
|
||||
|
||||
.PARAMETER InstalledList
|
||||
A pre-fetched winget installation list used when filtering installed apps.
|
||||
|
||||
.PARAMETER InitialCheckedFromJson
|
||||
Sets each returned app's IsChecked value from its SelectedByDefault setting.
|
||||
|
||||
.OUTPUTS
|
||||
System.Management.Automation.PSCustomObject[]
|
||||
Application detail objects containing display, selection, and removal data.
|
||||
#>
|
||||
function Import-AppDetailsFromJson {
|
||||
param (
|
||||
[switch]$OnlyInstalled,
|
||||
[string]$InstalledList = "",
|
||||
[object[]]$InstalledList = $null,
|
||||
[switch]$InitialCheckedFromJson
|
||||
)
|
||||
|
||||
@@ -17,22 +39,31 @@ function LoadAppsDetailsFromJson {
|
||||
|
||||
foreach ($appData in $jsonContent.Apps) {
|
||||
# Handle AppId as array (could be single or multiple IDs)
|
||||
$appIdArray = if ($appData.AppId -is [array]) { $appData.AppId } else { @($appData.AppId) }
|
||||
$appIdArray = $appIdArray | ForEach-Object { $_.Trim() } | Where-Object { $_.length -gt 0 }
|
||||
$appIdArray = @(
|
||||
foreach ($rawAppId in @($appData.AppId)) {
|
||||
if ($rawAppId -isnot [string]) { continue }
|
||||
$normalizedAppId = $rawAppId.Trim()
|
||||
if ($normalizedAppId.Length -gt 0) { $normalizedAppId }
|
||||
}
|
||||
)
|
||||
if ($appIdArray.Count -eq 0) { continue }
|
||||
|
||||
if ($OnlyInstalled) {
|
||||
$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 +83,7 @@ function LoadAppsDetailsFromJson {
|
||||
Description = $appData.Description
|
||||
SelectedByDefault = $appData.SelectedByDefault
|
||||
Recommendation = $appData.Recommendation
|
||||
RemovalMethod = if ($appData.RemovalMethod -and $appData.RemovalMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
# Read Apps.json and return the list of preset objects (Name + AppIds).
|
||||
# Returns an empty array if the file cannot be read or contains no presets.
|
||||
function LoadAppPresetsFromJson {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns preset names and application IDs from Apps.json, or an empty array when unavailable.
|
||||
#>
|
||||
function Import-AppPresetsFromJson {
|
||||
try {
|
||||
$jsonContent = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<#
|
||||
.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 Import-AppsFromFile {
|
||||
param (
|
||||
$appsFilePath
|
||||
)
|
||||
|
||||
$appsList = @()
|
||||
|
||||
if (-not (Test-Path $appsFilePath)) {
|
||||
return $appsList
|
||||
}
|
||||
|
||||
try {
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
return $appsList
|
||||
}
|
||||
catch {
|
||||
Write-Error "Unable to read apps list from file: $appsFilePath"
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# Loads a JSON file from the specified path and returns the parsed object
|
||||
# Returns $null if the file doesn't exist or if parsing fails
|
||||
function LoadJsonFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports a JSON file, optionally validates its version, and returns $null on failure.
|
||||
#>
|
||||
function Import-JsonFile {
|
||||
param (
|
||||
[string]$filePath,
|
||||
[string]$expectedVersion = $null,
|
||||
@@ -1,11 +1,14 @@
|
||||
# Loads settings from a JSON file and adds them to script params
|
||||
function LoadSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports enabled, compatible feature settings from a JSON file into the active parameters.
|
||||
#>
|
||||
function Import-Settings {
|
||||
param (
|
||||
[string]$filePath,
|
||||
[string]$expectedVersion = "1.0"
|
||||
)
|
||||
|
||||
$settingsJson = LoadJsonFile -filePath $filePath -expectedVersion $expectedVersion
|
||||
$settingsJson = Import-JsonFile -filePath $filePath -expectedVersion $expectedVersion
|
||||
|
||||
if (-not $settingsJson -or -not $settingsJson.Settings) {
|
||||
throw "Failed to load settings from $(Split-Path $filePath -Leaf)"
|
||||
@@ -21,11 +24,14 @@ 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
|
||||
}
|
||||
|
||||
AddParameter $setting.Name $setting.Value
|
||||
Add-Parameter $setting.Name $setting.Value
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# Returns list of apps from the specified file, it trims the app names and removes any comments
|
||||
function LoadAppsFromFile {
|
||||
param (
|
||||
$appsFilePath
|
||||
)
|
||||
|
||||
$appsList = @()
|
||||
|
||||
if (-not (Test-Path $appsFilePath)) {
|
||||
return $appsList
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return $appsList
|
||||
}
|
||||
catch {
|
||||
Write-Error "Unable to read apps list from file: $appsFilePath"
|
||||
AwaitKeyToExit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Saves active feature settings, excluding control parameters, unless running in WhatIf mode.
|
||||
#>
|
||||
function Save-Settings {
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan
|
||||
return
|
||||
}
|
||||
|
||||
$settings = @{
|
||||
"Version" = "1.0"
|
||||
"Settings" = @()
|
||||
}
|
||||
|
||||
foreach ($param in $script:Params.Keys) {
|
||||
if ($script:ControlParams -notcontains $param -and $script:Features.ContainsKey($param)) {
|
||||
$value = $script:Params[$param]
|
||||
|
||||
$settings.Settings += @{
|
||||
"Name" = $param
|
||||
"Value" = $value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Save-ToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) {
|
||||
Write-Output ""
|
||||
Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Serializes a configuration hashtable to a UTF-8 JSON file.
|
||||
|
||||
.PARAMETER Config
|
||||
The configuration data to serialize.
|
||||
|
||||
.PARAMETER FilePath
|
||||
The destination file path.
|
||||
|
||||
.PARAMETER MaxDepth
|
||||
The maximum object depth passed to ConvertTo-Json.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the file is written; otherwise $false.
|
||||
#>
|
||||
function Save-ToFile {
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[hashtable]$Config,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$MaxDepth = 10
|
||||
)
|
||||
|
||||
try {
|
||||
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8 -ErrorAction Stop
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to write '$FilePath': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,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
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Saves the current settings, excluding control parameters, to 'LastUsedSettings.json' file
|
||||
function SaveSettings {
|
||||
$settings = @{
|
||||
"Version" = "1.0"
|
||||
"Settings" = @()
|
||||
}
|
||||
|
||||
foreach ($param in $script:Params.Keys) {
|
||||
if ($script:ControlParams -notcontains $param) {
|
||||
$value = $script:Params[$param]
|
||||
|
||||
$settings.Settings += @{
|
||||
"Name" = $param
|
||||
"Value" = $value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (SaveToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) {
|
||||
Write-Output ""
|
||||
Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
# Saves configuration JSON to a file.
|
||||
# Returns $true on success, $false on failure.
|
||||
function SaveToFile {
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[hashtable]$Config,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$MaxDepth = 10
|
||||
)
|
||||
|
||||
try {
|
||||
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -1,6 +1,20 @@
|
||||
# Applies settings from a JSON object to UI controls (checkboxes and comboboxes)
|
||||
# Used by LoadDefaultsBtn and LoadLastUsedBtn in the UI
|
||||
function ApplySettingsToUiControls {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies enabled settings from JSON to mapped checkbox and combo-box controls.
|
||||
|
||||
.PARAMETER Window
|
||||
The window that owns the mapped controls.
|
||||
|
||||
.PARAMETER SettingsJson
|
||||
The settings object containing a Settings collection.
|
||||
|
||||
.PARAMETER UiControlMappings
|
||||
The feature-to-control mapping used to locate and update controls.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $false for invalid settings input; otherwise $true.
|
||||
#>
|
||||
function Apply-SettingsToUiControls {
|
||||
param (
|
||||
$window,
|
||||
$settingsJson,
|
||||
+17
-7
@@ -1,10 +1,20 @@
|
||||
# Attaches shift-click selection behavior to a checkbox in an apps panel
|
||||
# Parameters:
|
||||
# - $checkbox: The checkbox to attach the behavior to
|
||||
# - $appsPanel: The StackPanel containing checkbox items
|
||||
# - $lastSelectedCheckboxRef: A reference to a variable storing the last clicked checkbox
|
||||
# - $updateStatusCallback: Optional callback to update selection status
|
||||
function AttachShiftClickBehavior {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Attaches shift-click range-selection behavior to an application checkbox.
|
||||
|
||||
.PARAMETER Checkbox
|
||||
The checkbox that receives the mouse event handler.
|
||||
|
||||
.PARAMETER AppsPanel
|
||||
The panel whose visible checkboxes participate in range selection.
|
||||
|
||||
.PARAMETER LastSelectedCheckboxRef
|
||||
A reference that stores the previously clicked checkbox.
|
||||
|
||||
.PARAMETER UpdateStatusCallback
|
||||
An optional callback invoked after a range selection changes.
|
||||
#>
|
||||
function Attach-ShiftClickBehavior {
|
||||
param (
|
||||
[System.Windows.Controls.CheckBox]$checkbox,
|
||||
[System.Windows.Controls.StackPanel]$appsPanel,
|
||||
@@ -0,0 +1,22 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns whether Windows apps are configured to use dark mode.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $false when the personalization setting cannot be read.
|
||||
#>
|
||||
function Get-SystemUsesDarkMode {
|
||||
try {
|
||||
$personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
||||
|
||||
if ($null -eq $personalizeKey) {
|
||||
Write-Host "WARNING: Unable to retrieve personalization settings." -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
|
||||
return $personalizeKey.AppsUseLightTheme -eq 0
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
# 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Updates the app-removal scope description to match the selected ComboBoxItem.
|
||||
#>
|
||||
function Update-AppRemovalScopeDescription {
|
||||
param(
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
|
||||
[System.Windows.Controls.TextBlock]$AppRemovalScopeDescription
|
||||
)
|
||||
|
||||
$selectedItem = $AppRemovalScopeCombo.SelectedItem
|
||||
if ($selectedItem) {
|
||||
# Content is the display text and will change once translated; Name is stable.
|
||||
switch ($selectedItem.Name) {
|
||||
"AppRemovalScopeAllUsers" {
|
||||
$AppRemovalScopeDescription.Text = "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users."
|
||||
}
|
||||
"AppRemovalScopeCurrentUser" {
|
||||
$AppRemovalScopeDescription.Text = "Apps will only be removed for the current user."
|
||||
}
|
||||
"AppRemovalScopeTargetUser" {
|
||||
$AppRemovalScopeDescription.Text = "Apps will only be removed for the specified target user."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether the app-removal scope combo is currently set to "Target user only".
|
||||
#>
|
||||
function Test-AppRemovalScopeTargetsOtherUser {
|
||||
param(
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
|
||||
)
|
||||
|
||||
return ($AppRemovalScopeCombo -and $AppRemovalScopeCombo.SelectedItem -and $AppRemovalScopeCombo.SelectedItem.Name -eq 'AppRemovalScopeTargetUser')
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolves the -AppRemovalTarget value for the selected app-removal scope.
|
||||
#>
|
||||
function Get-AppRemovalScopeTarget {
|
||||
param(
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
|
||||
[System.Windows.Controls.TextBox]$OtherUsernameTextBox
|
||||
)
|
||||
|
||||
$selectedItem = $AppRemovalScopeCombo.SelectedItem
|
||||
if (-not $selectedItem) { return $null }
|
||||
|
||||
if (Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo) {
|
||||
return $OtherUsernameTextBox.Text.Trim()
|
||||
}
|
||||
|
||||
switch ($selectedItem.Name) {
|
||||
"AppRemovalScopeAllUsers" { return 'AllUsers' }
|
||||
"AppRemovalScopeCurrentUser" { return 'CurrentUser' }
|
||||
default {
|
||||
Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal."
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AppPreset {
|
||||
param(
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
[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 Set-PresetState($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')
|
||||
|
||||
Set-PresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true }
|
||||
foreach ($jsonCb in $script:JsonPresetCheckboxes) {
|
||||
$localIds = $jsonCb.PresetAppIds
|
||||
Set-PresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure()
|
||||
}
|
||||
|
||||
# Last used preset: only update if it's visible (has saved apps)
|
||||
if ($presetLastUsed.Visibility -ne 'Collapsed' -and $script:SavedAppIds) {
|
||||
Set-PresetState $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
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads application details and adds their interactive checkboxes to the main window.
|
||||
|
||||
.PARAMETER Window
|
||||
The main application window and resource owner.
|
||||
|
||||
.PARAMETER AppsPanel
|
||||
The panel populated with application checkboxes.
|
||||
|
||||
.PARAMETER OnlyInstalledAppsBox
|
||||
The filter control that determines whether only installed apps are loaded.
|
||||
|
||||
.PARAMETER LoadingAppsIndicator
|
||||
The loading indicator shown while application details are prepared.
|
||||
|
||||
.PARAMETER ImportConfigBtn
|
||||
The optional import control re-enabled after loading completes.
|
||||
|
||||
.PARAMETER ListOfApps
|
||||
An optional pre-fetched list of installed WinGet applications.
|
||||
#>
|
||||
function Add-AppsToMainWindow {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
[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
|
||||
Import-AppDetailsFromJson -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')
|
||||
})
|
||||
Attach-ShiftClickBehavior -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) { Invoke-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
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Starts asynchronous loading of application checkboxes for the main window.
|
||||
|
||||
.PARAMETER Window
|
||||
The main application window.
|
||||
|
||||
.PARAMETER AppsPanel
|
||||
The panel that receives application checkboxes.
|
||||
|
||||
.PARAMETER OnlyInstalledAppsBox
|
||||
The installed-applications filter control.
|
||||
|
||||
.PARAMETER LoadingAppsIndicator
|
||||
The loading indicator shown until loading completes.
|
||||
|
||||
.PARAMETER ImportConfigBtn
|
||||
The optional import control disabled while loading is in progress.
|
||||
#>
|
||||
function Initialize-MainWindowApps {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
[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 Invoke-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 = Get-WingetInstalledApps -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
|
||||
}
|
||||
}
|
||||
|
||||
Add-AppsToMainWindow -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user