2026-06-21 18:47:52 +02:00
<#
. SYNOPSIS
Removes one or more Windows app packages based on the target scope.
. DESCRIPTION
Iterates over the provided list of app identifiers and removes each one.
2026-06-22 22:13:01 +02:00
The removal method (winget vs. Appx cmdlets) is determined per-app from
2026-07-14 23:06:40 +03:00
Apps.json. A scheduled task is only created when the User or Sysprep
parameter was passed. After winget removal, the system is checked to
confirm whether the app is still installed before reporting an error.
Returns early if the CancelRequested flag is set.
2026-06-21 18:47:52 +02:00
.PARAMETER appsList
An array of app package identifiers to remove (e.g. 'Microsoft.BingNews').
. EXAMPLE
2026-07-19 22:06:07 +02:00
Remove-SelectedApps @('Microsoft.BingNews', 'Microsoft.BingWeather')
2026-06-21 18:47:52 +02:00
. EXAMPLE
2026-07-19 22:06:07 +02:00
Remove-SelectedApps -appsList (Generate-AppsList)
2026-06-21 18:47:52 +02:00
#>
2026-07-19 22:06:07 +02:00
function Remove-SelectedApps {
2026-03-07 20:28:48 +01:00
param (
$appslist
)
2026-06-22 02:30:31 +07:00
if ( $script:Params . ContainsKey ( "WhatIf" )) {
foreach ( $app in $appslist ) {
Write-Host "[WhatIf] Remove App Package: $app " -ForegroundColor Cyan
}
Write-Host ""
return
}
2026-07-19 22:06:07 +02:00
$targetUser = Get-TargetUserForAppRemoval
2026-03-07 20:28:48 +01:00
$appCount = @ ( $appsList ). Count
2026-06-22 22:13:01 +02:00
$appIndex = 0
2026-03-23 22:59:04 +01:00
$edgeIds = @ ( 'Microsoft.Edge' , 'XPFFTQ037JWMHS' )
2026-06-22 22:13:01 +02:00
$wingetRemovedApps = @ ()
2026-08-12 15:56:29 +02:00
$wingetRemovalFailures = @ {}
2026-03-07 20:28:48 +01:00
Foreach ( $app in $appsList ) {
2026-06-22 22:13:01 +02:00
if ( $script:CancelRequested ) { return }
2026-03-07 20:28:48 +01:00
$appIndex ++
if ( $script:ApplySubStepCallback -and $appCount -gt 1 ) {
2026-06-22 22:13:01 +02:00
& $script:ApplySubStepCallback "Removing apps ( $appIndex / $appCount )" $appIndex $appCount
}
2026-06-10 21:00:07 +02:00
Write-Host "Removing $app "
2026-03-07 20:28:48 +01:00
2026-06-22 22:13:01 +02:00
if (( Get-AppRemovalMethod $app ) -eq 'WinGet' ) {
2026-08-12 15:56:29 +02:00
if ( -not ( Remove-WinGetApp -app $app )) {
$wingetRemovalFailures [ $app ] = $true
}
2026-06-22 22:13:01 +02:00
$wingetRemovedApps += $app
}
else {
2026-08-12 15:56:29 +02:00
if ( -not ( Remove-AppxApp -app $app -targetUser $targetUser )) {
$script:AppRemovalFailures ++
}
2026-06-22 22:13:01 +02:00
}
}
2026-07-14 23:06:40 +03:00
if ( $script:CancelRequested ) {
Write-Host ""
return
2026-06-22 22:13:01 +02:00
}
# Check whether any winget-removed apps are still present, and report errors for each one.
2026-07-14 23:06:40 +03:00
if ( $wingetRemovedApps . Count -gt 0 ) {
2026-07-19 22:06:07 +02:00
$postRemovalList = if ( $script:WingetInstalled ) { Get-WingetInstalledApps -TimeOut 10 -NonBlocking } else { $null }
2026-07-14 23:06:40 +03:00
$edgeForceRemoveRequested = $false
2026-08-12 15:56:29 +02:00
if ( $null -eq $postRemovalList ) {
$script:AppRemovalVerificationUnavailable = $true
}
else {
foreach ( $app in $wingetRemovedApps ) {
if ( -not ( Test-AppInWingetList -appId $app -InstalledList $postRemovalList )) {
continue
2026-07-14 23:06:40 +03:00
}
2026-08-12 15:56:29 +02:00
if ( $edgeIds -contains $app ) {
Write-Host "Unable to uninstall Microsoft Edge via WinGet" -ForegroundColor Red
if ( -not $edgeForceRemoveRequested ) {
Request-EdgeForceRemove
$edgeForceRemoveRequested = $true
}
}
else {
Write-Host "Unable to uninstall $app via WinGet" -ForegroundColor Red
}
$wingetRemovalFailures [ $app ] = $true
2026-03-07 20:28:48 +01:00
}
}
}
2026-08-12 15:56:29 +02:00
$script:AppRemovalFailures += $wingetRemovalFailures . Count
2026-03-07 20:28:48 +01:00
Write-Host ""
2026-06-22 22:13:01 +02:00
}
<#
. SYNOPSIS
2026-07-14 23:06:40 +03:00
Uninstalls an app via WinGet and/or schedules its removal.
2026-06-22 22:13:01 +02:00
. DESCRIPTION
2026-08-09 01:24:27 +02:00
Runs winget uninstall for a single app, with a bounded execution time.
If the User or Sysprep parameter was passed, also schedules removal for
future logins.
2026-06-22 22:13:01 +02:00
.PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
2026-08-09 01:24:27 +02:00
.PARAMETER TimeoutSeconds
Maximum time to allow the foreground WinGet uninstall to run. Defaults
to 120 seconds.
2026-06-22 22:13:01 +02:00
#>
function Remove-WinGetApp {
2026-08-09 01:24:27 +02:00
param (
[ string ] $app ,
[ int ] $TimeoutSeconds = 120
)
2026-06-22 22:13:01 +02:00
if ( -not $script:WingetInstalled ) {
2026-08-12 15:56:29 +02:00
Write-Error "WinGet is either not installed or is outdated; $app could not be removed"
return $false
2026-06-22 22:13:01 +02:00
}
2026-08-12 15:56:29 +02:00
$uninstallSucceeded = $true
2026-08-09 01:24:27 +02:00
try {
2026-08-12 15:56:29 +02:00
$uninstallSucceeded = Invoke-NonBlocking -ScriptBlock {
2026-08-09 01:24:27 +02:00
param ( $appId )
2026-08-12 15:56:29 +02:00
$null = & winget uninstall - -accept-source-agreements - -disable-interactivity - -id $appId 2 >& 1
return $true
2026-08-09 01:24:27 +02:00
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
2026-08-12 15:56:29 +02:00
$uninstallSucceeded = [ bool ] $uninstallSucceeded
2026-08-09 01:24:27 +02:00
}
catch {
2026-08-12 15:56:29 +02:00
$uninstallSucceeded = $false
2026-08-09 01:24:27 +02:00
if ( $_ . Exception . Message -like 'Operation timed out after *' ) {
2026-08-12 15:56:29 +02:00
Write-Error "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_ "
2026-08-09 01:24:27 +02:00
}
else {
2026-08-12 15:56:29 +02:00
Write-Error "WinGet uninstall for $app failed: $_ "
2026-08-09 01:24:27 +02:00
}
}
2026-07-14 23:06:40 +03:00
2026-08-12 15:56:29 +02:00
$scheduleSucceeded = $true
2026-06-22 22:13:01 +02:00
if ( $script:Params . ContainsKey ( "User" )) {
2026-07-19 22:06:07 +02:00
Write-Host "Adding scheduled task to uninstall $app for user $( Get-UserName ) ..."
2026-08-12 15:56:29 +02:00
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
2026-06-22 22:13:01 +02:00
}
elseif ( $script:Params . ContainsKey ( "Sysprep" )) {
Write-Host "Adding scheduled task to uninstall $app for new users..."
2026-08-12 15:56:29 +02:00
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app
2026-06-22 22:13:01 +02:00
}
2026-08-12 15:56:29 +02:00
return ( $uninstallSucceeded -and $scheduleSucceeded )
2026-06-22 22:13:01 +02:00
}
<#
. SYNOPSIS
Removes an app via Remove-AppxPackage / Remove-ProvisionedAppxPackage.
.PARAMETER app
The package identifier to remove (e.g. 'Clipchamp.Clipchamp').
.PARAMETER targetUser
Target scope: "AllUsers", "CurrentUser", or a specific username.
#>
function Remove-AppxApp {
param ([ string ] $app , [ string ] $targetUser )
$appPattern = '*' + $app + '*'
try {
2026-08-12 15:56:29 +02:00
$removalResult = Invoke-NonBlocking -ScriptBlock {
param ( $pattern , $target )
$removalErrors = @ ()
$getPackageParams = @ { Name = $pattern ; ErrorAction = 'Continue' ; ErrorVariable = '+removalErrors' }
$removePackageParams = @ { ErrorAction = 'Continue' ; ErrorVariable = '+removalErrors' }
switch ( $target ) {
'AllUsers' {
$getPackageParams . AllUsers = $true
$removePackageParams . AllUsers = $true
}
'CurrentUser' { }
default {
$userAccount = New-Object System . Security . Principal . NTAccount ( $target )
2026-06-22 22:13:01 +02:00
$userSid = $userAccount . Translate ([ System.Security.Principal.SecurityIdentifier ]). Value
2026-08-12 15:56:29 +02:00
$getPackageParams . User = $userSid
$removePackageParams . User = $userSid
}
2026-06-22 22:13:01 +02:00
}
2026-08-12 15:56:29 +02:00
foreach ( $package in @ ( Get-AppxPackage @getPackageParams )) {
$removePackageParams . Package = $package . PackageFullName
$null = Remove-AppxPackage @removePackageParams
}
if ( $target -eq 'AllUsers' ) {
$provisionedPackages = @ ( Get-AppxProvisionedPackage -Online -ErrorAction Continue -ErrorVariable + removalErrors | Where-Object { $_ . PackageName -like $pattern })
foreach ( $package in $provisionedPackages ) {
$null = Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $package . PackageName -ErrorAction Continue -ErrorVariable + removalErrors
}
}
return [ PSCustomObject ] @ { Success = ( $removalErrors . Count -eq 0 ) }
} -ArgumentList @ ( $appPattern , $targetUser )
2026-06-22 22:13:01 +02:00
}
catch {
2026-08-12 15:56:29 +02:00
Write-Error "Unable to remove $app via Appx: $_ "
return $false
2026-06-22 22:13:01 +02:00
}
2026-08-12 15:56:29 +02:00
return [ bool ]( $removalResult -and $removalResult . Success )
2026-06-22 22:13:01 +02:00
}
<#
. SYNOPSIS
Returns the removal method for an app identifier.
. DESCRIPTION
Parses Apps.json once (cached in script scope) to build a lookup of
AppId -> RemovalMethod. Returns 'WinGet' if the app should be removed
via winget, or 'Appx' if via Remove-AppxPackage. Defaults to 'Appx'
for unknown IDs.
.PARAMETER appId
The package identifier (e.g. 'Clipchamp.Clipchamp').
#>
function Get-AppRemovalMethod {
param ([ string ] $appId )
if ( -not $script:AppRemovalMethodCache ) {
$script:AppRemovalMethodCache = @ {}
try {
if ( Test-Path $script:AppsListFilePath ) {
$appsJson = Get-Content -Path $script:AppsListFilePath -Raw | ConvertFrom-Json
foreach ( $appData in $appsJson . Apps ) {
$rawMethod = $appData . RemovalMethod
$method = if ( $rawMethod -and $rawMethod -eq 'WinGet' ) { 'WinGet' } else { 'Appx' }
2026-07-19 22:06:07 +02:00
foreach ( $id in @ ( $appData . AppId )) {
if ( $id -isnot [ string ]) { continue }
$normalizedId = $id . Trim ()
if ( -not [ string ]:: IsNullOrWhiteSpace ( $normalizedId )) {
$script:AppRemovalMethodCache [ $normalizedId ] = $method
}
2026-06-22 22:13:01 +02:00
}
}
}
}
catch {
Write-Warning "Failed to load app removal methods from ' $script:AppsListFilePath '. Defaulting unknown apps to Appx. Error: $_ "
}
}
if ( $script:AppRemovalMethodCache . ContainsKey ( $appId )) {
return $script:AppRemovalMethodCache [ $appId ]
}
return 'Appx'
}
<#
. SYNOPSIS
Prompts the user to forcefully remove Microsoft Edge when winget cannot uninstall it.
. DESCRIPTION
Only invoked after it has been confirmed that Edge is still present
following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package.
#>
function Request-EdgeForceRemove {
if ( $script:GuiWindow ) {
$result = Show-MessageBox -Message 'Unable to uninstall Microsoft Edge via WinGet. Would you like to forcefully uninstall it? NOT RECOMMENDED!' -Title 'Force Uninstall Microsoft Edge?' -Button 'YesNo' -Icon 'Warning'
if ( $result -eq 'Yes' ) {
Write-Host ""
2026-07-19 22:06:07 +02:00
Invoke-ForceRemoveEdge
2026-06-22 22:13:01 +02:00
}
}
elseif ( $ ( Read-Host -Prompt "Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n)" ) -eq 'y' ) {
Write-Host ""
2026-07-19 22:06:07 +02:00
Invoke-ForceRemoveEdge
2026-06-22 22:13:01 +02:00
}
}
<#
. SYNOPSIS
Dynamically sets a RunOnce registry key to schedule a winget uninstall.
. DESCRIPTION
Writes directly to HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
via the PowerShell registry API within Invoke-WithTargetUserHive,
which handles hive loading and HKEY_USERS\Default → SID remapping.
Used instead of static .reg files to avoid file dependency for each WinGet app.
The winget command is Base64-encoded and invoked via powershell.exe -EncodedCommand
rather than interpolated directly into cmd.exe /c. This prevents shell metacharacters
(such as &, |, <, >, ^, ") in the app ID from being interpreted as command syntax,
even if future catalog updates introduce IDs containing those characters.
.PARAMETER appId
The winget package ID to schedule for uninstall (e.g. 'XP9CXNGPPJ97XX').
#>
function Set-RunOnceWingetTask {
param ([ string ] $appId )
$targetUserName = if ( $script:Params . ContainsKey ( "Sysprep" )) { "Default" } else { $script:Params . Item ( "User" ) }
# Sanitize appId for use in registry value names (backslashes are path separators)
$safeAppId = $appId . Replace ( '\' , '_' )
$taskName = "Uninstall_ $safeAppId "
# Escape single quotes in appId, then wrap in single quotes so cmd/pwsh metacharacters
# like & | < > ^ " are treated as literals. Base64-encode the whole command so the
# RunOnce value contains only [A-Za-z0-9+/=] — safe in any shell parser.
$escapedAppId = $appId . Replace ( "'" , "''" )
$wingetCommand = "winget uninstall --accept-source-agreements --disable-interactivity --id ' $escapedAppId '"
$encodedWingetCommand = [ Convert ]:: ToBase64String ([ System.Text.Encoding ]:: Unicode . GetBytes ( $wingetCommand ))
$operation = [ PSCustomObject ] @ {
KeyPath = 'HKEY_USERS\Default\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
ValueName = $taskName
ValueType = 'String'
ValueData = "powershell.exe -NoProfile -EncodedCommand $encodedWingetCommand "
OperationType = 'SetValue'
}
try {
Invoke-WithTargetUserHive -TargetUserName $targetUserName -ScriptBlock {
param ( $op )
Invoke-RegistryOperation -Operation $op -RegFilePath '<dynamic>'
} -ArgumentObject $operation
2026-08-12 15:56:29 +02:00
return $true
2026-06-22 22:13:01 +02:00
}
catch {
2026-08-12 15:56:29 +02:00
Write-Error "Failed to schedule uninstall task for $( $appId ) : $_ "
return $false
2026-06-22 22:13:01 +02:00
}
2026-07-14 23:06:40 +03:00
}