bin/uninstall.ps1
|
# WinMole - Smart App Uninstaller # Lists installed apps with sizes, lets you select which to remove, # then runs their uninstallers and cleans up leftover AppData/registry entries. param( [switch]$DryRun ) $ErrorActionPreference = 'SilentlyContinue' . "$PSScriptRoot\..\lib\core.ps1" . "$PSScriptRoot\..\lib\ui.ps1" function Get-AppPropertyValue { param( [object]$App, [string]$Name, $Default = $null ) return Get-OptionalPropertyValue -InputObject $App -Name $Name -Default $Default } Write-Header -Title 'WinMole Uninstall' -Sub $(if ($DryRun) { '(dry run)' } else { '' }) Write-SpectreHost " [grey]Scanning installed applications... (this may take a moment)[/]" # ── Collect installed apps from registry ────────────────────────────────────── $regPaths = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $apps = foreach ($rp in $regPaths) { Get-ItemProperty $rp -ErrorAction SilentlyContinue | Where-Object { (Get-AppPropertyValue -App $_ -Name 'DisplayName') -and (Get-AppPropertyValue -App $_ -Name 'UninstallString') -and -not (Get-AppPropertyValue -App $_ -Name 'SystemComponent' -Default $false) -and -not (Get-AppPropertyValue -App $_ -Name 'ParentKeyName') } } # Deduplicate by DisplayName, prefer entries with more info $seen = @{} $deduped = foreach ($app in $apps) { $name = ([string](Get-AppPropertyValue -App $app -Name 'DisplayName' -Default '')).Trim() if (-not $name) { continue } $size = [long](Get-AppPropertyValue -App $app -Name 'EstimatedSize' -Default 0) if (-not $seen.ContainsKey($name)) { $seen[$name] = $app } elseif ($size -gt [long](Get-AppPropertyValue -App $seen[$name] -Name 'EstimatedSize' -Default 0)) { $seen[$name] = $app } } $deduped = $seen.Values # ── Determine last-used heuristic ───────────────────────────────────────────── $cutoff = (Get-Date).AddDays(-30) $menuItems = foreach ($app in $deduped | Sort-Object DisplayName) { $displayName = ([string](Get-AppPropertyValue -App $app -Name 'DisplayName' -Default '')).Trim() if (-not $displayName) { continue } $size = [long](Get-AppPropertyValue -App $app -Name 'EstimatedSize' -Default 0) * 1KB # registry stores in KB # Heuristic: check install date $tag = '' $installDateValue = [string](Get-AppPropertyValue -App $app -Name 'InstallDate' -Default '') if ($installDateValue -and $installDateValue -match '^\d{8}$') { try { $installDate = [datetime]::ParseExact($installDateValue, 'yyyyMMdd', $null) if ($installDate -lt $cutoff.AddDays(-150)) { $tag = 'Old' } elseif ($installDate -ge $cutoff) { $tag = 'Recent' } } catch {} } [pscustomobject]@{ Label = $displayName Size = $size Tag = $tag Selected = ($tag -eq 'Old') Disabled = $false UninstallStr = [string](Get-AppPropertyValue -App $app -Name 'UninstallString' -Default '') QuietStr = [string](Get-AppPropertyValue -App $app -Name 'QuietUninstallString' -Default '') InstallLoc = [string](Get-AppPropertyValue -App $app -Name 'InstallLocation' -Default '') Publisher = [string](Get-AppPropertyValue -App $app -Name 'Publisher' -Default '') } } $menuItems = @($menuItems) if ($menuItems.Count -eq 0) { Write-Warn "No removable applications found." return } Write-Host "" # ── Interactive selection ───────────────────────────────────────────────────── $chosen = Show-ChecklistMenu -Title 'Select Apps to Remove' -Items $menuItems if (-not $chosen) { Write-SpectreHost " [grey]Cancelled.[/]" Write-Host "" return } $toRemove = @($chosen | Where-Object { $_.Selected }) if ($toRemove.Count -eq 0) { Write-Info "No apps selected." return } Write-Host "" Write-SpectreHost " [bold]Apps to remove ($($toRemove.Count)):[/]" foreach ($app in $toRemove) { Write-SpectreHost " [gold1]•[/] $(Esc $app.Label) [grey]$(Format-Bytes $app.Size)[/]" } Write-Host "" if ($DryRun) { Write-Info "Dry run — no changes made." return } if (-not (Confirm-Action -Message 'Proceed with uninstall?')) { Write-SpectreHost " [grey]Cancelled.[/]" Write-Host "" return } # ── Leftover path patterns ──────────────────────────────────────────────────── function Get-AppLeftoverPaths { param([string]$AppName, [string]$Publisher, [string]$InstallLocation) $slug = $AppName -replace '[^a-zA-Z0-9 ]','' -replace '\s+', ' ' $words = ($slug -split ' ' | Where-Object { $_.Length -gt 3 }) $short = if ($words) { $words[0] } else { ($slug -split ' ')[0] } $paths = @( "$env:APPDATA\$AppName", "$env:APPDATA\$short", "$env:LOCALAPPDATA\$AppName", "$env:LOCALAPPDATA\$short", "$env:PROGRAMDATA\$AppName", "$env:PROGRAMDATA\$short" ) if ($Publisher -and $Publisher.Trim()) { $pub = $Publisher.Trim() $paths += @( "$env:APPDATA\$pub", "$env:LOCALAPPDATA\$pub", "$env:PROGRAMDATA\$pub" ) } if ($InstallLocation -and (Test-Path $InstallLocation)) { $paths += $InstallLocation } return @($paths | Select-Object -Unique | Where-Object { Test-Path $_ }) } # ── Uninstall loop ──────────────────────────────────────────────────────────── $totalFreed = [long]0 foreach ($app in $toRemove) { Write-Host "" Write-SpectreHost " [bold]Uninstalling:[/] [deepskyblue1]$(Esc $app.Label)[/]" # Run uninstaller $uninstalled = $false $uninstStr = if ($app.QuietStr) { $app.QuietStr } else { $app.UninstallStr } if ($uninstStr) { try { $invocation = Get-UninstallInvocation -UninstallString $app.UninstallStr -QuietUninstallString $app.QuietStr if (-not $invocation) { throw "Invalid uninstall command for $($app.Label)" } $proc = Start-Process -FilePath $invocation.FilePath -ArgumentList $invocation.ArgumentList -Wait -PassThru ` -ErrorAction SilentlyContinue $uninstalled = ($null -ne $proc -and $proc.ExitCode -eq 0) if ($uninstalled) { Write-Success -Message 'Removed application' } else { $exitCode = if ($null -ne $proc) { $proc.ExitCode } else { 'unknown' } Write-Warn "Uninstaller exited with code $exitCode" } } catch { Write-Warn "Could not run uninstaller: $_" } } # Clean up leftover paths $leftovers = Get-AppLeftoverPaths -AppName $app.Label ` -Publisher $app.Publisher -InstallLocation $app.InstallLoc $leftoverFreed = [long]0 $leftoverCount = 0 foreach ($path in $leftovers) { $sz = Remove-ItemSafe -Path $path -Recurse if ($sz -gt 0 -or (Test-Path $path) -eq $false) { $leftoverFreed += $sz $leftoverCount++ Write-OpLog "Removed leftover: $path ($(Format-Bytes $sz))" } } if ($leftoverCount -gt 0) { Write-Success -Message "Cleaned $leftoverCount leftover location(s)" ` -Right (Format-Bytes $leftoverFreed) } $appFreed = $(if ($uninstalled) { $app.Size } else { 0 }) + $leftoverFreed $totalFreed += $appFreed Write-OpLog "Uninstalled '$($app.Label)': freed ~$(Format-Bytes $appFreed)" } Write-Host "" Write-Summary -Label 'Space freed:' -Value (Format-Bytes -Bytes $totalFreed) |