Improve app page with sorting, recommendations and more (#520)

This commit is contained in:
Jeffrey
2026-03-15 22:58:06 +01:00
committed by GitHub
parent d187679cd0
commit c37bdcf5f2
25 changed files with 1573 additions and 970 deletions

View File

@@ -0,0 +1,31 @@
# Run winget list and return installed apps.
# Use -NonBlocking to keep the UI responsive (GUI mode) via Invoke-NonBlocking.
function GetInstalledAppsViaWinget {
param (
[int]$TimeOut = 10,
[switch]$NonBlocking
)
if (-not $script:WingetInstalled) { return $null }
$fetchBlock = {
param($timeOut)
$job = Start-Job { return winget list --accept-source-agreements --disable-interactivity }
$done = $job | Wait-Job -Timeout $timeOut
if ($done) {
$result = Receive-Job -Job $job
Remove-Job -Job $job -ErrorAction SilentlyContinue
return $result
}
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
return $null
}
if ($NonBlocking) {
return Invoke-NonBlocking -ScriptBlock $fetchBlock -ArgumentList $TimeOut
}
else {
return & $fetchBlock $TimeOut
}
}

View File

@@ -5,31 +5,25 @@ function CreateSystemRestorePoint {
if ($SysRestore.RPSessionInterval -eq 0) {
# In GUI mode, skip the prompt and just try to enable it
if ($script:GuiWindow -or $Silent -or $( Read-Host -Prompt "System restore is disabled, would you like to enable it and create a restore point? (y/n)") -eq 'y') {
$enableSystemRestoreJob = Start-Job {
try {
Enable-ComputerRestore -Drive "$env:SystemDrive"
try {
$enableResult = Invoke-NonBlocking -TimeoutSeconds 20 -ScriptBlock {
try {
Enable-ComputerRestore -Drive "$env:SystemDrive"
return $null
}
catch {
return "Error: Failed to enable System Restore: $_"
}
}
catch {
return "Error: Failed to enable System Restore: $_"
}
return $null
}
catch {
$enableResult = "Error: Failed to enable System Restore: $_"
}
$enableSystemRestoreJobDone = $enableSystemRestoreJob | Wait-Job -TimeOut 20
if (-not $enableSystemRestoreJobDone) {
Remove-Job -Job $enableSystemRestoreJob -Force -ErrorAction SilentlyContinue
Write-Host "Error: Failed to enable system restore and create restore point, operation timed out" -ForegroundColor Red
if ($enableResult) {
Write-Host $enableResult -ForegroundColor Red
$failed = $true
}
else {
$result = Receive-Job $enableSystemRestoreJob
Remove-Job -Job $enableSystemRestoreJob -ErrorAction SilentlyContinue
if ($result) {
Write-Host $result -ForegroundColor Red
$failed = $true
}
}
}
else {
Write-Host ""
@@ -38,46 +32,43 @@ function CreateSystemRestorePoint {
}
if (-not $failed) {
$createRestorePointJob = Start-Job {
# Find existing restore points that are less than 24 hours old
try {
$recentRestorePoints = Get-ComputerRestorePoint | Where-Object { (Get-Date) - [System.Management.ManagementDateTimeConverter]::ToDateTime($_.CreationTime) -le (New-TimeSpan -Hours 24) }
}
catch {
return @{ Success = $false; Message = "Error: Unable to retrieve existing restore points: $_" }
}
if ($recentRestorePoints.Count -eq 0) {
try {
$result = Invoke-NonBlocking -TimeoutSeconds 20 -ScriptBlock {
try {
Checkpoint-Computer -Description "Restore point created by Win11Debloat" -RestorePointType "MODIFY_SETTINGS"
return @{ Success = $true; Message = "System restore point created successfully" }
$recentRestorePoints = Get-ComputerRestorePoint | Where-Object { (Get-Date) - [System.Management.ManagementDateTimeConverter]::ToDateTime($_.CreationTime) -le (New-TimeSpan -Hours 24) }
}
catch {
return @{ Success = $false; Message = "Error: Unable to create restore point: $_" }
return [PSCustomObject]@{ Success = $false; Message = "Error: Unable to retrieve existing restore points: $_" }
}
if ($recentRestorePoints.Count -eq 0) {
try {
Checkpoint-Computer -Description "Restore point created by Win11Debloat" -RestorePointType "MODIFY_SETTINGS"
return [PSCustomObject]@{ Success = $true; Message = "System restore point created successfully" }
}
catch {
return [PSCustomObject]@{ Success = $false; Message = "Error: Unable to create restore point: $_" }
}
}
else {
return [PSCustomObject]@{ Success = $true; Message = "A recent restore point already exists, no new restore point was created" }
}
}
else {
return @{ Success = $true; Message = "A recent restore point already exists, no new restore point was created" }
}
}
catch {
$result = [PSCustomObject]@{ Success = $false; Message = "Error: Failed to create system restore point: $_" }
}
$createRestorePointJobDone = $createRestorePointJob | Wait-Job -TimeOut 20
if (-not $createRestorePointJobDone) {
Remove-Job -Job $createRestorePointJob -Force -ErrorAction SilentlyContinue
Write-Host "Error: Failed to create system restore point, operation timed out" -ForegroundColor Red
if ($result -and $result.Success) {
Write-Host $result.Message
}
elseif ($result) {
Write-Host $result.Message -ForegroundColor Red
$failed = $true
}
else {
$result = Receive-Job $createRestorePointJob
Remove-Job -Job $createRestorePointJob -ErrorAction SilentlyContinue
if ($result.Success) {
Write-Host $result.Message
}
else {
Write-Host $result.Message -ForegroundColor Red
$failed = $true
}
Write-Host "Error: Failed to create system restore point" -ForegroundColor Red
$failed = $true
}
}

View File

@@ -0,0 +1,196 @@
# Executes a single parameter/feature based on its key
# Parameters:
# $paramKey - The parameter name to execute
function ExecuteParameter {
param (
[string]$paramKey
)
# Check if this feature has metadata in Features.json
$feature = $null
if ($script:Features.ContainsKey($paramKey)) {
$feature = $script:Features[$paramKey]
}
# If feature has RegistryKey and ApplyText, use dynamic ImportRegistryFile
if ($feature -and $feature.RegistryKey -and $feature.ApplyText) {
ImportRegistryFile "> $($feature.ApplyText)" $feature.RegistryKey
# Handle special cases that have additional logic after ImportRegistryFile
switch ($paramKey) {
'DisableBing' {
# Also remove the app package for Bing search
RemoveApps 'Microsoft.BingSearch'
}
'DisableCopilot' {
# Also remove the app package for Copilot
RemoveApps 'Microsoft.Copilot'
}
'DisableWidgets' {
# Also remove the app package for Widgets
RemoveApps 'Microsoft.StartExperiencesApp'
}
}
return
}
# Handle features without RegistryKey or with special logic
switch ($paramKey) {
'RemoveApps' {
Write-Host "> Removing selected apps for $(GetFriendlyTargetUserName)..."
$appsList = GenerateAppsList
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Host ""
return
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
}
'RemoveAppsCustom' {
Write-Host "> Removing selected apps..."
$appsList = LoadAppsFromFile $script:CustomAppsListFilePath
if ($appsList.Count -eq 0) {
Write-Host "No valid apps were selected for removal" -ForegroundColor Yellow
Write-Host ""
return
}
Write-Host "$($appsList.Count) apps selected for removal"
RemoveApps $appsList
}
'RemoveCommApps' {
$appsList = 'Microsoft.windowscommunicationsapps', 'Microsoft.People'
Write-Host "> Removing Mail, Calendar and People apps..."
RemoveApps $appsList
return
}
'RemoveW11Outlook' {
$appsList = 'Microsoft.OutlookForWindows'
Write-Host "> Removing new Outlook for Windows app..."
RemoveApps $appsList
return
}
'RemoveGamingApps' {
$appsList = 'Microsoft.GamingApp', 'Microsoft.XboxGameOverlay', 'Microsoft.XboxGamingOverlay'
Write-Host "> Removing gaming related apps..."
RemoveApps $appsList
return
}
'RemoveHPApps' {
$appsList = 'AD2F1837.HPAIExperienceCenter', 'AD2F1837.HPJumpStarts', 'AD2F1837.HPPCHardwareDiagnosticsWindows', 'AD2F1837.HPPowerManager', 'AD2F1837.HPPrivacySettings', 'AD2F1837.HPSupportAssistant', 'AD2F1837.HPSureShieldAI', 'AD2F1837.HPSystemInformation', 'AD2F1837.HPQuickDrop', 'AD2F1837.HPWorkWell', 'AD2F1837.myHP', 'AD2F1837.HPDesktopSupportUtilities', 'AD2F1837.HPQuickTouch', 'AD2F1837.HPEasyClean', 'AD2F1837.HPConnectedMusic', 'AD2F1837.HPFileViewer', 'AD2F1837.HPRegistration', 'AD2F1837.HPWelcome', 'AD2F1837.HPConnectedPhotopoweredbySnapfish', 'AD2F1837.HPPrinterControl'
Write-Host "> Removing HP apps..."
RemoveApps $appsList
return
}
"EnableWindowsSandbox" {
Write-Host "> Enabling Windows Sandbox..."
EnableWindowsFeature "Containers-DisposableClientVM"
Write-Host ""
return
}
"EnableWindowsSubsystemForLinux" {
Write-Host "> Enabling Windows Subsystem for Linux..."
EnableWindowsFeature "VirtualMachinePlatform"
EnableWindowsFeature "Microsoft-Windows-Subsystem-Linux"
Write-Host ""
return
}
'ClearStart' {
Write-Host "> Removing all pinned apps from the start menu for user $(GetUserName)..."
ReplaceStartMenu
Write-Host ""
return
}
'ReplaceStart' {
Write-Host "> Replacing the start menu for user $(GetUserName)..."
ReplaceStartMenu $script:Params.Item("ReplaceStart")
Write-Host ""
return
}
'ClearStartAllUsers' {
ReplaceStartMenuForAllUsers
return
}
'ReplaceStartAllUsers' {
ReplaceStartMenuForAllUsers $script:Params.Item("ReplaceStartAllUsers")
return
}
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey("Sysprep")) {
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
DisableStoreSearchSuggestionsForAllUsers
Write-Host ""
return
}
Write-Host "> Disabling Microsoft Store search suggestions for user $(GetUserName)..."
DisableStoreSearchSuggestions
Write-Host ""
return
}
}
}
# Executes all selected parameters/features
function ExecuteAllChanges {
# Build list of actionable parameters (skip control params and data-only params)
$actionableKeys = @()
foreach ($paramKey in $script:Params.Keys) {
if ($script:ControlParams -contains $paramKey) { continue }
if ($paramKey -eq 'Apps') { continue }
if ($paramKey -eq 'CreateRestorePoint') { continue }
$actionableKeys += $paramKey
}
$totalSteps = $actionableKeys.Count
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$currentStep = 0
# Create restore point if requested (CLI only - GUI handles this separately)
if ($script:Params.ContainsKey("CreateRestorePoint")) {
$currentStep++
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $currentStep $totalSteps "Creating system restore point"
}
Write-Host "> Attempting to create a system restore point..."
CreateSystemRestorePoint
Write-Host ""
}
# Execute all parameters
foreach ($paramKey in $actionableKeys) {
if ($script:CancelRequested) {
return
}
$currentStep++
# Get friendly name for the step
$stepName = $paramKey
if ($script:Features.ContainsKey($paramKey)) {
$feature = $script:Features[$paramKey]
if ($feature.ApplyText) {
# Prefer explicit ApplyText when provided
$stepName = $feature.ApplyText
} elseif ($feature.Label) {
# Fallback: construct a name from Action and Label, or just Label
if ($feature.Action) {
$stepName = "$($feature.Action) $($feature.Label)"
} else {
$stepName = $feature.Label
}
}
}
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $currentStep $totalSteps $stepName
}
ExecuteParameter -paramKey $paramKey
}
}

View File

@@ -0,0 +1,22 @@
# Read Apps.json and return the list of preset objects (Name + AppIds).
# Returns an empty array if the file cannot be read or contains no presets.
function LoadAppPresetsFromJson {
try {
$jsonContent = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
}
catch {
Write-Warning "Failed to read Apps.json: $_"
return @()
}
if (-not $jsonContent.Presets) {
return @()
}
return @($jsonContent.Presets | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
AppIds = @($_.AppIds)
}
})
}

View File

@@ -39,6 +39,7 @@ function LoadAppsDetailsFromJson {
IsChecked = $isChecked
Description = $appData.Description
SelectedByDefault = $appData.SelectedByDefault
Recommendation = $appData.Recommendation
}
}

View File

@@ -11,60 +11,64 @@ function ApplySettingsToUiControls {
return $false
}
# First, reset all tweaks to "No Change" (index 0) or unchecked
if ($uiControlMappings) {
foreach ($comboName in $uiControlMappings.Keys) {
$control = $window.FindName($comboName)
if ($control -is [System.Windows.Controls.CheckBox]) {
$control.IsChecked = $false
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = 0
if (-not $uiControlMappings) {
return $true
}
# Build control cache and reverse index (featureId -> control info) in a single pass
$controlCache = @{}
$featureIdIndex = @{}
foreach ($comboName in $uiControlMappings.Keys) {
$control = $window.FindName($comboName)
if (-not $control) { continue }
$controlCache[$comboName] = $control
$mapping = $uiControlMappings[$comboName]
if ($mapping.Type -eq 'group') {
$i = 1
foreach ($val in $mapping.Values) {
foreach ($fid in $val.FeatureIds) {
$featureIdIndex[$fid] = @{ ComboName = $comboName; Control = $control; Index = $i; MappingType = 'group' }
}
$i++
}
}
elseif ($mapping.Type -eq 'feature') {
$featureIdIndex[$mapping.FeatureId] = @{ ComboName = $comboName; Control = $control; MappingType = 'feature' }
}
# Reset control to default state
if ($control -is [System.Windows.Controls.CheckBox]) {
$control.IsChecked = $false
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = 0
}
}
# Apply settings from JSON
# Apply settings using O(1) lookups
foreach ($setting in $settingsJson.Settings) {
if ($setting.Value -ne $true) { continue }
$paramName = $setting.Name
if ($setting.Name -eq 'CreateRestorePoint') { continue }
# Skip RestorePointCheckBox, this is always checked by default
if ($paramName -eq 'CreateRestorePoint') {
continue
$entry = $featureIdIndex[$setting.Name]
if (-not $entry) { continue }
$control = $entry.Control
if (-not $control -or $control.Visibility -ne 'Visible') { continue }
if ($entry.MappingType -eq 'group') {
if ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = $entry.Index
}
}
if ($uiControlMappings) {
foreach ($comboName in $uiControlMappings.Keys) {
$mapping = $uiControlMappings[$comboName]
if ($mapping.Type -eq 'group') {
$i = 1
foreach ($val in $mapping.Values) {
if ($val.FeatureIds -contains $paramName) {
$control = $window.FindName($comboName)
if ($control -and $control.Visibility -eq 'Visible') {
if ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = $i
}
}
break
}
$i++
}
}
elseif ($mapping.Type -eq 'feature') {
if ($mapping.FeatureId -eq $paramName) {
$control = $window.FindName($comboName)
if ($control -and $control.Visibility -eq 'Visible') {
if ($control -is [System.Windows.Controls.CheckBox]) {
$control.IsChecked = $true
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = 1
}
}
}
}
else {
if ($control -is [System.Windows.Controls.CheckBox]) {
$control.IsChecked = $true
}
elseif ($control -is [System.Windows.Controls.ComboBox]) {
$control.SelectedIndex = 1
}
}
}

View File

@@ -30,6 +30,8 @@ function SetWindowThemeResources {
$window.Resources.Add("InputFocusColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#1f1f1f")))
$window.Resources.Add("ScrollBarThumbColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#3d3d3d")))
$window.Resources.Add("ScrollBarThumbHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#4b4b4b")))
$window.Resources.Add("TitlebarButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#2d2d2d")))
$window.Resources.Add("TitlebarButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#292929")))
$window.Resources.Add("AppIdColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#afafaf")))
$window.Resources.Add("SearchHighlightColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#4A4A2A")))
$window.Resources.Add("SearchHighlightActiveColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#8A7000")))
@@ -60,6 +62,8 @@ function SetWindowThemeResources {
$window.Resources.Add("InputFocusColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#fbfbfb")))
$window.Resources.Add("ScrollBarThumbColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#b9b9b9")))
$window.Resources.Add("ScrollBarThumbHoverColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#8b8b8b")))
$window.Resources.Add("TitlebarButtonHover", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e1e1e1")))
$window.Resources.Add("TitlebarButtonPressed", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#e6e6e6")))
$window.Resources.Add("AppIdColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#666666")))
$window.Resources.Add("SearchHighlightColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFF4CE")))
$window.Resources.Add("SearchHighlightActiveColor", [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFD966")))

View File

@@ -143,7 +143,7 @@ function Show-AppSelectionWindow {
# Load apps after window is shown (allows UI to render first)
$window.Add_ContentRendered({
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ LoadApps })
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ LoadApps }) | Out-Null
})
# Show the window and return dialog result

View File

@@ -216,7 +216,7 @@ function Show-ApplyModal {
$script:ApplyProgressCallback = $null
$script:ApplySubStepCallback = $null
}
})
}) | Out-Null
# Button handlers
$applyCloseBtn.Add_Click({

View File

@@ -1,4 +1,4 @@
function Show-MainWindow {
function Show-MainWindow {
Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms | Out-Null
# Get current Windows build version
@@ -210,21 +210,113 @@ function Show-MainWindow {
$onlyInstalledAppsBox = $window.FindName('OnlyInstalledAppsBox')
$loadingAppsIndicator = $window.FindName('LoadingAppsIndicator')
$appSelectionStatus = $window.FindName('AppSelectionStatus')
$defaultAppsBtn = $window.FindName('DefaultAppsBtn')
$headerNameBtn = $window.FindName('HeaderNameBtn')
$headerDescriptionBtn = $window.FindName('HeaderDescriptionBtn')
$headerAppIdBtn = $window.FindName('HeaderAppIdBtn')
$sortArrowName = $window.FindName('SortArrowName')
$sortArrowDescription = $window.FindName('SortArrowDescription')
$sortArrowAppId = $window.FindName('SortArrowAppId')
$presetsBtn = $window.FindName('PresetsBtn')
$presetsPopup = $window.FindName('PresetsPopup')
$presetDefaultApps = $window.FindName('PresetDefaultApps')
$presetLastUsed = $window.FindName('PresetLastUsed')
$jsonPresetsPanel = $window.FindName('JsonPresetsPanel')
$presetsArrow = $window.FindName('PresetsArrow')
$clearAppSelectionBtn = $window.FindName('ClearAppSelectionBtn')
# Load JSON-defined presets and build dynamic preset checkboxes
$script:JsonPresetCheckboxes = @()
foreach ($preset in (LoadAppPresetsFromJson)) {
$checkbox = New-Object System.Windows.Controls.CheckBox
$checkbox.Content = $preset.Name
$checkbox.IsThreeState = $true
$checkbox.Style = $window.Resources['PresetCheckBoxStyle']
$checkbox.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $preset.Name)
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name 'PresetAppIds' -Value $preset.AppIds
$jsonPresetsPanel.Children.Add($checkbox) | Out-Null
$script:JsonPresetCheckboxes += $checkbox
$checkbox.Add_Click({
if ($script:UpdatingPresets) { return }
$check = ($this.IsChecked -eq $true)
if ($this.IsChecked -eq $null) { $this.IsChecked = $false; $check = $false }
$presetIds = $this.PresetAppIds
ApplyPresetToApps -MatchFilter { param($c) $presetIds -contains $c.Tag }.GetNewClosure() -Check $check
})
}
# Track the last selected checkbox for shift-click range selection
$script:MainWindowLastSelectedCheckbox = $null
# Track current app loading operation to prevent race conditions
$script:CurrentAppLoadTimer = $null
$script:CurrentAppLoadJob = $null
$script:CurrentAppLoadJobStartTime = $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
# Set script-level variable for GUI window reference
$script:GuiWindow = $window
# Updates app selection status text in the App Selection tab
# Guard flag to prevent preset handlers from firing when we update their state programmatically
$script:UpdatingPresets = $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)
}
}
function SortApps {
$children = @($appsPanel.Children)
$key = switch ($script:SortColumn) {
'Name' { { $_.AppName } }
'Description' { { $_.AppDescription } }
'AppId' { { $_.Tag } }
}
$sorted = $children | Sort-Object $key -Descending:(-not $script:SortAscending)
$appsPanel.Children.Clear()
foreach ($checkbox in $sorted) {
$appsPanel.Children.Add($checkbox) | Out-Null
}
UpdateSortArrows
}
function SetSortColumn($column) {
if ($script:SortColumn -eq $column) {
$script:SortAscending = -not $script:SortAscending
} else {
$script:SortColumn = $column
$script:SortAscending = $true
}
SortApps
}
function UpdateAppSelectionStatus {
$selectedCount = 0
foreach ($child in $appsPanel.Children) {
@@ -235,6 +327,80 @@ function Show-MainWindow {
$appSelectionStatus.Text = "$selectedCount app(s) selected for removal"
}
# 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 {
# Build a set of currently checked app tags for fast lookup
$checkedTags = @{}
foreach ($child in $appsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox] -and $child.IsChecked) {
$checkedTags[$child.Tag] = $true
}
}
# 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 ($checkedTags.ContainsKey($child.Tag)) { $checked++ }
}
}
}
if ($total -eq 0) {
$checkbox.IsChecked = $false
$checkbox.IsEnabled = $false
} else {
$checkbox.IsEnabled = $true
if ($checked -eq 0) {
$checkbox.IsChecked = $false
} elseif ($checked -eq $total) {
$checkbox.IsChecked = $true
} else {
$checkbox.IsChecked = [System.Nullable[bool]]$null
}
}
}
SetPresetState $presetDefaultApps { param($c) $c.SelectedByDefault -eq $true }
foreach ($jsonCb in $script:JsonPresetCheckboxes) {
$localIds = $jsonCb.PresetAppIds
SetPresetState $jsonCb { param($c) $localIds -contains $c.Tag }.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) $script:SavedAppIds -contains $c.Tag }
}
}
finally {
$script:UpdatingPresets = $false
}
}
# Dynamically builds Tweaks UI from Features.json
function BuildDynamicTweaks {
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
@@ -309,7 +475,7 @@ function Show-MainWindow {
$combo = New-Object System.Windows.Controls.ComboBox
$combo.Name = $comboName
$combo.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $labelText)
foreach ($it in $items) { $cbItem = New-Object System.Windows.Controls.ComboBoxItem; $cbItem.Content = $it; $combo.Items.Add($cbItem) | Out-Null }
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
@@ -512,189 +678,243 @@ function Show-MainWindow {
try { $lblBorderObj = $window.FindName("$comboName`_LabelBorder") } catch {}
if ($lblBorderObj) { $lblBorderObj.ToolTip = $tipBlock }
}
$script:UiControlMappings[$comboName] = @{ Type='feature'; FeatureId = $feature.FeatureId; Action = $feature.Action }
$script:UiControlMappings[$comboName] = @{ Type='feature'; FeatureId = $feature.FeatureId; Action = $feature.Action; Label = $feature.Label }
}
}
}
# Build a feature-label lookup so GenerateOverview can resolve feature IDs without reloading JSON
$script:FeatureLabelLookup = @{}
foreach ($f in $featuresJson.Features) {
$script:FeatureLabelLookup[$f.FeatureId] = $f.Action + ' ' + $f.Label
}
}
# Helper function to complete app loading with the WinGet list
# Helper function to load apps and populate the app list panel
function script:LoadAppsWithList($listOfApps) {
$appsToAdd = LoadAppsDetailsFromJson -OnlyInstalled:$onlyInstalledAppsBox.IsChecked -InstalledList $listOfApps -InitialCheckedFromJson:$false
# Reset the last selected checkbox when loading a new list
$script:MainWindowLastSelectedCheckbox = $null
# Sort apps alphabetically and add to panel
$appsToAdd | Sort-Object -Property FriendlyName | ForEach-Object {
$checkbox = New-Object System.Windows.Controls.CheckBox
$checkbox.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $_.FriendlyName)
$checkbox.Tag = $_.AppId
$checkbox.IsChecked = $_.IsChecked
$checkbox.Style = $window.Resources["AppsPanelCheckBoxStyle"]
$loaderScriptPath = $script:LoadAppsDetailsScriptPath
$appsFilePath = $script:AppsListFilePath
$onlyInstalled = [bool]$onlyInstalledAppsBox.IsChecked
# Build table row content: App Name | Description | App ID
$row = New-Object System.Windows.Controls.Grid
$c0 = New-Object System.Windows.Controls.ColumnDefinition; $c0.Width = [System.Windows.GridLength]::new(160)
$c1 = New-Object System.Windows.Controls.ColumnDefinition; $c1.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
$c2 = New-Object System.Windows.Controls.ColumnDefinition; $c2.Width = [System.Windows.GridLength]::new(286)
$row.ColumnDefinitions.Add($c0); $row.ColumnDefinitions.Add($c1); $row.ColumnDefinitions.Add($c2)
$tbName = New-Object System.Windows.Controls.TextBlock
$tbName.Text = $_.FriendlyName
$tbName.Style = $window.Resources["AppNameTextStyle"]
[System.Windows.Controls.Grid]::SetColumn($tbName, 0)
$tbDesc = New-Object System.Windows.Controls.TextBlock
$tbDesc.Text = $_.Description
$tbDesc.Style = $window.Resources["AppDescTextStyle"]
$tbDesc.ToolTip = $_.Description
[System.Windows.Controls.Grid]::SetColumn($tbDesc, 1)
$tbId = New-Object System.Windows.Controls.TextBlock
$tbId.Text = $_.AppId
$tbId.Style = $window.Resources["AppIdTextStyle"]
$tbId.ToolTip = $_.AppId
[System.Windows.Controls.Grid]::SetColumn($tbId, 2)
$row.Children.Add($tbName) | Out-Null
$row.Children.Add($tbDesc) | Out-Null
$row.Children.Add($tbId) | Out-Null
$checkbox.Content = $row
# Store metadata in checkbox for later use
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name "AppName" -Value $_.FriendlyName
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name "AppDescription" -Value $_.Description
Add-Member -InputObject $checkbox -MemberType NoteProperty -Name "SelectedByDefault" -Value $_.SelectedByDefault
# Add event handler to update status
$checkbox.Add_Checked({ UpdateAppSelectionStatus })
$checkbox.Add_Unchecked({ UpdateAppSelectionStatus })
# Attach shift-click behavior for range selection
AttachShiftClickBehavior -checkbox $checkbox -appsPanel $appsPanel -lastSelectedCheckboxRef ([ref]$script:MainWindowLastSelectedCheckbox) -updateStatusCallback { UpdateAppSelectionStatus }
$appsPanel.Children.Add($checkbox) | Out-Null
# 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
}
# Hide loading indicator and navigation blocker, update status
$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
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.AppId) { $app.AppId } else { $null }
if ($automationName) { $checkbox.SetValue([System.Windows.Automation.AutomationProperties]::NameProperty, $automationName) }
$checkbox.Tag = $app.AppId
$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.AppId
$tbId.Style = $window.Resources['AppIdTextStyle']
$tbId.ToolTip = $app.AppId
[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
$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
}
# Loads apps into the UI
function LoadAppsIntoMainUI {
# Cancel any existing load operation to prevent race conditions
if ($script:CurrentAppLoadTimer -and $script:CurrentAppLoadTimer.IsEnabled) {
$script:CurrentAppLoadTimer.Stop()
}
if ($script:CurrentAppLoadJob) {
Remove-Job -Job $script:CurrentAppLoadJob -Force -ErrorAction SilentlyContinue
}
$script:CurrentAppLoadTimer = $null
$script:CurrentAppLoadJob = $null
$script:CurrentAppLoadJobStartTime = $null
# Show loading indicator and navigation blocker, clear existing apps immediately
# Prevent concurrent loads
if ($script:IsLoadingApps) { return }
$script:IsLoadingApps = $true
# 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 UI to update and render all changes (loading indicator, blocker, disabled buttons)
$window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [action]{})
# Schedule the actual loading work to run after UI has updated
$window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{
$listOfApps = ""
if ($onlyInstalledAppsBox.IsChecked -and ($script:WingetInstalled -eq $true)) {
# Start job to get list of installed apps via WinGet (async helper)
$asyncJob = GetInstalledAppsViaWinget -Async
$script:CurrentAppLoadJob = $asyncJob.Job
$script:CurrentAppLoadJobStartTime = $asyncJob.StartTime
# Create timer to poll job status without blocking UI
$script:CurrentAppLoadTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:CurrentAppLoadTimer.Interval = [TimeSpan]::FromMilliseconds(100)
$script:CurrentAppLoadTimer.Add_Tick({
# Check if this timer was cancelled (another load started)
if (-not $script:CurrentAppLoadJob -or -not $script:CurrentAppLoadTimer -or -not $script:CurrentAppLoadJobStartTime) {
if ($script:CurrentAppLoadTimer) { $script:CurrentAppLoadTimer.Stop() }
return
}
$elapsed = (Get-Date) - $script:CurrentAppLoadJobStartTime
# Check if job is complete or timed out (10 seconds)
if ($script:CurrentAppLoadJob.State -eq 'Completed') {
$script:CurrentAppLoadTimer.Stop()
$listOfApps = Receive-Job -Job $script:CurrentAppLoadJob
Remove-Job -Job $script:CurrentAppLoadJob -ErrorAction SilentlyContinue
$script:CurrentAppLoadJob = $null
$script:CurrentAppLoadTimer = $null
$script:CurrentAppLoadJobStartTime = $null
# Continue with loading apps
LoadAppsWithList $listOfApps
}
elseif ($elapsed.TotalSeconds -gt 10 -or $script:CurrentAppLoadJob.State -eq 'Failed') {
$script:CurrentAppLoadTimer.Stop()
Remove-Job -Job $script:CurrentAppLoadJob -Force -ErrorAction SilentlyContinue
$script:CurrentAppLoadJob = $null
$script:CurrentAppLoadTimer = $null
$script:CurrentAppLoadJobStartTime = $null
# Show error that the script was unable to get list of apps from WinGet
# 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
# Continue with loading all apps (unchecked now)
LoadAppsWithList ""
}
})
$script:CurrentAppLoadTimer.Start()
return # Exit here, timer will continue the work
}
}
# If checkbox is not checked or winget not installed, load all apps immediately
LoadAppsWithList $listOfApps
LoadAppsWithList $listOfApps
}
finally {
$script:IsLoadingApps = $false
}
}) | Out-Null
}
# Event handlers for app selection
$onlyInstalledAppsBox.Add_Checked({
LoadAppsIntoMainUI
LoadAppsIntoMainUI
})
$onlyInstalledAppsBox.Add_Unchecked({
LoadAppsIntoMainUI
LoadAppsIntoMainUI
})
# Quick selection buttons - only select apps actually in those categories
$defaultAppsBtn.Add_Click({
foreach ($child in $appsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
if ($child.SelectedByDefault -eq $true) {
$child.IsChecked = $true
} else {
$child.IsChecked = $false
}
}
# Animate arrow when popup opens/closes, and lazily update preset states
$presetsPopup.Add_Opened({
UpdatePresetStates
$animation = New-Object System.Windows.Media.Animation.DoubleAnimation
$animation.To = 180
$animation.Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromMilliseconds(200))
$animation.EasingFunction = New-Object System.Windows.Media.Animation.CubicEase
$animation.EasingFunction.EasingMode = 'EaseOut'
$presetsArrow.RenderTransform.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $animation)
})
$presetsPopup.Add_Closed({
$animation = New-Object System.Windows.Media.Animation.DoubleAnimation
$animation.To = 0
$animation.Duration = [System.Windows.Duration]::new([System.TimeSpan]::FromMilliseconds(200))
$animation.EasingFunction = New-Object System.Windows.Media.Animation.CubicEase
$animation.EasingFunction.EasingMode = 'EaseOut'
$presetsArrow.RenderTransform.BeginAnimation([System.Windows.Media.RotateTransform]::AngleProperty, $animation)
$presetsBtn.IsChecked = $false
})
# Close popup when clicking anywhere outside the popup or the presets button.
$window.Add_PreviewMouseDown({
if (-not $presetsPopup.IsOpen) { return }
if ($presetsPopup.Child -ne $null -and $presetsPopup.Child.IsMouseOver) { return }
$src = $_.OriginalSource -as [System.Windows.DependencyObject]
if ($src -ne $null) {
$inBtn = $presetsBtn.IsAncestorOf($src) -or [System.Object]::ReferenceEquals($presetsBtn, $src)
if (-not $inBtn) { $presetsPopup.IsOpen = $false }
}
})
# Toggle popup on button click
$presetsBtn.Add_Click({
$presetsPopup.IsOpen = -not $presetsPopup.IsOpen
$presetsBtn.IsChecked = $presetsPopup.IsOpen
})
# Preset: Default selection
$presetDefaultApps.Add_Click({
if ($script:UpdatingPresets) { return }
$check = ($this.IsChecked -eq $true)
if ($this.IsChecked -eq $null) { $this.IsChecked = $false; $check = $false }
ApplyPresetToApps -MatchFilter { param($c) $c.SelectedByDefault -eq $true } -Check $check
})
# Clear selection button + reset all preset checkboxes
$clearAppSelectionBtn.Add_Click({
foreach ($child in $appsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
$child.IsChecked = $false
}
}
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 (
@@ -830,7 +1050,7 @@ function Show-MainWindow {
# The 17px accounts for the scrollbar width + some padding
$tweakSearchBorder.Margin = [System.Windows.Thickness]::new(0, 0, 17, 0)
} else {
$tweakSearchBorder.Margin = [System.Windows.Thickness]::new(0, 0, 0, 0)
$tweakSearchBorder.Margin = [System.Windows.Thickness]::new(0)
}
})
@@ -980,9 +1200,6 @@ function Show-MainWindow {
# Update progress indicators
# Tab indices: 0=Home, 1=App Removal, 2=Tweaks, 3=Deployment Settings
$blueColor = "#0067c0"
$greyColor = "#808080"
$progressIndicator1 = $window.FindName('ProgressIndicator1') # App Removal
$progressIndicator2 = $window.FindName('ProgressIndicator2') # Tweaks
$progressIndicator3 = $window.FindName('ProgressIndicator3') # Deployment Settings
@@ -998,23 +1215,23 @@ function Show-MainWindow {
# Update indicator colors based on current tab
# Indicator 1 (App Removal) - tab index 1
if ($currentIndex -ge 1) {
$progressIndicator1.Fill = $blueColor
$progressIndicator1.Fill = $window.Resources['ProgressActiveColor']
} else {
$progressIndicator1.Fill = $greyColor
$progressIndicator1.Fill = $window.Resources['ProgressInactiveColor']
}
# Indicator 2 (Tweaks) - tab index 2
if ($currentIndex -ge 2) {
$progressIndicator2.Fill = $blueColor
$progressIndicator2.Fill = $window.Resources['ProgressActiveColor']
} else {
$progressIndicator2.Fill = $greyColor
$progressIndicator2.Fill = $window.Resources['ProgressInactiveColor']
}
# Indicator 3 (Deployment Settings) - tab index 3
if ($currentIndex -ge 3) {
$progressIndicator3.Fill = $blueColor
$progressIndicator3.Fill = $window.Resources['ProgressActiveColor']
} else {
$progressIndicator3.Fill = $greyColor
$progressIndicator3.Fill = $window.Resources['ProgressInactiveColor']
}
}
@@ -1033,7 +1250,7 @@ function Show-MainWindow {
$appRemovalScopeCombo.SelectedIndex = 0
}
1 {
$userSelectionDescription.Text = "Changes will be applied to a different user profile on this system."
$userSelectionDescription.Text = "Changes will be applied to a different user profile on this system. Note: changes may not apply correctly if the target user is currently logged in."
$otherUserPanel.Visibility = 'Visible'
$usernameValidationMessage.Text = ""
# Hide "Current user only" option, show "Target user only" option
@@ -1099,8 +1316,8 @@ function Show-MainWindow {
$username = $otherUsernameTextBox.Text.Trim()
$errorBrush = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#c42b1c"))
$successBrush = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#28a745"))
$errorBrush = $window.Resources['ValidationErrorColor']
$successBrush = $window.Resources['ValidationSuccessColor']
if ($username.Length -eq 0) {
$usernameValidationMessage.Text = "[X] Please enter a username"
@@ -1128,9 +1345,6 @@ function Show-MainWindow {
}
function GenerateOverview {
# Load Features.json
$featuresJson = LoadJsonFile -filePath $script:FeaturesFilePath -expectedVersion "1.0"
$changesList = @()
# Collect selected apps
@@ -1180,13 +1394,14 @@ function Show-MainWindow {
# For combobox: SelectedIndex 0 = No Change, so subtract 1 to index into Values
$selectedValue = $mapping.Values[$control.SelectedIndex - 1]
foreach ($fid in $selectedValue.FeatureIds) {
$feature = $featuresJson.Features | Where-Object { $_.FeatureId -eq $fid }
if ($feature) { $changesList += ($feature.Action + ' ' + $feature.Label) }
$label = $script:FeatureLabelLookup[$fid]
if ($label) { $changesList += $label }
}
}
elseif ($mapping.Type -eq 'feature') {
$feature = $featuresJson.Features | Where-Object { $_.FeatureId -eq $mapping.FeatureId }
if ($feature) { $changesList += ($feature.Action + ' ' + $feature.Label) }
$label = $script:FeatureLabelLookup[$mapping.FeatureId]
if (-not $label) { $label = $mapping.Action + ' ' + $mapping.Label }
$changesList += $label
}
}
}
@@ -1207,7 +1422,7 @@ function Show-MainWindow {
Show-MessageBox -Message $message -Title 'Selected Changes' -Button 'OK' -Icon 'None' -Width 600
}
$previousBtn.Add_Click({
$previousBtn.Add_Click({
Hide-Bubble -Immediate
if ($tabControl.SelectedIndex -gt 0) {
$tabControl.SelectedIndex--
@@ -1215,10 +1430,9 @@ function Show-MainWindow {
}
})
$nextBtn.Add_Click({
$nextBtn.Add_Click({
if ($tabControl.SelectedIndex -lt ($tabControl.Items.Count - 1)) {
$tabControl.SelectedIndex++
UpdateNavigationButtons
}
})
@@ -1240,11 +1454,11 @@ function Show-MainWindow {
ApplySettingsToUiControls -window $window -settingsJson $defaultsJson -uiControlMappings $script:UiControlMappings
}
# Select default apps
foreach ($child in $appsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
$child.IsChecked = ($child.SelectedByDefault -eq $true)
}
# 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
}
# Navigate directly to the Deployment Settings tab
@@ -1375,8 +1589,17 @@ function Show-MainWindow {
# Store selected user mode
switch ($userSelectionCombo.SelectedIndex) {
1 { AddParameter User ($otherUsernameTextBox.Text.Trim()) }
2 { AddParameter Sysprep }
0 {
Write-Host "Selected user mode: current user ($(GetUserName))"
}
1 {
Write-Host "Selected user mode: $($otherUsernameTextBox.Text.Trim())"
AddParameter User ($otherUsernameTextBox.Text.Trim())
}
2 {
Write-Host "Selected user mode: default user profile (Sysprep)"
AddParameter Sysprep
}
}
SaveSettings
@@ -1452,7 +1675,6 @@ function Show-MainWindow {
# Handle Load Last Used settings and Load Last Used apps
$loadLastUsedBtn = $window.FindName('LoadLastUsedBtn')
$loadLastUsedAppsBtn = $window.FindName('LoadLastUsedAppsBtn')
$lastUsedSettingsJson = LoadJsonFile -filePath $script:SavedSettingsFilePath -expectedVersion "1.0" -optionalFile
@@ -1481,28 +1703,24 @@ function Show-MainWindow {
$loadLastUsedBtn.Visibility = 'Collapsed'
}
# Show option to load last used apps if they exist
# Preset: Last used selection (wired to PresetLastUsed checkbox)
if ($appsSetting -and $appsSetting.ToString().Trim().Length -gt 0) {
$loadLastUsedAppsBtn.Add_Click({
try {
$savedApps = @()
if ($appsSetting -is [string]) { $savedApps = $appsSetting.Split(',') }
elseif ($appsSetting -is [array]) { $savedApps = $appsSetting }
$savedApps = $savedApps | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
# Parse and store saved app IDs for UpdatePresetStates
$script:SavedAppIds = @()
if ($appsSetting -is [string]) { $script:SavedAppIds = $appsSetting.Split(',') }
elseif ($appsSetting -is [array]) { $script:SavedAppIds = $appsSetting }
$script:SavedAppIds = $script:SavedAppIds | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
foreach ($child in $appsPanel.Children) {
if ($child -is [System.Windows.Controls.CheckBox]) {
if ($savedApps -contains $child.Tag) { $child.IsChecked = $true } else { $child.IsChecked = $false }
}
}
}
catch {
Show-MessageBox -Message "Failed to load last used app selection: $_" -Title "Error" -Button 'OK' -Icon 'Error'
}
$presetLastUsed.Add_Click({
if ($script:UpdatingPresets) { return }
$check = ($this.IsChecked -eq $true)
if ($this.IsChecked -eq $null) { $this.IsChecked = $false; $check = $false }
ApplyPresetToApps -MatchFilter { param($c) $script:SavedAppIds -contains $c.Tag } -Check $check
})
}
else {
$loadLastUsedAppsBtn.Visibility = 'Collapsed'
$script:SavedAppIds = $null
$presetLastUsed.Visibility = 'Collapsed'
}
# Clear All Tweaks button
@@ -1519,9 +1737,17 @@ function Show-MainWindow {
$control.SelectedIndex = 0
}
}
}
}
})
# Preload app data to speed up loading when user navigates to App Removal tab
try {
$script:PreloadedAppData = LoadAppsDetailsFromJson -OnlyInstalled:$false -InstalledList '' -InitialCheckedFromJson:$false
}
catch {
Write-Warning "Failed to preload apps list: $_"
}
# Show the window
return $window.ShowDialog()
}

View File

@@ -215,7 +215,7 @@ if (Test-Path "$env:TEMP/Win11Debloat") {
Write-Output "> Cleaning up..."
# Cleanup, remove Win11Debloat directory
Get-ChildItem -Path "$env:TEMP/Win11Debloat" -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Config,Logs | Remove-Item -Recurse -Force
Get-ChildItem -Path "$env:TEMP/Win11Debloat" -Exclude CustomAppsList,LastUsedSettings.json,Win11Debloat.log,Win11Debloat-Run.log,Config,Logs | Remove-Item -Recurse -Force
}
Write-Output ""

View File

@@ -0,0 +1,15 @@
# Add parameter to script and write to file
function AddParameter {
param (
$parameterName,
$value = $true
)
# Add parameter or update its value if key already exists
if (-not $script:Params.ContainsKey($parameterName)) {
$script:Params.Add($parameterName, $value)
}
else {
$script:Params[$parameterName] = $value
}
}

View File

@@ -0,0 +1,32 @@
function CheckIfUserExists {
param (
$userName
)
if ($userName -match '[<>:"|?*]') {
return $false
}
if ([string]::IsNullOrWhiteSpace($userName)) {
return $false
}
try {
$userExists = Test-Path "$env:SystemDrive\Users\$userName"
if ($userExists) {
return $true
}
$userExists = Test-Path ($env:USERPROFILE -Replace ('\\' + $env:USERNAME + '$'), "\$userName")
if ($userExists) {
return $true
}
}
catch {
Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system"
}
return $false
}

View File

@@ -0,0 +1,27 @@
# Check if this machine supports S0 Modern Standby power state. Returns true if S0 Modern Standby is supported, false otherwise.
function CheckModernStandbySupport {
$count = 0
try {
switch -Regex (powercfg /a) {
':' {
$count += 1
}
'(.*S0.{1,}\))' {
if ($count -eq 1) {
return $true
}
}
}
}
catch {
Write-Host "Error: Unable to check for S0 Modern Standby support, powercfg command failed" -ForegroundColor Red
Write-Host ""
Write-Host "Press any key to continue..."
$null = [System.Console]::ReadKey()
return $true
}
return $false
}

View File

@@ -0,0 +1,20 @@
# Generates a list of apps to remove based on the Apps parameter
function GenerateAppsList {
if (-not ($script:Params["Apps"] -and $script:Params["Apps"] -is [string])) {
return @()
}
$appMode = $script:Params["Apps"].toLower()
switch ($appMode) {
'default' {
$appsList = LoadAppsFromFile $script:AppsListFilePath
return $appsList
}
default {
$appsList = $script:Params["Apps"].Split(',') | ForEach-Object { $_.Trim() }
$validatedAppsList = ValidateAppslist $appsList
return $validatedAppsList
}
}
}

View File

@@ -0,0 +1,9 @@
function GetFriendlyTargetUserName {
$target = GetTargetUserForAppRemoval
switch ($target) {
"AllUsers" { return "all users" }
"CurrentUser" { return "the current user" }
default { return "user $target" }
}
}

View File

@@ -0,0 +1,9 @@
# Target is determined from $script:Params["AppRemovalTarget"] or defaults to "AllUsers"
# Target values: "AllUsers" (removes for all users + from image), "CurrentUser", or a specific username
function GetTargetUserForAppRemoval {
if ($script:Params.ContainsKey("AppRemovalTarget")) {
return $script:Params["AppRemovalTarget"]
}
return "AllUsers"
}

View File

@@ -0,0 +1,36 @@
# Returns the directory path of the specified user, exits script if user path can't be found
function GetUserDirectory {
param (
$userName,
$fileName = "",
$exitIfPathNotFound = $true
)
try {
if (-not (CheckIfUserExists -userName $userName) -and $userName -ne "*") {
Write-Error "User $userName does not exist on this system"
AwaitKeyToExit
}
$userDirectoryExists = Test-Path "$env:SystemDrive\Users\$userName"
$userPath = "$env:SystemDrive\Users\$userName\$fileName"
if ((Test-Path $userPath) -or ($userDirectoryExists -and (-not $exitIfPathNotFound))) {
return $userPath
}
$userDirectoryExists = Test-Path ($env:USERPROFILE -Replace ('\\' + $env:USERNAME + '$'), "\$userName")
$userPath = $env:USERPROFILE -Replace ('\\' + $env:USERNAME + '$'), "\$userName\$fileName"
if ((Test-Path $userPath) -or ($userDirectoryExists -and (-not $exitIfPathNotFound))) {
return $userPath
}
}
catch {
Write-Error "Something went wrong when trying to find the user directory path for user $userName. Please ensure the user exists on this system"
AwaitKeyToExit
}
Write-Error "Unable to find user directory path for user $userName"
AwaitKeyToExit
}

View File

@@ -0,0 +1,7 @@
function GetUserName {
if ($script:Params.ContainsKey("User")) {
return $script:Params.Item("User")
}
return $env:USERNAME
}

View File

@@ -0,0 +1,16 @@
# Processes all pending WPF window messages (input, render, etc.) to keep the UI responsive
# during long-running operations on the UI thread. Equivalent to Application.DoEvents().
function DoEvents {
if (-not $script:GuiWindow) { return }
$frame = [System.Windows.Threading.DispatcherFrame]::new()
$null = [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
[System.Windows.Threading.DispatcherPriority]::Background,
[System.Windows.Threading.DispatcherOperationCallback]{
param($f)
$f.Continue = $false
return $null
},
$frame
)
$null = [System.Windows.Threading.Dispatcher]::PushFrame($frame)
}

View File

@@ -0,0 +1,55 @@
# Runs a scriptblock in a background PowerShell runspace while keeping the UI responsive.
# In GUI mode, the work executes on a separate thread and the UI thread pumps messages (~60fps).
# In CLI mode, the scriptblock runs directly in the current session.
function Invoke-NonBlocking {
param(
[scriptblock]$ScriptBlock,
[object[]]$ArgumentList = @(),
[int]$TimeoutSeconds = 0
)
# CLI mode without timeout: run directly in-process
if (-not $script:GuiWindow -and $TimeoutSeconds -eq 0) {
return (& $ScriptBlock @ArgumentList)
}
$ps = [powershell]::Create()
try {
$null = $ps.AddScript($ScriptBlock.ToString())
foreach ($arg in $ArgumentList) {
$null = $ps.AddArgument($arg)
}
$handle = $ps.BeginInvoke()
if ($script:GuiWindow) {
# GUI mode: pump UI messages while waiting
$stopwatch = if ($TimeoutSeconds -gt 0) { [System.Diagnostics.Stopwatch]::StartNew() } else { $null }
while (-not $handle.IsCompleted) {
if ($stopwatch -and $stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
$ps.Stop()
throw "Operation timed out after $TimeoutSeconds seconds"
}
DoEvents
Start-Sleep -Milliseconds 16
}
}
else {
# CLI mode with timeout: block until completion or timeout
if (-not $handle.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) {
$ps.Stop()
throw "Operation timed out after $TimeoutSeconds seconds"
}
}
$result = $ps.EndInvoke($handle)
if ($result.Count -eq 0) { return $null }
if ($result.Count -eq 1) { return $result[0] }
return @($result)
}
finally {
$ps.Dispose()
}
}