PS
Clean-WindowsInstaller.ps1
PowerShell 1,002 lines 38.1 KB Jun 10, 2026
<#
.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 = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Windows Installer Cleaner" Height="640" Width="980"
        WindowStartupLocation="CenterScreen">
    <DockPanel>
        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="8">
            <TextBlock Text="Categories:" VerticalAlignment="Center" Margin="0,0,8,0"/>
            <CheckBox x:Name="cbInstaller" Content="Installer orphans" IsChecked="True" Margin="4"/>
            <CheckBox x:Name="cbRegistry"  Content="Registry orphans"   Margin="4"/>
            <CheckBox x:Name="cbSuper"     Content="Superseded patches" Margin="4"/>
            <CheckBox x:Name="cbWU"        Content="WU Download cache"  Margin="4"/>
            <Button x:Name="btnScan" Content="Scan" Width="90" Margin="16,0,0,0"/>
        </StackPanel>
        <DockPanel DockPanel.Dock="Bottom" LastChildFill="False" Margin="8">
            <TextBlock x:Name="tbStatus" DockPanel.Dock="Left" VerticalAlignment="Center"
                       Text="Idle."/>
            <Button x:Name="btnDelete" DockPanel.Dock="Right" Width="220" Height="32"
                    Content="Delete selected" Background="#C62828" Foreground="White"
                    FontWeight="Bold" IsEnabled="False"/>
        </DockPanel>
        <DataGrid x:Name="grid" AutoGenerateColumns="False" CanUserAddRows="False"
                  Margin="8" SelectionMode="Extended">
            <DataGrid.Columns>
                <DataGridCheckBoxColumn Header="" Binding="{Binding Selected, Mode=TwoWay,
                    UpdateSourceTrigger=PropertyChanged}" Width="32"/>
                <DataGridTextColumn Header="Category" Binding="{Binding Category}" Width="140"/>
                <DataGridTextColumn Header="Product"  Binding="{Binding Product}"  Width="240"/>
                <DataGridTextColumn Header="Path"     Binding="{Binding Path}"     Width="*"/>
                <DataGridTextColumn Header="Size"     Binding="{Binding SizeText}" Width="100"/>
                <DataGridTextColumn Header="Modified" Binding="{Binding LastModified}" Width="140"/>
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</Window>
'@

    $reader = New-Object System.Xml.XmlNodeReader $xaml
    $window = [Windows.Markup.XamlReader]::Load($reader)
    foreach ($name in 'cbInstaller','cbRegistry','cbSuper','cbWU','btnScan',
                      'btnDelete','grid','tbStatus') {
        Set-Variable -Name $name -Value $window.FindName($name) -Scope 1
    }

    $items = New-Object System.Collections.ObjectModel.ObservableCollection[object]
    $grid.ItemsSource = $items

    $updateStatus = {
        $sel = @($items | Where-Object Selected)
        $sumSel = ($sel | Measure-Object Size -Sum).Sum
        $sumAll = ($items | Measure-Object Size -Sum).Sum
        $tbStatus.Text = "Found {0} items ({1}). Selected {2} ({3})." -f `
            $items.Count, (Format-Bytes ([long]$sumAll)),
            $sel.Count, (Format-Bytes ([long]$sumSel))
        $btnDelete.IsEnabled = $sel.Count -gt 0
    }

    $btnScan.Add_Click({
        $tbStatus.Text = "Scanning..."
        $window.Cursor = [System.Windows.Input.Cursors]::Wait
        try {
            $items.Clear()
            $cats = @()
            if ($cbInstaller.IsChecked) { $cats += 'InstallerOrphans' }
            if ($cbRegistry.IsChecked)  { $cats += 'RegistryOrphans' }
            if ($cbSuper.IsChecked)     { $cats += 'SupersededPatches' }
            if ($cbWU.IsChecked)        { $cats += 'WUDownload' }
            if ($cats.Count -eq 0) {
                $tbStatus.Text = "Pick at least one category."
                return
            }
            $installer = New-MsiInstaller
            foreach ($row in (Get-CleanupCandidate -Category $cats -Installer $installer)) {
                $row | Add-Member -NotePropertyName Selected -NotePropertyValue $true -Force
                $row | Add-Member -NotePropertyName SizeText `
                       -NotePropertyValue (Format-Bytes ([long]$row.Size)) -Force
                $items.Add($row)
            }
            & $updateStatus
        } finally {
            $window.Cursor = $null
        }
    })

    $grid.Add_CellEditEnding({ $window.Dispatcher.InvokeAsync($updateStatus) | Out-Null })

    $btnDelete.Add_Click({
        $sel = @($items | Where-Object Selected)
        if ($sel.Count -eq 0) { return }
        $sumSel = ($sel | Measure-Object Size -Sum).Sum
        $msg = "Permanently delete $($sel.Count) items ($(Format-Bytes ([long]$sumSel)))?`n`n" +
               "This cannot be undone (registry keys are backed up to .reg files)."
        $res = [System.Windows.MessageBox]::Show($msg, 'Confirm delete',
            'YesNo', 'Warning')
        if ($res -ne 'Yes') { return }

        $window.Cursor = [System.Windows.Input.Cursors]::Wait
        try {
            Invoke-Removal -Items $sel -SkipConfirmation
            foreach ($row in $sel) { $items.Remove($row) | Out-Null }
            & $updateStatus
        } finally {
            $window.Cursor = $null
        }
    })

    $window.ShowDialog() | Out-Null
}

# ---------------------------------------------------------------------------
# Remote orchestration
#
# Stages this exact script onto each target over PowerShell Remoting and runs
# it there, so the safety-critical discovery (Get-ReferencedInstallerFile et al)
# executes against the remote machine's own WindowsInstaller database. The
# orchestrator side needs no elevation; the remote session does, and we verify
# it up front because a non-interactive remote session cannot self-elevate.
# ---------------------------------------------------------------------------

function Start-RemoteWinRM {
    # Ensure the WinRM service is running on a remote machine WITHOUT using
    # WinRM (which may be the thing that's down). Goes over CIM/DCOM (RPC),
    # so it needs SCM/DCOM reachability + admin rights, not a WSMan listener.
    # Returns $true if WinRM ends up running. Never throws.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $ComputerName,
        [pscredential] $Credential
    )

    $cim = $null
    try {
        $opt = New-CimSessionOption -Protocol Dcom
        $cimParams = @{ ComputerName = $ComputerName; SessionOption = $opt; ErrorAction = 'Stop' }
        if ($Credential) { $cimParams.Credential = $Credential }
        $cim = New-CimSession @cimParams

        $svc = Get-CimInstance -CimSession $cim -ClassName Win32_Service `
                               -Filter "Name='WinRM'" -ErrorAction Stop
        if (-not $svc) {
            Write-Warning ("{0}: WinRM service not found." -f $ComputerName)
            return $false
        }
        if ($svc.State -eq 'Running') { return $true }

        Write-Host ("{0}: WinRM is {1}; attempting to start it via DCOM..." -f `
            $ComputerName, $svc.State)

        # A Disabled service can't be started until its start mode is changed.
        if ($svc.StartMode -eq 'Disabled') {
            $r = Invoke-CimMethod -InputObject $svc -MethodName ChangeStartMode `
                                  -Arguments @{ StartMode = 'Automatic' } -ErrorAction Stop
            if ($r.ReturnValue -ne 0) {
                Write-Warning ("{0}: ChangeStartMode failed (code {1})." -f $ComputerName, $r.ReturnValue)
            }
        }

        $start = Invoke-CimMethod -InputObject $svc -MethodName StartService -ErrorAction Stop
        # 0 = success, 10 = service already running (race).
        if ($start.ReturnValue -notin 0, 10) {
            Write-Warning ("{0}: StartService failed (code {1})." -f $ComputerName, $start.ReturnValue)
            return $false
        }

        $svc = Get-CimInstance -CimSession $cim -ClassName Win32_Service `
                               -Filter "Name='WinRM'" -ErrorAction SilentlyContinue
        $running = $svc -and $svc.State -eq 'Running'
        if ($running) { Write-Host ("{0}: WinRM started." -f $ComputerName) -ForegroundColor Green }
        return $running
    } catch {
        Write-Warning ("{0}: could not start WinRM over DCOM: {1}" -f $ComputerName, $_)
        return $false
    } finally {
        if ($cim) { Remove-CimSession $cim -ErrorAction SilentlyContinue }
    }
}

function Test-RemoteWinRM {
    # Probe WinRM on one machine without opening a full session. Returns a
    # result object; never throws. Used for the -TestConnection preflight.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $ComputerName,
        [pscredential] $Credential,
        [switch] $UseSSL,
        [switch] $StartWinRM
    )

    $result = [pscustomobject] @{
        ComputerName = $ComputerName
        Reachable    = $false
        Elevated     = $false
        Detail       = $null
    }

    $probe = {
        $wsmanParams = @{ ComputerName = $ComputerName; ErrorAction = 'Stop' }
        if ($Credential) { $wsmanParams.Credential = $Credential; $wsmanParams.Authentication = 'Default' }
        if ($UseSSL)     { $wsmanParams.UseSSL = $true }
        Test-WSMan @wsmanParams | Out-Null
    }

    # Test-WSMan is the cheapest "is the listener up?" probe.
    try {
        & $probe
        $result.Reachable = $true
    } catch {
        # Listener down: optionally bring WinRM up via DCOM, then re-probe.
        if ($StartWinRM -and (Start-RemoteWinRM -ComputerName $ComputerName -Credential $Credential)) {
            try { & $probe; $result.Reachable = $true }
            catch { $result.Detail = "WinRM started but still unreachable: $_"; return $result }
        } else {
            $result.Detail = "WinRM unreachable: $_"
            return $result
        }
    }

    # Reachable: confirm the credential actually opens an elevated session.
    $session = $null
    try {
        $sessParams = @{ ComputerName = $ComputerName; ErrorAction = 'Stop' }
        if ($Credential) { $sessParams.Credential = $Credential }
        if ($UseSSL)     { $sessParams.UseSSL = $true }
        $session = New-PSSession @sessParams
        $result.Elevated = Invoke-Command -Session $session -ScriptBlock {
            $id = [Security.Principal.WindowsIdentity]::GetCurrent()
            ([Security.Principal.WindowsPrincipal]$id).IsInRole(
                [Security.Principal.WindowsBuiltInRole]::Administrator)
        }
        $result.Detail = if ($result.Elevated) {
            'OK: reachable and session is elevated.'
        } else {
            'Reachable but session is NOT elevated (connect as a local admin).'
        }
    } catch {
        $result.Detail = "Reachable but session failed: $_"
    } finally {
        if ($session) { Remove-PSSession $session }
    }

    return $result
}

function Invoke-RemoteCleanup {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string[]] $ComputerName,
        [pscredential] $Credential,
        [string[]] $Categories,
        [switch] $DoClean,
        [switch] $Force,
        [switch] $UseSSL,
        [switch] $CollectLogs,
        [switch] $TestConnection,
        [switch] $StartWinRM
    )

    # Preflight-only mode: report reachability/elevation and stop.
    if ($TestConnection) {
        $results = foreach ($computer in $ComputerName) {
            Write-Host ("Testing {0}..." -f $computer)
            Test-RemoteWinRM -ComputerName $computer -Credential $Credential `
                             -UseSSL:$UseSSL -StartWinRM:$StartWinRM
        }
        $results | Format-Table ComputerName, Reachable, Elevated, Detail -AutoSize |
            Out-Host
        $ready = @($results | Where-Object { $_.Reachable -and $_.Elevated })
        Write-Host ("{0} of {1} target(s) ready for cleanup." -f `
            $ready.Count, @($results).Count) -ForegroundColor Cyan
        return
    }

    if ($DoClean -and -not $Force) {
        throw ("Remote clean is non-interactive (there is no DELETE prompt over " +
               "a remote session). Re-run with -Force to authorize deletion.")
    }

    foreach ($computer in $ComputerName) {
        Write-Host ""
        Write-Host ("=== {0} ===" -f $computer) -ForegroundColor Cyan

        $sessionParams = @{ ComputerName = $computer; ErrorAction = 'Stop' }
        if ($Credential) { $sessionParams.Credential = $Credential }
        if ($UseSSL)     { $sessionParams.UseSSL = $true }

        # Make sure WinRM is up first (over DCOM) when asked.
        if ($StartWinRM) {
            Start-RemoteWinRM -ComputerName $computer -Credential $Credential | Out-Null
        }

        $session = $null
        $remotePath = $null
        try {
            try {
                $session = New-PSSession @sessionParams
            } catch {
                Write-Warning ("Cannot open a remoting session to {0}: {1}" -f $computer, $_)
                continue
            }

            # The remote session must already be elevated; it cannot self-elevate.
            $isAdmin = Invoke-Command -Session $session -ScriptBlock {
                $id = [Security.Principal.WindowsIdentity]::GetCurrent()
                ([Security.Principal.WindowsPrincipal]$id).IsInRole(
                    [Security.Principal.WindowsBuiltInRole]::Administrator)
            }
            if (-not $isAdmin) {
                Write-Warning ("Remote session on {0} is not elevated; skipping. " +
                    "Connect with a local administrator account." -f $computer)
                continue
            }

            # Stage this script in the remote temp dir under a unique name.
            $remotePath = Invoke-Command -Session $session -ScriptBlock {
                Join-Path $env:TEMP ("Clean-WindowsInstaller-{0}.ps1" -f `
                    ([guid]::NewGuid().ToString('N')))
            }
            Copy-Item -LiteralPath $PSCommandPath -Destination $remotePath `
                      -ToSession $session -Force

            $mode = if ($DoClean) { 'clean' } else { 'scan' }
            Write-Host ("Running {0} on {1} (categories: {2})..." -f `
                $mode, $computer, ($Categories -join ', '))

            # Run the staged script remotely, then hand back the newest log files
            # so the operator can archive proof of exactly what was touched.
            $remoteLogs = Invoke-Command -Session $session -ScriptBlock {
                param($scriptPath, $doClean, $force, $categories)
                $p = @{ Categories = $categories }
                if ($doClean) {
                    $p.Clean = $true
                    if ($force) { $p.Force = $true }
                } else {
                    $p.Scan = $true
                }
                & $scriptPath @p

                $logDir = Join-Path $env:ProgramData 'WindowsInstallerCleaner\logs'
                if (Test-Path $logDir) {
                    Get-ChildItem -Path $logDir -File |
                        Sort-Object LastWriteTime -Descending |
                        Select-Object -First 6 -ExpandProperty FullName
                }
            } -ArgumentList $remotePath, $DoClean.IsPresent, $Force.IsPresent, $Categories

            if ($CollectLogs -and $remoteLogs) {
                $dest = Join-Path $script:LogDir ("remote\{0}" -f $computer)
                if (-not (Test-Path $dest)) {
                    New-Item -ItemType Directory -Path $dest -Force | Out-Null
                }
                foreach ($log in @($remoteLogs | Where-Object {
                            $_ -is [string] -and $_ -match '\.(log|csv|reg)$' })) {
                    try {
                        Copy-Item -LiteralPath $log -Destination $dest `
                                  -FromSession $session -Force
                    } catch {
                        Write-Verbose ("Could not collect {0}: {1}" -f $log, $_)
                    }
                }
                Write-Host ("Collected remote logs to {0}" -f $dest)
            }
        } catch {
            Write-Warning ("Cleanup on {0} failed: {1}" -f $computer, $_)
        } finally {
            if ($session) {
                if ($remotePath) {
                    try {
                        Invoke-Command -Session $session -ScriptBlock {
                            param($p)
                            if ($p -and (Test-Path -LiteralPath $p)) {
                                Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue
                            }
                        } -ArgumentList $remotePath
                    } catch { }
                }
                Remove-PSSession $session
            }
        }
    }
}

# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

# Skip elevation/IO setup when the script is dot-sourced for testing.
if ($MyInvocation.InvocationName -eq '.' -or $MyInvocation.Line -match '^\s*\. ') {
    return
}

# Remote orchestration runs locally without elevation and targets other
# machines, so it skips the local self-elevate / environment assertions. It
# still keeps a local transcript of the orchestration run.
if ($PSCmdlet.ParameterSetName -eq 'Remote') {
    Start-RunLog
    try {
        Invoke-RemoteCleanup -ComputerName $ComputerName -Credential $Credential `
            -Categories $Categories -DoClean:$RemoteClean -Force:$Force `
            -UseSSL:$UseSSL -CollectLogs:$CollectLogs -TestConnection:$TestConnection `
            -StartWinRM:$StartWinRM
    } finally {
        Stop-RunLog
    }
    return
}

Invoke-SelfElevate -OriginalArgs $MyInvocation.UnboundArguments
Assert-Environment
Start-RunLog

try {
    switch ($PSCmdlet.ParameterSetName) {
        'Gui' {
            Show-Gui
        }
        'Clean' {
            $installer = New-MsiInstaller
            $items = @(Get-CleanupCandidate -Category $Categories -Installer $installer)
            if ($ReportPath) {
                $items | Export-Csv -Path $ReportPath -NoTypeInformation
                Write-Host "Wrote report to $ReportPath"
            }
            Invoke-Removal -Items $items -SkipConfirmation:$Force
        }
        default {
            # Scan
            $installer = New-MsiInstaller
            $items = @(Get-CleanupCandidate -Category $Categories -Installer $installer)
            Write-CandidateSummary -Items $items
            if ($ReportPath) {
                $items | Export-Csv -Path $ReportPath -NoTypeInformation
                Write-Host "Wrote report to $ReportPath"
            }
        }
    }
} finally {
    Stop-RunLog
}
Running this PowerShell script

By default, Windows blocks unsigned PowerShell scripts from running. Pick one of the options below to allow it.

Option 1 — Run once (no permanent change)

Open PowerShell in the folder containing the script and run:

powershell.exe -ExecutionPolicy Bypass -File .\Clean-WindowsInstaller.ps1

Option 2 — Allow scripts for your user (persistent)

Open PowerShell as Administrator and run:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

RemoteSigned lets local scripts run while still requiring signatures on scripts downloaded from the internet.

Option 3 — Fully unrestricted (not recommended)

If you want zero restrictions, run PowerShell as Administrator and:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Unrestricted

If Windows blocks the file after download

Downloaded files get a "Mark of the Web" flag. Unblock with:

Unblock-File .\Clean-WindowsInstaller.ps1
Copied to clipboard