mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-08-23 08:02:07 +00:00
Compare commits
50
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 |
+39
-4
@@ -18,7 +18,7 @@ You can help us test the latest changes and additions to the script. If you enco
|
||||
You can launch the prerelease version of Win11Debloat by running this command:
|
||||
|
||||
```ps1
|
||||
& ([scriptblock]::Create((irm "https://debloat.raphi.re/dev")))
|
||||
& ([scriptblock]::Create((irm "https://debloat.raphi.re/"))) -Dev
|
||||
```
|
||||
|
||||
# Contributing Code
|
||||
@@ -58,6 +58,25 @@ You can launch the prerelease version of Win11Debloat by running this command:
|
||||
.\Win11Debloat.ps1
|
||||
```
|
||||
|
||||
### Running Automated Tests
|
||||
|
||||
The automated test cases use Pester 5 and do not modify the registry or other
|
||||
system state. The optional bootstrap step installs Pester for your user account
|
||||
when needed. To run the complete suite:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Run-Tests.ps1 -Bootstrap
|
||||
```
|
||||
|
||||
After the initial setup, run the suite with:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Run-Tests.ps1
|
||||
```
|
||||
|
||||
GitHub Actions runs the same test command with Windows PowerShell 5.1 for pull
|
||||
requests and pushes to `master`.
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Project Structure
|
||||
@@ -72,7 +91,7 @@ Win11Debloat/
|
||||
│ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat
|
||||
│ ├── AppRemoval/ # App package removal logic
|
||||
│ ├── CLI/ # Command-line interface helpers
|
||||
│ ├── Features/ # Feature apply/undo logic (e.g. InvokeChanges.ps1, ReplaceStartMenu.ps1)
|
||||
│ ├── Features/ # Feature apply/undo logic (e.g. Invoke-Changes.ps1, Replace-StartMenu.ps1)
|
||||
│ ├── FileIO/ # File input/output helpers
|
||||
│ ├── GUI/ # GUI window definitions and logic
|
||||
│ ├── Helpers/ # Shared helper functions
|
||||
@@ -159,10 +178,26 @@ To add a new app that can be removed via Win11Debloat:
|
||||
"FriendlyName": "Display Name",
|
||||
"AppId": "AppPackageIdentifier",
|
||||
"Description": "Brief description of the app",
|
||||
"SelectedByDefault": true|false
|
||||
"SelectedByDefault": false,
|
||||
"Recommendation": "optional",
|
||||
"RemovalMethod": "Appx"
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions**:
|
||||
|
||||
- `FriendlyName`: Display name shown in the GUI.
|
||||
- `AppId`: The `AppPackageIdentifier` from `Get-AppxPackage` or the `Id` from `winget list`, depending on removal method.
|
||||
- `Description`: Brief description of the app shown in the GUI.
|
||||
- `SelectedByDefault`: Set to `true` only for apps that are largely considered bloatware, otherwise set to `false`.
|
||||
- `Recommendation`: Indicates how strongly the app is recommended for removal. One of:
|
||||
- `safe` — safe to remove for most users
|
||||
- `optional` — can be safely removed if the user doesn't need the app
|
||||
- `unsafe` — should only remove if the user knows what they are doing
|
||||
- `RemovalMethod`: The method used to remove the app. One of:
|
||||
- `Appx` — remove as a standard Appx package via `Remove-AppxPackage` (most apps)
|
||||
- `WinGet` — remove via WinGet (`winget uninstall`). Use for non-Appx apps such as Microsoft Copilot.
|
||||
|
||||
3. **Follow the Guidelines**:
|
||||
|
||||
- Use clear, user-friendly names for `FriendlyName`
|
||||
@@ -204,7 +239,7 @@ Windows Registry Editor Version 5.00
|
||||
|
||||
#### 1b. Implement the Feature Logic
|
||||
|
||||
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/InvokeChanges.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
|
||||
If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/Invoke-Changes.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file.
|
||||
|
||||
#### 2. Add Feature to Features.json
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Pester (Windows PowerShell 5.1)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run tests
|
||||
shell: powershell
|
||||
run: ./Scripts/Run-Tests.ps1 -Bootstrap
|
||||
+10
-1
@@ -921,6 +921,14 @@
|
||||
"Recommendation": "optional",
|
||||
"RemovalMethod": "Appx"
|
||||
},
|
||||
{
|
||||
"FriendlyName": "LG Monitor App",
|
||||
"AppId": "LGElectronics.LGMonitorApp",
|
||||
"Description": "LG OEM software for controlling your LG monitor, Windows Update may silently reinstall this app if you have an LG monitor connected",
|
||||
"SelectedByDefault": false,
|
||||
"Recommendation": "optional",
|
||||
"RemovalMethod": "Appx"
|
||||
},
|
||||
{
|
||||
"FriendlyName": "HP AI Experience Center",
|
||||
"AppId": "AD2F1837.HPAIExperienceCenter",
|
||||
@@ -1132,7 +1140,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "OEM software (Dell, HP, Lenovo)",
|
||||
"Name": "OEM software (Dell, HP, Lenovo, LG)",
|
||||
"AppIds": [
|
||||
"AD2F1837.HPAIExperienceCenter",
|
||||
"AD2F1837.HPConnectedMusic",
|
||||
@@ -1156,6 +1164,7 @@
|
||||
"AD2F1837.myHP",
|
||||
"E046963F.LenovoCompanion",
|
||||
"LenovoCompanyLimited.LenovoVantageService",
|
||||
"LGElectronics.LGMonitorApp",
|
||||
"DellInc.DellSupportAssistforPCs",
|
||||
"DellInc.DellDigitalDelivery",
|
||||
"DellInc.DellMobileConnect"
|
||||
|
||||
+41
-2
@@ -2,50 +2,62 @@
|
||||
"Version": "1.0",
|
||||
"Categories": [
|
||||
{
|
||||
"CategoryId": "PrivacySuggestedContent",
|
||||
"Name": "Privacy & Suggested Content",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "System",
|
||||
"Name": "System",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "StartMenuSearch",
|
||||
"Name": "Start Menu & Search",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "AI",
|
||||
"Name": "AI",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "WindowsUpdate",
|
||||
"Name": "Windows Update",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "Taskbar",
|
||||
"Name": "Taskbar",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "Appearance",
|
||||
"Name": "Appearance",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "FileExplorer",
|
||||
"Name": "File Explorer",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "Gaming",
|
||||
"Name": "Gaming",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "MultiTasking",
|
||||
"Name": "Multi-tasking",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "OptionalWindowsFeatures",
|
||||
"Name": "Optional Windows Features",
|
||||
"Icon": ""
|
||||
},
|
||||
{
|
||||
"CategoryId": "Other",
|
||||
"Name": "Other",
|
||||
"Icon": ""
|
||||
}
|
||||
@@ -401,6 +413,19 @@
|
||||
"MinVersion": null,
|
||||
"MaxVersion": null
|
||||
},
|
||||
{
|
||||
"FeatureId": "DisableNotifications",
|
||||
"Label": "Disable Windows Notifications (From apps and other senders)",
|
||||
"ToolTip": "Disables native Windows notifications from apps and other senders, this includes notifications from apps like Discord, WhatsApp, Teams, and Slack.",
|
||||
"Category": "Privacy & Suggested Content",
|
||||
"RegistryKey": "Disable_Notifications.reg",
|
||||
"ApplyText": "Disabling Windows notifications and reminders",
|
||||
"UndoLabel": "Enable Windows notifications and reminders",
|
||||
"ApplyUndoText": "Enabling Windows notifications and reminders",
|
||||
"RegistryUndoKey": "Enable_Notifications.reg",
|
||||
"MinVersion": null,
|
||||
"MaxVersion": null
|
||||
},
|
||||
{
|
||||
"FeatureId": "DisableLocationServices",
|
||||
"Label": "Disable Windows location services & app location access",
|
||||
@@ -667,7 +692,7 @@
|
||||
"UndoLabel": "Show Microsoft 365 Copilot ads in Settings Home",
|
||||
"ApplyUndoText": "Enabling Microsoft 365 Copilot ads in Settings Home",
|
||||
"RegistryUndoKey": "Enable_Settings_365_Ads.reg",
|
||||
"MinVersion": null,
|
||||
"MinVersion": 22000,
|
||||
"MaxVersion": null
|
||||
},
|
||||
{
|
||||
@@ -680,7 +705,7 @@
|
||||
"UndoLabel": "Show Settings 'Home' page",
|
||||
"ApplyUndoText": "Enabling the Settings Home page",
|
||||
"RegistryUndoKey": "Enable_Settings_Home.reg",
|
||||
"MinVersion": null,
|
||||
"MinVersion": 22000,
|
||||
"MaxVersion": null
|
||||
},
|
||||
{
|
||||
@@ -800,6 +825,7 @@
|
||||
"UndoLabel": "Enable window snapping",
|
||||
"ApplyUndoText": "Enabling window snapping",
|
||||
"RegistryUndoKey": "Enable_Window_Snapping.reg",
|
||||
"RequiresReboot": true,
|
||||
"MinVersion": null,
|
||||
"MaxVersion": null
|
||||
},
|
||||
@@ -1356,6 +1382,19 @@
|
||||
"MinVersion": null,
|
||||
"MaxVersion": null
|
||||
},
|
||||
{
|
||||
"FeatureId": "DisableDeviceAutoAppDownload",
|
||||
"Label": "Prevent Windows from auto-installing device companion apps",
|
||||
"ToolTip": "Stops Windows from silently installing device companion apps via Windows Update when you connect certain devices, like monitors from LG and Alienware. These devices will still work as expected, this only stops Windows Update from automatically installing additional apps without consent.",
|
||||
"Category": "Windows Update",
|
||||
"RegistryKey": "Disable_Device_Auto_App_Download.reg",
|
||||
"ApplyText": "Preventing automatic installation of device companion apps",
|
||||
"UndoLabel": "Allow automatic installation of device companion apps",
|
||||
"ApplyUndoText": "Allowing automatic installation of device companion apps",
|
||||
"RegistryUndoKey": "Enable_Device_Auto_App_Download.reg",
|
||||
"MinVersion": null,
|
||||
"MaxVersion": null
|
||||
},
|
||||
{
|
||||
"FeatureId": "ForceRemoveEdge",
|
||||
"Label": "Forcefully uninstall Microsoft Edge. NOT RECOMMENDED!",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
Win11Debloat is a lightweight, easy to use PowerShell script that allows you to quickly declutter and customize your Windows experience, no installation required! You can use it to remove pre-installed apps, disable telemetry, remove intrusive interface elements and much more. No need to painstakingly go through all the settings yourself or remove apps one by one. Win11Debloat makes the process quick and easy!
|
||||
|
||||
The script also includes many features that system administrators and power users will enjoy. Such as a powerful command-line interface, support for Windows Audit mode and the ability to make changes to other Windows users. Please refer to our [wiki](https://github.com/Raphire/Win11Debloat/wiki/) for more details.
|
||||
The script also includes many features that system administrators and power users will enjoy. Such as a powerful command-line interface, support for Windows Audit mode and the ability to make changes to other Windows users. You can also easily export & import your preferred settings, allowing you to quickly apply the same settings on all your systems. Please refer to our [wiki](https://github.com/Raphire/Win11Debloat/wiki) for more details.
|
||||
|
||||

|
||||
|
||||
@@ -23,14 +23,14 @@ The script also includes many features that system administrators and power user
|
||||
|
||||
Download & run the script automatically via PowerShell.
|
||||
|
||||
1. Open PowerShell or Terminal, preferably as an administrator.
|
||||
1. Open PowerShell or Terminal.
|
||||
2. Copy and paste the command below into PowerShell:
|
||||
|
||||
```PowerShell
|
||||
& ([scriptblock]::Create((irm "https://debloat.raphi.re/")))
|
||||
```
|
||||
|
||||
3. Wait for the script to automatically download Win11Debloat.
|
||||
3. Wait for the script to automatically download and launch Win11Debloat.
|
||||
4. Carefully read through and follow the on-screen instructions.
|
||||
|
||||
This method supports command-line parameters to customize the behaviour of the script. Please click [here](https://github.com/Raphire/Win11Debloat/wiki/Command%E2%80%90line-Interface#parameters) for more information.
|
||||
@@ -57,7 +57,7 @@ This method supports command-line parameters to customize the behaviour of the s
|
||||
3. Temporarily enable PowerShell execution by entering the following command:
|
||||
|
||||
```PowerShell
|
||||
Set-ExecutionPolicy Unrestricted -Scope Process -Force
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
```
|
||||
|
||||
4. In PowerShell, navigate to the directory where the files were extracted. Example: `cd c:\Win11Debloat`
|
||||
@@ -74,10 +74,10 @@ This method supports command-line parameters to customize the behaviour of the s
|
||||
|
||||
## Features
|
||||
|
||||
Below is an overview of the key features and functionality offered by Win11Debloat. Please refer to [the wiki](https://github.com/Raphire/Win11Debloat/wiki/Default-Settings) for more information about the default settings preset.
|
||||
Below is an overview of the key features and functionality offered by Win11Debloat. You can visit the [the wiki](https://github.com/Raphire/Win11Debloat/wiki) for more details.
|
||||
|
||||
> [!Tip]
|
||||
> All of the changes made by Win11Debloat can easily be reverted and almost all of the apps can be reinstalled through the Microsoft Store. A full guide on how to revert changes can be found [here](https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes).
|
||||
> All of the changes made by Win11Debloat can easily be reverted and almost all of the apps can be reinstalled through the Microsoft Store. You can visit [the wiki](https://github.com/Raphire/Win11Debloat/wiki/Reverting-Changes) for more information on reverting changes.
|
||||
|
||||
#### App Removal
|
||||
|
||||
@@ -86,29 +86,21 @@ Below is an overview of the key features and functionality offered by Win11Deblo
|
||||
#### Privacy & Suggested Content
|
||||
|
||||
- Disable telemetry, diagnostic data, activity history, app-launch tracking & targeted ads.
|
||||
- Disable tips, tricks, suggestions & ads across Windows.
|
||||
- Disable Windows location services & app location access.
|
||||
- Disable Find My Device location tracking.
|
||||
- Disable 'Windows Spotlight' and tips & tricks on the lock screen.
|
||||
- Disable 'Windows Spotlight' desktop background option.
|
||||
- Disable ads, suggestions and the MSN news feed in Microsoft Edge.
|
||||
- Disable tips, tricks, suggestions & ads across Windows, the lock screen and Microsoft Edge.
|
||||
- Disable Windows location services, app location access and Find My Device location tracking.
|
||||
- Hide Microsoft 365 ads on the Settings 'Home' page, or hide the 'Home' page entirely.
|
||||
|
||||
#### AI Features
|
||||
|
||||
- Disable & remove Microsoft Copilot.
|
||||
- Disable Windows Recall.
|
||||
- Disable Click to Do, AI text & image analysis tool.
|
||||
- Disable & remove Microsoft Copilot, Windows Recall and Click to Do.
|
||||
- Prevent AI service (WSAIFabricSvc) from starting automatically.
|
||||
- Disable AI Features in Edge.
|
||||
- Disable AI Features in Paint.
|
||||
- Disable AI Features in Notepad.
|
||||
- Disable AI Features in Edge, Paint and Notepad.
|
||||
|
||||
#### System
|
||||
|
||||
- Disable the Drag Tray for sharing & moving files.
|
||||
- Restore the old Windows 10 style context menu.
|
||||
- Turn off Enhance Pointer Precision, also known as mouse acceleration.
|
||||
- Turn off Enhance Pointer Precision (mouse acceleration).
|
||||
- Disable the Sticky Keys keyboard shortcut.
|
||||
- Disable Storage Sense automatic disk cleanup.
|
||||
- Disable fast start-up to ensure a full shutdown.
|
||||
@@ -120,54 +112,43 @@ Below is an overview of the key features and functionality offered by Win11Deblo
|
||||
- Prevent Windows from getting updates as soon as they're available.
|
||||
- Prevent automatic restarts after updates while signed in.
|
||||
- Disable sharing of downloaded updates with other PCs, also known as Delivery Optimization.
|
||||
- Prevent Windows from auto-installing device companion apps, like LG Monitor App, Alienware Command Center and more.
|
||||
|
||||
#### Appearance
|
||||
|
||||
- Enable dark mode for system and apps.
|
||||
- Disable transparency effects
|
||||
- Disable animations and visual effects.
|
||||
- Disable transparency, animations and visual effects.
|
||||
|
||||
#### Start Menu & Search
|
||||
|
||||
- Remove or replace all pinned apps from the start menu.
|
||||
- Hide the recommended section in the start menu.
|
||||
- Hide the 'All Apps' section in the start menu.
|
||||
- Customize the start menu by removing pinned apps, hiding recommendations, and customizing the 'All Apps' section.
|
||||
- Disable the Phone Link mobile devices integration in the start menu.
|
||||
- Disable Bing web search & Copilot integration in Windows search.
|
||||
- Disable Microsoft Store app suggestions in Windows search.
|
||||
- Disable Search Highlights (dynamic/branded content) in the taskbar search box.
|
||||
- Disable local Windows search history.
|
||||
- Disable Bing web search & Copilot integration and Microsoft Store app suggestions in Windows search.
|
||||
|
||||
#### Taskbar
|
||||
|
||||
- Align taskbar icons to the left.
|
||||
- Hide or change the search icon/box on the taskbar.
|
||||
- Hide the taskview button from the taskbar.
|
||||
- Change taskbar alignment.
|
||||
- Customize or hide taskbar buttons like the search bar, taskview and more.
|
||||
- Disable widgets on the taskbar & lock screen.
|
||||
- Hide the chat (meet now) icon from the taskbar.
|
||||
- Enable the 'End Task' option in the taskbar right click menu.
|
||||
- Enable the 'End Task' option in the taskbar right click menu to quickly force-close apps.
|
||||
- Enable the 'Last Active Click' behavior in the taskbar app area. This allows you to repeatedly click on an application's icon in the taskbar to switch focus between the open windows of that application.
|
||||
- Choose how app icons are shown on the taskbar when using multiple monitors.
|
||||
- Choose combine mode for taskbar buttons and labels.
|
||||
- Customize how app buttons are shown on the taskbar.
|
||||
|
||||
#### File Explorer
|
||||
|
||||
- Change the default location that File Explorer opens to.
|
||||
- Show file extensions for known file types.
|
||||
- Show hidden files, folders and drives.
|
||||
- Hide the Home or Gallery section from the File Explorer navigation pane.
|
||||
- Hide the Home, Gallery or OneDrive section from the File Explorer navigation pane.
|
||||
- Hide duplicate removable drive entries from the File Explorer navigation pane, so only the entry under 'This PC' remains.
|
||||
- Add all common folders (Desktop, Downloads, etc.) back to 'This PC' in File Explorer.
|
||||
- Hide the 3D objects, music or OneDrive folder from the File Explorer navigation pane.
|
||||
- Hide the 'Include in library', 'Give access to' and 'Share' options from the context menu.
|
||||
- Change drive letter position or visibility in File Explorer.
|
||||
|
||||
#### Multi-tasking
|
||||
|
||||
- Disable window snapping.
|
||||
- Disable Snap Assist suggestions when snapping a window.
|
||||
- Disable Snap Layout suggestions when dragging windows to the top of screen and when hovering on the maximize button.
|
||||
- Change if tabs are shown when snapping or pressing Alt+Tab.
|
||||
- Disable Snap Assist and Snap Layout suggestions when dragging or snapping windows.
|
||||
- Change whether tabs are shown when snapping windows or pressing Alt+Tab.
|
||||
|
||||
#### Optional Windows Features
|
||||
|
||||
@@ -181,12 +162,12 @@ Below is an overview of the key features and functionality offered by Win11Deblo
|
||||
|
||||
#### Advanced Features
|
||||
|
||||
- Option to [apply changes to a different user](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#running-as-another-user), instead of the currently logged in user.
|
||||
- Ability to [apply changes to a different user](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#running-as-another-user), instead of the currently logged in user.
|
||||
- [Sysprep mode](https://github.com/Raphire/Win11Debloat/wiki/Advanced-Features#sysprep-mode) to apply changes to the Windows Default user profile. Which ensures, all new users will have the changes automatically applied to them.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions of all kinds! Please see our [Contributing Guidelines](/.github/CONTRIBUTING.md) for detailed instructions on how to get started and best practices for contributing.
|
||||
We welcome contributions of all kinds! Please see our [Contributing Guidelines](https://github.com/Raphire/Win11Debloat/blob/master/.github/CONTRIBUTING.md) for detailed instructions on how to get started and best practices for contributing.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,22 +15,20 @@ if exist "%wtDefaultPath%" (
|
||||
) else if exist "%wtScoopPath%" (
|
||||
set "wtPath=%wtScoopPath%"
|
||||
) else (
|
||||
echo Windows Terminal not found. Using default PowerShell instead.
|
||||
set "wtPath="
|
||||
)
|
||||
|
||||
set "SCRIPT_PATH=\"%~dp0Win11Debloat.ps1\""
|
||||
:: Interpolated into a PS single-quoted string below;
|
||||
:: Apostrophes escaped via %:'=''% and -File arg uses [char]34 to avoid quote-parity bugs.
|
||||
set "SCRIPT_PATH=%~dp0Win11Debloat.ps1"
|
||||
|
||||
:: Launch script
|
||||
if defined wtPath (
|
||||
call :Log Launching Win11Debloat.ps1 with Windows Terminal...
|
||||
PowerShell -Command "Start-Process -FilePath '%wtPath%' -ArgumentList 'PowerShell -NoProfile -ExecutionPolicy Bypass -File %SCRIPT_PATH%' -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
call :Log Script execution passed successfully to Win11Debloat.ps1
|
||||
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $w='%wtPath:'=''%'; $q=[char]34; Start-Process -FilePath $w -ArgumentList ('PowerShell -NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
) else (
|
||||
echo Windows Terminal not found. Using default PowerShell instead...
|
||||
echo Windows Terminal not found, using default PowerShell...
|
||||
call :Log Windows Terminal not found. Using default PowerShell to launch Win11Debloat.ps1...
|
||||
PowerShell -ExecutionPolicy Bypass -Command "& {Start-Process PowerShell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File %SCRIPT_PATH%' -Verb RunAs}" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
call :Log Script execution passed successfully to Win11Debloat.ps1
|
||||
PowerShell -NoProfile -ExecutionPolicy Bypass -Command "$p='%SCRIPT_PATH:'=''%'; $q=[char]34; Start-Process PowerShell -ArgumentList ('-NoProfile -ExecutionPolicy Bypass -File ' + $q + $p + $q) -Verb RunAs" >> "%logFile%" || call :Error "PowerShell command failed"
|
||||
)
|
||||
|
||||
echo.
|
||||
@@ -40,11 +38,13 @@ goto :EOF
|
||||
|
||||
:: Logging Function
|
||||
:Log
|
||||
echo %* >> "%logFile%"
|
||||
echo(%* >> "%logFile%"
|
||||
goto :EOF
|
||||
|
||||
:: Error Handler
|
||||
:Error
|
||||
echo ERROR: %*
|
||||
echo(ERROR: %*
|
||||
call :Log ERROR: %*
|
||||
echo Logged in %logFile%
|
||||
pause
|
||||
goto :EOF
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
Text=""
|
||||
Foreground="{DynamicResource TitleBarCloseHoverColor}"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Width" Value="46"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</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,7 +54,7 @@
|
||||
<!-- Loading icon (spinning) -->
|
||||
<TextBlock x:Name="ApplySpinnerIcon"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="36"
|
||||
Foreground="{DynamicResource ButtonBgColor}"
|
||||
HorizontalAlignment="Center"
|
||||
@@ -115,7 +115,7 @@
|
||||
<!-- Success icon -->
|
||||
<TextBlock x:Name="ApplyCompletionIcon"
|
||||
Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource ButtonBgColor}"
|
||||
HorizontalAlignment="Center"
|
||||
@@ -141,7 +141,7 @@
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||
<TextBlock Text=""
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="14"
|
||||
Foreground="#e8912d"
|
||||
VerticalAlignment="Center"
|
||||
@@ -163,7 +163,7 @@
|
||||
Style="{DynamicResource ModalSecondaryStretchedButtonStyle}"
|
||||
AutomationProperties.Name="Support the creator">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" 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>
|
||||
|
||||
+40
-38
@@ -65,7 +65,7 @@
|
||||
|
||||
<!-- Category header icon style -->
|
||||
<Style x:Key="CategoryHeaderIcon" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe Fluent Icons"/>
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
|
||||
<Setter Property="FontSize" Value="20"/>
|
||||
<Setter Property="LineHeight" Value="20"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppFgColor}"/>
|
||||
@@ -196,7 +196,7 @@
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Width" Value="46"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="FontFamily" Value="Segoe Fluent Icons"/>
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppIconFontFamily}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
@@ -362,8 +362,8 @@
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button x:Name="KofiBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="" FontFamily="Segoe Fluent Icons" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Support the creator" AutomationProperties.Name="Support the creator"/>
|
||||
<Button x:Name="MenuBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="" FontFamily="Segoe Fluent Icons" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Options" AutomationProperties.Name="Options">
|
||||
<Button x:Name="KofiBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Support the creator" AutomationProperties.Name="Support the creator"/>
|
||||
<Button x:Name="MenuBtn" shell:WindowChrome.IsHitTestVisibleInChrome="True" Content="" FontSize="15" Style="{StaticResource TitlebarButton}" ToolTip="Options" AutomationProperties.Name="Options">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu x:Name="MainMenu">
|
||||
<ContextMenu.Resources>
|
||||
@@ -380,38 +380,38 @@
|
||||
</ContextMenu.Resources>
|
||||
<MenuItem x:Name="ImportConfigBtn" Header="Import config" AutomationProperties.Name="Import configuration">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="ExportConfigBtn" Header="Export config" AutomationProperties.Name="Export configuration">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="RestoreBackupBtn" Header="Restore backup" AutomationProperties.Name="Restore registry backup">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="MenuDocumentation" Header="Documentation" AutomationProperties.Name="Documentation">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="MenuReportBug" Header="Report a bug" AutomationProperties.Name="Report a bug">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="MenuLogs" Header="Logs" AutomationProperties.Name="Logs">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="MenuAbout" Header="About" AutomationProperties.Name="About">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
@@ -470,13 +470,13 @@
|
||||
<Path x:Name="LogoFallback" Data="M0,0 L80,0 L80,80 L0,80 Z M90,0 L170,0 L170,80 L90,80 Z M0,90 L80,90 L80,170 L0,170 Z M90,90 L170,90 L170,170 L90,170 Z"
|
||||
Fill="{DynamicResource ButtonBgColor}" Stretch="Uniform" Margin="10"/>
|
||||
<!-- Sparkle effects -->
|
||||
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="50" Height="50" Margin="0,0,2,2">
|
||||
<Path Canvas.Left="10" Canvas.Top="16" Data="M12,0 L14,10 L24,12 L14,14 L12,24 L10,14 L0,12 L10,10 Z"
|
||||
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" Stretch="Uniform"/>
|
||||
<Canvas HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="80" Height="80" Margin="0,0,2,2">
|
||||
<Path Canvas.Left="12" Canvas.Top="32" Data="M12,0 L14,10 L24,12 L14,14 L12,24 L10,14 L0,12 L10,10 Z"
|
||||
Fill="{DynamicResource AppAccentColor}" Width="60" Height="60" Stretch="Uniform"/>
|
||||
<Path Canvas.Left="0" Canvas.Top="0" Data="M6,0 L7,5 L12,6 L7,7 L6,12 L5,7 L0,6 L5,5 Z"
|
||||
Fill="{DynamicResource AppAccentColor}" Width="22" Height="22" Stretch="Uniform"/>
|
||||
<Path Canvas.Left="35" Canvas.Top="8" Data="M4,0 L5,3 L8,4 L5,5 L4,8 L3,5 L0,4 L3,3 Z"
|
||||
Fill="{DynamicResource AppAccentColor}" Width="17" Height="17" Stretch="Uniform"/>
|
||||
Fill="{DynamicResource AppAccentColor}" Width="40" Height="40" Stretch="Uniform"/>
|
||||
<Path Canvas.Left="55" Canvas.Top="16" Data="M4,0 L5,3 L8,4 L5,5 L4,8 L3,5 L0,4 L3,3 Z"
|
||||
Fill="{DynamicResource AppAccentColor}" Width="25" Height="25" Stretch="Uniform"/>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
@@ -490,7 +490,7 @@
|
||||
<Border HorizontalAlignment="Center" BorderBrush="{DynamicResource AppBorderColor}" BorderThickness="1" CornerRadius="4" Background="{DynamicResource CardBgColor}" Padding="16,12" Width="500">
|
||||
<StackPanel>
|
||||
<TextBlock Text="What user do you want to apply changes to?" Style="{StaticResource CategoryHeaderTextBlock}"/>
|
||||
<ComboBox x:Name="UserSelectionCombo" Margin="0,0,0,6" AutomationProperties.Name="Apply Changes To">
|
||||
<ComboBox x:Name="UserSelectionCombo" Margin="0,0,0,6" AutomationProperties.Name="Apply Changes To" ToolTip="The currently logged-in user profile">
|
||||
<ComboBoxItem Content="Current User" IsSelected="True"/>
|
||||
<ComboBoxItem Content="Other User"/>
|
||||
<ComboBoxItem Content="Windows Default User (Sysprep)"/>
|
||||
@@ -506,7 +506,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="UsernameTextBoxPlaceholder" Grid.Column="0" Text="Enter username" Foreground="{DynamicResource AppFgColor}" Opacity="0.7" FontSize="13" Margin="3,0,0,1" VerticalAlignment="Center" IsHitTestVisible="False"/>
|
||||
<TextBox x:Name="OtherUsernameTextBox" Grid.Column="0" Style="{DynamicResource TextBoxInputStyle}" Text="" AutomationProperties.Name="Enter username"/>
|
||||
<TextBlock Grid.Column="1" Text="" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
|
||||
<TextBlock Grid.Column="1" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
@@ -517,13 +517,13 @@
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,8,0,4">
|
||||
<Button x:Name="HomeDefaultModeBtn" Width="227" Height="50" Style="{DynamicResource PrimaryButtonStyle}" Margin="0,0,12,0" AutomationProperties.Name="Default Mode">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="16" VerticalAlignment="Center" Margin="0,0,8,-1"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="16" VerticalAlignment="Center" Margin="0,0,8,-1"/>
|
||||
<TextBlock Text="Default Mode" ToolTip="Quickly select the recommended settings" FontWeight="SemiBold" VerticalAlignment="Center" FontSize="17" Margin="0,0,0,1"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="HomeStartBtn" Width="227" Height="50" Style="{DynamicResource SecondaryButtonStyle}" AutomationProperties.Name="Custom Setup">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" 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="Custom Setup" ToolTip="Manually select your preferred settings" FontWeight="SemiBold" VerticalAlignment="Center" FontSize="17" Margin="0,0,0,1"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -585,9 +585,9 @@
|
||||
</Style>
|
||||
</ToggleButton.Style>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
|
||||
<TextBlock Text="Quick Select" FontSize="13" VerticalAlignment="Center" Margin="0,0,6,1"/>
|
||||
<TextBlock x:Name="PresetsArrow" Text="" FontFamily="Segoe Fluent Icons" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock x:Name="PresetsArrow" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform>
|
||||
<RotateTransform x:Name="PresetsArrowRotation" Angle="0"/>
|
||||
</TextBlock.RenderTransform>
|
||||
@@ -596,7 +596,7 @@
|
||||
</ToggleButton>
|
||||
<Button x:Name="ClearAppSelectionBtn" ToolTip="Clear all selected apps" Style="{DynamicResource SecondaryButtonStyle}" Height="32" Padding="10,0" Margin="0,0,10,0" AutomationProperties.Name="Clear Selection">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
|
||||
<TextBlock Text="Clear Selection" FontSize="13" VerticalAlignment="Center" Margin="0,0,0,1"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -627,7 +627,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="AppSearchPlaceholder" Grid.Column="0" Text="Search apps..." Foreground="{DynamicResource AppFgColor}" Opacity="0.7" FontSize="13" Margin="3,0,0,1" VerticalAlignment="Center" IsHitTestVisible="False"/>
|
||||
<TextBox x:Name="AppSearchBox" Grid.Column="0" Style="{DynamicResource TextBoxInputStyle}" Text="" AutomationProperties.Name="Search app"/>
|
||||
<TextBlock Grid.Column="1" Text="" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
|
||||
<TextBlock Grid.Column="1" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
@@ -658,19 +658,19 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel x:Name="HeaderNameBtn" Grid.Column="1" Orientation="Horizontal" Cursor="Hand" VerticalAlignment="Center" Style="{StaticResource SortHeaderBtnStyle}">
|
||||
<TextBlock Text="Name" FontWeight="SemiBold" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock x:Name="SortArrowName" Text="" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock x:Name="SortArrowName" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform><RotateTransform Angle="0"/></TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="HeaderDescriptionBtn" Grid.Column="2" Orientation="Horizontal" Cursor="Hand" VerticalAlignment="Center" Margin="8,0,0,0" Style="{StaticResource SortHeaderBtnStyle}">
|
||||
<TextBlock Text="Description" FontWeight="SemiBold" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock x:Name="SortArrowDescription" Text="" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock x:Name="SortArrowDescription" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform><RotateTransform Angle="0"/></TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="HeaderAppIdBtn" Grid.Column="3" Orientation="Horizontal" Cursor="Hand" VerticalAlignment="Center" Style="{StaticResource SortHeaderBtnStyle}">
|
||||
<TextBlock Text="App ID" FontWeight="SemiBold" FontSize="16" Foreground="{DynamicResource AppFgColor}"/>
|
||||
<TextBlock x:Name="SortArrowAppId" Text="" FontFamily="Segoe Fluent Icons" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock x:Name="SortArrowAppId" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="11" Foreground="{DynamicResource AppFgColor}" VerticalAlignment="Center" Margin="5,1,0,0" Opacity="0.3" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform><RotateTransform Angle="0"/></TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
@@ -684,7 +684,7 @@
|
||||
</ScrollViewer>
|
||||
<Border x:Name="LoadingAppsIndicator" CornerRadius="0,0,4,4" Background="{DynamicResource CardBgColor}" Opacity="0.8" Visibility="Collapsed">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="28" Foreground="{DynamicResource AppFgColor}" HorizontalAlignment="Center" Margin="0,0,0,8" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="28" Foreground="{DynamicResource AppFgColor}" HorizontalAlignment="Center" Margin="0,0,0,8" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform>
|
||||
<RotateTransform Angle="0"/>
|
||||
</TextBlock.RenderTransform>
|
||||
@@ -789,9 +789,9 @@
|
||||
</Style>
|
||||
</ToggleButton.Style>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="0,1,6,0"/>
|
||||
<TextBlock Text="Quick Select" FontSize="13" VerticalAlignment="Center" Margin="0,0,6,1"/>
|
||||
<TextBlock x:Name="TweaksPresetsArrow" Text="" FontFamily="Segoe Fluent Icons" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock x:Name="TweaksPresetsArrow" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="10" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<TextBlock.RenderTransform>
|
||||
<RotateTransform Angle="0"/>
|
||||
</TextBlock.RenderTransform>
|
||||
@@ -800,7 +800,7 @@
|
||||
</ToggleButton>
|
||||
<Button x:Name="ClearAllTweaksBtn" ToolTip="Clear all selected tweaks" Style="{DynamicResource SecondaryButtonStyle}" Padding="10,0" Height="32" Margin="0,0,10,0" AutomationProperties.Name="Clear Selection">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="15" VerticalAlignment="Center" Margin="0,3,6,0"/>
|
||||
<TextBlock Text="Clear Selection" FontSize="13" VerticalAlignment="Center" Margin="0,0,0,1"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -832,7 +832,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="TweakSearchPlaceholder" Grid.Column="0" Text="Search tweaks..." Foreground="{DynamicResource AppFgColor}" Opacity="0.7" FontSize="13" Margin="3,0,0,1" VerticalAlignment="Center" IsHitTestVisible="False"/>
|
||||
<TextBox x:Name="TweakSearchBox" Grid.Column="0" Style="{DynamicResource TextBoxInputStyle}" Text="" AutomationProperties.Name="Search tweaks"/>
|
||||
<TextBlock Grid.Column="1" Text="" FontFamily="Segoe Fluent Icons" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
|
||||
<TextBlock Grid.Column="1" Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="14" VerticalAlignment="Center" Margin="8,0,4,0" Foreground="{DynamicResource AppFgColor}" Opacity="0.7"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
@@ -933,12 +933,14 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="Options" Style="{StaticResource CategoryHeaderTextBlock}"/>
|
||||
|
||||
<!-- Restore Point Option -->
|
||||
<StackPanel>
|
||||
<CheckBox x:Name="RegistryBackupCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a registry backup (Recommended)" AutomationProperties.Name="Create a registry backup (Recommended)"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<CheckBox x:Name="RestorePointCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a system restore point (Recommended)" AutomationProperties.Name="Create a system restore point (Recommended)"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Restart Explorer Option -->
|
||||
<StackPanel>
|
||||
<CheckBox x:Name="RestartExplorerCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" Content="Restart the Windows Explorer process to apply all changes immediately" AutomationProperties.Name="Restart the Windows Explorer process to apply all changes immediately"/>
|
||||
</StackPanel>
|
||||
@@ -986,7 +988,7 @@
|
||||
</Button>
|
||||
<Button x:Name="DeploymentApplyBtn" Style="{DynamicResource PrimaryButtonStyle}" Width="200" Height="44" HorizontalAlignment="Center" AutomationProperties.Name="Apply Changes">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Apply Changes" VerticalAlignment="Center" FontSize="18" FontWeight="SemiBold" Margin="8,0,0,4"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -1006,7 +1008,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button x:Name="PreviousBtn" Grid.Column="0" Width="120" Height="36" Style="{DynamicResource SecondaryButtonStyle}" Visibility="Collapsed" AutomationProperties.Name="Back">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="12" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="12" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Back" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,3"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -1015,7 +1017,7 @@
|
||||
<Button x:Name="NextBtn" Width="120" Height="36" Style="{DynamicResource PrimaryButtonStyle}" AutomationProperties.Name="Next">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Next" VerticalAlignment="Center" FontSize="14" Margin="0,0,0,3"/>
|
||||
<TextBlock Text="" FontFamily="Segoe Fluent Icons" FontSize="12" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="" FontFamily="{DynamicResource AppIconFontFamily}" FontSize="12" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<!-- Icon -->
|
||||
<TextBlock x:Name="IconText"
|
||||
Grid.Column="0"
|
||||
FontFamily="Segoe Fluent Icons"
|
||||
FontFamily="{DynamicResource AppIconFontFamily}"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource AppFgColor}"
|
||||
VerticalAlignment="Center"
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</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">
|
||||
@@ -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"/>
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
<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"
|
||||
@@ -393,12 +393,12 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="CheckBoxBorder" Grid.Column="0" Width="20" Height="20" Background="{DynamicResource CheckBoxBgColor}" BorderBrush="{DynamicResource CheckBoxBorderColor}" BorderThickness="1" CornerRadius="4" Margin="0,0,8,0">
|
||||
<Grid>
|
||||
<TextBlock x:Name="CheckMark" Text="" FontFamily="Segoe Fluent Icons" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource ButtonBgColor}" 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="13" FontWeight="Bold" Foreground="{DynamicResource ButtonBgColor}" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" Margin="1,0,0,1">
|
||||
<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,12"/>
|
||||
</TextBlock.Clip>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Forcefully removes Microsoft Edge using its uninstaller
|
||||
# Credit: Based on work from loadstring1 & ave9858
|
||||
function ForceRemoveEdge {
|
||||
Write-Host "> Forcefully uninstalling Microsoft Edge..."
|
||||
|
||||
$regView = [Microsoft.Win32.RegistryView]::Registry32
|
||||
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
|
||||
$hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev').SetValue('AllowUninstall', '')
|
||||
|
||||
# Create stub (This somehow allows uninstalling Edge)
|
||||
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
|
||||
New-Item $edgeStub -ItemType Directory | Out-Null
|
||||
New-Item "$edgeStub\MicrosoftEdge.exe" | Out-Null
|
||||
|
||||
# Remove edge
|
||||
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
|
||||
if ($null -ne $uninstallRegKey) {
|
||||
Write-Host "Running uninstaller..."
|
||||
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($cmd)
|
||||
Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait
|
||||
} -ArgumentList $uninstallString
|
||||
|
||||
Write-Host "Removing leftover files..."
|
||||
|
||||
$edgePaths = @(
|
||||
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
|
||||
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
|
||||
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
|
||||
"$edgeStub"
|
||||
)
|
||||
|
||||
foreach ($path in $edgePaths) {
|
||||
if (Test-Path -Path $path) {
|
||||
Remove-Item -Path $path -Force -Recurse -ErrorAction SilentlyContinue
|
||||
Write-Host " Removed $path" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Cleaning up registry..."
|
||||
|
||||
# Remove MS Edge from autostart
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" /v "Microsoft Edge Update" /f *>$null
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C" /f *>$null
|
||||
reg delete "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" /v "Microsoft Edge Update" /f *>$null
|
||||
|
||||
Write-Host "Microsoft Edge was uninstalled"
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -17,7 +17,7 @@
|
||||
PSCustomObject[] with Name and Id properties. Returns $null on
|
||||
failure, or an empty array when winget succeeds but lists no apps.
|
||||
#>
|
||||
function GetInstalledAppsViaWinget {
|
||||
function Get-WingetInstalledApps {
|
||||
param (
|
||||
[int]$TimeOut = 10,
|
||||
[switch]$NonBlocking
|
||||
@@ -66,7 +66,14 @@ function GetInstalledAppsViaWinget {
|
||||
}
|
||||
}
|
||||
|
||||
if ($dataStart -lt 0 -or $dataStart -ge $lines.Count) { return @() }
|
||||
# A missing table separator means the output is malformed or empty
|
||||
if ($dataStart -lt 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($dataStart -ge $lines.Count) {
|
||||
return ,@()
|
||||
}
|
||||
|
||||
$apps = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
@@ -94,7 +101,7 @@ function GetInstalledAppsViaWinget {
|
||||
}
|
||||
}
|
||||
|
||||
return @($apps)
|
||||
return ,@($apps)
|
||||
}
|
||||
|
||||
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
|
||||
@@ -0,0 +1,146 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when Edge is uninstalled and cleanup succeeds; otherwise $false.
|
||||
#>
|
||||
function Invoke-ForceRemoveEdge {
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Forcefully uninstall Microsoft Edge" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "> Forcefully uninstalling Microsoft Edge..."
|
||||
|
||||
$regView = [Microsoft.Win32.RegistryView]::Registry32
|
||||
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView)
|
||||
$edgeUpdateKey = $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev')
|
||||
$edgeUpdateKey.SetValue('AllowUninstall', '')
|
||||
|
||||
# Create stub (This somehow allows uninstalling Edge)
|
||||
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
|
||||
New-Item $edgeStub -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
New-Item "$edgeStub\MicrosoftEdge.exe" -ItemType File -Force -ErrorAction Stop | Out-Null
|
||||
|
||||
# Remove edge
|
||||
$uninstallRegKey = $hklm.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft Edge')
|
||||
if ($null -eq $uninstallRegKey) {
|
||||
Write-Host "Unable to forcefully uninstall Microsoft Edge, uninstaller could not be found" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Running uninstaller..."
|
||||
$uninstallString = $uninstallRegKey.GetValue('UninstallString') + ' --force-uninstall'
|
||||
$exitCode = Invoke-NonBlocking -ScriptBlock {
|
||||
param($cmd)
|
||||
$process = Start-Process cmd.exe "/c $cmd" -WindowStyle Hidden -Wait -PassThru
|
||||
return $process.ExitCode
|
||||
} -ArgumentList $uninstallString
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Warning "Microsoft Edge uninstaller failed with exit code $exitCode."
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Removing leftover files..."
|
||||
$cleanupSucceeded = $true
|
||||
|
||||
$edgePaths = @(
|
||||
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk",
|
||||
"$env:APPDATA\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Tombstones\Microsoft Edge.lnk",
|
||||
"$env:PUBLIC\Desktop\Microsoft Edge.lnk",
|
||||
"$env:USERPROFILE\Desktop\Microsoft Edge.lnk",
|
||||
"$edgeStub"
|
||||
)
|
||||
|
||||
foreach ($path in $edgePaths) {
|
||||
if (Test-Path -Path $path) {
|
||||
try {
|
||||
Remove-Item -Path $path -Force -Recurse -ErrorAction Stop
|
||||
Write-Host " Removed $path" -ForegroundColor DarkGray
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove Edge leftover '$path': $($_.Exception.Message)"
|
||||
$cleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Cleaning up registry..."
|
||||
$registryCleanupSucceeded = $true
|
||||
|
||||
# Remove MS Edge from autostart. Missing values are already-clean state,
|
||||
# while failures to inspect or remove an existing value are reported.
|
||||
$autostartValues = @(
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Name = 'Microsoft Edge Update' },
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'MicrosoftEdgeAutoLaunch_A9F6DCE4ABADF4F51CF45CD7129E3C6C' },
|
||||
@{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'; Name = 'Microsoft Edge Update' }
|
||||
)
|
||||
foreach ($autostartValue in $autostartValues) {
|
||||
if (-not (Remove-EdgeAutostartValue -Path $autostartValue.Path -Name $autostartValue.Name)) {
|
||||
$registryCleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $cleanupSucceeded -or -not $registryCleanupSucceeded) {
|
||||
Write-Warning "Microsoft Edge was uninstalled, but some leftover files or autostart entries could not be removed."
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Microsoft Edge was uninstalled"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to forcefully uninstall Microsoft Edge: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
finally {
|
||||
if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() }
|
||||
if ($uninstallRegKey) { $uninstallRegKey.Dispose() }
|
||||
if ($hklm) { $hklm.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes an Edge autostart registry value when it exists.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the value is absent or removed; $false when inspection or removal fails.
|
||||
#>
|
||||
function Remove-EdgeAutostartValue {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
$properties = Get-ItemProperty -Path $Path -ErrorAction Stop
|
||||
}
|
||||
catch [System.Management.Automation.ItemNotFoundException] {
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to inspect Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not $properties.PSObject.Properties[$Name]) {
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-ItemProperty -Path $Path -Name $Name -ErrorAction Stop
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove Edge autostart entry '$Path\$Name': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes one or more Windows app packages based on the target scope.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates over the provided list of app identifiers and removes each one.
|
||||
The removal method (winget vs. Appx cmdlets) is determined per-app from
|
||||
Apps.json. A scheduled task is only created when the User or Sysprep
|
||||
parameter was passed. After winget removal, the system is checked to
|
||||
confirm whether the app is still installed before reporting an error.
|
||||
Returns early if the CancelRequested flag is set.
|
||||
|
||||
.PARAMETER appsList
|
||||
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
|
||||
|
||||
.EXAMPLE
|
||||
Remove-SelectedApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
|
||||
|
||||
.EXAMPLE
|
||||
Remove-SelectedApps -appsList (Generate-AppsList)
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when all removals can be confirmed; otherwise $false.
|
||||
#>
|
||||
function Remove-SelectedApps {
|
||||
param (
|
||||
$appslist
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
foreach ($app in $appslist) {
|
||||
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
$failuresBefore = $script:AppRemovalFailures
|
||||
$targetUser = Get-TargetUserForAppRemoval
|
||||
$appCount = @($appsList).Count
|
||||
$appIndex = 0
|
||||
|
||||
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
|
||||
$wingetRemovedApps = @()
|
||||
$wingetRemovalFailures = @{}
|
||||
|
||||
Foreach ($app in $appsList) {
|
||||
if ($script:CancelRequested) { return $false }
|
||||
|
||||
$appIndex++
|
||||
|
||||
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
|
||||
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
|
||||
}
|
||||
|
||||
Write-Host "Removing $app"
|
||||
|
||||
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
|
||||
$removalSucceeded = Remove-WinGetApp -app $app
|
||||
$wingetRemovedApps += $app
|
||||
if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) {
|
||||
$wingetRemovalFailures[$app] = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) {
|
||||
$script:AppRemovalFailures++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:CancelRequested) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check whether any winget-removed apps are still present, and report errors for each one.
|
||||
if ($wingetRemovedApps.Count -gt 0) {
|
||||
$postRemovalList = if ($script:WingetInstalled) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null }
|
||||
$edgeForceRemoveRequested = $false
|
||||
$edgeForceRemoveSucceeded = $false
|
||||
|
||||
if ($null -eq $postRemovalList) {
|
||||
$script:AppRemovalVerificationUnavailable = $true
|
||||
foreach ($app in $wingetRemovedApps) {
|
||||
$wingetRemovalFailures[$app] = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($app in $wingetRemovedApps) {
|
||||
if (-not (Test-AppInWingetList -appId $app -InstalledList $postRemovalList)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($edgeIds -contains $app) {
|
||||
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
|
||||
if (-not $edgeForceRemoveRequested) {
|
||||
$edgeForceRemoveRequested = $true
|
||||
$edgeForceRemoveSucceeded = Request-EdgeForceRemove
|
||||
}
|
||||
if ($edgeForceRemoveSucceeded) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
|
||||
}
|
||||
$wingetRemovalFailures[$app] = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$script:AppRemovalFailures += $wingetRemovalFailures.Count
|
||||
|
||||
return ($script:AppRemovalFailures -eq $failuresBefore)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Uninstalls an app via WinGet and/or schedules its removal.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs winget uninstall for a single app, with a bounded execution time.
|
||||
WinGet's own exit code/success reporting is unreliable and is only logged
|
||||
for diagnostics; it never causes this function to report failure. Callers
|
||||
verify removal with a post-removal inventory check instead. This function
|
||||
only reports failure when the winget invocation itself throws a terminating
|
||||
error (e.g. it times out or cannot be started). If the User or Sysprep
|
||||
parameter was passed, also schedules removal for future logins.
|
||||
|
||||
.PARAMETER app
|
||||
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
|
||||
|
||||
.PARAMETER TimeoutSeconds
|
||||
Maximum time to allow the foreground WinGet uninstall to run. Defaults
|
||||
to 120 seconds.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true unless the winget invocation threw a terminating error
|
||||
or any required RunOnce scheduling failed; otherwise $false.
|
||||
#>
|
||||
function Remove-WinGetApp {
|
||||
param(
|
||||
[string]$app,
|
||||
[int]$TimeoutSeconds = 120
|
||||
)
|
||||
|
||||
if (-not $script:WingetInstalled) {
|
||||
Write-Error "WinGet is either not installed or is outdated; $app could not be removed"
|
||||
return $false
|
||||
}
|
||||
|
||||
$uninstallCommandSucceeded = $true
|
||||
$exitCode = $null
|
||||
try {
|
||||
$uninstallResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($appId)
|
||||
$output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1)
|
||||
return [PSCustomObject]@{
|
||||
ExitCode = $LASTEXITCODE
|
||||
Output = $output
|
||||
}
|
||||
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
|
||||
Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null })
|
||||
$exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' }
|
||||
Write-Verbose "WinGet uninstall for $app returned exit code $exitCode."
|
||||
}
|
||||
catch {
|
||||
$uninstallCommandSucceeded = $false
|
||||
if ($_.Exception.Message -like 'Operation timed out after *') {
|
||||
Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "WinGet uninstall for $app failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
$scheduleSucceeded = $true
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
Write-Host "Adding scheduled task to uninstall $app for user $(Get-UserName)..."
|
||||
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
|
||||
}
|
||||
elseif ($script:Params.ContainsKey("Sysprep")) {
|
||||
Write-Host "Adding scheduled task to uninstall $app for new users..."
|
||||
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
|
||||
}
|
||||
|
||||
return ($uninstallCommandSucceeded -and $scheduleSucceeded)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes captured WinGet uninstall output to the verbose stream.
|
||||
|
||||
.OUTPUTS
|
||||
None.
|
||||
#>
|
||||
function Write-WinGetUninstallOutput {
|
||||
param(
|
||||
[object[]]$Output
|
||||
)
|
||||
|
||||
foreach ($line in @($Output)) {
|
||||
if ($null -eq $line) { continue }
|
||||
|
||||
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
|
||||
if ([string]::IsNullOrWhiteSpace($lineText)) { continue }
|
||||
|
||||
Write-Verbose $lineText
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
|
||||
|
||||
.PARAMETER app
|
||||
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
|
||||
|
||||
.PARAMETER targetUser
|
||||
Target scope: "AllUsers", "CurrentUser", or a specific username.
|
||||
#>
|
||||
function Remove-AppxApp {
|
||||
param([string]$app, [string]$targetUser)
|
||||
|
||||
$appPattern = '*' + $app + '*'
|
||||
|
||||
try {
|
||||
$removalResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($pattern, $target)
|
||||
|
||||
$removalErrors = @()
|
||||
$getPackageParams = @{ Name = $pattern; ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
|
||||
$removePackageParams = @{ ErrorAction = 'Continue'; ErrorVariable = '+removalErrors' }
|
||||
|
||||
switch ($target) {
|
||||
'AllUsers' {
|
||||
$getPackageParams.AllUsers = $true
|
||||
$removePackageParams.AllUsers = $true
|
||||
}
|
||||
'CurrentUser' { }
|
||||
default {
|
||||
$userAccount = New-Object System.Security.Principal.NTAccount($target)
|
||||
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
$getPackageParams.User = $userSid
|
||||
$removePackageParams.User = $userSid
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($package in @(Get-AppxPackage @getPackageParams)) {
|
||||
$removePackageParams.Package = $package.PackageFullName
|
||||
$null = Remove-AppxPackage @removePackageParams
|
||||
}
|
||||
|
||||
if ($target -eq 'AllUsers') {
|
||||
$provisionedPackages = @(Get-AppxProvisionedPackage -Online -ErrorAction Continue -ErrorVariable +removalErrors | Where-Object { $_.PackageName -like $pattern })
|
||||
foreach ($package in $provisionedPackages) {
|
||||
$null = Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $package.PackageName -ErrorAction Continue -ErrorVariable +removalErrors
|
||||
}
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{ Success = ($removalErrors.Count -eq 0) }
|
||||
} -ArgumentList @($appPattern, $targetUser)
|
||||
}
|
||||
catch {
|
||||
Write-Error "Unable to remove $app via Appx: $_"
|
||||
return $false
|
||||
}
|
||||
|
||||
return [bool]($removalResult -and $removalResult.Success)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the removal method for an app identifier.
|
||||
|
||||
.DESCRIPTION
|
||||
Parses Apps.json once (cached in script scope) to build a lookup of
|
||||
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
|
||||
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
|
||||
for unknown IDs.
|
||||
|
||||
.PARAMETER appId
|
||||
The package identifier (e.g. 'Clipchamp.Clipchamp').
|
||||
#>
|
||||
function Get-AppRemovalMethod {
|
||||
param([string]$appId)
|
||||
|
||||
if (-not $script:AppRemovalMethodCache) {
|
||||
$script:AppRemovalMethodCache = @{}
|
||||
try {
|
||||
if (Test-Path $script:AppsListFilePath) {
|
||||
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
|
||||
foreach ($appData in $appsJson.Apps) {
|
||||
$rawMethod = $appData.RemovalMethod
|
||||
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
|
||||
foreach ($id in @($appData.AppId)) {
|
||||
if ($id -isnot [string]) { continue }
|
||||
$normalizedId = $id.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($normalizedId)) {
|
||||
$script:AppRemovalMethodCache[$normalizedId] = $method
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
|
||||
return $script:AppRemovalMethodCache[$appId]
|
||||
}
|
||||
return 'Appx'
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
|
||||
|
||||
.DESCRIPTION
|
||||
Only invoked after it has been confirmed that Edge is still present
|
||||
following all winget uninstall attempts. In GUI mode, displays a
|
||||
warning message box; in CLI mode, prompts via Read-Host. On
|
||||
confirmation, performs a force-remove of the Edge package.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when Edge is forcefully removed; otherwise $false.
|
||||
#>
|
||||
function Request-EdgeForceRemove {
|
||||
if ($script:GuiWindow) {
|
||||
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
|
||||
if ($result -eq 'Yes') {
|
||||
Write-Host ""
|
||||
return (Invoke-ForceRemoveEdge)
|
||||
}
|
||||
}
|
||||
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
|
||||
Write-Host ""
|
||||
return (Invoke-ForceRemoveEdge)
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
|
||||
via the PowerShell registry API within Invoke-WithTargetUserHive,
|
||||
which handles hive loading and HKEY_USERS\Default → SID remapping.
|
||||
Used instead of static .reg files to avoid file dependency for each WinGet app.
|
||||
|
||||
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
|
||||
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
|
||||
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
|
||||
even if future catalog updates introduce IDs containing those characters.
|
||||
|
||||
.PARAMETER appId
|
||||
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
|
||||
#>
|
||||
function Set-RunOnceWingetTask {
|
||||
param([string]$appId)
|
||||
|
||||
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
|
||||
|
||||
# Sanitize appId for use in registry value names (backslashes are path separators)
|
||||
$safeAppId = $appId.Replace('\', '_')
|
||||
|
||||
$taskName = "Uninstall_$safeAppId"
|
||||
|
||||
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
|
||||
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
|
||||
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
|
||||
$escapedAppId = $appId.Replace("'", "''")
|
||||
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
|
||||
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
|
||||
|
||||
$operation = [PSCustomObject]@{
|
||||
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
ValueName = $taskName
|
||||
ValueType = 'String'
|
||||
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
|
||||
OperationType = 'SetValue'
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
|
||||
param($op)
|
||||
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
|
||||
} -ArgumentObject $operation
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to schedule uninstall task for $($appId): $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes one or more Windows app packages based on the target scope.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates over the provided list of app identifiers and removes each one.
|
||||
The removal method (winget vs. Appx cmdlets) is determined per-app from
|
||||
Apps.json. Microsoft Edge is deferred to the end of the loop so that all
|
||||
winget attempts run before any force-remove prompt. A scheduled task is
|
||||
only created when the User or Sysprep parameter was passed.
|
||||
After each winget removal, the system is checked to confirm whether the
|
||||
app is still installed before reporting an error.
|
||||
|
||||
.PARAMETER appsList
|
||||
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
|
||||
|
||||
.EXAMPLE
|
||||
RemoveApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
|
||||
|
||||
.EXAMPLE
|
||||
RemoveApps -appsList (GenerateAppsList)
|
||||
#>
|
||||
function RemoveApps {
|
||||
param (
|
||||
$appslist
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
foreach ($app in $appslist) {
|
||||
Write-Host "[WhatIf] Remove App Package: $app" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
$targetUser = GetTargetUserForAppRemoval
|
||||
$appCount = @($appsList).Count
|
||||
$appIndex = 0
|
||||
|
||||
$edgeIds = @('Microsoft.Edge', 'XPFFTQ037JWMHS')
|
||||
$edgeAppsInList = @()
|
||||
$wingetRemovedApps = @()
|
||||
|
||||
Foreach ($app in $appsList) {
|
||||
if ($script:CancelRequested) { return }
|
||||
|
||||
$appIndex++
|
||||
|
||||
if ($script:ApplySubStepCallback -and $appCount -gt 1) {
|
||||
& $script:ApplySubStepCallback "Removing apps ($appIndex/$appCount)" $appIndex $appCount
|
||||
}
|
||||
|
||||
# Microsoft Edge is handled after the loop to avoid duplicate scheduled tasks and allow fallback if winget fails
|
||||
if ($edgeIds -contains $app) {
|
||||
$edgeAppsInList += $app
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "Removing $app"
|
||||
|
||||
if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
|
||||
Remove-WinGetApp -app $app
|
||||
$wingetRemovedApps += $app
|
||||
}
|
||||
else {
|
||||
Remove-AppxApp -app $app -targetUser $targetUser
|
||||
}
|
||||
}
|
||||
|
||||
# Remove Microsoft Edge
|
||||
if ($edgeAppsInList.Count -gt 0) {
|
||||
Remove-EdgeApp -edgeAppsInList $edgeAppsInList
|
||||
}
|
||||
|
||||
# Check whether any winget-removed apps are still present, and report errors for each one.
|
||||
if ($wingetRemovedApps.Count -gt 0 -or $edgeAppsInList.Count -gt 0) {
|
||||
$postRemovalList = if ($script:WingetInstalled) { GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking } else { $null }
|
||||
foreach ($app in $wingetRemovedApps) {
|
||||
if (Test-AppStillInstalled -appId $app -InstalledList $postRemovalList) {
|
||||
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# Verify Edge separately (triggers its own force-remove path if still installed)
|
||||
$edgeStillInstalled = $false
|
||||
foreach ($edgeApp in $edgeAppsInList) {
|
||||
if (Test-AppStillInstalled -appId $edgeApp -InstalledList $postRemovalList) {
|
||||
$edgeStillInstalled = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($edgeStillInstalled) {
|
||||
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
|
||||
Request-EdgeForceRemove
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Uninstalls a non-Edge app via WinGet and/or schedules its removal.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs winget uninstall for a single app. If the User or Sysprep
|
||||
parameter was passed, also schedules removal for future logins.
|
||||
After uninstall, the system is checked to confirm whether the app
|
||||
is still present — winget output is not trusted on its
|
||||
own, as it sometimes reports failure after a successful removal.
|
||||
Edge apps are handled separately after the main loop.
|
||||
|
||||
.PARAMETER app
|
||||
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
|
||||
#>
|
||||
function Remove-WinGetApp {
|
||||
param([string]$app)
|
||||
|
||||
if (-not $script:WingetInstalled) {
|
||||
Write-Host "ERROR: WinGet is either not installed or is outdated, $app could not be removed" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
Write-Host "Adding scheduled task to uninstall $app for user $(GetUserName)..."
|
||||
Set-RunOnceWingetTask -appId $app
|
||||
}
|
||||
elseif ($script:Params.ContainsKey("Sysprep")) {
|
||||
Write-Host "Adding scheduled task to uninstall $app for new users..."
|
||||
Set-RunOnceWingetTask -appId $app
|
||||
}
|
||||
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($appId)
|
||||
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
|
||||
} -ArgumentList $app
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes Microsoft Edge via WinGet (both AppIds), with fallback to force-remove.
|
||||
|
||||
.DESCRIPTION
|
||||
Edge has multiple package IDs. Runs winget uninstall for each one,
|
||||
then creates a single scheduled task if the User or Sysprep parameter
|
||||
was passed. After all attempts, the system is checked to confirm
|
||||
whether Edge is still present. The force-remove prompt only
|
||||
appears if Edge remains installed — winget false positives are ignored.
|
||||
|
||||
.PARAMETER edgeAppsInList
|
||||
The Edge AppIds that appear in the removal list (one or both).
|
||||
#>
|
||||
function Remove-EdgeApp {
|
||||
param([string[]]$edgeAppsInList)
|
||||
|
||||
if (-not $script:WingetInstalled) {
|
||||
Write-Host "ERROR: WinGet is either not installed or is outdated, Microsoft Edge could not be removed" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
Write-Host "Adding scheduled task to uninstall Microsoft Edge for user $(GetUserName)..."
|
||||
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
|
||||
}
|
||||
elseif ($script:Params.ContainsKey("Sysprep")) {
|
||||
Write-Host "Adding scheduled task to uninstall Microsoft Edge for new users..."
|
||||
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
|
||||
}
|
||||
|
||||
foreach ($edgeApp in $edgeAppsInList) {
|
||||
Write-Host "Removing $edgeApp"
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($appId)
|
||||
winget uninstall --accept-source-agreements --disable-interactivity --id $appId
|
||||
} -ArgumentList $edgeApp
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
|
||||
|
||||
.PARAMETER app
|
||||
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
|
||||
|
||||
.PARAMETER targetUser
|
||||
Target scope: "AllUsers", "CurrentUser", or a specific username.
|
||||
#>
|
||||
function Remove-AppxApp {
|
||||
param([string]$app, [string]$targetUser)
|
||||
|
||||
$appPattern = '*' + $app + '*'
|
||||
|
||||
try {
|
||||
switch ($targetUser) {
|
||||
"AllUsers" {
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($pattern)
|
||||
Get-AppxPackage -Name $pattern -AllUsers | Remove-AppxPackage -AllUsers -ErrorAction Continue
|
||||
Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like $pattern } | ForEach-Object { Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $_.PackageName }
|
||||
} -ArgumentList $appPattern
|
||||
}
|
||||
"CurrentUser" {
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($pattern)
|
||||
Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue
|
||||
} -ArgumentList $appPattern
|
||||
}
|
||||
default {
|
||||
Invoke-NonBlocking -ScriptBlock {
|
||||
param($pattern, $user)
|
||||
$userAccount = New-Object System.Security.Principal.NTAccount($user)
|
||||
$userSid = $userAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
Get-AppxPackage -Name $pattern -User $userSid | Remove-AppxPackage -User $userSid -ErrorAction Continue
|
||||
} -ArgumentList @($appPattern, $targetUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Verbose "Something went wrong while trying to remove $($app): $_"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks whether an app package is still installed after a removal attempt.
|
||||
|
||||
.DESCRIPTION
|
||||
Checks Get-AppxPackage across all users first (fast, no process launch),
|
||||
then falls back to a pre-fetched or live winget list for non-Appx packages.
|
||||
Uses Test-AppInWingetList which provides exact-match-first with substring
|
||||
fallback against the parsed winget objects.
|
||||
Returns $true if the app is still present, $false otherwise.
|
||||
|
||||
.PARAMETER appId
|
||||
The package identifier to check (e.g. 'Microsoft.BingNews').
|
||||
|
||||
.PARAMETER InstalledList
|
||||
Optional pre-fetched array of winget objects from GetInstalledAppsViaWinget.
|
||||
When provided, used directly; otherwise a live winget call is made.
|
||||
#>
|
||||
function Test-AppStillInstalled {
|
||||
param(
|
||||
[string]$appId,
|
||||
[object[]]$InstalledList
|
||||
)
|
||||
|
||||
# Check Get-AppxPackage for all users first (fast, covers all Store apps).
|
||||
if (Get-AppxPackage -Name "$appId" -AllUsers -ErrorAction SilentlyContinue) {
|
||||
return $true
|
||||
}
|
||||
|
||||
# Use the pre-fetched list if provided; otherwise fall back to a live winget call.
|
||||
if ($InstalledList) {
|
||||
return (Test-AppInWingetList -appId $appId -InstalledList $InstalledList)
|
||||
}
|
||||
|
||||
if ($script:WingetInstalled) {
|
||||
$liveList = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
|
||||
if (Test-AppInWingetList -appId $appId -InstalledList $liveList) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "Unable to verify whether '$appId' is still installed (WinGet is unavailable)"
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the removal method for an app identifier.
|
||||
|
||||
.DESCRIPTION
|
||||
Parses Apps.json once (cached in script scope) to build a lookup of
|
||||
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
|
||||
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
|
||||
for unknown IDs.
|
||||
|
||||
.PARAMETER appId
|
||||
The package identifier (e.g. 'Clipchamp.Clipchamp').
|
||||
#>
|
||||
function Get-AppRemovalMethod {
|
||||
param([string]$appId)
|
||||
|
||||
if (-not $script:AppRemovalMethodCache) {
|
||||
$script:AppRemovalMethodCache = @{}
|
||||
try {
|
||||
if (Test-Path $script:AppsListFilePath) {
|
||||
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
|
||||
foreach ($appData in $appsJson.Apps) {
|
||||
$rawMethod = $appData.RemovalMethod
|
||||
$method = if ($rawMethod -and $rawMethod -eq 'WinGet') { 'WinGet' } else { 'Appx' }
|
||||
if ($appData.AppId -is [array]) {
|
||||
foreach ($id in $appData.AppId) { $script:AppRemovalMethodCache[$id.Trim()] = $method }
|
||||
}
|
||||
else {
|
||||
$script:AppRemovalMethodCache[$appData.AppId.Trim()] = $method
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to load app removal methods from '$script:AppsListFilePath'. Defaulting unknown apps to Appx. Error: $_"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:AppRemovalMethodCache.ContainsKey($appId)) {
|
||||
return $script:AppRemovalMethodCache[$appId]
|
||||
}
|
||||
return 'Appx'
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
|
||||
|
||||
.DESCRIPTION
|
||||
Only invoked after it has been confirmed that Edge is still present
|
||||
following all winget uninstall attempts. In GUI mode, displays a
|
||||
warning message box; in CLI mode, prompts via Read-Host. On
|
||||
confirmation, performs a force-remove of the Edge package.
|
||||
#>
|
||||
function Request-EdgeForceRemove {
|
||||
if ($script:GuiWindow) {
|
||||
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
|
||||
if ($result -eq 'Yes') {
|
||||
Write-Host ""
|
||||
ForceRemoveEdge
|
||||
}
|
||||
}
|
||||
elseif ($(Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)") -eq 'y') {
|
||||
Write-Host ""
|
||||
ForceRemoveEdge
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
|
||||
via the PowerShell registry API within Invoke-WithTargetUserHive,
|
||||
which handles hive loading and HKEY_USERS\Default → SID remapping.
|
||||
Used instead of static .reg files to avoid file dependency for each WinGet app.
|
||||
|
||||
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
|
||||
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
|
||||
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
|
||||
even if future catalog updates introduce IDs containing those characters.
|
||||
|
||||
.PARAMETER appId
|
||||
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
|
||||
#>
|
||||
function Set-RunOnceWingetTask {
|
||||
param([string]$appId)
|
||||
|
||||
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
|
||||
|
||||
# Sanitize appId for use in registry value names (backslashes are path separators)
|
||||
$safeAppId = $appId.Replace('\', '_')
|
||||
|
||||
$taskName = "Uninstall_$safeAppId"
|
||||
|
||||
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
|
||||
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
|
||||
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
|
||||
$escapedAppId = $appId.Replace("'", "''")
|
||||
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id '$escapedAppId'"
|
||||
$encodedWingetCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wingetCommand))
|
||||
|
||||
$operation = [PSCustomObject]@{
|
||||
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
|
||||
ValueName = $taskName
|
||||
ValueType = 'String'
|
||||
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand"
|
||||
OperationType = 'SetValue'
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
|
||||
param($op)
|
||||
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
|
||||
} -ArgumentObject $operation
|
||||
}
|
||||
catch {
|
||||
Write-Host "Failed to schedule uninstall task for $($appId): $_" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
The identifier to search for (e.g. 'Microsoft.Copilot').
|
||||
|
||||
.PARAMETER InstalledList
|
||||
An array of PSCustomObject from GetInstalledAppsViaWinget.
|
||||
An array of PSCustomObject from Get-WingetInstalledApps.
|
||||
#>
|
||||
function Test-AppInWingetList {
|
||||
param(
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
function AwaitKeyToExit {
|
||||
# Suppress prompt if Silent parameter was passed
|
||||
if (-not $Silent) {
|
||||
Write-Output ""
|
||||
Write-Output "Press any key to exit..."
|
||||
$null = [System.Console]::ReadKey()
|
||||
}
|
||||
|
||||
Stop-Transcript
|
||||
Exit
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Shows the CLI app removal menu and prompts the user to select which apps to remove.
|
||||
function ShowCLIAppRemoval {
|
||||
PrintHeader "App Removal"
|
||||
function Show-CliAppRemoval {
|
||||
Write-CliHeader "App Removal"
|
||||
|
||||
Write-Output "> Opening app selection form..."
|
||||
|
||||
@@ -8,10 +8,10 @@ function ShowCLIAppRemoval {
|
||||
|
||||
if ($result -eq $true) {
|
||||
Write-Output "You have selected $($script:SelectedApps.Count) apps for removal"
|
||||
AddParameter 'RemoveApps'
|
||||
AddParameter 'Apps' ($script:SelectedApps -join ',')
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
|
||||
|
||||
SaveSettings
|
||||
Save-Settings
|
||||
|
||||
# Suppress prompt if Silent parameter was passed
|
||||
if (-not $Silent) {
|
||||
@@ -19,7 +19,7 @@ function ShowCLIAppRemoval {
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to remove the selected apps or press CTRL+C to quit..."
|
||||
Read-Host | Out-Null
|
||||
PrintHeader "App Removal"
|
||||
Write-CliHeader "App Removal"
|
||||
}
|
||||
}
|
||||
else {
|
||||
+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 ""
|
||||
+14
-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,40 +15,40 @@ function ShowCLIDefaultModeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
PrintHeader 'Default Mode'
|
||||
Write-CliHeader 'Default Mode'
|
||||
|
||||
try {
|
||||
# Select app removal options based on user input
|
||||
switch ($RemoveAppsInput) {
|
||||
'1' {
|
||||
AddParameter 'RemoveApps'
|
||||
AddParameter 'Apps' 'Default'
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' 'Default'
|
||||
}
|
||||
'2' {
|
||||
AddParameter 'RemoveApps'
|
||||
AddParameter 'Apps' ($script:SelectedApps -join ',')
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' ($script:SelectedApps -join ',')
|
||||
|
||||
if ($DisableGameBarIntegrationInput) {
|
||||
AddParameter 'DisableDVR'
|
||||
AddParameter 'DisableGameBarIntegration'
|
||||
Add-Parameter 'DisableDVR'
|
||||
Add-Parameter 'DisableGameBarIntegration'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadSettings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
Import-Settings -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load settings from DefaultSettings.json file: $_"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
|
||||
SaveSettings
|
||||
Save-Settings
|
||||
|
||||
if ($Silent) {
|
||||
# Skip change summary and confirmation prompt
|
||||
return
|
||||
}
|
||||
|
||||
PrintPendingChanges
|
||||
PrintHeader 'Default Mode'
|
||||
Write-PendingChanges
|
||||
Write-CliHeader 'Default Mode'
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
# Shows the CLI last used settings from LastUsedSettings.json file, displays pending changes and prompts the user to apply them.
|
||||
function ShowCLILastUsedSettings {
|
||||
PrintHeader 'Custom Mode'
|
||||
function Show-CliLastUsedSettings {
|
||||
Write-CliHeader 'Custom Mode'
|
||||
|
||||
try {
|
||||
LoadSettings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
|
||||
Import-Settings -filePath $script:SavedSettingsFilePath -expectedVersion "1.0"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load settings from LastUsedSettings.json file: $_"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
|
||||
if ($Silent) {
|
||||
@@ -15,6 +15,6 @@ function ShowCLILastUsedSettings {
|
||||
return
|
||||
}
|
||||
|
||||
PrintPendingChanges
|
||||
PrintHeader 'Custom Mode'
|
||||
Write-PendingChanges
|
||||
Write-CliHeader 'Custom Mode'
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# Shows the CLI menu options and prompts the user to select one. Loops until a valid option is selected.
|
||||
function ShowCLIMenuOptions {
|
||||
function Show-CliMenuOptions {
|
||||
Do {
|
||||
$ModeSelectionMessage = "Please select an option (1/2)"
|
||||
|
||||
PrintHeader 'Menu'
|
||||
Write-CliHeader 'Menu'
|
||||
|
||||
Write-Host "(1) Default mode: Quickly apply the recommended changes"
|
||||
Write-Host "(2) App removal mode: Select & remove apps, without making other changes"
|
||||
@@ -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,7 +10,7 @@ function PrintHeader {
|
||||
$fullTitle = "$fullTitle (Sysprep mode)"
|
||||
}
|
||||
else {
|
||||
$fullTitle = "$fullTitle (User: $(GetUserName))"
|
||||
$fullTitle = "$fullTitle (User: $(Get-UserName))"
|
||||
}
|
||||
|
||||
Clear-Host
|
||||
@@ -12,7 +12,7 @@
|
||||
After printing the summary the function pauses until the user presses
|
||||
Enter, giving them an opportunity to review and cancel via Ctrl+C.
|
||||
#>
|
||||
function PrintPendingChanges {
|
||||
function Write-PendingChanges {
|
||||
Write-Output "Win11Debloat will make the following changes:"
|
||||
|
||||
if ($script:Params['CreateRestorePoint']) {
|
||||
@@ -32,7 +32,7 @@ function PrintPendingChanges {
|
||||
continue
|
||||
}
|
||||
'RemoveApps' {
|
||||
$appsList = GenerateAppsList
|
||||
$appsList = Generate-AppsList
|
||||
|
||||
if ($appsList.Count -eq 0) {
|
||||
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
||||
+38
-5
@@ -195,10 +195,19 @@ function Get-RegistryKeySnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts an open registry key into a backup snapshot.
|
||||
|
||||
.DESCRIPTION
|
||||
Captures all values or selected value names, records missing selected values,
|
||||
and recursively captures subkeys when requested. Throws if a requested subkey
|
||||
cannot be read.
|
||||
#>
|
||||
function Convert-RegistryKeyToSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FullPath,
|
||||
[bool]$CaptureAllValues = $false,
|
||||
@@ -233,7 +242,9 @@ function Convert-RegistryKeyToSnapshot {
|
||||
if ($IncludeSubKeys) {
|
||||
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
|
||||
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
|
||||
if ($null -eq $childKey) { continue }
|
||||
if ($null -eq $childKey) {
|
||||
throw "Unable to read registry subkey '$($RegistryKey.Name)\$subKeyName' while creating a backup snapshot. The backup was not created."
|
||||
}
|
||||
|
||||
try {
|
||||
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
|
||||
@@ -253,20 +264,34 @@ function Convert-RegistryKeyToSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a registry value into a serializable backup snapshot.
|
||||
|
||||
.DESCRIPTION
|
||||
Preserves the value kind and normalizes supported data types for JSON
|
||||
serialization without expanding environment-string values. REG_NONE values
|
||||
are rejected.
|
||||
#>
|
||||
function Convert-RegistryValueToSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string]$ValueName
|
||||
)
|
||||
|
||||
$valueKind = $RegistryKey.GetValueKind($ValueName)
|
||||
if ($valueKind -eq [Microsoft.Win32.RegistryValueKind]::None) {
|
||||
throw "REG_NONE registry values are not supported for backup. Key='$($RegistryKey.Name)' Name='$ValueName'"
|
||||
}
|
||||
|
||||
$value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
try {
|
||||
$normalizedValue = switch ($valueKind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) { @($value | ForEach-Object { [int]$_ }) }
|
||||
# Prevent an empty byte sequence from being unrolled to $null by the switch pipeline.
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) { if ($null -eq $value) { ,@() } else { ,@($value | ForEach-Object { [int]$_ }) } }
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) { @($value) }
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) { [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$value), 0) }
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) { [BitConverter]::ToUInt64([BitConverter]::GetBytes([int64]$value), 0) }
|
||||
@@ -287,12 +312,20 @@ function Convert-RegistryValueToSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Describes the user profile targeted by a registry backup.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns DefaultUserProfile for Sysprep, User:<name> for an explicit user,
|
||||
or CurrentUser:<name> otherwise.
|
||||
#>
|
||||
function Get-RegistryBackupTargetDescription {
|
||||
if ($script:Params.ContainsKey('Sysprep')) {
|
||||
return 'DefaultUserProfile'
|
||||
}
|
||||
|
||||
$resolvedUserName = [string](GetUserName)
|
||||
$resolvedUserName = [string](Get-UserName)
|
||||
|
||||
if ($script:Params.ContainsKey('User')) {
|
||||
return "User:$resolvedUserName"
|
||||
+1
-1
@@ -37,7 +37,7 @@ function New-RegistrySettingsBackup {
|
||||
$backupFilePath = Join-Path $backupDirectory $backupFileName
|
||||
|
||||
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -UndoFeatures $undoFeatures -CreatedAt $timestamp
|
||||
if (-not (SaveToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
|
||||
if (-not (Save-ToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
|
||||
throw "Failed to save registry backup to '$backupFilePath'"
|
||||
}
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ function Test-FeatureApplied {
|
||||
return (Test-StoreSearchSuggestionsDisabledForAllUsers)
|
||||
}
|
||||
|
||||
$storeDbPath = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
|
||||
$storeDbPath = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||
|
||||
return (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDbPath)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports and executes a registry file.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the registry file is applied or previewed successfully; otherwise $false.
|
||||
#>
|
||||
function Import-RegistryFile {
|
||||
param (
|
||||
$message,
|
||||
$path
|
||||
)
|
||||
|
||||
try {
|
||||
Write-Host $message
|
||||
|
||||
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
|
||||
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
|
||||
|
||||
if (-not (Test-Path $regFilePath)) {
|
||||
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
|
||||
Write-Host "Error: $errorMessage" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
$importScript = {
|
||||
param($targetRegFilePath, $hiveContext)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
return (Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath)
|
||||
}
|
||||
|
||||
# When the target user's hive is already loaded under their SID, the .reg file's
|
||||
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
|
||||
# which remaps Default → SID via Split-RegistryPath.
|
||||
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
|
||||
|
||||
if ($usePowerShellFallbackOnly) {
|
||||
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
if ($fallbackSucceeded) {
|
||||
Write-Host "The operation completed successfully via PowerShell registry writer."
|
||||
}
|
||||
return $fallbackSucceeded
|
||||
}
|
||||
|
||||
$regResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($targetRegFilePath)
|
||||
$result = @{
|
||||
Output = @()
|
||||
ExitCode = 0
|
||||
Error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$global:LASTEXITCODE = 0
|
||||
$output = reg import $targetRegFilePath 2>&1
|
||||
$importExitCode = $LASTEXITCODE
|
||||
|
||||
if ($output) {
|
||||
$result.Output = @($output)
|
||||
}
|
||||
$result.ExitCode = $importExitCode
|
||||
|
||||
if ($importExitCode -ne 0) {
|
||||
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$result.Error = $_.Exception.Message
|
||||
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
|
||||
}
|
||||
|
||||
return $result
|
||||
} -ArgumentList $targetRegFilePath
|
||||
|
||||
$regOutput = @($regResult.Output)
|
||||
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
|
||||
|
||||
if ($regOutput) {
|
||||
foreach ($line in $regOutput) {
|
||||
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
|
||||
if ($lineText -and $lineText.Length -gt 0) {
|
||||
if ($hasSuccess) {
|
||||
Write-Host $lineText
|
||||
}
|
||||
else {
|
||||
Write-Host $lineText -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $hasSuccess) {
|
||||
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
|
||||
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
|
||||
$fallbackSucceeded = Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
if ($fallbackSucceeded) {
|
||||
Write-Host "The operation completed successfully via PowerShell registry writer."
|
||||
}
|
||||
return $fallbackSucceeded
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($usesOfflineHive) {
|
||||
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
|
||||
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
|
||||
$succeeded = Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
|
||||
}
|
||||
else {
|
||||
$succeeded = & $importScript $regFilePath $null
|
||||
}
|
||||
return [bool]$succeeded
|
||||
}
|
||||
catch {
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
# Import & execute regfile
|
||||
function ImportRegistryFile {
|
||||
param (
|
||||
$message,
|
||||
$path
|
||||
)
|
||||
|
||||
Write-Host $message
|
||||
|
||||
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
|
||||
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
|
||||
|
||||
if (-not (Test-Path $regFilePath)) {
|
||||
$errorMessage = "Unable to find registry file: $path ($regFilePath)"
|
||||
$script:RegistryImportFailures++
|
||||
Write-Host "Error: $errorMessage" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
throw $errorMessage
|
||||
}
|
||||
|
||||
$importScript = {
|
||||
param($targetRegFilePath, $hiveContext)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
# When the target user's hive is already loaded under their SID, the .reg file's
|
||||
# HKEY_USERS\Default paths won't match. Use the PowerShell registry writer instead,
|
||||
# which remaps Default → SID via Split-RegistryPath.
|
||||
$usePowerShellFallbackOnly = $hiveContext -and [bool]$hiveContext.WasAlreadyLoaded
|
||||
|
||||
if ($usePowerShellFallbackOnly) {
|
||||
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
Write-Host "The operation completed successfully via PowerShell registry writer."
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
$regResult = Invoke-NonBlocking -ScriptBlock {
|
||||
param($targetRegFilePath)
|
||||
$result = @{
|
||||
Output = @()
|
||||
ExitCode = 0
|
||||
Error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$global:LASTEXITCODE = 0
|
||||
$output = reg import $targetRegFilePath 2>&1
|
||||
$importExitCode = $LASTEXITCODE
|
||||
|
||||
if ($output) {
|
||||
$result.Output = @($output)
|
||||
}
|
||||
$result.ExitCode = $importExitCode
|
||||
|
||||
if ($importExitCode -ne 0) {
|
||||
throw "Registry import failed with exit code $importExitCode for '$targetRegFilePath'"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$result.Error = $_.Exception.Message
|
||||
$result.ExitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
|
||||
}
|
||||
|
||||
return $result
|
||||
} -ArgumentList $targetRegFilePath
|
||||
|
||||
$regOutput = @($regResult.Output)
|
||||
$hasSuccess = ($regResult.ExitCode -eq 0) -and -not $regResult.Error
|
||||
|
||||
if ($regOutput) {
|
||||
foreach ($line in $regOutput) {
|
||||
$lineText = if ($line -is [System.Management.Automation.ErrorRecord]) { $line.Exception.Message } else { $line.ToString() }
|
||||
if ($lineText -and $lineText.Length -gt 0) {
|
||||
if ($hasSuccess) {
|
||||
Write-Host $lineText
|
||||
}
|
||||
else {
|
||||
Write-Host $lineText -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $hasSuccess) {
|
||||
$details = if ($regResult.Error) { $regResult.Error } else { "Exit code: $($regResult.ExitCode)" }
|
||||
Write-Warning "reg import failed for '$path'. Falling back to PowerShell registry writer. Details: $details"
|
||||
Invoke-RegistryOperationsFromRegFile -RegFilePath $targetRegFilePath
|
||||
Write-Host "The operation completed successfully via PowerShell registry writer."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
try {
|
||||
if ($usesOfflineHive) {
|
||||
# Sysprep targets Default user, User targets the specified user. Logged-in users already have their hive mounted under HKU\<SID>.
|
||||
$targetUserName = if ($script:Params.ContainsKey("Sysprep")) { "Default" } else { $script:Params.Item("User") }
|
||||
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $importScript -ArgumentObject $regFilePath -PassHiveContext
|
||||
}
|
||||
else {
|
||||
& $importScript $regFilePath $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$script:RegistryImportFailures++
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Handles two categories of features:
|
||||
- Registry-backed: imports the .reg file via ImportRegistryFile, then runs
|
||||
- Registry-backed: imports the .reg file via Import-RegistryFile, then runs
|
||||
any post-import side effects (e.g., removing companion app packages).
|
||||
- Custom logic: app removal, Windows optional features, start menu
|
||||
replacement, and other special-case features.
|
||||
replacement, and other special-case features. Returns $true when the
|
||||
feature completes successfully; otherwise writes a warning and returns
|
||||
$false.
|
||||
#>
|
||||
function Invoke-FeatureApply {
|
||||
param(
|
||||
@@ -15,137 +17,138 @@ function Invoke-FeatureApply {
|
||||
[string]$FeatureId
|
||||
)
|
||||
|
||||
try {
|
||||
# Resolve feature metadata from Features.json
|
||||
$feature = $script:Features[$FeatureId]
|
||||
$applyText = $feature.ApplyText
|
||||
|
||||
# ---- Registry-backed features: import .reg file, then handle side effects ----
|
||||
# ---- Registry-backed features: import .reg file, then handle additional tasks ----
|
||||
if ($feature.RegistryKey) {
|
||||
ImportRegistryFile "> $applyText..." $feature.RegistryKey
|
||||
if (-not (Import-RegistryFile "> $applyText..." $feature.RegistryKey)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Post-import side effects for specific features
|
||||
switch ($FeatureId) {
|
||||
'DisableBing' {
|
||||
# Also remove the app package for Bing search
|
||||
RemoveApps @('Microsoft.BingSearch')
|
||||
return (Remove-SelectedApps @('Microsoft.BingSearch'))
|
||||
}
|
||||
'DisableCopilot' {
|
||||
# Also remove the app packages for Copilot
|
||||
RemoveApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX')
|
||||
return (Remove-SelectedApps @('Microsoft.Copilot', 'XP9CXNGPPJ97XX'))
|
||||
}
|
||||
'DisableTelemetry' {
|
||||
# Also disable telemetry scheduled tasks
|
||||
Disable-TelemetryScheduledTasks
|
||||
return (Disable-TelemetryScheduledTasks)
|
||||
}
|
||||
}
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
# ---- Custom features (no registry backing, or special handling required) ----
|
||||
switch ($FeatureId) {
|
||||
'RemoveApps' {
|
||||
Write-Host "> $applyText for $(GetFriendlyTargetUserName)..."
|
||||
$appsList = GenerateAppsList
|
||||
Write-Host "> $applyText for $(Get-FriendlyTargetUserName)..."
|
||||
$appsList = Generate-AppsList
|
||||
|
||||
if ($appsList.Count -eq 0) {
|
||||
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Host "$($appsList.Count) apps selected for removal"
|
||||
RemoveApps $appsList
|
||||
return
|
||||
return (Remove-SelectedApps $appsList)
|
||||
}
|
||||
'RemoveGamingApps' {
|
||||
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
|
||||
Write-Host "> $applyText..."
|
||||
RemoveApps $appsList
|
||||
return
|
||||
return (Remove-SelectedApps $appsList)
|
||||
}
|
||||
'RemoveHPApps' {
|
||||
$appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl')
|
||||
Write-Host "> $applyText..."
|
||||
RemoveApps $appsList
|
||||
return
|
||||
return (Remove-SelectedApps $appsList)
|
||||
}
|
||||
'ForceRemoveEdge' {
|
||||
Write-Host "> $applyText..."
|
||||
return (Invoke-ForceRemoveEdge)
|
||||
}
|
||||
'DisableWidgets' {
|
||||
Write-Host "> $applyText..."
|
||||
# Stop widgets related processes before removing the app packages to prevent potential issues
|
||||
if (-not $script:Params.ContainsKey("WhatIf")) {
|
||||
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process
|
||||
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')
|
||||
return
|
||||
return (Remove-SelectedApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'))
|
||||
}
|
||||
'EnableWindowsSandbox' {
|
||||
Write-Host "> $applyText..."
|
||||
EnableWindowsFeature "Containers-DisposableClientVM"
|
||||
Write-Host ""
|
||||
return
|
||||
return (Enable-WindowsFeature "Containers-DisposableClientVM")
|
||||
}
|
||||
'EnableWindowsSubsystemForLinux' {
|
||||
Write-Host "> $applyText..."
|
||||
EnableWindowsFeature "VirtualMachinePlatform"
|
||||
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
|
||||
Write-Host ""
|
||||
return
|
||||
if (-not (Enable-WindowsFeature "VirtualMachinePlatform")) { return $false }
|
||||
return (Enable-WindowsFeature "Microsoft-Windows-Subsystem-Linux")
|
||||
}
|
||||
'ClearStart' {
|
||||
Write-Host "> $applyText for user $(GetUserName)..."
|
||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
|
||||
Write-Host "> $applyText for user $(Get-UserName)..."
|
||||
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
|
||||
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
||||
ReplaceStartMenu -startMenuBinFile $startMenuBinFile
|
||||
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile)
|
||||
}
|
||||
Write-Host ""
|
||||
return
|
||||
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
'ReplaceStart' {
|
||||
Write-Host "> $applyText for user $(GetUserName)..."
|
||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
|
||||
Write-Host "> $applyText for user $(Get-UserName)..."
|
||||
$startMenuBinFile = Get-StartMenuBinPathForUser -UserName (Get-UserName)
|
||||
if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) {
|
||||
ReplaceStartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart")
|
||||
return (Replace-StartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart"))
|
||||
}
|
||||
Write-Host ""
|
||||
return
|
||||
Write-Warning "Unable to apply '$applyText': the Start menu path for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
'ClearStartAllUsers' {
|
||||
ReplaceStartMenuForAllUsers
|
||||
return
|
||||
return (Replace-StartMenuForAllUsers)
|
||||
}
|
||||
'ReplaceStartAllUsers' {
|
||||
ReplaceStartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers")
|
||||
return
|
||||
return (Replace-StartMenuForAllUsers -startMenuTemplate $script:Params.Item("ReplaceStartAllUsers"))
|
||||
}
|
||||
'DisableStoreSearchSuggestions' {
|
||||
if ($script:Params.ContainsKey("Sysprep")) {
|
||||
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
|
||||
DisableStoreSearchSuggestionsForAllUsers
|
||||
Write-Host ""
|
||||
return
|
||||
return (Set-StoreSearchSuggestionsDisabledForAllUsers)
|
||||
}
|
||||
|
||||
Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..."
|
||||
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
|
||||
Write-Host "> Disabling Microsoft Store search suggestions for user $(Get-UserName)..."
|
||||
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||
if ($storeDb) {
|
||||
DisableStoreSearchSuggestions -StoreAppsDatabase $storeDb
|
||||
return (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $storeDb)
|
||||
}
|
||||
Write-Host ""
|
||||
return
|
||||
Write-Warning "Unable to disable Microsoft Store search suggestions because the Store database for user $(Get-UserName) could not be resolved."
|
||||
return $false
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to apply '$applyText': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Warning "Unknown feature '$FeatureId' could not be applied."
|
||||
return $false
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Undoes a single feature that has no RegistryUndoKey.
|
||||
Undoes a single feature.
|
||||
|
||||
.DESCRIPTION
|
||||
Handles undo for features that require custom logic rather than a simple
|
||||
.reg file import. Features with a RegistryUndoKey are handled directly
|
||||
via ImportRegistryFile in Invoke-UndoFeatures.
|
||||
Handles registry-backed undo imports and custom undo logic. Returns
|
||||
$true when the requested undo succeeds; otherwise writes a warning and
|
||||
returns $false.
|
||||
#>
|
||||
function Invoke-FeatureUndo {
|
||||
param(
|
||||
@@ -154,45 +157,67 @@ function Invoke-FeatureUndo {
|
||||
)
|
||||
|
||||
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
|
||||
if (-not $feature) {
|
||||
Write-Warning "Unknown feature '$FeatureId' could not be undone."
|
||||
return $false
|
||||
}
|
||||
|
||||
$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..."
|
||||
EnableStoreSearchSuggestionsForAllUsers
|
||||
Write-Host ""
|
||||
return
|
||||
return (Set-StoreSearchSuggestionsEnabledForAllUsers)
|
||||
}
|
||||
|
||||
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..."
|
||||
$storeDb = GetStoreAppsDatabasePathForUser -UserName (GetUserName)
|
||||
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(Get-UserName)..."
|
||||
$storeDb = Get-StoreAppsDatabasePathForUser -UserName (Get-UserName)
|
||||
if ($storeDb) {
|
||||
EnableStoreSearchSuggestions -StoreAppsDatabase $storeDb
|
||||
return (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $storeDb)
|
||||
}
|
||||
Write-Host ""
|
||||
return
|
||||
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 "> $($feature.ApplyUndoText)..."
|
||||
DisableWindowsFeature 'Containers-DisposableClientVM'
|
||||
Write-Host ""
|
||||
return
|
||||
Write-Host "> $undoText..."
|
||||
return (Disable-WindowsFeature 'Containers-DisposableClientVM')
|
||||
}
|
||||
'EnableWindowsSubsystemForLinux' {
|
||||
Write-Host "> $($feature.ApplyUndoText)..."
|
||||
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
|
||||
DisableWindowsFeature 'VirtualMachinePlatform'
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
'DisableTelemetry' {
|
||||
# Also re-enable telemetry scheduled tasks
|
||||
Enable-TelemetryScheduledTasks
|
||||
return
|
||||
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
|
||||
@@ -246,7 +271,13 @@ function Invoke-ApplyFeatures {
|
||||
& $script:ApplyProgressCallback $step $TotalSteps $displayName
|
||||
}
|
||||
|
||||
Invoke-FeatureApply -FeatureId $featureId
|
||||
# Compare app-removal failure counts so a feature that only fails due to
|
||||
# app removal isn't also double-reported as a feature failure.
|
||||
$appRemovalFailuresBefore = $script:AppRemovalFailures
|
||||
if ((-not (Invoke-FeatureApply -FeatureId $featureId)) -and ($script:AppRemovalFailures -eq $appRemovalFailuresBefore)) {
|
||||
$script:FeatureFailures++
|
||||
}
|
||||
Write-Host ""
|
||||
$step++
|
||||
}
|
||||
}
|
||||
@@ -257,9 +288,8 @@ function Invoke-ApplyFeatures {
|
||||
Undoes a list of features, reporting progress for each.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates through the provided feature IDs. Features with a RegistryUndoKey
|
||||
are handled by importing the undo .reg file; all others delegate to
|
||||
Invoke-FeatureUndo for custom undo logic.
|
||||
Iterates through the provided feature IDs and delegates each to
|
||||
Invoke-FeatureUndo, which handles registry-backed and custom undo logic.
|
||||
This is called by Invoke-AllChanges during the undo phase.
|
||||
#>
|
||||
function Invoke-UndoFeatures {
|
||||
@@ -286,11 +316,10 @@ function Invoke-UndoFeatures {
|
||||
& $script:ApplyProgressCallback $step $TotalSteps $undoText
|
||||
}
|
||||
|
||||
if ($f -and $f.RegistryUndoKey) {
|
||||
ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey)
|
||||
if (-not (Invoke-FeatureUndo -FeatureId $featureId)) {
|
||||
$script:FeatureFailures++
|
||||
}
|
||||
|
||||
Invoke-FeatureUndo -FeatureId $featureId
|
||||
Write-Host ""
|
||||
$step++
|
||||
}
|
||||
}
|
||||
@@ -302,8 +331,8 @@ function Invoke-UndoFeatures {
|
||||
|
||||
.DESCRIPTION
|
||||
Sequenced in four phases:
|
||||
1. Registry backup
|
||||
2. System restore point
|
||||
1. Registry backup (skipped when SkipRegistryBackup is present)
|
||||
2. System restore point (skipped when CreateRestorePoint is absent)
|
||||
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
|
||||
4. Undo phase - undoes selected features via Invoke-UndoFeatures
|
||||
|
||||
@@ -311,13 +340,17 @@ function Invoke-UndoFeatures {
|
||||
(used by the GUI modal). Cancellation is checked between each step.
|
||||
#>
|
||||
function Invoke-AllChanges {
|
||||
if ($script:CancelRequested) { return }
|
||||
|
||||
# Guard: prevent running as SYSTEM account without explicit target user
|
||||
$isSystem = ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18')
|
||||
$isSystem = Test-RunningAsSystem
|
||||
if ($isSystem -and -not $script:Params.ContainsKey("User") -and -not $script:Params.ContainsKey("Sysprep")) {
|
||||
throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user."
|
||||
}
|
||||
|
||||
$script:RegistryImportFailures = 0
|
||||
$script:AppRemovalFailures = 0
|
||||
$script:FeatureFailures = 0
|
||||
$script:AppRemovalVerificationUnavailable = $false
|
||||
|
||||
# ---- Gather work items ----
|
||||
$applyIds = @()
|
||||
@@ -347,14 +380,15 @@ function Invoke-AllChanges {
|
||||
|
||||
# ---- Calculate total progress steps ----
|
||||
$totalSteps = $applyIds.Count + $undoIds.Count
|
||||
if ($needsBackup) { $totalSteps++ }
|
||||
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ }
|
||||
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
|
||||
$step = 0
|
||||
|
||||
# ================================================================
|
||||
# Phase 1: Registry backup
|
||||
# ================================================================
|
||||
if ($needsBackup) {
|
||||
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) {
|
||||
if ($script:CancelRequested) { return }
|
||||
$step++
|
||||
if ($script:ApplyProgressCallback) {
|
||||
& $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..."
|
||||
@@ -384,6 +418,7 @@ function Invoke-AllChanges {
|
||||
# Phase 2: System restore point
|
||||
# ================================================================
|
||||
if ($script:Params.ContainsKey("CreateRestorePoint")) {
|
||||
if ($script:CancelRequested) { return }
|
||||
$step++
|
||||
if ($script:ApplyProgressCallback) {
|
||||
& $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..."
|
||||
@@ -394,7 +429,11 @@ function Invoke-AllChanges {
|
||||
}
|
||||
else {
|
||||
Write-Host "> Creating a system restore point..."
|
||||
CreateSystemRestorePoint
|
||||
$restorePointSucceeded = Invoke-SystemRestorePoint
|
||||
if (-not $restorePointSucceeded) {
|
||||
if ($script:CancelRequested) { return }
|
||||
$script:FeatureFailures++
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
@@ -407,6 +446,8 @@ function Invoke-AllChanges {
|
||||
$step += $applyIds.Count
|
||||
}
|
||||
|
||||
if ($script:CancelRequested) { return }
|
||||
|
||||
# ================================================================
|
||||
# Phase 4: Undo features
|
||||
# ================================================================
|
||||
@@ -416,10 +457,37 @@ function Invoke-AllChanges {
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Final: Report registry import failures
|
||||
# Final: Report failures
|
||||
# ================================================================
|
||||
if ($script:RegistryImportFailures -gt 0) {
|
||||
if ($script:AppRemovalFailures -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow
|
||||
Write-Warning "$($script:AppRemovalFailures) app removal(s) failed. See output above for details."
|
||||
}
|
||||
|
||||
if ($script:FeatureFailures -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Warning "$($script:FeatureFailures) feature change(s) failed. See output above for details."
|
||||
}
|
||||
|
||||
if ($script:AppRemovalVerificationUnavailable) {
|
||||
Write-Host ""
|
||||
Write-Warning "Unable to verify if all apps were uninstalled successfully."
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether Win11Debloat is running under the SYSTEM account.
|
||||
|
||||
.DESCRIPTION
|
||||
Compares the current Windows identity's security identifier (SID) with
|
||||
the well-known Local System SID (S-1-5-18).
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
Returns $true when the current process runs as SYSTEM; otherwise, $false.
|
||||
#>
|
||||
function Test-RunningAsSystem {
|
||||
return ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18')
|
||||
}
|
||||
+12
-13
@@ -1,10 +1,11 @@
|
||||
# Restart the Windows Explorer process
|
||||
function RestartExplorer {
|
||||
# Restarting Explorer while running in Sysprep or User context is not necessary
|
||||
if ($script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")) {
|
||||
return
|
||||
}
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restarts Windows Explorer to apply system changes.
|
||||
|
||||
.DESCRIPTION
|
||||
Restarts the Explorer process to ensure all UI modifications take effect. Shows a warning if any of the applied features require a reboot to take full effect.
|
||||
#>
|
||||
function Invoke-RestartExplorer {
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Restart the Windows Explorer process" -ForegroundColor Cyan
|
||||
return
|
||||
@@ -12,19 +13,17 @@ function RestartExplorer {
|
||||
|
||||
Write-Host "> Attempting to restart the Windows Explorer process to apply all changes..."
|
||||
|
||||
if ($script:Params.ContainsKey("NoRestartExplorer")) {
|
||||
if ($script:Params.ContainsKey('SkipExplorerRestart')) {
|
||||
Write-Host "Explorer process restart was skipped, please manually reboot your PC to apply all changes" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($paramKey in $script:Params.Keys) {
|
||||
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
|
||||
$feature = $script:Features[$paramKey]
|
||||
Write-Host "Warning: '$($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
|
||||
}
|
||||
$rebootFeatures = Get-RebootFeatureLabels
|
||||
foreach ($displayLabel in $rebootFeatures) {
|
||||
Write-Host "Warning: '$displayLabel' requires a reboot to take full effect" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Only restart if the powershell process matches the OS architecture.
|
||||
# Only restart if the PowerShell process matches the OS architecture.
|
||||
# Restarting explorer from a 32bit PowerShell window will fail on a 64bit OS
|
||||
if ([Environment]::Is64BitProcess -eq [Environment]::Is64BitOperatingSystem) {
|
||||
Write-Host "Restarting the Windows Explorer process... (This may cause your screen to flicker)"
|
||||
+26
-9
@@ -1,10 +1,25 @@
|
||||
function CreateSystemRestorePoint {
|
||||
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval"
|
||||
$failed = $false
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a system restore point.
|
||||
|
||||
if ($SysRestore.RPSessionInterval -eq 0) {
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when a restore point is created; otherwise $false.
|
||||
#>
|
||||
function Invoke-SystemRestorePoint {
|
||||
$failed = $false
|
||||
$isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent
|
||||
|
||||
try {
|
||||
$SysRestore = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "RPSessionInterval" -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Host "Error: Unable to determine whether System Restore is enabled: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed = $true
|
||||
}
|
||||
|
||||
if (-not $failed -and $SysRestore.RPSessionInterval -eq 0) {
|
||||
# In GUI mode, skip the prompt and just try to enable it
|
||||
if ($script:GuiWindow -or $Silent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
|
||||
if ($script:GuiWindow -or $isSilent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
|
||||
try {
|
||||
$enableResult = Invoke-NonBlocking -TimeoutSeconds 90 -ScriptBlock {
|
||||
try {
|
||||
@@ -26,7 +41,6 @@ function CreateSystemRestorePoint {
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
@@ -79,17 +93,20 @@ function CreateSystemRestorePoint {
|
||||
|
||||
if ($result -ne "Yes") {
|
||||
$script:CancelRequested = $true
|
||||
return
|
||||
return $false
|
||||
}
|
||||
}
|
||||
elseif (-not $Silent) {
|
||||
elseif (-not $isSilent) {
|
||||
Write-Host "Failed to create a system restore point. Do you want to continue without a restore point? (y/n)" -ForegroundColor Yellow
|
||||
if ($( Read-Host ) -ne 'y') {
|
||||
$script:CancelRequested = $true
|
||||
return
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Warning: Continuing without restore point" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
+89
-2
@@ -248,6 +248,14 @@ function New-RegistryBackupAllowListPlanMap {
|
||||
return $planMap
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts registry value names into a case-insensitive set.
|
||||
|
||||
.DESCRIPTION
|
||||
Preserves empty names and prevents PowerShell from enumerating the returned
|
||||
HashSet.
|
||||
#>
|
||||
function ConvertTo-RegistryValueNameSet {
|
||||
param(
|
||||
[AllowEmptyCollection()]
|
||||
@@ -259,9 +267,18 @@ function ConvertTo-RegistryValueNameSet {
|
||||
$null = $valueNameSet.Add([string]$valueName)
|
||||
}
|
||||
|
||||
return $valueNameSet
|
||||
# Prevent PowerShell from enumerating the HashSet into an array or single string
|
||||
return ,$valueNameSet
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates a registry snapshot against the selected-feature allow list.
|
||||
|
||||
.DESCRIPTION
|
||||
Recursively validates snapshot paths, value names, value kinds, and value
|
||||
data, appending validation errors to the supplied list.
|
||||
#>
|
||||
function Test-RegistrySnapshotAgainstAllowList {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -300,6 +317,9 @@ function Test-RegistrySnapshotAgainstAllowList {
|
||||
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
|
||||
$Errors.Add("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
|
||||
}
|
||||
elseif (-not (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) {
|
||||
$Errors.Add("Backup contains invalid registry data for kind '$kindName' at '$valueReference'.")
|
||||
}
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
|
||||
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
|
||||
@@ -311,6 +331,64 @@ function Test-RegistrySnapshotAgainstAllowList {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether backed-up registry data is valid for its declared value kind.
|
||||
|
||||
.DESCRIPTION
|
||||
Rejects corrupted or hand-edited backup data that cannot be restored safely,
|
||||
such as a DWord that overflows UInt32 or binary data containing an invalid byte.
|
||||
This validation runs before Restore-RegistryKeySnapshot mutates the live
|
||||
registry, preventing a failed conversion from leaving a partially restored key.
|
||||
|
||||
.PARAMETER KindName
|
||||
The declared registry value kind name, such as DWord, QWord, or Binary.
|
||||
|
||||
.PARAMETER Data
|
||||
The backed-up value data to validate against the declared kind.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
function Test-RegistryValueDataMatchesKind {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$KindName,
|
||||
[AllowNull()]
|
||||
$Data
|
||||
)
|
||||
|
||||
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
switch ($kind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) {
|
||||
$parsed = [uint32]0
|
||||
return [uint32]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) {
|
||||
$parsed = [uint64]0
|
||||
return [uint64]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) {
|
||||
if ($null -eq $Data -or $Data -isnot [array]) { return $false }
|
||||
foreach ($item in @($Data)) {
|
||||
if ($item -isnot [ValueType] -and $item -isnot [string]) { return $false }
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse([string]$item, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt 255) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) {
|
||||
foreach ($item in @($Data)) {
|
||||
if ($item -isnot [string]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
default { return ($null -eq $Data -or $Data -is [string]) }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryValueAllowedByPlan {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -428,6 +506,14 @@ function Get-NormalizedRegistryPathKey {
|
||||
return "$normalizedHive\\$normalizedSubKey"
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether a registry value-kind name is supported in backups.
|
||||
|
||||
.DESCRIPTION
|
||||
Parses kind names case-insensitively and rejects empty, invalid, Unknown,
|
||||
and None values.
|
||||
#>
|
||||
function Test-RegistryValueKindNameSupported {
|
||||
param(
|
||||
[string]$KindName
|
||||
@@ -439,9 +525,10 @@ function Test-RegistryValueKindNameSupported {
|
||||
|
||||
try {
|
||||
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
return $kind -ne [Microsoft.Win32.RegistryValueKind]::Unknown
|
||||
return $kind -notin @([Microsoft.Win32.RegistryValueKind]::Unknown, [Microsoft.Win32.RegistryValueKind]::None)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
bundled with the script (Assets/Start/start2.bin).
|
||||
|
||||
.EXAMPLE
|
||||
ReplaceStartMenuForAllUsers
|
||||
Replace-StartMenuForAllUsers
|
||||
|
||||
.EXAMPLE
|
||||
ReplaceStartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
|
||||
Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when all resolved profiles are updated or the change is previewed; otherwise $false.
|
||||
#>
|
||||
function ReplaceStartMenuForAllUsers {
|
||||
function Replace-StartMenuForAllUsers {
|
||||
param (
|
||||
[string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
||||
)
|
||||
@@ -29,37 +32,49 @@ function ReplaceStartMenuForAllUsers {
|
||||
# Check if template bin file exists
|
||||
if (-not (Test-Path $startMenuTemplate)) {
|
||||
Write-Host "Error: Unable to clear start menu, start2.bin file missing from script folder" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
return
|
||||
return $false
|
||||
}
|
||||
|
||||
# Get path to start menu file for all users
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and replace the start menu file
|
||||
$success = $true
|
||||
ForEach ($startMenuPath in $usersStartMenuPaths) {
|
||||
ReplaceStartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate
|
||||
if (-not (Replace-StartMenu -startMenuBinFile "$($startMenuPath.Fullname)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Also replace the start menu file for the default user profile
|
||||
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
$defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Replace Start Menu for Default user profile with template $startMenuTemplate" -ForegroundColor Cyan
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
# Create folder if it doesn't exist
|
||||
if (-not (Test-Path $defaultStartMenuPath)) {
|
||||
new-item $defaultStartMenuPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
New-Item $defaultStartMenuPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
Write-Host "Created LocalState folder for default user profile"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to create the Default profile Start Menu directory: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Copy template to default profile
|
||||
ReplaceStartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate
|
||||
if (-not (Replace-StartMenu -startMenuBinFile "$($defaultStartMenuPath)\start2.bin" -startMenuTemplate $startMenuTemplate)) {
|
||||
$success = $false
|
||||
}
|
||||
else {
|
||||
Write-Host "Replaced start menu for the default user profile"
|
||||
Write-Host ""
|
||||
}
|
||||
return $success
|
||||
}
|
||||
|
||||
|
||||
@@ -83,12 +98,15 @@ function ReplaceStartMenuForAllUsers {
|
||||
bundled with the script (Assets/Start/start2.bin).
|
||||
|
||||
.EXAMPLE
|
||||
ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
|
||||
.EXAMPLE
|
||||
ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin"
|
||||
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the template is valid and copied, or the change is previewed; otherwise $false.
|
||||
#>
|
||||
function ReplaceStartMenu {
|
||||
function Replace-StartMenu {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$startMenuBinFile,
|
||||
@@ -98,19 +116,19 @@ function ReplaceStartMenu {
|
||||
# Check if template bin file exists
|
||||
if (-not (Test-Path $startMenuTemplate)) {
|
||||
Write-Host "Error: Unable to replace start menu, template file not found" -ForegroundColor Red
|
||||
return
|
||||
return $false
|
||||
}
|
||||
|
||||
if ([IO.Path]::GetExtension($startMenuTemplate) -ne ".bin") {
|
||||
Write-Host "Error: Unable to replace start menu, template file is not a valid .bin file" -ForegroundColor Red
|
||||
return
|
||||
return $false
|
||||
}
|
||||
|
||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
|
||||
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Replace Start Menu for user $userName with template $startMenuTemplate" -ForegroundColor Cyan
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
@@ -118,20 +136,27 @@ function ReplaceStartMenu {
|
||||
$startMenuDir = Split-Path $startMenuBinFile -Parent
|
||||
$backupBinFile = Join-Path $startMenuDir $backupFileName
|
||||
|
||||
try {
|
||||
if (Test-Path $startMenuBinFile) {
|
||||
# Backup current start menu file
|
||||
Copy-Item -Path $startMenuBinFile -Destination $backupBinFile -Force
|
||||
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
|
||||
New-Item -ItemType File -Path $startMenuBinFile -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
# Copy template file
|
||||
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force
|
||||
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to replace Start Menu for user ${userName}: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Replaced start menu for user $userName"
|
||||
return $true
|
||||
}
|
||||
|
||||
<#
|
||||
@@ -147,12 +172,12 @@ function ReplaceStartMenu {
|
||||
The target username. Pass an empty string or omit to resolve for the current user.
|
||||
|
||||
.EXAMPLE
|
||||
GetStartMenuBinPathForUser -UserName "Jeff"
|
||||
Get-StartMenuBinPathForUser -UserName "Jeff"
|
||||
|
||||
.EXAMPLE
|
||||
GetStartMenuBinPathForUser -UserName "Default"
|
||||
Get-StartMenuBinPathForUser -UserName "Default"
|
||||
#>
|
||||
function GetStartMenuBinPathForUser {
|
||||
function Get-StartMenuBinPathForUser {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
@@ -161,7 +186,7 @@ function GetStartMenuBinPathForUser {
|
||||
return "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
}
|
||||
|
||||
return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false)
|
||||
return (Get-UserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false)
|
||||
}
|
||||
|
||||
<#
|
||||
@@ -177,9 +202,9 @@ function GetStartMenuBinPathForUser {
|
||||
The full path to a start2.bin file.
|
||||
|
||||
.EXAMPLE
|
||||
GetStartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
Get-StartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
#>
|
||||
function GetStartMenuUserNameFromPath {
|
||||
function Get-StartMenuUserNameFromPath {
|
||||
param(
|
||||
[string]$StartMenuBinFile
|
||||
)
|
||||
@@ -230,7 +255,7 @@ function Get-StartMenuBackupPath {
|
||||
return $null
|
||||
}
|
||||
else {
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
foreach ($startMenuPath in $usersStartMenuPaths) {
|
||||
$latestBackup = Get-ChildItem -Path (Join-Path $startMenuPath.FullName 'Win11Debloat-StartBackup-*.bak') -ErrorAction SilentlyContinue |
|
||||
@@ -261,19 +286,19 @@ function Get-StartMenuBackupPath {
|
||||
finds the latest Win11Debloat-StartBackup-*.bak file.
|
||||
|
||||
.EXAMPLE
|
||||
RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
|
||||
.EXAMPLE
|
||||
RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
Restore-StartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
#>
|
||||
function RestoreStartMenuFromBackup {
|
||||
function Restore-StartMenuFromBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StartMenuBinFile,
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
||||
$userName = Get-StartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
||||
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
|
||||
# Auto-detect latest backup in the same folder as the start2.bin
|
||||
$startMenuDir = Split-Path $StartMenuBinFile -Parent
|
||||
@@ -342,19 +367,19 @@ function RestoreStartMenuFromBackup {
|
||||
|
||||
.DESCRIPTION
|
||||
Resolves the start2.bin path for the currently logged-in user, then
|
||||
delegates to RestoreStartMenuFromBackup.
|
||||
delegates to Restore-StartMenuFromBackup.
|
||||
|
||||
.PARAMETER BackupFilePath
|
||||
Path to the backup file to restore from. If omitted, automatically
|
||||
finds the latest Win11Debloat-StartBackup-*.bak file.
|
||||
|
||||
.EXAMPLE
|
||||
RestoreStartMenu
|
||||
Restore-StartMenu
|
||||
|
||||
.EXAMPLE
|
||||
RestoreStartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
Restore-StartMenu -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
#>
|
||||
function RestoreStartMenu {
|
||||
function Restore-StartMenu {
|
||||
param(
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
@@ -364,7 +389,7 @@ function RestoreStartMenu {
|
||||
|
||||
Write-Host "Restoring start menu for user $targetUserName from backup..."
|
||||
|
||||
return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
return Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
}
|
||||
|
||||
<#
|
||||
@@ -384,17 +409,17 @@ function RestoreStartMenu {
|
||||
LocalState folder.
|
||||
|
||||
.EXAMPLE
|
||||
RestoreStartMenuForAllUsers
|
||||
Restore-StartMenuForAllUsers
|
||||
|
||||
.EXAMPLE
|
||||
RestoreStartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
Restore-StartMenuForAllUsers -BackupFilePath "C:\Backups\Win11Debloat-StartBackup-20260101_120000.bak"
|
||||
#>
|
||||
function RestoreStartMenuForAllUsers {
|
||||
function Restore-StartMenuForAllUsers {
|
||||
param(
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
$results = @()
|
||||
|
||||
@@ -402,10 +427,10 @@ function RestoreStartMenuForAllUsers {
|
||||
|
||||
foreach ($startMenuPath in $usersStartMenuPaths) {
|
||||
$startMenuBinFile = Join-Path $startMenuPath.FullName 'start2.bin'
|
||||
$results += RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
$results += Restore-StartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
}
|
||||
|
||||
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
$defaultStartMenuPath = Get-UserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
|
||||
if (Test-Path $defaultStartMenuPath) {
|
||||
$defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin'
|
||||
@@ -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
|
||||
}
|
||||
+67
-5
@@ -1,4 +1,20 @@
|
||||
function Load-RegistryBackupFromFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads a registry backup from a JSON file and normalizes its contents.
|
||||
|
||||
.DESCRIPTION
|
||||
Loads a registry backup from disk and returns a normalized representation
|
||||
of its contents suitable for use by the restore workflow. Throws if the
|
||||
file is missing, unreadable, or not valid JSON.
|
||||
|
||||
.PARAMETER FilePath
|
||||
The absolute path to the registry backup JSON file to load.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject
|
||||
A normalized registry backup object produced by ConvertTo-NormalizedRegistryBackup.
|
||||
#>
|
||||
function Import-RegistryBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FilePath
|
||||
@@ -15,10 +31,28 @@ function Load-RegistryBackupFromFile {
|
||||
throw "Failed to read backup file '$FilePath'. The file is not valid JSON."
|
||||
}
|
||||
|
||||
return Normalize-RegistryBackup -Backup $rawBackup
|
||||
return ConvertTo-NormalizedRegistryBackup -Backup $rawBackup
|
||||
}
|
||||
|
||||
function Normalize-RegistryBackup {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates and normalizes a raw registry backup object.
|
||||
|
||||
.DESCRIPTION
|
||||
Validates the structure and content of the supplied backup and converts
|
||||
it into a normalized representation that can be safely consumed by the
|
||||
restore workflow. Throws if validation fails.
|
||||
|
||||
.PARAMETER Backup
|
||||
The raw backup object (typically parsed from JSON) to normalize.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject
|
||||
A normalized backup with Version, BackupType, CreatedAt, CreatedBy,
|
||||
ComputerName, Target, SelectedFeatures, SelectedUndoFeatures, and
|
||||
RegistryKeys properties.
|
||||
#>
|
||||
function ConvertTo-NormalizedRegistryBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Backup
|
||||
@@ -59,7 +93,11 @@ function Normalize-RegistryBackup {
|
||||
}
|
||||
elseif ($normalizedTarget -like 'CurrentUser:*') {
|
||||
$targetCurrentUserName = $normalizedTarget.Substring(12)
|
||||
if ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or ($targetCurrentUserName -ne $env:USERNAME)) {
|
||||
if (Test-RunningAsSystem) {
|
||||
$errors.Add("Backup was made for '$targetCurrentUserName' and is user-scoped. Re-run as that user; SYSTEM cannot restore a CurrentUser backup.")
|
||||
}
|
||||
elseif ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or
|
||||
-not (Test-UserNameMatch -UserNameA $targetCurrentUserName -UserNameB $env:USERNAME)) {
|
||||
$errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$env:USERNAME'.")
|
||||
}
|
||||
}
|
||||
@@ -97,10 +135,17 @@ function Normalize-RegistryBackup {
|
||||
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) {
|
||||
Write-Error "Backup validation failed: $($errors -join ' ')"
|
||||
@@ -125,13 +170,30 @@ function Normalize-RegistryBackup {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restores registry state from a normalized backup object.
|
||||
|
||||
.DESCRIPTION
|
||||
Applies the registry state described by the supplied backup back to the
|
||||
registry, loading the appropriate user hive when required.
|
||||
|
||||
.PARAMETER Backup
|
||||
A normalized backup object (as produced by ConvertTo-NormalizedRegistryBackup) whose
|
||||
RegistryKeys snapshots should be restored.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject
|
||||
Returns an object with a Result property set to $true when the restore
|
||||
completes successfully.
|
||||
#>
|
||||
function Restore-RegistryBackupState {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Backup
|
||||
)
|
||||
|
||||
$friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
||||
$friendlyTarget = Get-FriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Restore registry backup for $friendlyTarget" -ForegroundColor Cyan
|
||||
@@ -1,224 +0,0 @@
|
||||
function Invoke-WithLoadedRestoreHive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock,
|
||||
$ArgumentObject = $null
|
||||
)
|
||||
|
||||
$targetUserName = if ($Target -eq 'DefaultUserProfile') {
|
||||
'Default'
|
||||
}
|
||||
elseif ($Target -like 'User:*') {
|
||||
$userName = $Target.Substring(5)
|
||||
if ([string]::IsNullOrWhiteSpace($userName)) {
|
||||
throw 'Invalid backup target format for user restore.'
|
||||
}
|
||||
$userName
|
||||
}
|
||||
else {
|
||||
throw "Unsupported backup target '$Target'."
|
||||
}
|
||||
|
||||
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock $ScriptBlock -ArgumentObject $ArgumentObject
|
||||
}
|
||||
|
||||
function Restore-RegistryKeySnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
$registryParts = Split-RegistryPath -path $Snapshot.Path
|
||||
if (-not $registryParts) {
|
||||
throw "Unsupported registry path in backup: $($Snapshot.Path)"
|
||||
}
|
||||
|
||||
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
|
||||
if (-not $rootKey) {
|
||||
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
|
||||
}
|
||||
|
||||
$subKeyPath = $registryParts.SubKey
|
||||
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
|
||||
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
|
||||
}
|
||||
|
||||
if (-not $Snapshot.Exists) {
|
||||
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
|
||||
return
|
||||
}
|
||||
|
||||
$forceFullTree = @($Snapshot.SubKeys).Count -gt 0
|
||||
if ($forceFullTree) {
|
||||
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
|
||||
}
|
||||
|
||||
$key = $rootKey.CreateSubKey($subKeyPath)
|
||||
if ($null -eq $key) {
|
||||
throw "Unable to create or open registry key '$($Snapshot.Path)'"
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($valueSnapshot in @($Snapshot.Values)) {
|
||||
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot $valueSnapshot
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$key.Close()
|
||||
}
|
||||
|
||||
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
|
||||
Restore-RegistryKeySnapshot -Snapshot $subKeySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-RegistryValueSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
$valueName = if ($null -ne $Snapshot.Name) { [string]$Snapshot.Name } else { '' }
|
||||
|
||||
if (-not [bool]$Snapshot.Exists) {
|
||||
try {
|
||||
$RegistryKey.DeleteValue($valueName, $false)
|
||||
}
|
||||
catch {
|
||||
throw "Failed deleting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$valueKind = Convert-RegistryValueKindFromBackup -KindName $Snapshot.Kind
|
||||
$normalizedData = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $Snapshot.Data
|
||||
|
||||
try {
|
||||
$RegistryKey.SetValue($valueName, $normalizedData, $valueKind)
|
||||
}
|
||||
catch {
|
||||
$retryBytes = Convert-BackupDataToByteArray -Data $Snapshot.Data
|
||||
if ($null -ne $retryBytes) {
|
||||
try {
|
||||
$RegistryKey.SetValue($valueName, $retryBytes, [Microsoft.Win32.RegistryValueKind]::Binary)
|
||||
return
|
||||
}
|
||||
catch {
|
||||
# Fall through to original error message for context.
|
||||
}
|
||||
}
|
||||
|
||||
throw "Failed setting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RegistryValueKindFromBackup {
|
||||
param(
|
||||
[string]$KindName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($KindName)) {
|
||||
return [Microsoft.Win32.RegistryValueKind]::String
|
||||
}
|
||||
|
||||
try {
|
||||
return [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
}
|
||||
catch {
|
||||
throw "Unsupported registry value kind in backup: $KindName"
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RegistryValueDataFromBackup {
|
||||
param(
|
||||
[Microsoft.Win32.RegistryValueKind]$Kind,
|
||||
$Data
|
||||
)
|
||||
|
||||
switch ($Kind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) {
|
||||
$unsigned = [uint32]$Data
|
||||
return [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) {
|
||||
$unsigned = [uint64]$Data
|
||||
return [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) { return @($Data | ForEach-Object { [string]$_ }) }
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) {
|
||||
$bytes = Convert-BackupDataToByteArray -Data $Data
|
||||
if ($null -eq $bytes) {
|
||||
return (New-Object byte[] 0)
|
||||
}
|
||||
return $bytes
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::None) { return $null }
|
||||
default {
|
||||
if ($null -ne $Data) {
|
||||
return [string]$Data
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-BackupDataToByteArray {
|
||||
param(
|
||||
$Data
|
||||
)
|
||||
|
||||
if ($null -eq $Data) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Data -is [byte[]]) {
|
||||
return ,$Data
|
||||
}
|
||||
|
||||
$items = @($Data)
|
||||
if ($items.Count -eq 0) {
|
||||
return ,(New-Object byte[] 0)
|
||||
}
|
||||
|
||||
foreach ($item in $items) {
|
||||
if ($item -isnot [ValueType] -and $item -isnot [string]) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse([string]$item, [ref]$parsed)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($parsed -lt 0 -or $parsed -gt 255) {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
$bytes = New-Object byte[] $items.Count
|
||||
for ($i = 0; $i -lt $items.Count; $i++) {
|
||||
$bytes[$i] = [byte][int]$items[$i]
|
||||
}
|
||||
|
||||
return ,$bytes
|
||||
}
|
||||
|
||||
function Remove-RegistrySubKeyTreeIfExists {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RootKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SubKeyPath
|
||||
)
|
||||
|
||||
$existing = $RootKey.OpenSubKey($SubKeyPath, $false)
|
||||
if ($existing) {
|
||||
$existing.Close()
|
||||
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
|
||||
}
|
||||
}
|
||||
+98
-34
@@ -10,24 +10,43 @@
|
||||
|
||||
.EXAMPLE
|
||||
DisableStoreSearchSuggestionsForAllUsers
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
|
||||
#>
|
||||
function DisableStoreSearchSuggestionsForAllUsers {
|
||||
function Set-StoreSearchSuggestionsDisabledForAllUsers {
|
||||
$success = $true
|
||||
$processedProfiles = 0
|
||||
|
||||
# Get path to Store app database for all users
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and disable start search suggestions
|
||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||
DisableStoreSearchSuggestions -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Also disable start search suggestions for the default user profile
|
||||
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
|
||||
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
if ($defaultStoreDbPath) {
|
||||
DisableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase $defaultStoreDbPath)) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($processedProfiles -eq 0) {
|
||||
Write-Warning 'Unable to disable Microsoft Store search suggestions because no target user profiles could be resolved.'
|
||||
return $false
|
||||
}
|
||||
|
||||
return $success
|
||||
}
|
||||
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@@ -44,8 +63,11 @@ function DisableStoreSearchSuggestionsForAllUsers {
|
||||
|
||||
.EXAMPLE
|
||||
DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the database ACL is restricted or previewed; otherwise $false.
|
||||
#>
|
||||
function DisableStoreSearchSuggestions {
|
||||
function Set-StoreSearchSuggestionsDisabled {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StoreAppsDatabase
|
||||
@@ -56,30 +78,35 @@ function DisableStoreSearchSuggestions {
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Disable Microsoft Store search suggestions for user $userName by restricting access to ${StoreAppsDatabase}" -ForegroundColor Cyan
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
# This file doesn't exist in EEA (No Store app suggestions).
|
||||
if (-not (Test-Path -Path $StoreAppsDatabase))
|
||||
{
|
||||
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 $storeDbDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
New-Item -Path $StoreAppsDatabase -ItemType File -Force | 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
|
||||
$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 | 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
|
||||
}
|
||||
|
||||
<#
|
||||
@@ -94,24 +121,43 @@ function DisableStoreSearchSuggestions {
|
||||
|
||||
.EXAMPLE
|
||||
EnableStoreSearchSuggestionsForAllUsers
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
|
||||
#>
|
||||
function EnableStoreSearchSuggestionsForAllUsers {
|
||||
function Set-StoreSearchSuggestionsEnabledForAllUsers {
|
||||
$success = $true
|
||||
$processedProfiles = 0
|
||||
|
||||
# Get path to Store app database for all users
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and re-enable start search suggestions
|
||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||
EnableStoreSearchSuggestions -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"))) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Also re-enable for the default user profile
|
||||
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
|
||||
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
if ($defaultStoreDbPath) {
|
||||
EnableStoreSearchSuggestions -StoreAppsDatabase $defaultStoreDbPath
|
||||
$processedProfiles++
|
||||
if (-not (Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase $defaultStoreDbPath)) {
|
||||
$success = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($processedProfiles -eq 0) {
|
||||
Write-Warning 'Unable to re-enable Microsoft Store search suggestions because no target user profiles could be resolved.'
|
||||
return $false
|
||||
}
|
||||
|
||||
return $success
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Re-enables Microsoft Store search suggestions for a single user.
|
||||
@@ -127,8 +173,11 @@ function EnableStoreSearchSuggestionsForAllUsers {
|
||||
|
||||
.EXAMPLE
|
||||
EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the deny ACL is removed, the database is absent, or the change is previewed; otherwise $false.
|
||||
#>
|
||||
function EnableStoreSearchSuggestions {
|
||||
function Set-StoreSearchSuggestionsEnabled {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StoreAppsDatabase
|
||||
@@ -139,28 +188,41 @@ function EnableStoreSearchSuggestions {
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Re-enable Microsoft Store search suggestions for user $userName by restoring access to ${StoreAppsDatabase}" -ForegroundColor Cyan
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $StoreAppsDatabase)) {
|
||||
Write-Host "Store app database not found for user $userName, nothing to undo"
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
# Ensure we can modify/delete the file even if restrictive ACLs were set.
|
||||
$global:LASTEXITCODE = 0
|
||||
takeown /F "$StoreAppsDatabase" /A | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Failed to take ownership of store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
|
||||
return $false
|
||||
}
|
||||
icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Failed to grant Administrators access to store database '$StoreAppsDatabase' while undoing Microsoft Store search suggestions. Exit code: $LASTEXITCODE"
|
||||
return $false
|
||||
}
|
||||
|
||||
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
|
||||
|
||||
try {
|
||||
$acl = Get-Acl -Path $StoreAppsDatabase
|
||||
$acl = Get-Acl -Path $StoreAppsDatabase -ErrorAction Stop
|
||||
$denyRules = @(
|
||||
$acl.Access | Where-Object {
|
||||
$_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
|
||||
(($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0) -and
|
||||
(try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid } catch { $false })
|
||||
if ($_.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Deny) { return $false }
|
||||
if (($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq 0) { return $false }
|
||||
try {
|
||||
return ($_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -168,7 +230,7 @@ function EnableStoreSearchSuggestions {
|
||||
$null = $acl.RemoveAccessRuleSpecific($denyRule)
|
||||
}
|
||||
|
||||
Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null
|
||||
Set-Acl -Path $StoreAppsDatabase -AclObject $acl -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
|
||||
@@ -177,9 +239,11 @@ function EnableStoreSearchSuggestions {
|
||||
try {
|
||||
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
|
||||
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
throw "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
|
||||
Write-Warning "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,12 +260,12 @@ function EnableStoreSearchSuggestions {
|
||||
The target username. Pass an empty string or omit to resolve for the current user.
|
||||
|
||||
.EXAMPLE
|
||||
GetStoreAppsDatabasePathForUser -UserName "Jeff"
|
||||
Get-StoreAppsDatabasePathForUser -UserName "Jeff"
|
||||
|
||||
.EXAMPLE
|
||||
GetStoreAppsDatabasePathForUser -UserName "Default"
|
||||
Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
#>
|
||||
function GetStoreAppsDatabasePathForUser {
|
||||
function Get-StoreAppsDatabasePathForUser {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
@@ -210,7 +274,7 @@ function GetStoreAppsDatabasePathForUser {
|
||||
return "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
|
||||
}
|
||||
|
||||
return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false)
|
||||
return (Get-UserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false)
|
||||
}
|
||||
|
||||
<#
|
||||
@@ -282,13 +346,13 @@ function Test-StoreSearchSuggestionsDisabled {
|
||||
function Test-StoreSearchSuggestionsDisabledForAllUsers {
|
||||
$paths = @()
|
||||
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$userPathString = Get-UserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
foreach ($storeDbPath in $usersStoreDbPaths) {
|
||||
$paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
|
||||
}
|
||||
|
||||
$defaultStoreDbPath = GetStoreAppsDatabasePathForUser -UserName "Default"
|
||||
$defaultStoreDbPath = Get-StoreAppsDatabasePathForUser -UserName "Default"
|
||||
if ($defaultStoreDbPath) {
|
||||
$paths += $defaultStoreDbPath
|
||||
}
|
||||
+52
-8
@@ -34,21 +34,36 @@ function Get-TelemetryScheduledTasks {
|
||||
|
||||
.EXAMPLE
|
||||
Disable-TelemetryScheduledTasks
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false.
|
||||
#>
|
||||
function Disable-TelemetryScheduledTasks {
|
||||
Write-Host "> Disabling telemetry scheduled tasks..."
|
||||
$tasks = Get-TelemetryScheduledTasks
|
||||
|
||||
$success = $true
|
||||
foreach ($task in $tasks) {
|
||||
if ($script:CancelRequested) { return $false }
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Disable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($path, $name)
|
||||
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
|
||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Import-Module ScheduledTasks -ErrorAction Stop
|
||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
|
||||
}
|
||||
if (-not $taskObj) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
@@ -63,16 +78,23 @@ function Disable-TelemetryScheduledTasks {
|
||||
}
|
||||
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 }
|
||||
'Error' { Write-Host "Failed to disable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
|
||||
default { Write-Warning "Unable to determine the result of disabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
return $success
|
||||
}
|
||||
|
||||
<#
|
||||
@@ -86,21 +108,36 @@ function Disable-TelemetryScheduledTasks {
|
||||
|
||||
.EXAMPLE
|
||||
Enable-TelemetryScheduledTasks
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false.
|
||||
#>
|
||||
function Enable-TelemetryScheduledTasks {
|
||||
Write-Host "> Enabling telemetry scheduled tasks..."
|
||||
$tasks = Get-TelemetryScheduledTasks
|
||||
|
||||
$success = $true
|
||||
foreach ($task in $tasks) {
|
||||
if ($script:CancelRequested) { return $false }
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Enable Scheduled Task: $($task.Path)$($task.Name)" -ForegroundColor Cyan
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($path, $name)
|
||||
Import-Module ScheduledTasks -ErrorAction SilentlyContinue
|
||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Import-Module ScheduledTasks -ErrorAction Stop
|
||||
$taskObj = Get-ScheduledTask -TaskPath $path -TaskName $name -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception -isnot [System.Management.Automation.CommandNotFoundException] -and $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
return @{ Success = $false; Status = 'Error'; Error = $_.Exception.Message }
|
||||
}
|
||||
if (-not $taskObj) {
|
||||
return @{ Success = $true; Status = 'NotFound' }
|
||||
}
|
||||
@@ -115,14 +152,21 @@ function Enable-TelemetryScheduledTasks {
|
||||
}
|
||||
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 }
|
||||
'Error' { Write-Host "Failed to enable Scheduled Task: $($task.Path)$($task.Name) - $($result.Error)" -ForegroundColor Yellow; $success = $false }
|
||||
default { Write-Warning "Unable to determine the result of enabling Scheduled Task: $($task.Path)$($task.Name)."; $success = $false }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
return $success
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enables a Windows optional feature and pipes its output to the console.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when enabling succeeds or is previewed; otherwise $false.
|
||||
#>
|
||||
function Enable-WindowsFeature {
|
||||
param (
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($name)
|
||||
try {
|
||||
$output = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -ErrorAction Stop
|
||||
return [PSCustomObject]@{
|
||||
Success = $true
|
||||
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
|
||||
Error = $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Output = $null
|
||||
Error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
} -ArgumentList $FeatureName
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to enable Windows feature '$FeatureName': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not $result -or -not $result.Success) {
|
||||
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
|
||||
Write-Warning "Failed to enable Windows feature '$FeatureName'$details"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($result.Output) { Write-Host $result.Output }
|
||||
return $true
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Disables a Windows optional feature and pipes its output to the console.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when disabling succeeds or is previewed; otherwise $false.
|
||||
#>
|
||||
function Disable-WindowsFeature {
|
||||
param (
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($name)
|
||||
try {
|
||||
$output = Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart -ErrorAction Stop
|
||||
return [PSCustomObject]@{
|
||||
Success = $true
|
||||
Output = if ($output) { ($output | Out-String).Trim() } else { $null }
|
||||
Error = $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Output = $null
|
||||
Error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
} -ArgumentList $FeatureName
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to disable Windows feature '$FeatureName': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not $result -or -not $result.Success) {
|
||||
$details = if ($result -and $result.Error) { ": $($result.Error)" } else { '' }
|
||||
Write-Warning "Failed to disable Windows feature '$FeatureName'$details"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($result.Output) { Write-Host $result.Output }
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-WindowsOptionalFeatureEnabled {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
try {
|
||||
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
return ($feature.State -eq 'Enabled')
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
# Enables a Windows optional feature and pipes its output to the console
|
||||
function EnableWindowsFeature {
|
||||
param (
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Enable Windows feature: $FeatureName" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($name)
|
||||
Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart
|
||||
} -ArgumentList $FeatureName
|
||||
|
||||
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
|
||||
if ($dismResult) {
|
||||
Write-Host ($dismResult | Out-String).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
# Disables a Windows optional feature and pipes its output to the console
|
||||
function DisableWindowsFeature {
|
||||
param (
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Disable Windows feature: $FeatureName" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
$result = Invoke-NonBlocking -ScriptBlock {
|
||||
param($name)
|
||||
Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart
|
||||
} -ArgumentList $FeatureName
|
||||
|
||||
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
|
||||
if ($dismResult) {
|
||||
Write-Host ($dismResult | Out-String).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WindowsOptionalFeatureEnabled {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FeatureName
|
||||
)
|
||||
|
||||
try {
|
||||
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
return ($feature.State -eq 'Enabled')
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
# Returns a validated list of apps based on the provided appsList and the supported apps from Apps.json
|
||||
function ValidateAppslist {
|
||||
function Get-ValidatedAppList {
|
||||
param (
|
||||
$appsList
|
||||
)
|
||||
|
||||
$supportedAppsList = @(LoadAppsDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }
|
||||
$supportedAppsList = @(Import-AppDetailsFromJson | ForEach-Object { @($_.AppId) }) | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }
|
||||
$validatedAppsList = @()
|
||||
|
||||
# Validate provided appsList against supportedAppsList
|
||||
+31
-4
@@ -1,5 +1,27 @@
|
||||
# Read Apps.json and return list of app objects with optional filtering
|
||||
function LoadAppsDetailsFromJson {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads application details from Apps.json.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads the application definitions from Apps.json, optionally filters the
|
||||
results to installed applications, and returns normalized app objects for
|
||||
display and selection.
|
||||
|
||||
.PARAMETER OnlyInstalled
|
||||
Filters the results to applications detected through Appx or the supplied
|
||||
winget installation list.
|
||||
|
||||
.PARAMETER InstalledList
|
||||
A pre-fetched winget installation list used when filtering installed apps.
|
||||
|
||||
.PARAMETER InitialCheckedFromJson
|
||||
Sets each returned app's IsChecked value from its SelectedByDefault setting.
|
||||
|
||||
.OUTPUTS
|
||||
System.Management.Automation.PSCustomObject[]
|
||||
Application detail objects containing display, selection, and removal data.
|
||||
#>
|
||||
function Import-AppDetailsFromJson {
|
||||
param (
|
||||
[switch]$OnlyInstalled,
|
||||
[object[]]$InstalledList = $null,
|
||||
@@ -17,8 +39,13 @@ function LoadAppsDetailsFromJson {
|
||||
|
||||
foreach ($appData in $jsonContent.Apps) {
|
||||
# Handle AppId as array (could be single or multiple IDs)
|
||||
$appIdArray = if ($appData.AppId -is [array]) { $appData.AppId } else { @($appData.AppId) }
|
||||
$appIdArray = $appIdArray | ForEach-Object { $_.Trim() } | Where-Object { $_.length -gt 0 }
|
||||
$appIdArray = @(
|
||||
foreach ($rawAppId in @($appData.AppId)) {
|
||||
if ($rawAppId -isnot [string]) { continue }
|
||||
$normalizedAppId = $rawAppId.Trim()
|
||||
if ($normalizedAppId.Length -gt 0) { $normalizedAppId }
|
||||
}
|
||||
)
|
||||
if ($appIdArray.Count -eq 0) { continue }
|
||||
|
||||
if ($OnlyInstalled) {
|
||||
+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
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
System.String[]. An array of app ID strings, or an empty array if the
|
||||
file does not exist or contains no selected-by-default apps.
|
||||
#>
|
||||
function LoadAppsFromFile {
|
||||
function Import-AppsFromFile {
|
||||
param (
|
||||
$appsFilePath
|
||||
)
|
||||
@@ -41,6 +41,6 @@ function LoadAppsFromFile {
|
||||
}
|
||||
catch {
|
||||
Write-Error "Unable to read apps list from file: $appsFilePath"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# Loads a JSON file from the specified path and returns the parsed object
|
||||
# Returns $null if the file doesn't exist or if parsing fails
|
||||
function LoadJsonFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports a JSON file, optionally validates its version, and returns $null on failure.
|
||||
#>
|
||||
function Import-JsonFile {
|
||||
param (
|
||||
[string]$filePath,
|
||||
[string]$expectedVersion = $null,
|
||||
@@ -1,11 +1,14 @@
|
||||
# Loads settings from a JSON file and adds them to script params
|
||||
function LoadSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports enabled, compatible feature settings from a JSON file into the active parameters.
|
||||
#>
|
||||
function Import-Settings {
|
||||
param (
|
||||
[string]$filePath,
|
||||
[string]$expectedVersion = "1.0"
|
||||
)
|
||||
|
||||
$settingsJson = LoadJsonFile -filePath $filePath -expectedVersion $expectedVersion
|
||||
$settingsJson = Import-JsonFile -filePath $filePath -expectedVersion $expectedVersion
|
||||
|
||||
if (-not $settingsJson -or -not $settingsJson.Settings) {
|
||||
throw "Failed to load settings from $(Split-Path $filePath -Leaf)"
|
||||
@@ -29,6 +32,6 @@ function LoadSettings {
|
||||
continue
|
||||
}
|
||||
|
||||
AddParameter $setting.Name $setting.Value
|
||||
Add-Parameter $setting.Name $setting.Value
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
# Saves the current settings, excluding control parameters, to 'LastUsedSettings.json' file
|
||||
function SaveSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Saves active feature settings, excluding control parameters, unless running in WhatIf mode.
|
||||
#>
|
||||
function Save-Settings {
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Save settings to LastUsedSettings.json" -ForegroundColor Cyan
|
||||
return
|
||||
@@ -21,7 +24,7 @@ function SaveSettings {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (SaveToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) {
|
||||
if (-not (Save-ToFile -Config $settings -FilePath $script:SavedSettingsFilePath)) {
|
||||
Write-Output ""
|
||||
Write-Host "Error: Failed to save settings to LastUsedSettings.json file" -ForegroundColor Red
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Serializes a configuration hashtable to a UTF-8 JSON file.
|
||||
|
||||
.PARAMETER Config
|
||||
The configuration data to serialize.
|
||||
|
||||
.PARAMETER FilePath
|
||||
The destination file path.
|
||||
|
||||
.PARAMETER MaxDepth
|
||||
The maximum object depth passed to ConvertTo-Json.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when the file is written; otherwise $false.
|
||||
#>
|
||||
function Save-ToFile {
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[hashtable]$Config,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$MaxDepth = 10
|
||||
)
|
||||
|
||||
try {
|
||||
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8 -ErrorAction Stop
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to write '$FilePath': $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
# Saves configuration JSON to a file.
|
||||
# Returns $true on success, $false on failure.
|
||||
function SaveToFile {
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[hashtable]$Config,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$MaxDepth = 10
|
||||
)
|
||||
|
||||
try {
|
||||
$Config | ConvertTo-Json -Depth $MaxDepth | Set-Content -Path $FilePath -Encoding UTF8
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -1,6 +1,20 @@
|
||||
# Applies settings from a JSON object to UI controls (checkboxes and comboboxes)
|
||||
# Used by LoadDefaultsBtn and LoadLastUsedBtn in the UI
|
||||
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,
|
||||
@@ -1,5 +1,11 @@
|
||||
# Checks if the system is set to use dark mode for apps
|
||||
function GetSystemUsesDarkMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns whether Windows apps are configured to use dark mode.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $false when the personalization setting cannot be read.
|
||||
#>
|
||||
function Get-SystemUsesDarkMode {
|
||||
try {
|
||||
$personalizeKey = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
||||
|
||||
@@ -174,6 +174,10 @@ function Update-AppSelectionStatus {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Updates the app-removal scope description to match the selected ComboBoxItem.
|
||||
#>
|
||||
function Update-AppRemovalScopeDescription {
|
||||
param(
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
|
||||
@@ -182,20 +186,60 @@ function Update-AppRemovalScopeDescription {
|
||||
|
||||
$selectedItem = $AppRemovalScopeCombo.SelectedItem
|
||||
if ($selectedItem) {
|
||||
switch ($selectedItem.Content) {
|
||||
"All users" {
|
||||
# Content is the display text and will change once translated; Name is stable.
|
||||
switch ($selectedItem.Name) {
|
||||
"AppRemovalScopeAllUsers" {
|
||||
$AppRemovalScopeDescription.Text = "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users."
|
||||
}
|
||||
"Current user only" {
|
||||
"AppRemovalScopeCurrentUser" {
|
||||
$AppRemovalScopeDescription.Text = "Apps will only be removed for the current user."
|
||||
}
|
||||
"Target user only" {
|
||||
"AppRemovalScopeTargetUser" {
|
||||
$AppRemovalScopeDescription.Text = "Apps will only be removed for the specified target user."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests whether the app-removal scope combo is currently set to "Target user only".
|
||||
#>
|
||||
function Test-AppRemovalScopeTargetsOtherUser {
|
||||
param(
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
|
||||
)
|
||||
|
||||
return ($AppRemovalScopeCombo -and $AppRemovalScopeCombo.SelectedItem -and $AppRemovalScopeCombo.SelectedItem.Name -eq 'AppRemovalScopeTargetUser')
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolves the -AppRemovalTarget value for the selected app-removal scope.
|
||||
#>
|
||||
function Get-AppRemovalScopeTarget {
|
||||
param(
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo,
|
||||
[System.Windows.Controls.TextBox]$OtherUsernameTextBox
|
||||
)
|
||||
|
||||
$selectedItem = $AppRemovalScopeCombo.SelectedItem
|
||||
if (-not $selectedItem) { return $null }
|
||||
|
||||
if (Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo) {
|
||||
return $OtherUsernameTextBox.Text.Trim()
|
||||
}
|
||||
|
||||
switch ($selectedItem.Name) {
|
||||
"AppRemovalScopeAllUsers" { return 'AllUsers' }
|
||||
"AppRemovalScopeCurrentUser" { return 'CurrentUser' }
|
||||
default {
|
||||
Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal."
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AppPreset {
|
||||
param(
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
@@ -223,7 +267,7 @@ function Update-AppPresetStates {
|
||||
$script:UpdatingPresets = $true
|
||||
try {
|
||||
# Helper: count matching and checked apps, set checkbox state
|
||||
function SetPresetState($CheckBox, [scriptblock]$MatchFilter) {
|
||||
function Set-PresetState($CheckBox, [scriptblock]$MatchFilter) {
|
||||
$total = 0; $checked = 0
|
||||
foreach ($child in $AppsPanel.Children) {
|
||||
if ($child -is [System.Windows.Controls.CheckBox]) {
|
||||
@@ -241,15 +285,15 @@ function Update-AppPresetStates {
|
||||
$presetDefaultApps = $window.FindName('PresetDefaultApps')
|
||||
$presetLastUsed = $window.FindName('PresetLastUsed')
|
||||
|
||||
SetPresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true }
|
||||
Set-PresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true }
|
||||
foreach ($jsonCb in $script:JsonPresetCheckboxes) {
|
||||
$localIds = $jsonCb.PresetAppIds
|
||||
SetPresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure()
|
||||
Set-PresetState $jsonCb { param($c) (@($c.AppIds) | Where-Object { $localIds -contains $_ }).Count -gt 0 }.GetNewClosure()
|
||||
}
|
||||
|
||||
# Last used preset: only update if it's visible (has saved apps)
|
||||
if ($presetLastUsed.Visibility -ne 'Collapsed' -and $script:SavedAppIds) {
|
||||
SetPresetState $presetLastUsed { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 }
|
||||
Set-PresetState $presetLastUsed { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
@@ -304,7 +348,29 @@ function Find-ParentScrollViewer {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Load-AppsWithList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Loads application details and adds their interactive checkboxes to the main window.
|
||||
|
||||
.PARAMETER Window
|
||||
The main application window and resource owner.
|
||||
|
||||
.PARAMETER AppsPanel
|
||||
The panel populated with application checkboxes.
|
||||
|
||||
.PARAMETER OnlyInstalledAppsBox
|
||||
The filter control that determines whether only installed apps are loaded.
|
||||
|
||||
.PARAMETER LoadingAppsIndicator
|
||||
The loading indicator shown while application details are prepared.
|
||||
|
||||
.PARAMETER ImportConfigBtn
|
||||
The optional import control re-enabled after loading completes.
|
||||
|
||||
.PARAMETER ListOfApps
|
||||
An optional pre-fetched list of installed WinGet applications.
|
||||
#>
|
||||
function Add-AppsToMainWindow {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
@@ -335,7 +401,7 @@ function Load-AppsWithList {
|
||||
$script:AppsListFilePath = $appsListFilePath
|
||||
. $helperScript
|
||||
. $loaderScript
|
||||
LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
|
||||
Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalled -InstalledList $installedList -InitialCheckedFromJson:$false
|
||||
} -ArgumentList $loaderScriptPath, $helperScriptPath, $appsFilePath, $ListOfApps, $onlyInstalled
|
||||
}
|
||||
|
||||
@@ -435,7 +501,7 @@ function Load-AppsWithList {
|
||||
-AppRemovalScopeDescription $w.FindName('AppRemovalScopeDescription') `
|
||||
-UserSelectionCombo $w.FindName('UserSelectionCombo')
|
||||
})
|
||||
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
|
||||
Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $AppsPanel `
|
||||
-lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) `
|
||||
-updateStatusCallback {
|
||||
$w = $script:MainWindow
|
||||
@@ -449,7 +515,7 @@ function Load-AppsWithList {
|
||||
|
||||
$AppsPanel.Children.Add($checkbox) | Out-Null
|
||||
|
||||
if (($i + 1) % $batchSize -eq 0) { DoEvents }
|
||||
if (($i + 1) % $batchSize -eq 0) { Invoke-DoEvents }
|
||||
}
|
||||
|
||||
$sortArrowName = $Window.FindName('SortArrowName')
|
||||
@@ -480,7 +546,26 @@ function Load-AppsWithList {
|
||||
}
|
||||
}
|
||||
|
||||
function Load-AppsIntoMainUI {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Starts asynchronous loading of application checkboxes for the main window.
|
||||
|
||||
.PARAMETER Window
|
||||
The main application window.
|
||||
|
||||
.PARAMETER AppsPanel
|
||||
The panel that receives application checkboxes.
|
||||
|
||||
.PARAMETER OnlyInstalledAppsBox
|
||||
The installed-applications filter control.
|
||||
|
||||
.PARAMETER LoadingAppsIndicator
|
||||
The loading indicator shown until loading completes.
|
||||
|
||||
.PARAMETER ImportConfigBtn
|
||||
The optional import control disabled while loading is in progress.
|
||||
#>
|
||||
function Initialize-MainWindowApps {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
@@ -511,7 +596,7 @@ function Load-AppsIntoMainUI {
|
||||
# Force a render so the loading indicator is visible, then schedule the
|
||||
# actual loading at Background priority so this call returns immediately.
|
||||
# This is critical when called from Add_Loaded: the window must finish
|
||||
# its initialization before we start a nested message pump via DoEvents.
|
||||
# its initialization before we start a nested message pump via Invoke-DoEvents.
|
||||
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action] {})
|
||||
$Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action] {
|
||||
try {
|
||||
@@ -519,7 +604,7 @@ function Load-AppsIntoMainUI {
|
||||
|
||||
if ($OnlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
|
||||
Write-Host "Retrieving installed apps via winget..."
|
||||
$listOfApps = GetInstalledAppsViaWinget -TimeOut 20 -NonBlocking
|
||||
$listOfApps = Get-WingetInstalledApps -TimeOut 20 -NonBlocking
|
||||
|
||||
if ($null -eq $listOfApps) {
|
||||
Write-Warning "WinGet returned no data (command timed out or failed)"
|
||||
@@ -528,7 +613,7 @@ function Load-AppsIntoMainUI {
|
||||
}
|
||||
}
|
||||
|
||||
Load-AppsWithList -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox `
|
||||
Add-AppsToMainWindow -Window $Window -AppsPanel $AppsPanel -OnlyInstalledAppsBox $OnlyInstalledAppsBox `
|
||||
-LoadingAppsIndicator $LoadingAppsIndicator -ImportConfigBtn $ImportConfigBtn -ListOfApps $listOfApps
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -13,6 +13,13 @@ function Get-UndoFeatureLabel {
|
||||
return [string]$script:FeatureLabelLookup[$FeatureId]
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the tweak actions that are pending based on the current UI state.
|
||||
|
||||
.OUTPUTS
|
||||
[PSCustomObject[]] Objects with Action, FeatureId, and Label properties.
|
||||
#>
|
||||
function Get-PendingTweakActions {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
@@ -54,7 +61,7 @@ function Get-PendingTweakActions {
|
||||
$actions.Add([PSCustomObject]@{
|
||||
Action = 'Undo'
|
||||
FeatureId = [string]$mapping.FeatureId
|
||||
Label = [string]$script:FeatureLabelLookup[$mapping.FeatureId]
|
||||
Label = [string](Get-UndoFeatureLabel -FeatureId $mapping.FeatureId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -140,7 +147,20 @@ function Invoke-ShowChangesOverview {
|
||||
Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600
|
||||
}
|
||||
|
||||
function Build-TweakPresetControlMap {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the control values needed to apply a saved tweak preset.
|
||||
|
||||
.PARAMETER Window
|
||||
The window that owns the visible tweak controls.
|
||||
|
||||
.PARAMETER SettingsJson
|
||||
The saved settings object to translate into control values.
|
||||
|
||||
.OUTPUTS
|
||||
System.Collections.Hashtable. Control metadata keyed by control name.
|
||||
#>
|
||||
function Get-TweakPresetControlMap {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
$SettingsJson
|
||||
@@ -151,7 +171,7 @@ function Build-TweakPresetControlMap {
|
||||
return $presetMap
|
||||
}
|
||||
|
||||
# FeatureId -> control metadata, similar to ApplySettingsToUiControls lookup.
|
||||
# FeatureId -> control metadata, similar to Apply-SettingsToUiControls lookup.
|
||||
$featureIdIndex = @{}
|
||||
foreach ($controlName in $script:UiControlMappings.Keys) {
|
||||
$control = $Window.FindName($controlName)
|
||||
@@ -192,10 +212,23 @@ function Build-TweakPresetControlMap {
|
||||
return $presetMap
|
||||
}
|
||||
|
||||
function Build-CategoryTweakPresetMap {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the enabled state map for visible tweak controls in a category.
|
||||
|
||||
.PARAMETER Window
|
||||
The window that owns the visible tweak controls.
|
||||
|
||||
.PARAMETER CategoryId
|
||||
The stable CategoryId (from Features.json) whose mapped controls are included.
|
||||
|
||||
.OUTPUTS
|
||||
System.Collections.Hashtable. Control metadata keyed by control name.
|
||||
#>
|
||||
function Get-CategoryTweakPresetMap {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
[string]$Category
|
||||
[string]$CategoryId
|
||||
)
|
||||
|
||||
$presetMap = @{}
|
||||
@@ -203,7 +236,7 @@ function Build-CategoryTweakPresetMap {
|
||||
|
||||
foreach ($controlName in $script:UiControlMappings.Keys) {
|
||||
$mapping = $script:UiControlMappings[$controlName]
|
||||
if ($mapping.Category -ne $Category) { continue }
|
||||
if ($mapping.CategoryId -ne $CategoryId) { continue }
|
||||
|
||||
$control = $Window.FindName($controlName)
|
||||
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
|
||||
@@ -368,10 +401,10 @@ function Initialize-TweakPresetSources {
|
||||
$LastUsedSettingsJson
|
||||
)
|
||||
|
||||
$script:DefaultTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson
|
||||
$script:LastUsedTweakPresetMap = Build-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson
|
||||
$script:PrivacyTweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'Privacy & Suggested Content'
|
||||
$script:AITweakPresetMap = Build-CategoryTweakPresetMap -Window $Window -Category 'AI'
|
||||
$script:DefaultTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $DefaultSettingsJson
|
||||
$script:LastUsedTweakPresetMap = Get-TweakPresetControlMap -Window $Window -SettingsJson $LastUsedSettingsJson
|
||||
$script:PrivacyTweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId 'PrivacySuggestedContent'
|
||||
$script:AITweakPresetMap = Get-CategoryTweakPresetMap -Window $Window -CategoryId 'AI'
|
||||
|
||||
$presetLastUsedTweaksBtn = $Window.FindName('PresetLastUsedTweaksBtn')
|
||||
if ($presetLastUsedTweaksBtn) {
|
||||
@@ -414,7 +447,7 @@ function Update-UserSelectionDescription {
|
||||
|
||||
switch ($UserSelectionCombo.SelectedIndex) {
|
||||
0 {
|
||||
$currentUserName = GetUserName
|
||||
$currentUserName = Get-UserName
|
||||
if ([string]::IsNullOrWhiteSpace($currentUserName)) {
|
||||
$UserSelectionDescription.Text = "The currently logged-in user profile"
|
||||
}
|
||||
@@ -435,6 +468,9 @@ function Update-UserSelectionDescription {
|
||||
$UserSelectionDescription.Text = "The default user template, affecting all new users created after this point. Useful for Sysprep deployment."
|
||||
}
|
||||
}
|
||||
|
||||
# Mirror the description text on the combo's tooltip so the same context is shown on hover.
|
||||
$UserSelectionCombo.ToolTip = $UserSelectionDescription.Text
|
||||
}
|
||||
|
||||
function Test-OtherUsername {
|
||||
@@ -442,11 +478,14 @@ function Test-OtherUsername {
|
||||
[System.Windows.Window]$Window,
|
||||
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
|
||||
[System.Windows.Controls.TextBox]$OtherUsernameTextBox,
|
||||
[System.Windows.Controls.TextBlock]$UsernameValidationMessage
|
||||
[System.Windows.Controls.TextBlock]$UsernameValidationMessage,
|
||||
[System.Windows.Controls.ComboBox]$AppRemovalScopeCombo
|
||||
)
|
||||
|
||||
# Only validate if "Other User" is selected
|
||||
if ($UserSelectionCombo.SelectedIndex -ne 1) {
|
||||
# Only validate if "Other User" is the deployment target, or "Target user only" is the app-removal scope
|
||||
$isOtherUserSelected = ($UserSelectionCombo.SelectedIndex -eq 1)
|
||||
$isAppRemovalTargetUserSelected = Test-AppRemovalScopeTargetsOtherUser -AppRemovalScopeCombo $AppRemovalScopeCombo
|
||||
if (-not $isOtherUserSelected -and -not $isAppRemovalTargetUserSelected) {
|
||||
return $true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
# MainWindow-TweaksBuilder.ps1
|
||||
# Dynamic tweaks UI construction from Features.json, tweak state management, selection clear, and search/highlight.
|
||||
|
||||
function Build-DynamicTweaks {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the main window's dynamic tweak controls from Features.json.
|
||||
|
||||
.PARAMETER Window
|
||||
The main window whose category columns receive the generated controls.
|
||||
|
||||
.PARAMETER WinVersion
|
||||
The Windows build number used for the category-icon fallback.
|
||||
|
||||
.NOTES
|
||||
Initializes script-scoped control and category mappings used by the tweak UI.
|
||||
#>
|
||||
function New-DynamicTweakControls {
|
||||
param(
|
||||
[System.Windows.Window]$Window,
|
||||
[int]$WinVersion
|
||||
)
|
||||
|
||||
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
|
||||
$featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
|
||||
|
||||
if (-not $featuresJson) {
|
||||
throw "Unable to load Features.json file. The GUI cannot continue without feature definitions."
|
||||
@@ -29,7 +42,26 @@ function Build-DynamicTweaks {
|
||||
$script:TweaksCompactMode = $null
|
||||
$script:TweaksCardsMovedFromCol2 = @()
|
||||
|
||||
function CreateLabeledCombo($parent, $labelText, $comboName, $items) {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates and registers a labeled combo box or a checkbox for a tweak.
|
||||
|
||||
.PARAMETER Parent
|
||||
The panel that receives the generated control.
|
||||
|
||||
.PARAMETER LabelText
|
||||
The display and automation label for the tweak.
|
||||
|
||||
.PARAMETER ComboName
|
||||
The name used to register the generated control.
|
||||
|
||||
.PARAMETER Items
|
||||
The available tweak options; two options produce a checkbox.
|
||||
|
||||
.OUTPUTS
|
||||
System.Windows.Controls.Control. The generated checkbox or combo box.
|
||||
#>
|
||||
function New-LabeledCombo($parent, $labelText, $comboName, $items) {
|
||||
# If only 2 items (No Change + one option), use a checkbox instead
|
||||
if ($items.Count -eq 2) {
|
||||
$checkbox = New-Object System.Windows.Controls.CheckBox
|
||||
@@ -96,7 +128,17 @@ function Build-DynamicTweaks {
|
||||
return $combo
|
||||
}
|
||||
|
||||
function GetWikiUrlForCategory($category) {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the Features wiki URL for a tweak category.
|
||||
|
||||
.PARAMETER Category
|
||||
The category name converted to a wiki anchor.
|
||||
|
||||
.OUTPUTS
|
||||
System.String. The category URL, or the Features page for an empty category.
|
||||
#>
|
||||
function Get-WikiUrlForCategory($category) {
|
||||
if (-not $category) { return 'https://github.com/Raphire/Win11Debloat/wiki/Features' }
|
||||
|
||||
$slug = $category.ToLowerInvariant()
|
||||
@@ -107,7 +149,17 @@ function Build-DynamicTweaks {
|
||||
return "https://github.com/Raphire/Win11Debloat/wiki/Features#$slug"
|
||||
}
|
||||
|
||||
function GetOrCreateCategoryCard($categoryObj) {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the existing category panel or creates and registers a new one.
|
||||
|
||||
.PARAMETER CategoryObj
|
||||
The category definition containing Name and Icon properties.
|
||||
|
||||
.OUTPUTS
|
||||
System.Windows.Controls.StackPanel. The category's content panel.
|
||||
#>
|
||||
function Get-OrCreateCategoryCard($categoryObj) {
|
||||
$categoryName = $categoryObj.Name
|
||||
$categoryIcon = $categoryObj.Icon
|
||||
|
||||
@@ -132,6 +184,9 @@ function Build-DynamicTweaks {
|
||||
# Convert HTML entity to character (e.g.,  -> actual character)
|
||||
if ($categoryIcon -match '&#x([0-9A-Fa-f]+);') {
|
||||
$hexValue = [Convert]::ToInt32($matches[1], 16)
|
||||
if ($WinVersion -lt 22000 -and $hexValue -eq 0xE794) {
|
||||
$hexValue = 0xE734
|
||||
}
|
||||
$icon.Text = [char]$hexValue
|
||||
}
|
||||
$icon.Style = $Window.Resources['CategoryHeaderIcon']
|
||||
@@ -149,7 +204,7 @@ function Build-DynamicTweaks {
|
||||
$helpBtn = New-Object System.Windows.Controls.Button
|
||||
$helpBtn.Content = $helpIcon
|
||||
$helpBtn.ToolTip = "Open the wiki for more info on '$categoryName' tweaks"
|
||||
$helpBtn.Tag = (GetWikiUrlForCategory -category $categoryName)
|
||||
$helpBtn.Tag = (Get-WikiUrlForCategory -category $categoryName)
|
||||
$helpBtn.Style = $Window.Resources['CategoryHelpLinkButtonStyle']
|
||||
$helpBtn.Add_Click({
|
||||
param($button, $e)
|
||||
@@ -179,8 +234,17 @@ function Build-DynamicTweaks {
|
||||
foreach ($c in $featuresJson.Categories) {
|
||||
$categoryName = if ($c -is [string]) { $c } else { $c.Name }
|
||||
if ($categoriesPresent.ContainsKey($categoryName)) {
|
||||
# Store the full category object (or create one with default icon for string categories)
|
||||
$categoryObj = if ($c -is [string]) { @{Name = $c; Icon = '' } } else { $c }
|
||||
# Store the full category object (or create one with default icon for string categories).
|
||||
# A category without its own CategoryId falls back to its Name, same as before CategoryId existed.
|
||||
$categoryObj = if ($c -is [string]) {
|
||||
@{Name = $c; CategoryId = $c; Icon = '' }
|
||||
}
|
||||
elseif (-not $c.CategoryId) {
|
||||
@{Name = $c.Name; CategoryId = $c.Name; Icon = $c.Icon }
|
||||
}
|
||||
else {
|
||||
$c
|
||||
}
|
||||
$orderedCategories += $categoryObj
|
||||
}
|
||||
}
|
||||
@@ -188,7 +252,7 @@ function Build-DynamicTweaks {
|
||||
else {
|
||||
# For backward compatibility, create category objects from keys
|
||||
foreach ($catName in $categoriesPresent.Keys) {
|
||||
$orderedCategories += @{Name = $catName; Icon = '' }
|
||||
$orderedCategories += @{Name = $catName; CategoryId = $catName; Icon = '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,10 +264,10 @@ function Build-DynamicTweaks {
|
||||
|
||||
foreach ($categoryObj in $orderedCategories) {
|
||||
$categoryName = $categoryObj.Name
|
||||
$categoryId = $categoryObj.CategoryId
|
||||
|
||||
# Create/get card for this category
|
||||
$panel = GetOrCreateCategoryCard -categoryObj $categoryObj
|
||||
if (-not $panel) { continue }
|
||||
# Card is created lazily on the first rendered item
|
||||
$panel = $null
|
||||
|
||||
# Collect groups and features for this category, then sort by priority
|
||||
$categoryItems = @()
|
||||
@@ -287,7 +351,8 @@ function Build-DynamicTweaks {
|
||||
if ($soleFeature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($soleFeature.FeatureId -match '^Enable') { $opt = 'Enable' }
|
||||
$items = @('No Change', $opt)
|
||||
$comboName = ("Feature_{0}_Combo" -f $soleFeature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
|
||||
$combo = CreateLabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items
|
||||
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
|
||||
$combo = New-LabeledCombo -parent $panel -labelText $soleFeature.Label -comboName $comboName -items $items
|
||||
# attach tooltip from Features.json if present
|
||||
if ($soleFeature.ToolTip -or $soleFeature.DisableWhenApplied -eq $true) {
|
||||
$tooltipText = $soleFeature.ToolTip
|
||||
@@ -304,14 +369,15 @@ function Build-DynamicTweaks {
|
||||
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
||||
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
|
||||
}
|
||||
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; Category = $categoryName }
|
||||
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $soleFeature.FeatureId; Label = $soleFeature.Label; CategoryId = $categoryId }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$items = @('No Change') + ($filteredValues | ForEach-Object { $_.Label })
|
||||
$comboName = 'Group_{0}Combo' -f $group.GroupId
|
||||
$combo = CreateLabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items
|
||||
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
|
||||
$combo = New-LabeledCombo -parent $panel -labelText $group.Label -comboName $comboName -items $items
|
||||
# attach tooltip from UiGroups if present
|
||||
if ($group.ToolTip) {
|
||||
$tipBlock = New-Object System.Windows.Controls.TextBlock
|
||||
@@ -323,7 +389,7 @@ function Build-DynamicTweaks {
|
||||
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
||||
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
|
||||
}
|
||||
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; Category = $categoryName }
|
||||
$script:UiControlMappings[$comboName] = @{ Type = 'group'; Values = $filteredValues; Label = $group.Label; CategoryId = $categoryId }
|
||||
}
|
||||
elseif ($item.Type -eq 'feature') {
|
||||
$feature = $item.Data
|
||||
@@ -331,7 +397,8 @@ function Build-DynamicTweaks {
|
||||
if ($feature.FeatureId -match '^Disable') { $opt = 'Disable' } elseif ($feature.FeatureId -match '^Enable') { $opt = 'Enable' }
|
||||
$items = @('No Change', $opt)
|
||||
$comboName = ("Feature_{0}_Combo" -f $feature.FeatureId) -replace '[^a-zA-Z0-9_]', ''
|
||||
$combo = CreateLabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items
|
||||
if (-not $panel) { $panel = Get-OrCreateCategoryCard -categoryObj $categoryObj }
|
||||
$combo = New-LabeledCombo -parent $panel -labelText $feature.Label -comboName $comboName -items $items
|
||||
# attach tooltip from Features.json if present, and include the disabled-state reason
|
||||
if ($feature.ToolTip -or $feature.DisableWhenApplied -eq $true) {
|
||||
$tooltipText = $feature.ToolTip
|
||||
@@ -349,7 +416,7 @@ function Build-DynamicTweaks {
|
||||
try { $lblBorderObj = $Window.FindName("$comboName`_LabelBorder") } catch {}
|
||||
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
|
||||
}
|
||||
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; Category = $categoryName }
|
||||
$script:UiControlMappings[$comboName] = @{ Type = 'feature'; FeatureId = $feature.FeatureId; Label = $feature.Label; CategoryId = $categoryId }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,7 +439,7 @@ function Update-CurrentTweakSystemState {
|
||||
if (-not $script:UiControlMappings) { return }
|
||||
if (-not $script:Features) { return }
|
||||
|
||||
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
|
||||
$featuresJson = Import-JsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
|
||||
if (-not $featuresJson) { return }
|
||||
|
||||
$groupMap = @{}
|
||||
@@ -418,7 +485,14 @@ function Update-CurrentTweakSystemState {
|
||||
}
|
||||
}
|
||||
|
||||
function Load-CurrentTweakStateIntoUI {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Updates tweak controls to reflect the current system state.
|
||||
|
||||
.PARAMETER Window
|
||||
The window that owns the generated tweak controls.
|
||||
#>
|
||||
function Set-CurrentTweakStateInUi {
|
||||
param([System.Windows.Window]$Window)
|
||||
|
||||
Update-CurrentTweakSystemState -Window $Window -ApplyToUi:$true
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
populates the window's Resources with SolidColorBrush entries keyed by
|
||||
category and resource name (e.g. "AppAccentColor"). Additionally loads and
|
||||
merges shared XAML styles from the script's SharedStylesSchema path if
|
||||
available.
|
||||
available. Also resolves the icon font: Segoe Fluent Icons on Windows 11
|
||||
and Segoe MDL2 Assets on Windows 10.
|
||||
|
||||
.PARAMETER window
|
||||
The WPF Window whose resource dictionary will be populated.
|
||||
@@ -16,13 +17,13 @@
|
||||
When $true, dark theme colors are applied; when $false, light theme colors.
|
||||
|
||||
.EXAMPLE
|
||||
SetWindowThemeResources -window $MainWindow -usesDarkMode $true
|
||||
Set-WindowThemeResources -window $MainWindow -usesDarkMode $true
|
||||
|
||||
.EXAMPLE
|
||||
SetWindowThemeResources -window $Dialog -usesDarkMode $false
|
||||
Set-WindowThemeResources -window $Dialog -usesDarkMode $false
|
||||
#>
|
||||
# Sets resource colors for a WPF window based on dark mode preference
|
||||
function SetWindowThemeResources {
|
||||
function Set-WindowThemeResources {
|
||||
param (
|
||||
$window,
|
||||
[bool]$usesDarkMode
|
||||
@@ -122,6 +123,12 @@ function SetWindowThemeResources {
|
||||
}
|
||||
}
|
||||
|
||||
# Segoe Fluent Icons ships only on Windows 11 (build >= 22000).
|
||||
# On Windows 10, fall back to Segoe MDL2 Assets.
|
||||
$winBuild = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
|
||||
$iconFontName = if ($winBuild -ge 22000) { 'Segoe Fluent Icons' } else { 'Segoe MDL2 Assets' }
|
||||
$window.Resources['AppIconFontFamily'] = [System.Windows.Media.FontFamily]::new($iconFontName)
|
||||
|
||||
# Load and merge shared styles
|
||||
if ($script:SharedStylesSchema -and (Test-Path $script:SharedStylesSchema)) {
|
||||
$sharedXaml = Get-Content -Path $script:SharedStylesSchema -Raw
|
||||
@@ -1,3 +1,10 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Displays the themed About dialog for the application.
|
||||
|
||||
.PARAMETER Owner
|
||||
The optional window that owns the dialog and its modal overlay.
|
||||
#>
|
||||
function Show-AboutDialog {
|
||||
param (
|
||||
[Parameter(Mandatory=$false)]
|
||||
@@ -6,7 +13,7 @@ function Show-AboutDialog {
|
||||
|
||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||
|
||||
$usesDarkMode = GetSystemUsesDarkMode
|
||||
$usesDarkMode = Get-SystemUsesDarkMode
|
||||
|
||||
# Determine owner window
|
||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||
@@ -42,7 +49,7 @@ function Show-AboutDialog {
|
||||
}
|
||||
|
||||
# Apply theme resources
|
||||
SetWindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
|
||||
Set-WindowThemeResources -window $aboutWindow -usesDarkMode $usesDarkMode
|
||||
|
||||
# Get UI elements
|
||||
$titleBar = $aboutWindow.FindName('TitleBar')
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Shows application selection window that allows the user to select what apps they want to remove or keep
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Displays the application-selection dialog and records the confirmed selections.
|
||||
|
||||
.OUTPUTS
|
||||
System.Nullable[System.Boolean]. The dialog result; confirmed application IDs are stored in $script:SelectedApps.
|
||||
#>
|
||||
function Show-AppSelectionWindow {
|
||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||
|
||||
$usesDarkMode = GetSystemUsesDarkMode
|
||||
$usesDarkMode = Get-SystemUsesDarkMode
|
||||
|
||||
# Show overlay if main window exists
|
||||
$overlay = $null
|
||||
@@ -34,7 +40,7 @@ function Show-AppSelectionWindow {
|
||||
catch { }
|
||||
}
|
||||
|
||||
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||
|
||||
$appsPanel = $window.FindName('AppsPanel')
|
||||
$checkAllBox = $window.FindName('CheckAllBox')
|
||||
@@ -46,8 +52,14 @@ function Show-AppSelectionWindow {
|
||||
# Track the last selected checkbox for shift-click range selection
|
||||
$script:AppSelectionWindowLastSelectedCheckbox = $null
|
||||
|
||||
# Loads apps into the apps UI
|
||||
function LoadApps {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reloads the application-selection checkboxes using the current installed-apps filter.
|
||||
|
||||
.NOTES
|
||||
Updates the dialog loading indicator and resets range-selection state.
|
||||
#>
|
||||
function Load-Apps {
|
||||
# Show loading indicator
|
||||
$loadingIndicator.Visibility = 'Visible'
|
||||
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{})
|
||||
@@ -57,7 +69,7 @@ function Show-AppSelectionWindow {
|
||||
|
||||
if ($onlyInstalledBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
|
||||
# Attempt to get a list of installed apps via WinGet, times out after 10 seconds
|
||||
$listOfApps = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
|
||||
$listOfApps = Get-WingetInstalledApps -TimeOut 10 -NonBlocking
|
||||
if ($null -eq $listOfApps) {
|
||||
# Show error that the script was unable to get list of apps from WinGet
|
||||
Show-MessageBox -Message 'Unable to load list of installed apps via WinGet.' -Title 'Error' -Button 'OK' -Icon 'Error' -Owner $window | Out-Null
|
||||
@@ -65,7 +77,7 @@ function Show-AppSelectionWindow {
|
||||
}
|
||||
}
|
||||
|
||||
$appsToAdd = LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true
|
||||
$appsToAdd = Import-AppDetailsFromJson -OnlyInstalled:$onlyInstalledBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$true
|
||||
|
||||
# Reset the last selected checkbox when loading a new list
|
||||
$script:AppSelectionWindowLastSelectedCheckbox = $null
|
||||
@@ -82,7 +94,7 @@ function Show-AppSelectionWindow {
|
||||
$checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"]
|
||||
|
||||
# Attach shift-click behavior for range selection
|
||||
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox)
|
||||
Attach-ShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:AppSelectionWindowLastSelectedCheckbox)
|
||||
|
||||
$appsPanel.Children.Add($checkbox) | Out-Null
|
||||
}
|
||||
@@ -112,8 +124,8 @@ function Show-AppSelectionWindow {
|
||||
}
|
||||
})
|
||||
|
||||
$onlyInstalledBox.Add_Checked({ LoadApps })
|
||||
$onlyInstalledBox.Add_Unchecked({ LoadApps })
|
||||
$onlyInstalledBox.Add_Checked({ Load-Apps })
|
||||
$onlyInstalledBox.Add_Unchecked({ Load-Apps })
|
||||
|
||||
$confirmBtn.Add_Click({
|
||||
$selectedApps = @()
|
||||
@@ -130,7 +142,7 @@ function Show-AppSelectionWindow {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
|
||||
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,7 +153,7 @@ function Show-AppSelectionWindow {
|
||||
|
||||
# Load apps after window is shown (allows UI to render first)
|
||||
$window.Add_ContentRendered({
|
||||
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ LoadApps }) | Out-Null
|
||||
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Load-Apps }) | Out-Null
|
||||
})
|
||||
|
||||
# Show the window and return dialog result
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Displays the modal progress window while selected changes are applied.
|
||||
|
||||
.PARAMETER Owner
|
||||
The optional window that owns the modal and its overlay.
|
||||
|
||||
.PARAMETER InvokeRestartExplorer
|
||||
Indicates whether the modal should run the Explorer-restart flow after applying changes.
|
||||
#>
|
||||
function Show-ApplyModal {
|
||||
param (
|
||||
[Parameter(Mandatory=$false)]
|
||||
[System.Windows.Window]$Owner = $null,
|
||||
[Parameter(Mandatory=$false)]
|
||||
[bool]$RestartExplorer = $false
|
||||
[bool]$InvokeRestartExplorer = $false
|
||||
)
|
||||
|
||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||
|
||||
$usesDarkMode = GetSystemUsesDarkMode
|
||||
$usesDarkMode = Get-SystemUsesDarkMode
|
||||
|
||||
# Determine owner window
|
||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||
@@ -44,7 +54,7 @@ function Show-ApplyModal {
|
||||
}
|
||||
|
||||
# Apply theme resources
|
||||
SetWindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
|
||||
Set-WindowThemeResources -window $applyWindow -usesDarkMode $usesDarkMode
|
||||
|
||||
# Get UI elements
|
||||
$script:ApplyInProgressPanel = $applyWindow.FindName('ApplyInProgressPanel')
|
||||
@@ -81,7 +91,7 @@ function Show-ApplyModal {
|
||||
$pct = if ($totalSteps -gt 0) { [math]::Round((($currentStep - 1) / $totalSteps) * 100) } else { 0 }
|
||||
$script:ApplyProgressBarEl.Value = $pct
|
||||
# Process pending window messages to keep UI responsive
|
||||
DoEvents
|
||||
Invoke-DoEvents
|
||||
}
|
||||
|
||||
# Sub-step callback updates step name and interpolates progress bar within the current step
|
||||
@@ -96,7 +106,7 @@ function Show-ApplyModal {
|
||||
$stepFraction = ($subIndex / $subCount) / $totalSteps
|
||||
$script:ApplyProgressBarEl.Value = [math]::Round(($baseProgress + $stepFraction) * 100)
|
||||
}
|
||||
DoEvents
|
||||
Invoke-DoEvents
|
||||
}
|
||||
|
||||
# Run changes in background to keep UI responsive
|
||||
@@ -104,11 +114,12 @@ function Show-ApplyModal {
|
||||
try {
|
||||
Invoke-AllChanges
|
||||
|
||||
$registryImportFailureCount = [int]$script:RegistryImportFailures
|
||||
$failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures
|
||||
$appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable
|
||||
|
||||
# Restart explorer if requested
|
||||
if ($RestartExplorer -and -not $script:CancelRequested) {
|
||||
RestartExplorer
|
||||
if ($InvokeRestartExplorer -and -not $script:CancelRequested) {
|
||||
Invoke-RestartExplorer
|
||||
|
||||
# Wait for Explorer to finish relaunching, then reclaim focus.
|
||||
Start-Sleep -Milliseconds 800
|
||||
@@ -118,11 +129,6 @@ function Show-ApplyModal {
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if ($script:CancelRequested) {
|
||||
Write-Host "Script execution was cancelled by the user. Some changes may not have been applied."
|
||||
} elseif ($registryImportFailureCount -eq 0) {
|
||||
Write-Host "All changes have been applied successfully!"
|
||||
}
|
||||
|
||||
# Show completion state
|
||||
$script:ApplyProgressBarEl.Value = 100
|
||||
@@ -130,27 +136,35 @@ function Show-ApplyModal {
|
||||
$script:ApplyCompletionPanel.Visibility = 'Visible'
|
||||
|
||||
if ($script:CancelRequested) {
|
||||
Write-Warning "Script execution was cancelled by the user. Any remaining changes were not applied."
|
||||
|
||||
$script:ApplyCompletionIconEl.Text = [char]0xE7BA
|
||||
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
|
||||
$script:ApplyCompletionTitleEl.Text = "Cancelled"
|
||||
$script:ApplyCompletionMessageEl.Text = "Script execution was cancelled by the user."
|
||||
} elseif ($registryImportFailureCount -gt 0) {
|
||||
} elseif ($failureCount -gt 0 -or $appRemovalVerificationUnavailable) {
|
||||
if ($failureCount -gt 0) {
|
||||
Write-Host "Script completed with $failureCount error(s)."
|
||||
}
|
||||
|
||||
$script:ApplyCompletionIconEl.Text = [char]0xE7BA
|
||||
$script:ApplyCompletionIconEl.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e8912d"))
|
||||
if ($failureCount -eq 0 -and $appRemovalVerificationUnavailable) {
|
||||
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
|
||||
$script:ApplyCompletionMessageEl.Text = "All changes were applied without errors, but Win11Debloat could not confirm that all selected apps were successfully uninstalled."
|
||||
}
|
||||
else {
|
||||
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors"
|
||||
$script:ApplyCompletionMessageEl.Text = "$registryImportFailureCount registry change(s) failed. See console for details."
|
||||
$script:ApplyCompletionMessageEl.Text = "$failureCount change(s) failed. See console for details."
|
||||
}
|
||||
} else {
|
||||
Write-Host "All changes have been applied successfully!"
|
||||
|
||||
$script:ApplyCompletionTitleEl.Text = "Changes Applied"
|
||||
|
||||
# Show completion message with reboot instructions if any applied features require reboot
|
||||
if ($RestartExplorer) {
|
||||
$rebootFeatures = @()
|
||||
foreach ($paramKey in $script:Params.Keys) {
|
||||
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
|
||||
$feature = $script:Features[$paramKey]
|
||||
$rebootFeatures += "$($feature.Label)"
|
||||
}
|
||||
}
|
||||
if ($InvokeRestartExplorer) {
|
||||
$rebootFeatures = Get-RebootFeatureLabels
|
||||
|
||||
if ($rebootFeatures.Count -gt 0) {
|
||||
foreach ($featureName in $rebootFeatures) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Shows a modal category-selection dialog for importing or exporting configuration.
|
||||
#>
|
||||
function Show-ImportExportConfigWindow {
|
||||
param (
|
||||
[System.Windows.Window]$Owner,
|
||||
@@ -45,7 +49,7 @@ function Show-ImportExportConfigWindow {
|
||||
}
|
||||
|
||||
$dlg.Owner = $Owner
|
||||
SetWindowThemeResources -window $dlg -usesDarkMode $UsesDarkMode
|
||||
Set-WindowThemeResources -window $dlg -usesDarkMode $UsesDarkMode
|
||||
|
||||
# Copy the CheckBox default style from the main window so checkboxes get the themed template
|
||||
try {
|
||||
@@ -215,6 +219,11 @@ function Get-DeploymentSettings {
|
||||
$deploySettings += @{ Name = 'CreateRestorePoint'; Value = [bool]$restorePointCheckBox.IsChecked }
|
||||
}
|
||||
|
||||
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
|
||||
if ($registryBackupCheckBox) {
|
||||
$deploySettings += @{ Name = 'SkipRegistryBackup'; Value = -not [bool]$registryBackupCheckBox.IsChecked }
|
||||
}
|
||||
|
||||
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
|
||||
if ($restartExplorerCheckBox) {
|
||||
$deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked }
|
||||
@@ -268,6 +277,7 @@ function Get-DeploymentCategoryDetailString {
|
||||
|
||||
$options = @()
|
||||
if ($lookup.ContainsKey('CreateRestorePoint') -and [bool]$lookup['CreateRestorePoint']) { $options += 'Restore Point' }
|
||||
if (-not ($lookup.ContainsKey('SkipRegistryBackup') -and [bool]$lookup['SkipRegistryBackup'])) { $options += 'Registry Backup' }
|
||||
if ($lookup.ContainsKey('RestartExplorer') -and [bool]$lookup['RestartExplorer']) { $options += 'Restart Explorer' }
|
||||
|
||||
$lines = @()
|
||||
@@ -308,7 +318,11 @@ function Build-CategoryDetails {
|
||||
return $details
|
||||
}
|
||||
|
||||
function Apply-ImportedApplications {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies imported application selections to the application checkboxes.
|
||||
#>
|
||||
function Set-ImportedApplications {
|
||||
param (
|
||||
[System.Windows.Controls.Panel]$AppsPanel,
|
||||
[string[]]$AppIds
|
||||
@@ -321,7 +335,11 @@ function Apply-ImportedApplications {
|
||||
}
|
||||
}
|
||||
|
||||
function Apply-ImportedTweakSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies imported tweak settings to their mapped UI controls.
|
||||
#>
|
||||
function Set-ImportedTweakSettings {
|
||||
param (
|
||||
[System.Windows.Window]$Owner,
|
||||
[hashtable]$UiControlMappings,
|
||||
@@ -329,10 +347,14 @@ function Apply-ImportedTweakSettings {
|
||||
)
|
||||
|
||||
$settingsJson = [PSCustomObject]@{ Settings = @($TweakSettings) }
|
||||
ApplySettingsToUiControls -window $Owner -settingsJson $settingsJson -uiControlMappings $UiControlMappings
|
||||
Apply-SettingsToUiControls -window $Owner -settingsJson $settingsJson -uiControlMappings $UiControlMappings
|
||||
}
|
||||
|
||||
function Apply-ImportedDeploymentSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies imported deployment settings to the deployment controls.
|
||||
#>
|
||||
function Set-ImportedDeploymentSettings {
|
||||
param (
|
||||
[System.Windows.Window]$Owner,
|
||||
[System.Windows.Controls.ComboBox]$UserSelectionCombo,
|
||||
@@ -362,12 +384,23 @@ function Apply-ImportedDeploymentSettings {
|
||||
$restorePointCheckBox.IsChecked = [bool]$lookup['CreateRestorePoint']
|
||||
}
|
||||
|
||||
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
|
||||
if ($registryBackupCheckBox) {
|
||||
if ($lookup.ContainsKey('SkipRegistryBackup')) {
|
||||
$registryBackupCheckBox.IsChecked = -not [bool]$lookup['SkipRegistryBackup']
|
||||
}
|
||||
}
|
||||
|
||||
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
|
||||
if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) {
|
||||
$restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer']
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Exports selected application, tweak, and deployment settings to a configuration file.
|
||||
#>
|
||||
function Export-Configuration {
|
||||
param (
|
||||
[System.Windows.Window]$Owner,
|
||||
@@ -427,7 +460,7 @@ function Export-Configuration {
|
||||
return
|
||||
}
|
||||
|
||||
if (SaveToFile -Config $config -FilePath $saveDialog.FileName) {
|
||||
if (Save-ToFile -Config $config -FilePath $saveDialog.FileName) {
|
||||
Write-Host "Configuration exported successfully: $($saveDialog.FileName)"
|
||||
Show-MessageBox -Message "Configuration exported successfully." -Title 'Export Configuration' -Button 'OK' -Icon 'Information' | Out-Null
|
||||
}
|
||||
@@ -437,6 +470,10 @@ function Export-Configuration {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports selected application, tweak, and deployment settings from a configuration file.
|
||||
#>
|
||||
function Import-Configuration {
|
||||
param (
|
||||
[System.Windows.Window]$Owner,
|
||||
@@ -462,27 +499,22 @@ function Import-Configuration {
|
||||
|
||||
Write-Host "Importing configuration from '$($openDialog.FileName)'..."
|
||||
|
||||
$config = LoadJsonFile -filePath $openDialog.FileName -expectedVersion '1.0'
|
||||
$config = Import-JsonFile -filePath $openDialog.FileName -expectedVersion '1.0'
|
||||
if (-not $config) {
|
||||
Write-Error "Failed to read configuration file '$($openDialog.FileName)'"
|
||||
Show-MessageBox -Message "Failed to read configuration file" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $config.Version) {
|
||||
Write-Error "Invalid configuration file format: '$($openDialog.FileName)'"
|
||||
Show-MessageBox -Message "Invalid configuration file format." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
|
||||
$consistencyError = Test-ConfigConsistency -Config $config
|
||||
if ($consistencyError) {
|
||||
Write-Error "Invalid configuration file '$($openDialog.FileName)': $consistencyError"
|
||||
Show-MessageBox -Message "Invalid configuration file: $consistencyError" -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
$availableCategories = Get-AvailableImportExportCategories -Config $config
|
||||
|
||||
if ($availableCategories.Count -eq 0) {
|
||||
Write-Warning "Configuration file '$($openDialog.FileName)' contains no importable data."
|
||||
Show-MessageBox -Message "The selected file contains no importable data." -Title 'Invalid Config' -Button 'OK' -Icon 'Error' | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Available categories in config: $($availableCategories -join ', ')"
|
||||
|
||||
$appCount = @($config.Apps | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }).Count
|
||||
@@ -504,7 +536,7 @@ function Import-Configuration {
|
||||
)
|
||||
|
||||
Write-Host "Importing $($appIds.Count) app selection(s)."
|
||||
Apply-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
|
||||
Set-ImportedApplications -AppsPanel $AppsPanel -AppIds $appIds
|
||||
|
||||
if ($OnAppsImported) {
|
||||
& $OnAppsImported
|
||||
@@ -513,11 +545,11 @@ function Import-Configuration {
|
||||
if ($categories -contains 'System Tweaks' -and $config.Tweaks) {
|
||||
$tweakCount = @($config.Tweaks).Count
|
||||
Write-Host "Importing $tweakCount tweak(s)."
|
||||
Apply-ImportedTweakSettings -Owner $Owner -UiControlMappings $UiControlMappings -TweakSettings @($config.Tweaks)
|
||||
Set-ImportedTweakSettings -Owner $Owner -UiControlMappings $UiControlMappings -TweakSettings @($config.Tweaks)
|
||||
}
|
||||
if ($categories -contains 'Deployment Settings' -and $config.Deployment) {
|
||||
Write-Host 'Importing deployment settings.'
|
||||
Apply-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment)
|
||||
Set-ImportedDeploymentSettings -Owner $Owner -UserSelectionCombo $UserSelectionCombo -OtherUsernameTextBox $OtherUsernameTextBox -DeploymentSettings @($config.Deployment)
|
||||
}
|
||||
|
||||
Write-Host 'Configuration imported successfully.'
|
||||
@@ -1,8 +1,12 @@
|
||||
function Show-MainWindow {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates and displays the main Win11Debloat window.
|
||||
#>
|
||||
function Show-MainWindow {
|
||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null
|
||||
|
||||
$WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild
|
||||
$usesDarkMode = GetSystemUsesDarkMode
|
||||
$usesDarkMode = Get-SystemUsesDarkMode
|
||||
|
||||
# ---- Load XAML ----
|
||||
$xaml = Get-Content -Path $script:MainWindowSchema -Raw
|
||||
@@ -14,7 +18,7 @@
|
||||
$reader.Close()
|
||||
}
|
||||
|
||||
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||
|
||||
$mainBorder = $window.FindName('MainBorder')
|
||||
$titleBarBackground = $window.FindName('TitleBarBackground')
|
||||
@@ -223,7 +227,7 @@
|
||||
if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false }
|
||||
|
||||
# ---- Build JSON-defined app presets ----
|
||||
foreach ($preset in (LoadAppPresetsFromJson)) {
|
||||
foreach ($preset in (Import-AppPresetsFromJson)) {
|
||||
$checkbox = New-Object System.Windows.Controls.CheckBox
|
||||
$checkbox.Content = $preset.Name
|
||||
$checkbox.IsThreeState = $true
|
||||
@@ -301,8 +305,8 @@
|
||||
|
||||
# ---- Load apps ----
|
||||
$appLoadStatusCallback = { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo }
|
||||
$onlyInstalledAppsBox.Add_Checked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
|
||||
$onlyInstalledAppsBox.Add_Unchecked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
|
||||
$onlyInstalledAppsBox.Add_Checked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
|
||||
$onlyInstalledAppsBox.Add_Unchecked({ Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn })
|
||||
|
||||
# ---- App presets popup ----
|
||||
$presetsPopup.Add_Opened({
|
||||
@@ -573,6 +577,7 @@
|
||||
# ---- App removal scope combo ----
|
||||
$appRemovalScopeCombo.Add_SelectionChanged({
|
||||
Update-AppRemovalScopeDescription -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeDescription $appRemovalScopeDescription
|
||||
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
|
||||
})
|
||||
|
||||
# ---- Other username text box ----
|
||||
@@ -584,12 +589,12 @@
|
||||
$usernameTextBoxPlaceholder.Visibility = 'Collapsed'
|
||||
}
|
||||
Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription
|
||||
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage | Out-Null
|
||||
Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo | Out-Null
|
||||
})
|
||||
|
||||
# ---- Validate target user helper ----
|
||||
$ensureValidTargetUserOrWarn = {
|
||||
if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage)) {
|
||||
if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage -AppRemovalScopeCombo $appRemovalScopeCombo)) {
|
||||
$validationMessage = if (-not [string]::IsNullOrWhiteSpace($usernameValidationMessage.Text)) {
|
||||
$usernameValidationMessage.Text
|
||||
}
|
||||
@@ -617,9 +622,9 @@
|
||||
$ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false
|
||||
}
|
||||
|
||||
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
if ($defaultsJson) {
|
||||
ApplySettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
|
||||
Apply-SettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
|
||||
}
|
||||
|
||||
if ($script:IsLoadingApps) {
|
||||
@@ -663,25 +668,23 @@
|
||||
$hasAppSelection = ($selectedApps.Count -gt 0)
|
||||
|
||||
if ($selectedApps.Count -gt 0) {
|
||||
if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return }
|
||||
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return }
|
||||
|
||||
AddParameter 'RemoveApps'
|
||||
AddParameter 'Apps' ($selectedApps -join ',')
|
||||
$scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox
|
||||
if ([string]::IsNullOrWhiteSpace($scopeTarget)) {
|
||||
Write-Warning 'App removal was cancelled because the selected removal scope is invalid.'
|
||||
return
|
||||
}
|
||||
|
||||
$selectedScopeItem = $appRemovalScopeCombo.SelectedItem
|
||||
if ($selectedScopeItem) {
|
||||
switch ($selectedScopeItem.Content) {
|
||||
"All users" { AddParameter 'AppRemovalTarget' 'AllUsers' }
|
||||
"Current user only" { AddParameter 'AppRemovalTarget' 'CurrentUser' }
|
||||
"Target user only" { AddParameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) }
|
||||
}
|
||||
}
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' ($selectedApps -join ',')
|
||||
Add-Parameter 'AppRemovalTarget' $scopeTarget
|
||||
}
|
||||
|
||||
# Apply dynamic tweaks
|
||||
foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) {
|
||||
if ($tweakAction.Action -eq 'Apply') {
|
||||
AddParameter $tweakAction.FeatureId
|
||||
Add-Parameter $tweakAction.FeatureId
|
||||
$null = $selectedForwardFeatureIds.Add([string]$tweakAction.FeatureId)
|
||||
continue
|
||||
}
|
||||
@@ -695,27 +698,32 @@
|
||||
|
||||
$restorePointCheckBox = $window.FindName('RestorePointCheckBox')
|
||||
if ($restorePointCheckBox -and $restorePointCheckBox.IsChecked) {
|
||||
AddParameter 'CreateRestorePoint'
|
||||
Add-Parameter 'CreateRestorePoint'
|
||||
}
|
||||
|
||||
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
|
||||
if ($registryBackupCheckBox -and -not $registryBackupCheckBox.IsChecked) {
|
||||
Add-Parameter 'SkipRegistryBackup'
|
||||
}
|
||||
|
||||
switch ($userSelectionCombo.SelectedIndex) {
|
||||
0 { Write-Host "Selected user mode: current user ($(GetUserName))" }
|
||||
0 { Write-Host "Selected user mode: current user ($(Get-UserName))" }
|
||||
1 {
|
||||
Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())"
|
||||
AddParameter User ($otherUsernameTextBox.Text.Trim())
|
||||
Add-Parameter User ($otherUsernameTextBox.Text.Trim())
|
||||
}
|
||||
2 {
|
||||
Write-Host "Selected user mode: default user profile (Sysprep)"
|
||||
AddParameter Sysprep
|
||||
Add-Parameter Sysprep
|
||||
}
|
||||
}
|
||||
|
||||
SaveSettings
|
||||
Save-Settings
|
||||
|
||||
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
|
||||
$shouldRestartExplorer = $restartExplorerCheckBox -and $restartExplorerCheckBox.IsChecked
|
||||
|
||||
Show-ApplyModal -Owner $window -RestartExplorer $shouldRestartExplorer
|
||||
Show-ApplyModal -Owner $window -InvokeRestartExplorer $shouldRestartExplorer
|
||||
$window.Close()
|
||||
})
|
||||
|
||||
@@ -737,12 +745,12 @@
|
||||
$window.Add_Loaded({
|
||||
try {
|
||||
& $updateHomeContentPosition
|
||||
Build-DynamicTweaks -Window $window -WinVersion $WinVersion
|
||||
Load-CurrentTweakStateIntoUI -Window $window
|
||||
New-DynamicTweakControls -Window $window -WinVersion $WinVersion
|
||||
Set-CurrentTweakStateInUi -Window $window
|
||||
Update-TweaksResponsiveColumns -Window $window
|
||||
|
||||
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
||||
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
$lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
||||
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
|
||||
$script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson
|
||||
|
||||
@@ -750,13 +758,13 @@
|
||||
Register-TweakPresetControlStateHandlers -Window $window
|
||||
Update-TweakPresetStates -Window $window
|
||||
|
||||
Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn
|
||||
Initialize-MainWindowApps -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn
|
||||
|
||||
# Update Current User label
|
||||
if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) {
|
||||
$currentUserItem = $userSelectionCombo.Items[0]
|
||||
if ($currentUserItem -is [System.Windows.Controls.ComboBoxItem]) {
|
||||
$currentUserItem.Content = "Current User ($(GetUserName))"
|
||||
$currentUserItem.Content = "Current User ($(Get-UserName))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,11 +781,17 @@
|
||||
}
|
||||
|
||||
$restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox')
|
||||
if ($restartExplorerCheckBox -and $script:Params.ContainsKey("NoRestartExplorer")) {
|
||||
if ($restartExplorerCheckBox -and $script:Params.ContainsKey('SkipExplorerRestart')) {
|
||||
$restartExplorerCheckBox.IsChecked = $false
|
||||
$restartExplorerCheckBox.IsEnabled = $false
|
||||
}
|
||||
|
||||
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
|
||||
if ($registryBackupCheckBox -and $script:Params.ContainsKey('SkipRegistryBackup')) {
|
||||
$registryBackupCheckBox.IsChecked = $false
|
||||
$registryBackupCheckBox.IsEnabled = $false
|
||||
}
|
||||
|
||||
if ($script:Params.ContainsKey("Sysprep")) {
|
||||
$userSelectionCombo.SelectedIndex = 2
|
||||
$userSelectionCombo.IsEnabled = $false
|
||||
@@ -809,8 +823,8 @@
|
||||
})
|
||||
|
||||
# ---- Tweak presets wiring ----
|
||||
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
||||
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
$lastUsedSettingsJson = Import-JsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
|
||||
$defaultsJson = Import-JsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
|
||||
$script:DefaultTweakPresetMap = @{}
|
||||
$script:LastUsedTweakPresetMap = @{}
|
||||
$script:PrivacyTweakPresetMap = @{}
|
||||
@@ -869,7 +883,7 @@
|
||||
|
||||
# ---- Preload app data ----
|
||||
try {
|
||||
$script:PreloadedAppData = LoadAppsDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false
|
||||
$script:PreloadedAppData = Import-AppDetailsFromJson -OnlyInstalled:$false -InstalledList $null -InitialCheckedFromJson:$false
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to preload apps list: $_"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# Shows a Windows 11 styled custom message box
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Shows a themed Windows 11-style message box.
|
||||
#>
|
||||
function Show-MessageBox {
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
@@ -24,7 +27,7 @@ function Show-MessageBox {
|
||||
|
||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||
|
||||
$usesDarkMode = GetSystemUsesDarkMode
|
||||
$usesDarkMode = Get-SystemUsesDarkMode
|
||||
|
||||
# Determine owner window - use provided Owner, or fall back to main GUI window
|
||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||
@@ -69,7 +72,7 @@ function Show-MessageBox {
|
||||
}
|
||||
|
||||
# Apply theme resources
|
||||
SetWindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
|
||||
Set-WindowThemeResources -window $msgWindow -usesDarkMode $usesDarkMode
|
||||
|
||||
# Get UI elements
|
||||
$titleText = $msgWindow.FindName('TitleText')
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Displays the Restore Backup wizard dialog.
|
||||
|
||||
.DESCRIPTION
|
||||
Presents a modal wizard that lets the user choose and restore either a
|
||||
registry backup or a Start Menu pinned-apps backup. Returns the user's
|
||||
selection via $window.Tag.
|
||||
|
||||
.PARAMETER Owner
|
||||
Optional parent WPF Window used to host this modal dialog. Defaults to the
|
||||
shared $script:GuiWindow when not supplied.
|
||||
|
||||
.OUTPUTS
|
||||
Hashtable
|
||||
Returns a Hashtable describing the user's choice. Possible shapes:
|
||||
RestoreRegistry - @{ Result='RestoreRegistry'; Backup=<normalizedBackup> }
|
||||
Restore-StartMenu - @{ Result='Restore-StartMenu'; StartMenuScope=<scope>;
|
||||
UseManualBackupFile=<bool>; BackupFilePath=<path|string> }
|
||||
Cancelled - @{ Result='Cancelled' } (from New-RestoreDialogState)
|
||||
#>
|
||||
function Show-RestoreBackupDialog {
|
||||
param(
|
||||
[System.Windows.Window]$Owner = $null
|
||||
@@ -5,7 +26,7 @@ function Show-RestoreBackupDialog {
|
||||
|
||||
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase | Out-Null
|
||||
|
||||
$usesDarkMode = GetSystemUsesDarkMode
|
||||
$usesDarkMode = Get-SystemUsesDarkMode
|
||||
$ownerWindow = if ($Owner) { $Owner } else { $script:GuiWindow }
|
||||
|
||||
$overlay = $null
|
||||
@@ -46,7 +67,7 @@ function Show-RestoreBackupDialog {
|
||||
}
|
||||
|
||||
try {
|
||||
SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||
Set-WindowThemeResources -window $window -usesDarkMode $usesDarkMode
|
||||
}
|
||||
catch { }
|
||||
|
||||
@@ -108,7 +129,7 @@ function Show-RestoreBackupDialog {
|
||||
param([string]$BackupFilePath)
|
||||
|
||||
$scopeInfo = & $getStartMenuScopeInfo
|
||||
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target $scopeInfo.Target
|
||||
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target $scopeInfo.Target
|
||||
$overviewSummaryText.Text = "This will replace the current Start Menu pinned apps layout for $($scopeInfo.SummaryText) with the selected backup."
|
||||
$backupFileText.Text = Split-Path -Path $BackupFilePath -Leaf
|
||||
|
||||
@@ -255,7 +276,7 @@ function Show-RestoreBackupDialog {
|
||||
|
||||
$backupFileText.Text = Split-Path $SelectedBackupFilePath -Leaf
|
||||
$backupCreatedText.Text = $createdText
|
||||
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
|
||||
$backupTargetText.Text = Get-FriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
|
||||
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
|
||||
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
|
||||
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
|
||||
@@ -295,11 +316,18 @@ function Show-RestoreBackupDialog {
|
||||
}
|
||||
|
||||
Write-Host "Backup file selected: $($openDialog.FileName)"
|
||||
$selectedBackup = Load-RegistryBackupFromFile -FilePath $openDialog.FileName
|
||||
|
||||
try {
|
||||
$selectedBackup = Import-RegistryBackup -FilePath $openDialog.FileName
|
||||
|
||||
if (-not (& $showRegistryOverview -SelectedBackup $selectedBackup -SelectedBackupFilePath $openDialog.FileName)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Show-MessageBox -Owner $window -Title 'Invalid Backup File' -Message "The selected file could not be loaded:`n$($_.Exception.Message)" -Button 'OK' -Icon 'Error' | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
$state.SelectedRegistryBackup = $selectedBackup
|
||||
$primaryActionBtn.Content = 'Restore from backup'
|
||||
@@ -338,7 +366,7 @@ function Show-RestoreBackupDialog {
|
||||
}
|
||||
|
||||
$window.Tag = @{
|
||||
Result = 'RestoreStartMenu'
|
||||
Result = 'Restore-StartMenu'
|
||||
StartMenuScope = $scope
|
||||
UseManualBackupFile = $useManualBackupFile
|
||||
BackupFilePath = $state.SelectedStartMenuBackupFilePath
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Shows the backup-restore dialog and performs the selected restore.
|
||||
#>
|
||||
function Show-RestoreBackupWindow {
|
||||
param(
|
||||
[System.Windows.Window]$Owner = $null
|
||||
@@ -38,7 +42,7 @@ function Show-RestoreBackupWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($dialogResult.Result -eq 'RestoreStartMenu') {
|
||||
elseif ($dialogResult.Result -eq 'Restore-StartMenu') {
|
||||
$scope = $dialogResult.StartMenuScope
|
||||
$useManualBackupFile = ($dialogResult.UseManualBackupFile -eq $true)
|
||||
$backupFilePath = $null
|
||||
@@ -54,10 +58,10 @@ function Show-RestoreBackupWindow {
|
||||
}
|
||||
|
||||
$result = if ($scope -eq 'AllUsers') {
|
||||
RestoreStartMenuForAllUsers -BackupFilePath $backupFilePath
|
||||
Restore-StartMenuForAllUsers -BackupFilePath $backupFilePath
|
||||
}
|
||||
else {
|
||||
RestoreStartMenu -BackupFilePath $backupFilePath
|
||||
Restore-StartMenu -BackupFilePath $backupFilePath
|
||||
}
|
||||
|
||||
$resultEntries = @($result)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
param (
|
||||
[switch]$CLI,
|
||||
[switch]$Silent,
|
||||
[switch]$Verbose,
|
||||
[switch]$Sysprep,
|
||||
[string]$LogPath,
|
||||
[string]$User,
|
||||
[switch]$NoRestartExplorer,
|
||||
[switch]$CreateRestorePoint,
|
||||
[switch]$RunDefaults,
|
||||
[switch]$RunDefaultsLite,
|
||||
[switch]$RunSavedSettings,
|
||||
[string]$Config,
|
||||
[string]$Apps,
|
||||
[string]$AppRemovalTarget,
|
||||
[switch]$RemoveApps,
|
||||
[switch]$RemoveGamingApps,
|
||||
[switch]$RemoveHPApps,
|
||||
[switch]$ForceRemoveEdge,
|
||||
[switch]$DisableDVR,
|
||||
[switch]$DisableGameBarIntegration,
|
||||
[switch]$EnableWindowsSandbox,
|
||||
[switch]$EnableWindowsSubsystemForLinux,
|
||||
[switch]$DisableTelemetry,
|
||||
[switch]$DisableSearchHistory,
|
||||
[switch]$DisableFastStartup,
|
||||
[switch]$DisableBitlockerAutoEncryption,
|
||||
[switch]$DisableModernStandbyNetworking,
|
||||
[switch]$DisableStorageSense,
|
||||
[switch]$DisableUpdateASAP,
|
||||
[switch]$PreventUpdateAutoReboot,
|
||||
[switch]$DisableDeliveryOptimization,
|
||||
[switch]$DisableBing,
|
||||
[switch]$DisableStoreSearchSuggestions,
|
||||
[switch]$DisableDesktopSpotlight,
|
||||
[switch]$DisableLockscreenTips,
|
||||
[switch]$DisableSuggestions,
|
||||
[switch]$DisableLocationServices,
|
||||
[switch]$DisableFindMyDevice,
|
||||
[switch]$DisableEdgeAds,
|
||||
[switch]$DisableBraveBloat,
|
||||
[switch]$DisableSettings365Ads,
|
||||
[switch]$DisableSettingsHome,
|
||||
[switch]$ShowHiddenFolders,
|
||||
[switch]$ShowKnownFileExt,
|
||||
[switch]$HideDupliDrive,
|
||||
[switch]$EnableDarkMode,
|
||||
[switch]$DisableTransparency,
|
||||
[switch]$DisableAnimations,
|
||||
[switch]$TaskbarAlignLeft,
|
||||
[switch]$CombineTaskbarAlways, [switch]$CombineTaskbarWhenFull, [switch]$CombineTaskbarNever,
|
||||
[switch]$CombineMMTaskbarAlways, [switch]$CombineMMTaskbarWhenFull, [switch]$CombineMMTaskbarNever,
|
||||
[switch]$MMTaskbarModeAll, [switch]$MMTaskbarModeMainActive, [switch]$MMTaskbarModeActive,
|
||||
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
|
||||
[switch]$HideTaskview,
|
||||
[switch]$DisableStartRecommended,
|
||||
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
|
||||
[switch]$DisableStartPhoneLink,
|
||||
[switch]$DisableCopilot,
|
||||
[switch]$DisableRecall,
|
||||
[switch]$DisableClickToDo,
|
||||
[switch]$DisableAISvcAutoStart,
|
||||
[switch]$DisablePaintAI,
|
||||
[switch]$DisableNotepadAI,
|
||||
[switch]$DisableEdgeAI,
|
||||
[switch]$DisableSearchHighlights,
|
||||
[switch]$DisableWidgets,
|
||||
[switch]$HideChat,
|
||||
[switch]$EnableEndTask,
|
||||
[switch]$EnableLastActiveClick,
|
||||
[switch]$ClearStart,
|
||||
[string]$ReplaceStart,
|
||||
[switch]$ClearStartAllUsers,
|
||||
[string]$ReplaceStartAllUsers,
|
||||
[switch]$RevertContextMenu,
|
||||
[switch]$DisableDragTray,
|
||||
[switch]$DisableMouseAcceleration,
|
||||
[switch]$DisableStickyKeys,
|
||||
[switch]$DisableWindowSnapping,
|
||||
[switch]$DisableSnapAssist,
|
||||
[switch]$DisableSnapLayouts,
|
||||
[switch]$HideTabsInAltTab, [switch]$Show3TabsInAltTab, [switch]$Show5TabsInAltTab, [switch]$Show20TabsInAltTab,
|
||||
[switch]$HideHome,
|
||||
[switch]$HideGallery,
|
||||
[switch]$ExplorerToHome,
|
||||
[switch]$ExplorerToThisPC,
|
||||
[switch]$ExplorerToDownloads,
|
||||
[switch]$ExplorerToOneDrive,
|
||||
[switch]$AddFoldersToThisPC,
|
||||
[switch]$HideOnedrive,
|
||||
[switch]$Hide3dObjects,
|
||||
[switch]$HideMusic,
|
||||
[switch]$HideIncludeInLibrary,
|
||||
[switch]$HideGiveAccessTo,
|
||||
[switch]$HideShare,
|
||||
[switch]$ShowDriveLettersFirst,
|
||||
[switch]$ShowDriveLettersLast,
|
||||
[switch]$ShowNetworkDriveLettersFirst,
|
||||
[switch]$HideDriveLetters
|
||||
)
|
||||
|
||||
# Show error if current powershell environment does not have LanguageMode set to FullLanguage
|
||||
if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") {
|
||||
Write-Host "Error: Win11Debloat is unable to run on your system. PowerShell execution is restricted by security policies" -ForegroundColor Red
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to exit..."
|
||||
Read-Host | Out-Null
|
||||
Exit
|
||||
}
|
||||
|
||||
Clear-Host
|
||||
Write-Output "-------------------------------------------------------------------------------------------"
|
||||
Write-Output " Win11Debloat Script - Get Dev"
|
||||
Write-Output "-------------------------------------------------------------------------------------------"
|
||||
|
||||
$tempRootPath = $env:TEMP
|
||||
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
|
||||
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
|
||||
|
||||
Write-Output "> Downloading Win11Debloat for development..."
|
||||
|
||||
# Download latest version of Win11Debloat from GitHub master branch as zip archive
|
||||
try {
|
||||
Invoke-RestMethod "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip" -OutFile $tempArchivePath
|
||||
}
|
||||
catch {
|
||||
Write-Host "Error: Unable to fetch master branch from GitHub. Please check your internet connection and try again." -ForegroundColor Red
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to exit..."
|
||||
Read-Host | Out-Null
|
||||
Exit
|
||||
}
|
||||
|
||||
# Remove old script folder if it exists, but keep configs, logs and backups
|
||||
if (Test-Path $tempWorkPath) {
|
||||
Write-Output ""
|
||||
Write-Output "> Cleaning up old Win11Debloat folder..."
|
||||
|
||||
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
|
||||
}
|
||||
|
||||
$configDir = Join-Path $tempWorkPath 'Config'
|
||||
$backupDir = Join-Path $tempWorkPath 'ConfigOld'
|
||||
|
||||
# Temporarily move existing config files if they exist to prevent them from being overwritten by the new script files, will be moved back after the new script is unpacked
|
||||
if (Test-Path "$configDir") {
|
||||
Write-Output ""
|
||||
Write-Output "> Backing up existing config files..."
|
||||
|
||||
New-Item -ItemType Directory -Path "$backupDir" -Force | Out-Null
|
||||
|
||||
$filesToKeep = @(
|
||||
'LastUsedSettings.json'
|
||||
)
|
||||
|
||||
Get-ChildItem -Path "$configDir" -Recurse | Where-Object { $_.Name -in $filesToKeep } | Move-Item -Destination "$backupDir"
|
||||
|
||||
Remove-Item "$configDir" -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Output ""
|
||||
Write-Output "> Unpacking..."
|
||||
|
||||
# Unzip archive to Win11Debloat folder
|
||||
Expand-Archive $tempArchivePath $tempWorkPath
|
||||
|
||||
# Remove archive
|
||||
Remove-Item $tempArchivePath
|
||||
|
||||
# Move files
|
||||
Get-ChildItem -Path (Join-Path $tempWorkPath '*Win11Debloat-*') -Recurse | Move-Item -Destination $tempWorkPath
|
||||
|
||||
# Add existing config files back to Config folder
|
||||
if (Test-Path "$backupDir") {
|
||||
if (-not (Test-Path "$configDir")) {
|
||||
New-Item -ItemType Directory -Path "$configDir" -Force | Out-Null
|
||||
}
|
||||
|
||||
Write-Output ""
|
||||
Write-Output "> Restoring existing config files..."
|
||||
|
||||
Get-ChildItem -Path "$backupDir" -Recurse | Move-Item -Destination "$configDir"
|
||||
Remove-Item "$backupDir" -Recurse -Force
|
||||
}
|
||||
|
||||
# Make list of arguments to pass on to the script
|
||||
$arguments = $($PSBoundParameters.GetEnumerator() | ForEach-Object {
|
||||
if ($_.Value -eq $true) {
|
||||
"-$($_.Key)"
|
||||
}
|
||||
else {
|
||||
"-$($_.Key) ""$($_.Value)"""
|
||||
}
|
||||
})
|
||||
|
||||
Write-Output ""
|
||||
Write-Output "> Launching Win11Debloat..."
|
||||
|
||||
# Minimize the powershell window when no parameters are provided
|
||||
if ($arguments.Count -eq 0) {
|
||||
$windowStyle = "Minimized"
|
||||
}
|
||||
else {
|
||||
$windowStyle = "Normal"
|
||||
}
|
||||
|
||||
# Remove Powershell 7 modules from path to prevent module loading issues in the script
|
||||
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
||||
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
|
||||
$env:PSModulePath = $NewPSModulePath -join ';'
|
||||
}
|
||||
|
||||
# Run Win11Debloat script with the provided arguments
|
||||
$debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1'
|
||||
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs
|
||||
|
||||
# Wait for the process to finish before continuing
|
||||
if ($null -ne $debloatProcess) {
|
||||
$debloatProcess.WaitForExit()
|
||||
}
|
||||
|
||||
# Remove all remaining script files, except for configs, logs and backups
|
||||
if (Test-Path $tempWorkPath) {
|
||||
Write-Output ""
|
||||
Write-Output "> Cleaning up..."
|
||||
|
||||
# Cleanup, remove Win11Debloat directory
|
||||
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Output ""
|
||||
+41
-22
@@ -1,12 +1,16 @@
|
||||
param (
|
||||
[switch]$Verbose,
|
||||
[switch]$WhatIf,
|
||||
[switch]$Dev,
|
||||
[switch]$CLI,
|
||||
[switch]$Silent,
|
||||
[switch]$Verbose,
|
||||
[switch]$Sysprep,
|
||||
[string]$LogPath,
|
||||
[string]$User,
|
||||
[switch]$NoRestartExplorer,
|
||||
[Alias('NoRestartExplorer')]
|
||||
[switch]$SkipExplorerRestart,
|
||||
[switch]$CreateRestorePoint,
|
||||
[switch]$SkipRegistryBackup,
|
||||
[switch]$RunDefaults,
|
||||
[switch]$RunDefaultsLite,
|
||||
[switch]$RunSavedSettings,
|
||||
@@ -26,10 +30,12 @@ param (
|
||||
[switch]$DisableFastStartup,
|
||||
[switch]$DisableBitlockerAutoEncryption,
|
||||
[switch]$DisableModernStandbyNetworking,
|
||||
[switch]$DisableNotifications,
|
||||
[switch]$DisableStorageSense,
|
||||
[switch]$DisableUpdateASAP,
|
||||
[switch]$PreventUpdateAutoReboot,
|
||||
[switch]$DisableDeliveryOptimization,
|
||||
[switch]$DisableDeviceAutoAppDownload,
|
||||
[switch]$DisableBing,
|
||||
[switch]$DisableStoreSearchSuggestions,
|
||||
[switch]$DisableDesktopSpotlight,
|
||||
@@ -99,43 +105,46 @@ param (
|
||||
[switch]$HideDriveLetters
|
||||
)
|
||||
|
||||
# Show error if current powershell environment does not have LanguageMode set to FullLanguage
|
||||
# Check if current PowerShell environment is limited by security policies
|
||||
if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") {
|
||||
Write-Host "Error: Win11Debloat is unable to run on your system. PowerShell execution is restricted by security policies" -ForegroundColor Red
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to exit..."
|
||||
Read-Host | Out-Null
|
||||
Exit
|
||||
Write-Error "Win11Debloat is unable to run on your system, PowerShell execution is restricted by security policies"
|
||||
Write-Output "Press any key to exit..."
|
||||
$null = [System.Console]::ReadKey()
|
||||
Exit 1
|
||||
}
|
||||
|
||||
Clear-Host
|
||||
Write-Output "-------------------------------------------------------------------------------------------"
|
||||
Write-Output " Win11Debloat Script - Get"
|
||||
Write-Output " Win11Debloat Script"
|
||||
Write-Output "-------------------------------------------------------------------------------------------"
|
||||
|
||||
$tempRootPath = $env:TEMP
|
||||
$tempWorkPath = Join-Path $tempRootPath 'Win11Debloat'
|
||||
$tempArchivePath = Join-Path $tempRootPath 'win11debloat.zip'
|
||||
|
||||
Write-Output "> Downloading Win11Debloat..."
|
||||
|
||||
# Download latest version of Win11Debloat from GitHub as zip archive
|
||||
# Download Win11Debloat from GitHub as a zip archive.
|
||||
try {
|
||||
$LatestReleaseUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
|
||||
Invoke-RestMethod $LatestReleaseUri -OutFile $tempArchivePath
|
||||
if ($Dev) {
|
||||
Write-Output "> Downloading development version of Win11Debloat..."
|
||||
$sourceUri = "https://github.com/Raphire/Win11Debloat/archive/refs/heads/master.zip"
|
||||
} else {
|
||||
Write-Output "> Downloading Win11Debloat..."
|
||||
$sourceUri = (Invoke-RestMethod https://api.github.com/repos/Raphire/Win11Debloat/releases/latest).zipball_url
|
||||
}
|
||||
Invoke-RestMethod $sourceUri -OutFile $tempArchivePath
|
||||
}
|
||||
catch {
|
||||
Write-Host "Error: Unable to fetch latest release from GitHub. Please check your internet connection and try again." -ForegroundColor Red
|
||||
Write-Host "Error: Unable to fetch required files from GitHub. Please check your internet connection and try again." -ForegroundColor Red
|
||||
Write-Output ""
|
||||
Write-Output "Press enter to exit..."
|
||||
Read-Host | Out-Null
|
||||
Exit
|
||||
Exit 1
|
||||
}
|
||||
|
||||
# Remove old script folder if it exists, but keep configs, logs and backups
|
||||
if (Test-Path $tempWorkPath) {
|
||||
Write-Output ""
|
||||
Write-Output "> Cleaning up old Win11Debloat folder..."
|
||||
Write-Output "> Cleaning up old script files..."
|
||||
|
||||
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
|
||||
}
|
||||
@@ -184,8 +193,8 @@ if (Test-Path "$backupDir") {
|
||||
Remove-Item "$backupDir" -Recurse -Force
|
||||
}
|
||||
|
||||
# Make list of arguments to pass on to the script
|
||||
$arguments = $($PSBoundParameters.GetEnumerator() | ForEach-Object {
|
||||
# Make list of arguments to pass on to the script (exclude the -Dev switch, which only affects this launcher)
|
||||
$arguments = $($PSBoundParameters.GetEnumerator() | Where-Object { $_.Key -ne 'Dev' } | ForEach-Object {
|
||||
if ($_.Value -eq $true) {
|
||||
"-$($_.Key)"
|
||||
}
|
||||
@@ -197,7 +206,7 @@ $arguments = $($PSBoundParameters.GetEnumerator() | ForEach-Object {
|
||||
Write-Output ""
|
||||
Write-Output "> Launching Win11Debloat..."
|
||||
|
||||
# Minimize the powershell window when no parameters are provided
|
||||
# Minimize the PowerShell window when no parameters are provided
|
||||
if ($arguments.Count -eq 0) {
|
||||
$windowStyle = "Minimized"
|
||||
}
|
||||
@@ -205,7 +214,7 @@ else {
|
||||
$windowStyle = "Normal"
|
||||
}
|
||||
|
||||
# Remove Powershell 7 modules from path to prevent module loading issues in the script
|
||||
# Remove PowerShell 7 modules from path to prevent module loading issues in the script
|
||||
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
||||
$NewPSModulePath = $env:PSModulePath -split ';' | Where-Object -FilterScript { $_ -like '*WindowsPowerShell*' }
|
||||
$env:PSModulePath = $NewPSModulePath -join ';'
|
||||
@@ -213,11 +222,20 @@ if ($PSVersionTable.PSVersion.Major -ge 7) {
|
||||
|
||||
# Run Win11Debloat script with the provided arguments
|
||||
$debloatScriptPath = Join-Path $tempWorkPath 'Win11Debloat.ps1'
|
||||
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs
|
||||
$exitCode = 0
|
||||
$debloatProcess = $null
|
||||
try {
|
||||
$debloatProcess = Start-Process powershell.exe -WindowStyle $windowStyle -PassThru -ArgumentList "-executionpolicy bypass -File `"$debloatScriptPath`" $arguments" -Verb RunAs -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$exitCode = 1
|
||||
Write-Error "Failed to start Win11Debloat: $_"
|
||||
}
|
||||
|
||||
# Wait for the process to finish before continuing
|
||||
if ($null -ne $debloatProcess) {
|
||||
$debloatProcess.WaitForExit()
|
||||
$exitCode = $debloatProcess.ExitCode
|
||||
}
|
||||
|
||||
# Remove all remaining script files, except for configs, logs and backups
|
||||
@@ -230,3 +248,4 @@ if (Test-Path $tempWorkPath) {
|
||||
}
|
||||
|
||||
Write-Output ""
|
||||
Exit $exitCode
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Add parameter to script and write to file
|
||||
function AddParameter {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Adds or updates a value in the active parameter collection.
|
||||
#>
|
||||
function Add-Parameter {
|
||||
param (
|
||||
$parameterName,
|
||||
$value = $true
|
||||
+29
-21
@@ -11,53 +11,51 @@ function Get-NormalizedRegistryValueName {
|
||||
return [string]$ValueName
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a parsed .reg operation into a Name/Kind/Value set for RegistryKey.SetValue.
|
||||
#>
|
||||
function Convert-RegOperationToValueKind {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Operation
|
||||
)
|
||||
|
||||
$valueName = if ([string]::IsNullOrEmpty([string]$Operation.ValueName)) { '' } else { [string]$Operation.ValueName }
|
||||
$valueName = Get-NormalizedRegistryValueName -ValueName $Operation.ValueName
|
||||
$valueType = [string]$Operation.ValueType
|
||||
$operationKeyPath = [string]$Operation.KeyPath
|
||||
|
||||
# ValueType here is whatever Get-RegFileOperations parsed it as.
|
||||
# Hex2/Hex7 are its names for REG_EXPAND_SZ/REG_MULTI_SZ, already decoded to string/string[].
|
||||
switch ($valueType) {
|
||||
'DWord' {
|
||||
$unsigned = [uint32]$Operation.ValueData
|
||||
$value = [BitConverter]::ToInt32([BitConverter]::GetBytes($unsigned), 0)
|
||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Value = $value }
|
||||
}
|
||||
'QWord' {
|
||||
$unsigned = [uint64]$Operation.ValueData
|
||||
$value = [BitConverter]::ToInt64([BitConverter]::GetBytes($unsigned), 0)
|
||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Value = $value }
|
||||
}
|
||||
'String' {
|
||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::String; Value = [string]$Operation.ValueData }
|
||||
}
|
||||
'Hex2' {
|
||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Value = [string]$Operation.ValueData }
|
||||
}
|
||||
'Binary' {
|
||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Value = [byte[]]$Operation.ValueData }
|
||||
}
|
||||
'Hex7' {
|
||||
return @{ Name = $valueName; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Value = [string[]]@($Operation.ValueData) }
|
||||
}
|
||||
default {
|
||||
throw "Unsupported value type '$valueType' while applying reg operation for '$operationKeyPath'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-RegistrySubKeyTreeIfExists {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RootKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SubKeyPath
|
||||
)
|
||||
|
||||
try {
|
||||
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
|
||||
}
|
||||
catch [System.UnauthorizedAccessException], [System.Security.SecurityException] {
|
||||
throw
|
||||
}
|
||||
catch {
|
||||
# Best-effort cleanup only; missing keys are fine.
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegistryKeyForOperation {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -193,6 +191,13 @@ function Invoke-RegistryOperation {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies all parsed operations from a registry file.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean. $true when all operations complete, including WhatIf; otherwise $false.
|
||||
#>
|
||||
function Invoke-RegistryOperationsFromRegFile {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -205,7 +210,7 @@ function Invoke-RegistryOperationsFromRegFile {
|
||||
|
||||
if ($script:Params.ContainsKey("WhatIf")) {
|
||||
Write-Host "[WhatIf] Apply $totalOperations registry changes from '$RegFilePath'" -ForegroundColor Cyan
|
||||
return
|
||||
return $true
|
||||
}
|
||||
|
||||
foreach ($operation in $operations) {
|
||||
@@ -224,5 +229,8 @@ function Invoke-RegistryOperationsFromRegFile {
|
||||
|
||||
if ($accessDeniedCount -gt 0) {
|
||||
Write-Warning "Registry fallback import completed with $accessDeniedCount access-restricted operation(s) skipped in '$RegFilePath'."
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
+5
-4
@@ -1,7 +1,8 @@
|
||||
# Shows confirmation dialogs for apps that require extra caution before removal.
|
||||
# Returns $true if the user confirmed all warnings (or if no warnings were triggered),
|
||||
# $false if the user declined any warning.
|
||||
function ConfirmUnsafeAppRemoval {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Confirms removal of applications that require an extra safety warning.
|
||||
#>
|
||||
function Confirm-UnsafeAppRemoval {
|
||||
param (
|
||||
[string[]]$SelectedApps,
|
||||
$Owner = $null
|
||||
@@ -1,5 +1,8 @@
|
||||
# Generates a list of apps to remove based on the Apps parameter
|
||||
function GenerateAppsList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds the validated application-removal list from the Apps parameter.
|
||||
#>
|
||||
function Generate-AppsList {
|
||||
if (-not ($script:Params["Apps"] -and $script:Params["Apps"] -is [string])) {
|
||||
return @()
|
||||
}
|
||||
@@ -8,12 +11,12 @@ function GenerateAppsList {
|
||||
|
||||
switch ($appMode) {
|
||||
'default' {
|
||||
$appsList = LoadAppsFromFile $script:AppsListFilePath
|
||||
$appsList = Import-AppsFromFile $script:AppsListFilePath
|
||||
return $appsList
|
||||
}
|
||||
default {
|
||||
$appsList = $script:Params["Apps"].Split(',') | ForEach-Object { $_.Trim() }
|
||||
$validatedAppsList = ValidateAppslist $appsList
|
||||
$validatedAppsList = Get-ValidatedAppList $appsList
|
||||
return $validatedAppsList
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
function GetFriendlyRegistryBackupTarget {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a registry-backup target identifier into a user-friendly label.
|
||||
#>
|
||||
function Get-FriendlyRegistryBackupTarget {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
@@ -0,0 +1,13 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a readable description of the current app-removal target.
|
||||
#>
|
||||
function Get-FriendlyTargetUserName {
|
||||
$target = Get-TargetUserForAppRemoval
|
||||
|
||||
switch ($target) {
|
||||
"AllUsers" { return "all users" }
|
||||
"CurrentUser" { return "the current user" }
|
||||
default { return "user $target" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolves display labels for selected features that require reboot.
|
||||
|
||||
.DESCRIPTION
|
||||
Combines parameter keys from both forward and undo selections, removes duplicates,
|
||||
and returns the feature label that should be shown to users. Undo selections use
|
||||
UndoLabel when available.
|
||||
#>
|
||||
function Get-RebootFeatureLabels {
|
||||
$rebootFeatureLabels = [System.Collections.Generic.List[string]]::new()
|
||||
$candidateParamKeys = (@($script:Params.Keys) + @($script:UndoParams.Keys)) | Select-Object -Unique
|
||||
|
||||
foreach ($paramKey in $candidateParamKeys) {
|
||||
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
|
||||
$feature = $script:Features[$paramKey]
|
||||
$isUndo = $script:UndoParams.ContainsKey($paramKey)
|
||||
$displayLabel = if ($isUndo -and $feature.UndoLabel) { $feature.UndoLabel } else { $feature.Label }
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$displayLabel)) {
|
||||
[void]$rebootFeatureLabels.Add([string]$displayLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rebootFeatureLabels
|
||||
}
|
||||
@@ -74,7 +74,10 @@ function Get-RegFileOperations {
|
||||
}
|
||||
|
||||
$parsedValue = Convert-RegValueData -valueData $matches.valueData.Trim()
|
||||
if (-not $parsedValue) { continue }
|
||||
if (-not $parsedValue) {
|
||||
Write-Warning "Skipping unsupported or malformed registry value '$valueName' in '$currentKeyPath'."
|
||||
continue
|
||||
}
|
||||
|
||||
$operations += [PSCustomObject]@{
|
||||
OperationType = $parsedValue.OperationType
|
||||
@@ -88,6 +91,10 @@ function Get-RegFileOperations {
|
||||
return $operations
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a .reg value literal into an operation type, registry value type, and data.
|
||||
#>
|
||||
function Convert-RegValueData {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -121,8 +128,13 @@ function Convert-RegValueData {
|
||||
}
|
||||
|
||||
if ($valueData -match '^hex(?:\((?<kind>[0-9a-fA-F]+)\))?:(?<bytes>[0-9a-fA-F,\s]+)$') {
|
||||
$bytes = Convert-HexStringToByteArray -hexValue $matches.bytes
|
||||
$parsedBytes = Convert-HexStringToByteArray -hexValue $matches.bytes
|
||||
if ($null -eq $parsedBytes) {
|
||||
return $null
|
||||
}
|
||||
$bytes = [byte[]]@($parsedBytes)
|
||||
$valueType = if ($matches.kind) { "Hex$($matches.kind)" } else { 'Binary' }
|
||||
|
||||
$value = switch ($matches.kind) {
|
||||
'2' { Convert-RegistryByteArrayToString -byteData $bytes }
|
||||
'7' { Convert-RegistryByteArrayToMultiString -byteData $bytes }
|
||||
@@ -150,16 +162,29 @@ function Convert-RegValueData {
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a comma-separated hexadecimal byte string into a byte array.
|
||||
#>
|
||||
function Convert-HexStringToByteArray {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$hexValue
|
||||
)
|
||||
|
||||
$parts = $hexValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
return [System.Linq.Enumerable]::Select($parts, [Func[object, byte]] {
|
||||
param($h) [System.Convert]::ToByte($h, 16)
|
||||
}) -as [byte[]]
|
||||
$parts = @($hexValue.Split(',') | ForEach-Object { $_.Trim() })
|
||||
if ($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$bytes = New-Object byte[] $parts.Count
|
||||
for ($i = 0; $i -lt $parts.Count; $i++) {
|
||||
if ($parts[$i] -notmatch '^[0-9a-fA-F]{1,2}$') {
|
||||
return $null
|
||||
}
|
||||
$bytes[$i] = [System.Convert]::ToByte($parts[$i], 16)
|
||||
}
|
||||
return ,$bytes
|
||||
}
|
||||
|
||||
function Convert-RegistryByteArrayToString {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Target is determined from $script:Params["AppRemovalTarget"] or defaults to "AllUsers"
|
||||
# Target values: "AllUsers" (removes for all users + from image), "CurrentUser", or a specific username
|
||||
function GetTargetUserForAppRemoval {
|
||||
function Get-TargetUserForAppRemoval {
|
||||
if ($script:Params.ContainsKey("AppRemovalTarget")) {
|
||||
return $script:Params["AppRemovalTarget"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Returns the directory path of the specified user, exits script if user path can't be found
|
||||
function GetUserDirectory {
|
||||
function Get-UserDirectory {
|
||||
param (
|
||||
$userName,
|
||||
$fileName = "",
|
||||
@@ -29,7 +29,7 @@ function GetUserDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
$userContext = ResolveUserProfileContext -UserName $userName
|
||||
$userContext = Resolve-UserProfileContext -UserName $userName
|
||||
$resolvedUserDirectory = if ($userContext) { $userContext.ProfilePath } else { $null }
|
||||
if ($resolvedUserDirectory) {
|
||||
$userPath = if ([string]::IsNullOrWhiteSpace($fileName)) {
|
||||
@@ -46,9 +46,9 @@ function GetUserDirectory {
|
||||
}
|
||||
catch {
|
||||
Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
|
||||
Write-Error "Unable to find user directory path for user $userName"
|
||||
AwaitKeyToExit
|
||||
Wait-ForKeyPress -ExitCode 1
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the explicitly targeted user name or the current process user name.
|
||||
#>
|
||||
function Get-UserName {
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
return $script:Params.Item("User")
|
||||
}
|
||||
|
||||
return $env:USERNAME
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
function GetFriendlyTargetUserName {
|
||||
$target = GetTargetUserForAppRemoval
|
||||
|
||||
switch ($target) {
|
||||
"AllUsers" { return "all users" }
|
||||
"CurrentUser" { return "the current user" }
|
||||
default { return "user $target" }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
function GetUserName {
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
return $script:Params.Item("User")
|
||||
}
|
||||
|
||||
return $env:USERNAME
|
||||
}
|
||||
+26
-12
@@ -1,4 +1,8 @@
|
||||
function ImportConfigToParams {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Imports valid application, tweak, and deployment selections from a configuration JSON file into active parameters.
|
||||
#>
|
||||
function Import-ConfigToParams {
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
@@ -22,11 +26,16 @@ function ImportConfigToParams {
|
||||
throw "Provided config file must be a .json file: $resolvedConfigPath"
|
||||
}
|
||||
|
||||
$configJson = LoadJsonFile -filePath $resolvedConfigPath -expectedVersion $ExpectedVersion
|
||||
$configJson = Import-JsonFile -filePath $resolvedConfigPath -expectedVersion $ExpectedVersion
|
||||
if ($null -eq $configJson) {
|
||||
throw "Failed to read config file: $resolvedConfigPath"
|
||||
}
|
||||
|
||||
$consistencyError = Test-ConfigConsistency -Config $configJson
|
||||
if ($consistencyError) {
|
||||
throw "Invalid config file '$resolvedConfigPath': $consistencyError"
|
||||
}
|
||||
|
||||
$importedItems = 0
|
||||
|
||||
if ($configJson.Apps) {
|
||||
@@ -38,8 +47,8 @@ function ImportConfigToParams {
|
||||
)
|
||||
|
||||
if ($appIds.Count -gt 0) {
|
||||
AddParameter 'RemoveApps'
|
||||
AddParameter 'Apps' ($appIds -join ',')
|
||||
Add-Parameter 'RemoveApps'
|
||||
Add-Parameter 'Apps' ($appIds -join ',')
|
||||
$importedItems++
|
||||
}
|
||||
}
|
||||
@@ -59,7 +68,7 @@ function ImportConfigToParams {
|
||||
continue
|
||||
}
|
||||
|
||||
AddParameter $setting.Name $true
|
||||
Add-Parameter $setting.Name $true
|
||||
$importedItems++
|
||||
}
|
||||
}
|
||||
@@ -73,12 +82,17 @@ function ImportConfigToParams {
|
||||
}
|
||||
|
||||
if ($deploymentLookup.ContainsKey('CreateRestorePoint') -and [bool]$deploymentLookup['CreateRestorePoint']) {
|
||||
AddParameter 'CreateRestorePoint'
|
||||
Add-Parameter 'CreateRestorePoint'
|
||||
$importedItems++
|
||||
}
|
||||
|
||||
if ($deploymentLookup.ContainsKey('SkipRegistryBackup') -and [bool]$deploymentLookup['SkipRegistryBackup']) {
|
||||
Add-Parameter 'SkipRegistryBackup'
|
||||
$importedItems++
|
||||
}
|
||||
|
||||
if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) {
|
||||
AddParameter 'NoRestartExplorer'
|
||||
Add-Parameter 'SkipExplorerRestart'
|
||||
$importedItems++
|
||||
}
|
||||
|
||||
@@ -87,12 +101,12 @@ function ImportConfigToParams {
|
||||
1 {
|
||||
$otherUserName = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
|
||||
if (-not [string]::IsNullOrWhiteSpace($otherUserName)) {
|
||||
AddParameter 'User' $otherUserName
|
||||
Add-Parameter 'User' $otherUserName
|
||||
$importedItems++
|
||||
}
|
||||
}
|
||||
2 {
|
||||
AddParameter 'Sysprep'
|
||||
Add-Parameter 'Sysprep'
|
||||
$importedItems++
|
||||
}
|
||||
}
|
||||
@@ -101,17 +115,17 @@ function ImportConfigToParams {
|
||||
if ($deploymentLookup.ContainsKey('AppRemovalScopeIndex') -and $script:Params.ContainsKey('RemoveApps')) {
|
||||
switch ([int]$deploymentLookup['AppRemovalScopeIndex']) {
|
||||
0 {
|
||||
AddParameter 'AppRemovalTarget' 'AllUsers'
|
||||
Add-Parameter 'AppRemovalTarget' 'AllUsers'
|
||||
$importedItems++
|
||||
}
|
||||
1 {
|
||||
AddParameter 'AppRemovalTarget' 'CurrentUser'
|
||||
Add-Parameter 'AppRemovalTarget' 'CurrentUser'
|
||||
$importedItems++
|
||||
}
|
||||
2 {
|
||||
$targetUser = if ($deploymentLookup.ContainsKey('OtherUsername')) { "$($deploymentLookup['OtherUsername'])".Trim() } else { '' }
|
||||
if (-not [string]::IsNullOrWhiteSpace($targetUser)) {
|
||||
AddParameter 'AppRemovalTarget' $targetUser
|
||||
Add-Parameter 'AppRemovalTarget' $targetUser
|
||||
$importedItems++
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Normalizes a rooted registry path and returns its hive and subkey components.
|
||||
#>
|
||||
function Split-RegistryPath {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -51,6 +55,10 @@ function Split-RegistryPath {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the .NET registry root key for a supported registry hive name.
|
||||
#>
|
||||
function Get-RegistryRootKey {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -67,6 +75,39 @@ function Get-RegistryRootKey {
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deletes a registry subkey tree and ignores a key that has already disappeared.
|
||||
#>
|
||||
function Remove-RegistrySubKeyTreeIfExists {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$RootKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SubKeyPath
|
||||
)
|
||||
|
||||
try {
|
||||
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
|
||||
}
|
||||
catch {
|
||||
$failure = $_.Exception
|
||||
while ($failure.InnerException) {
|
||||
$failure = $failure.InnerException
|
||||
}
|
||||
if ($failure -is [System.ArgumentException]) {
|
||||
# The key can disappear between snapshot inspection and deletion.
|
||||
return
|
||||
}
|
||||
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a feature's registry-file path, using the Sysprep layout when targeting another profile.
|
||||
#>
|
||||
function Get-RegistryFilePathForFeature {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -0,0 +1,971 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Normalize a user-name string for lookup and comparison.
|
||||
|
||||
.DESCRIPTION
|
||||
Strips zero-width chars and collapses whitespace so cosmetic input
|
||||
differences don't break downstream lookups. Returns '' for blank input.
|
||||
|
||||
.PARAMETER Value
|
||||
Raw user-supplied name to normalize.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Normalize-UserLookupValue {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
$normalized = $Value -replace '[\u200B-\u200D\uFEFF]', ''
|
||||
$normalized = $normalized.Trim() -replace '\s+', ' '
|
||||
return $normalized
|
||||
}
|
||||
|
||||
if (-not $script:ResolvedUserSidCache) {
|
||||
$script:ResolvedUserSidCache = @{}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build a form-agnostic cache key for a user name.
|
||||
|
||||
.DESCRIPTION
|
||||
Normalized + lower-cased so the same identity hits regardless of
|
||||
case, whitespace, or qualifier form. Returns '' for blank input.
|
||||
|
||||
.PARAMETER Value
|
||||
User name to derive a key from.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Get-UserLookupCacheKey {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$normalizedValue = Normalize-UserLookupValue -Value $Value
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return $normalizedValue.ToLowerInvariant()
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Normalize and de-duplicate a set of user-name candidates.
|
||||
|
||||
.DESCRIPTION
|
||||
Centralizes the normalize/filter/dedupe step shared by the SID
|
||||
resolution fallbacks so the dedupe semantic lives in one place.
|
||||
Returns @() for empty or all-blank input.
|
||||
|
||||
.PARAMETER Candidates
|
||||
Equivalent name forms to normalize.
|
||||
|
||||
.OUTPUTS
|
||||
System.String[]
|
||||
#>
|
||||
function Get-NormalizedLookupCandidates {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
$normalized = @($Candidates) |
|
||||
ForEach-Object { Normalize-UserLookupValue -Value $_ } |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
Select-Object -Unique
|
||||
|
||||
# The unary comma prevents PowerShell from unwrapping a single-element array.
|
||||
return ,@($normalized)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Escape a string for safe embedding in a WQL single-quoted literal.
|
||||
|
||||
.DESCRIPTION
|
||||
Doubles embedded single quotes; without this a user name containing
|
||||
an apostrophe could break the WQL filter.
|
||||
|
||||
.PARAMETER Value
|
||||
String to escape.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Escape-WqlString {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return $Value -replace "'", "''"
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extract the local name segment from a possibly domain-qualified identity.
|
||||
|
||||
.DESCRIPTION
|
||||
Reduces DOMAIN\user or user@domain to the bare leaf, since profile
|
||||
folder leafs never carry the domain prefix. Returns '' for blank input.
|
||||
|
||||
.PARAMETER UserName
|
||||
User name that may be domain-qualified.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Get-LocalUserNameSegment {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
$normalizedName = Normalize-UserLookupValue -Value $UserName
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if ($normalizedName.Contains('\')) {
|
||||
return Normalize-UserLookupValue -Value (($normalizedName -split '\\')[-1])
|
||||
}
|
||||
|
||||
if ($normalizedName.Contains('@')) {
|
||||
return Normalize-UserLookupValue -Value (($normalizedName -split '@')[0])
|
||||
}
|
||||
|
||||
return $normalizedName
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reduce a Win32_ComputerSystem.Domain value to a NetBIOS label.
|
||||
|
||||
.DESCRIPTION
|
||||
Win32_ComputerSystem.Domain may be DNS-style (e.g. contoso.com);
|
||||
prefer Win32_NTDomain.DomainName and fall back to the first DNS label
|
||||
so cached suffixes stay single-label (user.CONTOSO, not
|
||||
user.contoso.com). Returns the trimmed value as-is when already a
|
||||
single label. Falls back safely (no match) for FQDNs like
|
||||
corp.contoso.com whose NetBIOS label is CONTOSO.
|
||||
|
||||
.PARAMETER RawDomain
|
||||
Value reported by Win32_ComputerSystem.Domain.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Resolve-NetBiosDomainName {
|
||||
param(
|
||||
[string]$RawDomain
|
||||
)
|
||||
|
||||
$trimmed = Normalize-UserLookupValue -Value $RawDomain
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
# Prefer the joined-domain instance over the local SAM shadow
|
||||
# (DomainName == COMPUTERNAME); DomainControllerName may be $null.
|
||||
$computerName = $env:COMPUTERNAME
|
||||
$ntDomainInstances = @(Get-CimInstance -ClassName Win32_NTDomain -OperationTimeoutSec 5 -ErrorAction Stop |
|
||||
Where-Object {
|
||||
-not [string]::IsNullOrWhiteSpace($_.DomainName) -and
|
||||
$_.DomainName -ine 'WORKGROUP' -and
|
||||
$_.DomainName -ine $computerName
|
||||
})
|
||||
|
||||
$ntDomainInstance = $ntDomainInstances |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_.DomainControllerName) } |
|
||||
Select-Object -First 1
|
||||
if (-not $ntDomainInstance -and $ntDomainInstances.Count -gt 0) {
|
||||
$ntDomainInstance = $ntDomainInstances | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($ntDomainInstance -and -not [string]::IsNullOrWhiteSpace($ntDomainInstance.DomainName)) {
|
||||
$fromNtDomain = Normalize-UserLookupValue -Value $ntDomainInstance.DomainName
|
||||
if (-not [string]::IsNullOrWhiteSpace($fromNtDomain)) {
|
||||
return $fromNtDomain
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fall through to DNS-label derivation.
|
||||
}
|
||||
|
||||
if ($trimmed.Contains('.')) {
|
||||
$leaf = Normalize-UserLookupValue -Value (($trimmed -split '\.')[0])
|
||||
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
|
||||
return $leaf
|
||||
}
|
||||
}
|
||||
|
||||
return $trimmed
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Determine whether the local machine is joined to a domain.
|
||||
|
||||
.DESCRIPTION
|
||||
Cached in script scope for the process lifetime. Returns $false on
|
||||
error or workgroup. When joined, also caches the NetBIOS domain label
|
||||
(Resolve-NetBiosDomainName) for use as a profile-folder suffix.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
function Test-MachineIsDomainJoined {
|
||||
if ($null -ne $script:MachineDomainJoinStateKnown) {
|
||||
return [bool]$script:MachineIsDomainJoined
|
||||
}
|
||||
|
||||
$script:MachineDomainJoinStateKnown = $true
|
||||
$script:MachineIsDomainJoined = $false
|
||||
$script:MachineNetBiosDomain = ''
|
||||
|
||||
try {
|
||||
$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||
if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) {
|
||||
$script:MachineIsDomainJoined = $true
|
||||
$script:MachineNetBiosDomain = Resolve-NetBiosDomainName -RawDomain ([string]$computerSystem.Domain)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Leave as $false; callers fall back to legacy matching.
|
||||
}
|
||||
|
||||
return [bool]$script:MachineIsDomainJoined
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return the NetBIOS domain suffix Windows appends to profile folders.
|
||||
|
||||
.DESCRIPTION
|
||||
On domain-joined machines Windows writes profile folders as
|
||||
user.CONTOSO; knowing the suffix lets a bare name match that folder.
|
||||
Excluded on workgroup (USERDOMAIN == COMPUTERNAME) to avoid false
|
||||
matches. Returns '' when not applicable.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Get-ProfileFolderDomainSuffix {
|
||||
if (-not (Test-MachineIsDomainJoined)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
$domain = $env:USERDOMAIN
|
||||
if ([string]::IsNullOrWhiteSpace($domain)) {
|
||||
# USERDOMAIN can be empty in restricted contexts; fall back to the
|
||||
# cached NetBIOS label (single-label, safe as a folder suffix).
|
||||
$domain = $script:MachineNetBiosDomain
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($domain)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
# USERDOMAIN == COMPUTERNAME means effectively standalone; a suffix here
|
||||
# would produce false matches instead of disambiguation.
|
||||
if ($domain -ieq $env:COMPUTERNAME) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return $domain.Trim()
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enumerate the name forms equivalent to a given identity.
|
||||
|
||||
.DESCRIPTION
|
||||
One identity surfaces in different forms (bare, qualified,
|
||||
domain-suffixed); enumerating equivalents lets cross-source matching
|
||||
succeed without broadening workgroup validation. Returns @() for blank.
|
||||
|
||||
.PARAMETER Value
|
||||
User name to expand into equivalent forms.
|
||||
|
||||
.OUTPUTS
|
||||
System.String[]
|
||||
#>
|
||||
function Get-UserNameMatchCandidates {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$normalized = Normalize-UserLookupValue -Value $Value
|
||||
if ([string]::IsNullOrWhiteSpace($normalized)) {
|
||||
return @()
|
||||
}
|
||||
|
||||
$candidates = New-Object 'System.Collections.Generic.List[string]'
|
||||
[void]$candidates.Add($normalized)
|
||||
|
||||
$localSegment = Get-LocalUserNameSegment -UserName $normalized
|
||||
if (-not [string]::IsNullOrWhiteSpace($localSegment) -and ($localSegment -ine $normalized)) {
|
||||
[void]$candidates.Add($localSegment)
|
||||
}
|
||||
|
||||
# Domain-suffixed forms only apply where Windows writes them.
|
||||
$domainSuffix = Get-ProfileFolderDomainSuffix
|
||||
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
||||
# Prefer the local segment as the stem so DOMAIN\user still yields
|
||||
# user.CONTOSO rather than the fully qualified string.
|
||||
$stem = if (-not [string]::IsNullOrWhiteSpace($localSegment)) { $localSegment } else { $normalized }
|
||||
if (-not [string]::IsNullOrWhiteSpace($stem)) {
|
||||
$suffixedForm = "$stem.$domainSuffix"
|
||||
$alreadyPresent = $false
|
||||
foreach ($existing in $candidates) {
|
||||
if ($existing -ieq $suffixedForm) { $alreadyPresent = $true; break }
|
||||
}
|
||||
if (-not $alreadyPresent) {
|
||||
[void]$candidates.Add($suffixedForm)
|
||||
}
|
||||
}
|
||||
|
||||
# A suffixed input can also be referenced by its bare stem elsewhere
|
||||
# (registry, backup metadata), so add that form too.
|
||||
$suffixWithDot = ".$domainSuffix"
|
||||
if ($normalized.Length -gt $suffixWithDot.Length -and $normalized.EndsWith($suffixWithDot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$bareStem = Normalize-UserLookupValue -Value ($normalized.Substring(0, $normalized.Length - $suffixWithDot.Length))
|
||||
if (-not [string]::IsNullOrWhiteSpace($bareStem)) {
|
||||
$alreadyPresent = $false
|
||||
foreach ($existing in $candidates) {
|
||||
if ($existing -ieq $bareStem) { $alreadyPresent = $true; break }
|
||||
}
|
||||
if (-not $alreadyPresent) {
|
||||
[void]$candidates.Add($bareStem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates.ToArray() | Select-Object -Unique
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Test whether a user name and a profile folder leaf share an account.
|
||||
|
||||
.DESCRIPTION
|
||||
Compares candidate sets (via Get-UserNameMatchCandidates) instead of
|
||||
raw strings, so different forms of the same account still match.
|
||||
|
||||
.PARAMETER UserName
|
||||
User-supplied name to compare.
|
||||
|
||||
.PARAMETER ProfileLeaf
|
||||
On-disk profile folder leaf name to compare.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
function Test-UserNameMatchesProfileLeaf {
|
||||
param(
|
||||
[string]$UserName,
|
||||
[string]$ProfileLeaf
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName) -or [string]::IsNullOrWhiteSpace($ProfileLeaf)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$leafCandidates = @(Get-UserNameMatchCandidates -Value $ProfileLeaf)
|
||||
$userCandidates = @(Get-UserNameMatchCandidates -Value $UserName)
|
||||
|
||||
foreach ($leaf in $leafCandidates) {
|
||||
foreach ($user in $userCandidates) {
|
||||
if ($leaf -ieq $user) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Test whether two user-name strings refer to the same account.
|
||||
|
||||
.DESCRIPTION
|
||||
Accepts any equivalent candidate form so restore works across
|
||||
sessions and join states. Workgroup stays a strict normalized equality
|
||||
check to avoid broadening validation where suffixes aren't meaningful.
|
||||
|
||||
.PARAMETER UserNameA
|
||||
First user name to compare.
|
||||
|
||||
.PARAMETER UserNameB
|
||||
Second user name to compare.
|
||||
|
||||
.OUTPUTS
|
||||
System.Boolean
|
||||
#>
|
||||
function Test-UserNameMatch {
|
||||
param(
|
||||
[string]$UserNameA,
|
||||
[string]$UserNameB
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserNameA) -and [string]::IsNullOrWhiteSpace($UserNameB)) {
|
||||
return $true
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($UserNameA) -or [string]::IsNullOrWhiteSpace($UserNameB)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Workgroup: strict equality (no suffix disambiguation available).
|
||||
if (-not (Test-MachineIsDomainJoined)) {
|
||||
$normalizedA = Normalize-UserLookupValue -Value $UserNameA
|
||||
$normalizedB = Normalize-UserLookupValue -Value $UserNameB
|
||||
return ($normalizedA -ieq $normalizedB)
|
||||
}
|
||||
|
||||
$candidatesA = @(Get-UserNameMatchCandidates -Value $UserNameA)
|
||||
$candidatesB = @(Get-UserNameMatchCandidates -Value $UserNameB)
|
||||
|
||||
foreach ($a in $candidatesA) {
|
||||
foreach ($b in $candidatesB) {
|
||||
if ($a -ieq $b) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Memoize a resolved SID under every equivalent name form.
|
||||
|
||||
.DESCRIPTION
|
||||
Keyed under all equivalent forms so later lookups short-circuit
|
||||
regardless of which variant the caller holds. No-op on blank SID.
|
||||
|
||||
.PARAMETER Candidates
|
||||
Equivalent name forms to key the cache entry under.
|
||||
|
||||
.PARAMETER Sid
|
||||
Resolved SID to cache.
|
||||
#>
|
||||
function Set-ResolvedUserSidCache {
|
||||
param(
|
||||
[string[]]$Candidates,
|
||||
[string]$Sid
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Sid)) {
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($candidate in @($Candidates)) {
|
||||
$cacheKey = Get-UserLookupCacheKey -Value $candidate
|
||||
if ($cacheKey) {
|
||||
$script:ResolvedUserSidCache[$cacheKey] = $Sid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Retrieve a previously cached resolved SID.
|
||||
|
||||
.DESCRIPTION
|
||||
Probes every equivalent form; returns $null on a miss.
|
||||
|
||||
.PARAMETER Candidates
|
||||
Equivalent name forms to probe the cache with.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Get-CachedResolvedUserSid {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
foreach ($candidate in @($Candidates)) {
|
||||
$cacheKey = Get-UserLookupCacheKey -Value $candidate
|
||||
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
|
||||
return $script:ResolvedUserSidCache[$cacheKey]
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Attempt SID resolution via NTAccount translation.
|
||||
|
||||
.DESCRIPTION
|
||||
Most authoritative name->SID source; only resolves names reachable
|
||||
through standard security APIs. Returns $null on failure.
|
||||
|
||||
.PARAMETER UserName
|
||||
Name to translate.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Try-ResolveSidByNtAccount {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$ntAccount = [System.Security.Principal.NTAccount]::new($UserName)
|
||||
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
|
||||
if ($sid) {
|
||||
return $sid.Value
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fallback handled by caller.
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Attempt SID resolution against the local account database.
|
||||
|
||||
.DESCRIPTION
|
||||
Prefers Get-LocalUser (typed, fast), falls back to Win32_UserAccount
|
||||
CIM for older hosts or unavailable cmdlet. Returns $null if no match.
|
||||
|
||||
.PARAMETER Candidates
|
||||
Equivalent name forms to try.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Try-ResolveSidByLocalLookup {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
|
||||
if ($lookupCandidates.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if (Get-Command -Name Get-LocalUser -ErrorAction SilentlyContinue) {
|
||||
foreach ($candidate in $lookupCandidates) {
|
||||
try {
|
||||
$matchingLocalUser = Get-LocalUser -Name $candidate -ErrorAction Stop | Select-Object -First 1
|
||||
if ($matchingLocalUser -and $matchingLocalUser.SID) {
|
||||
return $matchingLocalUser.SID.Value
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Continue to next lookup strategy.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in $lookupCandidates) {
|
||||
try {
|
||||
$escapedCandidate = Escape-WqlString -Value $candidate
|
||||
$escapedComputerName = Escape-WqlString -Value $env:COMPUTERNAME
|
||||
$filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')"
|
||||
$matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1
|
||||
|
||||
if ($matchingAccount -and $matchingAccount.SID) {
|
||||
return $matchingAccount.SID
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Continue to next lookup strategy.
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recover a SID from the ProfileList registry hive.
|
||||
|
||||
.DESCRIPTION
|
||||
Last-resort heuristic: matches the profile folder leaf to recover a
|
||||
SID when name-resolution APIs fail. Returns $null on failure.
|
||||
|
||||
.PARAMETER Candidates
|
||||
Equivalent name forms to match against profile folder leafs.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Try-ResolveSidFromProfileList {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
|
||||
if ($lookupCandidates.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
|
||||
foreach ($sidKey in @(Get-ChildItem -LiteralPath $profileListPath -ErrorAction Stop)) {
|
||||
try {
|
||||
$imagePath = Get-ItemPropertyValue -LiteralPath $sidKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
|
||||
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
|
||||
|
||||
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
|
||||
$leafName = Normalize-UserLookupValue -Value (Split-Path -Leaf $expandedPath)
|
||||
|
||||
foreach ($candidate in $lookupCandidates) {
|
||||
if (Test-MachineIsDomainJoined) {
|
||||
if (Test-UserNameMatchesProfileLeaf -UserName $candidate -ProfileLeaf $leafName) {
|
||||
return $sidKey.PSChildName
|
||||
}
|
||||
}
|
||||
elseif ($leafName -ieq $candidate) {
|
||||
return $sidKey.PSChildName
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fallback handled by caller.
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Construct a resolved user context object.
|
||||
|
||||
.DESCRIPTION
|
||||
Bundles UserName/UserSid/ProfilePath so callers don't re-derive or
|
||||
thread three loose values.
|
||||
|
||||
.PARAMETER UserName
|
||||
Normalized user name.
|
||||
|
||||
.PARAMETER UserSid
|
||||
Resolved SID, if available.
|
||||
|
||||
.PARAMETER ProfilePath
|
||||
Resolved profile folder path.
|
||||
|
||||
.OUTPUTS
|
||||
System.Management.Automation.PSCustomObject
|
||||
#>
|
||||
function New-ResolvedUserContext {
|
||||
param(
|
||||
[string]$UserName,
|
||||
[string]$UserSid,
|
||||
[string]$ProfilePath
|
||||
)
|
||||
|
||||
return [PSCustomObject]@{
|
||||
UserName = $UserName
|
||||
UserSid = $UserSid
|
||||
ProfilePath = $ProfilePath
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Return the qualified (DOMAIN\user) name of the current process when it matches the input.
|
||||
|
||||
.DESCRIPTION
|
||||
Used to qualify a bare name on domain-joined boxes; returns $null when
|
||||
the input doesn't match the current process identity.
|
||||
|
||||
.PARAMETER Candidate
|
||||
Bare user name to compare against the current process identity.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Get-QualifiedProcessIdentityName {
|
||||
param(
|
||||
[string]$Candidate
|
||||
)
|
||||
|
||||
$normalizedCandidate = Normalize-UserLookupValue -Value $Candidate
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedCandidate)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($null -eq $currentIdentity) {
|
||||
return $null
|
||||
}
|
||||
|
||||
# Skip service/SYSTEM identities (no user profile).
|
||||
$currentSidString = [string]$currentIdentity.User.Value
|
||||
if ($currentSidString -in @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$currentName = [string]$currentIdentity.Name
|
||||
if ([string]::IsNullOrWhiteSpace($currentName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$currentLocalSegment = Get-LocalUserNameSegment -UserName $currentName
|
||||
if (-not [string]::IsNullOrWhiteSpace($currentLocalSegment) -and $currentLocalSegment -ieq $normalizedCandidate) {
|
||||
return $currentName
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fall through to name-based resolution.
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolve a user name to its SID.
|
||||
|
||||
.DESCRIPTION
|
||||
Always qualifies the input first (DOMAIN\user) and resolves that form;
|
||||
never guesses from a bare name on domain-joined boxes to avoid
|
||||
same-named local SAM shadowing.
|
||||
|
||||
.PARAMETER UserName
|
||||
User name to resolve. May be bare, DOMAIN\user, or user@domain.
|
||||
|
||||
.OUTPUTS
|
||||
System.String
|
||||
#>
|
||||
function Resolve-UserSid {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
$candidateUserName = Normalize-UserLookupValue -Value $UserName
|
||||
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
|
||||
$localNameSegment = Get-LocalUserNameSegment -UserName $candidateUserName
|
||||
$leafNameCandidates = @()
|
||||
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
|
||||
$leafNameCandidates = @($localNameSegment)
|
||||
}
|
||||
|
||||
# Unqualified inputs probe both the bare name and (for qualified inputs) the
|
||||
# local leaf segment; qualified inputs pin to the caller's full form only.
|
||||
$lookupCandidates = if ($hasQualifiedIdentity) {
|
||||
@($candidateUserName)
|
||||
}
|
||||
else {
|
||||
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
|
||||
}
|
||||
|
||||
$profileHeuristicCandidates = if ($leafNameCandidates.Count -gt 0) {
|
||||
$leafNameCandidates
|
||||
}
|
||||
else {
|
||||
@($candidateUserName)
|
||||
}
|
||||
|
||||
$cachedSid = Get-CachedResolvedUserSid -Candidates $lookupCandidates
|
||||
if ($cachedSid) {
|
||||
return $cachedSid
|
||||
}
|
||||
|
||||
# Step 1: derive the qualified form(s) to resolve; never guess from a bare
|
||||
# name on domain-joined boxes (local SAM nameshare risk).
|
||||
$qualifiedNamesToTry = New-Object 'System.Collections.Generic.List[string]'
|
||||
|
||||
if ($hasQualifiedIdentity) {
|
||||
# Caller already qualified; honor verbatim.
|
||||
[void]$qualifiedNamesToTry.Add($candidateUserName)
|
||||
}
|
||||
elseif (Test-MachineIsDomainJoined) {
|
||||
# Prefer process identity (authoritative), then USERDOMAIN\input.
|
||||
$processQualifiedName = Get-QualifiedProcessIdentityName -Candidate $candidateUserName
|
||||
if (-not [string]::IsNullOrWhiteSpace($processQualifiedName)) {
|
||||
[void]$qualifiedNamesToTry.Add($processQualifiedName)
|
||||
}
|
||||
|
||||
$domainSuffix = Get-ProfileFolderDomainSuffix
|
||||
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
||||
$domainQualifiedName = "$domainSuffix\$candidateUserName"
|
||||
if (-not ($qualifiedNamesToTry -contains $domainQualifiedName)) {
|
||||
[void]$qualifiedNamesToTry.Add($domainQualifiedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Workgroup: bare name is unambiguous.
|
||||
[void]$qualifiedNamesToTry.Add($candidateUserName)
|
||||
}
|
||||
|
||||
# Step 2: resolve qualified form(s) via NTAccount.Translate.
|
||||
foreach ($qualifiedName in $qualifiedNamesToTry) {
|
||||
$resolvedSid = Try-ResolveSidByNtAccount -UserName $qualifiedName
|
||||
if ($resolvedSid) {
|
||||
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
||||
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
}
|
||||
|
||||
# Step 3: local SAM fallback (workgroup only; skipped on domain to avoid
|
||||
# nameshare shadowing).
|
||||
if (-not (Test-MachineIsDomainJoined)) {
|
||||
$resolvedSid = Try-ResolveSidByLocalLookup -Candidates $lookupCandidates
|
||||
if ($resolvedSid) {
|
||||
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
||||
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
}
|
||||
|
||||
# Step 4: ProfileList leaf heuristic (last resort; disambiguates by
|
||||
# on-disk folder name, suffix-aware on domain boxes).
|
||||
$resolvedSid = Try-ResolveSidFromProfileList -Candidates $profileHeuristicCandidates
|
||||
if ($resolvedSid) {
|
||||
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
||||
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolve a user name to a full profile context (name, SID, path).
|
||||
|
||||
.DESCRIPTION
|
||||
SID-keyed registry data first (authoritative), then on-disk path
|
||||
probing so resolution still succeeds when the account or SID can't be
|
||||
looked up (deleted account, restricted context). Returns $null if not
|
||||
found.
|
||||
|
||||
.PARAMETER UserName
|
||||
User name whose profile context is required.
|
||||
|
||||
.OUTPUTS
|
||||
System.Management.Automation.PSCustomObject
|
||||
#>
|
||||
function Resolve-UserProfileContext {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
$candidateUserName = Normalize-UserLookupValue -Value $UserName
|
||||
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$rootPaths = @(
|
||||
(Join-Path $env:SystemDrive 'Users')
|
||||
(Split-Path -Path $env:USERPROFILE -Parent)
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
|
||||
if ($candidateUserName -ieq 'Default') {
|
||||
foreach ($rootPath in $rootPaths) {
|
||||
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$defaultProfilePath = Join-Path $rootPath 'Default'
|
||||
if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) {
|
||||
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath)
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$userSid = Resolve-UserSid -UserName $candidateUserName
|
||||
|
||||
if ($userSid) {
|
||||
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
|
||||
try {
|
||||
if (Test-Path -LiteralPath $sidRegistryPath) {
|
||||
$registryImagePath = Get-ItemPropertyValue -LiteralPath $sidRegistryPath -Name 'ProfileImagePath' -ErrorAction Stop
|
||||
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
|
||||
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
|
||||
if (Test-Path -LiteralPath $expandedPath -PathType Container) {
|
||||
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Try Win32_UserProfile fallback.
|
||||
}
|
||||
|
||||
try {
|
||||
$matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop)
|
||||
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
|
||||
if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) {
|
||||
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fall through to legacy path probing.
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rootPath in $rootPaths) {
|
||||
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
|
||||
continue
|
||||
}
|
||||
|
||||
# Exact leaf match first (common case; avoids an unnecessary scan).
|
||||
$candidateUserPath = Join-Path $rootPath $candidateUserName
|
||||
if (Test-Path -LiteralPath $candidateUserPath -PathType Container) {
|
||||
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath)
|
||||
}
|
||||
|
||||
# Only domain-joined boxes write suffixed folders; scanning workgroup
|
||||
# roots would risk matching the wrong account.
|
||||
if (Test-MachineIsDomainJoined) {
|
||||
try {
|
||||
foreach ($child in @(Get-ChildItem -LiteralPath $rootPath -Directory -ErrorAction SilentlyContinue)) {
|
||||
if (Test-UserNameMatchesProfileLeaf -UserName $candidateUserName -ProfileLeaf $child.Name) {
|
||||
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $child.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fall through to the next root path.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
function NormalizeUserLookupValue {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
# Remove zero-width characters and normalize whitespace for robust comparisons.
|
||||
$normalized = $Value -replace '[\u200B-\u200D\uFEFF]', ''
|
||||
$normalized = $normalized.Trim() -replace '\s+', ' '
|
||||
return $normalized
|
||||
}
|
||||
|
||||
if (-not $script:ResolvedUserSidCache) {
|
||||
$script:ResolvedUserSidCache = @{}
|
||||
}
|
||||
|
||||
function GetUserLookupCacheKey {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$normalizedValue = NormalizeUserLookupValue -Value $Value
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return $normalizedValue.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function EscapeWqlString {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return $Value -replace "'", "''"
|
||||
}
|
||||
|
||||
function GetLocalUserNameSegment {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
$normalizedName = NormalizeUserLookupValue -Value $UserName
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if ($normalizedName.Contains('\')) {
|
||||
return NormalizeUserLookupValue -Value (($normalizedName -split '\\')[-1])
|
||||
}
|
||||
|
||||
if ($normalizedName.Contains('@')) {
|
||||
return NormalizeUserLookupValue -Value (($normalizedName -split '@')[0])
|
||||
}
|
||||
|
||||
return $normalizedName
|
||||
}
|
||||
|
||||
function SetResolvedUserSidCache {
|
||||
param(
|
||||
[string[]]$Candidates,
|
||||
[string]$Sid
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Sid)) {
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($candidate in @($Candidates)) {
|
||||
$cacheKey = GetUserLookupCacheKey -Value $candidate
|
||||
if ($cacheKey) {
|
||||
$script:ResolvedUserSidCache[$cacheKey] = $Sid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function GetCachedResolvedUserSid {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
foreach ($candidate in @($Candidates)) {
|
||||
$cacheKey = GetUserLookupCacheKey -Value $candidate
|
||||
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
|
||||
return $script:ResolvedUserSidCache[$cacheKey]
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function TryResolveSidByNtAccount {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$ntAccount = [System.Security.Principal.NTAccount]::new($UserName)
|
||||
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
|
||||
if ($sid) {
|
||||
return $sid.Value
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fallback handled by caller.
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function TryResolveSidByLocalLookup {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
$lookupCandidates = @($Candidates) | ForEach-Object { NormalizeUserLookupValue -Value $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
if ($lookupCandidates.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if (Get-Command -Name Get-LocalUser -ErrorAction SilentlyContinue) {
|
||||
foreach ($candidate in $lookupCandidates) {
|
||||
try {
|
||||
$matchingLocalUser = Get-LocalUser -Name $candidate -ErrorAction Stop | Select-Object -First 1
|
||||
if ($matchingLocalUser -and $matchingLocalUser.SID) {
|
||||
return $matchingLocalUser.SID.Value
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Continue to next lookup strategy.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in $lookupCandidates) {
|
||||
try {
|
||||
$escapedCandidate = EscapeWqlString -Value $candidate
|
||||
$escapedComputerName = EscapeWqlString -Value $env:COMPUTERNAME
|
||||
$filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')"
|
||||
$matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1
|
||||
|
||||
if ($matchingAccount -and $matchingAccount.SID) {
|
||||
return $matchingAccount.SID
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Continue to next lookup strategy.
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function TryResolveSidFromProfileList {
|
||||
param(
|
||||
[string[]]$Candidates
|
||||
)
|
||||
|
||||
$lookupCandidates = @($Candidates) | ForEach-Object { NormalizeUserLookupValue -Value $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
if ($lookupCandidates.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
|
||||
foreach ($sidKey in @(Get-ChildItem -LiteralPath $profileListPath -ErrorAction Stop)) {
|
||||
try {
|
||||
$imagePath = Get-ItemPropertyValue -LiteralPath $sidKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
|
||||
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
|
||||
|
||||
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
|
||||
$leafName = NormalizeUserLookupValue -Value (Split-Path -Leaf $expandedPath)
|
||||
|
||||
foreach ($candidate in $lookupCandidates) {
|
||||
if ($leafName -ieq $candidate) {
|
||||
return $sidKey.PSChildName
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fallback handled by caller.
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function NewResolvedUserContext {
|
||||
param(
|
||||
[string]$UserName,
|
||||
[string]$UserSid,
|
||||
[string]$ProfilePath
|
||||
)
|
||||
|
||||
return [PSCustomObject]@{
|
||||
UserName = $UserName
|
||||
UserSid = $UserSid
|
||||
ProfilePath = $ProfilePath
|
||||
}
|
||||
}
|
||||
|
||||
function ResolveUserSid {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
$candidateUserName = NormalizeUserLookupValue -Value $UserName
|
||||
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
|
||||
$localNameSegment = GetLocalUserNameSegment -UserName $candidateUserName
|
||||
$leafNameCandidates = @()
|
||||
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
|
||||
$leafNameCandidates = @($localNameSegment)
|
||||
}
|
||||
|
||||
$cacheCandidates = if ($hasQualifiedIdentity) {
|
||||
@($candidateUserName)
|
||||
}
|
||||
else {
|
||||
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
|
||||
}
|
||||
|
||||
$localLookupCandidates = if ($hasQualifiedIdentity) {
|
||||
@()
|
||||
}
|
||||
else {
|
||||
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
|
||||
}
|
||||
|
||||
$profileHeuristicCandidates = if ($leafNameCandidates.Count -gt 0) {
|
||||
$leafNameCandidates
|
||||
}
|
||||
else {
|
||||
@($candidateUserName)
|
||||
}
|
||||
|
||||
$cachedSid = GetCachedResolvedUserSid -Candidates $cacheCandidates
|
||||
if ($cachedSid) {
|
||||
return $cachedSid
|
||||
}
|
||||
|
||||
# Resolve fully-qualified identities first to avoid accidentally matching a local leaf account.
|
||||
if ($hasQualifiedIdentity) {
|
||||
$resolvedSid = TryResolveSidByNtAccount -UserName $candidateUserName
|
||||
if ($resolvedSid) {
|
||||
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedSid = TryResolveSidByLocalLookup -Candidates $localLookupCandidates
|
||||
if ($resolvedSid) {
|
||||
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
|
||||
# Last-ditch NTAccount translation for non-qualified names.
|
||||
if (-not $hasQualifiedIdentity) {
|
||||
$resolvedSid = TryResolveSidByNtAccount -UserName $candidateUserName
|
||||
if ($resolvedSid) {
|
||||
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedSid = TryResolveSidFromProfileList -Candidates $profileHeuristicCandidates
|
||||
if ($resolvedSid) {
|
||||
SetResolvedUserSidCache -Candidates $cacheCandidates -Sid $resolvedSid
|
||||
return $resolvedSid
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function ResolveUserProfilePath {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
$userContext = ResolveUserProfileContext -UserName $UserName
|
||||
if ($userContext) {
|
||||
return $userContext.ProfilePath
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function ResolveUserProfileContext {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$candidateUserName = NormalizeUserLookupValue -Value $UserName
|
||||
$rootPaths = @(
|
||||
(Join-Path $env:SystemDrive 'Users')
|
||||
(Split-Path -Path $env:USERPROFILE -Parent)
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
|
||||
if ($candidateUserName -ieq 'Default') {
|
||||
foreach ($rootPath in $rootPaths) {
|
||||
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$defaultProfilePath = Join-Path $rootPath 'Default'
|
||||
if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) {
|
||||
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath)
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$userSid = ResolveUserSid -UserName $candidateUserName
|
||||
|
||||
if ($userSid) {
|
||||
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
|
||||
try {
|
||||
if (Test-Path -LiteralPath $sidRegistryPath) {
|
||||
$registryImagePath = Get-ItemPropertyValue -LiteralPath $sidRegistryPath -Name 'ProfileImagePath' -ErrorAction Stop
|
||||
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
|
||||
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
|
||||
if (Test-Path -LiteralPath $expandedPath -PathType Container) {
|
||||
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Try Win32_UserProfile fallback.
|
||||
}
|
||||
|
||||
try {
|
||||
$matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop)
|
||||
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
|
||||
if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) {
|
||||
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Fall through to legacy path probing.
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rootPath in $rootPaths) {
|
||||
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$candidateUserPath = Join-Path $rootPath $candidateUserName
|
||||
if (Test-Path -LiteralPath $candidateUserPath -PathType Container) {
|
||||
return (NewResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath)
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates that a configuration file is structurally consistent before it is applied.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns $null when the configuration is valid, otherwise a string describing the
|
||||
first problem found. Used by both the CLI and GUI import paths to reject invalid
|
||||
configs before any settings are applied.
|
||||
|
||||
.OUTPUTS
|
||||
System.String. $null when valid, otherwise an error message.
|
||||
#>
|
||||
function Test-ConfigConsistency {
|
||||
param($Config)
|
||||
|
||||
if (-not $Config) {
|
||||
return 'Configuration is empty or could not be read.'
|
||||
}
|
||||
|
||||
if (-not $Config.Version) {
|
||||
return 'Configuration is missing a Version field.'
|
||||
}
|
||||
|
||||
if (-not $Config.Apps -and -not $Config.Tweaks -and -not $Config.Deployment) {
|
||||
return 'The configuration file contains no importable data.'
|
||||
}
|
||||
|
||||
if ($null -ne $Config.Apps) {
|
||||
if ($Config.Apps -isnot [string] -and $Config.Apps -isnot [System.Collections.IEnumerable]) {
|
||||
return 'Configuration Apps entries must be strings.'
|
||||
}
|
||||
foreach ($app in @($Config.Apps)) {
|
||||
if ($app -isnot [string]) {
|
||||
return 'Configuration Apps entries must be strings.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($categoryName in @('Tweaks', 'Deployment')) {
|
||||
$category = $Config.$categoryName
|
||||
if ($null -eq $category) { continue }
|
||||
|
||||
if ($category -is [string] -or $category -isnot [System.Collections.IEnumerable]) {
|
||||
return "Configuration $categoryName entries must contain Name and Value properties."
|
||||
}
|
||||
foreach ($setting in @($category)) {
|
||||
$hasName = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Name') } else { $null -ne $setting.PSObject.Properties['Name'] }
|
||||
$hasValue = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Value') } else { $null -ne $setting.PSObject.Properties['Value'] }
|
||||
if (-not $setting -or -not $hasName -or -not $hasValue -or $setting.Name -isnot [string] -or [string]::IsNullOrWhiteSpace($setting.Name)) {
|
||||
return "Configuration $categoryName entries must contain Name and Value properties."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$lookup = @{}
|
||||
foreach ($setting in @($Config.Deployment)) {
|
||||
if ($setting -and $setting.Name) {
|
||||
$lookup[$setting.Name] = $setting.Value
|
||||
}
|
||||
}
|
||||
|
||||
$hasScope = $lookup.ContainsKey('AppRemovalScopeIndex')
|
||||
$hasUser = $lookup.ContainsKey('UserSelectionIndex')
|
||||
|
||||
$scopeIndex = $null
|
||||
if ($hasScope) {
|
||||
if (-not [int]::TryParse("$($lookup['AppRemovalScopeIndex'])", [ref]$scopeIndex) -or $scopeIndex -notin @(0, 1, 2)) {
|
||||
return 'AppRemovalScopeIndex must be a supported numeric value (0, 1, or 2).'
|
||||
}
|
||||
}
|
||||
|
||||
$userIndex = $null
|
||||
if ($hasUser) {
|
||||
if (-not [int]::TryParse("$($lookup['UserSelectionIndex'])", [ref]$userIndex) -or $userIndex -notin @(0, 1, 2)) {
|
||||
return 'UserSelectionIndex must be a supported numeric value (0, 1, or 2).'
|
||||
}
|
||||
}
|
||||
|
||||
# "Current user only" (index 1) is only valid together with "Current User" (index 0)
|
||||
if ($hasScope -and $scopeIndex -eq 1) {
|
||||
if (-not $hasUser -or $userIndex -ne 0) {
|
||||
return "App removal scope 'Current user only' (AppRemovalScopeIndex 1) requires the deployment target 'Current User' (UserSelectionIndex 0)."
|
||||
}
|
||||
}
|
||||
|
||||
# "Target user only" (index 2) is only valid together with "Other User" (index 1)
|
||||
if ($hasScope -and $scopeIndex -eq 2) {
|
||||
if (-not $hasUser -or $userIndex -ne 1) {
|
||||
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires the deployment target 'Other User' (UserSelectionIndex 1)."
|
||||
}
|
||||
if (-not $lookup.ContainsKey('OtherUsername') -or [string]::IsNullOrWhiteSpace("$($lookup['OtherUsername'])")) {
|
||||
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires an 'OtherUsername' value."
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Check if this machine supports S0 Modern Standby power state. Returns true if S0 Modern Standby is supported, false otherwise.
|
||||
function CheckModernStandbySupport {
|
||||
function Test-ModernStandbySupport {
|
||||
$count = 0
|
||||
|
||||
try {
|
||||
@@ -15,7 +15,7 @@ function Test-TargetUserName {
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalizedUserName -eq $env:USERNAME) {
|
||||
if (Test-UserNameMatch -UserNameA $normalizedUserName -UserNameB $env:USERNAME) {
|
||||
return [PSCustomObject]@{
|
||||
IsValid = $false
|
||||
UserName = $normalizedUserName
|
||||
@@ -23,7 +23,7 @@ function Test-TargetUserName {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (CheckIfUserExists -userName $normalizedUserName)) {
|
||||
if (-not (Test-UserProfileExists -userName $normalizedUserName)) {
|
||||
return [PSCustomObject]@{
|
||||
IsValid = $false
|
||||
UserName = $normalizedUserName
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function CheckIfUserExists {
|
||||
function Test-UserProfileExists {
|
||||
param (
|
||||
[string]$userName
|
||||
)
|
||||
@@ -10,7 +10,7 @@ function CheckIfUserExists {
|
||||
$lookupName = $userName.Trim()
|
||||
|
||||
# Validate special characters against the local username segment (user in DOMAIN\user or user@domain).
|
||||
$localUserName = GetLocalUserNameSegment -UserName $lookupName
|
||||
$localUserName = Get-LocalUserNameSegment -UserName $lookupName
|
||||
|
||||
if ($localUserName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
|
||||
return $false
|
||||
@@ -22,7 +22,7 @@ function CheckIfUserExists {
|
||||
}
|
||||
|
||||
try {
|
||||
$userContext = ResolveUserProfileContext -UserName $lookupName
|
||||
$userContext = Resolve-UserProfileContext -UserName $lookupName
|
||||
if (-not $userContext -or [string]::IsNullOrWhiteSpace($userContext.ProfilePath)) {
|
||||
return $false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user