Add option to show & undo applied tweaks

This commit is contained in:
Jeffrey
2026-05-27 21:36:07 +02:00
parent 37872b2030
commit 4109588e0f
14 changed files with 702 additions and 89 deletions

View File

@@ -732,6 +732,8 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
@@ -798,7 +800,9 @@
</Popup>
</StackPanel>
<Border x:Name="TweakSearchBorder" Grid.Column="2">
<CheckBox x:Name="ShowCurrentlyAppliedTweaksCheckBox" Grid.Column="2" Content="Check applied tweaks" IsChecked="True" Foreground="{DynamicResource FgColor}" VerticalAlignment="Center" AutomationProperties.Name="Check applied tweaks" ToolTip="Check all tweaks currently active on this system. Unchecking clears all selections."/>
<Border x:Name="TweakSearchBorder" Grid.Column="4">
<Border.Style>
<Style TargetType="Border" BasedOn="{StaticResource SearchBoxBorderStyle}">
<Style.Triggers>

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,7 +1,6 @@
function Get-RegistryBackupCapturePlans {
param(
[Parameter(Mandatory)]
[object[]]$SelectedRegistryFeatures,
[object[]]$SelectedRegistryFeatures = @(),
[switch]$UseSysprepRegFiles
)
@@ -59,8 +58,7 @@ function Get-RegistryBackupCapturePlans {
function Get-RegistrySnapshotsForBackup {
param(
[Parameter(Mandatory)]
[object[]]$CapturePlans
[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,22 @@ 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)
}
}
$allCapturableFeatures = @($SelectedFeatures) + @($UndoFeatures)
$selectedRegistryFeatures = @(Get-RegistryBackedFeatures -Features $allCapturableFeatures)
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures $selectedRegistryFeatures)
$registryKeys = @(Get-RegistrySnapshotsForBackup -CapturePlans $capturePlans)
return @{
$backupPayload = @{
Version = '1.0'
BackupType = 'RegistryState'
CreatedAt = $CreatedAt.ToString('o')
@@ -85,4 +99,10 @@ function Get-RegistryBackupPayload {
SelectedFeatures = @($selectedFeatureIds)
RegistryKeys = @($registryKeys)
}
if ($selectedUndoFeatureIds.Count -gt 0) {
$backupPayload['SelectedUndoFeatures'] = @($selectedUndoFeatureIds)
}
return $backupPayload
}

View File

@@ -49,4 +49,126 @@ 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
try {
$acl = Get-Acl -Path $StoreAppsDatabase
$denyRules = @(
$acl.Access | Where-Object {
$_.IdentityReference -eq 'Everyone' -and
$_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0)
}
)
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
}
foreach ($accessRule in @($acl.Access)) {
if ($accessRule.IdentityReference -eq 'Everyone' -and
$accessRule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and
(($accessRule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0)) {
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

@@ -13,4 +13,37 @@ function EnableWindowsFeature {
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

@@ -1,3 +1,58 @@
# List of undo actions to execute after forward changes.
# Each entry is a PSCustomObject with FeatureId and UndoRegFile (filename, without folder prefix).
$script:UndoRegistryKeys = @()
# List of undo actions for features that do not use registry undo files.
# Each entry is a PSCustomObject with FeatureId.
$script:UndoFeatureActions = @()
# 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
)
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 "> Disabling Windows Sandbox..."
DisableWindowsFeature 'Containers-DisposableClientVM'
Write-Host ""
return
}
'EnableWindowsSubsystemForLinux' {
Write-Host "> Disabling Windows Subsystem for Linux..."
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
DisableWindowsFeature 'VirtualMachinePlatform'
Write-Host ""
return
}
}
}
# Executes a single parameter/feature based on its key
# Parameters:
# $paramKey - The parameter name to execute
@@ -162,8 +217,12 @@ function ExecuteAllChanges {
break
}
}
# Undo operations that write registry values also require a backup
if (-not $hasRegistryBackedFeature -and $script:UndoRegistryKeys.Count -gt 0) {
$hasRegistryBackedFeature = $true
}
$totalSteps = $actionableKeys.Count
$totalSteps = $actionableKeys.Count + $script:UndoRegistryKeys.Count + $script:UndoFeatureActions.Count
if ($hasRegistryBackedFeature) { $totalSteps++ }
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$currentStep = 0
@@ -176,7 +235,10 @@ function ExecuteAllChanges {
Write-Host "> Creating registry backup..."
try {
New-RegistrySettingsBackup -ActionableKeys $actionableKeys | Out-Null
$undoSyntheticFeatures = @($script:UndoRegistryKeys | ForEach-Object {
[PSCustomObject]@{ FeatureId = $_.FeatureId; RegistryKey = (Resolve-UndoRegFilePath $_.UndoRegFile) }
})
New-RegistrySettingsBackup -ActionableKeys $actionableKeys -ExtraFeatures $undoSyntheticFeatures | Out-Null
}
catch {
throw "Registry backup failed before applying changes. $($_.Exception.Message)"
@@ -222,6 +284,38 @@ function ExecuteAllChanges {
ExecuteParameter -paramKey $paramKey
}
# Execute all undo operations
foreach ($undoAction in $script:UndoRegistryKeys) {
if ($script:CancelRequested) { return }
$undoLabel = if ($script:FeatureLabelLookup) { $script:FeatureLabelLookup[$undoAction.FeatureId] } else { $null }
if (-not $undoLabel) { $undoLabel = $undoAction.FeatureId }
$currentStep++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $currentStep $totalSteps "Undoing: $undoLabel"
}
ImportRegistryFile "> Undoing: $undoLabel" (Resolve-UndoRegFilePath $undoAction.UndoRegFile)
}
foreach ($undoAction in $script:UndoFeatureActions) {
if ($script:CancelRequested) { return }
$undoLabel = if ($script:FeatureLabelLookup) { $script:FeatureLabelLookup[$undoAction.FeatureId] } else { $null }
if (-not $undoLabel) { $undoLabel = $undoAction.FeatureId }
$currentStep++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $currentStep $totalSteps "Undoing: $undoLabel"
}
Invoke-UndoFeatureAction -FeatureId $undoAction.FeatureId
}
$script:UndoRegistryKeys = @()
$script:UndoFeatureActions = @()
if ($script:RegistryImportFailures -gt 0) {
Write-Host ""
Write-Host "$($script:RegistryImportFailures) registry import change(s) failed. See output above for details." -ForegroundColor Yellow

View File

@@ -0,0 +1,139 @@
# 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 Test-FeatureApplied {
param (
[Parameter(Mandatory)]
[string]$FeatureId
)
if (-not $script:Features.ContainsKey($FeatureId)) { return $false }
$feature = $script:Features[$FeatureId]
switch ($FeatureId) {
'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)
$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

@@ -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()
}
}

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 @($allSelectedFeatures) -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

@@ -82,13 +82,21 @@ function Test-RestoreDialogFeatureVisibleInOverview {
function Get-SelectedFeatureIdsFromBackup {
param($SelectedBackup)
return @(
foreach ($featureId in @($SelectedBackup.SelectedFeatures)) {
if (-not [string]::IsNullOrWhiteSpace([string]$featureId)) {
[string]$featureId
}
$featureIds = New-Object System.Collections.Generic.List[string]
$seenIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($featureId in @($SelectedBackup.SelectedFeatures) + @($SelectedBackup.SelectedUndoFeatures)) {
if ([string]::IsNullOrWhiteSpace([string]$featureId)) {
continue
}
)
$normalizedId = [string]$featureId
if ($seenIds.Add($normalizedId)) {
$featureIds.Add($normalizedId)
}
}
return @($featureIds.ToArray())
}
function Get-RestoreBackupFeatureLists {

View File

@@ -905,6 +905,49 @@ function Show-MainWindow {
}
}
# Reads current registry state and sets each tweak control to reflect whether that tweak is
# currently applied. Also stores the initial state on each control as a NoteProperty so the
# apply handler can detect which controls actually changed.
function LoadCurrentTweakStateIntoUI {
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 {}
$control.IsChecked = $applied
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialState' -Value $applied -Force
Add-Member -InputObject $control -MemberType NoteProperty -Name 'SystemState' -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 {}
}
$control.SelectedIndex = $activeIndex
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialIndex' -Value $activeIndex -Force
Add-Member -InputObject $control -MemberType NoteProperty -Name 'SystemIndex' -Value $activeIndex -Force
}
}
}
# Helper function to load apps and populate the app list panel
function script:LoadAppsWithList($listOfApps) {
$script:MainWindowLastSelectedCheckbox = $null
@@ -1298,6 +1341,46 @@ function Show-MainWindow {
$col0 = $window.FindName('Column0Panel')
$col1 = $window.FindName('Column1Panel')
$col2 = $window.FindName('Column2Panel')
$ShowCurrentlyAppliedTweaksCheckBox = $window.FindName('ShowCurrentlyAppliedTweaksCheckBox')
# Loads the currently applied tweaks from registry state into UI controls.
# When checkbox is checked: sets controls to their currently applied state
# When checkbox is unchecked: clears all control selections
function ResetTweaksToSystemState {
param ([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 }
$control.IsChecked = $applied
Add-Member -InputObject $control -MemberType NoteProperty -Name 'InitialState' -Value $applied -Force
} else {
# Clear the checkbox
$control.IsChecked = $false
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 UpdateTweaksResponsiveColumns {
if (-not $tweaksGrid -or -not $col0 -or -not $col1 -or -not $col2) { return }
@@ -1440,6 +1523,12 @@ function Show-MainWindow {
}
})
# Only show changed settings checkbox
if ($ShowCurrentlyAppliedTweaksCheckBox) {
$ShowCurrentlyAppliedTweaksCheckBox.Add_Checked({ ResetTweaksToSystemState -loadSystemState $true })
$ShowCurrentlyAppliedTweaksCheckBox.Add_Unchecked({ ResetTweaksToSystemState -loadSystemState $false })
}
# Add Ctrl+F keyboard shortcut to focus search box on current tab
$window.Add_KeyDown({
param($sourceControl, $e)
@@ -1643,35 +1732,48 @@ function Show-MainWindow {
UpdateAppSelectionStatus
# Collect all ComboBox/CheckBox selections from dynamically created controls
# Collect only controls that changed from their initial (system) state
if ($script:UiControlMappings) {
foreach ($mappingKey in $script:UiControlMappings.Keys) {
$control = $window.FindName($mappingKey)
$isSelected = $false
# Check if it's a checkbox or combobox
if ($control -is [System.Windows.Controls.CheckBox]) {
$isSelected = $control.IsChecked -eq $true
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$isSelected = $control.SelectedIndex -gt 0
}
if ($control -and $isSelected) {
$mapping = $script:UiControlMappings[$mappingKey]
if ($mapping.Type -eq 'group') {
# For combobox: SelectedIndex 0 = No Change, so subtract 1 to index into Values
$selectedValue = $mapping.Values[$control.SelectedIndex - 1]
foreach ($fid in $selectedValue.FeatureIds) {
$label = $script:FeatureLabelLookup[$fid]
if ($label) { $changesList += $label }
}
}
elseif ($mapping.Type -eq 'feature') {
if (-not $control) { continue }
$mapping = $script:UiControlMappings[$mappingKey]
if ($control -is [System.Windows.Controls.CheckBox] -and $mapping.Type -eq 'feature') {
$wasApplied = if ($null -ne $control.PSObject.Properties['InitialState']) { [bool]$control.InitialState } else { $false }
$isNowChecked = $control.IsChecked -eq $true
if (-not $wasApplied -and $isNowChecked) {
$label = $script:FeatureLabelLookup[$mapping.FeatureId]
if (-not $label) { $label = $mapping.Label }
$changesList += $label
}
elseif ($wasApplied -and -not $isNowChecked) {
$label = $script:FeatureLabelLookup[$mapping.FeatureId]
if (-not $label) { $label = $mapping.Label }
$changesList += "Undo: $label"
}
}
elseif ($control -is [System.Windows.Controls.ComboBox] -and $mapping.Type -eq 'group') {
$wasIndex = if ($null -ne $control.PSObject.Properties['InitialIndex']) { [int]$control.InitialIndex } else { 0 }
$isNowIndex = $control.SelectedIndex
if ($wasIndex -ne $isNowIndex) {
if ($isNowIndex -gt 0 -and $isNowIndex -le $mapping.Values.Count) {
$selectedValue = $mapping.Values[$isNowIndex - 1]
foreach ($fid in $selectedValue.FeatureIds) {
$label = $script:FeatureLabelLookup[$fid]
if ($label) { $changesList += $label }
}
}
elseif ($isNowIndex -eq 0 -and $wasIndex -gt 0 -and $wasIndex -le $mapping.Values.Count) {
$prevValue = $mapping.Values[$wasIndex - 1]
foreach ($fid in $prevValue.FeatureIds) {
$label = $script:FeatureLabelLookup[$fid]
if ($label) { $changesList += "Undo: $label" }
}
}
}
}
}
}
@@ -1717,6 +1819,10 @@ function Show-MainWindow {
# Handle Home Default Mode button - apply defaults and navigate directly to overview
$homeDefaultModeBtn = $window.FindName('HomeDefaultModeBtn')
$homeDefaultModeBtn.Add_Click({
if ($ShowCurrentlyAppliedTweaksCheckBox) {
$ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false
}
# Load and apply default settings
$defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0"
if ($defaultsJson) {
@@ -1763,6 +1869,9 @@ function Show-MainWindow {
Hide-Bubble -Immediate
$showAppliedTweaksMode = ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true)
$selectedForwardFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
# App Removal - collect selected apps from integrated UI
$selectedApps = @()
foreach ($child in $appsPanel.Children) {
@@ -1771,6 +1880,7 @@ function Show-MainWindow {
}
}
$selectedApps = @($selectedApps | Where-Object { $_ } | Select-Object -Unique)
$hasAppSelection = ($selectedApps.Count -gt 0)
if ($selectedApps.Count -gt 0) {
# Check if Microsoft Store is selected
@@ -1803,56 +1913,85 @@ function Show-MainWindow {
}
}
# Apply dynamic tweaks selections
# Apply dynamic tweaks - only controls that changed from their initial (system) state
$script:UndoRegistryKeys = @()
$script:UndoFeatureActions = @()
if ($script:UiControlMappings) {
foreach ($mappingKey in $script:UiControlMappings.Keys) {
$control = $window.FindName($mappingKey)
$isSelected = $false
$selectedIndex = 0
# Check if it's a checkbox or combobox
if ($control -is [System.Windows.Controls.CheckBox]) {
$isSelected = $control.IsChecked -eq $true
$selectedIndex = if ($isSelected) { 1 } else { 0 }
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) {
# Forward: user is applying a tweak that isn't currently active
AddParameter $mapping.FeatureId
$null = $selectedForwardFeatureIds.Add([string]$mapping.FeatureId)
}
elseif ($wasApplied -and -not $isNowChecked) {
# Undo: tweak was active and user unchecked it
$feature = if ($script:Features.ContainsKey($mapping.FeatureId)) { $script:Features[$mapping.FeatureId] } else { $null }
if ($feature -and $feature.RegistryUndoKey) {
$script:UndoRegistryKeys += [PSCustomObject]@{ FeatureId = $mapping.FeatureId; UndoRegFile = $feature.RegistryUndoKey }
}
elseif ($mapping.FeatureId -in @('DisableStoreSearchSuggestions', 'EnableWindowsSandbox', 'EnableWindowsSubsystemForLinux')) {
$script:UndoFeatureActions += [PSCustomObject]@{ FeatureId = $mapping.FeatureId }
}
}
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$isSelected = $control.SelectedIndex -gt 0
$selectedIndex = $control.SelectedIndex
}
if ($control -and $isSelected) {
$mapping = $script:UiControlMappings[$mappingKey]
if ($mapping.Type -eq 'group') {
if ($selectedIndex -gt 0 -and $selectedIndex -le $mapping.Values.Count) {
$selectedValue = $mapping.Values[$selectedIndex - 1]
foreach ($fid in $selectedValue.FeatureIds) {
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 -ne $isNowIndex) {
if ($isNowIndex -gt 0 -and $isNowIndex -le $mapping.Values.Count) {
# Apply the newly selected group option
$selectedValue = $mapping.Values[$isNowIndex - 1]
foreach ($fid in $selectedValue.FeatureIds) {
AddParameter $fid
$null = $selectedForwardFeatureIds.Add([string]$fid)
}
}
elseif ($isNowIndex -eq 0 -and $wasIndex -gt 0 -and $wasIndex -le $mapping.Values.Count) {
# Revert to 'No Change' from a detected initial state - undo the previously active option
$prevValue = $mapping.Values[$wasIndex - 1]
foreach ($fid in $prevValue.FeatureIds) {
$feature = if ($script:Features.ContainsKey($fid)) { $script:Features[$fid] } else { $null }
if ($feature -and $feature.RegistryUndoKey) {
$script:UndoRegistryKeys += [PSCustomObject]@{ FeatureId = $fid; UndoRegFile = $feature.RegistryUndoKey }
}
elseif ($fid -in @('DisableStoreSearchSuggestions', 'EnableWindowsSandbox', 'EnableWindowsSubsystemForLinux')) {
$script:UndoFeatureActions += [PSCustomObject]@{ FeatureId = $fid }
}
}
}
}
elseif ($mapping.Type -eq 'feature') {
AddParameter $mapping.FeatureId
}
}
}
}
$controlParamsCount = 0
foreach ($Param in $script:ControlParams) {
if ($script:Params.ContainsKey($Param)) {
$controlParamsCount++
}
}
# Check if any changes were selected
$totalChanges = $script:Params.Count - $controlParamsCount
# Apps parameter does not count as a change itself
if ($script:Params.ContainsKey('Apps')) {
$totalChanges = $totalChanges - 1
}
if ($totalChanges -eq 0) {
if (-not $hasAppSelection -and $selectedForwardFeatureIds.Count -eq 0 -and $script:UndoRegistryKeys.Count -eq 0 -and $script:UndoFeatureActions.Count -eq 0) {
Show-MessageBox -Message 'No changes have been selected, please select at least one option to proceed.' -Title 'No Changes Selected' -Button 'OK' -Icon 'Information'
return
}
@@ -1894,6 +2033,7 @@ function Show-MainWindow {
# Initialize UI elements on window load
$window.Add_Loaded({
BuildDynamicTweaks
LoadCurrentTweakStateIntoUI
UpdateTweaksResponsiveColumns
RefreshTweakPresetSources -defaultSettingsJson $defaultsJson -lastUsedSettingsJson $lastUsedSettingsJson
RegisterTweakPresetControlStateHandlers
@@ -2224,6 +2364,10 @@ function Show-MainWindow {
# Clear All Tweaks button
$clearAllTweaksBtn = $window.FindName('ClearAllTweaksBtn')
$clearAllTweaksBtn.Add_Click({
if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) {
# Keep the toggle state aligned with the cleared UI selection state.
$ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false
}
ClearTweakSelections
UpdateTweakPresetStates
})

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

@@ -293,6 +293,7 @@ if (-not $script:WingetInstalled -and -not $Silent) {
. "$PSScriptRoot/Scripts/CLI/PrintHeader.ps1"
# Features functions
. "$PSScriptRoot/Scripts/Features/GetCurrentTweakState.ps1"
. "$PSScriptRoot/Scripts/Features/ExecuteChanges.ps1"
. "$PSScriptRoot/Scripts/Features/CreateSystemRestorePoint.ps1"
. "$PSScriptRoot/Scripts/Features/BackupRegistryFeatureSelection.ps1"