diff --git a/Schemas/MainWindow.xaml b/Schemas/MainWindow.xaml index 5c93777..6d2d86c 100644 --- a/Schemas/MainWindow.xaml +++ b/Schemas/MainWindow.xaml @@ -547,14 +547,14 @@ diff --git a/Scripts/Features/DisableStoreSearchSuggestions.ps1 b/Scripts/Features/DisableStoreSearchSuggestions.ps1 index 13006b3..adb1780 100644 --- a/Scripts/Features/DisableStoreSearchSuggestions.ps1 +++ b/Scripts/Features/DisableStoreSearchSuggestions.ps1 @@ -140,9 +140,16 @@ function Test-StoreSearchSuggestionsDisabled { $everyoneSid = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') foreach ($accessRule in @($acl.Access)) { - if ($accessRule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny -and - (($accessRule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne 0) -and - (try { $accessRule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $everyoneSid } catch { $false })) { + $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 } } diff --git a/Scripts/GUI/MainWindow-AppSelection.ps1 b/Scripts/GUI/MainWindow-AppSelection.ps1 new file mode 100644 index 0000000..a26ea55 --- /dev/null +++ b/Scripts/GUI/MainWindow-AppSelection.ps1 @@ -0,0 +1,536 @@ +# 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) { + $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 Apply button now that the full, correctly-checked app list is ready + $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 Apply button while apps are loading so it can't be clicked with a partial list + $Window.FindName('DeploymentApplyBtn').IsEnabled = $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' + $Window.FindName('DeploymentApplyBtn').IsEnabled = $true + if ($ImportConfigBtn) { $ImportConfigBtn.IsEnabled = $true } + } + finally { + $script:IsLoadingApps = $false + } + }) | Out-Null +} diff --git a/Scripts/GUI/MainWindow-Deployment.ps1 b/Scripts/GUI/MainWindow-Deployment.ps1 new file mode 100644 index 0000000..3c26c29 --- /dev/null +++ b/Scripts/GUI/MainWindow-Deployment.ps1 @@ -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 +} diff --git a/Scripts/GUI/MainWindow-Navigation.ps1 b/Scripts/GUI/MainWindow-Navigation.ps1 new file mode 100644 index 0000000..29bcd8b --- /dev/null +++ b/Scripts/GUI/MainWindow-Navigation.ps1 @@ -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'] + } +} diff --git a/Scripts/GUI/MainWindow-TweaksBuilder.ps1 b/Scripts/GUI/MainWindow-TweaksBuilder.ps1 new file mode 100644 index 0000000..62a904d --- /dev/null +++ b/Scripts/GUI/MainWindow-TweaksBuilder.ps1 @@ -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.,  -> 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 = '' } } else { $c } + $orderedCategories += $categoryObj + } + } + } + else { + # For backward compatibility, create category objects from keys + foreach ($catName in $categoriesPresent.Keys) { + $orderedCategories += @{Name = $catName; Icon = '' } + } + } + + 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 +} diff --git a/Scripts/GUI/MainWindow-WindowChrome.ps1 b/Scripts/GUI/MainWindow-WindowChrome.ps1 new file mode 100644 index 0000000..810fb71 --- /dev/null +++ b/Scripts/GUI/MainWindow-WindowChrome.ps1 @@ -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) +} diff --git a/Scripts/GUI/Show-MainWindow.ps1 b/Scripts/GUI/Show-MainWindow.ps1 index 8571434..e18a664 100644 --- a/Scripts/GUI/Show-MainWindow.ps1 +++ b/Scripts/GUI/Show-MainWindow.ps1 @@ -1,8 +1,7 @@ -function Show-MainWindow { +function Show-MainWindow { Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null - # Helper to constrain the maximized window to the monitor working area (respects taskbar). - # Required for WindowStyle=None windows — without this the window extends behind the taskbar. + # ---- Constrain maximized window to taskbar work area ---- if (-not ([System.Management.Automation.PSTypeName]'Win11Debloat.MaximizedWindowHelper').Type) { Add-Type -Namespace Win11Debloat -Name MaximizedWindowHelper ` -ReferencedAssemblies 'PresentationFramework','System.Windows.Forms','System.Drawing' ` @@ -67,12 +66,10 @@ function Show-MainWindow { '@ } - # Get current Windows build version $WinVersion = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' CurrentBuild - $usesDarkMode = GetSystemUsesDarkMode - # Load XAML from file + # ---- Load XAML ---- $xaml = Get-Content -Path $script:MainWindowSchema -Raw $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { @@ -84,7 +81,6 @@ function Show-MainWindow { SetWindowThemeResources -window $window -usesDarkMode $usesDarkMode - # Get named elements $mainBorder = $window.FindName('MainBorder') $titleBarBackground = $window.FindName('TitleBarBackground') $kofiBtn = $window.FindName('KofiBtn') @@ -97,88 +93,37 @@ function Show-MainWindow { $importConfigBtn = $window.FindName('ImportConfigBtn') $exportConfigBtn = $window.FindName('ExportConfigBtn') $restoreBackupBtn = $window.FindName('RestoreBackupBtn') + $homeContentPanel = $window.FindName('HomeContentPanel') + $contentGrid = $window.FindName('ContentGrid') + $maxContentWidth = 1600.0 $windowStateNormal = [System.Windows.WindowState]::Normal $windowStateMaximized = [System.Windows.WindowState]::Maximized $normalWindowShadow = $mainBorder.Effect $initialNormalMaxWidth = 1400.0 - $convertScreenPointToDip = { - param( - [double]$x, - [double]$y - ) + $script:MainWindow = $window + $script:GuiWindow = $window - $source = [System.Windows.PresentationSource]::FromVisual($window) - if ($null -eq $source -or $null -eq $source.CompositionTarget) { - return [System.Windows.Point]::new($x, $y) - } + # ---- Handle unhandled exceptions on the dispatcher thread ---- + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Add_UnhandledException({ + param($sender, $e) + Write-Warning "Unhandled exception in GUI: $($e.Exception.Message)" + Write-Warning "Stack trace: $($e.Exception.StackTrace)" + $e.Handled = $true + }) - return $source.CompositionTarget.TransformFromDevice.Transform([System.Windows.Point]::new($x, $y)) - } - - $convertScreenPixelsToDip = { - param( - [double]$width, - [double]$height - ) - - $topLeft = & $convertScreenPointToDip 0 0 - $bottomRight = & $convertScreenPointToDip $width $height - return [System.Windows.Size]::new($bottomRight.X - $topLeft.X, $bottomRight.Y - $topLeft.Y) - } - - $getWindowScreen = { - $hwnd = (New-Object System.Windows.Interop.WindowInteropHelper($window)).Handle - if ($hwnd -eq [IntPtr]::Zero) { - return $null - } - - return [System.Windows.Forms.Screen]::FromHandle($hwnd) - } - - $updateWindowChrome = { - $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) } - } - } - - $applyInitialWindowSize = { - if ($window.WindowState -ne $windowStateNormal) { - return - } - - $screen = & $getWindowScreen - if ($null -eq $screen) { - return - } - - $workingAreaTopLeftDip = & $convertScreenPointToDip $screen.WorkingArea.Left $screen.WorkingArea.Top - $workingAreaDip = & $convertScreenPixelsToDip $screen.WorkingArea.Width $screen.WorkingArea.Height - $window.Width = [Math]::Min($initialNormalMaxWidth, $workingAreaDip.Width) - $window.Left = $workingAreaTopLeftDip.X + (($workingAreaDip.Width - $window.Width) / 2) - } + # ---- Window chrome helpers ---- + $updateWindowChrome = { Update-MainWindowChrome -Window $window -MainBorder $mainBorder -TitleBarBackground $titleBarBackground -NormalWindowShadow $normalWindowShadow } + $applyInitialWindowSize = { Set-MainWindowInitialSize -Window $window -InitialNormalMaxWidth $initialNormalMaxWidth } + $updateContentMargin = { Update-MainWindowContentMargin -Window $window -ContentGrid $contentGrid -MaxContentWidth $maxContentWidth } + $updateHomeContentPosition = { Update-MainWindowHomeContentPosition -Window $window -HomeContentPanel $homeContentPanel } + # ---- Window chrome event wiring ---- $window.Add_SourceInitialized({ & $applyInitialWindowSize & $updateWindowChrome - # Register WM_GETMINMAXINFO hook so maximizing respects the working area (taskbar) $hwndHelper = New-Object System.Windows.Interop.WindowInteropHelper($window) $hwndSource = [System.Windows.Interop.HwndSource]::FromHwnd($hwndHelper.Handle) $hookMethod = [Win11Debloat.MaximizedWindowHelper].GetMethod('WmGetMinMaxInfoHook') @@ -186,66 +131,32 @@ function Show-MainWindow { $hwndSource.AddHook($hook) }) - $contentGrid = $window.FindName('ContentGrid') - $maxContentWidth = 1600.0 - - $homeContentPanel = $window.FindName('HomeContentPanel') - - $updateContentMargin = { - $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) - } - } - - $updateHomeContentPosition = { - 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) - } - } - } - $window.Add_SizeChanged({ & $updateContentMargin & $updateHomeContentPosition - UpdateTweaksResponsiveColumns + Update-TweaksResponsiveColumns -Window $window }) - $window.Add_StateChanged({ - & $updateWindowChrome - }) + $window.Add_StateChanged({ & $updateWindowChrome }) $window.Add_LocationChanged({ - # Nudge the popup offset to force WPF to recalculate its screen position if ($script:BubblePopup -and $script:BubblePopup.IsOpen) { $script:BubblePopup.HorizontalOffset += 1 $script:BubblePopup.HorizontalOffset -= 1 } }) - - $kofiBtn.Add_Click({ - Start-Process "https://ko-fi.com/raphire" - }) - + + # ---- Menu/button event wiring ---- + $kofiBtn.Add_Click({ Start-Process "https://ko-fi.com/raphire" }) + $menuBtn.Add_Click({ $menuBtn.ContextMenu.PlacementTarget = $menuBtn $menuBtn.ContextMenu.Placement = [System.Windows.Controls.Primitives.PlacementMode]::Bottom $menuBtn.ContextMenu.IsOpen = $true }) - $menuDocumentation.Add_Click({ - Start-Process "https://github.com/Raphire/Win11Debloat/wiki" - }) - - $menuReportBug.Add_Click({ - Start-Process "https://github.com/Raphire/Win11Debloat/issues" - }) + $menuDocumentation.Add_Click({ Start-Process "https://github.com/Raphire/Win11Debloat/wiki" }) + $menuReportBug.Add_Click({ Start-Process "https://github.com/Raphire/Win11Debloat/issues" }) $menuLogs.Add_Click({ $logsFolder = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'Logs' @@ -257,67 +168,12 @@ function Show-MainWindow { } }) - $menuAbout.Add_Click({ - Show-AboutDialog -Owner $window - }) + $menuAbout.Add_Click({ Show-AboutDialog -Owner $window }) - $exportConfigBtn.Add_Click({ - try { - Export-Configuration -Owner $window -UsesDarkMode $usesDarkMode -AppsPanel $appsPanel -UiControlMappings $script:UiControlMappings -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox - } - catch { - Write-Warning "Export configuration failed: $($_.Exception.Message)" - Show-MessageBox -Owner $window -Message "Unable to open export configuration dialog: $($_.Exception.Message)" -Title 'Export Configuration Failed' -Button 'OK' -Icon 'Error' | Out-Null - } - }) + $closeBtn.Add_Click({ $window.Close() }) + $window.Add_Closing({ $script:CancelRequested = $true }) - $importConfigBtn.Add_Click({ - try { - Import-Configuration -Owner $window -UsesDarkMode $usesDarkMode -AppsPanel $appsPanel -UiControlMappings $script:UiControlMappings -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -OnAppsImported { UpdateAppSelectionStatus; UpdatePresetStates } -OnImportCompleted { - $tabControl.SelectedIndex = 3 - UpdateNavigationButtons - - $window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Loaded, [action]{ - Show-Bubble -TargetControl $reviewChangesBtn -Message 'View the selected changes here' - }) | Out-Null - } - } - catch { - Write-Warning "Import configuration failed: $($_.Exception.Message)" - Show-MessageBox -Owner $window -Message "Unable to open import configuration dialog: $($_.Exception.Message)" -Title 'Import Configuration Failed' -Button 'OK' -Icon 'Error' | Out-Null - } - }) - - if ($restoreBackupBtn) { - $restoreBackupBtn.Add_Click({ - try { - $restoreResult = Show-RestoreBackupWindow -Owner $window - if ($restoreResult -and $restoreResult.RestoredRegistry -eq $true) { - RefreshCurrentTweakSystemState -ApplyToUi:$false - - if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { - ResetTweaksToSystemState -loadSystemState $true - UpdateTweakPresetStates - } - } - } - catch { - Write-Warning "Restore backup action failed: $($_.Exception.Message)" - Show-MessageBox -Owner $window -Message "Unable to open restore backup dialog: $($_.Exception.Message)" -Title 'Restore Backup Failed' -Button 'OK' -Icon 'Error' | Out-Null - } - }) - } - - $closeBtn.Add_Click({ - $window.Close() - }) - - # Ensure closing the main window stops all execution - $window.Add_Closing({ - $script:CancelRequested = $true - }) - - # Integrated App Selection UI + # ---- App Selection panel elements ---- $appsPanel = $window.FindName('AppSelectionPanel') $onlyInstalledAppsBox = $window.FindName('OnlyInstalledAppsBox') $loadingAppsIndicator = $window.FindName('LoadingAppsIndicator') @@ -343,96 +199,101 @@ function Show-MainWindow { $presetAITweaksBtn = $window.FindName('PresetAITweaksBtn') $tweaksPresetsArrow = $window.FindName('TweaksPresetsArrow') - function AttachTriStateClickBehavior { - param([System.Windows.Controls.CheckBox]$checkBox) + # ---- Navigation elements ---- + $tabControl = $window.FindName('MainTabControl') + $previousBtn = $window.FindName('PreviousBtn') + $nextBtn = $window.FindName('NextBtn') + $userSelectionCombo = $window.FindName('UserSelectionCombo') + $userSelectionDescription = $window.FindName('UserSelectionDescription') + $otherUserPanel = $window.FindName('OtherUserPanel') + $otherUsernameTextBox = $window.FindName('OtherUsernameTextBox') + $usernameTextBoxPlaceholder = $window.FindName('UsernameTextBoxPlaceholder') + $usernameValidationMessage = $window.FindName('UsernameValidationMessage') + $appRemovalScopeCombo = $window.FindName('AppRemovalScopeCombo') + $appRemovalScopeDescription = $window.FindName('AppRemovalScopeDescription') + $appRemovalScopeSection = $window.FindName('AppRemovalScopeSection') + $appRemovalScopeCurrentUser = $window.FindName('AppRemovalScopeCurrentUser') + $appRemovalScopeTargetUser = $window.FindName('AppRemovalScopeTargetUser') - if (-not $checkBox -or -not $checkBox.IsThreeState) { return } + # ---- Tweak search elements ---- + $tweakSearchBox = $window.FindName('TweakSearchBox') + $tweakSearchPlaceholder = $window.FindName('TweakSearchPlaceholder') + $tweakSearchBorder = $window.FindName('TweakSearchBorder') + $tweaksScrollViewer = $window.FindName('TweaksScrollViewer') + $tweaksGrid = $window.FindName('TweaksGrid') + $ShowCurrentlyAppliedTweaksCheckBox = $window.FindName('ShowCurrentlyAppliedTweaksCheckBox') + $clearAllTweaksBtn = $window.FindName('ClearAllTweaksBtn') - if (-not $checkBox.PSObject.Properties['WasIndeterminateBeforeClick']) { - Add-Member -InputObject $checkBox -MemberType NoteProperty -Name 'WasIndeterminateBeforeClick' -Value $false + # ---- Deployment elements ---- + $reviewChangesBtn = $window.FindName('ReviewChangesBtn') + $deploymentApplyBtn = $window.FindName('DeploymentApplyBtn') + $homeStartBtn = $window.FindName('HomeStartBtn') + $homeDefaultModeBtn = $window.FindName('HomeDefaultModeBtn') + + # ---- Wire export/import ---- + $exportConfigBtn.Add_Click({ + try { + Export-Configuration -Owner $window -UsesDarkMode $usesDarkMode -AppsPanel $appsPanel -UiControlMappings $script:UiControlMappings -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox } + catch { + Write-Warning "Export configuration failed: $($_.Exception.Message)" + Show-MessageBox -Owner $window -Message "Unable to open export configuration dialog: $($_.Exception.Message)" -Title 'Export Configuration Failed' -Button 'OK' -Icon 'Error' | Out-Null + } + }) - $checkBox.Add_PreviewMouseLeftButtonDown({ - $this.WasIndeterminateBeforeClick = ($this.IsChecked -eq [System.Nullable[bool]]$null) + $importConfigBtn.Add_Click({ + try { + Import-Configuration -Owner $window -UsesDarkMode $usesDarkMode -AppsPanel $appsPanel -UiControlMappings $script:UiControlMappings -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -OnAppsImported { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo; Update-AppPresetStates -AppsPanel $appsPanel } -OnImportCompleted { + $tabControl.SelectedIndex = 3 + Update-NavigationButtons -Window $window -TabControl $tabControl + $window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Loaded, [action]{ + Show-Bubble -TargetControl $reviewChangesBtn -Message 'View the selected changes here' + }) | Out-Null + } + } + catch { + Write-Warning "Import configuration failed: $($_.Exception.Message)" + Show-MessageBox -Owner $window -Message "Unable to open import configuration dialog: $($_.Exception.Message)" -Title 'Import Configuration Failed' -Button 'OK' -Icon 'Error' | Out-Null + } + }) + + # ---- Restore backup ---- + if ($restoreBackupBtn) { + $restoreBackupBtn.Add_Click({ + try { + $restoreResult = Show-RestoreBackupWindow -Owner $window + if ($restoreResult -and $restoreResult.RestoredRegistry -eq $true) { + Update-CurrentTweakSystemState -Window $window -ApplyToUi:$false + + if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { + Reset-TweaksToSystemState -Window $window -LoadSystemState $true + Update-TweakPresetStates -Window $window + } + } + } + catch { + Write-Warning "Restore backup action failed: $($_.Exception.Message)" + Show-MessageBox -Owner $window -Message "Unable to open restore backup dialog: $($_.Exception.Message)" -Title 'Restore Backup Failed' -Button 'OK' -Icon 'Error' | Out-Null + } }) } - function NormalizeCheckboxState { - 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 SetTriStatePresetCheckBoxState { - 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 ClearTweakSelections { - 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 AnimateDropdownArrow { - 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) - } - - # Load JSON-defined presets and build dynamic preset checkboxes + # ---- Script-level state initialization ---- + $script:MainWindowLastSelectedCheckbox = $null + $script:IsLoadingApps = $false + $script:PendingDefaultMode = $false + $script:PreloadedAppData = $null + $script:UpdatingPresets = $false + $script:UpdatingTweakPresets = $false + $script:SortColumn = 'Name' + $script:SortAscending = $true + $script:AppSearchMatches = @() + $script:AppSearchMatchIndex = -1 $script:JsonPresetCheckboxes = @() + + if ($importConfigBtn) { $importConfigBtn.IsEnabled = $false } + + # ---- Build JSON-defined app presets ---- foreach ($preset in (LoadAppPresetsFromJson)) { $checkbox = New-Object System.Windows.Controls.CheckBox $checkbox.Content = $preset.Name @@ -440,7 +301,7 @@ function Show-MainWindow { $checkbox.Style = $window.Resources['PresetCheckBoxStyle'] $checkbox.ToolTip = "Select $($preset.Name)" $checkbox.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $preset.Name) - AttachTriStateClickBehavior -checkBox $checkbox + Add-TriStateClickBehavior -CheckBox $checkbox Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'PresetAppIds' -Value $preset.AppIds $jsonPresetsPanel.Children.Add($checkbox) | Out-Null $script:JsonPresetCheckboxes += $checkbox @@ -448,754 +309,92 @@ function Show-MainWindow { $checkbox.Add_Click({ if ($script:UpdatingPresets) { return } $presetIds = $this.PresetAppIds - $check = NormalizeCheckboxState -checkBox $this - ApplyPresetToApps -MatchFilter { param($c) (@($c.AppIds) | Where-Object { $presetIds -contains $_ }).Count -gt 0 }.GetNewClosure() -Check $check + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-AppPreset -AppsPanel $appsPanel -MatchFilter { param($c) (@($c.AppIds) | Where-Object { $presetIds -contains $_ }).Count -gt 0 }.GetNewClosure() -Check $check }) } - - # Track the last selected checkbox for shift-click range selection - $script:MainWindowLastSelectedCheckbox = $null - - # Guard flag: true while a load is in progress; prevents concurrent loads - $script:IsLoadingApps = $false - # Flag set when Default Mode is clicked before apps have finished loading - $script:PendingDefaultMode = $false - # Holds apps data preloaded before ShowDialog() so the first load skips the background job - $script:PreloadedAppData = $null - # Prevent app import until the apps list has finished initial population. - if ($importConfigBtn) { - $importConfigBtn.IsEnabled = $false - } - - # Set script-level variable for GUI window reference - $script:GuiWindow = $window - - # Guard flag to prevent preset handlers from firing when we update their state programmatically - $script:UpdatingPresets = $false - $script:UpdatingTweakPresets = $false - - # Sort state for the app table - $script:SortColumn = 'Name' - $script:SortAscending = $true - - function UpdateSortArrows { - $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) - } + # ---- App sort helpers ---- + $updateSortArrows = { + Update-SortArrows ` + -SortArrowName $sortArrowName -SortArrowDescription $sortArrowDescription -SortArrowAppId $sortArrowAppId } - # Rebuilds $script:AppSearchMatches by scanning appsPanel children in their current order, - # collecting any that are still highlighted. Preserves the active match across reorderings. - function RebuildAppSearchIndex { + $rebuildAppSearchIndex = { param($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 } + Update-AppsPanelRebuildSearchIndex -AppsPanel $appsPanel -ActiveMatch $activeMatch } - function SortApps { - $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 - } - UpdateSortArrows - - # Rebuild search match list in new sorted order so keyboard navigation stays correct + $sortApps = { + Update-AppsPanelSort -AppsPanel $appsPanel ` + -SortArrowName $sortArrowName -SortArrowDescription $sortArrowDescription -SortArrowAppId $sortArrowAppId 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 } - RebuildAppSearchIndex -activeMatch $activeMatch + & $rebuildAppSearchIndex -activeMatch $activeMatch } } - function SetSortColumn($column) { + $setSortColumn = { + param($column) if ($script:SortColumn -eq $column) { $script:SortAscending = -not $script:SortAscending - } else { + } + else { $script:SortColumn = $column $script:SortAscending = $true } - SortApps + & $sortApps } - function UpdateAppSelectionStatus { - $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 - } - UpdateAppRemovalScopeDescription - } - else { - $appRemovalScopeSection.Visibility = 'Collapsed' - } - } + # ---- Tri-state preset wiring for app presets ---- + foreach ($presetCheckBox in @($presetDefaultApps, $presetLastUsed)) { + Add-TriStateClickBehavior -CheckBox $presetCheckBox } - # Applies a preset by checking/unchecking apps that match the given filter - # When -Exclusive is set, all apps are unchecked first so only matching apps end up selected - function ApplyPresetToApps { - param ( - [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 - } - } - } - UpdatePresetStates - } - - # Update preset checkboxes to reflect checked/indeterminate/unchecked state - function UpdatePresetStates { - $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++ } - } - } - } - SetTriStatePresetCheckBoxState -CheckBox $checkbox -Total $total -Selected $checked - } - - 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 - } - } - - # Dynamically builds Tweaks UI from Features.json - function BuildDynamicTweaks { - $featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0" - - if (-not $featuresJson) { - Show-MessageBox -Message "Unable to load Features.json file!" -Title "Error" -Button 'OK' -Icon 'Error' | Out-Null - Exit - } - - # 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.,  -> 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 = ''} } else { $c } - $orderedCategories += $categoryObj - } - } - } else { - # For backward compatibility, create category objects from keys - foreach ($catName in $categoriesPresent.Keys) { - $orderedCategories += @{Name = $catName; Icon = ''} - } - } - - 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 RefreshCurrentTweakSystemState { - param([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 - } - } - } - } - - # 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 { - RefreshCurrentTweakSystemState -ApplyToUi:$true - } - - # Helper function to load apps and populate the app list panel - function script:LoadAppsWithList($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) { - $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({ UpdateAppSelectionStatus }) - $checkbox.Add_Unchecked({ UpdateAppSelectionStatus }) - AttachShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel ` - -lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) ` - -updateStatusCallback { UpdateAppSelectionStatus } - - $appsPanel.Children.Add($checkbox) | Out-Null - - if (($i + 1) % $batchSize -eq 0) { DoEvents } - } - - SortApps - - # If Default Mode was clicked while apps were still loading, apply defaults now - if ($script:PendingDefaultMode) { - $script:PendingDefaultMode = $false - ApplyPresetToApps -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Exclusive - } - - UpdateAppSelectionStatus - - # Re-enable Apply button now that the full, correctly-checked app list is ready - $window.FindName('DeploymentApplyBtn').IsEnabled = $true - if ($importConfigBtn) { - $importConfigBtn.IsEnabled = $true - } - } - - # Loads apps into the UI - function LoadAppsIntoMainUI { - # 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 Apply button while apps are loading so it can't be clicked with a partial list - $window.FindName('DeploymentApplyBtn').IsEnabled = $false - - # Update navigation buttons to disable Next/Previous - UpdateNavigationButtons - - # 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 - } - } - - LoadAppsWithList $listOfApps - } - finally { - $script:IsLoadingApps = $false - } - }) | Out-Null - } - - # Event handlers for app selection - $onlyInstalledAppsBox.Add_Checked({ - LoadAppsIntoMainUI - }) - $onlyInstalledAppsBox.Add_Unchecked({ - LoadAppsIntoMainUI + # ---- Preset: Default selection ---- + $presetDefaultApps.Add_Click({ + if ($script:UpdatingPresets) { return } + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-AppPreset -AppsPanel $appsPanel -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Check $check }) - # Animate arrow when popup opens/closes, and lazily update preset states + # ---- Clear selection button ---- + $clearAppSelectionBtn.Add_Click({ + Invoke-AppPreset -AppsPanel $appsPanel -MatchFilter { param($c) $true } -Check $false + }) + + # ---- Column header sort handlers ---- + $headerNameBtn.Add_MouseLeftButtonUp({ & $setSortColumn 'Name' }) + $headerDescriptionBtn.Add_MouseLeftButtonUp({ & $setSortColumn 'Description' }) + $headerAppIdBtn.Add_MouseLeftButtonUp({ & $setSortColumn 'AppId' }) + + # ---- Load apps ---- + $appLoadStatusCallback = { Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo } + $onlyInstalledAppsBox.Add_Checked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn }) + $onlyInstalledAppsBox.Add_Unchecked({ Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn }) + + # ---- App presets popup ---- $presetsPopup.Add_Opened({ - UpdatePresetStates - AnimateDropdownArrow -arrow $presetsArrow -angle 180 + Update-AppPresetStates -AppsPanel $appsPanel + Start-DropdownArrowAnimation -Arrow $presetsArrow -Angle 180 }) $presetsPopup.Add_Closed({ - AnimateDropdownArrow -arrow $presetsArrow -angle 0 + Start-DropdownArrowAnimation -Arrow $presetsArrow -Angle 0 $presetsBtn.IsChecked = $false }) $tweaksPresetsPopup.Add_Opened({ - UpdateTweakPresetStates - AnimateDropdownArrow -arrow $tweaksPresetsArrow -angle 180 + Update-TweakPresetStates -Window $window + Start-DropdownArrowAnimation -Arrow $tweaksPresetsArrow -Angle 180 }) $tweaksPresetsPopup.Add_Closed({ - AnimateDropdownArrow -arrow $tweaksPresetsArrow -angle 0 + Start-DropdownArrowAnimation -Arrow $tweaksPresetsArrow -Angle 0 $tweaksPresetsBtn.IsChecked = $false }) - # Close popup when clicking anywhere outside the popup or the presets button. + # ---- Popup dismiss on outside click ---- $window.Add_PreviewMouseDown({ $isAppPopupOpen = $presetsPopup.IsOpen $isTweaksPopupOpen = $tweaksPresetsPopup.IsOpen @@ -1214,13 +413,12 @@ function Show-MainWindow { } }) - # Close the preset menu when the main window loses focus (e.g., user switches to another app). $window.Add_Deactivated({ if ($presetsPopup.IsOpen) { $presetsPopup.IsOpen = $false } if ($tweaksPresetsPopup.IsOpen) { $tweaksPresetsPopup.IsOpen = $false } }) - # Toggle popup on button click + # ---- Toggle popup on button click ---- $presetsBtn.Add_Click({ $presetsPopup.IsOpen = -not $presetsPopup.IsOpen $presetsBtn.IsChecked = $presetsPopup.IsOpen @@ -1231,113 +429,28 @@ function Show-MainWindow { $tweaksPresetsBtn.IsChecked = $tweaksPresetsPopup.IsOpen }) - foreach ($presetCheckBox in @( - $presetDefaultApps, - $presetLastUsed, - $presetDefaultTweaksBtn, - $presetLastUsedTweaksBtn, - $presetPrivacyTweaksBtn, - $presetAITweaksBtn - )) { - AttachTriStateClickBehavior -checkBox $presetCheckBox - } - - # Preset: Default selection - $presetDefaultApps.Add_Click({ - if ($script:UpdatingPresets) { return } - $check = NormalizeCheckboxState -checkBox $this - ApplyPresetToApps -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Check $check - }) - - # Clear selection button + reset all preset checkboxes - $clearAppSelectionBtn.Add_Click({ - ApplyPresetToApps -MatchFilter { param($c) $true } -Check $false - }) - - # Column header sort handlers - $headerNameBtn.Add_MouseLeftButtonUp({ SetSortColumn 'Name' }) - $headerDescriptionBtn.Add_MouseLeftButtonUp({ SetSortColumn 'Description' }) - $headerAppIdBtn.Add_MouseLeftButtonUp({ SetSortColumn 'AppId' }) - - # Helper function to scroll to an item if it's not visible, centering it in the viewport - function ScrollToItemIfNotVisible { - 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() - } - } - - # Helper function to find the parent ScrollViewer of an element - function FindParentScrollViewer { - 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 - } - - # App Search Box functionality + # ---- App Search Box ---- $appSearchBox = $window.FindName('AppSearchBox') $appSearchPlaceholder = $window.FindName('AppSearchPlaceholder') - - # Track current search matches and active index for Enter-key navigation - $script:AppSearchMatches = @() - $script:AppSearchMatchIndex = -1 - + $appSearchBox.Add_TextChanged({ $searchText = $appSearchBox.Text.ToLower().Trim() - - # Show/hide placeholder $appSearchPlaceholder.Visibility = if ([string]::IsNullOrWhiteSpace($appSearchBox.Text)) { 'Visible' } else { 'Collapsed' } - - # Clear all highlights first + foreach ($child in $appsPanel.Children) { if ($child -is [System.Windows.Controls.CheckBox]) { $child.Background = [System.Windows.Media.Brushes]::Transparent } } - + $script:AppSearchMatches = @() $script:AppSearchMatchIndex = -1 - + if ([string]::IsNullOrWhiteSpace($searchText)) { return } - - # Find and highlight all matching apps + $highlightBrush = $window.Resources["SearchHighlightColor"] $activeHighlightBrush = $window.Resources["SearchHighlightActiveColor"] - + foreach ($child in $appsPanel.Children) { if ($child -is [System.Windows.Controls.CheckBox] -and $child.Visibility -eq 'Visible') { $appName = if ($child.AppName) { $child.AppName } else { '' } @@ -1349,206 +462,56 @@ function Show-MainWindow { } } } - - # Scroll to first match and mark it as active + if ($script:AppSearchMatches.Count -gt 0) { $script:AppSearchMatchIndex = 0 $script:AppSearchMatches[0].Background = $activeHighlightBrush - $scrollViewer = FindParentScrollViewer -element $appsPanel + $scrollViewer = Find-ParentScrollViewer -Element $appsPanel if ($scrollViewer) { - ScrollToItemIfNotVisible -scrollViewer $scrollViewer -item $script:AppSearchMatches[0] -container $appsPanel + Scroll-ToItemIfNotVisible -ScrollViewer $scrollViewer -Item $script:AppSearchMatches[0] -Container $appsPanel } } }) - + $appSearchBox.Add_KeyDown({ param($sourceControl, $e) if ($e.Key -eq [System.Windows.Input.Key]::Enter -and $script:AppSearchMatches.Count -gt 0) { - # Reset background of current active match $script:AppSearchMatches[$script:AppSearchMatchIndex].Background = $window.Resources["SearchHighlightColor"] - # Advance to next match (wrapping) $script:AppSearchMatchIndex = ($script:AppSearchMatchIndex + 1) % $script:AppSearchMatches.Count - # Highlight new active match $script:AppSearchMatches[$script:AppSearchMatchIndex].Background = $window.Resources["SearchHighlightActiveColor"] - $scrollViewer = FindParentScrollViewer -element $appsPanel + $scrollViewer = Find-ParentScrollViewer -Element $appsPanel if ($scrollViewer) { - ScrollToItemIfNotVisible -scrollViewer $scrollViewer -item $script:AppSearchMatches[$script:AppSearchMatchIndex] -container $appsPanel + Scroll-ToItemIfNotVisible -ScrollViewer $scrollViewer -Item $script:AppSearchMatches[$script:AppSearchMatchIndex] -Container $appsPanel } $e.Handled = $true } }) - # Tweak Search Box functionality - $tweakSearchBox = $window.FindName('TweakSearchBox') - $tweakSearchPlaceholder = $window.FindName('TweakSearchPlaceholder') - $tweakSearchBorder = $window.FindName('TweakSearchBorder') - $tweaksScrollViewer = $window.FindName('TweaksScrollViewer') - $tweaksGrid = $window.FindName('TweaksGrid') - $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 } - $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 UpdateAppliedTweaksUserModeState { - # 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 UpdateTweaksResponsiveColumns { - 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 = @() - } - - # Monitor scrollbar visibility and adjust searchbar margin + # ---- Tweak Search Box ---- $tweaksScrollViewer.Add_ScrollChanged({ if ($tweaksScrollViewer.ScrollableHeight -gt 0) { - # The 17px accounts for the scrollbar width + some padding $tweakSearchBorder.Margin = [System.Windows.Thickness]::new(0, 0, 17, 0) - } else { + } + else { $tweakSearchBorder.Margin = [System.Windows.Thickness]::new(0) } }) - - # Helper function to clear all tweak highlights - function ClearTweakHighlights { - $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 - } - } - } - } - } - } - - # Helper function to check if a ComboBox contains matching items - function 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 - } - + $tweakSearchBox.Add_TextChanged({ $searchText = $tweakSearchBox.Text.ToLower().Trim() - - # Show/hide placeholder $tweakSearchPlaceholder.Visibility = if ([string]::IsNullOrWhiteSpace($tweakSearchBox.Text)) { 'Visible' } else { 'Collapsed' } - - # Clear all highlights - ClearTweakHighlights - + + Clear-TweakHighlights -Window $window + if ([string]::IsNullOrWhiteSpace($searchText)) { return } - - # Find and highlight all matching tweaks + $firstMatch = $null $highlightBrush = $window.Resources["SearchHighlightColor"] + $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]) { @@ -1557,7 +520,7 @@ function Show-MainWindow { $control = $controlsList[$i] $matchFound = $false $controlToHighlight = $null - + if ($control -is [System.Windows.Controls.CheckBox]) { if ($control.Content.ToString().ToLower().Contains($searchText)) { $matchFound = $true @@ -1567,14 +530,13 @@ function Show-MainWindow { elseif ($control -is [System.Windows.Controls.Border] -and $control.Name -like '*_LabelBorder') { $labelText = if ($control.Child) { $control.Child.Text.ToLower() } else { "" } $comboBox = if ($i + 1 -lt $controlsList.Count -and $controlsList[$i + 1] -is [System.Windows.Controls.ComboBox]) { $controlsList[$i + 1] } else { $null } - - # Check label text or combo box items - if ($labelText.Contains($searchText) -or ($comboBox -and (ComboBoxContainsMatch -comboBox $comboBox -searchText $searchText))) { + + if ($labelText.Contains($searchText) -or ($comboBox -and (Test-ComboBoxContainsMatch -ComboBox $comboBox -SearchText $searchText))) { $matchFound = $true $controlToHighlight = $control } } - + if ($matchFound -and $controlToHighlight) { $controlToHighlight.Background = $highlightBrush if ($null -eq $firstMatch) { $firstMatch = $controlToHighlight } @@ -1583,35 +545,34 @@ function Show-MainWindow { } } } - - # Scroll to first match if not visible + if ($firstMatch -and $tweaksScrollViewer) { - ScrollToItemIfNotVisible -scrollViewer $tweaksScrollViewer -item $firstMatch -container $tweaksGrid + Scroll-ToItemIfNotVisible -ScrollViewer $tweaksScrollViewer -Item $firstMatch -Container $tweaksGrid } }) - # Only show changed settings checkbox + # ---- Show currently applied tweaks checkbox ---- if ($ShowCurrentlyAppliedTweaksCheckBox) { - $ShowCurrentlyAppliedTweaksCheckBox.Add_Checked({ ResetTweaksToSystemState -loadSystemState $true; UpdateAppliedTweaksUserModeState }) - $ShowCurrentlyAppliedTweaksCheckBox.Add_Unchecked({ ResetTweaksToSystemState -loadSystemState $false; UpdateAppliedTweaksUserModeState }) + $ShowCurrentlyAppliedTweaksCheckBox.Add_Checked({ + Reset-TweaksToSystemState -Window $window -LoadSystemState $true + Update-AppliedTweaksUserModeState -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox -UserSelectionCombo $userSelectionCombo + }) + $ShowCurrentlyAppliedTweaksCheckBox.Add_Unchecked({ + Reset-TweaksToSystemState -Window $window -LoadSystemState $false + Update-AppliedTweaksUserModeState -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox -UserSelectionCombo $userSelectionCombo + }) } - # Add Ctrl+F keyboard shortcut to focus search box on current tab + # ---- Ctrl+F keyboard shortcut ---- $window.Add_KeyDown({ param($sourceControl, $e) - - # Check if Ctrl+F was pressed - if ($e.Key -eq [System.Windows.Input.Key]::F -and + if ($e.Key -eq [System.Windows.Input.Key]::F -and ([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control)) { - $currentTab = $tabControl.SelectedItem - - # Focus AppSearchBox if on App Removal tab if ($currentTab.Header -eq "App Removal" -and $appSearchBox) { $appSearchBox.Focus() $e.Handled = $true } - # Focus TweakSearchBox if on Tweaks tab elseif ($currentTab.Header -eq "Tweaks" -and $tweakSearchBox) { $tweakSearchBox.Focus() $e.Handled = $true @@ -1619,457 +580,150 @@ function Show-MainWindow { } }) - # Wizard Navigation - $tabControl = $window.FindName('MainTabControl') - $previousBtn = $window.FindName('PreviousBtn') - $nextBtn = $window.FindName('NextBtn') - $userSelectionCombo = $window.FindName('UserSelectionCombo') - $userSelectionDescription = $window.FindName('UserSelectionDescription') - $otherUserPanel = $window.FindName('OtherUserPanel') - $otherUsernameTextBox = $window.FindName('OtherUsernameTextBox') - $usernameTextBoxPlaceholder = $window.FindName('UsernameTextBoxPlaceholder') - $usernameValidationMessage = $window.FindName('UsernameValidationMessage') - $appRemovalScopeCombo = $window.FindName('AppRemovalScopeCombo') - $appRemovalScopeDescription = $window.FindName('AppRemovalScopeDescription') - $appRemovalScopeSection = $window.FindName('AppRemovalScopeSection') - $appRemovalScopeCurrentUser = $window.FindName('AppRemovalScopeCurrentUser') - $appRemovalScopeTargetUser = $window.FindName('AppRemovalScopeTargetUser') - - # Navigation button handlers - function UpdateNavigationButtons { - $currentIndex = $tabControl.SelectedIndex - $totalTabs = $tabControl.Items.Count - - $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'] - } - } - - function UpdateUserSelectionDescription { - 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." - } - } - } - - # Update user selection description and show/hide other user panel - $userSelectionCombo.Add_SelectionChanged({ - UpdateUserSelectionDescription - - switch ($userSelectionCombo.SelectedIndex) { - 0 { - $otherUserPanel.Visibility = 'Collapsed' - $usernameValidationMessage.Text = "" - # Show "Current user only" option, hide "Target user only" option - $appRemovalScopeCurrentUser.Visibility = 'Visible' - $appRemovalScopeTargetUser.Visibility = 'Collapsed' - $appRemovalScopeCombo.SelectedIndex = 0 - # Re-check detect applied tweaks when switching back to current user - if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -ne $true) { - $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $true - } - } - 1 { - $otherUserPanel.Visibility = 'Visible' - $usernameValidationMessage.Text = "" - # Hide "Current user only" option, show "Target user only" option - $appRemovalScopeCurrentUser.Visibility = 'Collapsed' - $appRemovalScopeTargetUser.Visibility = 'Visible' - $appRemovalScopeCombo.SelectedIndex = 0 - # Uncheck detect applied tweaks for other user mode - if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { - $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false - } - } - 2 { - $otherUserPanel.Visibility = 'Collapsed' - $usernameValidationMessage.Text = "" - # Hide other user options since they don't apply to default user template - $appRemovalScopeCurrentUser.Visibility = 'Collapsed' - $appRemovalScopeTargetUser.Visibility = 'Collapsed' - # Lock app removal scope to "All users" when applying to sysprep - $appRemovalScopeCombo.SelectedIndex = 0 - # Uncheck detect applied tweaks for sysprep mode - if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { - $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false - } - } - } - - # Keep enabled/disabled state in sync with both app selection and user mode. - UpdateAppSelectionStatus - UpdateAppliedTweaksUserModeState - }) - - # Helper function to update app removal scope description - function UpdateAppRemovalScopeDescription { - $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." - } - } - } - } - - # Update app removal scope description - $appRemovalScopeCombo.Add_SelectionChanged({ - UpdateAppRemovalScopeDescription - }) - - $otherUsernameTextBox.Add_TextChanged({ - # Show/hide placeholder - if ([string]::IsNullOrWhiteSpace($otherUsernameTextBox.Text)) { - $usernameTextBoxPlaceholder.Visibility = 'Visible' - } else { - $usernameTextBoxPlaceholder.Visibility = 'Collapsed' - } - - UpdateUserSelectionDescription - - ValidateOtherUsername - }) - - function ValidateOtherUsername { - # 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 - } - - 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( - [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 GenerateOverview { - $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)" - } - - UpdateAppSelectionStatus - - foreach ($tweakAction in @(Get-PendingTweakActions -ShowAppliedTweaksMode:$showAppliedTweaksMode)) { - if ($tweakAction.Action -eq 'Undo') { - $changesList += "Undo: $($tweakAction.Label)" - } - else { - $changesList += $tweakAction.Label - } - } - - return $changesList - } - - function ShowChangesOverview { - $changesList = GenerateOverview - - 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 + # ---- Navigation button handlers ---- + function Invoke-NavigationUpdate { + Update-NavigationButtons -Window $window -TabControl $tabControl } $previousBtn.Add_Click({ Hide-Bubble -Immediate if ($tabControl.SelectedIndex -gt 0) { $tabControl.SelectedIndex-- - UpdateNavigationButtons + Invoke-NavigationUpdate } }) $nextBtn.Add_Click({ if ($tabControl.SelectedIndex -lt ($tabControl.Items.Count - 1)) { $tabControl.SelectedIndex++ - UpdateNavigationButtons + Invoke-NavigationUpdate } }) + # ---- User selection combo ---- + $userSelectionCombo.Add_SelectionChanged({ + Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription + + switch ($userSelectionCombo.SelectedIndex) { + 0 { + $otherUserPanel.Visibility = 'Collapsed' + $usernameValidationMessage.Text = "" + $appRemovalScopeCurrentUser.Visibility = 'Visible' + $appRemovalScopeTargetUser.Visibility = 'Collapsed' + $appRemovalScopeCombo.SelectedIndex = 0 + if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -ne $true) { + $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $true + } + } + 1 { + $otherUserPanel.Visibility = 'Visible' + $usernameValidationMessage.Text = "" + $appRemovalScopeCurrentUser.Visibility = 'Collapsed' + $appRemovalScopeTargetUser.Visibility = 'Visible' + $appRemovalScopeCombo.SelectedIndex = 0 + if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { + $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false + } + } + 2 { + $otherUserPanel.Visibility = 'Collapsed' + $usernameValidationMessage.Text = "" + $appRemovalScopeCurrentUser.Visibility = 'Collapsed' + $appRemovalScopeTargetUser.Visibility = 'Collapsed' + $appRemovalScopeCombo.SelectedIndex = 0 + if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { + $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false + } + } + } + + Update-AppSelectionStatus -AppsPanel $appsPanel -AppSelectionStatus $appSelectionStatus -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeSection $appRemovalScopeSection -AppRemovalScopeDescription $appRemovalScopeDescription -UserSelectionCombo $userSelectionCombo + Update-AppliedTweaksUserModeState -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox -UserSelectionCombo $userSelectionCombo + }) + + # ---- App removal scope combo ---- + $appRemovalScopeCombo.Add_SelectionChanged({ + Update-AppRemovalScopeDescription -AppRemovalScopeCombo $appRemovalScopeCombo -AppRemovalScopeDescription $appRemovalScopeDescription + }) + + # ---- Other username text box ---- + $otherUsernameTextBox.Add_TextChanged({ + if ([string]::IsNullOrWhiteSpace($otherUsernameTextBox.Text)) { + $usernameTextBoxPlaceholder.Visibility = 'Visible' + } + else { + $usernameTextBoxPlaceholder.Visibility = 'Collapsed' + } + Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription + Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage | Out-Null + }) + + # ---- Validate target user helper ---- $ensureValidTargetUserOrWarn = { - if (-not (ValidateOtherUsername)) { + if (-not (Test-OtherUsername -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UsernameValidationMessage $usernameValidationMessage)) { $validationMessage = if (-not [string]::IsNullOrWhiteSpace($usernameValidationMessage.Text)) { $usernameValidationMessage.Text } else { "Please enter a valid username." } - Show-MessageBox -Message $validationMessage -Title "Invalid Username" -Button 'OK' -Icon 'Warning' | Out-Null return $false } - return $true } - # Handle Home Start button - $homeStartBtn = $window.FindName('HomeStartBtn') + # ---- Home Start button ---- $homeStartBtn.Add_Click({ - if (-not (& $ensureValidTargetUserOrWarn)) { - return - } - - # Navigate to first tab after home (App Removal) + if (-not (& $ensureValidTargetUserOrWarn)) { return } $tabControl.SelectedIndex = 1 - UpdateNavigationButtons + Invoke-NavigationUpdate }) - # Handle Home Default Mode button - apply defaults and navigate directly to overview - $homeDefaultModeBtn = $window.FindName('HomeDefaultModeBtn') + # ---- Home Default Mode button ---- $homeDefaultModeBtn.Add_Click({ - if (-not (& $ensureValidTargetUserOrWarn)) { - return - } + if (-not (& $ensureValidTargetUserOrWarn)) { return } if ($ShowCurrentlyAppliedTweaksCheckBox) { $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false } - # Load and apply default settings $defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" if ($defaultsJson) { ApplySettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings } - # Deselect all apps, then select default apps (defer if apps are still loading in the background) if ($script:IsLoadingApps) { $script:PendingDefaultMode = $true - } else { - ApplyPresetToApps -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Exclusive + } + else { + Invoke-AppPreset -AppsPanel $appsPanel -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Exclusive } - # Navigate directly to the Deployment Settings tab $tabControl.SelectedIndex = 3 - UpdateNavigationButtons + Invoke-NavigationUpdate - # Show contextual hint bubble for the Review Changes link $window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Loaded, [action]{ Show-Bubble -TargetControl $reviewChangesBtn -Message 'View the selected changes here' }) | Out-Null }) - # Handle Review Changes link button - $reviewChangesBtn = $window.FindName('ReviewChangesBtn') + # ---- Review Changes link ---- $reviewChangesBtn.Add_Click({ Hide-Bubble - ShowChangesOverview + Invoke-ShowChangesOverview -Window $window -AppsPanel $appsPanel -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox }) - # Handle Apply Changes button - validates and immediately starts applying changes - $deploymentApplyBtn = $window.FindName('DeploymentApplyBtn') + # ---- Apply Changes button ---- $deploymentApplyBtn.Add_Click({ - if (-not (& $ensureValidTargetUserOrWarn)) { - return - } + if (-not (& $ensureValidTargetUserOrWarn)) { return } 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 + # App Removal - collect selected apps $selectedApps = @() foreach ($child in $appsPanel.Children) { if ($child -is [System.Windows.Controls.CheckBox] -and $child.IsChecked) { @@ -2078,42 +732,30 @@ function Show-MainWindow { } $selectedApps = @($selectedApps | Where-Object { $_ } | Select-Object -Unique) $hasAppSelection = ($selectedApps.Count -gt 0) - + if ($selectedApps.Count -gt 0) { - if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { - return - } - - + if (-not (ConfirmUnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } + AddParameter 'RemoveApps' AddParameter 'Apps' ($selectedApps -join ',') - - # Add app removal target parameter based on selection + $selectedScopeItem = $appRemovalScopeCombo.SelectedItem if ($selectedScopeItem) { switch ($selectedScopeItem.Content) { - "All users" { - AddParameter 'AppRemovalTarget' 'AllUsers' - } - "Current user only" { - AddParameter 'AppRemovalTarget' 'CurrentUser' - } - "Target user only" { - # Use the target username from Other User panel - AddParameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) - } + "All users" { AddParameter 'AppRemovalTarget' 'AllUsers' } + "Current user only" { AddParameter 'AppRemovalTarget' 'CurrentUser' } + "Target user only" { AddParameter 'AppRemovalTarget' ($otherUsernameTextBox.Text.Trim()) } } } } - # Apply dynamic tweaks - only controls that changed from their current baseline state - foreach ($tweakAction in @(Get-PendingTweakActions -ShowAppliedTweaksMode:$showAppliedTweaksMode)) { + # Apply dynamic tweaks + foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) { if ($tweakAction.Action -eq 'Apply') { AddParameter $tweakAction.FeatureId $null = $selectedForwardFeatureIds.Add([string]$tweakAction.FeatureId) continue } - $script:UndoParams[[string]$tweakAction.FeatureId] = $true } @@ -2122,20 +764,16 @@ function Show-MainWindow { return } - # Check RestorePointCheckBox $restorePointCheckBox = $window.FindName('RestorePointCheckBox') if ($restorePointCheckBox -and $restorePointCheckBox.IsChecked) { AddParameter 'CreateRestorePoint' } - - # Store selected user mode + switch ($userSelectionCombo.SelectedIndex) { - 0 { - Write-Host "Selected user mode: current user ($(GetUserName))" - } - 1 { + 0 { Write-Host "Selected user mode: current user ($(GetUserName))" } + 1 { Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())" - AddParameter User ($otherUsernameTextBox.Text.Trim()) + AddParameter User ($otherUsernameTextBox.Text.Trim()) } 2 { Write-Host "Selected user mode: default user profile (Sysprep)" @@ -2145,344 +783,142 @@ function Show-MainWindow { SaveSettings - # Check if user wants to restart explorer $restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox') $shouldRestartExplorer = $restartExplorerCheckBox -and $restartExplorerCheckBox.IsChecked - # Show the apply changes window Show-ApplyModal -Owner $window -RestartExplorer $shouldRestartExplorer - - # Close the main window after the apply dialog closes $window.Close() }) - # Initialize UI elements on window load + # ---- Tweaks presets tri-state ---- + foreach ($presetCheckBox in @($presetDefaultTweaksBtn, $presetLastUsedTweaksBtn, $presetPrivacyTweaksBtn, $presetAITweaksBtn)) { + Add-TriStateClickBehavior -CheckBox $presetCheckBox + } + + # ---- Clear All Tweaks ---- + $clearAllTweaksBtn.Add_Click({ + if ($ShowCurrentlyAppliedTweaksCheckBox -and $ShowCurrentlyAppliedTweaksCheckBox.IsChecked -eq $true) { + $ShowCurrentlyAppliedTweaksCheckBox.IsChecked = $false + } + Clear-TweakSelections -Window $window + Update-TweakPresetStates -Window $window + }) + + # ---- Window Load event ---- $window.Add_Loaded({ - & $updateHomeContentPosition - BuildDynamicTweaks - LoadCurrentTweakStateIntoUI - UpdateTweaksResponsiveColumns - RefreshTweakPresetSources -defaultSettingsJson $defaultsJson -lastUsedSettingsJson $lastUsedSettingsJson - RegisterTweakPresetControlStateHandlers - UpdateTweakPresetStates + try { + & $updateHomeContentPosition + Build-DynamicTweaks -Window $window -WinVersion $WinVersion + Load-CurrentTweakStateIntoUI -Window $window + Update-TweaksResponsiveColumns -Window $window - LoadAppsIntoMainUI + $lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile + $defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" - # Update Current User label with username - if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) { - $currentUserItem = $userSelectionCombo.Items[0] - if ($currentUserItem -is [System.Windows.Controls.ComboBoxItem]) { - $currentUserItem.Content = "Current User ($(GetUserName))" + $script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson + + Initialize-TweakPresetSources -Window $window -DefaultSettingsJson $defaultsJson -LastUsedSettingsJson $lastUsedSettingsJson + Register-TweakPresetControlStateHandlers -Window $window + Update-TweakPresetStates -Window $window + + Load-AppsIntoMainUI -Window $window -AppsPanel $appsPanel -OnlyInstalledAppsBox $onlyInstalledAppsBox -LoadingAppsIndicator $loadingAppsIndicator -ImportConfigBtn $importConfigBtn + + # Update Current User label + if ($userSelectionCombo -and $userSelectionCombo.Items.Count -gt 0) { + $currentUserItem = $userSelectionCombo.Items[0] + if ($currentUserItem -is [System.Windows.Controls.ComboBoxItem]) { + $currentUserItem.Content = "Current User ($(GetUserName))" + } } - } - # Disable Restart Explorer option if NoRestartExplorer parameter is set - $restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox') - if ($restartExplorerCheckBox -and $script:Params.ContainsKey("NoRestartExplorer")) { - $restartExplorerCheckBox.IsChecked = $false - $restartExplorerCheckBox.IsEnabled = $false - } + $restartExplorerCheckBox = $window.FindName('RestartExplorerCheckBox') + if ($restartExplorerCheckBox -and $script:Params.ContainsKey("NoRestartExplorer")) { + $restartExplorerCheckBox.IsChecked = $false + $restartExplorerCheckBox.IsEnabled = $false + } - # Force Apply Changes To setting if Sysprep or User parameters are set - if ($script:Params.ContainsKey("Sysprep")) { - $userSelectionCombo.SelectedIndex = 2 - $userSelectionCombo.IsEnabled = $false - } - elseif ($script:Params.ContainsKey("User")) { - $userSelectionCombo.SelectedIndex = 1 - $userSelectionCombo.IsEnabled = $false - $otherUsernameTextBox.Text = $script:Params.Item("User") - $otherUsernameTextBox.IsEnabled = $false - } + if ($script:Params.ContainsKey("Sysprep")) { + $userSelectionCombo.SelectedIndex = 2 + $userSelectionCombo.IsEnabled = $false + } + elseif ($script:Params.ContainsKey("User")) { + $userSelectionCombo.SelectedIndex = 1 + $userSelectionCombo.IsEnabled = $false + $otherUsernameTextBox.Text = $script:Params.Item("User") + $otherUsernameTextBox.IsEnabled = $false + } - UpdateUserSelectionDescription - UpdateAppliedTweaksUserModeState - UpdateNavigationButtons + Update-UserSelectionDescription -Window $window -UserSelectionCombo $userSelectionCombo -OtherUsernameTextBox $otherUsernameTextBox -UserSelectionDescription $userSelectionDescription + Update-AppliedTweaksUserModeState -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox -UserSelectionCombo $userSelectionCombo + Invoke-NavigationUpdate + } + catch { + Write-Warning "Error during GUI initialization: $($_.Exception.Message)" + Write-Warning "Stack trace: $($_.Exception.StackTrace)" + Show-MessageBox -Message "An error occurred during initialization: $($_.Exception.Message)" -Title "Initialization Error" -Button 'OK' -Icon 'Error' | Out-Null + } }) - # Add event handler for tab changes + # ---- Tab change event ---- $tabControl.Add_SelectionChanged({ - # Regenerate overview when switching to Overview tab if ($tabControl.SelectedIndex -eq ($tabControl.Items.Count - 2)) { - GenerateOverview + New-Overview -Window $window -AppsPanel $appsPanel -ShowCurrentlyAppliedTweaksCheckBox $ShowCurrentlyAppliedTweaksCheckBox | Out-Null } - UpdateNavigationButtons + Invoke-NavigationUpdate }) - function BuildTweakPresetControlMap { - param($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 BuildCategoryTweakPresetMap { - param([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 GetSavedAppIdsFromSettingsJson { - 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 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) { - UpdateTweakPresetStates - } - } - - function SetTweakPresetState { - 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++ - } - } - - SetTriStatePresetCheckBoxState -CheckBox $PresetCheckBox -Total $total -Selected $selected - } - - function UpdateTweakPresetStates { - $script:UpdatingTweakPresets = $true - try { - SetTweakPresetState -PresetCheckBox $presetDefaultTweaksBtn -PresetMap $script:DefaultTweakPresetMap - if ($presetLastUsedTweaksBtn -and $presetLastUsedTweaksBtn.Visibility -ne 'Collapsed') { - SetTweakPresetState -PresetCheckBox $presetLastUsedTweaksBtn -PresetMap $script:LastUsedTweakPresetMap - } - SetTweakPresetState -PresetCheckBox $presetPrivacyTweaksBtn -PresetMap $script:PrivacyTweakPresetMap - SetTweakPresetState -PresetCheckBox $presetAITweaksBtn -PresetMap $script:AITweakPresetMap - } - finally { - $script:UpdatingTweakPresets = $false - } - } - - function RegisterTweakPresetControlStateHandlers { - 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) { UpdateTweakPresetStates } }) - $control.Add_Unchecked({ if (-not $script:UpdatingTweakPresets) { UpdateTweakPresetStates } }) - } - elseif ($control -is [System.Windows.Controls.ComboBox]) { - $control.Add_SelectionChanged({ if (-not $script:UpdatingTweakPresets) { UpdateTweakPresetStates } }) - } - } - } - - function RefreshTweakPresetSources { - param( - $defaultSettingsJson, - $lastUsedSettingsJson - ) - - $script:DefaultTweakPresetMap = BuildTweakPresetControlMap -settingsJson $defaultSettingsJson - $script:LastUsedTweakPresetMap = BuildTweakPresetControlMap -settingsJson $lastUsedSettingsJson - $script:PrivacyTweakPresetMap = BuildCategoryTweakPresetMap -Category 'Privacy & Suggested Content' - $script:AITweakPresetMap = BuildCategoryTweakPresetMap -Category 'AI' - - if ($presetLastUsedTweaksBtn) { - $presetLastUsedTweaksBtn.Visibility = if ($script:LastUsedTweakPresetMap.Count -gt 0) { 'Visible' } else { 'Collapsed' } - } - } - + # ---- Tweak presets wiring ---- $lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile - $defaultsJson = LoadJsonFile -filePath $script:DefaultSettingsFilePath -expectedVersion "1.0" $script:DefaultTweakPresetMap = @{} $script:LastUsedTweakPresetMap = @{} $script:PrivacyTweakPresetMap = @{} $script:AITweakPresetMap = @{} - $script:SavedAppIds = GetSavedAppIdsFromSettingsJson -settingsJson $lastUsedSettingsJson + $script:SavedAppIds = Get-SavedAppIdsFromSettingsJson -SettingsJson $lastUsedSettingsJson if ($presetDefaultTweaksBtn) { $presetDefaultTweaksBtn.Add_Click({ if ($script:UpdatingTweakPresets) { return } - $check = NormalizeCheckboxState -checkBox $this - ApplyTweakPresetMap -PresetMap $script:DefaultTweakPresetMap -Check $check + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-ApplyTweakPresetMap -PresetMap $script:DefaultTweakPresetMap -Check $check }) } if ($presetLastUsedTweaksBtn) { $presetLastUsedTweaksBtn.Add_Click({ if ($script:UpdatingTweakPresets) { return } - $check = NormalizeCheckboxState -checkBox $this - ApplyTweakPresetMap -PresetMap $script:LastUsedTweakPresetMap -Check $check + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-ApplyTweakPresetMap -PresetMap $script:LastUsedTweakPresetMap -Check $check }) } if ($presetPrivacyTweaksBtn) { $presetPrivacyTweaksBtn.Add_Click({ if ($script:UpdatingTweakPresets) { return } - $check = NormalizeCheckboxState -checkBox $this - ApplyTweakPresetMap -PresetMap $script:PrivacyTweakPresetMap -Check $check + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-ApplyTweakPresetMap -PresetMap $script:PrivacyTweakPresetMap -Check $check }) } if ($presetAITweaksBtn) { $presetAITweaksBtn.Add_Click({ if ($script:UpdatingTweakPresets) { return } - $check = NormalizeCheckboxState -checkBox $this - ApplyTweakPresetMap -PresetMap $script:AITweakPresetMap -Check $check + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-ApplyTweakPresetMap -PresetMap $script:AITweakPresetMap -Check $check }) } - # Hide Last used tweak preset by default; it is shown after dynamic controls are built and mappings are resolved. + # Hide Last used tweak preset by default if ($presetLastUsedTweaksBtn) { $presetLastUsedTweaksBtn.Visibility = 'Collapsed' } - # Preset: Last used selection (wired to PresetLastUsed checkbox) + # ---- Preset: Last used selection (apps) ---- if ($script:SavedAppIds) { $presetLastUsed.Add_Click({ if ($script:UpdatingPresets) { return } - $check = NormalizeCheckboxState -checkBox $this - ApplyPresetToApps -MatchFilter { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 } -Check $check + $check = ConvertTo-NormalizedCheckboxState -CheckBox $this + Invoke-AppPreset -AppsPanel $appsPanel -MatchFilter { param($c) (@($c.AppIds) | Where-Object { $script:SavedAppIds -contains $_ }).Count -gt 0 } -Check $check }) } else { @@ -2490,18 +926,7 @@ function Show-MainWindow { $presetLastUsed.Visibility = 'Collapsed' } - # 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 - }) - - # Preload app data to speed up loading when user navigates to App Removal tab + # ---- Preload app data ---- try { $script:PreloadedAppData = LoadAppsDetailsFromJson -OnlyInstalled:$false -InstalledList '' -InitialCheckedFromJson:$false } @@ -2509,7 +934,7 @@ function Show-MainWindow { Write-Warning "Failed to preload apps list: $_" } - # Show the window + # ---- Show window ---- $frame = [System.Windows.Threading.DispatcherFrame]::new() $window.Add_Closed({ $frame.Continue = $false diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index 19a0ea9..3cce8d3 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -331,6 +331,11 @@ if (-not $script:WingetInstalled -and -not $Silent) { . "$PSScriptRoot/Scripts/GUI/Show-RestoreBackupWindow.ps1" . "$PSScriptRoot/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1" . "$PSScriptRoot/Scripts/GUI/Show-RestoreBackupDialog.ps1" +. "$PSScriptRoot/Scripts/GUI/MainWindow-WindowChrome.ps1" +. "$PSScriptRoot/Scripts/GUI/MainWindow-AppSelection.ps1" +. "$PSScriptRoot/Scripts/GUI/MainWindow-TweaksBuilder.ps1" +. "$PSScriptRoot/Scripts/GUI/MainWindow-Navigation.ps1" +. "$PSScriptRoot/Scripts/GUI/MainWindow-Deployment.ps1" . "$PSScriptRoot/Scripts/GUI/Show-MainWindow.ps1" . "$PSScriptRoot/Scripts/GUI/Show-AboutDialog.ps1" . "$PSScriptRoot/Scripts/GUI/Show-Bubble.ps1"