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
Apps . json . Microsoft Edge is deferred to the end of the loop so that all
winget attempts run before any force-remove prompt . A scheduled task is
only created when the User or Sysprep parameter was passed .
After each winget removal , the system is checked to confirm whether the
app is still installed before reporting an error .
2026-06-21 18:47:52 +02:00
. PARAMETER appsList
An array of app package identifiers to remove ( e . g . 'Microsoft.BingNews' ) .
. EXAMPLE
RemoveApps @ ( 'Microsoft.BingNews' , 'Microsoft.BingWeather' )
. EXAMPLE
RemoveApps -appsList ( GenerateAppsList )
#>
2026-03-07 20:28:48 +01:00
function RemoveApps {
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-03-07 20:28:48 +01:00
$targetUser = GetTargetUserForAppRemoval
$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
$edgeAppsInList = @ ( )
$wingetRemovedApps = @ ( )
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
}
# Microsoft Edge is handled after the loop to avoid duplicate scheduled tasks and allow fallback if winget fails
if ( $edgeIds -contains $app ) {
$edgeAppsInList + = $app
continue
2026-03-07 20:28:48 +01:00
}
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' ) {
Remove-WinGetApp -app $app
$wingetRemovedApps + = $app
}
else {
Remove-AppxApp -app $app -targetUser $targetUser
}
}
2026-03-07 20:28:48 +01:00
2026-06-22 22:13:01 +02:00
# Remove Microsoft Edge
if ( $edgeAppsInList . Count -gt 0 ) {
Remove-EdgeApp -edgeAppsInList $edgeAppsInList
}
2026-03-07 20:28:48 +01:00
2026-06-22 22:13:01 +02:00
# Check whether any winget-removed apps are still present, and report errors for each one.
if ( $wingetRemovedApps . Count -gt 0 -or $edgeAppsInList . Count -gt 0 ) {
$postRemovalList = if ( $script:WingetInstalled ) { GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking } else { $null }
foreach ( $app in $wingetRemovedApps ) {
if ( Test-AppStillInstalled -appId $app -InstalledList $postRemovalList ) {
Write-Host " Unable to uninstall $app via WinGet " -ForegroundColor Red
2026-03-07 20:28:48 +01:00
}
2026-06-22 22:13:01 +02:00
}
# Verify Edge separately (triggers its own force-remove path if still installed)
$edgeStillInstalled = $false
foreach ( $edgeApp in $edgeAppsInList ) {
if ( Test-AppStillInstalled -appId $edgeApp -InstalledList $postRemovalList ) {
$edgeStillInstalled = $true
break
2026-03-07 20:28:48 +01:00
}
2026-06-22 22:13:01 +02:00
}
if ( $edgeStillInstalled ) {
Write-Host " Unable to uninstall Microsoft Edge via WinGet " -ForegroundColor Red
Request-EdgeForceRemove
}
}
2026-03-23 22:59:04 +01:00
2026-06-22 22:13:01 +02:00
Write-Host " "
}
2026-03-23 22:59:04 +01:00
2026-06-22 22:13:01 +02:00
<#
. SYNOPSIS
Uninstalls a non-Edge app via WinGet and / or schedules its removal .
. DESCRIPTION
Runs winget uninstall for a single app . If the User or Sysprep
parameter was passed , also schedules removal for future logins .
After uninstall , the system is checked to confirm whether the app
is still present — winget output is not trusted on its
own , as it sometimes reports failure after a successful removal .
Edge apps are handled separately after the main loop .
. PARAMETER app
The WinGet package ID to uninstall ( e . g . 'Microsoft.BingNews' ) .
#>
function Remove-WinGetApp {
param ( [ string ] $app )
if ( -not $script:WingetInstalled ) {
Write-Host " ERROR: WinGet is either not installed or is outdated, $app could not be removed " -ForegroundColor Red
return
}
if ( $script:Params . ContainsKey ( " User " ) ) {
Write-Host " Adding scheduled task to uninstall $app for user $( GetUserName ) ... "
Set-RunOnceWingetTask -appId $app
}
elseif ( $script:Params . ContainsKey ( " Sysprep " ) ) {
Write-Host " Adding scheduled task to uninstall $app for new users... "
Set-RunOnceWingetTask -appId $app
}
Invoke-NonBlocking -ScriptBlock {
param ( $appId )
winget uninstall - -accept -source -agreements - -disable -interactivity - -id $appId
} -ArgumentList $app
}
<#
. SYNOPSIS
Removes Microsoft Edge via WinGet ( both AppIds ) , with fallback to force-remove .
. DESCRIPTION
Edge has multiple package IDs . Runs winget uninstall for each one ,
then creates a single scheduled task if the User or Sysprep parameter
was passed . After all attempts , the system is checked to confirm
whether Edge is still present . The force-remove prompt only
appears if Edge remains installed — winget false positives are ignored .
. PARAMETER edgeAppsInList
The Edge AppIds that appear in the removal list ( one or both ) .
#>
function Remove-EdgeApp {
param ( [ string[] ] $edgeAppsInList )
if ( -not $script:WingetInstalled ) {
Write-Host " ERROR: WinGet is either not installed or is outdated, Microsoft Edge could not be removed " -ForegroundColor Red
return
}
if ( $script:Params . ContainsKey ( " User " ) ) {
Write-Host " Adding scheduled task to uninstall Microsoft Edge for user $( GetUserName ) ... "
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
elseif ( $script:Params . ContainsKey ( " Sysprep " ) ) {
Write-Host " Adding scheduled task to uninstall Microsoft Edge for new users... "
Set-RunOnceWingetTask -appId 'Microsoft.Edge'
}
foreach ( $edgeApp in $edgeAppsInList ) {
Write-Host " Removing $edgeApp "
Invoke-NonBlocking -ScriptBlock {
param ( $appId )
winget uninstall - -accept -source -agreements - -disable -interactivity - -id $appId
} -ArgumentList $edgeApp
}
}
<#
. 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 {
switch ( $targetUser ) {
" AllUsers " {
Invoke-NonBlocking -ScriptBlock {
param ( $pattern )
Get-AppxPackage -Name $pattern -AllUsers | Remove-AppxPackage -AllUsers -ErrorAction Continue
Get-AppxProvisionedPackage -Online | Where-Object { $_ . PackageName -like $pattern } | ForEach-Object { Remove-ProvisionedAppxPackage -Online -AllUsers -PackageName $_ . PackageName }
} -ArgumentList $appPattern
2026-03-07 20:28:48 +01:00
}
2026-06-22 22:13:01 +02:00
" CurrentUser " {
Invoke-NonBlocking -ScriptBlock {
param ( $pattern )
Get-AppxPackage -Name $pattern | Remove-AppxPackage -ErrorAction Continue
} -ArgumentList $appPattern
}
default {
Invoke-NonBlocking -ScriptBlock {
param ( $pattern , $user )
$userAccount = New-Object System . Security . Principal . NTAccount ( $user )
$userSid = $userAccount . Translate ( [ System.Security.Principal.SecurityIdentifier ] ) . Value
Get-AppxPackage -Name $pattern -User $userSid | Remove-AppxPackage -User $userSid -ErrorAction Continue
} -ArgumentList @ ( $appPattern , $targetUser )
}
}
}
catch {
Write-Verbose " Something went wrong while trying to remove $( $app ) : $_ "
}
}
2026-03-07 20:28:48 +01:00
2026-06-22 22:13:01 +02:00
<#
. SYNOPSIS
Checks whether an app package is still installed after a removal attempt .
. DESCRIPTION
Checks Get-AppxPackage across all users first ( fast , no process launch ) ,
then falls back to a pre-fetched or live winget list for non-Appx packages .
Uses Test-AppInWingetList which provides exact-match -first with substring
fallback against the parsed winget objects .
Returns $true if the app is still present , $false otherwise .
. PARAMETER appId
The package identifier to check ( e . g . 'Microsoft.BingNews' ) .
. PARAMETER InstalledList
Optional pre-fetched array of winget objects from GetInstalledAppsViaWinget .
When provided , used directly ; otherwise a live winget call is made .
#>
function Test-AppStillInstalled {
param (
[ string ] $appId ,
[ object[] ] $InstalledList
)
# Check Get-AppxPackage for all users first (fast, covers all Store apps).
if ( Get-AppxPackage -Name " $appId " -AllUsers -ErrorAction SilentlyContinue ) {
return $true
}
# Use the pre-fetched list if provided; otherwise fall back to a live winget call.
if ( $InstalledList ) {
return ( Test-AppInWingetList -appId $appId -InstalledList $InstalledList )
}
if ( $script:WingetInstalled ) {
$liveList = GetInstalledAppsViaWinget -TimeOut 10 -NonBlocking
if ( Test-AppInWingetList -appId $appId -InstalledList $liveList ) {
return $true
2026-03-07 20:28:48 +01:00
}
2026-06-22 22:13:01 +02:00
}
else {
Write-Warning " Unable to verify whether ' $appId ' is still installed (WinGet is unavailable) "
}
return $false
}
2026-03-07 20:28:48 +01:00
2026-06-22 22:13:01 +02:00
<#
. SYNOPSIS
Returns the removal method for an app identifier .
2026-03-07 20:28:48 +01:00
2026-06-22 22:13:01 +02:00
. 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 = @ { }
2026-03-07 20:28:48 +01:00
try {
2026-06-22 22:13:01 +02:00
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' }
if ( $appData . AppId -is [ array ] ) {
foreach ( $id in $appData . AppId ) { $script:AppRemovalMethodCache [ $id . Trim ( ) ] = $method }
}
else {
$script:AppRemovalMethodCache [ $appData . AppId . Trim ( ) ] = $method
}
2026-03-07 20:28:48 +01:00
}
}
}
catch {
2026-06-22 22:13:01 +02:00
Write-Warning " Failed to load app removal methods from ' $script:AppsListFilePath '. Defaulting unknown apps to Appx. Error: $_ "
2026-03-07 20:28:48 +01:00
}
}
2026-06-22 22:13:01 +02:00
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 " "
ForceRemoveEdge
}
}
elseif ( $ ( Read-Host -Prompt " Would you like to forcefully uninstall Microsoft Edge? NOT RECOMMENDED! (y/n) " ) -eq 'y' ) {
Write-Host " "
ForceRemoveEdge
}
}
<#
. 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
}
catch {
Write-Host " Failed to schedule uninstall task for $( $appId ) : $_ " -ForegroundColor Red
}
2026-03-07 20:28:48 +01:00
}