Show and Optionally Remove Installed Programs

Show a list of all installed programs for the system and for any logged on users. Search pattern can be used to narrow the list of results (wildcards allowed).
Optionally run the uninstaller for the one package that matches the given string. If more than one package matches the string passed as a parameter, the script will abort because it only allows removal of one application per invocation. (this can be changed in the script)

If the uninstall program is not msiexec there is no guarantee that the unninstall process will run silently and the script may hang because the uninstall process, which cannot show a user interface because it is running in sessionn zero, is trying to show a user interface. If the uninstall process, or any child processes it launches, have not exited by the time that the timeout time, passed as a parameter (non-whole numbers are allowed, e.g. 1.5), is exceeded, the uninstall process and any child processes will be forcibly terminated.
Version 1.1.26
Created on 2021-07-26
Modified on 2022-02-08
Created by guy.leech01
Downloads: 100

The Script Copy Script Copied to clipboard
#requires -version 3
<#
.SYNOPSIS
Retrieve information on installed programs

.DESCRIPTION
Does not use WMI/CIM since Win32_Product only retrieves MSI installed software and can be slow. Instead it processes the "Uninstall" registry key(s) which is also usually much faster
From https://github.com/guyrleech/Microsoft/blob/master/Get%20installed%20software.ps1
Based on code from https://blogs.technet.microsoft.com/heyscriptingguy/2011/11/13/use-powershell-to-quickly-find-installed-software/

.PARAMETER computers
Comma separated list of computer names to query. Use . to represent the local computer

.PARAMETER exportcsv
The name and optional path to a non-existent csv file which will have the results written to it

.PARAMETER productname
Only show products where the display name matches this regular expression 

.PARAMETER vendor
Only show products where the publisher matches this regular expression 

.PARAMETER gridview
The output will be presented in an on screen filterable/sortable grid view. Lines selected when OK is clicked will be placed in the clipboard

.PARAMETER removePattern
Comma separated list of one or more package names, or patterns that match one or more package names, that will be uninstalled.

.PARAMETER silent
Try and run the uninstall silently. This only works where the uninstall program is msiexec.exe

.PARAMETER asjson
Output objects as json

.PARAMETER ascsv
Output objects as csv

.PARAMETER importcsv
A csv file containing a list of computers to process where the computer name is in the "ComputerName" column unless specified via the -computerColumnName parameter

.PARAMETER computerColumnName
The column name in the csv specified via -importcsv which contains the name of the computer to query

.PARAMETER includeEmptyDisplayNames
Includes registry keys which have no "DisplayName" value which may not be valid installed packages

.EXAMPLE
& '.\Get installed software.ps1' -gridview -computers .
Retrieve installed software details on the local computer and show in a grid view

.EXAMPLE
& '.\Get installed software.ps1' -gridview -computers . -ascsv -quiet -productname 'Teams' -vendor 'Microsoft'
Retrieve installed software details on the local computer where the display name contains "Teams" and the vendor contains "Microsoft".
Output the results to the pipeline in csv format , whilst suppressing warnings.

.EXAMPLE
& '.\Get installed software.ps1' -computers computer1,computer2,computer3 -exportcsv installed.software.csv
Retrieve installed software details on the computers computer1, computer2 and computer3 and write to the CSV file "installed.software.csv" in the current directory

.EXAMPLE
& '.\Get installed software.ps1' -gridview -importcsv computers.csv -computerColumnName Machine
Retrieve installed software details on computers in the CSV file computers.csv in the current directory where the column name "Machine" contains the name of the computer and write the results to standard output

.EXAMPLE
& '.\Get installed software.ps1' -gridview -computers . -uninstall
Retrieve installed software details on the local computer and show in a grid view. Packages selected after OK is clicked in the grid view will be uninstalled.

.EXAMPLE
& '.\Get installed software.ps1' -computers . -remove 'Notepad++*' -Confirm:$false
Retrieve installed software details on the local computer and and remove Notepad++ without asking for confirmation.

.EXAMPLE
& '.\Get installed software.ps1' -computers . -remove '*Acrobat Reader*' -Confirm:$false -silent
Retrieve installed software details on the local computer and and remove Adobe Acrobat Reader silently, so without any user prompts, and without asking for confirmation.

.NOTES
THE SCRIPT IS PROVIDED IN AN "AS IS" CONDITION, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL CONTROLUP, ANY AUTHORS OR ANY COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SCRIPT OR THE USE OR OTHER DEALINGS IN THE SCRIPT.

Modification History:
16/11/18 GRL  Added functionality to uninstall items selected in grid view
18/11/18 GR  Added -remove to remove one or more packages without needing grid view display
     Added -silent to run silent uninstalls where installer is msiexec
     Added -quiet option
02/03/19 GRL  Added SystemComponent value
23/07/19 GRL  Added HKU searching
14/10/20 GRL  Added default parameter set name and hiding error if can't open reg key
     Added InstallSource
22/05/21 GRL  Added json and csv direct output. Added -delimiter for csv outputs for ze Dutch
24/05/21 GRL  Added parameters to filter on product name and/or vendor
07/02/22 GRL  Changed removeRegex to removePattern. Added maximumRemovals and timeoutMinutes. Added checks for child processes of uninstall process to complete
#>

[CmdletBinding()]
Param
(
    [ValidateSet('yes','no')]
    [string]$noRemoval = 'yes' ,
 [decimal]$timeoutMinutes = 5 ,
    [string]$removePattern ,
    [string]$productname ,
    [string]$vendor ,
    [switch]$silent = $true,
    [switch]$quiet ,
    [switch]$asjson ,
    [switch]$ascsv ,
    [string]$delimiter = ',' ,
    [switch]$includeEmptyDisplayNames
)

$VerbosePreference = $(if( $PSBoundParameters[ 'verbose' ] ) { $VerbosePreference } else { 'SilentlyContinue' })
$DebugPreference = $(if( $PSBoundParameters[ 'debug' ] ) { $DebugPreference } else { 'SilentlyContinue' })
$ErrorActionPreference = $(if( $PSBoundParameters[ 'erroraction' ] ) { $ErrorActionPreference } else { 'Stop' })
$ProgressPreference = 'SilentlyContinue'

## technically the script can remove any number of packages but to reduce the risk of inadvertent removals, it is limited to 1 - CHANGE AT YOUR OWN RISK
[int]$maximumRemovals = 1

[int]$outputWidth = 400
if( ( $PSWindow = (Get-Host).UI.RawUI ) -and ( $WideDimensions = $PSWindow.BufferSize ) )
{
    $WideDimensions.Width = $outputWidth
    $PSWindow.BufferSize = $WideDimensions
}

Function Stop-ChildProcesses
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true)]
        $ParentProcess ,
        [switch]$kill ## could just be counting to see if there are child processes we need to wait for as some uninstallers spawn a new process but don't wait for it to exit
    )
    
    Get-CimInstance -ClassName win32_process -ErrorAction SilentlyContinue -Filter "ParentProcessId = '$($ParentProcess.Id)'" -Verbose:$false | ForEach-Object `
    {
        if( $thisProcess = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue -IncludeUserName )
        {
            if( $thisProcess.StartTime -gt $parentProcess.StartTime -and $parentProcess.SessionId -eq $thisProcess.SessionId -and $thisProcess.UserName -eq $parentProcess.UserName )
            {
                Stop-ChildProcesses -ParentProcess $thisProcess -kill:$kill
                Write-Verbose -Message "$(if( -Not $kill ) { 'Not ' })Killing $($thisProcess.Name) ($($thisProcess.Id)), parent $($parentProcess.Name) ($($parentProcess.Id))"
                if( $kill )
                {
                    Stop-Process -InputObject $thisProcess -Force -ErrorAction SilentlyContinue
                }
                Add-Member -InputObject $thisProcess -MemberType NoteProperty -Name ParentProcess -Value $parentProcess -PassThru ## return
            }
            else
            {
                Write-Verbose -Message "Ignoring child process $($thisProcess.Name) ($($thisProcess.Id)), parent $($parentProcess.Name) ($($parentProcess.Id)), parent sid $($parentProcess.SessionId) this sid $($thisProcess.SessionId) username $($thisProcess.UserName) vs $($parentProcess.UserName)"
            }
        }
    }
}

Function Remove-Package()
{
    [CmdletBinding(SupportsShouldProcess=$True,ConfirmImpact='High')]
    Param
    (
        $package ,
        [switch]$silent ,
        [int]$timeoutSeconds
    )

    [bool]$uninstallerRan = $false

    Write-Verbose "Removing `"$($package.DisplayName)`""

    if( ! [string]::IsNullOrEmpty( $package.Uninstall ) )
    {
        ## need to split uninstall line so we can pass to Start-Process since we need to wait for each to finish in turn
        [string]$executable = $null
        [string]$arguments = $null
        if( $package.Uninstall -match '^"([^"]*)"\s?(.*)$' `
            -or $package.Uninstall -match '^(.*\.exe)\s?(.*)$' ) ## cope with spaces in path but no quotes
        {
            $executable = $Matches[1]
            $arguments = $Matches[2].Trim()
        }
        else ## unquoted so see if there's a space delimiting exe and arguments
        {
            [int]$space = $package.Uninstall.IndexOf( ' ' )
            if( $space -lt 0 )
            {
                $executable = $package.Uninstall
            }
            else
            {
                $executable = $package.Uninstall.SubString( 0 , $space )
                if( $space -lt $package.Uninstall.Length )
                {
                    $arguments = $package.Uninstall.SubString( $space ).Trim()
                }
            }
        }
        [hashtable]$processArguments = @{
            'FilePath' = $executable
            'PassThru' = $true
            'Wait' = $false
        }
        if( $silent )
        {
            if( $executable -match '^msiexec\.exe$' -or $executable -match '^msiexec$' -or $executable -match '[^a-z0-9_]msiexec\.exe$' -or $executable -match '[^a-z0-9_]msiexec$' )
            {
                ## Some uninstallers pass /I as they are meant to be interactive so we'll change this to /X
                $arguments = ($arguments -replace '/I' , '/X') + ' /qn /norestart'
            }
            else
            {
                ## NSIS installers use /S (case sensitive)
                ## if unins000.exe then try /VerySilent /NoRestart
                Write-Warning "Don't know how to run silent uninstall for package `"$($package.DisplayName)`", uninstaller `"$executable`""
            }
        }
        if( ! [string]::IsNullOrEmpty( $arguments ) )
        {
            $processArguments.Add( 'ArgumentList' , $arguments )
        }
        Write-Verbose "Running $executable `"$arguments`" for $($package.DisplayName) ..."
        [System.Diagnostics.Trace]::WriteLine( "Running $executable `"$arguments`" for $($package.DisplayName) ..." )
 
        $uninstallProcess = $null
        $uninstallProcess = Start-Process @processArguments

        if( $uninstallProcess ) 
        {
            [datetime]$timeoutTime = $uninstallProcess.StartTime.AddSeconds( $timeoutSeconds )

            Write-Verbose -Message "$(Get-Date -Format G): Uninstaller pid $($uninstallProcess.Id) , timeout at $(Get-Date -Date $timeoutTime -Format G)"

            $waitError = $null
            [hashtable]$waitParameters = @{
                InputObject = $uninstallProcess
                ErrorAction = 'SilentlyContinue'
                ErrorVariable = 'waitError'
            }
            if( $timeoutSeconds -gt 0 )
            {
                $waitParameters.Add( 'Timeout' , $timeoutSeconds )
            }

            ## when we get child processes, we need the username of the installer process as will not be $username if running as system (will be computername$)
            Add-Member -InputObject $uninstallProcess -MemberType NoteProperty -Name Username -Value (Get-Process -InputObject $uninstallProcess -IncludeUserName | Select-Object -ExpandProperty Username) -Force

            Wait-Process @waitParameters

            [bool]$waitResult = $?
            [int]$childProcesses = 0
            [array]$childProcesses = @( )
            [bool]$timedOut = $false

            if( $waitResult )
            {
                ## need to see if any child processes still running as the main uninstaller may have started another process which hasn't exited (e.g. notepad++ uninstaller)    
                do
                {
                    $childProcesses = @( Stop-ChildProcesses -ParentProcess $uninstallProcess -kill:$false | Sort-Object -Property StartTime )
                    if( $childProcesses -and $childProcesses.Count -gt 0 )
                    {
                        try
                        {
                            Wait-Process -Id $childProcesses[0].Id -Timeout ($timeoutTime - [datetime]::Now).TotalSeconds -ErrorAction SilentlyContinue
                        }
                        catch
                        {
                            ## so we don't have to verify that the timeout is positive
                        }
                    }
                } while( [datetime]::now -le $timeoutTime -and $childProcesses -and $childProcesses.Count -gt 0 )
                
                if( $timedOut = $childProcesses -and $childProcesses.Count -gt 0 )
                {
                    $waitResult = $false
                }
            }

            if( $waitResult )
            {
                Write-Verbose "Uninstall exited with code $($uninstallProcess.ExitCode)"
                ## https://docs.microsoft.com/en-us/windows/desktop/Msi/error-codes
                if( $uninstallProcess.ExitCode -eq 3010 ) ## maybe should check it's msiexec that ran
                {
                    Write-Warning "Uninstall of `"$($package.DisplayName)`" requires a reboot"
                }
                $uninstallerRan = $true
            }
            elseif( $timedOut -or ( $waitError -and $waitError.Count -and $waitError[0].Exception -and $waitError[0].Exception.HResult -eq 0x80131505 ) ) ## language neutral
            {
                Write-Warning -Message "Timeout waiting for uninstall process $($package.Uninstall) (pid $($uninstallProcess.Id)) to finish"
                ## kill it and all child processes
                [array]$killed = @( Stop-ChildProcesses -ParentProcess $uninstallProcess -kill:$true )
                Stop-Process -InputObject $uninstallProcess -Force
                Write-Host -Object "Killed uninstall process and $($killed.Count) child processes"
            }
            else
            {
                Write-Warning -Message "Unexpected issue waiting for uninstall process $($package.Uninstall) (pid $($uninstallProcess.Id)) to finish - $waitError"
            }
        }
    }
    else
    {
        Write-Warning "Unable to uninstall `"$($package.DisplayName)`" as it has no uninstall string"
    }
    $uninstallerRan
}

Function Process-RegistryKey
{
    [CmdletBinding()]
    Param
    (
        [string]$hive ,
        $reg ,
        [string[]]$UninstallKeys ,
        [switch]$includeEmptyDisplayNames ,
        [AllowNull()]
        [string]$username ,
        [AllowNull()]
        [string]$productname ,
        [AllowNull()]
        [string]$vendor
    )

    ForEach( $UninstallKey in $UninstallKeys )
    {
        $regkey = $reg.OpenSubKey($UninstallKey) 
    
        if( $regkey )
        {
            [string]$architecture = if( $UninstallKey -match '\\wow6432node\\' ){ '32 bit' } else { 'Native' } 

            $subkeys = $regkey.GetSubKeyNames() 
    
            foreach($key in $subkeys)
            {
                $thisKey = Join-Path -Path $UninstallKey -ChildPath $key 

                $thisSubKey = $reg.OpenSubKey($thisKey) 

                if( $includeEmptyDisplayNames -or ! [string]::IsNullOrEmpty( $thisSubKey.GetValue('DisplayName') ) )
                {
                    [string]$installDate = $thisSubKey.GetValue('InstallDate')
                    $installedOn = New-Object -TypeName 'DateTime'
                    if( [string]::IsNullOrEmpty( $installDate ) -or ! [datetime]::TryParseExact( $installDate , 'yyyyMMdd' , [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$installedOn ) )
                    {
                        $installedOn = $null
                    }
                    $size = New-Object -TypeName 'Int'
                    if( ! [int]::TryParse( $thisSubKey.GetValue('EstimatedSize') , [ref]$size ) )
                    {
                        $size = $null
                    }
                    else
                    {
                        $size = [math]::Round( $size / 1KB , 1 ) ## already in KB
                    }

                    if( $thisSubKey.GetValue('DisplayName') -match $productname -and $thisSubKey.GetValue('Publisher') -match $vendor )
                    {
                        [pscustomobject][ordered]@{
                            ##'ComputerName' = $computername
                            'Hive' = $Hive
                            'User' = $username
                            'Key' = $key
                            'Architecture' = $architecture
                            'DisplayName' = $($thisSubKey.GetValue('DisplayName'))
                            'DisplayVersion' = $($thisSubKey.GetValue('DisplayVersion'))
                            'InstallLocation' = $($thisSubKey.GetValue('InstallLocation'))
                            'InstallSource' = $($thisSubKey.GetValue('InstallSource'))
                            'Publisher' = $($thisSubKey.GetValue('Publisher'))
                            'InstallDate' = $(if( $installedOn ) { Get-Date -Date $installedOn -Format d }) ## never includes time so no point outputting 00:00
                            'Size (MB)' = $size
                            'System Component' = $($thisSubKey.GetValue('SystemComponent') -eq 1)
                            'Comments' = $($thisSubKey.GetValue('Comments'))
                            'Contact' = $($thisSubKey.GetValue('Contact'))
                            'HelpLink' = $($thisSubKey.GetValue('HelpLink'))
                            'HelpTelephone' = $($thisSubKey.GetValue('HelpTelephone'))
                            'Uninstall' = $($thisSubKey.GetValue('UninstallString'))
                        }
                    }
                }
                else
                {
                    ## Write-Warning "Ignoring `"$hive\$thisKey`" on $computername as has no DisplayName entry"
                }

                $thisSubKey.Close()
            } 
            $regKey.Close()
        }
        elseif( $hive -eq 'HKLM' )
        {
            Write-Warning "Failed to open `"$hive\$UninstallKey`" on $computername"
        }
    }
}

if( $quiet )
{
    $VerbosePreference = $WarningPreference = 'SilentlyContinue'
}

[string[]]$UninstallKeys = @( 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' , 'SOFTWARE\\wow6432node\\Microsoft\\Windows\\CurrentVersion\\Uninstall' )

if( $PSBoundParameters[ 'removePattern' ] -and -Not [string]::IsNullOrEmpty( $removePattern ) )
{
    ## check if string is all * characters
    if( $removePattern -match '^\**$' )
    {
        Throw "Search pattern $removePattern is too broad and not allowed by this script. If you would like to list all programs leave the Search Pattern empty."
    }
}

[array]$installed = @(
    [string]$computername = $env:COMPUTERNAME

    $reg = [microsoft.win32.registrykey]::OpenRemoteBaseKey( 'LocalMachine' , $computername )
    
    if( $? -and $reg )
    {
        Process-RegistryKey -Hive 'HKLM' -reg $reg -UninstallKeys $UninstallKeys -includeEmptyDisplayNames:$includeEmptyDisplayNames -productname $productname -vendor $vendor
        $reg.Close()
    }
    else
    {
        Throw "Failed to open HKLM on $computername"
    }

    $reg = [microsoft.win32.registrykey]::OpenRemoteBaseKey( 'Users' , $computername )
    
    if( $? -and $reg )
    {
        ## get each user SID key and process that for per-user installed apps
        ForEach( $subkey in $reg.GetSubKeyNames() )
        {
            try
            {
                if( $userReg = $reg.OpenSubKey( $subKey ) )
                {
                    [string]$username = $null
                    try
                    {
                        $username = ([System.Security.Principal.SecurityIdentifier]($subKey)).Translate([System.Security.Principal.NTAccount]).Value
                    }
                    catch
                    {
                        $username = $null
                    }
                    Process-RegistryKey -Hive (Join-Path -Path 'HKU' -ChildPath $subkey) -reg $userReg -UninstallKeys $UninstallKeys -includeEmptyDisplayNames:$includeEmptyDisplayNames -user $username  -productname $productname -vendor $vendor
                    $userReg.Close()
                }
            }
            catch
            {
            }
        }
        $reg.Close()
    }
    else
    {
        Write-Warning "Failed to open HKU on $computername"
    }
) | Sort -Property ComputerName, DisplayName

[int]$uninstalled = 0

if( $installed -and $installed.Count )
{
    Write-Verbose "Found $($installed.Count)"
    
    [System.Collections.Generic.List[string]]$excluded = @( 'Hive' , 'Key' , 'Help*', 'Uninstall' , 'Contact' , 'InstallSource' )
    
    if( ($installed | Where-Object { ! [string]::IsNullOrEmpty( $_.User ) } | Measure-Object ).Count -eq 0 )
    {
        $excluded.Add( 'User' )
    }

    if( $PSBoundParameters[ 'removePattern' ] -and ! [string]::IsNullOrEmpty( $removePattern ) )
    {
        [int]$matched = 0
        $removedPackages = New-Object -TypeName System.Collections.Generic.List[object] -ArgumentList @()
        [array]$toRemove = @( $installed | Where-Object { $_.DisplayName -like $removePattern } )
        if( $toRemove.Count -gt $maximumRemovals )
        {
            Throw "$($toRemove.Count) packages matched `"$removePattern`" which exceeds script maximum removal count of $maximumRemovals"
        }

        $removed = @( ForEach( $package in $toRemove )
        {
            $removedPackages.Add( $package )
            if( $noRemoval -and $noRemoval.Length -and $noRemoval[0] -ine 'y' )
            {
                if( Remove-Package -Package $package -silent:$silent -timeoutSeconds ( $timeoutMinutes * 60 ) )
                {
                    $uninstalled++
                    $package
                }
            }
        })
        
        [string]$before = $null
        [string]$after = $null
        if( $noRemoval -and $noRemoval.Length -and $noRemoval[0] -ine 'y' )
        {
            $before = "Ran"
            $after = ", $uninstalled uninstallers ran ok"
        }
        else
        {
            $before = "Would have run"
        }
        
        if( ! $removedPackages -or ! $removedPackages.Count )
        {
            Write-Warning "No uninstallers $($before.ToLower()) as none of the $($installed.Count) packages found matched $removePattern"
        }
        else
        {
            Write-Output "`n$before uninstaller for $($removedPackages.Count) matches$after :"
        
            $removedPackages | Select-Object -Property * -ExcludeProperty $excluded | Format-Table -AutoSize
        }
    }
    else
    {
        $installed | Select-Object -Property * -ExcludeProperty $excluded | Format-Table -AutoSize
    }
}
else
{
    Write-Warning "Found no installed products in the registry"
}