Add option to show & undo applied tweaks + more (#599)

* Remove RemoveCommApps and RemoveW11Outlook presets. These are largely redundant. Use -RemoveApps parameter instead

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

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

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

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

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

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

* Update README.md

* Update CONTRIBUTING.md
This commit is contained in:
Jeffrey
2026-06-10 17:40:31 +02:00
committed by GitHub
parent 53ca51dffd
commit 157d26bb22
43 changed files with 3541 additions and 1996 deletions

View File

@@ -14,8 +14,7 @@ function Get-FeatureId {
function Get-RegistryBackedFeatures {
param(
[Parameter(Mandatory)]
[object[]]$Features
[object[]]$Features = @()
)
return @($Features | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) })

View File

@@ -1,47 +1,44 @@
function Get-RegistryBackupCapturePlans {
param(
[Parameter(Mandatory)]
[object[]]$SelectedRegistryFeatures,
[object[]]$SelectedRegistryFeatures = @(),
[object[]]$UndoRegistryFeatures = @(),
[switch]$UseSysprepRegFiles
)
$planMap = @{}
foreach ($feature in $SelectedRegistryFeatures) {
$regFilePath = Get-RegistryFilePathForFeature -Feature $feature -UseSysprepRegFiles:$UseSysprepRegFiles
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $feature.RegistryKey -UseSysprepRegFiles:$UseSysprepRegFiles
if (-not (Test-Path $regFilePath)) {
throw "Unable to find registry file for backup: $($feature.RegistryKey) ($regFilePath)"
}
foreach ($operation in @(Get-RegFileOperations -regFilePath $regFilePath)) {
if (-not $operation.KeyPath) { continue }
Add-RegistryPlanOperation -PlanMap $planMap -Operation $operation
}
}
$mapKey = $operation.KeyPath.ToLowerInvariant()
if (-not $planMap.ContainsKey($mapKey)) {
$planMap[$mapKey] = [PSCustomObject]@{
Path = $operation.KeyPath
IncludeSubKeys = $false
CaptureAllValues = $false
ValueNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
}
foreach ($feature in $UndoRegistryFeatures) {
$regFilePath = Resolve-RegistryBackupUndoFilePath -Feature $feature
if ([string]::IsNullOrWhiteSpace($regFilePath)) {
continue
}
if (-not (Test-Path $regFilePath)) {
$undoKeyDescription = if (-not [string]::IsNullOrWhiteSpace([string]$feature.RegistryUndoKey)) {
[string]$feature.RegistryUndoKey
}
else {
[string]$feature.RegistryKey
}
$plan = $planMap[$mapKey]
switch ($operation.OperationType) {
'DeleteKey' {
$plan.IncludeSubKeys = $true
$plan.CaptureAllValues = $true
}
'SetValue' {
if (-not $plan.CaptureAllValues) {
$null = $plan.ValueNames.Add([string]$operation.ValueName)
}
}
'DeleteValue' {
if (-not $plan.CaptureAllValues) {
$null = $plan.ValueNames.Add([string]$operation.ValueName)
}
}
}
throw "Unable to find registry undo file for backup: $undoKeyDescription ($regFilePath)"
}
foreach ($operation in @(Get-RegFileOperations -regFilePath $regFilePath)) {
if (-not $operation.KeyPath) { continue }
Add-RegistryPlanOperation -PlanMap $planMap -Operation $operation
}
}
@@ -57,10 +54,68 @@ function Get-RegistryBackupCapturePlans {
)
}
function Get-RegistrySnapshotsForBackup {
function Add-RegistryPlanOperation {
param(
[hashtable]$PlanMap,
[PSCustomObject]$Operation
)
$mapKey = $Operation.KeyPath.ToLowerInvariant()
if (-not $PlanMap.ContainsKey($mapKey)) {
$PlanMap[$mapKey] = [PSCustomObject]@{
Path = $Operation.KeyPath
IncludeSubKeys = $false
CaptureAllValues = $false
ValueNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
}
}
$plan = $PlanMap[$mapKey]
switch ($Operation.OperationType) {
'DeleteKey' {
$plan.IncludeSubKeys = $true
$plan.CaptureAllValues = $true
}
'SetValue' {
if (-not $plan.CaptureAllValues) {
$null = $plan.ValueNames.Add([string]$Operation.ValueName)
}
}
'DeleteValue' {
if (-not $plan.CaptureAllValues) {
$null = $plan.ValueNames.Add([string]$Operation.ValueName)
}
}
}
}
function Resolve-RegistryBackupUndoFilePath {
param(
[Parameter(Mandatory)]
[object[]]$CapturePlans
$Feature
)
$undoRegistryKey = [string]$Feature.RegistryUndoKey
if (-not [string]::IsNullOrWhiteSpace($undoRegistryKey)) {
$resolvedUndoPath = Resolve-UndoRegFilePath -FileName $undoRegistryKey
return Join-Path $script:RegfilesPath $resolvedUndoPath
}
$resolvedRegistryKey = [string]$Feature.RegistryKey
if ([string]::IsNullOrWhiteSpace($resolvedRegistryKey)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($resolvedRegistryKey)) {
return $resolvedRegistryKey
}
return Join-Path $script:RegfilesPath $resolvedRegistryKey
}
function Get-RegistrySnapshotsForBackup {
param(
[object[]]$CapturePlans = @()
)
if ($CapturePlans.Count -eq 0) {

View File

@@ -1,11 +1,14 @@
function New-RegistrySettingsBackup {
param(
[string[]]$ActionableKeys
[string[]]$ActionableKeys,
[object[]]$ExtraFeatures = @()
)
$ActionableKeys = @($ActionableKeys)
$selectedFeatures = Get-SelectedFeatures -ActionableKeys $ActionableKeys
if (@($selectedFeatures | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) }).Count -eq 0) {
$selectedFeatures = @(Get-SelectedFeatures -ActionableKeys $ActionableKeys)
$undoFeatures = @($ExtraFeatures | Where-Object { $_ -ne $null })
$allFeatures = @($selectedFeatures) + @($undoFeatures)
if (@($allFeatures | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) }).Count -eq 0) {
return $null
}
@@ -18,7 +21,7 @@ function New-RegistrySettingsBackup {
$backupFileName = 'Win11Debloat-RegistryBackup-{0}.json' -f $timestamp.ToString('yyyyMMdd_HHmmss')
$backupFilePath = Join-Path $backupDirectory $backupFileName
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -CreatedAt $timestamp
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -UndoFeatures $undoFeatures -CreatedAt $timestamp
if (-not (SaveToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
throw "Failed to save registry backup to '$backupFilePath'"
}
@@ -55,8 +58,8 @@ function Get-SelectedFeatures {
function Get-RegistryBackupPayload {
param(
[Parameter(Mandatory)]
[object[]]$SelectedFeatures,
[object[]]$SelectedFeatures = @(),
[object[]]$UndoFeatures = @(),
[Parameter(Mandatory)]
[datetime]$CreatedAt
)
@@ -71,11 +74,24 @@ function Get-RegistryBackupPayload {
}
}
$selectedRegistryFeatures = Get-RegistryBackedFeatures -Features $SelectedFeatures
$capturePlans = Get-RegistryBackupCapturePlans -SelectedRegistryFeatures $SelectedRegistryFeatures
$selectedUndoFeatureIds = New-Object System.Collections.Generic.List[string]
$seenUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($feature in $UndoFeatures) {
$featureId = Get-FeatureId -Feature $feature
if ($seenUndoFeatureIds.Add($featureId)) {
$selectedUndoFeatureIds.Add($featureId)
}
}
$selectedRegistryFeatures = @(Get-RegistryBackedFeatures -Features $SelectedFeatures)
$undoRegistryFeatures = @($UndoFeatures | Where-Object {
-not [string]::IsNullOrWhiteSpace([string]$_.RegistryUndoKey) -or -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey)
})
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures $selectedRegistryFeatures -UndoRegistryFeatures $undoRegistryFeatures)
$registryKeys = @(Get-RegistrySnapshotsForBackup -CapturePlans $capturePlans)
return @{
$backupPayload = @{
Version = '1.0'
BackupType = 'RegistryState'
CreatedAt = $CreatedAt.ToString('o')
@@ -85,4 +101,10 @@ function Get-RegistryBackupPayload {
SelectedFeatures = @($selectedFeatureIds)
RegistryKeys = @($registryKeys)
}
if ($selectedUndoFeatureIds.Count -gt 0) {
$backupPayload['SelectedUndoFeatures'] = @($selectedUndoFeatureIds)
}
return $backupPayload
}

View File

@@ -49,4 +49,137 @@ function DisableStoreSearchSuggestions {
Set-Acl -Path $StoreAppsDatabase -AclObject $Acl | Out-Null
Write-Host "Disabled Microsoft Store search suggestions for user $userName"
}
function EnableStoreSearchSuggestionsForAllUsers {
# Get path to Store app database for all users
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
# Go through all users and re-enable start search suggestions
ForEach ($storeDbPath in $usersStoreDbPaths) {
EnableStoreSearchSuggestions ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
}
# Also re-enable for the default user profile
$defaultStoreDbPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false
EnableStoreSearchSuggestions $defaultStoreDbPath
}
function EnableStoreSearchSuggestions {
param (
$StoreAppsDatabase = "$env:LocalAppData\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db"
)
# Change path to correct user if a user was specified
if ($script:Params.ContainsKey("User")) {
$StoreAppsDatabase = GetUserDirectory -userName "$(GetUserName)" -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false
}
$userName = [regex]::Match($StoreAppsDatabase, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
if (-not $userName) { $userName = '<unknown>' }
if (-not (Test-Path -Path $StoreAppsDatabase)) {
Write-Host "Store app database not found for user $userName, nothing to undo"
return
}
# Ensure we can modify/delete the file even if restrictive ACLs were set.
$global:LASTEXITCODE = 0
takeown /F "$StoreAppsDatabase" /A | Out-Null
icacls "$StoreAppsDatabase" /grant *S-1-5-32-544:F /C | Out-Null
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') # 'EVERYONE' group
try {
$acl = Get-Acl -Path $StoreAppsDatabase
$denyRules = @(
$acl.Access | Where-Object {
$_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0) -and
(try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid } catch { $false })
}
)
foreach ($denyRule in $denyRules) {
$null = $acl.RemoveAccessRuleSpecific($denyRule)
}
Set-Acl -Path $StoreAppsDatabase -AclObject $acl | Out-Null
}
catch {
Write-Warning "Failed to normalize ACL for store database '$StoreAppsDatabase': $($_.Exception.Message)"
}
try {
Remove-Item -Path $StoreAppsDatabase -Force -ErrorAction Stop
Write-Host "Re-enabled Microsoft Store search suggestions for user $userName"
}
catch {
throw "Failed to remove '$StoreAppsDatabase' while undoing Microsoft Store search suggestions for user $userName. $($_.Exception.Message)"
}
}
function Test-StoreSearchSuggestionsDisabled {
param(
[Parameter(Mandatory)]
[string]$StoreAppsDatabase
)
if (-not (Test-Path -Path $StoreAppsDatabase)) {
return $false
}
try {
$acl = Get-Acl -Path $StoreAppsDatabase
}
catch {
return $false
}
$everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0')
foreach ($accessRule in @($acl.Access)) {
$isDenyFullControl = $accessRule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($accessRule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0)
if (-not $isDenyFullControl) { continue }
$isEveryone = $false
try {
$isEveryone = $accessRule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid
} catch { }
if ($isEveryone) {
return $true
}
}
return $false
}
function Test-StoreSearchSuggestionsDisabledForAllUsers {
$paths = @()
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
foreach ($storeDbPath in $usersStoreDbPaths) {
$paths += ($storeDbPath.FullName + "\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db")
}
$defaultStoreDbPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" -exitIfPathNotFound $false
if ($defaultStoreDbPath) {
$paths += $defaultStoreDbPath
}
if ($paths.Count -eq 0) {
return $false
}
foreach ($path in $paths) {
if (-not (Test-StoreSearchSuggestionsDisabled -StoreAppsDatabase $path)) {
return $false
}
}
return $true
}

View File

@@ -1,16 +0,0 @@
# Enables a Windows optional feature and pipes its output to the console
function EnableWindowsFeature {
param (
[string]$FeatureName
)
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}

View File

@@ -20,11 +20,11 @@ function ExecuteParameter {
switch ($paramKey) {
'DisableBing' {
# Also remove the app package for Bing search
RemoveApps 'Microsoft.BingSearch'
RemoveApps @('Microsoft.BingSearch')
}
'DisableCopilot' {
# Also remove the app package for Copilot
RemoveApps 'Microsoft.Copilot'
RemoveApps @('Microsoft.Copilot')
}
}
return
@@ -33,7 +33,7 @@ function ExecuteParameter {
# Handle features without RegistryKey or with special logic
switch ($paramKey) {
'RemoveApps' {
Write-Host "> Removing selected apps for $(GetFriendlyTargetUserName)..."
Write-Host "> $($feature.ApplyText) for $(GetFriendlyTargetUserName)..."
$appsList = GenerateAppsList
if ($appsList.Count -eq 0) {
@@ -46,7 +46,7 @@ function ExecuteParameter {
RemoveApps $appsList
}
'RemoveAppsCustom' {
Write-Host "> Removing selected apps..."
Write-Host "> $($feature.ApplyText)..."
$appsList = LoadAppsFromFile $script:CustomAppsListFilePath
if ($appsList.Count -eq 0) {
@@ -58,58 +58,46 @@ function ExecuteParameter {
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
}
'RemoveCommApps' {
$appsList = 'Microsoft.windowscommunicationsapps', 'Microsoft.People'
Write-Host "> Removing Mail, Calendar and People apps..."
RemoveApps $appsList
return
}
'RemoveW11Outlook' {
$appsList = 'Microsoft.OutlookForWindows'
Write-Host "> Removing new Outlook for Windows app..."
RemoveApps $appsList
return
}
'RemoveGamingApps' {
$appsList = 'Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay'
Write-Host "> Removing gaming related apps..."
$appsList = @('Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay')
Write-Host "> $($feature.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 "> Removing HP apps..."
$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)..."
RemoveApps $appsList
return
}
'DisableWidgets' {
Write-Host "> Disabling widgets on the taskbar & lock screen..."
Write-Host "> $($feature.ApplyText)..."
# Stop widgets related processes before removing the app packages to prevent potential issues
Get-Process *Widget* | Stop-Process
RemoveApps 'Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime'
Get-Process *Widget* -ErrorAction SilentlyContinue | Stop-Process
RemoveApps @('Microsoft.StartExperiencesApp','MicrosoftWindows.Client.WebExperience','Microsoft.WidgetsPlatformRuntime')
}
"EnableWindowsSandbox" {
Write-Host "> Enabling Windows Sandbox..."
'EnableWindowsSandbox' {
Write-Host "> $($feature.ApplyText)..."
EnableWindowsFeature "Containers-DisposableClientVM"
Write-Host ""
return
}
"EnableWindowsSubsystemForLinux" {
Write-Host "> Enabling Windows Subsystem for Linux..."
'EnableWindowsSubsystemForLinux' {
Write-Host "> $($feature.ApplyText)..."
EnableWindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host ""
return
}
'ClearStart' {
Write-Host "> Removing all pinned apps from the start menu for user $(GetUserName)..."
Write-Host "> $($feature.ApplyText) for user $(GetUserName)..."
ReplaceStartMenu
Write-Host ""
return
}
'ReplaceStart' {
Write-Host "> Replacing the start menu for user $(GetUserName)..."
Write-Host "> $($feature.ApplyText) for user $(GetUserName)..."
ReplaceStartMenu $script:Params.Item("ReplaceStart")
Write-Host ""
return
@@ -169,8 +157,15 @@ function ExecuteAllChanges {
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
$totalSteps = $actionableKeys.Count + $script:UndoParams.Count
if ($hasRegistryBackedFeature) { $totalSteps++ }
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$currentStep = 0
@@ -183,7 +178,13 @@ function ExecuteAllChanges {
Write-Host "> Creating registry backup..."
try {
New-RegistrySettingsBackup -ActionableKeys $actionableKeys | Out-Null
$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)"
@@ -203,9 +204,7 @@ function ExecuteAllChanges {
# Execute all parameters
foreach ($paramKey in $actionableKeys) {
if ($script:CancelRequested) {
return
}
if ($script:CancelRequested) { return }
$currentStep++
@@ -229,8 +228,82 @@ function ExecuteAllChanges {
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)
} else {
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 {
param(
[Parameter(Mandatory)]
[string]$FeatureId
)
$feature = if ($script:Features.ContainsKey($FeatureId)) { $script:Features[$FeatureId] } else { $null }
switch ($FeatureId) {
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) {
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
EnableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
}
Write-Host "> Re-enabling Microsoft Store search suggestions for user $(GetUserName)..."
EnableStoreSearchSuggestions
Write-Host ""
return
}
'EnableWindowsSandbox' {
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Containers-DisposableClientVM'
Write-Host ""
return
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
DisableWindowsFeature 'VirtualMachinePlatform'
Write-Host ""
return
}
default {
Write-Host "> No undo action defined for $FeatureId, skipping..." -ForegroundColor Yellow
Write-Host ""
return
}
}
}

View File

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

View File

@@ -8,13 +8,7 @@ function ImportRegistryFile {
Write-Host $message
$usesOfflineHive = $script:Params.ContainsKey("Sysprep") -or $script:Params.ContainsKey("User")
$regFileDirectory = if ($usesOfflineHive) {
Join-Path $script:RegfilesPath "Sysprep"
}
else {
$script:RegfilesPath
}
$regFilePath = Join-Path $regFileDirectory $path
$regFilePath = Get-RegistryFilePathForFeature -RegistryKey $path
if (-not (Test-Path $regFilePath)) {
$errorMessage = "Unable to find registry file: $path ($regFilePath)"

View File

@@ -33,12 +33,49 @@ function Get-NormalizedSelectedFeatureIdsFromBackup {
$errors.Add('SelectedFeatures must contain non-empty string feature IDs.')
}
if ($selectedFeatures.Count -eq 0) {
$errors.Add('SelectedFeatures must contain at least one feature ID.')
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
Errors = $errors.ToArray()
}
}
function Get-NormalizedSelectedUndoFeatureIdsFromBackup {
param(
[Parameter(Mandatory)]
$Backup
)
$selectedUndoFeatures = New-Object System.Collections.Generic.List[string]
$selectedUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$errors = New-Object System.Collections.Generic.List[string]
# SelectedUndoFeatures is optional - only process if present
if (-not $Backup.PSObject.Properties['SelectedUndoFeatures']) {
return [PSCustomObject]@{
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
Errors = $errors.ToArray()
}
}
$hasInvalidSelectedUndoFeatureId = $false
foreach ($featureId in @($Backup.SelectedUndoFeatures)) {
if ($featureId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$featureId)) {
$hasInvalidSelectedUndoFeatureId = $true
continue
}
$normalizedFeatureId = [string]$featureId
if ($selectedUndoFeatureIds.Add($normalizedFeatureId)) {
$selectedUndoFeatures.Add($normalizedFeatureId)
}
}
if ($hasInvalidSelectedUndoFeatureId) {
$errors.Add('SelectedUndoFeatures must contain non-empty string feature IDs.')
}
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
Errors = $errors.ToArray()
}
}
@@ -96,6 +133,9 @@ function Test-RegistryBackupMatchesSelectedFeatures {
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedUndoFeatureIds,
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
@@ -109,18 +149,19 @@ function Test-RegistryBackupMatchesSelectedFeatures {
return $errors.ToArray()
}
$selectedRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedFeatureIds) -Errors $errors)
$selectedRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedFeatureIds) -IsUndoFeature:$false -Errors $errors)
$undoRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedUndoFeatureIds) -IsUndoFeature:$true -Errors $errors)
$useSysprepRegFiles = ($Target -eq 'DefaultUserProfile') -or ($Target -like 'User:*')
$capturePlans = @()
if ($errors.Count -eq 0 -and $selectedRegistryFeatures.Count -gt 0) {
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @($selectedRegistryFeatures) -UseSysprepRegFiles:$useSysprepRegFiles)
if ($errors.Count -eq 0 -and ($selectedRegistryFeatures.Count -gt 0 -or $undoRegistryFeatures.Count -gt 0)) {
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @($selectedRegistryFeatures) -UndoRegistryFeatures @($undoRegistryFeatures) -UseSysprepRegFiles:$useSysprepRegFiles)
}
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @($capturePlans)
if ($planMap.Count -eq 0 -and @($RegistryKeys).Count -gt 0) {
$errors.Add('Backup contains registry snapshots but no allowed registry paths were derived from SelectedFeatures.')
$errors.Add('Backup contains registry snapshots but no allowed registry paths were derived from the selected features.')
}
foreach ($rootSnapshot in @($RegistryKeys)) {
@@ -136,6 +177,8 @@ function Get-SelectedRegistryFeaturesForBackupValidation {
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[bool]$IsUndoFeature,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
$Errors
)
@@ -152,7 +195,26 @@ function Get-SelectedRegistryFeaturesForBackupValidation {
}
$feature = $script:Features[$featureId]
if ($feature -and -not [string]::IsNullOrWhiteSpace([string]$feature.RegistryKey)) {
if (-not $feature) {
continue
}
# For undo features, check RegistryUndoKey if present (real features)
# Otherwise check RegistryKey (for synthetic features from backup capture)
$registryKeyToUse = if ($IsUndoFeature) {
$key = [string]$feature.RegistryUndoKey
if (-not [string]::IsNullOrWhiteSpace($key)) {
$key
}
else {
[string]$feature.RegistryKey
}
}
else {
[string]$feature.RegistryKey
}
if (-not [string]::IsNullOrWhiteSpace($registryKeyToUse)) {
$selectedRegistryFeatures.Add($feature)
}
}

View File

@@ -39,7 +39,7 @@ function ReplaceStartMenuForAllUsers {
}
# Replace the startmenu for all users, when using the default startmenuTemplate this clears all pinned apps
# 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/
function ReplaceStartMenu {
param (

View File

@@ -87,7 +87,17 @@ function Normalize-RegistryBackup {
$errors.Add([string]$selectedFeatureParseError)
}
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -Target $normalizedTarget -RegistryKeys @($normalizedKeys))
$selectedUndoFeatureParseResult = Get-NormalizedSelectedUndoFeatureIdsFromBackup -Backup $Backup
$selectedUndoFeatures = @($selectedUndoFeatureParseResult.SelectedUndoFeatures)
foreach ($selectedUndoFeatureParseError in @($selectedUndoFeatureParseResult.Errors)) {
$errors.Add([string]$selectedUndoFeatureParseError)
}
$allSelectedFeatures = @($selectedFeatures) + @($selectedUndoFeatures)
if ($allSelectedFeatures.Count -eq 0) {
$errors.Add('Backup must contain at least one feature ID in SelectedFeatures or SelectedUndoFeatures.')
}
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -SelectedUndoFeatureIds @($selectedUndoFeatures) -Target $normalizedTarget -RegistryKeys @($normalizedKeys))
foreach ($allowListValidationError in $allowListValidationErrors) {
$errors.Add([string]$allowListValidationError)
}
@@ -110,6 +120,7 @@ function Normalize-RegistryBackup {
ComputerName = [string]$Backup.ComputerName
Target = $normalizedTarget
SelectedFeatures = @($selectedFeatures)
SelectedUndoFeatures = @($selectedUndoFeatures)
RegistryKeys = @($normalizedKeys)
}
}

View File

@@ -0,0 +1,49 @@
# Enables a Windows optional feature and pipes its output to the console
function EnableWindowsFeature {
param (
[string]$FeatureName
)
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}
# Disables a Windows optional feature and pipes its output to the console
function DisableWindowsFeature {
param (
[string]$FeatureName
)
$result = Invoke-NonBlocking -ScriptBlock {
param($name)
Disable-WindowsOptionalFeature -Online -FeatureName $name -NoRestart
} -ArgumentList $FeatureName
$dismResult = @($result) | Where-Object { $_ -is [Microsoft.Dism.Commands.ImageObject] }
if ($dismResult) {
Write-Host ($dismResult | Out-String).Trim()
}
}
function Test-WindowsOptionalFeatureEnabled {
param (
[Parameter(Mandatory)]
[string]$FeatureName
)
try {
$feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop
}
catch {
return $false
}
return ($feature.State -eq 'Enabled')
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,215 @@
# MainWindow-WindowChrome.ps1
# Window sizing, DPI-aware coordinate conversion, maximized-window taskbar-constraint helpers, and UI animations.
function Register-MaximizedWindowHelper {
if (-not ([System.Management.Automation.PSTypeName]'Win11Debloat.MaximizedWindowHelper').Type) {
Add-Type -Namespace Win11Debloat -Name MaximizedWindowHelper `
-ReferencedAssemblies 'PresentationFramework','System.Windows.Forms','System.Drawing' `
-MemberDefinition @'
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct MINMAXINFO {
public POINT ptReserved, ptMaxSize, ptMaxPosition, ptMinTrackSize, ptMaxTrackSize;
}
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct POINT { public int x, y; }
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern System.IntPtr MonitorFromWindow(System.IntPtr hwnd, uint dwFlags);
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)]
private static extern bool GetMonitorInfo(System.IntPtr hMonitor, ref MONITORINFO lpmi);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct RECT {
public int Left, Top, Right, Bottom;
}
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Auto)]
private struct MONITORINFO {
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
public static System.IntPtr WmGetMinMaxInfoHook(
System.IntPtr hwnd, int msg, System.IntPtr wParam, System.IntPtr lParam, ref bool handled) {
if (msg == 0x0024) { // WM_GETMINMAXINFO
var mmi = (MINMAXINFO)System.Runtime.InteropServices.Marshal.PtrToStructure(
lParam, typeof(MINMAXINFO));
const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
var monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
var monitorInfo = new MONITORINFO();
monitorInfo.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(MONITORINFO));
if (monitor != System.IntPtr.Zero && GetMonitorInfo(monitor, ref monitorInfo)) {
mmi.ptMaxPosition.x = monitorInfo.rcWork.Left - monitorInfo.rcMonitor.Left;
mmi.ptMaxPosition.y = monitorInfo.rcWork.Top - monitorInfo.rcMonitor.Top;
mmi.ptMaxSize.x = monitorInfo.rcWork.Right - monitorInfo.rcWork.Left;
mmi.ptMaxSize.y = monitorInfo.rcWork.Bottom - monitorInfo.rcWork.Top;
}
else {
var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
var wa = screen.WorkingArea;
var bounds = screen.Bounds;
mmi.ptMaxPosition.x = wa.Left - bounds.Left;
mmi.ptMaxPosition.y = wa.Top - bounds.Top;
mmi.ptMaxSize.x = wa.Width;
mmi.ptMaxSize.y = wa.Height;
}
System.Runtime.InteropServices.Marshal.StructureToPtr(mmi, lParam, true);
}
return System.IntPtr.Zero;
}
'@
}
}
# Convert screen-pixel coordinates to WPF device-independent pixels (DIP)
function ConvertTo-ScreenPointToDip {
param(
[System.Windows.Window]$Window,
[double]$X,
[double]$Y
)
$source = [System.Windows.PresentationSource]::FromVisual($Window)
if ($null -eq $source -or $null -eq $source.CompositionTarget) {
return [System.Windows.Point]::new($X, $Y)
}
return $source.CompositionTarget.TransformFromDevice.Transform([System.Windows.Point]::new($X, $Y))
}
# Convert screen-pixel size to WPF device-independent size
function ConvertTo-ScreenPixelsToDip {
param(
[System.Windows.Window]$Window,
[double]$Width,
[double]$Height
)
$topLeft = ConvertTo-ScreenPointToDip -Window $Window -X 0 -Y 0
$bottomRight = ConvertTo-ScreenPointToDip -Window $Window -X $Width -Y $Height
return [System.Windows.Size]::new($bottomRight.X - $topLeft.X, $bottomRight.Y - $topLeft.Y)
}
# Get the screen that currently contains the window
function Get-WindowScreen {
param([System.Windows.Window]$Window)
$hwnd = (New-Object System.Windows.Interop.WindowInteropHelper($Window)).Handle
if ($hwnd -eq [IntPtr]::Zero) {
return $null
}
return [System.Windows.Forms.Screen]::FromHandle($hwnd)
}
# Update window border/corner chrome when transitioning between Normal and Maximized
function Update-MainWindowChrome {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Border]$MainBorder,
[System.Windows.Controls.Border]$TitleBarBackground,
[object]$NormalWindowShadow
)
$windowStateMaximized = [System.Windows.WindowState]::Maximized
$chrome = [System.Windows.Shell.WindowChrome]::GetWindowChrome($Window)
if ($Window.WindowState -eq $windowStateMaximized) {
$MainBorder.Margin = [System.Windows.Thickness]::new(0)
$MainBorder.BorderThickness = [System.Windows.Thickness]::new(0)
$MainBorder.CornerRadius = [System.Windows.CornerRadius]::new(0)
$MainBorder.Effect = $null
$TitleBarBackground.CornerRadius = [System.Windows.CornerRadius]::new(0)
# Zero out resize borders when maximized so the entire title bar row is draggable
if ($chrome) { $chrome.ResizeBorderThickness = [System.Windows.Thickness]::new(0) }
}
else {
$MainBorder.Margin = [System.Windows.Thickness]::new(0)
$MainBorder.BorderThickness = [System.Windows.Thickness]::new(1)
$MainBorder.CornerRadius = [System.Windows.CornerRadius]::new(8)
$MainBorder.Effect = $NormalWindowShadow
$TitleBarBackground.CornerRadius = [System.Windows.CornerRadius]::new(8, 8, 0, 0)
if ($chrome) { $chrome.ResizeBorderThickness = [System.Windows.Thickness]::new(5) }
}
}
# Set the initial window size and center on screen (normal state only)
function Set-MainWindowInitialSize {
param(
[System.Windows.Window]$Window,
[double]$InitialNormalMaxWidth = 1400.0
)
if ($Window.WindowState -ne [System.Windows.WindowState]::Normal) {
return
}
$screen = Get-WindowScreen -Window $Window
if ($null -eq $screen) {
return
}
$workingAreaTopLeftDip = ConvertTo-ScreenPointToDip -Window $Window -X $screen.WorkingArea.Left -Y $screen.WorkingArea.Top
$workingAreaDip = ConvertTo-ScreenPixelsToDip -Window $Window -Width $screen.WorkingArea.Width -Height $screen.WorkingArea.Height
$Window.Width = [Math]::Min($InitialNormalMaxWidth, $workingAreaDip.Width)
$Window.Left = $workingAreaTopLeftDip.X + (($workingAreaDip.Width - $Window.Width) / 2)
}
# Update the content grid margin to constrain max content width
function Update-MainWindowContentMargin {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Grid]$ContentGrid,
[double]$MaxContentWidth = 1600.0
)
$w = $Window.ActualWidth
if ($w -gt $MaxContentWidth) {
$gutter = [Math]::Floor(($w - $MaxContentWidth) / 2)
$ContentGrid.Margin = [System.Windows.Thickness]::new($gutter, 0, $gutter, 0)
}
else {
$ContentGrid.Margin = [System.Windows.Thickness]::new(0)
}
}
# Vertically center the home content panel
function Update-MainWindowHomeContentPosition {
param(
[System.Windows.Window]$Window,
[System.Windows.Controls.Panel]$HomeContentPanel
)
if ($HomeContentPanel) {
$availableHeight = $Window.ActualHeight - 32 # subtract title bar height
if ($availableHeight -gt 0) {
$topMargin = ($availableHeight - 584) * 0.5
$HomeContentPanel.Margin = [System.Windows.Thickness]::new(0, $topMargin, 0, 0)
}
}
}
function Start-DropdownArrowAnimation {
param(
[System.Windows.Controls.TextBlock]$Arrow,
[double]$Angle
)
if (-not $Arrow) { return }
$animation = New-Object System.Windows.Media.Animation.DoubleAnimation
$animation.To = $Angle
$animation.Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromMilliseconds(200))
$ease = New-Object System.Windows.Media.Animation.CubicEase
$ease.EasingMode = 'EaseOut'
$animation.EasingFunction = $ease
$Arrow.RenderTransform.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $animation)
}

View File

@@ -79,16 +79,70 @@ function Test-RestoreDialogFeatureVisibleInOverview {
return -not [string]::IsNullOrWhiteSpace([string]$featureDefinition.Category)
}
function Get-SelectedForwardFeatureIdsFromBackup {
param($SelectedBackup)
$selectedFeatureIds = New-Object System.Collections.Generic.List[string]
$seenSelectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @($SelectedBackup.SelectedFeatures)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
$normalizedId = [string]$featureId
if ($seenSelectedFeatureIds.Add($normalizedId)) {
$selectedFeatureIds.Add($normalizedId)
}
}
return @($selectedFeatureIds.ToArray())
}
function Get-SelectedUndoFeatureIdsFromBackup {
param($SelectedBackup)
$selectedUndoFeatureIds = New-Object System.Collections.Generic.List[string]
$seenUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @($SelectedBackup.SelectedUndoFeatures)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
$normalizedId = [string]$featureId
if ($seenUndoFeatureIds.Add($normalizedId)) {
$selectedUndoFeatureIds.Add($normalizedId)
}
}
return @($selectedUndoFeatureIds.ToArray())
}
function Get-CombinedSelectedFeatureIdsFromBackup {
param($SelectedBackup)
$featureIds = New-Object System.Collections.Generic.List[string]
$seenIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @(Get-SelectedForwardFeatureIdsFromBackup -SelectedBackup $SelectedBackup) + @(Get-SelectedUndoFeatureIdsFromBackup -SelectedBackup $SelectedBackup)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
$normalizedId = [string]$featureId
if ($seenIds.Add($normalizedId)) {
$featureIds.Add($normalizedId)
}
}
return @($featureIds.ToArray())
}
function Get-SelectedFeatureIdsFromBackup {
param($SelectedBackup)
return @(
foreach ($featureId in @($SelectedBackup.SelectedFeatures)) {
if (-not [string]::IsNullOrWhiteSpace([string]$featureId)) {
[string]$featureId
}
}
)
return @(Get-CombinedSelectedFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
}
function Get-RestoreBackupFeatureLists {

View File

@@ -186,7 +186,7 @@ function Show-ApplyModal {
$applyRebootPanel.Visibility = 'Visible'
}
else {
$script:ApplyCompletionMessageEl.Text = "Your clean system is ready. Thanks for using Win11Debloat!"
$script:ApplyCompletionMessageEl.Text = "Your system is ready. Thanks for using Win11Debloat!"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,9 @@ function Show-RestoreBackupDialog {
$backupCreatedText = $window.FindName('BackupCreatedText')
$backupTargetText = $window.FindName('BackupTargetText')
$featuresItemsControl = $window.FindName('FeaturesItemsControl')
$reappliedSeparator = $window.FindName('ReappliedSeparator')
$reappliedPanel = $window.FindName('ReappliedPanel')
$reappliedFeaturesItemsControl = $window.FindName('ReappliedFeaturesItemsControl')
$nonRevertibleSeparator = $window.FindName('NonRevertibleSeparator')
$nonRevertiblePanel = $window.FindName('NonRevertiblePanel')
$nonRevertibleFeaturesItemsControl = $window.FindName('NonRevertibleFeaturesItemsControl')
@@ -119,6 +122,8 @@ function Show-RestoreBackupDialog {
$overviewFeaturesSection.Visibility = 'Collapsed'
$overviewSummaryText.Visibility = 'Visible'
$reappliedSeparator.Visibility = 'Collapsed'
$reappliedPanel.Visibility = 'Collapsed'
$nonRevertibleSeparator.Visibility = 'Collapsed'
$nonRevertiblePanel.Visibility = 'Collapsed'
$introInfoPanel.Visibility = 'Collapsed'
@@ -215,13 +220,33 @@ function Show-RestoreBackupDialog {
}
}
$selectedFeatureIds = Get-SelectedFeatureIdsFromBackup -SelectedBackup $SelectedBackup
$featureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds $selectedFeatureIds -Features $script:Features
$revertibleFeaturesList = @($featureLists.Revertible)
$nonRevertibleFeaturesList = @($featureLists.NonRevertible)
Write-Host "Backup overview prepared. Revertible=$($revertibleFeaturesList.Count), NonRevertible=$($nonRevertibleFeaturesList.Count)"
$selectedForwardFeatureIds = @(Get-SelectedForwardFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
$selectedUndoFeatureIds = @(Get-SelectedUndoFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
if ($revertibleFeaturesList.Count -eq 0) {
$seenForwardFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in $selectedForwardFeatureIds) {
[void]$seenForwardFeatureIds.Add([string]$featureId)
}
$filteredUndoFeatureIds = New-Object System.Collections.Generic.List[string]
foreach ($featureId in $selectedUndoFeatureIds) {
if ($seenForwardFeatureIds.Contains([string]$featureId)) {
continue
}
$filteredUndoFeatureIds.Add([string]$featureId)
}
$forwardFeatureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds $selectedForwardFeatureIds -Features $script:Features
$undoFeatureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds @($filteredUndoFeatureIds.ToArray()) -Features $script:Features
$combinedFeatureLists = Get-RestoreBackupFeatureLists -SelectedFeatureIds (Get-SelectedFeatureIdsFromBackup -SelectedBackup $SelectedBackup) -Features $script:Features
$revertibleFeaturesList = @($forwardFeatureLists.Revertible)
$reappliedFeaturesList = @($undoFeatureLists.Revertible)
$nonRevertibleFeaturesList = @($combinedFeatureLists.NonRevertible)
Write-Host "Backup overview prepared. Reverted=$($revertibleFeaturesList.Count), ReApplied=$($reappliedFeaturesList.Count), NonRevertible=$($nonRevertibleFeaturesList.Count)"
if ($revertibleFeaturesList.Count -eq 0 -and $reappliedFeaturesList.Count -eq 0) {
throw 'The selected backup does not contain any changes that can be restored.'
}
@@ -229,13 +254,16 @@ function Show-RestoreBackupDialog {
$backupCreatedText.Text = $createdText
$backupTargetText.Text = GetFriendlyRegistryBackupTarget -Target ([string]$SelectedBackup.Target)
$featuresItemsControl.ItemsSource = $revertibleFeaturesList
$overviewFeaturesSection.Visibility = 'Visible'
$overviewFeaturesSection.Visibility = if ($revertibleFeaturesList.Count -gt 0) { 'Visible' } else { 'Collapsed' }
$reappliedFeaturesItemsControl.ItemsSource = $reappliedFeaturesList
if ($reappliedFeaturesList.Count -gt 0) { $reappliedPanel.Visibility = 'Visible' } else { $reappliedPanel.Visibility = 'Collapsed' }
if ($revertibleFeaturesList.Count -gt 0 -and $reappliedFeaturesList.Count -gt 0) { $reappliedSeparator.Visibility = 'Visible' } else { $reappliedSeparator.Visibility = 'Collapsed' }
$overviewSummaryText.Visibility = 'Collapsed'
$nonRevertibleFeaturesItemsControl.ItemsSource = $nonRevertibleFeaturesList
$hasNonRevertibleItems = ($nonRevertibleFeaturesList.Count -gt 0)
if ($hasNonRevertibleItems) { $nonRevertiblePanel.Visibility = 'Visible' } else { $nonRevertiblePanel.Visibility = 'Collapsed' }
if ($hasNonRevertibleItems) { $nonRevertibleSeparator.Visibility = 'Visible' } else { $nonRevertibleSeparator.Visibility = 'Collapsed' }
if ($hasNonRevertibleItems -and ($revertibleFeaturesList.Count -gt 0 -or $reappliedFeaturesList.Count -gt 0)) { $nonRevertibleSeparator.Visibility = 'Visible' } else { $nonRevertibleSeparator.Visibility = 'Collapsed' }
$introInfoPanel.Visibility = 'Collapsed'
$overviewPanel.Visibility = 'Visible'
@@ -295,6 +323,30 @@ function Show-RestoreBackupDialog {
return
}
if (-not $useManualBackupFile) {
$autoBackupExists = $false
if ($scope -eq 'AllUsers') {
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
foreach ($startMenuPath in $usersStartMenuPaths) {
if (Test-Path -LiteralPath (Join-Path $startMenuPath.FullName 'start2.bin.bak')) {
$autoBackupExists = $true
break
}
}
}
else {
$autoBackupPath = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin.bak"
$autoBackupExists = Test-Path -LiteralPath $autoBackupPath
}
if (-not $autoBackupExists) {
$scopeText = (& $getStartMenuScopeInfo).SummaryText
Show-MessageBox -Owner $window -Title 'No Backup Found' -Message "No Start Menu backup file was found. You can uncheck the 'Automatically find Start Menu backup' option to select a backup file manually." -Button 'OK' -Icon 'Warning' | Out-Null
return
}
}
$window.Tag = @{
Result = 'RestoreStartMenu'
StartMenuScope = $scope

View File

@@ -7,10 +7,15 @@ function Show-RestoreBackupWindow {
try {
Write-Host 'Opening restore backup dialog.'
$restoreResult = [PSCustomObject]@{
RestoredRegistry = $false
RestoredStartMenu = $false
}
$dialogResult = Show-RestoreBackupDialog -Owner $Owner
if (-not $dialogResult -or $dialogResult.Result -eq 'Cancel') {
Write-Host 'Restore canceled by user.'
return
return $restoreResult
}
$successMessage = $null
@@ -24,7 +29,8 @@ function Show-RestoreBackupWindow {
Write-Host "User confirmed registry restore for $($backup.Target)."
Restore-RegistryBackupState -Backup $backup
$successMessage = 'Registry backup restored successfully. Please restart your computer for all changes to take effect.'
$restoreResult.RestoredRegistry = $true
$successMessage = 'Registry backup restored successfully. Some changes may require a restart to take effect.'
}
elseif ($dialogResult.Result -eq 'RestoreStartMenu') {
$scope = $dialogResult.StartMenuScope
@@ -69,6 +75,8 @@ function Show-RestoreBackupWindow {
$successMessage = "The Start Menu backup was successfully restored for the current user. The changes will apply the next time you sign in."
}
}
$restoreResult.RestoredStartMenu = $true
}
if ($warningMessage) {
@@ -79,10 +87,16 @@ function Show-RestoreBackupWindow {
Write-Host "$successMessage"
Show-MessageBox -Title 'Backup Restored' -Message $successMessage -Icon Success
}
return $restoreResult
}
catch {
$errorMessage = if ($_.Exception.Message) { $_.Exception.Message } else { 'An unexpected error occurred.' }
Write-Error "Restore operation failed: $errorMessage"
Show-MessageBox -Title 'Error' -Message "Restore failed: $errorMessage" -Icon Error
return [PSCustomObject]@{
RestoredRegistry = $false
RestoredStartMenu = $false
}
}
}

View File

@@ -17,9 +17,7 @@ param (
[switch]$RemoveApps,
[switch]$RemoveAppsCustom,
[switch]$RemoveGamingApps,
[switch]$RemoveCommApps,
[switch]$RemoveHPApps,
[switch]$RemoveW11Outlook,
[switch]$ForceRemoveEdge,
[switch]$DisableDVR,
[switch]$DisableGameBarIntegration,
@@ -58,7 +56,7 @@ param (
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
[switch]$HideTaskview,
[switch]$DisableStartRecommended,
[switch]$DisableStartAllApps,
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
[switch]$DisableStartPhoneLink,
[switch]$DisableCopilot,
[switch]$DisableRecall,
@@ -135,12 +133,12 @@ catch {
Exit
}
Write-Output ""
Write-Output "> Cleaning up old Win11Debloat folder..."
# Remove old script folder if it exists, but keep config and log files
# Remove old script folder if it exists, but keep configs, logs and backups
if (Test-Path $tempWorkPath) {
Get-ChildItem -Path $tempWorkPath -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Config,Logs,Backups | Remove-Item -Recurse -Force
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'
@@ -148,6 +146,9 @@ $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 = @(
@@ -178,6 +179,9 @@ if (Test-Path "$backupDir") {
New-Item -ItemType Directory -Path "$configDir" -Force | Out-Null
}
Write-Output ""
Write-Output "> Restoring existing config files..."
Get-ChildItem -Path "$backupDir" -Recurse | Move-Item -Destination "$configDir"
Remove-Item "$backupDir" -Recurse -Force
}
@@ -218,13 +222,13 @@ if ($null -ne $debloatProcess) {
$debloatProcess.WaitForExit()
}
# Remove all remaining script files, except for CustomAppsList and LastUsedSettings.json files
# 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 CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Win11Debloat-Run.log,Config,Logs,Backups | Remove-Item -Recurse -Force
Get-ChildItem -Path $tempWorkPath -Exclude Config,Logs,Backups | Remove-Item -Recurse -Force
}
Write-Output ""

View File

@@ -17,9 +17,7 @@ param (
[switch]$RemoveApps,
[switch]$RemoveAppsCustom,
[switch]$RemoveGamingApps,
[switch]$RemoveCommApps,
[switch]$RemoveHPApps,
[switch]$RemoveW11Outlook,
[switch]$ForceRemoveEdge,
[switch]$DisableDVR,
[switch]$DisableGameBarIntegration,
@@ -58,7 +56,7 @@ param (
[switch]$HideSearchTb, [switch]$ShowSearchIconTb, [switch]$ShowSearchLabelTb, [switch]$ShowSearchBoxTb,
[switch]$HideTaskview,
[switch]$DisableStartRecommended,
[switch]$DisableStartAllApps,
[switch]$DisableStartAllApps, [switch]$StartAllAppsCategory, [switch]$StartAllAppsGrid, [switch]$StartAllAppsList,
[switch]$DisableStartPhoneLink,
[switch]$DisableCopilot,
[switch]$DisableRecall,
@@ -174,7 +172,7 @@ Expand-Archive $tempArchivePath $tempWorkPath
Remove-Item $tempArchivePath
# Move files
Get-ChildItem -Path (Join-Path $tempWorkPath 'Raphire-Win11Debloat-*') -Recurse | Move-Item -Destination $tempWorkPath
Get-ChildItem -Path (Join-Path $tempWorkPath '*Win11Debloat-*') -Recurse | Move-Item -Destination $tempWorkPath
# Add existing config files back to Config folder
if (Test-Path "$backupDir") {

View File

@@ -129,10 +129,13 @@ function Convert-RegValueData {
}
if ($valueData -match '^"(?<value>.*)"$') {
$stringValue = $matches.value
# Unescape registry string escape sequences
$stringValue = $stringValue -replace '\\"', '"' -replace '\\\\', '\'
return [PSCustomObject]@{
OperationType = 'SetValue'
ValueType = 'String'
ValueData = $matches.value
ValueData = $stringValue
}
}

View File

@@ -70,14 +70,14 @@ function Get-RegistryRootKey {
function Get-RegistryFilePathForFeature {
param(
[Parameter(Mandatory)]
$Feature,
[string]$RegistryKey,
[switch]$UseSysprepRegFiles
)
$useSysprepLayout = $UseSysprepRegFiles -or $script:Params.ContainsKey('Sysprep') -or $script:Params.ContainsKey('User')
if ($useSysprepLayout) {
return Join-Path (Join-Path $script:RegfilesPath 'Sysprep') $Feature.RegistryKey
return Join-Path (Join-Path $script:RegfilesPath 'Sysprep') $RegistryKey
}
return Join-Path $script:RegfilesPath $Feature.RegistryKey
return Join-Path $script:RegfilesPath $RegistryKey
}