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
This commit is contained in:
Chris Titus
2026-07-15 15:57:56 -05:00
committed by GitHub
parent c8c3c7869f
commit 822003d87f
13 changed files with 733 additions and 77 deletions
+60 -30
View File
@@ -4,203 +4,232 @@
"Content": "Feedback Hub", "Content": "Feedback Hub",
"Description": "Allows users to submit bug reports, feature suggestions, and diagnostic data directly to Microsoft.", "Description": "Allows users to submit bug reports, feature suggestions, and diagnostic data directly to Microsoft.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.WindowsFeedbackHub" "PackageId": "Microsoft.WindowsFeedbackHub",
"StoreId": "9NBLGGH4R32N"
}, },
"WPFAppxMicrosoft_GetHelp": { "WPFAppxMicrosoft_GetHelp": {
"Category": "Microsoft Apps", "Category": "Microsoft Apps",
"Content": "Get Help", "Content": "Get Help",
"Description": "Provides access to automated troubleshooting guides, support documentation, and direct Microsoft customer assistance.", "Description": "Provides access to automated troubleshooting guides, support documentation, and direct Microsoft customer assistance.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.GetHelp" "PackageId": "Microsoft.GetHelp",
"StoreId": "9PKDZBMV1H3T"
}, },
"WPFAppxMicrosoft_OutlookForWindows": { "WPFAppxMicrosoft_OutlookForWindows": {
"Category": "Microsoft Apps", "Category": "Microsoft Apps",
"Content": "Outlook for Windows", "Content": "Outlook for Windows",
"Description": "Provides modern email management, calendar scheduling, and contact organization features.", "Description": "Provides modern email management, calendar scheduling, and contact organization features.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.OutlookForWindows" "PackageId": "Microsoft.OutlookForWindows",
"StoreId": "9NRX63209R7B"
}, },
"WPFAppxMSTeams": { "WPFAppxMSTeams": {
"Category": "Microsoft Apps", "Category": "Microsoft Apps",
"Content": "Microsoft Teams", "Content": "Microsoft Teams",
"Description": "Facilitates instant messaging, video conferencing, file sharing, and workspace collaboration.", "Description": "Facilitates instant messaging, video conferencing, file sharing, and workspace collaboration.",
"Panel": "0", "Panel": "0",
"PackageId": "MSTeams" "PackageId": "MSTeams",
"StoreId": "XP8BT8DW290MPQ"
}, },
"WPFAppxClipchamp_Clipchamp": { "WPFAppxClipchamp_Clipchamp": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Clipchamp", "Content": "Clipchamp",
"Description": "Provides a user-friendly video editor with built-in templates, effects, and timeline editing tools.", "Description": "Provides a user-friendly video editor with built-in templates, effects, and timeline editing tools.",
"Panel": "0", "Panel": "0",
"PackageId": "Clipchamp.Clipchamp" "PackageId": "Clipchamp.Clipchamp",
"StoreId": "9P1J8S7CCWWT"
}, },
"WPFAppxMicrosoft_MicrosoftOfficeHub": { "WPFAppxMicrosoft_MicrosoftOfficeHub": {
"Category": "Microsoft Apps", "Category": "Microsoft Apps",
"Content": "Microsoft 365", "Content": "Microsoft 365",
"Description": "Serves as a centralized launcher and dashboard for accessing cloud-based Microsoft 365 apps and recent documents.", "Description": "Serves as a centralized launcher and dashboard for accessing cloud-based Microsoft 365 apps and recent documents.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.MicrosoftOfficeHub" "PackageId": "Microsoft.MicrosoftOfficeHub",
"StoreId": "9WZDNCRD29V9"
}, },
"WPFAppxMicrosoft_ZuneMusic": { "WPFAppxMicrosoft_ZuneMusic": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Media Player", "Content": "Media Player",
"Description": "Plays local audio and video files with modern playlist management and casting capabilities.", "Description": "Plays local audio and video files with modern playlist management and casting capabilities.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.ZuneMusic" "PackageId": "Microsoft.ZuneMusic",
"StoreId": "9WZDNCRFJ3PT"
}, },
"WPFAppxMicrosoft_BingSearch": { "WPFAppxMicrosoft_BingSearch": {
"Category": "Bing & Web Services", "Category": "Bing & Web Services",
"Content": "Bing Search", "Content": "Bing Search",
"Description": "Integrates Microsoft Bing search capabilities and web services directly into the operating system.", "Description": "Integrates Microsoft Bing search capabilities and web services directly into the operating system.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.BingSearch" "PackageId": "Microsoft.BingSearch",
"StoreId": "9NZBF4GT040C"
}, },
"WPFAppxMicrosoftCorporationII_QuickAssist": { "WPFAppxMicrosoftCorporationII_QuickAssist": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Quick Assist", "Content": "Quick Assist",
"Description": "Enables secure remote technical support and screen sharing over an internet connection.", "Description": "Enables secure remote technical support and screen sharing over an internet connection.",
"Panel": "0", "Panel": "0",
"PackageId": "MicrosoftCorporationII.QuickAssist" "PackageId": "MicrosoftCorporationII.QuickAssist",
"StoreId": "9P7BP5VNWKX5"
}, },
"WPFAppxMicrosoft_WindowsDevHome": { "WPFAppxMicrosoft_WindowsDevHome": {
"Category": "Developer Tools", "Category": "Developer Tools",
"Content": "Dev Home", "Content": "Dev Home",
"Description": "Provides a specialized dashboard for software developer environment setups, repository syncing, and hardware widgets.", "Description": "Provides a specialized dashboard for software developer environment setups, repository syncing, and hardware widgets.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.Windows.DevHome" "PackageId": "Microsoft.Windows.DevHome",
"StoreId": "9N8MHTPHNGVV"
}, },
"WPFAppxMicrosoft_WindowsCrossDevice": { "WPFAppxMicrosoft_WindowsCrossDevice": {
"Category": "Microsoft Ecosystem", "Category": "Microsoft Ecosystem",
"Content": "Mobile Devices", "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.", "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", "Panel": "0",
"PackageId": "MicrosoftWindows.CrossDevice" "PackageId": "MicrosoftWindows.CrossDevice",
"StoreId": "9NTXGKQ8P7N0"
}, },
"WPFAppxMicrosoft_Todos": { "WPFAppxMicrosoft_Todos": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "To Do", "Content": "To Do",
"Description": "Creates, tracks, and synchronizes personal tasks, smart lists, and daily reminders.", "Description": "Creates, tracks, and synchronizes personal tasks, smart lists, and daily reminders.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.Todos" "PackageId": "Microsoft.Todos",
"StoreId": "9NBLGGH5R558"
}, },
"WPFAppxMicrosoft_PowerAutomateDesktop": { "WPFAppxMicrosoft_PowerAutomateDesktop": {
"Category": "Developer Tools", "Category": "Developer Tools",
"Content": "Power Automate", "Content": "Power Automate",
"Description": "Automates repetitive workflows and desktop tasks using low-code visual scripting.", "Description": "Automates repetitive workflows and desktop tasks using low-code visual scripting.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.PowerAutomateDesktop" "PackageId": "Microsoft.PowerAutomateDesktop",
"StoreId": "9NFTCH6J7FHV"
}, },
"WPFAppxMicrosoft_YourPhone": { "WPFAppxMicrosoft_YourPhone": {
"Category": "Microsoft Ecosystem", "Category": "Microsoft Ecosystem",
"Content": "Phone Link", "Content": "Phone Link",
"Description": "Synchronizes text messages, phone notifications, photos, and calls from a mobile device to the desktop.", "Description": "Synchronizes text messages, phone notifications, photos, and calls from a mobile device to the desktop.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.YourPhone" "PackageId": "Microsoft.YourPhone",
"StoreId": "9NMPJ99VJBWV"
}, },
"WPFAppxMicrosoft_MicrosoftStickyNotes": { "WPFAppxMicrosoft_MicrosoftStickyNotes": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Sticky Notes", "Content": "Sticky Notes",
"Description": "Creates quick, floating text notes on the desktop that automatically sync across devices.", "Description": "Creates quick, floating text notes on the desktop that automatically sync across devices.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.MicrosoftStickyNotes" "PackageId": "Microsoft.MicrosoftStickyNotes",
"StoreId": "9NBLGGH4QGHW"
}, },
"WPFAppxMicrosoft_WindowsSoundRecorder": { "WPFAppxMicrosoft_WindowsSoundRecorder": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Sound Recorder", "Content": "Sound Recorder",
"Description": "Records and trims live audio inputs with simple microphone adjustment controls.", "Description": "Records and trims live audio inputs with simple microphone adjustment controls.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.WindowsSoundRecorder" "PackageId": "Microsoft.WindowsSoundRecorder",
"StoreId": "9WZDNCRFHWKN"
}, },
"WPFAppxMicrosoft_WindowsAlarms": { "WPFAppxMicrosoft_WindowsAlarms": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Clock", "Content": "Clock",
"Description": "Features world clocks, alarms, countdown timers, stopwatches, and dedicated focus session tracking.", "Description": "Features world clocks, alarms, countdown timers, stopwatches, and dedicated focus session tracking.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.WindowsAlarms" "PackageId": "Microsoft.WindowsAlarms",
"StoreId": "9WZDNCRFJ3PR"
}, },
"WPFAppxMicrosoft_Paint": { "WPFAppxMicrosoft_Paint": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Paint", "Content": "Paint",
"Description": "Provides built-in digital sketching, basic image editing, and pixel-level graphic manipulation tools.", "Description": "Provides built-in digital sketching, basic image editing, and pixel-level graphic manipulation tools.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.Paint" "PackageId": "Microsoft.Paint",
"StoreId": "9PCFS5B6T72H"
}, },
"WPFAppxMicrosoft_WindowsNotepad": { "WPFAppxMicrosoft_WindowsNotepad": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Notepad", "Content": "Notepad",
"Description": "Provides a lightweight text editor with multi-tab support for plain text files and code snippets.", "Description": "Provides a lightweight text editor with multi-tab support for plain text files and code snippets.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.WindowsNotepad" "PackageId": "Microsoft.WindowsNotepad",
"StoreId": "9MSMLRH6LZF3"
}, },
"WPFAppxMicrosoft_ScreenSketch": { "WPFAppxMicrosoft_ScreenSketch": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Snipping Tool", "Content": "Snipping Tool",
"Description": "Captures screenshots or screen recordings with built-in markup, image cropping, and optical character recognition (OCR).", "Description": "Captures screenshots or screen recordings with built-in markup, image cropping, and optical character recognition (OCR).",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.ScreenSketch" "PackageId": "Microsoft.ScreenSketch",
"StoreId": "9MZ95KL8MR0L"
}, },
"WPFAppxMicrosoft_Copilot": { "WPFAppxMicrosoft_Copilot": {
"Category": "Bing & Web Services", "Category": "Bing & Web Services",
"Content": "Copilot", "Content": "Copilot",
"Description": "Launches the Microsoft AI companion for contextual answers, creative writing assistance, and intelligent web search.", "Description": "Launches the Microsoft AI companion for contextual answers, creative writing assistance, and intelligent web search.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.Copilot" "PackageId": "Microsoft.Copilot",
"StoreId": "9NHT9RB2F4HD"
}, },
"WPFAppxMicrosoft_WindowsCalculator": { "WPFAppxMicrosoft_WindowsCalculator": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Calculator", "Content": "Calculator",
"Description": "Performs standard arithmetic, scientific operations, programming calculations, and unit conversions.", "Description": "Performs standard arithmetic, scientific operations, programming calculations, and unit conversions.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.WindowsCalculator" "PackageId": "Microsoft.WindowsCalculator",
"StoreId": "9WZDNCRFHVN5"
}, },
"WPFAppxMicrosoft_WindowsCamera": { "WPFAppxMicrosoft_WindowsCamera": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Camera", "Content": "Camera",
"Description": "Captures photographs and records video files via connected webcams or imaging hardware.", "Description": "Captures photographs and records video files via connected webcams or imaging hardware.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.WindowsCamera" "PackageId": "Microsoft.WindowsCamera",
"StoreId": "9WZDNCRFJBBG"
}, },
"WPFAppxMicrosoft_WindowsPhotos": { "WPFAppxMicrosoft_WindowsPhotos": {
"Category": "Utilities & Productivity", "Category": "Utilities & Productivity",
"Content": "Photos", "Content": "Photos",
"Description": "Organizes, views, and crops local images with basic color adjustment and album creation tools.", "Description": "Organizes, views, and crops local images with basic color adjustment and album creation tools.",
"Panel": "0", "Panel": "0",
"PackageId": "Microsoft.Windows.Photos" "PackageId": "Microsoft.Windows.Photos",
"StoreId": "9WZDNCRFJBH4"
}, },
"WPFAppxMicrosoft_BingNews": { "WPFAppxMicrosoft_BingNews": {
"Category": "Bing & Web Services", "Category": "Bing & Web Services",
"Content": "News", "Content": "News",
"Description": "Aggregates breaking news headlines, personalized article feeds, and world current events.", "Description": "Aggregates breaking news headlines, personalized article feeds, and world current events.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.BingNews" "PackageId": "Microsoft.BingNews",
"StoreId": "9WZDNCRFHVFW"
}, },
"WPFAppxMicrosoft_BingWeather": { "WPFAppxMicrosoft_BingWeather": {
"Category": "Bing & Web Services", "Category": "Bing & Web Services",
"Content": "Weather", "Content": "Weather",
"Description": "Displays local real-time weather tracking, radar maps, and historical meteorological forecasts.", "Description": "Displays local real-time weather tracking, radar maps, and historical meteorological forecasts.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.BingWeather" "PackageId": "Microsoft.BingWeather",
"StoreId": "9WZDNCRFJ3Q2"
}, },
"WPFAppxMicrosoft_GamingApp": { "WPFAppxMicrosoft_GamingApp": {
"Category": "Xbox & Gaming", "Category": "Xbox & Gaming",
"Content": "Xbox App", "Content": "Xbox App",
"Description": "Serves as the primary gaming library manager, social community interface, and PC Game Pass dashboard.", "Description": "Serves as the primary gaming library manager, social community interface, and PC Game Pass dashboard.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.GamingApp" "PackageId": "Microsoft.GamingApp",
"StoreId": "9MV0B5HZVK9Z"
}, },
"WPFAppxMicrosoft_XboxGamingOverlay": { "WPFAppxMicrosoft_XboxGamingOverlay": {
"Category": "Xbox & Gaming", "Category": "Xbox & Gaming",
"Content": "Xbox Game Bar", "Content": "Xbox Game Bar",
"Description": "Provides customizable in-game status widgets, audio balancing sliders, system monitoring tools, and gameplay recording.", "Description": "Provides customizable in-game status widgets, audio balancing sliders, system monitoring tools, and gameplay recording.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.XboxGamingOverlay" "PackageId": "Microsoft.XboxGamingOverlay",
"StoreId": "9NZKPSTSNW4P"
}, },
"WPFAppxMicrosoft_XboxIdentityProvider": { "WPFAppxMicrosoft_XboxIdentityProvider": {
"Category": "Xbox & Gaming", "Category": "Xbox & Gaming",
"Content": "Xbox Identity Provider", "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.", "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", "Panel": "1",
"PackageId": "Microsoft.XboxIdentityProvider" "PackageId": "Microsoft.XboxIdentityProvider",
"StoreId": "9WZDNCRD1HKW"
}, },
"WPFAppxMicrosoft_XboxSpeechToTextOverlay": { "WPFAppxMicrosoft_XboxSpeechToTextOverlay": {
"Category": "Xbox & Gaming", "Category": "Xbox & Gaming",
@@ -221,7 +250,8 @@
"Content": "Start Experiences App", "Content": "Start Experiences App",
"Description": "Powers the Windows Widgets board, delivering a personalized feed of news, weather, sports, and finance content.", "Description": "Powers the Windows Widgets board, delivering a personalized feed of news, weather, sports, and finance content.",
"Panel": "1", "Panel": "1",
"PackageId": "Microsoft.StartExperiencesApp" "PackageId": "Microsoft.StartExperiencesApp",
"StoreId": "9PC1H9VN18CM"
}, },
"WPFAppxMicrosoft_MicrosoftSolitaireCollection": { "WPFAppxMicrosoft_MicrosoftSolitaireCollection": {
"Category": "Xbox & Gaming", "Category": "Xbox & Gaming",
+8
View File
@@ -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. * **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. * **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
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. 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.
@@ -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)
}
+79
View File
@@ -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")
}
@@ -53,8 +53,9 @@ function Remove-WinUtilProvisionedAPPX {
$removalOutput = powershell.exe -NoProfile -NonInteractive -Command $ps5Command -args $PackageList 2>&1 $removalOutput = powershell.exe -NoProfile -NonInteractive -Command $ps5Command -args $PackageList 2>&1
if ($LASTEXITCODE -ne 0 -or $null -ne $removalOutput) { if ($LASTEXITCODE -ne 0 -or $null -ne $removalOutput) {
$failureDetails = ($removalOutput | Out-String).Trim() $failureDetails = ($removalOutput | Out-String).Trim()
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX provisioned package removal failed: $failureDetails" $errorMessage = "AppX provisioned package removal failed: $failureDetails"
return Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
throw $errorMessage
} }
Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed." Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed."
@@ -1,9 +1,9 @@
function Set-WinUtilTweaksProgressIndicator { function Set-WinUtilTweaksProgressIndicator {
<# <#
.SYNOPSIS .SYNOPSIS
Shows, updates, or hides the window-level progress indicator used by the Shows, updates, or hides the window-level progress indicator used by long-running
Tweaks and Undo workflows. It lives outside the TabControl, so unlike the workflows such as Tweaks, Undo, and AppX management. It lives outside the TabControl,
Install tab's progress bar it stays visible no matter which tab is active. so unlike the Install tab's progress bar it stays visible no matter which tab is active.
.PARAMETER Visible .PARAMETER Visible
Whether the indicator should be shown or hidden. Whether the indicator should be shown or hidden.
.PARAMETER Label .PARAMETER Label
@@ -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
}
}
}
+53 -10
View File
@@ -1,21 +1,41 @@
function Invoke-WPFAppxRemoval { 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) { if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) {
Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error"
return return
} }
$selected = $sync.selectedAppx $selected = @($sync.selectedAppx)
$apps = $sync.configs.appxHashtable $apps = $sync.configs.appxHashtable
$sync.ProcessRunning = $true
Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock { Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock {
param($selected, $apps) param($selected, $apps)
$sync.ProcessRunning = $true $totalPackages = @($selected).Count
Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $(@($selected).Count) selected package(s)." $hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
$packageList = [System.Collections.Generic.List[string]]::new() $packageList = [System.Collections.Generic.List[string]]::new()
foreach ($key in $selected) { 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" }
}
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") { if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") {
# Making sure Game Bar isn't running # Making sure Game Bar isn't running
Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay."
@@ -31,19 +51,28 @@ function Invoke-WPFAppxRemoval {
Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue
} }
Write-Host "Removing $($apps[$key].Content)" Write-Host "Removing $($app.Content)"
Write-WinUtilLog -Component "AppX" -Message "Removing $($apps[$key].Content) ($($apps[$key].PackageId))." Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))."
Remove-WinUtilAPPX -Name $apps[$key].PackageId Remove-WinUtilAPPX -Name $app.PackageId
$packageList.Add($apps[$key].PackageId) $packageList.Add($app.PackageId)
if ($key -eq "WPFAppxMSTeams") { if ($key -eq "WPFAppxMSTeams") {
# Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office
Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package."
Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force 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) }
}
} }
if ($packageList.Count -gt 0) { if ($packageList.Count -gt 0) {
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing provisioned AppX packages" -Percent 90
}
Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray() Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray()
} }
@@ -51,7 +80,21 @@ function Invoke-WPFAppxRemoval {
Write-Host "-- AppX Removal Finished ---" Write-Host "-- AppX Removal Finished ---"
Write-Host "=================================" Write-Host "================================="
Write-WinUtilLog -Component "AppX" -Message "AppX removal finished." 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" }
}
}
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 $sync.ProcessRunning = $false
} }
}
} }
+2 -1
View File
@@ -68,6 +68,7 @@ function Invoke-WPFButton {
"WPFGetInstalledTweaks" {Invoke-WPFGetInstalled -CheckBox "tweaks"} "WPFGetInstalledTweaks" {Invoke-WPFGetInstalled -CheckBox "tweaks"}
"WPFAppxRemoval" {Invoke-WPFTab "WPFTab6BT"} "WPFAppxRemoval" {Invoke-WPFTab "WPFTab6BT"}
"WPFBackToTweaks" {Invoke-WPFTab "WPFTab2BT"} "WPFBackToTweaks" {Invoke-WPFTab "WPFTab2BT"}
"WPFInstallSelectedAppx" {Invoke-WPFAppxInstall}
"WPFRemoveSelectedAppx" {Invoke-WPFAppxRemoval} "WPFRemoveSelectedAppx" {Invoke-WPFAppxRemoval}
"WPFDefaultAppxSelection" {Invoke-WPFPresets "AppxDefault" -checkboxfilterpattern "WPFAppx*"} "WPFDefaultAppxSelection" {Invoke-WPFPresets "AppxDefault" -checkboxfilterpattern "WPFAppx*"}
"WPFSelectAllAppx" { "WPFSelectAllAppx" {
@@ -77,7 +78,7 @@ function Invoke-WPFButton {
$sync.configs.appxHashtable.Keys | ForEach-Object {$sync.$_.IsChecked = $false} $sync.configs.appxHashtable.Keys | ForEach-Object {$sync.$_.IsChecked = $false}
} }
"WPFGetInstalledAppx" { "WPFGetInstalledAppx" {
$installedAppxPackages = Get-AppxPackage -AllUsers | Select-Object -ExpandProperty Name $installedAppxPackages = Get-WinUtilInstalledAPPX
foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) { foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) {
if ($appx.Value.PackageId -in $installedAppxPackages) { if ($appx.Value.PackageId -in $installedAppxPackages) {
$sync.$($appx.Key).IsChecked = $true $sync.$($appx.Key).IsChecked = $true
+398 -6
View File
@@ -1,12 +1,16 @@
#=========================================================================== #===========================================================================
# Tests - AppX Removal # Tests - AppX Management
#=========================================================================== #===========================================================================
BeforeAll { BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $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-WinUtilAPPX.ps1")
. (Join-Path $script:repoRoot "functions\private\Remove-WinUtilProvisionedAPPX.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-WPFAppxRemoval.ps1")
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1")
$tokens = $null $tokens = $null
$parseErrors = $null $parseErrors = $null
@@ -20,6 +24,16 @@ BeforeAll {
}, $true) }, $true)
$script:provisionedRemovalScriptBlock = $ps5CommandAssignment.Right.Expression.ScriptBlock.GetScriptBlock() $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 { function Write-WinUtilLog {
param($Message, $Level, $Component) param($Message, $Level, $Component)
} }
@@ -29,8 +43,25 @@ BeforeAll {
function Invoke-WPFRunspace { function Invoke-WPFRunspace {
param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) 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 { 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 { function Remove-AppxPackage {
param( 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" { Describe "Remove-WinUtilAPPX" {
BeforeEach { BeforeEach {
Mock Write-Host { } 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 = Get-Content -Path $provisionedSourcePath -Raw
$source | Should -Match '\$removalOutput = powershell\.exe .* 2>&1' $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 \$errorMessage'
$source | Should -Match 'Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX provisioned package removal failed:' $source | Should -Match '(?s)AppX provisioned package removal failed:.*throw \$errorMessage.*AppX provisioned package removal completed\.'
$source | Should -Match '(?s)AppX provisioned package removal failed:.*return.*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:capturedAppxScriptBlock = $null
$script:capturedAppxParameterList = $null $script:capturedAppxParameterList = $null
$script:appxRemovalProcessRunningAtLaunch = $null
Mock Show-WinUtilMessage { "OK" } Mock Show-WinUtilMessage { "OK" }
Mock Invoke-WPFRunspace { Mock Invoke-WPFRunspace {
$script:appxRemovalProcessRunningAtLaunch = $script:sync.ProcessRunning
$script:capturedAppxScriptBlock = $ScriptBlock $script:capturedAppxScriptBlock = $ScriptBlock
$script:capturedAppxParameterList = $ParameterList $script:capturedAppxParameterList = $ParameterList
[pscustomobject]@{ MockHandle = $true } [pscustomobject]@{ MockHandle = $true }
@@ -189,6 +488,7 @@ Describe "Invoke-WPFAppxRemoval entrypoint" {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxParameterList -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" { 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 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" { It "passes selected AppX keys and app metadata to the removal runspace" {
$script:sync.selectedAppx.Add("WPFAppxExample") $script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{
@@ -211,7 +526,10 @@ Describe "Invoke-WPFAppxRemoval entrypoint" {
} }
Invoke-WPFAppxRemoval 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 Show-WinUtilMessage -Times 0 -Exactly
Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter {
$ScriptBlock -is [scriptblock] -and $ScriptBlock -is [scriptblock] -and
@@ -228,6 +546,7 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
BeforeEach { BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{ $script:sync = [Hashtable]::Synchronized(@{
ProcessRunning = $false ProcessRunning = $false
Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} }
selectedAppx = [System.Collections.Generic.List[string]]::new() selectedAppx = [System.Collections.Generic.List[string]]::new()
configs = @{ configs = @{
appxHashtable = @{} appxHashtable = @{}
@@ -260,6 +579,8 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
Mock Show-WinUtilMessage { "OK" } Mock Show-WinUtilMessage { "OK" }
Mock Write-Host { } Mock Write-Host { }
Mock Write-WinUtilLog { } Mock Write-WinUtilLog { }
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Invoke-WPFUIThread { }
Mock Stop-Process { } Mock Stop-Process { }
Mock Set-ItemProperty { } Mock Set-ItemProperty { }
Mock Get-AppxPackage { Mock Get-AppxPackage {
@@ -301,6 +622,77 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter { Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter {
$PackageList.Count -eq 1 -and $PackageList[0] -eq "Example.Package" $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 $script:sync.ProcessRunning | Should -BeFalse
} }
+5
View File
@@ -326,6 +326,11 @@ Describe "UI-rendered config entries" {
foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) {
$invalidEntries.Add($missingField) $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) { if ($invalidEntries.Count -gt 0) {
+3
View File
@@ -158,6 +158,7 @@ Describe "XAML document" {
"WPFSelectAllAppx", "WPFSelectAllAppx",
"WPFClearAppxSelection", "WPFClearAppxSelection",
"WPFBackToTweaks", "WPFBackToTweaks",
"WPFInstallSelectedAppx",
"WPFRemoveSelectedAppx" "WPFRemoveSelectedAppx"
) )
@@ -240,9 +241,11 @@ Describe "XAML document" {
$openButton.GetAttribute("Content").Trim() | Should -Be "AppX Removal" $openButton.GetAttribute("Content").Trim() | Should -Be "AppX Removal"
$openAppxIndex | Should -Be ($getInstalledIndex + 1) $openAppxIndex | Should -Be ($getInstalledIndex + 1)
$appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFBackToTweaks"]') | Should -Not -BeNullOrEmpty $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 $appxTab.SelectSingleNode('.//*[local-name()="Button"][@Name="WPFRemoveSelectedAppx"]') | Should -Not -BeNullOrEmpty
$buttonSource | Should -Match '"WPFAppxRemoval"\s*\{Invoke-WPFTab "WPFTab6BT"\}' $buttonSource | Should -Match '"WPFAppxRemoval"\s*\{Invoke-WPFTab "WPFTab6BT"\}'
$buttonSource | Should -Match '"WPFBackToTweaks"\s*\{Invoke-WPFTab "WPFTab2BT"\}' $buttonSource | Should -Match '"WPFBackToTweaks"\s*\{Invoke-WPFTab "WPFTab2BT"\}'
$buttonSource | Should -Match '"WPFInstallSelectedAppx"\s*\{Invoke-WPFAppxInstall\}'
$tabSource | Should -Match '\$sync\.\$tabNav\.Items\[\$tabNumber\]\.IsSelected = \$true' $tabSource | Should -Match '\$sync\.\$tabNav\.Items\[\$tabNumber\]\.IsSelected = \$true'
} }
} }
+4 -2
View File
@@ -1715,8 +1715,9 @@
<Border Grid.Row="2" Style="{StaticResource BorderStyle}" Margin="5,15,5,5"> <Border Grid.Row="2" Style="{StaticResource BorderStyle}" Margin="5,15,5,5">
<StackPanel Background="{DynamicResource MainBackgroundColor}" Orientation="Horizontal" HorizontalAlignment="Left"> <StackPanel Background="{DynamicResource MainBackgroundColor}" Orientation="Horizontal" HorizontalAlignment="Left">
<TextBlock Padding="10" TextWrapping="Wrap" Foreground="{DynamicResource MainForegroundColor}"> <TextBlock Padding="10" TextWrapping="Wrap" Foreground="{DynamicResource MainForegroundColor}">
Note: Select the pre-installed Windows AppX packages you wish to remove and click 'Remove Selected'. Note: Select the Windows AppX packages you wish to install or remove.
<LineBreak/>These packages are removed for the current user and all new user profiles. <LineBreak/>Install Selected registers a local manifest when available, then falls back to the Microsoft Store.
<LineBreak/>Remove Selected removes packages for the current user and all new user profiles.
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -1726,6 +1727,7 @@
<Border Grid.Row="1" Background="{DynamicResource MainBackgroundColor}" BorderBrush="{DynamicResource BorderColor}" BorderThickness="1" CornerRadius="5" HorizontalAlignment="Stretch" Padding="10"> <Border Grid.Row="1" Background="{DynamicResource MainBackgroundColor}" BorderBrush="{DynamicResource BorderColor}" BorderThickness="1" CornerRadius="5" HorizontalAlignment="Stretch" Padding="10">
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center"> <WrapPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
<Button Name="WPFBackToTweaks" Content="Back to Tweaks" Margin="5" Width="{DynamicResource ButtonWidth}" Height="{DynamicResource ButtonHeight}"/> <Button Name="WPFBackToTweaks" Content="Back to Tweaks" Margin="5" Width="{DynamicResource ButtonWidth}" Height="{DynamicResource ButtonHeight}"/>
<Button Name="WPFInstallSelectedAppx" Content="Install Selected" Margin="5" Width="{DynamicResource ButtonWidth}" Height="{DynamicResource ButtonHeight}"/>
<Button Name="WPFRemoveSelectedAppx" Content="Remove Selected" Margin="5" Width="{DynamicResource ButtonWidth}" Height="{DynamicResource ButtonHeight}"/> <Button Name="WPFRemoveSelectedAppx" Content="Remove Selected" Margin="5" Width="{DynamicResource ButtonWidth}" Height="{DynamicResource ButtonHeight}"/>
</WrapPanel> </WrapPanel>
</Border> </Border>