From 822003d87f643dee36662ce15411a39a0f21b9b7 Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Wed, 15 Jul 2026 15:57:56 -0500 Subject: [PATCH] Fix AppX detection and add package restore support (#4842) * Fix installed AppX package detection * Add AppX package restore workflow * Add AppX operation progress feedback * Fix AppX operation launch race * Handle AppX workflow failures --- config/appx.json | 90 ++-- docs/content/userguide/tweaks/_index.md | 8 + .../private/Get-WinUtilInstalledAPPX.ps1 | 24 ++ functions/private/Install-WinUtilAPPX.ps1 | 79 ++++ .../private/Remove-WinUtilProvisionedAPPX.ps1 | 5 +- .../Set-WinUtilTweaksProgressIndicator.ps1 | 6 +- functions/public/Invoke-WPFAppxInstall.ps1 | 68 +++ functions/public/Invoke-WPFAppxRemoval.ps1 | 109 +++-- functions/public/Invoke-WPFButton.ps1 | 3 +- pester/appx.Tests.ps1 | 404 +++++++++++++++++- pester/configs.Tests.ps1 | 5 + pester/xaml.Tests.ps1 | 3 + xaml/inputXML.xaml | 6 +- 13 files changed, 733 insertions(+), 77 deletions(-) create mode 100644 functions/private/Get-WinUtilInstalledAPPX.ps1 create mode 100644 functions/private/Install-WinUtilAPPX.ps1 create mode 100644 functions/public/Invoke-WPFAppxInstall.ps1 diff --git a/config/appx.json b/config/appx.json index dffd9377..7a3550e9 100644 --- a/config/appx.json +++ b/config/appx.json @@ -4,203 +4,232 @@ "Content": "Feedback Hub", "Description": "Allows users to submit bug reports, feature suggestions, and diagnostic data directly to Microsoft.", "Panel": "0", - "PackageId": "Microsoft.WindowsFeedbackHub" + "PackageId": "Microsoft.WindowsFeedbackHub", + "StoreId": "9NBLGGH4R32N" }, "WPFAppxMicrosoft_GetHelp": { "Category": "Microsoft Apps", "Content": "Get Help", "Description": "Provides access to automated troubleshooting guides, support documentation, and direct Microsoft customer assistance.", "Panel": "0", - "PackageId": "Microsoft.GetHelp" + "PackageId": "Microsoft.GetHelp", + "StoreId": "9PKDZBMV1H3T" }, "WPFAppxMicrosoft_OutlookForWindows": { "Category": "Microsoft Apps", "Content": "Outlook for Windows", "Description": "Provides modern email management, calendar scheduling, and contact organization features.", "Panel": "0", - "PackageId": "Microsoft.OutlookForWindows" + "PackageId": "Microsoft.OutlookForWindows", + "StoreId": "9NRX63209R7B" }, "WPFAppxMSTeams": { "Category": "Microsoft Apps", "Content": "Microsoft Teams", "Description": "Facilitates instant messaging, video conferencing, file sharing, and workspace collaboration.", "Panel": "0", - "PackageId": "MSTeams" + "PackageId": "MSTeams", + "StoreId": "XP8BT8DW290MPQ" }, "WPFAppxClipchamp_Clipchamp": { "Category": "Utilities & Productivity", "Content": "Clipchamp", "Description": "Provides a user-friendly video editor with built-in templates, effects, and timeline editing tools.", "Panel": "0", - "PackageId": "Clipchamp.Clipchamp" + "PackageId": "Clipchamp.Clipchamp", + "StoreId": "9P1J8S7CCWWT" }, "WPFAppxMicrosoft_MicrosoftOfficeHub": { "Category": "Microsoft Apps", "Content": "Microsoft 365", "Description": "Serves as a centralized launcher and dashboard for accessing cloud-based Microsoft 365 apps and recent documents.", "Panel": "0", - "PackageId": "Microsoft.MicrosoftOfficeHub" + "PackageId": "Microsoft.MicrosoftOfficeHub", + "StoreId": "9WZDNCRD29V9" }, "WPFAppxMicrosoft_ZuneMusic": { "Category": "Utilities & Productivity", "Content": "Media Player", "Description": "Plays local audio and video files with modern playlist management and casting capabilities.", "Panel": "0", - "PackageId": "Microsoft.ZuneMusic" + "PackageId": "Microsoft.ZuneMusic", + "StoreId": "9WZDNCRFJ3PT" }, "WPFAppxMicrosoft_BingSearch": { "Category": "Bing & Web Services", "Content": "Bing Search", "Description": "Integrates Microsoft Bing search capabilities and web services directly into the operating system.", "Panel": "1", - "PackageId": "Microsoft.BingSearch" + "PackageId": "Microsoft.BingSearch", + "StoreId": "9NZBF4GT040C" }, "WPFAppxMicrosoftCorporationII_QuickAssist": { "Category": "Utilities & Productivity", "Content": "Quick Assist", "Description": "Enables secure remote technical support and screen sharing over an internet connection.", "Panel": "0", - "PackageId": "MicrosoftCorporationII.QuickAssist" + "PackageId": "MicrosoftCorporationII.QuickAssist", + "StoreId": "9P7BP5VNWKX5" }, "WPFAppxMicrosoft_WindowsDevHome": { "Category": "Developer Tools", "Content": "Dev Home", "Description": "Provides a specialized dashboard for software developer environment setups, repository syncing, and hardware widgets.", "Panel": "1", - "PackageId": "Microsoft.Windows.DevHome" + "PackageId": "Microsoft.Windows.DevHome", + "StoreId": "9N8MHTPHNGVV" }, "WPFAppxMicrosoft_WindowsCrossDevice": { "Category": "Microsoft Ecosystem", "Content": "Mobile Devices", "Description": "Manages system-level background connectivity with paired mobile devices. Removing this may disable cross-device features such as phone screen mirroring, file transfer, and mobile hotspot handoff integrated into Windows Settings.", "Panel": "0", - "PackageId": "MicrosoftWindows.CrossDevice" + "PackageId": "MicrosoftWindows.CrossDevice", + "StoreId": "9NTXGKQ8P7N0" }, "WPFAppxMicrosoft_Todos": { "Category": "Utilities & Productivity", "Content": "To Do", "Description": "Creates, tracks, and synchronizes personal tasks, smart lists, and daily reminders.", "Panel": "0", - "PackageId": "Microsoft.Todos" + "PackageId": "Microsoft.Todos", + "StoreId": "9NBLGGH5R558" }, "WPFAppxMicrosoft_PowerAutomateDesktop": { "Category": "Developer Tools", "Content": "Power Automate", "Description": "Automates repetitive workflows and desktop tasks using low-code visual scripting.", "Panel": "1", - "PackageId": "Microsoft.PowerAutomateDesktop" + "PackageId": "Microsoft.PowerAutomateDesktop", + "StoreId": "9NFTCH6J7FHV" }, "WPFAppxMicrosoft_YourPhone": { "Category": "Microsoft Ecosystem", "Content": "Phone Link", "Description": "Synchronizes text messages, phone notifications, photos, and calls from a mobile device to the desktop.", "Panel": "0", - "PackageId": "Microsoft.YourPhone" + "PackageId": "Microsoft.YourPhone", + "StoreId": "9NMPJ99VJBWV" }, "WPFAppxMicrosoft_MicrosoftStickyNotes": { "Category": "Utilities & Productivity", "Content": "Sticky Notes", "Description": "Creates quick, floating text notes on the desktop that automatically sync across devices.", "Panel": "0", - "PackageId": "Microsoft.MicrosoftStickyNotes" + "PackageId": "Microsoft.MicrosoftStickyNotes", + "StoreId": "9NBLGGH4QGHW" }, "WPFAppxMicrosoft_WindowsSoundRecorder": { "Category": "Utilities & Productivity", "Content": "Sound Recorder", "Description": "Records and trims live audio inputs with simple microphone adjustment controls.", "Panel": "0", - "PackageId": "Microsoft.WindowsSoundRecorder" + "PackageId": "Microsoft.WindowsSoundRecorder", + "StoreId": "9WZDNCRFHWKN" }, "WPFAppxMicrosoft_WindowsAlarms": { "Category": "Utilities & Productivity", "Content": "Clock", "Description": "Features world clocks, alarms, countdown timers, stopwatches, and dedicated focus session tracking.", "Panel": "0", - "PackageId": "Microsoft.WindowsAlarms" + "PackageId": "Microsoft.WindowsAlarms", + "StoreId": "9WZDNCRFJ3PR" }, "WPFAppxMicrosoft_Paint": { "Category": "Utilities & Productivity", "Content": "Paint", "Description": "Provides built-in digital sketching, basic image editing, and pixel-level graphic manipulation tools.", "Panel": "0", - "PackageId": "Microsoft.Paint" + "PackageId": "Microsoft.Paint", + "StoreId": "9PCFS5B6T72H" }, "WPFAppxMicrosoft_WindowsNotepad": { "Category": "Utilities & Productivity", "Content": "Notepad", "Description": "Provides a lightweight text editor with multi-tab support for plain text files and code snippets.", "Panel": "0", - "PackageId": "Microsoft.WindowsNotepad" + "PackageId": "Microsoft.WindowsNotepad", + "StoreId": "9MSMLRH6LZF3" }, "WPFAppxMicrosoft_ScreenSketch": { "Category": "Utilities & Productivity", "Content": "Snipping Tool", "Description": "Captures screenshots or screen recordings with built-in markup, image cropping, and optical character recognition (OCR).", "Panel": "0", - "PackageId": "Microsoft.ScreenSketch" + "PackageId": "Microsoft.ScreenSketch", + "StoreId": "9MZ95KL8MR0L" }, "WPFAppxMicrosoft_Copilot": { "Category": "Bing & Web Services", "Content": "Copilot", "Description": "Launches the Microsoft AI companion for contextual answers, creative writing assistance, and intelligent web search.", "Panel": "1", - "PackageId": "Microsoft.Copilot" + "PackageId": "Microsoft.Copilot", + "StoreId": "9NHT9RB2F4HD" }, "WPFAppxMicrosoft_WindowsCalculator": { "Category": "Utilities & Productivity", "Content": "Calculator", "Description": "Performs standard arithmetic, scientific operations, programming calculations, and unit conversions.", "Panel": "0", - "PackageId": "Microsoft.WindowsCalculator" + "PackageId": "Microsoft.WindowsCalculator", + "StoreId": "9WZDNCRFHVN5" }, "WPFAppxMicrosoft_WindowsCamera": { "Category": "Utilities & Productivity", "Content": "Camera", "Description": "Captures photographs and records video files via connected webcams or imaging hardware.", "Panel": "0", - "PackageId": "Microsoft.WindowsCamera" + "PackageId": "Microsoft.WindowsCamera", + "StoreId": "9WZDNCRFJBBG" }, "WPFAppxMicrosoft_WindowsPhotos": { "Category": "Utilities & Productivity", "Content": "Photos", "Description": "Organizes, views, and crops local images with basic color adjustment and album creation tools.", "Panel": "0", - "PackageId": "Microsoft.Windows.Photos" + "PackageId": "Microsoft.Windows.Photos", + "StoreId": "9WZDNCRFJBH4" }, "WPFAppxMicrosoft_BingNews": { "Category": "Bing & Web Services", "Content": "News", "Description": "Aggregates breaking news headlines, personalized article feeds, and world current events.", "Panel": "1", - "PackageId": "Microsoft.BingNews" + "PackageId": "Microsoft.BingNews", + "StoreId": "9WZDNCRFHVFW" }, "WPFAppxMicrosoft_BingWeather": { "Category": "Bing & Web Services", "Content": "Weather", "Description": "Displays local real-time weather tracking, radar maps, and historical meteorological forecasts.", "Panel": "1", - "PackageId": "Microsoft.BingWeather" + "PackageId": "Microsoft.BingWeather", + "StoreId": "9WZDNCRFJ3Q2" }, "WPFAppxMicrosoft_GamingApp": { "Category": "Xbox & Gaming", "Content": "Xbox App", "Description": "Serves as the primary gaming library manager, social community interface, and PC Game Pass dashboard.", "Panel": "1", - "PackageId": "Microsoft.GamingApp" + "PackageId": "Microsoft.GamingApp", + "StoreId": "9MV0B5HZVK9Z" }, "WPFAppxMicrosoft_XboxGamingOverlay": { "Category": "Xbox & Gaming", "Content": "Xbox Game Bar", "Description": "Provides customizable in-game status widgets, audio balancing sliders, system monitoring tools, and gameplay recording.", "Panel": "1", - "PackageId": "Microsoft.XboxGamingOverlay" + "PackageId": "Microsoft.XboxGamingOverlay", + "StoreId": "9NZKPSTSNW4P" }, "WPFAppxMicrosoft_XboxIdentityProvider": { "Category": "Xbox & Gaming", "Content": "Xbox Identity Provider", "Description": "Manages Xbox network user authentication and background account validation for connected titles. Warning: removing this may break Microsoft account sign-in for non-Xbox games and apps that rely on this authentication pipeline.", "Panel": "1", - "PackageId": "Microsoft.XboxIdentityProvider" + "PackageId": "Microsoft.XboxIdentityProvider", + "StoreId": "9WZDNCRD1HKW" }, "WPFAppxMicrosoft_XboxSpeechToTextOverlay": { "Category": "Xbox & Gaming", @@ -221,7 +250,8 @@ "Content": "Start Experiences App", "Description": "Powers the Windows Widgets board, delivering a personalized feed of news, weather, sports, and finance content.", "Panel": "1", - "PackageId": "Microsoft.StartExperiencesApp" + "PackageId": "Microsoft.StartExperiencesApp", + "StoreId": "9PC1H9VN18CM" }, "WPFAppxMicrosoft_MicrosoftSolitaireCollection": { "Category": "Xbox & Gaming", diff --git a/docs/content/userguide/tweaks/_index.md b/docs/content/userguide/tweaks/_index.md index cf12f999..ef16108b 100644 --- a/docs/content/userguide/tweaks/_index.md +++ b/docs/content/userguide/tweaks/_index.md @@ -34,6 +34,14 @@ Use the quick-selection buttons at the top of the Tweaks tab to speed up setup: * **Select Tweaks to Remove**: Choose the tweaks you want to disable or remove. * **Undo Tweaks**: Click **Undo Selected Tweaks** at the bottom of the screen to apply the changes. +### AppX Packages + +Open **AppX Removal** from the Tweaks tab to manage the listed Windows apps. Select one or more packages, then choose **Install Selected** or **Remove Selected**. + +When installing a selected package, WinUtil first registers an existing local `AppxManifest.xml`. If no usable local manifest remains, WinUtil installs the package from the Microsoft Store through WinGet when a Store product ID is available. + +During AppX installation or removal, the window-level progress bar shows the current package, completed package count, and overall progress. The Windows taskbar also reflects progress and the final success or failure state. + ### Essential Tweaks Essential Tweaks are the safest starting point for most systems. They focus on lower-risk changes that improve usability, reduce noise, and avoid the more invasive changes found in advanced options. diff --git a/functions/private/Get-WinUtilInstalledAPPX.ps1 b/functions/private/Get-WinUtilInstalledAPPX.ps1 new file mode 100644 index 00000000..6f74ff8c --- /dev/null +++ b/functions/private/Get-WinUtilInstalledAPPX.ps1 @@ -0,0 +1,24 @@ +function Get-WinUtilInstalledAPPX { + <# + + .SYNOPSIS + Gets the names of AppX packages installed for all users + + #> + + # AppX module auto-loading can leave PowerShell 7 dependent on a temporary Windows PowerShell + # compatibility proxy. Run the query in Windows PowerShell 5.1 so it remains available after + # those temporary proxy files are removed. + $ps5Command = { + Get-AppxPackage -AllUsers -ErrorAction Stop | Select-Object -ExpandProperty Name + } + + $packageOutput = powershell.exe -NoProfile -NonInteractive -Command $ps5Command 2>&1 + if ($LASTEXITCODE -ne 0) { + $failureDetails = ($packageOutput | Out-String).Trim() + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "Failed to get installed AppX packages: $failureDetails" + return @() + } + + return @($packageOutput) +} diff --git a/functions/private/Install-WinUtilAPPX.ps1 b/functions/private/Install-WinUtilAPPX.ps1 new file mode 100644 index 00000000..57c0cece --- /dev/null +++ b/functions/private/Install-WinUtilAPPX.ps1 @@ -0,0 +1,79 @@ +function Install-WinUtilAPPX { + <# + + .SYNOPSIS + Registers a local AppX package or installs it from the Microsoft Store + + .PARAMETER Name + The AppX package name to install + + .PARAMETER StoreId + The optional Microsoft Store product ID used when no local manifest is available + + #> + param ( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string]$StoreId + ) + + Write-WinUtilLog -Component "AppX" -Message "Installing AppX package: $Name" + + # AppX and DISM cmdlets are more reliable in Windows PowerShell 5.1. Query both installed and + # provisioned package metadata because either can expose a local manifest that can be registered. + $ps5Command = { + $packageName = $args[0] + $manifestPaths = [System.Collections.Generic.List[string]]::new() + + Get-AppxPackage -AllUsers -Name $packageName -ErrorAction SilentlyContinue | + Sort-Object -Property Version -Descending | + ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($_.InstallLocation)) { + $manifestPaths.Add((Join-Path $_.InstallLocation "AppxManifest.xml")) + } + } + + Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | + Where-Object DisplayName -EQ $packageName | + ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($_.InstallLocation)) { + $manifestPaths.Add((Join-Path $_.InstallLocation "AppxManifest.xml")) + } + } + + $manifestPath = $manifestPaths | + Select-Object -Unique | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + + if ($null -ne $manifestPath) { + Add-AppxPackage -Register $manifestPath -DisableDevelopmentMode -ErrorAction Stop + Write-Output $manifestPath + } + } + + $manifestOutput = powershell.exe -NoProfile -NonInteractive -Command $ps5Command -args $Name 2>&1 + if ($LASTEXITCODE -eq 0 -and $null -ne $manifestOutput) { + $manifestPath = ($manifestOutput | Select-Object -Last 1).ToString().Trim() + if (-not [string]::IsNullOrWhiteSpace($manifestPath)) { + Write-WinUtilLog -Component "AppX" -Message "Registered local AppX manifest for $Name`: $manifestPath" + return + } + } + + if ($LASTEXITCODE -ne 0) { + $failureDetails = ($manifestOutput | Out-String).Trim() + Write-WinUtilLog -Level "WARN" -Component "AppX" -Message "Local AppX registration failed for $Name`: $failureDetails" + } + + if ([string]::IsNullOrWhiteSpace($StoreId)) { + $errorMessage = "Unable to install $Name because no local manifest or Microsoft Store ID is available." + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage + throw $errorMessage + } + + Write-WinUtilLog -Component "AppX" -Message "No usable local manifest found for $Name. Installing Microsoft Store product $StoreId." + Install-WinUtilWinget + Install-WinUtilProgramWinget -Action Install -Programs @("msstore:$StoreId") +} diff --git a/functions/private/Remove-WinUtilProvisionedAPPX.ps1 b/functions/private/Remove-WinUtilProvisionedAPPX.ps1 index 98709fb4..2e0867c7 100644 --- a/functions/private/Remove-WinUtilProvisionedAPPX.ps1 +++ b/functions/private/Remove-WinUtilProvisionedAPPX.ps1 @@ -53,8 +53,9 @@ function Remove-WinUtilProvisionedAPPX { $removalOutput = powershell.exe -NoProfile -NonInteractive -Command $ps5Command -args $PackageList 2>&1 if ($LASTEXITCODE -ne 0 -or $null -ne $removalOutput) { $failureDetails = ($removalOutput | Out-String).Trim() - Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX provisioned package removal failed: $failureDetails" - return + $errorMessage = "AppX provisioned package removal failed: $failureDetails" + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage + throw $errorMessage } Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed." diff --git a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 b/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 index cface543..5a470768 100644 --- a/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 +++ b/functions/private/Set-WinUtilTweaksProgressIndicator.ps1 @@ -1,9 +1,9 @@ function Set-WinUtilTweaksProgressIndicator { <# .SYNOPSIS - Shows, updates, or hides the window-level progress indicator used by the - Tweaks and Undo workflows. It lives outside the TabControl, so unlike the - Install tab's progress bar it stays visible no matter which tab is active. + Shows, updates, or hides the window-level progress indicator used by long-running + workflows such as Tweaks, Undo, and AppX management. It lives outside the TabControl, + so unlike the Install tab's progress bar it stays visible no matter which tab is active. .PARAMETER Visible Whether the indicator should be shown or hidden. .PARAMETER Label diff --git a/functions/public/Invoke-WPFAppxInstall.ps1 b/functions/public/Invoke-WPFAppxInstall.ps1 new file mode 100644 index 00000000..90276453 --- /dev/null +++ b/functions/public/Invoke-WPFAppxInstall.ps1 @@ -0,0 +1,68 @@ +function Invoke-WPFAppxInstall { + if ($sync.ProcessRunning) { + Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" + return + } + + if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { + Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" + return + } + + $selected = @($sync.selectedAppx) + $apps = $sync.configs.appxHashtable + + $sync.ProcessRunning = $true + Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { + param($selected, $apps) + + $totalPackages = @($selected).Count + $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher + + try { + Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)." + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX install (0/$totalPackages)" -Percent 0 + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } + } + + for ($index = 0; $index -lt $totalPackages; $index++) { + $key = $selected[$index] + $app = $apps[$key] + $position = $index + 1 + $startPercent = [int](($index / $totalPackages) * 100) + + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $($app.Content) ($position/$totalPackages)" -Percent $startPercent + } + Write-Host "Installing $($app.Content)" + Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId + + $completedPercent = [int](($position / $totalPackages) * 100) + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } + } + } + + Write-Host "=================================" + Write-Host "-- AppX Install Finished ---" + Write-Host "=================================" + Write-WinUtilLog -Component "AppX" -Message "AppX install finished." + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install finished" -Percent 100 + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } + } + } + catch { + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX install failed: $($_.Exception.Message)" + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install failed" -Percent 100 + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + } + } + finally { + $sync.ProcessRunning = $false + } + } +} diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index ef4f13ea..23090c4e 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -1,57 +1,100 @@ function Invoke-WPFAppxRemoval { + if ($sync.ProcessRunning) { + Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning" + return + } + if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" return } - $selected = $sync.selectedAppx + $selected = @($sync.selectedAppx) $apps = $sync.configs.appxHashtable + $sync.ProcessRunning = $true Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { param($selected, $apps) - $sync.ProcessRunning = $true - Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $(@($selected).Count) selected package(s)." - + $totalPackages = @($selected).Count + $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher $packageList = [System.Collections.Generic.List[string]]::new() - foreach ($key in $selected) { - if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { - # Making sure Game Bar isn't running - Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." - Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue - - # This stops annoying ms-gamebar popup when launching games. - Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." - Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 + try { + Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $totalPackages selected package(s)." + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX removal (0/$totalPackages)" -Percent 0 + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" } } - if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { - Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." - Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue + for ($index = 0; $index -lt $totalPackages; $index++) { + $key = $selected[$index] + $app = $apps[$key] + $position = $index + 1 + $startPercent = [int](($index / $totalPackages) * 90) + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing $($app.Content) ($position/$totalPackages)" -Percent $startPercent + } + + if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { + # Making sure Game Bar isn't running + Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." + Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue + + # This stops annoying ms-gamebar popup when launching games. + Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." + Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 + } + + if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { + Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." + Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue + } + + Write-Host "Removing $($app.Content)" + Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))." + Remove-WinUtilAPPX -Name $app.PackageId + $packageList.Add($app.PackageId) + + if ($key -eq "WPFAppxMSTeams") { + # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office + Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." + Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force + } + + $completedPercent = [int](($position / $totalPackages) * 90) + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) } + } } - Write-Host "Removing $($apps[$key].Content)" - Write-WinUtilLog -Component "AppX" -Message "Removing $($apps[$key].Content) ($($apps[$key].PackageId))." - Remove-WinUtilAPPX -Name $apps[$key].PackageId - $packageList.Add($apps[$key].PackageId) + if ($packageList.Count -gt 0) { + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing provisioned AppX packages" -Percent 90 + } + Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() + } - if ($key -eq "WPFAppxMSTeams") { - # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office - Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." - Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force + Write-Host "=================================" + Write-Host "-- AppX Removal Finished ---" + Write-Host "=================================" + Write-WinUtilLog -Component "AppX" -Message "AppX removal finished." + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal finished" -Percent 100 + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } } } - - if ($packageList.Count -gt 0) { - Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() + catch { + Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX removal failed: $($_.Exception.Message)" + if ($hasUI) { + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal failed" -Percent 100 + Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + } + } + finally { + $sync.ProcessRunning = $false } - Write-Host "=================================" - Write-Host "-- AppX Removal Finished ---" - Write-Host "=================================" - Write-WinUtilLog -Component "AppX" -Message "AppX removal finished." - - $sync.ProcessRunning = $false } } diff --git a/functions/public/Invoke-WPFButton.ps1 b/functions/public/Invoke-WPFButton.ps1 index 5a8d3538..cfa27b59 100644 --- a/functions/public/Invoke-WPFButton.ps1 +++ b/functions/public/Invoke-WPFButton.ps1 @@ -68,6 +68,7 @@ function Invoke-WPFButton { "WPFGetInstalledTweaks" {Invoke-WPFGetInstalled -CheckBox "tweaks"} "WPFAppxRemoval" {Invoke-WPFTab "WPFTab6BT"} "WPFBackToTweaks" {Invoke-WPFTab "WPFTab2BT"} + "WPFInstallSelectedAppx" {Invoke-WPFAppxInstall} "WPFRemoveSelectedAppx" {Invoke-WPFAppxRemoval} "WPFDefaultAppxSelection" {Invoke-WPFPresets "AppxDefault" -checkboxfilterpattern "WPFAppx*"} "WPFSelectAllAppx" { @@ -77,7 +78,7 @@ function Invoke-WPFButton { $sync.configs.appxHashtable.Keys | ForEach-Object {$sync.$_.IsChecked = $false} } "WPFGetInstalledAppx" { - $installedAppxPackages = Get-AppxPackage -AllUsers | Select-Object -ExpandProperty Name + $installedAppxPackages = Get-WinUtilInstalledAPPX foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) { if ($appx.Value.PackageId -in $installedAppxPackages) { $sync.$($appx.Key).IsChecked = $true diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 index 3a341dff..a732c225 100644 --- a/pester/appx.Tests.ps1 +++ b/pester/appx.Tests.ps1 @@ -1,12 +1,16 @@ #=========================================================================== -# Tests - AppX Removal +# Tests - AppX Management #=========================================================================== BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilInstalledAPPX.ps1") + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilAPPX.ps1") . (Join-Path $script:repoRoot "functions\private\Remove-WinUtilAPPX.ps1") . (Join-Path $script:repoRoot "functions\private\Remove-WinUtilProvisionedAPPX.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxInstall.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1") $tokens = $null $parseErrors = $null @@ -20,6 +24,16 @@ BeforeAll { }, $true) $script:provisionedRemovalScriptBlock = $ps5CommandAssignment.Right.Expression.ScriptBlock.GetScriptBlock() + $installSourcePath = Join-Path $script:repoRoot "functions\private\Install-WinUtilAPPX.ps1" + $installSourceAst = [System.Management.Automation.Language.Parser]::ParseFile($installSourcePath, [ref]$tokens, [ref]$parseErrors) + $installCommandAssignment = $installSourceAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -eq "ps5Command" + }, $true) + $script:appxInstallScriptBlock = $installCommandAssignment.Right.Expression.ScriptBlock.GetScriptBlock() + function Write-WinUtilLog { param($Message, $Level, $Component) } @@ -29,8 +43,25 @@ BeforeAll { function Invoke-WPFRunspace { param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) } + function Invoke-WPFUIThread { + param([scriptblock]$ScriptBlock) + } + function Set-WinUtilProgressBar { + param($Label, $Percent) + } + function Set-WinUtilTweaksProgressIndicator { + param($Visible, $Label, $Percent) + } + function powershell.exe { } function Get-AppxPackage { - param($Name, [switch]$AllUsers) + param($Name, [switch]$AllUsers, $ErrorAction) + } + function Add-AppxPackage { + param($Register, [switch]$DisableDevelopmentMode, $ErrorAction) + } + function Install-WinUtilWinget { } + function Install-WinUtilProgramWinget { + param($Action, $Programs) } function Remove-AppxPackage { param( @@ -73,6 +104,162 @@ BeforeAll { } } +Describe "Get-WinUtilInstalledAPPX" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock powershell.exe { + $global:LASTEXITCODE = 0 + @("Example.One", "Example.Two") + } + } + + It "queries installed package names through Windows PowerShell" { + $result = Get-WinUtilInstalledAPPX + + $result | Should -Be @("Example.One", "Example.Two") + Should -Invoke -CommandName powershell.exe -Times 1 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 0 -Exactly + } + + It "logs query failures and returns no package names" { + Mock powershell.exe { + $global:LASTEXITCODE = 1 + "AppX query failed" + } + + $result = @(Get-WinUtilInstalledAPPX) + + $result | Should -HaveCount 0 + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and + $Component -eq "AppX" -and + $Message -eq "Failed to get installed AppX packages: AppX query failed" + } + } +} + +Describe "Install-WinUtilAPPX" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Install-WinUtilWinget { } + Mock Install-WinUtilProgramWinget { } + Mock powershell.exe { + $global:LASTEXITCODE = 0 + "C:\Program Files\WindowsApps\Example.Package\AppxManifest.xml" + } + } + + It "uses a local manifest without contacting the Microsoft Store" { + Install-WinUtilAPPX -Name "Example.Package" -StoreId "9EXAMPLE1234" + + Should -Invoke -CommandName Install-WinUtilWinget -Times 0 -Exactly + Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 0 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "AppX" -and + $Message -like "Registered local AppX manifest for Example.Package*" + } + } + + It "falls back to the Microsoft Store when no local manifest is available" { + Mock powershell.exe { $global:LASTEXITCODE = 0 } + + Install-WinUtilAPPX -Name "Example.Package" -StoreId "9EXAMPLE1234" + + Should -Invoke -CommandName Install-WinUtilWinget -Times 1 -Exactly + Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { + $Action -eq "Install" -and $Programs.Count -eq 1 -and $Programs[0] -eq "msstore:9EXAMPLE1234" + } + } + + It "logs local registration failures before using the Microsoft Store" { + Mock powershell.exe { + $global:LASTEXITCODE = 1 + "Registration failed" + } + + Install-WinUtilAPPX -Name "Example.Package" -StoreId "9EXAMPLE1234" + + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "WARN" -and + $Component -eq "AppX" -and + $Message -eq "Local AppX registration failed for Example.Package: Registration failed" + } + Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly + } + + It "throws after logging an error when neither install method is available" { + Mock powershell.exe { $global:LASTEXITCODE = 0 } + + { Install-WinUtilAPPX -Name "Example.Package" } | + Should -Throw "Unable to install Example.Package because no local manifest or Microsoft Store ID is available." + + Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 0 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and + $Component -eq "AppX" -and + $Message -eq "Unable to install Example.Package because no local manifest or Microsoft Store ID is available." + } + } + + It "registers an installed package manifest through Windows PowerShell" { + Mock Get-AppxPackage { + [pscustomobject]@{ + InstallLocation = "C:\Program Files\WindowsApps\Example.Package" + Version = [version]"2.0.0.0" + } + } + Mock Get-AppxProvisionedPackage { } + Mock Test-Path { $LiteralPath -eq "C:\Program Files\WindowsApps\Example.Package\AppxManifest.xml" } + Mock Add-AppxPackage { } + + $result = & $script:appxInstallScriptBlock "Example.Package" + + $result | Should -Be "C:\Program Files\WindowsApps\Example.Package\AppxManifest.xml" + Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly + Should -Invoke -CommandName Add-AppxPackage -Times 1 -Exactly + } +} + +Describe "Get installed AppX selection" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $false + configs = @{ + feature = @{} + appxHashtable = @{ + WPFAppxExample = [pscustomobject]@{ PackageId = "Example.Package" } + WPFAppxMissing = [pscustomobject]@{ PackageId = "Missing.Package" } + } + } + WPFAppxExample = [pscustomobject]@{ IsChecked = $false } + WPFAppxMissing = [pscustomobject]@{ IsChecked = $false } + }) + + Mock Set-WinUtilProgressBar { } + Mock Set-WinUtilTweaksProgressIndicator { } + Mock Get-WinUtilInstalledAPPX { @("Example.Package") } + Mock Invoke-WPFAppxInstall { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "selects configured packages returned by the compatibility-safe query" { + Invoke-WPFButton -Button "WPFGetInstalledAppx" + + Should -Invoke -CommandName Get-WinUtilInstalledAPPX -Times 1 -Exactly + $script:sync.WPFAppxExample.IsChecked | Should -BeTrue + $script:sync.WPFAppxMissing.IsChecked | Should -BeFalse + } + + It "routes the install button to the AppX install workflow" { + Invoke-WPFButton -Button "WPFInstallSelectedAppx" + + Should -Invoke -CommandName Invoke-WPFAppxInstall -Times 1 -Exactly + } +} + Describe "Remove-WinUtilAPPX" { BeforeEach { Mock Write-Host { } @@ -155,13 +342,123 @@ Describe "Remove-WinUtilProvisionedAPPX" { } } - It "handles child process failures before logging completion" { + It "throws after logging child process failures" { $source = Get-Content -Path $provisionedSourcePath -Raw $source | Should -Match '\$removalOutput = powershell\.exe .* 2>&1' - $source | Should -Match 'if \(\$LASTEXITCODE -ne 0 -or \$null -ne \$removalOutput\)' - $source | Should -Match 'Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX provisioned package removal failed:' - $source | Should -Match '(?s)AppX provisioned package removal failed:.*return.*AppX provisioned package removal completed\.' + $source | Should -Match 'Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message \$errorMessage' + $source | Should -Match '(?s)AppX provisioned package removal failed:.*throw \$errorMessage.*AppX provisioned package removal completed\.' + } +} + +Describe "Invoke-WPFAppxInstall" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $false + Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } + selectedAppx = [System.Collections.Generic.List[string]]::new() + configs = @{ + appxHashtable = @{ + WPFAppxExample = [pscustomobject]@{ + Content = "Example App" + PackageId = "Example.Package" + StoreId = "9EXAMPLE1234" + } + } + } + }) + $script:capturedAppxInstallScriptBlock = $null + $script:capturedAppxInstallParameterList = $null + $script:appxInstallProcessRunningAtLaunch = $null + + Mock Show-WinUtilMessage { "OK" } + Mock Write-Host { } + Mock Write-WinUtilLog { } + Mock Set-WinUtilTweaksProgressIndicator { } + Mock Invoke-WPFUIThread { } + Mock Install-WinUtilAPPX { } + Mock Invoke-WPFRunspace { + $script:appxInstallProcessRunningAtLaunch = $script:sync.ProcessRunning + $script:capturedAppxInstallScriptBlock = $ScriptBlock + $script:capturedAppxInstallParameterList = $ParameterList + [pscustomobject]@{ MockHandle = $true } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxInstallParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name appxInstallProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue + } + + It "prompts and exits when no AppX packages are selected for install" { + Invoke-WPFAppxInstall + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "No AppX Package selected" -and + $Title -eq "Error" -and + $Button -eq "OK" -and + $Icon -eq "Error" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + + It "prevents overlapping AppX install operations" { + $script:sync.ProcessRunning = $true + $script:sync.selectedAppx.Add("WPFAppxExample") + + Invoke-WPFAppxInstall + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "An AppX process is currently running." -and + $Title -eq "WinUtil" -and + $Button -eq "OK" -and + $Icon -eq "Warning" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + + It "installs selected AppX packages with their Store IDs" { + $script:sync.selectedAppx.Add("WPFAppxExample") + + Invoke-WPFAppxInstall + $script:appxInstallProcessRunningAtLaunch | Should -BeTrue + & $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable + + $script:capturedAppxInstallParameterList[0][1][0] | Should -Be "WPFAppxExample" + Should -Invoke -CommandName Install-WinUtilAPPX -Times 1 -Exactly -ParameterFilter { + $Name -eq "Example.Package" -and $StoreId -eq "9EXAMPLE1234" + } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "Installing Example App (1/1)" -and $Percent -eq 0 + } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "Installed Example App (1/1)" -and $Percent -eq 100 + } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "AppX install finished" -and $Percent -eq 100 + } + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + } + $script:sync.ProcessRunning | Should -BeFalse + } + + It "shows failure feedback and clears ProcessRunning when install fails" { + $script:sync.selectedAppx.Add("WPFAppxExample") + Mock Install-WinUtilAPPX { throw "Install failed" } + + Invoke-WPFAppxInstall + & $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable + + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "AppX install failed" -and $Percent -eq 100 + } + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' + } + $script:sync.ProcessRunning | Should -BeFalse } } @@ -176,9 +473,11 @@ Describe "Invoke-WPFAppxRemoval entrypoint" { }) $script:capturedAppxScriptBlock = $null $script:capturedAppxParameterList = $null + $script:appxRemovalProcessRunningAtLaunch = $null Mock Show-WinUtilMessage { "OK" } Mock Invoke-WPFRunspace { + $script:appxRemovalProcessRunningAtLaunch = $script:sync.ProcessRunning $script:capturedAppxScriptBlock = $ScriptBlock $script:capturedAppxParameterList = $ParameterList [pscustomobject]@{ MockHandle = $true } @@ -189,6 +488,7 @@ Describe "Invoke-WPFAppxRemoval entrypoint" { Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxParameterList -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name appxRemovalProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue } It "prompts and exits when no AppX packages are selected" { @@ -203,6 +503,21 @@ Describe "Invoke-WPFAppxRemoval entrypoint" { Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly } + It "prevents overlapping AppX removal operations" { + $script:sync.ProcessRunning = $true + $script:sync.selectedAppx.Add("WPFAppxExample") + + Invoke-WPFAppxRemoval + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "An AppX process is currently running." -and + $Title -eq "WinUtil" -and + $Button -eq "OK" -and + $Icon -eq "Warning" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + It "passes selected AppX keys and app metadata to the removal runspace" { $script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ @@ -211,7 +526,10 @@ Describe "Invoke-WPFAppxRemoval entrypoint" { } Invoke-WPFAppxRemoval + $script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch") + $script:appxRemovalProcessRunningAtLaunch | Should -BeTrue + $script:capturedAppxParameterList[0][1] | Should -HaveCount 1 Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { $ScriptBlock -is [scriptblock] -and @@ -228,6 +546,7 @@ Describe "Invoke-WPFAppxRemoval runspace body" { BeforeEach { $script:sync = [Hashtable]::Synchronized(@{ ProcessRunning = $false + Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} } selectedAppx = [System.Collections.Generic.List[string]]::new() configs = @{ appxHashtable = @{} @@ -260,6 +579,8 @@ Describe "Invoke-WPFAppxRemoval runspace body" { Mock Show-WinUtilMessage { "OK" } Mock Write-Host { } Mock Write-WinUtilLog { } + Mock Set-WinUtilTweaksProgressIndicator { } + Mock Invoke-WPFUIThread { } Mock Stop-Process { } Mock Set-ItemProperty { } Mock Get-AppxPackage { @@ -301,6 +622,77 @@ Describe "Invoke-WPFAppxRemoval runspace body" { Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { $PackageList.Count -eq 1 -and $PackageList[0] -eq "Example.Package" } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "Removing Example App (1/1)" -and $Percent -eq 0 + } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "Removed Example App (1/1)" -and $Percent -eq 90 + } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "AppX removal finished" -and $Percent -eq 100 + } + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + } + $script:sync.ProcessRunning | Should -BeFalse + } + + It "shows failure feedback and clears ProcessRunning when removal fails" { + $selected = @("WPFAppxExample") + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable = $script:apps + Mock Remove-WinUtilAPPX { throw "Removal failed" } + + Invoke-WPFAppxRemoval + & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100 + } + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' + } + Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 0 -Exactly + $script:sync.ProcessRunning | Should -BeFalse + } + + It "removes packages without UI progress during headless autorun" { + $selected = @("WPFAppxExample") + $script:sync.Remove("Form") + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable = $script:apps + + Invoke-WPFAppxRemoval + & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + + Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly + Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly + Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly + $script:sync.ProcessRunning | Should -BeFalse + } + + It "shows failure feedback when provisioned package removal fails" { + $selected = @("WPFAppxExample") + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable = $script:apps + Mock Remove-WinUtilProvisionedAPPX { throw "Provisioned removal failed" } + + Invoke-WPFAppxRemoval + & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter { + $Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100 + } + Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly -ParameterFilter { + $Label -eq "AppX removal finished" + } + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' + } + Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + } $script:sync.ProcessRunning | Should -BeFalse } diff --git a/pester/configs.Tests.ps1 b/pester/configs.Tests.ps1 index d0fff569..5fa20f38 100644 --- a/pester/configs.Tests.ps1 +++ b/pester/configs.Tests.ps1 @@ -326,6 +326,11 @@ Describe "UI-rendered config entries" { foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { $invalidEntries.Add($missingField) } + + if ((Test-WinUtilHasProperty -Object $entry.Value -Name "StoreId") -and + $entry.Value.StoreId -notmatch '^(?:[A-Z0-9]{12}|XP[A-Z0-9]{12})$') { + $invalidEntries.Add("$($entry.Name) has invalid Microsoft Store ID '$($entry.Value.StoreId)'") + } } if ($invalidEntries.Count -gt 0) { diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index be312992..d24f5609 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -158,6 +158,7 @@ Describe "XAML document" { "WPFSelectAllAppx", "WPFClearAppxSelection", "WPFBackToTweaks", + "WPFInstallSelectedAppx", "WPFRemoveSelectedAppx" ) @@ -240,9 +241,11 @@ Describe "XAML document" { $openButton.GetAttribute("Content").Trim() | Should -Be "AppX Removal" $openAppxIndex | Should -Be ($getInstalledIndex + 1) $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFBackToTweaks"]') | Should -Not -BeNullOrEmpty + $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFInstallSelectedAppx"]') | Should -Not -BeNullOrEmpty $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFRemoveSelectedAppx"]') | Should -Not -BeNullOrEmpty $buttonSource | Should -Match '"WPFAppxRemoval"\s*\{Invoke-WPFTab "WPFTab6BT"\}' $buttonSource | Should -Match '"WPFBackToTweaks"\s*\{Invoke-WPFTab "WPFTab2BT"\}' + $buttonSource | Should -Match '"WPFInstallSelectedAppx"\s*\{Invoke-WPFAppxInstall\}' $tabSource | Should -Match '\$sync\.\$tabNav\.Items\[\$tabNumber\]\.IsSelected = \$true' } } diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index 56eecefb..2fa74807 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -1715,8 +1715,9 @@ - Note: Select the pre-installed Windows AppX packages you wish to remove and click 'Remove Selected'. - These packages are removed for the current user and all new user profiles. + Note: Select the Windows AppX packages you wish to install or remove. + Install Selected registers a local manifest when available, then falls back to the Microsoft Store. + Remove Selected removes packages for the current user and all new user profiles. @@ -1726,6 +1727,7 @@