diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9e80650..770a274 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,7 @@ # How to Contribute? We welcome contributions from the community. You can contribute to Win11Debloat by: + - Reporting issues and bugs [here](https://github.com/Raphire/Win11Debloat/issues/new?template=bug_report.yml) - Submitting feature requests [here](https://github.com/Raphire/Win11Debloat/issues/new?template=feature_request.yml) - Testing Win11Debloat @@ -15,6 +16,7 @@ You can help us test the latest changes and additions to the script. If you enco > The prerelease version of Win11Debloat is meant for developers to test the script. Don't use this in production environments! You can launch the prerelease version of Win11Debloat by running this command: + ```ps1 & ([scriptblock]::Create((irm "https://debloat.raphi.re/dev"))) ``` @@ -28,12 +30,14 @@ You can launch the prerelease version of Win11Debloat by running this command: 1. **Fork the project** on GitHub by clicking the "Fork" button at the top right of the repository page. 2. **Clone the repository** to your local machine: + ```powershell git clone https://github.com/YOUR-USERNAME/Win11Debloat.git cd Win11Debloat ``` 3. **Create a new branch** for your contribution: + ```powershell git checkout -b feature/your-feature-name ``` @@ -42,11 +46,14 @@ You can launch the prerelease version of Win11Debloat by running this command: 1. Open PowerShell as an administrator 2. Enable script execution if necessary: + ```powershell Set-ExecutionPolicy Unrestricted -Scope Process -Force ``` + 3. Navigate to your Win11Debloat directory 4. Run the script: + ```powershell .\Win11Debloat.ps1 ``` @@ -57,18 +64,31 @@ You can launch the prerelease version of Win11Debloat by running this command: Understanding the project structure is essential for contributing effectively: -``` +```text Win11Debloat/ ├── Win11Debloat.ps1 # Main PowerShell script +├── Run.bat # Batch launcher for the quick launch method ├── Scripts/ # Additional PowerShell scripts and functions -│ └── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat +│ ├── Get.ps1 # Script used for the quick launch method to automatically download and run Win11debloat +│ ├── AppRemoval/ # App package removal logic +│ ├── CLI/ # Command-line interface helpers +│ ├── Features/ # Feature apply/undo logic (e.g. InvokeChanges.ps1, ReplaceStartMenu.ps1) +│ ├── FileIO/ # File input/output helpers +│ ├── GUI/ # GUI window definitions and logic +│ ├── Helpers/ # Shared helper functions +│ └── Threading/ # Threading utilities ├── Config/ │ ├── Apps.json # List of supported apps for removal │ ├── DefaultSettings.json # Default configuration preset │ ├── Features.json # All features with metadata │ └── LastUsedSettings.json # Last used configuration (generated during use) ├── Regfiles/ # Registry files for all features -└── Schemas/ # XAML Schemas for GUI elements +│ ├── Undo/ # Registry files for reverting features +│ └── Sysprep/ # Registry files for Sysprep mode +├── Schemas/ # XAML Schemas for GUI elements +├── Assets/ # Static assets (icons, start menu templates) +├── Backups/ # Registry backups (generated during use) +└── Logs/ # Script logs (generated during use) ``` ### Best Practices @@ -109,9 +129,9 @@ Avoid these common mistakes when contributing: Placing files in the wrong directory may cause the script to fail when trying to apply or undo changes. -6. **Not Testing Undo Functionality**: Always test that your undo registry file properly reverts all changes. +5. **Not Testing Undo Functionality**: Always test that your undo registry file properly reverts all changes. -7. **Not Testing User/Sysprep Functionality**: Always test that your feature works when applied to another user or to the Windows default user with Sysprep. Sysprep changes can be tested by creating new users after running the script. +6. **Not Testing User/Sysprep Functionality**: Always test that your feature works when applied to another user or to the Windows default user with Sysprep. Sysprep changes can be tested by creating new users after running the script. 7. **Missing Category**: Features without a `Category` field (set to `null`) won't appear in the GUI. This is intentional for command-line-only features, make sure this is what you want before submitting. @@ -127,11 +147,13 @@ Avoid these common mistakes when contributing: To add a new app that can be removed via Win11Debloat: 1. **Find the AppId**: To find the correct AppId for an app: + ```powershell Get-AppxPackage | Select-Object Name, PackageFullName ``` 2. **Edit `Config/Apps.json`**: Add a new entry to the `"Apps"` array: + ```json { "FriendlyName": "Display Name", @@ -142,9 +164,10 @@ To add a new app that can be removed via Win11Debloat: ``` 3. **Follow the Guidelines**: -- Use clear, user-friendly names for `FriendlyName` -- Set `SelectedByDefault` to `true` only for apps that are largely considered bloatware, otherwise set to `false` -- Provide a concise description explaining what the app does + + - Use clear, user-friendly names for `FriendlyName` + - Set `SelectedByDefault` to `true` only for apps that are largely considered bloatware, otherwise set to `false` + - Provide a concise description explaining what the app does ### Adding a New Feature @@ -162,6 +185,7 @@ Create new registry files in the `Regfiles/` directory: - **Sysprep file**: `Sysprep/Disable_YourFeature.reg` (for Sysprep mode) Example registry file structure: + ```reg Windows Registry Editor Version 5.00 @@ -170,6 +194,7 @@ Windows Registry Editor Version 5.00 ``` A Sysprep registry file should apply the same changes as the normal action. Replace the hive of registry keys that start with `HKEY_CURRENT_USER` with `hkey_users\default`. For example: + ```reg Windows Registry Editor Version 5.00 @@ -179,7 +204,7 @@ Windows Registry Editor Version 5.00 #### 1b. Implement the Feature Logic -If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `ExecuteParameter` function for your new feature. +If your feature requires more than just applying a registry file, add custom logic to the main script in the appropriate section. In most cases this will involve creating a new entry in the `Invoke-FeatureApply` function (in `Scripts/Features/InvokeChanges.ps1`) for your new feature. If your feature also requires custom undo logic (beyond a simple registry file import), add a corresponding entry to the `Invoke-FeatureUndo` function in the same file. #### 2. Add Feature to Features.json @@ -205,6 +230,7 @@ Add your feature to the `"Features"` array in `Config/Features.json`: ``` **Field Descriptions**: + - `FeatureId`: Unique identifier, this must match parameter name in the Win11Debloat.ps1 and Get.ps1 files. - `Label`: Short description shown in the UI and wiki documentation. - `ToolTip`: Detailed explanation of what the feature does, used for tooltips in the GUI. @@ -223,6 +249,7 @@ Add your feature to the `"Features"` array in `Config/Features.json`: #### 3. Add Command-Line Parameter Add a corresponding parameter to both `Win11Debloat.ps1` AND `Scripts/Get.ps1`, the parameter name should match the FeatureId you have defined in `Features.json`. In most cases this will be a switch parameter, example: + ```powershell [switch]$YourFeatureId, ``` @@ -235,12 +262,14 @@ Add a corresponding parameter to both `Win11Debloat.ps1` AND `Scripts/Get.ps1`, The default preset (`Config/DefaultSettings.json`) defines which features are automatically applied when users run Win11Debloat in "Default Mode" or with the `-RunDefaults` parameter. This preset should include features that are widely considered to improve the Windows experience without breaking functionality. **When to add a feature to the default preset:** + - The feature removes obvious bloatware or distractions - The feature enhances privacy without breaking core functionality - The feature is generally non-controversial and beneficial to most users - The change can be easily reverted if needed **When NOT to add a feature to the default preset:** + - The feature significantly changes core Windows behavior - The feature might break applications or workflows for some users - The feature is highly opinionated or preference-based @@ -256,10 +285,12 @@ To add your feature to the default preset, edit `Config/DefaultSettings.json` an ``` **Field Descriptions**: + - `Name`: Must exactly match the `FeatureId` from Features.json - `Value`: Set to `true` to enable the feature in default mode **Example:** + ```json { "Version": "1.0", @@ -285,12 +316,13 @@ To add your feature to the default preset, edit `Config/DefaultSettings.json` an To add a new category for organizing features: - Add a new category entry to the `"Categories"` array in `Config/Features.json`: - ```json - { - "Name": "Your Category Name", - "Icon": "#### ;" - } - ``` + + ```json + { + "Name": "Your Category Name", + "Icon": "#### ;" + } + ``` > [!TIP] > Use [Segoe Fluent Icon Assets](https://learn.microsoft.com/en-us/windows/apps/design/iconography/segoe-fluent-icons-font) for icon codes. @@ -322,17 +354,20 @@ UI Groups allow features to be grouped together in the GUI with a combobox (drop ## Submitting a Pull Request 1. **Commit your changes** with clear, descriptive commit messages: + ```powershell git add . git commit -m "Add feature: Description of your changes" ``` 2. **Push to your fork**: + ```powershell git push origin feature/your-feature-name ``` 3. **Create a Pull Request** on GitHub: + - Go to the original Win11Debloat repository - Click "New Pull Request" - Select your fork and branch @@ -344,6 +379,7 @@ UI Groups allow features to be grouped together in the GUI with a combobox (drop # Questions? If you have questions about contributing, feel free to: + - Open a [discussion](https://github.com/Raphire/Win11Debloat/discussions) - Comment on an existing issue -- Ask in your pull request \ No newline at end of file +- Ask in your pull request diff --git a/Scripts/Features/ExecuteChanges.ps1 b/Scripts/Features/InvokeChanges.ps1 similarity index 50% rename from Scripts/Features/ExecuteChanges.ps1 rename to Scripts/Features/InvokeChanges.ps1 index c9b2d6b..cdd92d3 100644 --- a/Scripts/Features/ExecuteChanges.ps1 +++ b/Scripts/Features/InvokeChanges.ps1 @@ -1,23 +1,32 @@ -# Executes a single parameter/feature based on its key -# Parameters: -# $paramKey - The parameter name to execute -function ExecuteParameter { - param ( - [string]$paramKey +<# + .SYNOPSIS + Applies a single feature/debloat operation. + + .DESCRIPTION + Handles two categories of features: + - Registry-backed: imports the .reg file via ImportRegistryFile, then runs + any post-import side effects (e.g., removing companion app packages). + - Custom logic: app removal, Windows optional features, start menu + replacement, and other special-case features. +#> +function Invoke-FeatureApply { + param( + [Parameter(Mandatory)] + [string]$FeatureId ) - - # Check if this feature has metadata in Features.json + + # Resolve feature metadata from Features.json $feature = $null - if ($script:Features.ContainsKey($paramKey)) { - $feature = $script:Features[$paramKey] + if ($script:Features.ContainsKey($FeatureId)) { + $feature = $script:Features[$FeatureId] } - - # If feature has RegistryKey and ApplyText, use dynamic ImportRegistryFile + + # ---- Registry-backed features: import .reg file, then handle side effects ---- if ($feature -and $feature.RegistryKey -and $feature.ApplyText) { ImportRegistryFile "> $($feature.ApplyText)..." $feature.RegistryKey - - # Handle special cases that have additional logic after ImportRegistryFile - switch ($paramKey) { + + # Post-import side effects for specific features + switch ($FeatureId) { 'DisableBing' { # Also remove the app package for Bing search RemoveApps @('Microsoft.BingSearch') @@ -33,11 +42,13 @@ function ExecuteParameter { } return } - - # Handle features without RegistryKey or with special logic - switch ($paramKey) { + + # ---- Custom features (no registry backing, or special handling required) ---- + # Resolve a safe apply-text fallback in case the feature is missing from Features.json + $applyText = if ($feature -and $feature.ApplyText) { $feature.ApplyText } else { $FeatureId } + switch ($FeatureId) { 'RemoveApps' { - Write-Host "> $($feature.ApplyText) for $(GetFriendlyTargetUserName)..." + Write-Host "> $applyText for $(GetFriendlyTargetUserName)..." $appsList = GenerateAppsList if ($appsList.Count -eq 0) { @@ -48,9 +59,10 @@ function ExecuteParameter { Write-Host "$($appsList.Count) apps selected for removal" RemoveApps $appsList + return } 'RemoveAppsCustom' { - Write-Host "> $($feature.ApplyText)..." + Write-Host "> $applyText..." $appsList = LoadAppsFromFile $script:CustomAppsListFilePath if ($appsList.Count -eq 0) { @@ -61,52 +73,58 @@ function ExecuteParameter { Write-Host "$($appsList.Count) apps selected for removal" RemoveApps $appsList + return } 'RemoveGamingApps' { $appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay') - Write-Host "> $($feature.ApplyText)..." + Write-Host "> $applyText..." RemoveApps $appsList return } 'RemoveHPApps' { $appsList = @('AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl') - Write-Host "> $($feature.ApplyText)..." + Write-Host "> $applyText..." RemoveApps $appsList return } 'DisableWidgets' { - Write-Host "> $($feature.ApplyText)..." + Write-Host "> $applyText..." # Stop widgets related processes before removing the app packages to prevent potential issues if (-not $script:Params.ContainsKey("WhatIf")) { Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process } RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime') + return } 'EnableWindowsSandbox' { - Write-Host "> $($feature.ApplyText)..." + Write-Host "> $applyText..." EnableWindowsFeature "Containers-DisposableClientVM" Write-Host "" return } 'EnableWindowsSubsystemForLinux' { - Write-Host "> $($feature.ApplyText)..." + Write-Host "> $applyText..." EnableWindowsFeature "VirtualMachinePlatform" EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux" Write-Host "" return } 'ClearStart' { - Write-Host "> $($feature.ApplyText) for user $(GetUserName)..." + Write-Host "> $applyText for user $(GetUserName)..." $startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName) - ReplaceStartMenu -startMenuBinFile $startMenuBinFile + if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) { + ReplaceStartMenu -startMenuBinFile $startMenuBinFile + } Write-Host "" return } 'ReplaceStart' { - Write-Host "> $($feature.ApplyText) for user $(GetUserName)..." + Write-Host "> $applyText for user $(GetUserName)..." $startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName) - ReplaceStartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart") + if (-not [string]::IsNullOrWhiteSpace($startMenuBinFile)) { + ReplaceStartMenu -startMenuBinFile $startMenuBinFile -startMenuTemplate $script:Params.Item("ReplaceStart") + } Write-Host "" return } @@ -138,156 +156,16 @@ function ExecuteParameter { } -# Executes all selected parameters/features -function ExecuteAllChanges { - # When running as SYSTEM, require -User or -Sysprep to prevent applying - # changes to the SYSTEM profile instead of a real user. - $isSystem = ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18') - if ($isSystem -and -not $script:Params.ContainsKey("User") -and -not $script:Params.ContainsKey("Sysprep")) { - throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user." - } +<# + .SYNOPSIS + Undoes a single feature that has no RegistryUndoKey. - $script:RegistryImportFailures = 0 - - # Build list of actionable parameters (skip control params and data-only params) - $actionableKeys = @() - foreach ($paramKey in $script:Params.Keys) { - if ($script:ControlParams -contains $paramKey) { continue } - if ($paramKey -eq 'Apps') { continue } - if ($paramKey -eq 'CreateRestorePoint') { continue } - $actionableKeys += $paramKey - } - - $hasRegistryBackedFeature = $false - foreach ($paramKey in $actionableKeys) { - if (-not $script:Features.ContainsKey($paramKey)) { continue } - - $feature = $script:Features[$paramKey] - if ($feature -and -not [string]::IsNullOrWhiteSpace([string]$feature.RegistryKey)) { - $hasRegistryBackedFeature = $true - break - } - } - # Undo operations that write registry values also require a backup - if (-not $hasRegistryBackedFeature) { - foreach ($featureId in $script:UndoParams.Keys) { - $f = if ($script:Features.ContainsKey($featureId)) { $script:Features[$featureId] } else { $null } - if ($f -and $f.RegistryUndoKey) { $hasRegistryBackedFeature = $true; break } - } - } - - $totalSteps = $actionableKeys.Count + $script:UndoParams.Count - if ($hasRegistryBackedFeature) { $totalSteps++ } - if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ } - $currentStep = 0 - - if ($hasRegistryBackedFeature) { - $currentStep++ - if ($script:ApplyProgressCallback) { - & $script:ApplyProgressCallback $currentStep $totalSteps "Creating registry backup..." - } - - if ($script:Params.ContainsKey("WhatIf")) { - Write-Host "[WhatIf] Create registry backup" -ForegroundColor Cyan - } - else { - Write-Host "> Creating registry backup..." - try { - $undoSyntheticFeatures = @($script:UndoParams.Keys | ForEach-Object { - $f = if ($script:Features.ContainsKey($_)) { $script:Features[$_] } else { $null } - if ($f -and $f.RegistryUndoKey) { - [PSCustomObject]@{ FeatureId = $_; RegistryKey = (Resolve-UndoRegFilePath $f.RegistryUndoKey) } - } - } | Where-Object { $_ }) - New-RegistrySettingsBackup -ActionableKeys $actionableKeys -ExtraFeatures $undoSyntheticFeatures | Out-Null - } - catch { - throw "Registry backup failed before applying changes. $($_.Exception.Message)" - } - } - } - - # Create restore point if requested (CLI only - GUI handles this separately) - if ($script:Params.ContainsKey("CreateRestorePoint")) { - $currentStep++ - if ($script:ApplyProgressCallback) { - & $script:ApplyProgressCallback $currentStep $totalSteps "Creating system restore point, this may take a moment..." - } - if ($script:Params.ContainsKey("WhatIf")) { - Write-Host "[WhatIf] Create system restore point" -ForegroundColor Cyan - Write-Host "" - } - else { - Write-Host "> Creating a system restore point..." - CreateSystemRestorePoint - Write-Host "" - } - } - - # Execute all parameters - foreach ($paramKey in $actionableKeys) { - if ($script:CancelRequested) { return } - - $currentStep++ - - # Get friendly name for the step - $stepName = $paramKey - if ($script:Features.ContainsKey($paramKey)) { - $feature = $script:Features[$paramKey] - if ($feature.ApplyText) { - # Prefer explicit ApplyText when provided - $stepName = $feature.ApplyText - } elseif ($feature.Label) { - # Fallback: use label from Features.json - $stepName = $feature.Label - } - } - - if ($script:ApplyProgressCallback) { - & $script:ApplyProgressCallback $currentStep $totalSteps $stepName - } - - ExecuteParameter -paramKey $paramKey - } - - # Execute all undo operations - foreach ($featureId in $script:UndoParams.Keys) { - if ($script:CancelRequested) { return } - - $f = if ($script:Features.ContainsKey($featureId)) { $script:Features[$featureId] } else { $null } - $undoLabel = if ($f -and $f.UndoLabel) { $f.UndoLabel } else { $featureId } - $applyUndoText = if ($f -and $f.ApplyUndoText) { $f.ApplyUndoText } else { $undoLabel } - - $currentStep++ - if ($script:ApplyProgressCallback) { - & $script:ApplyProgressCallback $currentStep $totalSteps $applyUndoText - } - - if ($f -and $f.RegistryUndoKey) { - ImportRegistryFile "> $applyUndoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey) - } - - Invoke-UndoFeatureAction -FeatureId $featureId - } - - if ($script:RegistryImportFailures -gt 0) { - Write-Host "" - Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow - } -} - -# Resolves the path of an undo reg file relative to $script:RegfilesPath. -# Checks the Undo/ subfolder first, then falls back to the root Regfiles/ folder. -function Resolve-UndoRegFilePath { - param ([string]$FileName) - $undoSubPath = Join-Path 'Undo' $FileName - if (Test-Path (Join-Path $script:RegfilesPath $undoSubPath)) { - return $undoSubPath - } - return $FileName -} - -function Invoke-UndoFeatureAction { + .DESCRIPTION + Handles undo for features that require custom logic rather than a simple + .reg file import. Features with a RegistryUndoKey are handled directly + via ImportRegistryFile in Invoke-UndoFeatures. +#> +function Invoke-FeatureUndo { param( [Parameter(Mandatory)] [string]$FeatureId @@ -313,13 +191,15 @@ function Invoke-UndoFeatureAction { return } 'EnableWindowsSandbox' { - Write-Host "> $($feature.ApplyUndoText)..." + $undoText = if ($feature) { $feature.ApplyUndoText } else { 'Disabling Windows Sandbox' } + Write-Host "> $undoText..." DisableWindowsFeature 'Containers-DisposableClientVM' Write-Host "" return } 'EnableWindowsSubsystemForLinux' { - Write-Host "> $($feature.ApplyUndoText)..." + $undoText = if ($feature) { $feature.ApplyUndoText } else { 'Disabling Windows Subsystem for Linux' } + Write-Host "> $undoText..." DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux' DisableWindowsFeature 'VirtualMachinePlatform' Write-Host "" @@ -332,3 +212,242 @@ function Invoke-UndoFeatureAction { } } } + + +<# + .SYNOPSIS + Resolves the path of an undo .reg file relative to $script:RegfilesPath. + + .DESCRIPTION + Checks the Undo/ subfolder first, then falls back to the root Regfiles/ + folder. This allows undo files to be organized separately from apply files. +#> +function Resolve-UndoRegFilePath { + param([string]$FileName) + + $undoSubPath = Join-Path 'Undo' $FileName + if (Test-Path (Join-Path $script:RegfilesPath $undoSubPath)) { + return $undoSubPath + } + return $FileName +} + + +<# +.SYNOPSIS + Applies a list of features, reporting progress for each. + +.DESCRIPTION + Iterates through the provided feature IDs and calls Invoke-FeatureApply + for each. Handles progress callbacks (GUI mode) and cancellation checks. + This is called by Invoke-AllChanges during the apply phase. +#> +function Invoke-ApplyFeatures { + param( + [Parameter(Mandatory)] + [string[]]$FeatureIds, + [Parameter(Mandatory)] + [int]$StartStep, + [Parameter(Mandatory)] + [int]$TotalSteps + ) + + if ($FeatureIds.Count -eq 0) { return } + + $step = $StartStep + foreach ($featureId in $FeatureIds) { + if ($script:CancelRequested) { return } + + # Resolve display name for the progress indicator + $displayName = $featureId + if ($script:Features.ContainsKey($featureId)) { + $f = $script:Features[$featureId] + if ($f.ApplyText) { + $displayName = $f.ApplyText + } elseif ($f.Label) { + $displayName = $f.Label + } + } + + if ($script:ApplyProgressCallback) { + & $script:ApplyProgressCallback $step $TotalSteps $displayName + } + + Invoke-FeatureApply -FeatureId $featureId + $step++ + } +} + + +<# + .SYNOPSIS + Undoes a list of features, reporting progress for each. + + .DESCRIPTION + Iterates through the provided feature IDs. Features with a RegistryUndoKey + are handled by importing the undo .reg file; all others delegate to + Invoke-FeatureUndo for custom undo logic. + This is called by Invoke-AllChanges during the undo phase. +#> +function Invoke-UndoFeatures { + param( + [Parameter(Mandatory)] + [string[]]$FeatureIds, + [Parameter(Mandatory)] + [int]$StartStep, + [Parameter(Mandatory)] + [int]$TotalSteps + ) + + if ($FeatureIds.Count -eq 0) { return } + + $step = $StartStep + foreach ($featureId in $FeatureIds) { + if ($script:CancelRequested) { return } + + $f = if ($script:Features.ContainsKey($featureId)) { $script:Features[$featureId] } else { $null } + $undoLabel = if ($f -and $f.UndoLabel) { $f.UndoLabel } else { $featureId } + $undoText = if ($f -and $f.ApplyUndoText) { $f.ApplyUndoText } else { $undoLabel } + + if ($script:ApplyProgressCallback) { + & $script:ApplyProgressCallback $step $TotalSteps $undoText + } + + if ($f -and $f.RegistryUndoKey) { + ImportRegistryFile "> $undoText" (Resolve-UndoRegFilePath $f.RegistryUndoKey) + } + + Invoke-FeatureUndo -FeatureId $featureId + $step++ + } +} + + +<# + .SYNOPSIS + Main orchestrator: applies and undoes all selected features. + + .DESCRIPTION + Sequenced in four phases: + 1. Registry backup + 2. System restore point + 3. Apply phase - applies all selected features via Invoke-ApplyFeatures + 4. Undo phase - undoes selected features via Invoke-UndoFeatures + + Progress is reported through $script:ApplyProgressCallback when set + (used by the GUI modal). Cancellation is checked between each step. +#> +function Invoke-AllChanges { + # Guard: prevent running as SYSTEM account without explicit target user + $isSystem = ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -eq 'S-1-5-18') + if ($isSystem -and -not $script:Params.ContainsKey("User") -and -not $script:Params.ContainsKey("Sysprep")) { + throw "Win11Debloat is running as the SYSTEM account. Use the '-User' or '-Sysprep' parameter to target a specific user." + } + + $script:RegistryImportFailures = 0 + + # ---- Gather work items ---- + $applyIds = @() + foreach ($key in $script:Params.Keys) { + if ($script:ControlParams -contains $key) { continue } + if ($key -eq 'Apps') { continue } + if ($key -eq 'CreateRestorePoint') { continue } + $applyIds += $key + } + $undoIds = @($script:UndoParams.Keys) + + # ---- Determine if registry backup is needed ---- + $needsBackup = $false + foreach ($id in $applyIds) { + if (-not $script:Features.ContainsKey($id)) { continue } + $f = $script:Features[$id] + if ($f -and -not [string]::IsNullOrWhiteSpace([string]$f.RegistryKey)) { + $needsBackup = $true + break + } + } + if (-not $needsBackup) { + foreach ($id in $undoIds) { + $f = if ($script:Features.ContainsKey($id)) { $script:Features[$id] } else { $null } + if ($f -and $f.RegistryUndoKey) { $needsBackup = $true; break } + } + } + + # ---- Calculate total progress steps ---- + $totalSteps = $applyIds.Count + $undoIds.Count + if ($needsBackup) { $totalSteps++ } + if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ } + $step = 0 + + # ================================================================ + # Phase 1: Registry backup + # ================================================================ + if ($needsBackup) { + $step++ + if ($script:ApplyProgressCallback) { + & $script:ApplyProgressCallback $step $totalSteps "Creating registry backup..." + } + + if ($script:Params.ContainsKey("WhatIf")) { + Write-Host "[WhatIf] Create registry backup" -ForegroundColor Cyan + } + else { + Write-Host "> Creating registry backup..." + try { + $undoSyntheticFeatures = @($undoIds | ForEach-Object { + $f = if ($script:Features.ContainsKey($_)) { $script:Features[$_] } else { $null } + if ($f -and $f.RegistryUndoKey) { + [PSCustomObject]@{ FeatureId = $_; RegistryKey = (Resolve-UndoRegFilePath $f.RegistryUndoKey) } + } + } | Where-Object { $_ }) + New-RegistrySettingsBackup -ActionableKeys $applyIds -ExtraFeatures $undoSyntheticFeatures | Out-Null + } + catch { + throw "Registry backup failed before applying changes. $($_.Exception.Message)" + } + } + } + + # ================================================================ + # Phase 2: System restore point + # ================================================================ + if ($script:Params.ContainsKey("CreateRestorePoint")) { + $step++ + if ($script:ApplyProgressCallback) { + & $script:ApplyProgressCallback $step $totalSteps "Creating system restore point, this may take a moment..." + } + if ($script:Params.ContainsKey("WhatIf")) { + Write-Host "[WhatIf] Create system restore point" -ForegroundColor Cyan + Write-Host "" + } + else { + Write-Host "> Creating a system restore point..." + CreateSystemRestorePoint + Write-Host "" + } + } + + # ================================================================ + # Phase 3: Apply features + # ================================================================ + if ($applyIds.Count -gt 0) { + Invoke-ApplyFeatures -FeatureIds $applyIds -StartStep ($step + 1) -TotalSteps $totalSteps + $step += $applyIds.Count + } + + # ================================================================ + # Phase 4: Undo features + # ================================================================ + if ($undoIds.Count -gt 0) { + Invoke-UndoFeatures -FeatureIds $undoIds -StartStep ($step + 1) -TotalSteps $totalSteps + $step += $undoIds.Count + } + + # ================================================================ + # Final: Report registry import failures + # ================================================================ + if ($script:RegistryImportFailures -gt 0) { + Write-Host "" + Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow + } +} diff --git a/Scripts/Features/ReplaceStartMenu.ps1 b/Scripts/Features/ReplaceStartMenu.ps1 index ede5254..89bd47e 100644 --- a/Scripts/Features/ReplaceStartMenu.ps1 +++ b/Scripts/Features/ReplaceStartMenu.ps1 @@ -1,5 +1,24 @@ -# Replace the startmenu for all users, when using the default startmenuTemplate this clears all pinned apps -# Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/ +<# + .SYNOPSIS + Replaces the start menu layout for all user profiles. + + .DESCRIPTION + Iterates over every existing user profile and the Default user profile, + replacing each user's start2.bin file with the specified template. When + using the default template, this clears all pinned apps from the start menu. + + Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/ + + .PARAMETER startMenuTemplate + Path to the .bin template file to apply. Defaults to the blank template + bundled with the script (Assets/Start/start2.bin). + + .EXAMPLE + ReplaceStartMenuForAllUsers + + .EXAMPLE + ReplaceStartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin" +#> function ReplaceStartMenuForAllUsers { param ( [string]$startMenuTemplate = "$script:AssetsPath\Start\start2.bin" @@ -44,8 +63,31 @@ function ReplaceStartMenuForAllUsers { } -# Replace the startmenu at the specified location, when using the default startmenuTemplate this clears all pinned apps -# Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/ +<# + .SYNOPSIS + Replaces the start menu layout for a single user. + + .DESCRIPTION + Backs up the current start2.bin file (if it exists), then copies the + specified template over it. When using the default template this clears + all pinned apps from the start menu. Validates that the template file + exists and has a .bin extension before proceeding. + + Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/ + + .PARAMETER startMenuBinFile + The full path to the user's start2.bin file to replace. + + .PARAMETER startMenuTemplate + Path to the .bin template file to apply. Defaults to the blank template + bundled with the script (Assets/Start/start2.bin). + + .EXAMPLE + ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" + + .EXAMPLE + ReplaceStartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin" +#> function ReplaceStartMenu { param ( [Parameter(Mandatory)] @@ -88,6 +130,24 @@ function ReplaceStartMenu { Write-Host "Replaced start menu for user $userName" } +<# + .SYNOPSIS + Returns the full path to the start menu bin file for a given user. + + .DESCRIPTION + Resolves the path to the start2.bin file for the specified username. + When no username is provided or the value is empty, falls back to + the current user's local app data path via $env:LOCALAPPDATA. + + .PARAMETER UserName + The target username. Pass an empty string or omit to resolve for the current user. + + .EXAMPLE + GetStartMenuBinPathForUser -UserName "Jeff" + + .EXAMPLE + GetStartMenuBinPathForUser -UserName "Default" +#> function GetStartMenuBinPathForUser { param( [string]$UserName @@ -100,6 +160,21 @@ function GetStartMenuBinPathForUser { return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false) } +<# + .SYNOPSIS + Extracts the username from a start2.bin file path. + + .DESCRIPTION + Parses a typical C:\Users\\AppData\... path and returns the + username portion. Returns 'unknown' if the path does not match the + expected pattern. + + .PARAMETER StartMenuBinFile + The full path to a start2.bin file. + + .EXAMPLE + GetStartMenuUserNameFromPath -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" +#> function GetStartMenuUserNameFromPath { param( [string]$StartMenuBinFile @@ -115,6 +190,29 @@ function GetStartMenuUserNameFromPath { +<# + .SYNOPSIS + Restores a user's start menu from a backup file. + + .DESCRIPTION + Moves the current start2.bin to a .restore.bak safety copy, then copies + the specified backup file into place. Returns a PSCustomObject with + UserName, Result ($true/$false), and Message properties describing + the outcome. + + .PARAMETER StartMenuBinFile + The full path to the user's start2.bin file to restore. + + .PARAMETER BackupFilePath + Path to the backup file to restore from. If omitted, defaults to + StartMenuBinFile with a .bak extension. + + .EXAMPLE + RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" + + .EXAMPLE + RestoreStartMenuFromBackup -StartMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -BackupFilePath "C:\Backups\start2.bin" +#> function RestoreStartMenuFromBackup { param( [Parameter(Mandatory)] @@ -169,6 +267,26 @@ function RestoreStartMenuFromBackup { } } +<# + .SYNOPSIS + Restores the start menu for the current target user from a backup. + + .DESCRIPTION + Resolves the start2.bin path for the current user (or the user specified + via the -User parameter), then delegates to RestoreStartMenuFromBackup. + Returns early with a warning if the user's start menu path cannot + be resolved. + + .PARAMETER BackupFilePath + Path to the backup file to restore from. If omitted, defaults to + the .bak file alongside the current start2.bin. + + .EXAMPLE + RestoreStartMenu + + .EXAMPLE + RestoreStartMenu -BackupFilePath "C:\Backups\start2.bin" +#> function RestoreStartMenu { param( [string]$BackupFilePath @@ -177,11 +295,40 @@ function RestoreStartMenu { $targetUserName = GetUserName $startMenuBinFile = GetStartMenuBinPathForUser -UserName $targetUserName + if ([string]::IsNullOrWhiteSpace($startMenuBinFile)) { + Write-Host "Unable to resolve start menu path for user $targetUserName, nothing to restore" -ForegroundColor Yellow + return [PSCustomObject]@{ + UserName = $targetUserName + Result = $false + Message = "Could not resolve start menu path for user $targetUserName." + } + } + Write-Host "Restoring start menu for user $targetUserName from backup..." return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath } +<# + .SYNOPSIS + Restores the start menu for all user profiles from a backup. + + .DESCRIPTION + Iterates over every existing user profile and restores each user's + start2.bin from its .bak backup. For the Default user profile, removes + the start2.bin file (which was previously copied from a template) so + that new profiles revert to the system default start menu. + + .PARAMETER BackupFilePath + Path to the backup file to restore from. If omitted, defaults to + the .bak file alongside each user's current start2.bin. + + .EXAMPLE + RestoreStartMenuForAllUsers + + .EXAMPLE + RestoreStartMenuForAllUsers -BackupFilePath "C:\Backups\start2.bin" +#> function RestoreStartMenuForAllUsers { param( [string]$BackupFilePath diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index 8d67aef..aaa3a36 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -69,7 +69,7 @@ function Show-ApplyModal { $script:ApplyProgressBarEl.Value = 0 $script:ApplyModalInErrorState = $false - # Set up progress callback for ExecuteAllChanges + # Set up progress callback for Invoke-AllChanges $script:ApplyProgressCallback = { param($currentStep, $totalSteps, $stepName) $script:ApplyStepNameEl.Text = $stepName @@ -102,7 +102,7 @@ function Show-ApplyModal { # Run changes in background to keep UI responsive $applyWindow.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ try { - ExecuteAllChanges + Invoke-AllChanges $registryImportFailureCount = [int]$script:RegistryImportFailures diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index c0270b2..5d8935b 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -302,7 +302,7 @@ if (-not $script:WingetInstalled -and -not $Silent) { # Features functions . "$PSScriptRoot/Scripts/Features/GetCurrentTweakState.ps1" -. "$PSScriptRoot/Scripts/Features/ExecuteChanges.ps1" +. "$PSScriptRoot/Scripts/Features/InvokeChanges.ps1" . "$PSScriptRoot/Scripts/Features/CreateSystemRestorePoint.ps1" . "$PSScriptRoot/Scripts/Features/BackupRegistryFeatureSelection.ps1" . "$PSScriptRoot/Scripts/Features/BackupRegistrySnapshotCapture.ps1" @@ -554,7 +554,7 @@ if (($controlParamsCount -eq $script:Params.Keys.Count) -or ($script:Params.Keys # Execute all selected/provided parameters using the consolidated function # (This also handles restore point creation if requested) -ExecuteAllChanges +Invoke-AllChanges RestartExplorer