Last active
July 18, 2026 12:50
-
-
Save Yunuuuu/167cea034ec1ff03c839c745db66c522 to your computer and use it in GitHub Desktop.
Interactive PowerShell function for finding and removing stale Windows uninstall registry entries.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Finds and removes Windows uninstall registry entries interactively. | |
| .DESCRIPTION | |
| Scans user and machine uninstall registry locations, detects stale MSI | |
| entries, and lets you select entries before deletion. | |
| Selection: | |
| - Press Enter to select detected stale MSI entries. | |
| - Enter 0 to select all matching entries. | |
| - Enter one or more indexes, such as 1 or 1,3. | |
| - Enter q to cancel. | |
| - At the deletion prompt, press Enter or enter y to confirm. | |
| This function only removes uninstall registry entries. It does not remove | |
| application files, settings, services, scheduled tasks, or other leftovers. | |
| Machine-level entries require an elevated PowerShell session. | |
| .PARAMETER Name | |
| The application display name to search for. Partial matching is used by default. | |
| .PARAMETER Version | |
| Optionally limits results to an exact DisplayVersion value. | |
| .PARAMETER Exact | |
| Requires the display name to match exactly. | |
| .EXAMPLE | |
| Remove-UninstallEntry 'draw.io' | |
| .EXAMPLE | |
| Remove-UninstallEntry 'QuickLook' -Exact | |
| .EXAMPLE | |
| Remove-UninstallEntry 'draw.io' -Version '26.1.1.0' | |
| #> | |
| function Remove-UninstallEntry { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory, Position = 0)] | |
| [ValidateNotNullOrEmpty()] | |
| [string] $Name, | |
| [string] $Version, | |
| [switch] $Exact | |
| ) | |
| $locations = @( | |
| @{ | |
| Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' | |
| Scope = 'User' | |
| View = '64-bit' | |
| } | |
| @{ | |
| Path = 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' | |
| Scope = 'User' | |
| View = '32-bit' | |
| } | |
| @{ | |
| Path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall' | |
| Scope = 'Machine' | |
| View = '64-bit' | |
| } | |
| @{ | |
| Path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' | |
| Scope = 'Machine' | |
| View = '32-bit' | |
| } | |
| ) | |
| $guidRegex = [regex]::new( | |
| '\{[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}\}' | |
| ) | |
| try { | |
| $installer = New-Object -ComObject WindowsInstaller.Installer | |
| } | |
| catch { | |
| Write-Warning 'Unable to access Windows Installer. MSI states will be unknown.' | |
| $installer = $null | |
| } | |
| function Test-NameMatch { | |
| param ( | |
| [AllowNull()] | |
| [string] $DisplayName | |
| ) | |
| if ([string]::IsNullOrWhiteSpace($DisplayName)) { | |
| return $false | |
| } | |
| if ($Exact) { | |
| return $DisplayName -eq $Name | |
| } | |
| return $DisplayName.IndexOf( | |
| $Name, | |
| [StringComparison]::OrdinalIgnoreCase | |
| ) -ge 0 | |
| } | |
| function Get-MsiStatus { | |
| param ( | |
| [AllowNull()] | |
| [Nullable[int]] $State | |
| ) | |
| if ($null -eq $State) { | |
| return 'MSI state unknown' | |
| } | |
| switch ($State) { | |
| -1 { return 'Stale MSI entry' } | |
| 0 { return 'Broken MSI installation' } | |
| 1 { return 'Advertised MSI product' } | |
| 2 { return 'MSI product absent' } | |
| 3 { return 'MSI installed locally' } | |
| 4 { return 'MSI running from source' } | |
| 5 { return 'MSI installed' } | |
| default { return "MSI state $State" } | |
| } | |
| } | |
| function Get-MatchingEntries { | |
| $results = @() | |
| foreach ($location in $locations) { | |
| if (-not (Test-Path -LiteralPath $location.Path)) { | |
| continue | |
| } | |
| foreach ( | |
| $key in Get-ChildItem ` | |
| -LiteralPath $location.Path ` | |
| -ErrorAction SilentlyContinue | |
| ) { | |
| $entry = Get-ItemProperty ` | |
| -LiteralPath $key.PSPath ` | |
| -ErrorAction SilentlyContinue | |
| if (-not (Test-NameMatch $entry.DisplayName)) { | |
| continue | |
| } | |
| if ( | |
| -not [string]::IsNullOrWhiteSpace($Version) -and | |
| [string]$entry.DisplayVersion -ne $Version | |
| ) { | |
| continue | |
| } | |
| $uninstallString = [string]$entry.UninstallString | |
| $quietString = [string]$entry.QuietUninstallString | |
| $modifyPath = [string]$entry.ModifyPath | |
| $searchText = @( | |
| $key.PSChildName | |
| $uninstallString | |
| $quietString | |
| $modifyPath | |
| ) -join ' ' | |
| $guidMatch = $guidRegex.Match($searchText) | |
| $productCode = if ($guidMatch.Success) { | |
| $guidMatch.Value | |
| } | |
| else { | |
| $null | |
| } | |
| $isMsi = ( | |
| $entry.WindowsInstaller -eq 1 -or | |
| $uninstallString -match '(?i)\bmsiexec(?:\.exe)?\b' -or | |
| $quietString -match '(?i)\bmsiexec(?:\.exe)?\b' | |
| ) | |
| $msiState = $null | |
| if ($isMsi -and $productCode -and $null -ne $installer) { | |
| try { | |
| $msiState = [int]$installer.ProductState($productCode) | |
| } | |
| catch { | |
| $msiState = $null | |
| } | |
| } | |
| $isResidual = $isMsi -and $msiState -eq -1 | |
| $results += [pscustomobject]@{ | |
| DisplayName = [string]$entry.DisplayName | |
| DisplayVersion = [string]$entry.DisplayVersion | |
| Scope = $location.Scope | |
| RegistryView = $location.View | |
| Status = if ($isMsi) { | |
| Get-MsiStatus $msiState | |
| } | |
| else { | |
| 'Non-MSI entry' | |
| } | |
| IsResidual = $isResidual | |
| ProductCode = $productCode | |
| InstallLocation = [string]$entry.InstallLocation | |
| UninstallString = $uninstallString | |
| RegistryPath = $key.Name | |
| PSPath = $key.PSPath | |
| } | |
| } | |
| } | |
| return $results | |
| } | |
| $found = @(Get-MatchingEntries) | |
| if ($found.Count -eq 0) { | |
| Write-Warning "No uninstall entries matched '$Name'." | |
| return | |
| } | |
| $items = @() | |
| $index = 0 | |
| foreach ( | |
| $item in $found | | |
| Sort-Object ` | |
| @{ Expression = 'IsResidual'; Descending = $true }, | |
| DisplayName, | |
| DisplayVersion, | |
| Scope, | |
| RegistryView | |
| ) { | |
| $index++ | |
| $items += [pscustomobject]@{ | |
| Default = if ($item.IsResidual) { '*' } else { '' } | |
| Index = $index | |
| DisplayName = $item.DisplayName | |
| DisplayVersion = $item.DisplayVersion | |
| Scope = $item.Scope | |
| RegistryView = $item.RegistryView | |
| Status = $item.Status | |
| IsResidual = $item.IsResidual | |
| ProductCode = $item.ProductCode | |
| InstallLocation = $item.InstallLocation | |
| UninstallString = $item.UninstallString | |
| RegistryPath = $item.RegistryPath | |
| PSPath = $item.PSPath | |
| } | |
| } | |
| Write-Host | |
| Write-Host '* = selected by default when Enter is pressed.' | |
| Write-Host | |
| $items | | |
| Format-Table ` | |
| Default, | |
| Index, | |
| DisplayName, | |
| DisplayVersion, | |
| Scope, | |
| RegistryView, | |
| Status, | |
| ProductCode ` | |
| -AutoSize ` | |
| -Wrap | |
| Write-Host | |
| $selection = ( | |
| Read-Host 'Enter indexes, press Enter for stale entries, 0 for all, or q to cancel' | |
| ).Trim() | |
| if ($selection -match '^(?i:q|quit|cancel)$') { | |
| Write-Host 'Cancelled.' | |
| return | |
| } | |
| if ([string]::IsNullOrWhiteSpace($selection)) { | |
| $selected = @( | |
| $items | | |
| Where-Object IsResidual | |
| ) | |
| if ($selected.Count -eq 0) { | |
| Write-Warning 'No stale MSI entries were detected.' | |
| return | |
| } | |
| } | |
| elseif ($selection -eq '0') { | |
| $selected = @($items) | |
| } | |
| else { | |
| $tokens = @( | |
| $selection -split '[,\s]+' | | |
| Where-Object { | |
| -not [string]::IsNullOrWhiteSpace($_) | |
| } | |
| ) | |
| $invalidTokens = @( | |
| $tokens | | |
| Where-Object { | |
| $_ -notmatch '^\d+$' | |
| } | |
| ) | |
| if ($invalidTokens.Count -gt 0) { | |
| Write-Warning "Invalid selection: $($invalidTokens -join ', ')" | |
| return | |
| } | |
| $numbers = @( | |
| $tokens | | |
| ForEach-Object { [int]$_ } | | |
| Select-Object -Unique | |
| ) | |
| if ($numbers -contains 0) { | |
| Write-Warning 'Use 0 by itself.' | |
| return | |
| } | |
| $invalidNumbers = @( | |
| $numbers | | |
| Where-Object { | |
| $_ -lt 1 -or | |
| $_ -gt $items.Count | |
| } | |
| ) | |
| if ($invalidNumbers.Count -gt 0) { | |
| Write-Warning "Indexes out of range: $($invalidNumbers -join ', ')" | |
| return | |
| } | |
| $selected = @( | |
| $items | | |
| Where-Object { | |
| $_.Index -in $numbers | |
| } | |
| ) | |
| } | |
| $identity = [Security.Principal.WindowsIdentity]::GetCurrent() | |
| $principal = [Security.Principal.WindowsPrincipal]::new($identity) | |
| $isAdministrator = $principal.IsInRole( | |
| [Security.Principal.WindowsBuiltInRole]::Administrator | |
| ) | |
| foreach ($item in $selected) { | |
| Write-Host | |
| Write-Host ('=' * 72) | |
| Write-Host "Name: $($item.DisplayName)" | |
| Write-Host "Version: $($item.DisplayVersion)" | |
| Write-Host "Status: $($item.Status)" | |
| Write-Host "Scope: $($item.Scope) / $($item.RegistryView)" | |
| Write-Host "Product code: $($item.ProductCode)" | |
| Write-Host "Install location: $($item.InstallLocation)" | |
| Write-Host "Uninstall string: $($item.UninstallString)" | |
| Write-Host "Registry path: $($item.RegistryPath)" | |
| Write-Host | |
| while ($true) { | |
| $confirmation = ( | |
| Read-Host 'Delete this uninstall registry entry? [Y/n]' | |
| ).Trim() | |
| if ( | |
| [string]::IsNullOrWhiteSpace($confirmation) -or | |
| $confirmation -match '^(?i:y|yes)$' | |
| ) { | |
| $shouldDelete = $true | |
| break | |
| } | |
| if ($confirmation -match '^(?i:n|no|q|quit|cancel)$') { | |
| $shouldDelete = $false | |
| break | |
| } | |
| Write-Warning 'Enter y, n, or press Enter to delete.' | |
| } | |
| if (-not $shouldDelete) { | |
| Write-Host 'Skipped.' | |
| continue | |
| } | |
| $isMachineEntry = $item.RegistryPath.StartsWith( | |
| 'HKEY_LOCAL_MACHINE\', | |
| [StringComparison]::OrdinalIgnoreCase | |
| ) | |
| if ($isMachineEntry -and -not $isAdministrator) { | |
| Write-Error 'Run PowerShell as administrator to remove this entry.' | |
| continue | |
| } | |
| try { | |
| Remove-Item ` | |
| -LiteralPath $item.PSPath ` | |
| -Recurse ` | |
| -Force ` | |
| -ErrorAction Stop | |
| if (Test-Path -LiteralPath $item.PSPath) { | |
| Write-Error 'The registry entry still exists.' | |
| } | |
| else { | |
| Write-Host 'Removed.' | |
| } | |
| } | |
| catch { | |
| Write-Error "Removal failed: $($_.Exception.Message)" | |
| } | |
| } | |
| $remaining = @(Get-MatchingEntries) | |
| Write-Host | |
| if ($remaining.Count -eq 0) { | |
| Write-Host "No matching uninstall entries remain for '$Name'." | |
| } | |
| else { | |
| Write-Host 'Remaining matching entries:' | |
| $remaining | | |
| Format-Table ` | |
| DisplayName, | |
| DisplayVersion, | |
| Scope, | |
| RegistryView, | |
| Status, | |
| RegistryPath ` | |
| -AutoSize ` | |
| -Wrap | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment