1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 16:49:41 +00:00
Files
Mole/bin/uninstall.ps1
Bhadra 8e661a7b22 refactor: standardize CLI with 'mo' alias and lowercase flags
Addresses tw93's PR #305 feedback:
- Add 'mo' short alias (mo.cmd) alongside mole.cmd
- Use 'mo' in all help text and documentation
- Document lowercase flag style (--dry-run, --help, etc.)
- Simplify optimize: repairs run automatically, no extra flags
- Fix RepairsApplied counter bug in optimize.ps1
- Update README with standardized examples
2026-01-16 12:45:36 +05:30

629 lines
21 KiB
PowerShell

# Mole - Uninstall Command
# Interactive application uninstaller for Windows
#Requires -Version 5.1
[CmdletBinding()]
param(
[Alias('d')]
[switch]$DebugMode,
[Alias('r')]
[switch]$Rescan,
[Alias('h')]
[switch]$ShowHelp
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Script location
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
# Import core modules
. "$libDir\core\base.ps1"
. "$libDir\core\log.ps1"
. "$libDir\core\ui.ps1"
. "$libDir\core\file_ops.ps1"
# ============================================================================
# Configuration
# ============================================================================
$script:CacheDir = "$env:USERPROFILE\.cache\mole"
$script:AppCacheFile = "$script:CacheDir\app_scan_cache.json"
$script:CacheTTLHours = 24
# ============================================================================
# Help
# ============================================================================
function Show-UninstallHelp {
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mmo uninstall$esc[0m - Interactive application uninstaller"
Write-Host ""
Write-Host "$esc[33mUsage:$esc[0m mo uninstall [options]"
Write-Host ""
Write-Host "$esc[33mOptions:$esc[0m"
Write-Host " --rescan Force rescan of installed applications"
Write-Host " --debug Enable debug logging"
Write-Host " --help Show this help message"
Write-Host ""
Write-Host "$esc[33mFeatures:$esc[0m"
Write-Host " - Scans installed programs from registry and Windows Apps"
Write-Host " - Shows program size and last used date"
Write-Host " - Interactive selection with arrow keys"
Write-Host " - Cleans leftover files after uninstall"
Write-Host ""
}
# ============================================================================
# Protected Applications
# ============================================================================
$script:ProtectedApps = @(
"Microsoft Windows"
"Windows Feature Experience Pack"
"Microsoft Edge"
"Microsoft Edge WebView2"
"Windows Security"
"Microsoft Visual C++ *"
"Microsoft .NET *"
".NET Desktop Runtime*"
"Microsoft Update Health Tools"
"NVIDIA Graphics Driver*"
"AMD Software*"
"Intel*Driver*"
)
function Test-ProtectedApp {
param([string]$AppName)
foreach ($pattern in $script:ProtectedApps) {
if ($AppName -like $pattern) {
return $true
}
}
return $false
}
# ============================================================================
# Application Discovery
# ============================================================================
function Get-InstalledApplications {
<#
.SYNOPSIS
Scan and return all installed applications
#>
param([switch]$ForceRescan)
# Check cache
if (-not $ForceRescan -and (Test-Path $script:AppCacheFile)) {
$cacheInfo = Get-Item $script:AppCacheFile
$cacheAge = (Get-Date) - $cacheInfo.LastWriteTime
if ($cacheAge.TotalHours -lt $script:CacheTTLHours) {
Write-Debug "Loading from cache..."
try {
$cached = Get-Content $script:AppCacheFile | ConvertFrom-Json
return $cached
}
catch {
Write-Debug "Cache read failed, rescanning..."
}
}
}
Write-Info "Scanning installed applications..."
$apps = @()
# Registry paths for installed programs
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$count = 0
$total = $registryPaths.Count
foreach ($path in $registryPaths) {
$count++
Write-Progress -Activity "Scanning applications" -Status "Registry path $count of $total" -PercentComplete (($count / $total) * 50)
try {
$regItems = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
foreach ($item in $regItems) {
# Skip items without required properties
$displayName = $null
$uninstallString = $null
try { $displayName = $item.DisplayName } catch { }
try { $uninstallString = $item.UninstallString } catch { }
if ([string]::IsNullOrWhiteSpace($displayName) -or [string]::IsNullOrWhiteSpace($uninstallString)) {
continue
}
if (Test-ProtectedApp $displayName) {
continue
}
# Calculate size
$sizeKB = 0
try {
if ($item.EstimatedSize) {
$sizeKB = [long]$item.EstimatedSize
}
elseif ($item.InstallLocation -and (Test-Path $item.InstallLocation -ErrorAction SilentlyContinue)) {
$sizeKB = Get-PathSizeKB -Path $item.InstallLocation
}
}
catch { }
# Get install date
$installDate = $null
try {
if ($item.InstallDate) {
$installDate = [DateTime]::ParseExact($item.InstallDate, "yyyyMMdd", $null)
}
}
catch { }
# Get other properties safely
$publisher = $null
$version = $null
$installLocation = $null
try { $publisher = $item.Publisher } catch { }
try { $version = $item.DisplayVersion } catch { }
try { $installLocation = $item.InstallLocation } catch { }
$apps += [PSCustomObject]@{
Name = $displayName
Publisher = $publisher
Version = $version
SizeKB = $sizeKB
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
InstallLocation = $installLocation
UninstallString = $uninstallString
InstallDate = $installDate
Source = "Registry"
}
}
}
catch {
Write-Debug "Error scanning registry path $path : $_"
}
}
# UWP / Store Apps
Write-Progress -Activity "Scanning applications" -Status "Scanning Windows Apps" -PercentComplete 75
try {
$uwpApps = Get-AppxPackage -ErrorAction SilentlyContinue |
Where-Object {
$_.IsFramework -eq $false -and
$_.SignatureKind -ne 'System' -and
-not (Test-ProtectedApp $_.Name)
}
foreach ($uwp in $uwpApps) {
# Get friendly name
$name = $uwp.Name
try {
$manifest = Get-AppxPackageManifest -Package $uwp.PackageFullName -ErrorAction SilentlyContinue
if ($manifest.Package.Properties.DisplayName -and
-not $manifest.Package.Properties.DisplayName.StartsWith("ms-resource:")) {
$name = $manifest.Package.Properties.DisplayName
}
}
catch { }
# Calculate size
$sizeKB = 0
if ($uwp.InstallLocation -and (Test-Path $uwp.InstallLocation)) {
$sizeKB = Get-PathSizeKB -Path $uwp.InstallLocation
}
$apps += [PSCustomObject]@{
Name = $name
Publisher = $uwp.Publisher
Version = $uwp.Version
SizeKB = $sizeKB
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
InstallLocation = $uwp.InstallLocation
UninstallString = $null
PackageFullName = $uwp.PackageFullName
InstallDate = $null
Source = "WindowsStore"
}
}
}
catch {
Write-Debug "Could not enumerate UWP apps: $_"
}
Write-Progress -Activity "Scanning applications" -Completed
# Sort by size (largest first)
$apps = $apps | Sort-Object -Property SizeKB -Descending
# Cache results
if (-not (Test-Path $script:CacheDir)) {
New-Item -ItemType Directory -Path $script:CacheDir -Force | Out-Null
}
$apps | ConvertTo-Json -Depth 5 | Set-Content $script:AppCacheFile
return $apps
}
# ============================================================================
# Application Selection UI
# ============================================================================
function Show-AppSelectionMenu {
<#
.SYNOPSIS
Interactive menu for selecting applications to uninstall
#>
param([array]$Apps)
if ($Apps.Count -eq 0) {
Write-MoleWarning "No applications found to uninstall"
return @()
}
$esc = [char]27
$selectedIndices = @{}
$currentIndex = 0
$pageSize = 15
$pageStart = 0
$searchTerm = ""
$filteredApps = $Apps
# Hide cursor (may fail in non-interactive terminals)
try { [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
Clear-Host
# Header
Write-Host ""
Write-Host "$esc[1;35mSelect Applications to Uninstall$esc[0m"
Write-Host ""
Write-Host "$esc[90mUse: $($script:Icons.NavUp)$($script:Icons.NavDown) navigate | Space select | Enter confirm | Q quit | / search$esc[0m"
Write-Host ""
# Search indicator
if ($searchTerm) {
Write-Host "$esc[33mSearch:$esc[0m $searchTerm ($($filteredApps.Count) matches)"
Write-Host ""
}
# Display apps
$pageEnd = [Math]::Min($pageStart + $pageSize, $filteredApps.Count)
for ($i = $pageStart; $i -lt $pageEnd; $i++) {
$app = $filteredApps[$i]
$isSelected = $selectedIndices.ContainsKey($app.Name)
$isCurrent = ($i -eq $currentIndex)
# Selection indicator
$checkbox = if ($isSelected) { "$esc[32m[$($script:Icons.Success)]$esc[0m" } else { "[ ]" }
# Highlight current
if ($isCurrent) {
Write-Host "$esc[7m" -NoNewline # Reverse video
}
# App info
$name = $app.Name
if ($name.Length -gt 40) {
$name = $name.Substring(0, 37) + "..."
}
$size = $app.SizeHuman
if (-not $size -or $size -eq "0B") {
$size = "N/A"
}
Write-Host (" {0} {1,-42} {2,10}" -f $checkbox, $name, $size) -NoNewline
if ($isCurrent) {
Write-Host "$esc[0m" # Reset
}
else {
Write-Host ""
}
}
# Footer
Write-Host ""
$selectedCount = $selectedIndices.Count
if ($selectedCount -gt 0) {
$totalSize = 0
foreach ($key in $selectedIndices.Keys) {
$app = $Apps | Where-Object { $_.Name -eq $key }
if ($app.SizeKB) {
$totalSize += $app.SizeKB
}
}
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
Write-Host "$esc[33mSelected:$esc[0m $selectedCount apps ($totalSizeHuman)"
}
# Page indicator
$totalPages = [Math]::Ceiling($filteredApps.Count / $pageSize)
$currentPage = [Math]::Floor($pageStart / $pageSize) + 1
Write-Host "$esc[90mPage $currentPage of $totalPages$esc[0m"
# Handle input
$key = [Console]::ReadKey($true)
switch ($key.Key) {
'UpArrow' {
if ($currentIndex -gt 0) {
$currentIndex--
if ($currentIndex -lt $pageStart) {
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
}
}
}
'DownArrow' {
if ($currentIndex -lt $filteredApps.Count - 1) {
$currentIndex++
if ($currentIndex -ge $pageStart + $pageSize) {
$pageStart += $pageSize
}
}
}
'PageUp' {
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
$currentIndex = $pageStart
}
'PageDown' {
$pageStart = [Math]::Min($filteredApps.Count - $pageSize, $pageStart + $pageSize)
if ($pageStart -lt 0) { $pageStart = 0 }
$currentIndex = $pageStart
}
'Spacebar' {
$app = $filteredApps[$currentIndex]
if ($selectedIndices.ContainsKey($app.Name)) {
$selectedIndices.Remove($app.Name)
}
else {
$selectedIndices[$app.Name] = $true
}
}
'Enter' {
if ($selectedIndices.Count -gt 0) {
# Return selected apps
$selected = $Apps | Where-Object { $selectedIndices.ContainsKey($_.Name) }
return $selected
}
}
'Escape' {
return @()
}
'Q' {
return @()
}
'Oem2' { # Forward slash
# Search mode
Write-Host ""
Write-Host "Search: " -NoNewline
try { [Console]::CursorVisible = $true } catch { }
$searchTerm = Read-Host
try { [Console]::CursorVisible = $false } catch { }
if ($searchTerm) {
$filteredApps = $Apps | Where-Object { $_.Name -like "*$searchTerm*" }
}
else {
$filteredApps = $Apps
}
$currentIndex = 0
$pageStart = 0
}
'Backspace' {
if ($searchTerm) {
$searchTerm = ""
$filteredApps = $Apps
$currentIndex = 0
$pageStart = 0
}
}
}
}
}
finally {
try { [Console]::CursorVisible = $true } catch { }
}
}
# ============================================================================
# Uninstallation
# ============================================================================
function Uninstall-SelectedApps {
<#
.SYNOPSIS
Uninstall the selected applications
#>
param([array]$Apps)
$esc = [char]27
Write-Host ""
Write-Host "$esc[1;35mUninstalling Applications$esc[0m"
Write-Host ""
$successCount = 0
$failCount = 0
foreach ($app in $Apps) {
Write-Host "$esc[34m$($script:Icons.Arrow)$esc[0m Uninstalling: $($app.Name)" -NoNewline
try {
if ($app.Source -eq "WindowsStore") {
# UWP app
if ($app.PackageFullName) {
Remove-AppxPackage -Package $app.PackageFullName -ErrorAction Stop
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
$successCount++
}
}
else {
# Registry app with uninstall string
$uninstallString = $app.UninstallString
# Handle different uninstall types
if ($uninstallString -like "MsiExec.exe*") {
# MSI uninstall
$productCode = [regex]::Match($uninstallString, '\{[0-9A-F-]+\}').Value
if ($productCode) {
$process = Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/x", $productCode, "/qn", "/norestart" `
-Wait -PassThru -NoNewWindow
if ($process.ExitCode -eq 0 -or $process.ExitCode -eq 3010) {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
$successCount++
}
else {
Write-Host " $esc[33m(requires interaction)$esc[0m"
# Fallback to interactive uninstall
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x", $productCode -Wait
$successCount++
}
}
}
else {
# Direct executable uninstall
# Try silent uninstall first
$silentArgs = @("/S", "/silent", "/quiet", "-s", "-silent", "-quiet", "/VERYSILENT")
$uninstalled = $false
foreach ($arg in $silentArgs) {
try {
$process = Start-Process -FilePath "cmd.exe" `
-ArgumentList "/c", "`"$uninstallString`"", $arg `
-Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue
if ($process.ExitCode -eq 0) {
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
$successCount++
$uninstalled = $true
break
}
}
catch { }
}
if (-not $uninstalled) {
# Fallback to interactive - don't count as automatic success
Write-Host " $esc[33m(launching uninstaller - verify completion manually)$esc[0m"
Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$uninstallString`"" -Wait
# Note: Not incrementing $successCount since we can't verify if user completed or cancelled
}
}
}
# Clean leftover files
if ($app.InstallLocation -and (Test-Path $app.InstallLocation)) {
Write-Host " $esc[90mCleaning leftover files...$esc[0m"
Remove-SafeItem -Path $app.InstallLocation -Description "Leftover files" -Recurse
}
}
catch {
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m"
Write-Debug "Uninstall failed: $_"
$failCount++
}
}
# Summary
Write-Host ""
Write-Host "$esc[1;35mUninstall Complete$esc[0m"
Write-Host " Successfully uninstalled: $esc[32m$successCount$esc[0m"
if ($failCount -gt 0) {
Write-Host " Failed: $esc[31m$failCount$esc[0m"
}
Write-Host ""
# Clear cache
if (Test-Path $script:AppCacheFile) {
Remove-Item $script:AppCacheFile -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
# Main Entry Point
# ============================================================================
function Main {
# Enable debug if requested
if ($DebugMode) {
$env:MOLE_DEBUG = "1"
$DebugPreference = "Continue"
}
# Show help
if ($ShowHelp) {
Show-UninstallHelp
return
}
# Clear screen
Clear-Host
# Get installed apps
$apps = Get-InstalledApplications -ForceRescan:$Rescan
if ($apps.Count -eq 0) {
Write-MoleWarning "No applications found"
return
}
Write-Info "Found $($apps.Count) applications"
# Show selection menu
$selected = Show-AppSelectionMenu -Apps $apps
if ($selected.Count -eq 0) {
Write-Info "No applications selected"
return
}
# Confirm uninstall
$esc = [char]27
Clear-Host
Write-Host ""
Write-Host "$esc[33mThe following applications will be uninstalled:$esc[0m"
Write-Host ""
foreach ($app in $selected) {
Write-Host " $($script:Icons.List) $($app.Name) ($($app.SizeHuman))"
}
Write-Host ""
$confirm = Read-Host "Continue? (y/N)"
if ($confirm -eq 'y' -or $confirm -eq 'Y') {
Uninstall-SelectedApps -Apps $selected
}
else {
Write-Info "Cancelled"
}
}
# Run main
Main