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 @@
+