<# .SYNOPSIS Safely reclaim space in C:\Windows\Installer and adjacent caches without breaking future Windows updates, repairs, or uninstalls. .DESCRIPTION Enumerates installed products and patches via the WindowsInstaller COM API (the same source of truth msiexec uses), diffs that against the files on disk in C:\Windows\Installer, and offers four cleanup categories: - InstallerOrphans MSI/MSP in C:\Windows\Installer not referenced by any installed product or patch. - RegistryOrphans Uninstall registry entries whose target is gone. - SupersededPatches MSP files for patches superseded by newer ones. - WUDownload C:\Windows\SoftwareDistribution\Download\* Runs as a CLI by default; pass -Gui for a WPF window. .EXAMPLE .\Clean-WindowsInstaller.ps1 -Scan -Categories All -ReportPath .\report.csv .EXAMPLE .\Clean-WindowsInstaller.ps1 -Clean -WhatIf .EXAMPLE .\Clean-WindowsInstaller.ps1 -Gui .EXAMPLE # Preflight: just check WinRM reachability + elevation, no staging/cleanup. # -StartWinRM brings the service up over DCOM first if it's stopped. .\Clean-WindowsInstaller.ps1 -Remote -ComputerName PC1,PC2 ` -Credential (Get-Credential) -TestConnection -StartWinRM .EXAMPLE # Scan two remote machines (read-only, nothing deleted). .\Clean-WindowsInstaller.ps1 -Remote -ComputerName PC1,PC2 ` -Credential (Get-Credential) -Categories All .EXAMPLE # Actually clean a remote machine. Remote sessions are non-interactive, # so -Force is required to authorize deletion (there is no DELETE prompt), # and -CollectLogs pulls the run log + audit CSV back to this machine. .\Clean-WindowsInstaller.ps1 -Remote -ComputerName PC1 ` -Credential (Get-Credential) -RemoteClean -Force ` -Categories InstallerOrphans,SupersededPatches -CollectLogs #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Scan')] param( [Parameter(ParameterSetName = 'Scan')] [switch] $Scan, [Parameter(ParameterSetName = 'Clean')] [switch] $Clean, [Parameter(ParameterSetName = 'Gui')] [switch] $Gui, [Parameter(ParameterSetName = 'Remote', Mandatory)] [switch] $Remote, [Parameter(ParameterSetName = 'Remote', Mandatory)] [Alias('Cn','Computer')] [string[]] $ComputerName, [Parameter(ParameterSetName = 'Remote')] [pscredential] $Credential, # When set, performs deletion on the remote machine. Without it, the remote # run is scan-only. Requires -Force because a remote session cannot prompt. [Parameter(ParameterSetName = 'Remote')] [switch] $RemoteClean, # Connect to the remote WinRM endpoint over HTTPS (port 5986). [Parameter(ParameterSetName = 'Remote')] [switch] $UseSSL, # Copy each remote machine's run log + audit CSV back to this machine. [Parameter(ParameterSetName = 'Remote')] [switch] $CollectLogs, # Preflight only: check WinRM reachability + elevation on each target and # report, without staging the script or cleaning anything. [Parameter(ParameterSetName = 'Remote')] [switch] $TestConnection, # If WinRM is not running on a target, start it first via CIM/DCOM (which # does not need WinRM). Also flips Disabled -> Automatic so it survives. [Parameter(ParameterSetName = 'Remote')] [switch] $StartWinRM, [ValidateSet('InstallerOrphans','RegistryOrphans','SupersededPatches','WUDownload','All')] [string[]] $Categories = @('InstallerOrphans'), [switch] $Force, [string] $ReportPath, [string] $LogPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- $script:InstallerDir = Join-Path $env:SystemRoot 'Installer' $script:WUDownloadDir = Join-Path $env:SystemRoot 'SoftwareDistribution\Download' $script:DataRoot = Join-Path $env:ProgramData 'WindowsInstallerCleaner' $script:LogDir = Join-Path $script:DataRoot 'logs' # Expand 'All' to the concrete set. if ($Categories -contains 'All') { $Categories = @('InstallerOrphans','RegistryOrphans','SupersededPatches','WUDownload') } # --------------------------------------------------------------------------- # Sanity + elevation # --------------------------------------------------------------------------- function Test-Admin { $id = [Security.Principal.WindowsIdentity]::GetCurrent() ([Security.Principal.WindowsPrincipal]$id).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) } function Assert-Environment { if ($env:SystemRoot -ne 'C:\Windows') { throw "Refusing to run: SystemRoot is '$env:SystemRoot' (expected C:\Windows)." } if (-not (Test-Path $script:InstallerDir)) { throw "Refusing to run: $script:InstallerDir does not exist." } } function Invoke-SelfElevate { param([string[]] $OriginalArgs) if (Test-Admin) { return } Write-Host "Re-launching elevated..." -ForegroundColor Yellow $argLine = ($OriginalArgs | ForEach-Object { if ($_ -match '\s') { '"' + $_.Replace('"','`"') + '"' } else { $_ } }) -join ' ' $scriptArg = '-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f $PSCommandPath, $argLine Start-Process -FilePath 'powershell.exe' -ArgumentList $scriptArg -Verb RunAs | Out-Null exit 0 } # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- function Start-RunLog { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $script:RunStamp = $stamp $script:TranscriptPath = if ($LogPath) { $LogPath } else { Join-Path $script:LogDir "$stamp.log" } $script:AuditCsvPath = Join-Path $script:LogDir "$stamp-removed.csv" Start-Transcript -Path $script:TranscriptPath -Append | Out-Null } function Stop-RunLog { try { Stop-Transcript | Out-Null } catch { } } function Write-Audit { param([Parameter(Mandatory)] [object[]] $Items) if (-not $Items -or $Items.Count -eq 0) { return } $Items | Select-Object Path, Size, Category, @{N='Timestamp'; E={ (Get-Date).ToString('s') }} | Export-Csv -Path $script:AuditCsvPath -NoTypeInformation -Append } # --------------------------------------------------------------------------- # Discovery: build the set of MSI/MSP files actually referenced by installed # products and patches. This is the safety-critical function. # --------------------------------------------------------------------------- function New-MsiInstaller { # Factored out so tests can mock the COM object. New-Object -ComObject WindowsInstaller.Installer } function Get-ComProperty { # Strict mode v3+ rejects direct property access on IDispatch COM objects # ("property X cannot be found"). Reading through InvokeMember bypasses # the reflection check and goes through IDispatch directly. param( [Parameter(Mandatory)] [object] $Target, [Parameter(Mandatory)] [string] $Name ) $Target.GetType().InvokeMember( $Name, [System.Reflection.BindingFlags]::GetProperty, $null, $Target, @()) } function Get-ReferencedInstallerFile { [CmdletBinding()] param( [object] $Installer = (New-MsiInstaller) ) $referenced = New-Object 'System.Collections.Generic.HashSet[string]' ( [System.StringComparer]::OrdinalIgnoreCase) foreach ($productCode in @(Get-ComProperty -Target $Installer -Name 'Products')) { try { $local = $Installer.ProductInfo($productCode, 'LocalPackage') if ($local) { [void] $referenced.Add($local) } } catch { Write-Verbose "ProductInfo failed for $productCode : $_" } try { $patches = @($Installer.Patches($productCode)) } catch { Write-Verbose "Patches() failed for $productCode : $_" $patches = @() } foreach ($patchCode in $patches) { try { $local = $Installer.PatchInfo($patchCode, 'LocalPackage') if ($local) { [void] $referenced.Add($local) } } catch { Write-Verbose "PatchInfo failed for $patchCode : $_" } } } return ,$referenced } function Get-MsiProductName { param( [Parameter(Mandatory)] [string] $Path, [object] $Installer = (New-MsiInstaller) ) try { # 0 = msiOpenDatabaseModeReadOnly $db = $Installer.OpenDatabase($Path, 0) $view = $db.OpenView("SELECT Value FROM Property WHERE Property='ProductName'") $view.Execute($null) $rec = $view.Fetch() if ($rec) { return $rec.StringData(1) } } catch { } return $null } # --------------------------------------------------------------------------- # Category 1: orphan MSI/MSP in C:\Windows\Installer # --------------------------------------------------------------------------- function Get-InstallerOrphan { [CmdletBinding()] param( [object] $Installer = (New-MsiInstaller) ) $referenced = Get-ReferencedInstallerFile -Installer $Installer Write-Verbose "Referenced MSI/MSP count: $($referenced.Count)" Get-ChildItem -Path $script:InstallerDir -Force -File ` -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.msi','.msp' -and -not $referenced.Contains($_.FullName) } | ForEach-Object { [pscustomobject] @{ Category = 'InstallerOrphans' Path = $_.FullName Size = $_.Length LastModified = $_.LastWriteTime Product = (Get-MsiProductName -Path $_.FullName -Installer $Installer) } } } # --------------------------------------------------------------------------- # Category 2: dead Uninstall registry entries # --------------------------------------------------------------------------- function Get-UninstallRegistryOrphan { [CmdletBinding()] param( [object] $Installer = (New-MsiInstaller) ) $installedProductCodes = New-Object 'System.Collections.Generic.HashSet[string]' ( [System.StringComparer]::OrdinalIgnoreCase) foreach ($pc in @(Get-ComProperty -Target $Installer -Name 'Products')) { [void] $installedProductCodes.Add($pc) } $roots = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' ) foreach ($root in $roots) { if (-not (Test-Path $root)) { continue } Get-ChildItem -Path $root -ErrorAction SilentlyContinue | ForEach-Object { $key = $_ $props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue if (-not $props) { return } $display = $props.DisplayName $installLoc = $props.InstallLocation $uninstall = $props.UninstallString $name = $key.PSChildName $isMsiGuid = $name -match '^\{[0-9A-Fa-f-]+\}$' $productCodeStillInstalled = $isMsiGuid -and $installedProductCodes.Contains($name) $installLocMissing = $installLoc -and -not (Test-Path -LiteralPath $installLoc) $uninstallMissing = $false if ($uninstall -and $uninstall -notmatch '(?i)msiexec') { # Pull the executable path from the UninstallString and check it. $exePath = $null if ($uninstall -match '^"([^"]+)"') { $exePath = $Matches[1] } elseif ($uninstall -match '^(\S+)') { $exePath = $Matches[1] } if ($exePath -and -not (Test-Path -LiteralPath $exePath)) { $uninstallMissing = $true } } $isOrphan = $false $reason = $null if ($isMsiGuid -and -not $productCodeStillInstalled) { $isOrphan = $true $reason = 'MSI product code not installed' } elseif ($installLocMissing -and ($uninstallMissing -or -not $uninstall)) { $isOrphan = $true $reason = 'InstallLocation gone and no working UninstallString' } if ($isOrphan -and $display) { [pscustomobject] @{ Category = 'RegistryOrphans' Path = $key.PSPath Size = 0 LastModified = $null Product = $display Reason = $reason } } } } } # --------------------------------------------------------------------------- # Category 3: superseded MSP patches # --------------------------------------------------------------------------- function Get-SupersededPatch { [CmdletBinding()] param( [object] $Installer = (New-MsiInstaller) ) # PatchesEx(patchCode=null, productCode=null, context=7=All, filter=2=Superseded) $supersededFilter = 2 $allContexts = 7 try { $patches = @($Installer.PatchesEx($null, $null, $allContexts, $supersededFilter)) } catch { Write-Warning "PatchesEx failed; cannot enumerate superseded patches: $_" return } foreach ($patch in $patches) { $local = $null try { $local = $patch.PatchProperty('LocalPackage') } catch { } if (-not $local -or -not (Test-Path -LiteralPath $local)) { continue } $info = Get-Item -LiteralPath $local [pscustomobject] @{ Category = 'SupersededPatches' Path = $info.FullName Size = $info.Length LastModified = $info.LastWriteTime Product = $patch.PatchProperty('DisplayName') } } } # --------------------------------------------------------------------------- # Category 4: SoftwareDistribution\Download cleanup # --------------------------------------------------------------------------- function Get-WUDownloadItem { if (-not (Test-Path $script:WUDownloadDir)) { return } Get-ChildItem -Path $script:WUDownloadDir -Force -Recurse -File ` -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject] @{ Category = 'WUDownload' Path = $_.FullName Size = $_.Length LastModified = $_.LastWriteTime Product = 'Windows Update payload' } } } function Stop-WUServices { foreach ($svc in 'wuauserv','bits','cryptSvc','msiserver') { try { Stop-Service -Name $svc -Force -ErrorAction Stop } catch { Write-Verbose "Stop-Service $svc : $_" } } } function Start-WUServices { foreach ($svc in 'msiserver','cryptSvc','bits','wuauserv') { try { Start-Service -Name $svc -ErrorAction Stop } catch { Write-Verbose "Start-Service $svc : $_" } } } # --------------------------------------------------------------------------- # Discovery dispatcher # --------------------------------------------------------------------------- function Get-CleanupCandidate { [CmdletBinding()] param( [string[]] $Category, [object] $Installer = (New-MsiInstaller) ) foreach ($c in $Category) { switch ($c) { 'InstallerOrphans' { Get-InstallerOrphan -Installer $Installer } 'RegistryOrphans' { Get-UninstallRegistryOrphan -Installer $Installer } 'SupersededPatches' { Get-SupersededPatch -Installer $Installer } 'WUDownload' { Get-WUDownloadItem } } } } # --------------------------------------------------------------------------- # Reporting + deletion # --------------------------------------------------------------------------- function Format-Bytes { param([long] $Bytes) $units = 'B','KB','MB','GB','TB' $i = 0; $n = [double]$Bytes while ($n -ge 1024 -and $i -lt $units.Count - 1) { $n /= 1024; $i++ } '{0:N2} {1}' -f $n, $units[$i] } function Write-CandidateSummary { param([object[]] $Items) if (-not $Items) { Write-Host "Nothing found."; return } $byCat = $Items | Group-Object Category foreach ($g in $byCat) { $sum = ($g.Group | Measure-Object Size -Sum).Sum Write-Host ("{0,-20} {1,6} items {2}" -f $g.Name, $g.Count, (Format-Bytes $sum)) } $total = ($Items | Measure-Object Size -Sum).Sum Write-Host ("{0,-20} {1,6} items {2}" -f 'TOTAL', $Items.Count, (Format-Bytes $total)) ` -ForegroundColor Cyan } function Backup-RegistryKey { param([Parameter(Mandatory)] [string] $PSPath) # Convert provider path "Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\..." # to "HKLM\..." for reg.exe. $regPath = $PSPath -replace '^Microsoft\.PowerShell\.Core\\Registry::','' $regPath = $regPath -replace '^HKEY_LOCAL_MACHINE','HKLM' ` -replace '^HKEY_CURRENT_USER','HKCU' ` -replace '^HKEY_CLASSES_ROOT','HKCR' ` -replace '^HKEY_USERS','HKU' $safe = ($regPath -replace '[\\:]','_') $backupFile = Join-Path $script:LogDir "$($script:RunStamp)-reg-$safe.reg" & reg.exe export $regPath $backupFile /y | Out-Null return $backupFile } function Invoke-Removal { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory)] [object[]] $Items, [switch] $SkipConfirmation ) if (-not $Items -or $Items.Count -eq 0) { Write-Host "Nothing to delete." return } Write-CandidateSummary -Items $Items $touchesInstallerDir = $Items | Where-Object { $_.Category -in 'InstallerOrphans','SupersededPatches' } | Select-Object -First 1 $touchesWUDownload = $Items | Where-Object { $_.Category -eq 'WUDownload' } | Select-Object -First 1 if (-not $SkipConfirmation -and -not $WhatIfPreference) { Write-Host "" Write-Host "Type DELETE to proceed, anything else to abort: " -ForegroundColor Yellow -NoNewline $resp = Read-Host if ($resp -ne 'DELETE') { Write-Host "Aborted." -ForegroundColor Red return } } if (($touchesInstallerDir -or $touchesWUDownload) -and -not $WhatIfPreference) { Stop-WUServices } try { $removed = New-Object System.Collections.Generic.List[object] foreach ($item in $Items) { $target = $item.Path if (-not $PSCmdlet.ShouldProcess($target, "Remove ($($item.Category))")) { continue } try { if ($item.Category -eq 'RegistryOrphans') { $backup = Backup-RegistryKey -PSPath $target Write-Verbose "Backed up registry key to $backup" Remove-Item -Path $target -Recurse -Force } else { Remove-Item -LiteralPath $target -Force } $removed.Add($item) } catch { Write-Warning "Failed to remove $target : $_" } } Write-Audit -Items $removed.ToArray() Write-Host ("Removed {0} of {1} items." -f $removed.Count, $Items.Count) ` -ForegroundColor Green } finally { if (($touchesInstallerDir -or $touchesWUDownload) -and -not $WhatIfPreference) { Start-WUServices } } } # --------------------------------------------------------------------------- # GUI (WPF, embedded XAML) # --------------------------------------------------------------------------- function Show-Gui { Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase [xml] $xaml = @'