Dark mode

Dark mode

There are 0 results matching

article card image dark article card image light

Published by · May 5, 2025 tools · 6 mins read

Introducing: Windows Bulk Uninstall Tool

Bulk Uninstalling Applications with PowerShell with PowerShell and Configuration Manager or Intune ...

See More
article card image dark article card image light

Published by · Jun 25, 2024 tools · 2 mins read

Introducing: macOS JAMF Offboarding Tool

Offboarding macOS Devices from JAMF in Bulk using the JAMF API with a bash script ...

See More
article card image dark article card image light

Published by · Jun 25, 2024 tools · 2 mins read

Introducing: Microsoft Cloud License Automation Tool - Part 1

Automating Microsoft Cloud License Assignment and Reporting with PowerShell and Slack for Enterprise Mobility and Security E3 ...

See More
article card image dark article card image light

Published by · Jun 18, 2024 tools · 2 mins read

Introducing: Configuration Manager Set Implicit Uninstall Flag Tool

Setting Configuration Manager ConfigMgr Implicit Uninstall Flag with PowerShell for Required Application Deployments ...

See More
article card image dark article card image light

Published by · Jun 11, 2024 configmgr · 2 mins read

Configuration Manager Next Maintenance Window SQL Function

Get Next Configuration Manager Maintenance Window from a Schedule Token with Offset Days using an SQL Function. ...

See More
article card image dark article card image light

Published by · Jun 3, 2024 tools · 2 mins read

Introducing: Windows User Rights Assignment Tool - Part 3

Add, Remove, or Replace Windows Rights Assignment with our PowerShell Tool. ...

See More
article card image dark article card image light

Published by · May 28, 2024 tools · 2 mins read

Introducing: Windows User Rights Assignment Tool - Part 2

Get and Report Windows Rights Assignment with our PowerShell Tool. ...

See More
article card image dark article card image light

Published by · May 22, 2024 tools · 2 mins read

Introducing: Windows User Rights Assignment Tool - Part 1

Get Windows Rights Assignment with our PowerShell Tool. ...

See More
article card image dark article card image light

Published by · Apr 11, 2024 tools · 2 mins read

Introducing: Intune Linux Onboarding Tool

Onboard Ubuntu Linux devices to Microsoft Intune using a bash script. Installs prerequisites and starts the user-driven enrollment. ...

See More
article card image dark article card image light

Published by · Apr 11, 2024 tools · 2 mins read

Introducing: Intune macOS Onboarding Tool

Onboard macOS devices to Microsoft Intune using a bash script that initiates the process. Optionally, the script converts mobile accounts, resets the FileVault key, and removes ...

See More
article card image dark article card image light

Published by · Jan 23, 2024 tools · 3 mins read

Introducing: Intune Device Renaming Tool

Rename Intune Devices by setting a Prefix or using a User Attribute as Prefix. Supports Windows, macOS, and Linux ...

See More
article card image dark article card image light

Published by · Dec 8, 2023 intune · 5 mins read

Intune Logs: A Deep Dive into Locations, Interpretation, and Configuration

A Comprehensive Guide to Locations, Interpretation, and Configuration of Intune Logs ...

See More
article card image dark article card image light

Published by · Aug 14, 2023 configmgr · 2 mins read

Configuration Manager Console Extension to show Device Collection Membership with Console Builder

Use the Configuration Manager Console Builder, to add Collection Membership View to the Device Node ...

See More
article card image dark article card image light

Published by · Aug 3, 2023 tools · 3 mins read

Introducing: Configuration Manager SSRS Dashboards

A Configuration Manager Dashboards solution with Reports for Software Updates, Bitlocker and more ...

See More
article card image dark article card image light

Published by · Aug 3, 2023 tools · 2 mins read

Introducing: PowerShell WMI Management Toolkit Module

Streamline your WMI Namespace, Class, and Instance Management with our PowerShell Module ...

See More
article card image dark article card image light

Published by · Jul 14, 2023 configmgr · 1 mins read

Configuration Manager detailed, filterable Port Documentation

Configuration Manager detailed, filterable port documentation as an excel document ...

See More
article card image dark article card image light

Published by · Jul 14, 2023 configmgr · 3 mins read

Configuration Manager PXE TFTP Window Size Bug

Configuration Manager TFTP Block Size and TFTP Window Size Correct Configuration ...

See More
article card image dark article card image light

Published by · Jun 18, 2023 tools · 4 mins read

Introducing: Configuration Manager Client Cache Cleanup Tool

Cleaning the Configuration Manager Client Cache the Right Way with PowerShell and Configuration Baselines ...

See More
article card image dark article card image light

Published by · Jun 18, 2023 tools · 2 mins read

Introducing: Windows Cache Cleanup Tool

Cleaning Windows and Configuration Manager Caches for Configuration Manager Build and Capture Task Sequence or Standalone Use ...

See More
article card image dark article card image light

Published by · Jun 17, 2023 tools · 1 mins read

Introducing: Windows Update Database Reinitialization Tool

Proactively repair corrupted Windows Update Database with Powershell and Configuration Manager ...

See More
article card image dark article card image light

Published by · Mar 31, 2023 tools · 4 mins read

Introducing: Configuration Manager SQL Products Reporting

A Complete SQL Products reporting solution using Configuration Manager ...

See More
article card image dark article card image light

Published by · Jan 28, 2023 configmgr · 1 mins read

Application Detection Method using the Configuration Manager Application Version

Replace hardcoded application version in scripts, with the Configuration Manager Application Version ...

See More
article card image dark article card image light

Published by · Jan 28, 2023 tools · 3 mins read

Introducing: Certificate Management Toolkit

Managing Certificates with Configuration Manager and PowerShell by using just the Public Key ...

See More
article card image dark article card image light

Published by · Jan 7, 2019 reports · 2 mins read

Configuration Manager Device Boundary and Network Information Report

List Device Boundaries and Network Information with Configuration Manager ...

See More
article card image dark article card image light

Published by · Sep 9, 1980 help · 5 mins read

MEM.Zone Blog Publishing Documentation

Publishing Documentation for MEM.Zone ...

See More

We couldn’t find anything related to

“SCCM”

BLOG / tools zone

Introducing: Windows Bulk Uninstall Tool

Published by Popovici Ioan · May 5, 2025 · 6 mins read
article card image dark article card image light

Quick Summary

Managing Windows bulk application uninstalls just got easier.

The Windows Bulk Uninstall Tool enables you to automatically detect and remove unwanted software, using Configuration Manager, Intune or PowerShell.

Features

  • Pattern-based detection of installed applications.
  • Automatic silent uninstall switch detection.
  • Supports both MSI and EXE uninstallers.
  • Prevents concurrent uninstalls using Mutex locking.
  • Waits for Windows Installer to finish before proceeding.
  • Supports non-interactive uninstalls with timeout handling.
  • Optimized for compliance remediation and automation pipelines.
  • Has comprehensive logging capabilities.

Prerequisites

Notes

Administrative rights may be required to uninstall certain applications.


Recommendations

  • Always use a test environment to validate your configuration!
  • History and uninstall logs are available under the %ProgramData%\Logs folder.
Notes

Using wildcards may lead to unexpected results. Always test your patterns before using them in production.


Uninstall Script Explained

Initialization Phase

Setting up runtime environment.

  • Initializes configuration and logging components.
  • Verifies administrative privileges.
  • Acquires a Script Mutex to prevent multiple script instances.
  • Logs essential metadata such as script version, and timestamp.

Application Discovery Phase

Scans the system for installed applications

  • Queries both HKLM and HKCU registry hives for installed applications.
  • Applies configurable name-based filters for Application Matching.

Uninstallation Process Phase

  • Detects whether the uninstaller type is MSI or EXE.
  • Acquires a Uninstall Mutex to prevent concurrent uninstalls.

MSI-Based Uninstall

  • Extracts the ProductCode from the registry.
  • Starts the uninstall using msiexec.exe.
  • Waits for the process to complete.

EXE-Based Uninstall

  • Checks for QuietUninstallString in the registry.
  • If none exists it tries to infer silent arguments::
    • Matches executable metadata with UninstallerMetadataDetectionRules.
    • Matches executable name with UninstallerFileNameDetectionRules.
    • If matching fails, defaults to /S silent parameter.
  • Executes the uninstaller with the inferred silent parameters.
  • Waits for the uninstallation to complete.

Post-Uninstall Phase

  • Kills the browser if it was launched during the uninstallation.
  • Kills post processes starting with post.
  • Kills processes matching PostProcessDetectionRules.

Summary Phase

  • Generates a summary report of the uninstallation process.
  • Logs detailed statistics about successful and failed uninstallation.
  • Flushes log buffers, and exits with an appropriate exit code based on the overall success of the operation.

Parameters

SearchPattern

Specifies application name patterns to search for.

  • Is a string or array of strings.
  • Is case-insensitive.
  • Is matched as a RegEx pattern.
  • Supports wildcards.
## Example - Parameter
@('Dell SupportAssist*', '*minitool*', '*treesize*', '*krita*', '*ccleaner*', '*recuva*', '*download manager*')
Notes

You can also use the inline ApplicationDetectionRules variable to specify a list of application detection rules.

Notes

Using wildcards may lead to unexpected results. Always test your RegEx _patterns before using them in production.
Using * alone in the SearchPattern parameter will match all applications, which may not be desirable.

Verbose

Specifies whether to write verbose messages to the console.

  • Is a switch parameter.
  • Defaults to False.

Debug

Specifies whether to write debug messages to the console.

  • Is a switch parameter.
  • Defaults to False.

Inline Variables

ApplicationDetectionRules

Specifies application name patterns to search for and can be used as an alternative to the SearchPattern parameter.

  • Is a string or array of strings.
  • Is case-insensitive.
  • Is matched as a RegEx pattern.
  • Supports wildcards.
## Example - Inline Variable
$Script:ApplicationDetectionRules = [string[]]@(
    'Dell SupportAssist*'
    '*minitool*'
    '*treesize*'
    '*krita*'
    '*ccleaner*'
    '*recuva*'
    '*download manager*'

    #  Add more applications as needed
)
Notes

Using wildcards may lead to unexpected results. Always test your RegEx _patterns before using them in production.
Using * alone in the SearchPattern parameter will match all applications, which may not be desirable.

UninstallerMetadataDetectionRules

Specifies metadata detection rules for EXE uninstallers and is used to identify the uninstaller type based on its metadata.

  • Pattern
    Specify the RegEx pattern to match the the uninstaller type.
    Run with Debug to see the metadata or check the uninstaller directly.
  • SilentArgs
    Specifies the silent uninstall arguments for the uninstaller type.
  • Type
    Specify the uninstaller type.
## Example - UninstallerMetadataDetectionRules
$Script:UninstallerMetadataDetectionRules = [array]@(
    @{ Pattern = '*NSIs*'                    ; SilentArgs = '/S'                                       ; Type = 'NSIs'              },
    @{ Pattern = '*Inno*'                    ; SilentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART' ; Type = 'Inno Setup'        },
    @{ Pattern = '*InstallShield*'           ; SilentArgs = '/s'                                       ; Type = 'InstallShield'     },
    @{ Pattern = '*Squirrel*'                ; SilentArgs = '--uninstall'                              ; Type = 'Squirrel'          },
    @{ Pattern = '*Google Chrome Installer*' ; SilentArgs = '--force-uninstall'                        ; Type = 'Google Chrome'     },
    @{ Pattern = '*Adobe*'                   ; SilentArgs = '/sAll /rs /rps'                           ; Type = 'Adobe Installer'   }
Notes

These examples are already included in the script. You can add more as needed.
If you find an exception please add a pull request or open an issue on GitHub.

UninstallerFileNameDetectionRules

Specifies file name detection rules for EXE uninstallers and is used to identify the uninstaller type based on its file name.

  • Pattern
    Specify the RegEx pattern to match the the uninstaller type.
    Check the file name directly.
  • SilentArgs
    Specifies the silent uninstall arguments for the uninstaller type.
  • Type
    Specify the uninstaller type
## Example - UninstallerFileNameDetectionRules
$Script:UninstallerFileNameDetectionRules = [array]@(
    @{ Pattern = 'uninst.exe$'               ; SilentArgs = '/S'                                       ; Type = 'NSIs'              },
    @{ Pattern = 'unins\d{3}\.exe$'          ; SilentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART' ; Type = 'Inno Setup'        },
    @{ Pattern = 'setup\.exe$'               ; SilentArgs = '/s'                                       ; Type = 'InstallShield'     },
    @{ Pattern = 'install\.exe$'             ; SilentArgs = '/quiet'                                   ; Type = 'Generic Installer' },
    @{ Pattern = 'update\.exe$'              ; SilentArgs = '--uninstall'                              ; Type = 'Squirrel'          }
)
Notes

The example is included in the script and you can add more rules as needed.
If you find an exception, please make a pull request or open an issue on GitHub.

PostProcessDetectionRules

Specifies post-uninstall process detection rules. This is used to kill processes that may have been started after the uninstallation has finished.

  • PostProcessDetectionRules is a string or array of strings.
  • PostProcessDetectionRules is case-insensitive.
  • PostProcessDetectionRules are RegEx patterns.
## Example - PostProcessDetectionRules
$Script:PostProcessDetectionRules = [string[]]@(
    '_iu14D2N.tmp'

    #  Add more process names as needed
)
Notes

The example is included in the script and you can add more rules as needed.
If you find an exception, please make a pull request or open an issue on GitHub.

Notes

Using * alone in the PostProcessDetectionRules variable will match all processes, which may not be desirable.
Don’t use wildcards in the PostProcessDetectionRules variable if possible.


ConfigMgr and Intune

The scripts were designed to support compliance remediation and automation pipelines.

  • Get-Application can be used for Discovery
  • Remove-Application can be used for Remediation

Logging

  • Both Get-Application and Remove-Application scripts log in to %ProgramData%\Logs\Uninstall-BlacklistedApplications by default.
  • You can change the name by using the Script:LogName parameter.
  • You can separate the logs by using the Script:$Name parameter.
  • MSI uninstalls are logged in _%ProgramData%\Logs\Uninstall-BlacklistedApplications_
  • EXE uninstall logging is not implemented yet.

Flow Diagrams

Main Uninstall Flow

%%{init: { "theme": "dark", "themeVariables": { "primaryColor": "#0f192c", "primaryTextColor": "#ffffff", "primaryBorderColor": "#666666", "lineColor": "#a0a0a0", "secondaryColor": "#252525", "tertiaryColor": "#252525", "background": "#0a0e1a", "fontSize": "16px", "fontFamily": "Archia, sans-serif" } }%% flowchart TD classDef default fill:#252525,color:#ffffff,stroke:#6b6b6b classDef diamond fill:#252525,color:#ffffff,stroke:#6b6b6b Start([START]) --> Find[Find Applications] Find --> Check{Applications Found?} Check -->|No| Summary[Generate Summary] Check -->|Yes| Process[Process Each Application] Process --> Type{MSI or EXE?} Type -->|MSI| MSI[Uninstall via MSI] Type -->|EXE| EXE[Uninstall via EXE] MSI --> Result{Success?} EXE --> Result Result -->|Yes| Count1[Count Success] Result -->|No| Count2[Count Failure] Count1 --> More{More Apps?} Count2 --> More More -->|Yes| Process More -->|No| Summary Summary --> End([END])

EXE Uninstall Flow

%%{init: { "theme": "dark", "themeVariables": { "primaryColor": "#0f192c", "primaryTextColor": "#ffffff", "primaryBorderColor": "#666666", "lineColor": "#a0a0a0", "secondaryColor": "#252525", "tertiaryColor": "#252525", "background": "#0a0e1a", "fontSize": "16px", "fontFamily": "Archia, sans-serif" } }%% flowchart TD classDef default fill:#252525,color:#ffffff,stroke:#6b6b6b classDef diamond fill:#252525,color:#ffffff,stroke:#6b6b6b class CheckRegistry,CheckMetadata,CheckFilename diamond Start["Start EXE Uninstall"] --> CheckRegistry{Check Registry} CheckRegistry -->|Has QuietUninstallString| UseRegistry["Use Registry Args"] CheckRegistry -->|No Registry Args| CheckMetadata{Check Metadata} CheckMetadata -->|Match Found| UseMetadata["Use Metadata Args"] CheckMetadata -->|No Match| CheckFilename{Check Filename} CheckFilename -->|Match Found| UseFilename["Use Filename Args"] CheckFilename -->|No Match| UseDefault["Use Default /S"] UseRegistry --> Execute["Execute Uninstaller"] UseMetadata --> Execute UseFilename --> Execute UseDefault --> Execute Execute --> ProcessResult["Process Result"]

Preview

article card image powershell-remove-application-running
Uninstall In Progress
article card image powershell-get-application
Get-Application Result
article card image powershell-remove-application
Remove-Application Result

Code

Get-Application

  1<#
  2.SYNOPSIS
  3    Gets matching installed applications on a system.
  4.DESCRIPTION
  5    Gets matching installed applications on a system using specified search patterns.
  6.PARAMETER SearchPatterns
  7    Specifies application name patterns to use (supports wildcards).
  8.EXAMPLE
  9    .\Get-Application.ps1
 10.EXAMPLE
 11    .\Get-Application.ps1 -SearchPatterns '7-Zip*', 'Adobe*'
 12.INPUTS
 13    None.
 14.OUTPUTS
 15    System.String
 16.NOTES
 17    Created by Ioan Popovici 2025-04-04
 18.LINK
 19    https://MEMZ.one/Get-Application
 20.LINK
 21    https://MEMZ.one/Get-Application-CHANGELOG
 22.LINK
 23    https://MEMZ.one/Get-Application-GIT
 24.LINK
 25    https://MEM.Zone/ISSUES
 26.COMPONENT
 27    Application Management
 28.FUNCTIONALITY
 29    Get Matching Installed Applications
 30#>
 31
 32## Set script requirements
 33#Requires -Version 5.0
 34
 35##*=============================================
 36##* VARIABLE DECLARATION
 37##*=============================================
 38#region VariableDeclaration
 39
 40## Get script parameters
 41[CmdletBinding()]
 42Param (
 43    [Parameter(Mandatory = $false, Position = 0)]
 44    [string[]]$SearchPatterns
 45)
 46
 47## Define default application search patterns
 48$Script:ApplicationDetectionRules = [string[]]@(
 49    'Dell SupportAssist*'
 50    '*minitool*'
 51    '*treesize*'
 52    '*krita*'
 53    '*ccleaner*'
 54    '*recuva*'
 55    '*download manager*' # '_iu14D2N.tmp'
 56    #'*adobe*'
 57    #'*Zip*'
 58    #'*vlc*'
 59    #'*firefox*'
 60    #'*chrome*'
 61    #'*ccleaner*'
 62    #  Add more applications as needed
 63)
 64
 65### Do not modify anything below this line unless you know what you're doing!
 66## --------------------------------------------------------------------------
 67
 68## Set script variables
 69$Script:Version          = '3.2.0'
 70$Script:Name             = 'Discover-BlacklistedApplications'
 71$Script:NameAndVersion   = $Script:Name + ' v' + $Script:Version
 72$Script:Path             = [System.IO.Path]::GetDirectoryName($MyInvocation.MyCommand.Path)
 73$Script:FullName         = $MyInvocation.MyCommand.Path
 74$Script:LogName          = 'Uninstall-BlacklistedApplications'
 75$Script:LogPath          = [System.IO.Path]::Combine($Env:ProgramData, 'Logs', $Script:LogName)
 76$Script:LogFullName      = [System.IO.Path]::Combine($Script:LogPath, $Script:LogName + '.log')
 77$Script:LogDebugMessages = $false
 78$Script:LogMaxSizeMB     = 5
 79$Script:LogBuffer        = [System.Collections.ArrayList]::new()
 80$Script:RunningAs        = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
 81$Script:RunningAsAdmin   = [bool]([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
 82
 83## Set default matching applications if none provided, working around for the 'Compliant' state passed by the Discovery script
 84if (-not $PSBoundParameters['SearchPatterns'] -or $PSBoundParameters['SearchPatterns'] -eq 'Compliant') { $SearchPatterns = $Script:ApplicationDetectionRules }
 85
 86## Set compliance state
 87[string]$ComplianceState = 'NonCompliant'
 88[string]$Severity        = 'Warning'
 89
 90#endregion
 91##*=============================================
 92##* END VARIABLE DECLARATION
 93##*=============================================
 94
 95##*=============================================
 96##* FUNCTION LISTINGS
 97##*=============================================
 98#region FunctionListings
 99
100#region function Test-LogFile
101function Test-LogFile {
102<#
103.SYNOPSIS
104    Checks if the log path exists and if the log file exceeds the maximum specified size.
105.DESCRIPTION
106    Checks if the log path exists and creates the folder and file if needed
107    Checks if the log file exceeds the maximum specified size and clears it if needed.
108.PARAMETER LogFile
109    Specifies the path to the log file.
110.PARAMETER MaxSizeMB
111    Specifies the maximum size in MB before the log file is cleared.
112.EXAMPLE
113    Test-LogFile -LogFile 'C:\Logs\Application.log' -MaxSizeMB 5
114.INPUTS
115    None.
116.OUTPUTS
117    None.
118.NOTES
119    This is an internal script function and should typically not be called directly.
120.LINK
121    https://MEM.Zone
122.LINK
123    https://MEM.Zone/GIT
124.LINK
125    https://MEM.Zone/ISSUES
126#>
127    [CmdletBinding()]
128    param (
129        [Parameter(Mandatory = $true, Position = 0)]
130        [ValidateNotNullorEmpty()]
131        [string]$LogFile,
132
133        [Parameter(Mandatory = $true, Position = 1)]
134        [ValidateRange(1, 100)]
135        [int]$MaxSizeMB
136    )
137
138    process {
139        try {
140
141            ## Create log folder if it doesn't exists
142            $LogPath = [System.IO.Path]::GetDirectoryName($LogFile)
143            [bool]$LogFolderExists = Test-Path -Path $LogPath -PathType Container
144            if (-not $LogFolderExists) {
145                try {
146                    $null = New-Item -Path $LogPath -ItemType Directory -Force -ErrorAction Stop
147                }
148                catch {
149
150                    ## Fallback to script directory if ProgramData is inaccessible
151                    [string]$Script:LogPath = $Script:Path
152                    [string]$Script:LogFullName = Join-Path -Path $Script:LogPath -ChildPath $Script:LogName
153                    Write-Warning -Message "Failed to create log folder: $($PSItem.Exception.Message). Using script directory instead."
154                }
155            }
156
157            ## Create log file if it doesn't exist
158            [bool]$LogFileExists = Test-Path -Path $LogFile -PathType Leaf
159            if (-not $LogFileExists) {
160                try {
161                    $null = New-Item -Path $LogFile -ItemType File -Force -ErrorAction Stop
162                }
163                catch {
164                    Write-Warning -Message "Failed to create log file: $($PSItem.Exception.Message)"
165                }
166            }
167
168            ## Get log file information
169            [System.IO.FileInfo]$LogFileInfo = Get-Item -Path $LogFile -ErrorAction Stop
170
171            #  Convert bytes to MB
172            [double]$LogFileSizeMB = $LogFileInfo.Length / 1MB
173
174            ## If log file exceeds maximum size, clear it
175            if ($LogFileSizeMB -ge $MaxSizeMB) {
176                Write-Verbose -Message "Log file size [$($LogFileSizeMB.ToString('0.00')) MB] exceeds maximum size [$MaxSizeMB MB]. Clearing log file..."
177
178                ## Clear the log file by creating a new empty file
179                [string]$CurrentTimestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
180                [string]$RotationMessage = "$CurrentTimestamp [Information] Log file exceeded [$MaxSizeMB MB] limit and was cleared"
181                Set-Content -Path $LogFile -Value $RotationMessage -Force -ErrorAction Stop
182            }
183        }
184        catch {
185            Write-Warning -Message "Error checking log file size: [$($PSItem.Exception.Message)]"
186        }
187    }
188}
189#endregion
190
191#region function Write-LogBuffer
192function Write-LogBuffer {
193<#
194.SYNOPSIS
195    Writes the log buffer to the log file.
196.DESCRIPTION
197    Writes the log buffer to the log file and clears the buffer.
198.EXAMPLE
199    Write-LogBuffer
200.INPUTS
201    None.
202.OUTPUTS
203    None.
204.NOTES
205    This is an internal script function and should typically not be called directly.
206.LINK
207    https://MEM.Zone
208.LINK
209    https://MEM.Zone/GIT
210.LINK
211    https://MEM.Zone/ISSUES
212#>
213    [CmdletBinding()]
214    param()
215
216    process {
217        if ($Script:LogBuffer.Count -gt 0) {
218            try {
219
220                ## Convert ArrayList to string array for Add-Content
221                [string[]]$LogEntries = $Script:LogBuffer.ToArray()
222
223                ## Append to log file
224                Add-Content -Path $Script:LogFullName -Value $LogEntries -ErrorAction Stop
225
226                ## Clear buffer
227                $Script:LogBuffer.Clear()
228            }
229            catch {
230                Write-Warning -Message "Failed to write to log file: [$($PSItem.Exception.Message)]"
231            }
232        }
233    }
234}
235#endregion
236
237#region function Write-Log
238function Write-Log {
239<#
240.SYNOPSIS
241    Writes a message to the log file and/or console.
242.DESCRIPTION
243    Writes a timestamped log entry to the internal buffer and displays it with optional formatting.
244.PARAMETER Severity
245    Specifies the severity level of the message. Available options: Information, Warning, Debug, Error.
246.PARAMETER Message
247    The log message to write.
248.PARAMETER FormatOptions
249    Optional hashtable of parameters to pass to Format-Header for formatting the console and/or log output.
250.PARAMETER LogDebugMessages
251    Whether to write debug messages to the log file (default: value of $Script:LogDebugMessages).
252.PARAMETER SkipLogFormatting
253    Whether to skip formatting for the log message.
254#>
255    [CmdletBinding()]
256    param (
257        [Parameter(Position = 0)]
258        [Alias('Level')]
259        [ValidateSet('Information', 'Warning', 'Debug', 'Error')]
260        [string]$Severity = 'Information',
261
262        [Parameter(Mandatory = $true, Position = 1)]
263        [Alias('LogMessage')]
264        [ValidateNotNullOrEmpty()]
265        [string]$Message,
266
267        [Parameter()]
268        [hashtable]$FormatOptions,
269
270        [Parameter()]
271        [switch]$LogDebugMessages = $Script:LogDebugMessages,
272
273        [Parameter()]
274        [switch]$SkipLogFormatting
275    )
276
277    begin {
278        [string]$Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
279    }
280    process {
281        try {
282
283            ## Apply formatting options if provided
284            [string[]]$MessageLines = if ($PSBoundParameters.ContainsKey('$SkipLogFormatting')) {
285                @($Message)
286            }
287            elseif ($PSBoundParameters.ContainsKey('FormatOptions')) {
288                Format-Header -Message $Message @FormatOptions
289            }
290            else {
291                Format-Header -Message $Message -Mode 'Timeline' -AddEmptyRow 'No'
292            }
293
294            ## Write to console and log file
295            foreach ($MessageLine in $MessageLines) {
296                switch ($Severity) {
297                    'Information' { Write-Verbose -Message $MessageLine }
298                    'Warning'     { Write-Warning -Message $MessageLine }
299                    'Debug'       { Write-Debug -Message $MessageLine}
300                    'Error'       { Write-Error -Message $MessageLine -ErrorAction Continue }
301                }
302
303                #  Skip debug logging if disabled
304                if ($Severity -eq 'Debug' -and -not $LogDebugMessages) {
305                    continue
306                }
307
308                #  Add timestamp and severity to the message and add to the log buffer
309                [string]$LogEntry = "$Timestamp [$Severity] $MessageLine"
310                $null = $Script:LogBuffer.Add($LogEntry)
311            }
312
313            #  Write the log buffer to the log file if it exceeds the threshold or if the severity is Error
314            if ($Script:LogBuffer.Count -ge 10 -or $Severity -eq 'Error') {
315                Write-LogBuffer
316            }
317        }
318        catch {
319            Write-Warning -Message "Write-Log failed: [$($PSItem.Exception.Message)]"
320        }
321    }
322}
323#endregion
324
325#region Function Format-Header
326Function Format-Header {
327<#
328.SYNOPSIS
329    Formats a text header.
330.DESCRIPTION
331    Formats a header block, centered block, section header, sub-header, inline log, or adds separator rows.
332.PARAMETER Message
333    The main message to display (used for Block and CenteredBlock).
334.PARAMETER AddEmptyRow
335    Optionally adds blank lines before/after.
336    Default is: 'No'.
337.PARAMETER Mode
338    Defines output style: Block, CenteredBlock, Line, InlineHeader, InlineSubHeader, Timeline, or AddRow.
339    Default is: 'Block'.
340.EXAMPLE
341    Format-Header -Message 'UNINSTALL' -Mode InlineHeader
342.EXAMPLE
343    Format-Header -Message 'Mozilla Firefox (v137.0.1)' -Mode InlineSubHeader
344.EXAMPLE
345    Format-Header -Mode Line
346.EXAMPLE
347    Format-Header -Message 'Uninstalling Google Chrome v100.0.0.0' -Mode Timeline
348.INPUTS
349    None.
350.OUTPUTS
351    None.
352.NOTES
353    This is an internal script function and should typically not be called directly.
354.LINK
355    https://MEM.Zone
356.LINK
357    https://MEM.Zone/GIT
358.LINK
359    https://MEM.Zone/ISSUES
360.COMPONENT
361    Console
362.FUNCTIONALITY
363    Format Output
364#>
365    [CmdletBinding()]
366    param (
367        [Parameter(Mandatory = $true, Position = 0)]
368        [ValidateNotNullOrEmpty()]
369        [string]$Message,
370
371        [Parameter(Position = 1)]
372        [ValidateSet('No', 'Before', 'After', 'BeforeAndAfter')]
373        [string]$AddEmptyRow = 'No',
374
375        [Parameter(Position = 2)]
376        [ValidateSet('Block', 'CenteredBlock', 'Line', 'InlineHeader', 'InlineSubHeader', 'Timeline', 'Default')]
377        [string]$Mode = 'Default'
378    )
379
380    begin {
381
382        ## Fixed output width
383        [int]$LineWidth = 60
384        [string]$Separator = '=' * $LineWidth
385    }
386    process {
387        try {
388            [string[]]$OutputLines = @()
389
390            ## Format the message based on the specified mode
391            switch ($Mode) {
392                'Line' {
393                    $OutputLines = @($Separator)
394                }
395                'Block' {
396
397                    #  Add prefix and format message
398                    [string]$Prefix = '▶ '
399                    [int]$MaxMessageLength = $LineWidth - $Prefix.Length
400                    [string]$FormattedMessage = $Prefix + $Message.Trim()
401
402                    #  Truncate message if it exceeds the maximum length
403                    if ($FormattedMessage.Length -gt $LineWidth) { $FormattedMessage = $Prefix + $Message.Trim().Substring(0, $MaxMessageLength - 3) + '...' }
404
405                    #  Add separation lines
406                    $OutputLines = @($Separator, $FormattedMessage, $Separator)
407                }
408                'CenteredBlock' {
409
410                    #  Trim message
411                    [string]$CleanMessage = $Message.Trim()
412
413                    #  Truncate message if it exceeds the maximum length
414                    if ($CleanMessage.Length -gt ($LineWidth - 4)) { $CleanMessage = $CleanMessage.Substring(0, $LineWidth - 7) + '...' }
415
416                    #  Center the message
417                    [int]$ContentWidth = $CleanMessage.Length
418                    [int]$SidePadding = [math]::Floor(($LineWidth - $ContentWidth) / 2)
419                    [string]$CenteredLine = $CleanMessage.PadLeft($ContentWidth + $SidePadding).PadRight($LineWidth)
420
421                    #  Add separator lines
422                    $OutputLines = @($Separator, $CenteredLine, $Separator)
423                }
424                'InlineHeader' {
425
426                    #  Trim and truncate message
427                    [string]$Trimmed = $Message.Trim()
428                    if ($Trimmed.Length -gt 54) { $Trimmed = $Trimmed.Substring(0, 51) + '...' }
429
430                    #  Add padding to the message
431                    [string]$HeaderLine = "===[ $Trimmed ]==="
432                    $OutputLines = @($HeaderLine)
433                }
434                'InlineSubHeader' {
435
436                    #  Trim and truncate message
437                    [string]$Trimmed = $Message.Trim()
438                    if ($Trimmed.Length -gt 54) { $Trimmed = $Trimmed.Substring(0, 51) + '...' }
439
440                    #  Add padding to the message
441                    [string]$HeaderLine = "---[ $Trimmed ]---"
442                    $OutputLines = @($HeaderLine)
443                }
444                'Timeline' {
445
446                    #  Add prefix to the message
447                    $HeaderLine = "    - $Message"
448                    $OutputLines = @($HeaderLine)
449                }
450                Default {
451
452                    #  Trim the message
453                    $OutputLines = @($Message.Trim())
454                }
455            }
456
457            ## Add spacing if requested
458            switch ($AddEmptyRow) {
459                'Before' {
460                    $OutputLines = @('') + $OutputLines
461                }
462                'After' {
463                    $OutputLines += ''
464                }
465                'BeforeAndAfter' {
466                    $OutputLines = @('') + $OutputLines + @('')
467                }
468            }
469
470            ## Output
471            foreach ($OutputLine in $OutputLines) {
472                Write-Output -InputObject $OutputLine
473            }
474        }
475        catch {
476            Continue
477        }
478    }
479}
480#endregion
481
482#region function Get-Application
483function Get-Application {
484<#
485.SYNOPSIS
486    Gets installed applications from the registry.
487.DESCRIPTION
488    Gets installed applications from the registry by querying the uninstall keys.
489    If search patterns are provided, returns only matching applications.
490.PARAMETER SearchPatterns
491    Optional search patterns to match against application display names.
492.EXAMPLE
493    Get-Application
494    Returns all installed applications.
495.EXAMPLE
496    Get-Application -SearchPatterns '*chrome*', '*vlc*'
497    Returns only applications with display names matching the patterns.
498.INPUTS
499    None.
500.OUTPUTS
501    System.Collections.ArrayList
502.NOTES
503    This is an internal script function and should typically not be called directly.
504.LINK
505    https://MEM.Zone
506.LINK
507    https://MEM.Zone/GIT
508.LINK
509    https://MEM.Zone/ISSUES
510#>
511    [CmdletBinding()]
512    [OutputType([System.Collections.ArrayList])]
513    param (
514        [Parameter()]
515        [string[]]$SearchPatterns
516    )
517
518    begin {
519
520        ## Set Registry paths for installed applications
521        [string[]]$UninstallPaths = @(
522            'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
523            'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
524            'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
525            'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
526        )
527
528        ## Initialize application lists
529        $InstalledApplications = [System.Collections.ArrayList]::new()
530        $FilteredApplications = [System.Collections.ArrayList]::new()
531
532        ## Log the start of the process
533        Write-Log -Message 'Retrieving installed applications...' -FormatOptions @{ AddEmptyRow = 'After' }
534    }
535    process {
536        try {
537
538            ## Get registry application uninstall keys
539            $UninstallKeys = Get-ItemProperty -Path $UninstallPaths -ErrorAction SilentlyContinue
540
541            ## Process items registry uninstall keys
542            foreach ($UninstallKey in $UninstallKeys) {
543                if (-not [string]::IsNullOrWhiteSpace($UninstallKey.DisplayName)) {
544                    $Application = [PSCustomObject]@{
545                        DisplayName          = $UninstallKey.DisplayName
546                        DisplayVersion       = $UninstallKey.DisplayVersion
547                        Publisher            = $UninstallKey.Publisher
548                        UninstallString      = $UninstallKey.UninstallString
549                        QuietUninstallString = $UninstallKey.QuietUninstallString
550                        InstallerType        = if ($UninstallKey.WindowsInstaller -eq 1) { 'MSI' } else { 'EXE' }
551                        PSPath               = $UninstallKey.PSPath
552                    }
553                    $null = $InstalledApplications.Add($Application)
554                }
555            }
556            #  Log installed applications
557            Write-Log -Severity Debug -Message $($InstalledApplications | Out-String) -FormatOptions @{ AddEmptyRow = 'After' }
558
559            ## If search patterns are supplied, filter the list
560            if ($SearchPatterns) {
561                Write-Log -Severity Debug -Message "Filtering applications using search patterns: [$($SearchPatterns -join ', ')]" -FormatOptions @{ AddEmptyRow = 'After' }
562                foreach ($InstalledApp in $InstalledApplications) {
563                    foreach ($SearchPattern in $SearchPatterns) {
564                        if ($InstalledApp.DisplayName -like $SearchPattern) {
565                            $null = $FilteredApplications.Add($InstalledApp)
566                            break
567                        }
568                    }
569                }
570
571                ## Log the number of matching applications
572                [int]$MatchCount = $FilteredApplications.Count
573                Write-Log -Message "Found [$MatchCount/$($InstalledApplications.Count)] matching application(s)" -FormatOptions @{ Mode = 'Default' }
574
575                ## Output the filtered list
576                #  Get longest display name length
577                [int]$MaxNameLength = ($FilteredApplications.DisplayName | ForEach-Object { $PsItem.Length } | Measure-Object -Maximum).Maximum
578
579                #  Format and output the filtered list
580                foreach ($Application in $FilteredApplications) {
581                    [string]$PaddedApplicationName = $Application.DisplayName.PadRight($MaxNameLength)
582                    Write-Log -Message "$PaddedApplicationName | Type: $($Application.InstallerType) | Version: $($Application.DisplayVersion)"
583                }
584            }
585        }
586        catch {
587            Write-Log -Severity Error -Message "Error retrieving installed applications: [$($PSItem.Exception.Message)]" -FormatOptions @{ AddEmptyRow = 'After' }
588        }
589        finally {
590
591            ## Output matching or full list based on presence of search patterns
592            $OutputList = if ($SearchPatterns) { $FilteredApplications } else { $InstalledApplications }
593            if ($OutputList) {
594                Write-Log -Severity Debug -Message ($OutputList -join "`n") -FormatOptions @{ AddEmptyRow = 'After' }
595            }
596            Write-Output -InputObject $OutputList -NoEnumerate
597        }
598    }
599}
600#endregion
601
602#endregion
603##*=============================================
604##* END FUNCTION LISTINGS
605##*=============================================
606
607##*=============================================
608##* SCRIPT BODY
609##*=============================================
610#region ScriptBody
611
612## Check if log path exists or if the log file exceeds size limit
613Test-LogFile -LogFile $Script:LogFullName -MaxSizeMB $Script:LogMaxSizeMB
614
615## Write initial log entries
616Write-Log -Message "$Script:NameAndVersion Started" -FormatOptions @{ Mode = 'CenteredBlock'; AddEmptyRow = 'After' }
617Write-Log -Message 'ENVIRONMENT' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'After' }
618Write-Log -Message "Running elevated: [$Script:RunningAsAdmin]" -FormatOptions @{ Mode = 'Default' }
619Write-Log -Message "Running as:       [$Script:RunningAs]" -FormatOptions @{ Mode = 'Default' }
620Write-Log -Message "Script path:      [$Script:Path]" -FormatOptions @{ Mode = 'Default' }
621Write-Log -Message "Log path:         [$Script:LogPath]" -FormatOptions @{ Mode = 'Default' }
622Write-Log -Message 'DISCOVERY' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'BeforeAndAfter' }
623
624Try {
625
626    ## Get matching applications
627    $InstalledApplications = Get-Application -SearchPatterns $SearchPatterns
628
629    ## Check if any applications were found
630    [int]$MatchCount = $InstalledApplications.DisplayName.Count
631
632    ## Set compliance state based on match count
633    if ($MatchCount -eq 0) {
634        $ComplianceState = 'Compliant'
635        $Severity = 'Information'
636    }
637
638    ## Log the compliance
639    Write-Log -Message 'SUMMARY' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'BeforeAndAfter' }
640    Write-Log -Message "Compliance state: [$ComplianceState] - [$MatchCount] matching application(s) found" -FormatOptions @{ AddEmptyRow = 'After' }
641}
642Catch {
643
644    ## If discovery fails for any reason, return a clear failure message and assume non-compliant to be safe
645    [string]$ComplianceState = "Compliance state: [NonCompliant] - Discovery Failed: [$($_.Exception.Message)]"
646    Write-Log -Severity Error -Message $ComplianceState
647}
648Finally {
649
650    ## Make sure to flush any buffered log entries
651    Write-LogBuffer
652
653    ## End logging
654    Write-Log -Message "$Script:NameAndVersion Completed" -FormatOptions @{ Mode = 'CenteredBlock'; AddEmptyRow = 'Before' }
655
656    ## Ensure final flush of log buffer
657    Write-LogBuffer
658
659    ## Output the result
660    Write-Output -InputObject $ComplianceState
661}
662
663#endregion
664##*=============================================
665##* END SCRIPT BODY
666##*=============================================

Remove-Application

   1<#
   2.SYNOPSIS
   3    Removes matching installed applications.
   4.DESCRIPTION
   5    Removes matching installed applications using a specified search pattern in bulk.
   6    Supports both MSI and EXE uninstallation methods.
   7    AppX support is not yet implemented.
   8.PARAMETER SearchPattern
   9    Specifies application name patterns to remove (supports wildcards).
  10.EXAMPLE
  11    .\Remove-Application.ps1
  12    Uninstalls all applications matching the default search pattern.
  13.EXAMPLE
  14    .\Remove-Application.ps1 -SearchPatterns "7-Zip*", "Adobe*"
  15    Uninstalls all applications matching the specified patterns.
  16.INPUTS
  17    None.
  18.OUTPUTS
  19    None.
  20.NOTES
  21    Created by Ioan Popovici 2025-04-04
  22.LINK
  23    https://MEMZ.one/Remove-Application
  24.LINK
  25    https://MEMZ.one/Remove-Application-CHANGELOG
  26.LINK
  27    https://MEMZ.one/Remove-Application-GIT
  28.LINK
  29    https://MEM.Zone/ISSUES
  30.COMPONENT
  31    Application Management
  32.FUNCTIONALITY
  33    Removes Matching Applications
  34#>
  35
  36## Set script requirements
  37#Requires -Version 5.0
  38
  39##*=============================================
  40##* VARIABLE DECLARATION
  41##*=============================================
  42#region VariableDeclaration
  43
  44## Get script parameters
  45[CmdletBinding()]
  46param (
  47    [Parameter(Position = 0)]
  48    [Alias('Applications')]
  49    [ValidateNotNullOrEmpty()]
  50    [string[]]$SearchPatterns
  51)
  52
  53## Define default application search patterns
  54$Script:ApplicationDetectionRules = [string[]]@(
  55    #'Dell SupportAssist*'
  56    #'*minitool*'
  57    #'*treesize*'
  58    #'*krita*'
  59    #'*ccleaner*'
  60    #'*recuva*'
  61    '*download manager*'
  62    #'*adobe*'
  63    '*Zip*'
  64    #'*vlc*'
  65    #'*firefox*'
  66    #'*chrome*'
  67    #'*ccleaner*'
  68
  69    #  Add more applications as needed
  70)
  71
  72## Define post process to kill search patterns
  73$Script:PostProcessDetectionRules = [string[]]@(
  74    '_iu14D2N.tmp'
  75    #  Add more process names as needed
  76)
  77
  78## Define EXE uninstaller detection rules
  79#  These rules are used to identify the type of uninstaller based on the uninstaller executable metadata
  80$Script:UninstallerMetadataDetectionRules = [array]@(
  81    @{ Pattern = '*NSIs*'                    ; SilentArgs = '/S'                                       ; Type = 'NSIs'              },
  82    @{ Pattern = '*Inno*'                    ; SilentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART' ; Type = 'Inno Setup'        },
  83    @{ Pattern = '*InstallShield*'           ; SilentArgs = '/s'                                       ; Type = 'InstallShield'     },
  84    @{ Pattern = '*Squirrel*'                ; SilentArgs = '--uninstall'                              ; Type = 'Squirrel'          },
  85    @{ Pattern = '*Google Chrome Installer*' ; SilentArgs = '--force-uninstall'                        ; Type = 'Google Chrome'     },
  86    @{ Pattern = '*Adobe*'                   ; SilentArgs = '/sAll /rs /rps'                           ; Type = 'Adobe Installer'   }
  87)
  88#  These rules are used to identify the type of uninstaller based on the executable name
  89$Script:UninstallerFileNameDetectionRules = [array]@(
  90    @{ Pattern = 'uninst.exe$'               ; SilentArgs = '/S'                                       ; Type = 'NSIs'              },
  91    @{ Pattern = 'unins\d{3}\.exe$'          ; SilentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART' ; Type = 'Inno Setup'        },
  92    @{ Pattern = 'setup\.exe$'               ; SilentArgs = '/s'                                       ; Type = 'InstallShield'     },
  93    @{ Pattern = 'install\.exe$'             ; SilentArgs = '/quiet'                                   ; Type = 'Generic Installer' },
  94    @{ Pattern = 'update\.exe$'              ; SilentArgs = '--uninstall'                              ; Type = 'Squirrel'          }
  95)
  96
  97## Do not modify anything below this line unless you know what you're doing!
  98## -------------------------------------------------------------------------
  99
 100## Set script variables
 101$Script:Version          = '3.2.0'
 102$Script:Name             = 'Remediate-BlacklistedApplications'
 103$Script:NameAndVersion   = $Script:Name + ' v' + $Script:Version
 104$Script:Path             = [System.IO.Path]::GetDirectoryName($MyInvocation.MyCommand.Path)
 105$Script:FullName         = $MyInvocation.MyCommand.Path
 106$Script:LogName          = 'Uninstall-BlacklistedApplications'
 107$Script:LogPath          = [System.IO.Path]::Combine($Env:ProgramData, 'Logs', $Script:LogName)
 108$Script:LogFullName      = [System.IO.Path]::Combine($Script:LogPath, $Script:LogName + '.log')
 109$Script:LogDebugMessages = $false
 110$Script:LogMaxSizeMB     = 5
 111$Script:LogBuffer        = [System.Collections.ArrayList]::new()
 112$Script:RunningAs        = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
 113$Script:RunningAsAdmin   = [bool]([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
 114
 115
 116## Set default matching applications if none provided, working around for the 'Compliant' state passed by the Discovery script
 117if (-not $PSBoundParameters['SearchPatterns'] -or $PSBoundParameters['SearchPatterns'] -eq 'Compliant') { $SearchPatterns = $Script:ApplicationDetectionRules }
 118
 119#endregion
 120##*=============================================
 121##* END VARIABLE DECLARATION
 122##*=============================================
 123
 124##*=============================================
 125##* FUNCTION LISTINGS
 126##*=============================================
 127#region FunctionListings
 128
 129#region function Test-LogFile
 130function Test-LogFile {
 131<#
 132.SYNOPSIS
 133    Checks if the log path exists and if the log file exceeds the maximum specified size.
 134.DESCRIPTION
 135    Checks if the log path exists and creates the folder and file if needed
 136    Checks if the log file exceeds the maximum specified size and clears it if needed.
 137.PARAMETER LogFile
 138    Specifies the path to the log file.
 139.PARAMETER MaxSizeMB
 140    Specifies the maximum size in MB before the log file is cleared.
 141.EXAMPLE
 142    Test-LogFile -LogFile 'C:\Logs\Application.log' -MaxSizeMB 5
 143.INPUTS
 144    None.
 145.OUTPUTS
 146    None.
 147.NOTES
 148    This is an internal script function and should typically not be called directly.
 149.LINK
 150    https://MEM.Zone
 151.LINK
 152    https://MEM.Zone/GIT
 153.LINK
 154    https://MEM.Zone/ISSUES
 155#>
 156    [CmdletBinding()]
 157    param (
 158        [Parameter(Mandatory = $true, Position = 0)]
 159        [ValidateNotNullorEmpty()]
 160        [string]$LogFile,
 161
 162        [Parameter(Mandatory = $true, Position = 1)]
 163        [ValidateRange(1, 100)]
 164        [int]$MaxSizeMB
 165    )
 166
 167    process {
 168        try {
 169
 170            ## Create log folder if it doesn't exists
 171            $LogPath = [System.IO.Path]::GetDirectoryName($LogFile)
 172            [bool]$LogFolderExists = Test-Path -Path $LogPath -PathType Container
 173            if (-not $LogFolderExists) {
 174                try {
 175                    $null = New-Item -Path $LogPath -ItemType Directory -Force -ErrorAction Stop
 176                }
 177                catch {
 178
 179                    ## Fallback to script directory if ProgramData is inaccessible
 180                    [string]$Script:LogPath = $Script:Path
 181                    [string]$Script:LogFullName = Join-Path -Path $Script:LogPath -ChildPath $Script:LogName
 182                    Write-Warning -Message "Failed to create log folder: $($PSItem.Exception.Message). Using script directory instead."
 183                }
 184            }
 185
 186            ## Create log file if it doesn't exist
 187            [bool]$LogFileExists = Test-Path -Path $LogFile -PathType Leaf
 188            if (-not $LogFileExists) {
 189                try {
 190                    $null = New-Item -Path $LogFile -ItemType File -Force -ErrorAction Stop
 191                }
 192                catch {
 193                    Write-Warning -Message "Failed to create log file: $($PSItem.Exception.Message)"
 194                }
 195            }
 196
 197            ## Get log file information
 198            [System.IO.FileInfo]$LogFileInfo = Get-Item -Path $LogFile -ErrorAction Stop
 199
 200            #  Convert bytes to MB
 201            [double]$LogFileSizeMB = $LogFileInfo.Length / 1MB
 202
 203            ## If log file exceeds maximum size, clear it
 204            if ($LogFileSizeMB -ge $MaxSizeMB) {
 205                Write-Verbose -Message "Log file size [$($LogFileSizeMB.ToString('0.00')) MB] exceeds maximum size [$MaxSizeMB MB]. Clearing log file..."
 206
 207                ## Clear the log file by creating a new empty file
 208                [string]$CurrentTimestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
 209                [string]$RotationMessage = "$CurrentTimestamp [Information] Log file exceeded [$MaxSizeMB MB] limit and was cleared"
 210                Set-Content -Path $LogFile -Value $RotationMessage -Force -ErrorAction Stop
 211            }
 212        }
 213        catch {
 214            Write-Warning -Message "Error checking log file size: [$($PSItem.Exception.Message)]"
 215        }
 216    }
 217}
 218#endregion
 219
 220#region function Write-LogBuffer
 221function Write-LogBuffer {
 222<#
 223.SYNOPSIS
 224    Writes the log buffer to the log file.
 225.DESCRIPTION
 226    Writes the log buffer to the log file and clears the buffer.
 227.EXAMPLE
 228    Write-LogBuffer
 229.INPUTS
 230    None.
 231.OUTPUTS
 232    None.
 233.NOTES
 234    This is an internal script function and should typically not be called directly.
 235.LINK
 236    https://MEM.Zone
 237.LINK
 238    https://MEM.Zone/GIT
 239.LINK
 240    https://MEM.Zone/ISSUES
 241#>
 242    [CmdletBinding()]
 243    param()
 244
 245    process {
 246        if ($Script:LogBuffer.Count -gt 0) {
 247            try {
 248
 249                ## Convert ArrayList to string array for Add-Content
 250                [string[]]$LogEntries = $Script:LogBuffer.ToArray()
 251
 252                ## Append to log file
 253                Add-Content -Path $Script:LogFullName -Value $LogEntries -ErrorAction Stop
 254
 255                ## Clear buffer
 256                $Script:LogBuffer.Clear()
 257            }
 258            catch {
 259                Write-Warning -Message "Failed to write to log file: [$($PSItem.Exception.Message)]"
 260            }
 261        }
 262    }
 263}
 264#endregion
 265
 266#region function Write-Log
 267function Write-Log {
 268<#
 269.SYNOPSIS
 270    Writes a message to the log file and/or console.
 271.DESCRIPTION
 272    Writes a timestamped log entry to the internal buffer and displays it with optional formatting.
 273.PARAMETER Severity
 274    Specifies the severity level of the message. Available options: Information, Warning, Debug, Error.
 275.PARAMETER Message
 276    The log message to write.
 277.PARAMETER FormatOptions
 278    Optional hashtable of parameters to pass to Format-Header for formatting the console and/or log output.
 279.PARAMETER LogDebugMessages
 280    Whether to write debug messages to the log file (default: value of $Script:LogDebugMessages).
 281.PARAMETER SkipLogFormatting
 282    Whether to skip formatting for the log message.
 283#>
 284    [CmdletBinding()]
 285    param (
 286        [Parameter(Position = 0)]
 287        [Alias('Level')]
 288        [ValidateSet('Information', 'Warning', 'Debug', 'Error')]
 289        [string]$Severity = 'Information',
 290
 291        [Parameter(Mandatory = $true, Position = 1)]
 292        [Alias('LogMessage')]
 293        [ValidateNotNullOrEmpty()]
 294        [string]$Message,
 295
 296        [Parameter()]
 297        [hashtable]$FormatOptions,
 298
 299        [Parameter()]
 300        [switch]$LogDebugMessages = $Script:LogDebugMessages,
 301
 302        [Parameter()]
 303        [switch]$SkipLogFormatting
 304    )
 305
 306    begin {
 307        [string]$Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
 308    }
 309    process {
 310        try {
 311
 312            ## Apply formatting options if provided
 313            [string[]]$MessageLines = if ($PSBoundParameters.ContainsKey('$SkipLogFormatting')) {
 314                @($Message)
 315            }
 316            elseif ($PSBoundParameters.ContainsKey('FormatOptions')) {
 317                Format-Header -Message $Message @FormatOptions
 318            }
 319            else {
 320                Format-Header -Message $Message -Mode 'Timeline' -AddEmptyRow 'No'
 321            }
 322
 323            ## Write to console and log file
 324            foreach ($MessageLine in $MessageLines) {
 325                switch ($Severity) {
 326                    'Information' { Write-Verbose -Message $MessageLine }
 327                    'Warning'     { Write-Warning -Message $MessageLine }
 328                    'Debug'       { Write-Debug -Message $MessageLine}
 329                    'Error'       { Write-Error -Message $MessageLine -ErrorAction Continue }
 330                }
 331
 332                #  Skip debug logging if disabled
 333                if ($Severity -eq 'Debug' -and -not $LogDebugMessages) {
 334                    continue
 335                }
 336
 337                #  Add timestamp and severity to the message and add to the log buffer
 338                [string]$LogEntry = "$Timestamp [$Severity] $MessageLine"
 339                $null = $Script:LogBuffer.Add($LogEntry)
 340            }
 341
 342            #  Write the log buffer to the log file if it exceeds the threshold or if the severity is Error
 343            if ($Script:LogBuffer.Count -ge 10 -or $Severity -eq 'Error') {
 344                Write-LogBuffer
 345            }
 346        }
 347        catch {
 348            Write-Warning -Message "Write-Log failed: [$($PSItem.Exception.Message)]"
 349        }
 350    }
 351}
 352#endregion
 353
 354#region Function Format-Header
 355Function Format-Header {
 356<#
 357.SYNOPSIS
 358    Formats a text header.
 359.DESCRIPTION
 360    Formats a header block, centered block, section header, sub-header, inline log, or adds separator rows.
 361.PARAMETER Message
 362    The main message to display (used for Block and CenteredBlock).
 363.PARAMETER AddEmptyRow
 364    Optionally adds blank lines before/after.
 365    Default is: 'No'.
 366.PARAMETER Mode
 367    Defines output style: Block, CenteredBlock, Line, InlineHeader, InlineSubHeader, Timeline, or AddRow.
 368    Default is: 'Block'.
 369.EXAMPLE
 370    Format-Header -Message 'UNINSTALL' -Mode InlineHeader
 371.EXAMPLE
 372    Format-Header -Message 'Mozilla Firefox (v137.0.1)' -Mode InlineSubHeader
 373.EXAMPLE
 374    Format-Header -Mode Line
 375.EXAMPLE
 376    Format-Header -Message 'Uninstalling Google Chrome v100.0.0.0' -Mode Timeline
 377.INPUTS
 378    None.
 379.OUTPUTS
 380    None.
 381.NOTES
 382    This is an internal script function and should typically not be called directly.
 383.LINK
 384    https://MEM.Zone
 385.LINK
 386    https://MEM.Zone/GIT
 387.LINK
 388    https://MEM.Zone/ISSUES
 389.COMPONENT
 390    Console
 391.FUNCTIONALITY
 392    Format Output
 393#>
 394    [CmdletBinding()]
 395    param (
 396        [Parameter(Mandatory = $true, Position = 0)]
 397        [ValidateNotNullOrEmpty()]
 398        [string]$Message,
 399
 400        [Parameter(Position = 1)]
 401        [ValidateSet('No', 'Before', 'After', 'BeforeAndAfter')]
 402        [string]$AddEmptyRow = 'No',
 403
 404        [Parameter(Position = 2)]
 405        [ValidateSet('Block', 'CenteredBlock', 'Line', 'InlineHeader', 'InlineSubHeader', 'Timeline', 'Default')]
 406        [string]$Mode = 'Default'
 407    )
 408
 409    begin {
 410
 411        ## Fixed output width
 412        [int]$LineWidth = 60
 413        [string]$Separator = '=' * $LineWidth
 414    }
 415    process {
 416        try {
 417            [string[]]$OutputLines = @()
 418
 419            ## Format the message based on the specified mode
 420            switch ($Mode) {
 421                'Line' {
 422                    $OutputLines = @($Separator)
 423                }
 424                'Block' {
 425
 426                    #  Add prefix and format message
 427                    [string]$Prefix = '▶ '
 428                    [int]$MaxMessageLength = $LineWidth - $Prefix.Length
 429                    [string]$FormattedMessage = $Prefix + $Message.Trim()
 430
 431                    #  Truncate message if it exceeds the maximum length
 432                    if ($FormattedMessage.Length -gt $LineWidth) { $FormattedMessage = $Prefix + $Message.Trim().Substring(0, $MaxMessageLength - 3) + '...' }
 433
 434                    #  Add separation lines
 435                    $OutputLines = @($Separator, $FormattedMessage, $Separator)
 436                }
 437                'CenteredBlock' {
 438
 439                    #  Trim message
 440                    [string]$CleanMessage = $Message.Trim()
 441
 442                    #  Truncate message if it exceeds the maximum length
 443                    if ($CleanMessage.Length -gt ($LineWidth - 4)) { $CleanMessage = $CleanMessage.Substring(0, $LineWidth - 7) + '...' }
 444
 445                    #  Center the message
 446                    [int]$ContentWidth = $CleanMessage.Length
 447                    [int]$SidePadding = [math]::Floor(($LineWidth - $ContentWidth) / 2)
 448                    [string]$CenteredLine = $CleanMessage.PadLeft($ContentWidth + $SidePadding).PadRight($LineWidth)
 449
 450                    #  Add separator lines
 451                    $OutputLines = @($Separator, $CenteredLine, $Separator)
 452                }
 453                'InlineHeader' {
 454
 455                    #  Trim and truncate message
 456                    [string]$Trimmed = $Message.Trim()
 457                    if ($Trimmed.Length -gt 54) { $Trimmed = $Trimmed.Substring(0, 51) + '...' }
 458
 459                    #  Add padding to the message
 460                    [string]$HeaderLine = "===[ $Trimmed ]==="
 461                    $OutputLines = @($HeaderLine)
 462                }
 463                'InlineSubHeader' {
 464
 465                    #  Trim and truncate message
 466                    [string]$Trimmed = $Message.Trim()
 467                    if ($Trimmed.Length -gt 54) { $Trimmed = $Trimmed.Substring(0, 51) + '...' }
 468
 469                    #  Add padding to the message
 470                    [string]$HeaderLine = "---[ $Trimmed ]---"
 471                    $OutputLines = @($HeaderLine)
 472                }
 473                'Timeline' {
 474
 475                    #  Add prefix to the message
 476                    $HeaderLine = "    - $Message"
 477                    $OutputLines = @($HeaderLine)
 478                }
 479                Default {
 480
 481                    #  Trim the message
 482                    $OutputLines = @($Message.Trim())
 483                }
 484            }
 485
 486            ## Add spacing if requested
 487            switch ($AddEmptyRow) {
 488                'Before' {
 489                    $OutputLines = @('') + $OutputLines
 490                }
 491                'After' {
 492                    $OutputLines += ''
 493                }
 494                'BeforeAndAfter' {
 495                    $OutputLines = @('') + $OutputLines + @('')
 496                }
 497            }
 498
 499            ## Output
 500            foreach ($OutputLine in $OutputLines) {
 501                Write-Output -InputObject $OutputLine
 502            }
 503        }
 504        catch {
 505            Continue
 506        }
 507    }
 508}
 509#endregion
 510
 511#region function Get-Application
 512function Get-Application {
 513<#
 514.SYNOPSIS
 515    Gets installed applications from the registry.
 516.DESCRIPTION
 517    Gets installed applications from the registry by querying the uninstall keys.
 518    If search patterns are provided, returns only matching applications.
 519.PARAMETER SearchPatterns
 520    Optional search patterns to match against application display names.
 521.EXAMPLE
 522    Get-Application
 523    Returns all installed applications.
 524.EXAMPLE
 525    Get-Application -SearchPatterns '*chrome*', '*vlc*'
 526    Returns only applications with display names matching the patterns.
 527.INPUTS
 528    None.
 529.OUTPUTS
 530    System.Collections.ArrayList
 531.NOTES
 532    This is an internal script function and should typically not be called directly.
 533.LINK
 534    https://MEM.Zone
 535.LINK
 536    https://MEM.Zone/GIT
 537.LINK
 538    https://MEM.Zone/ISSUES
 539#>
 540    [CmdletBinding()]
 541    [OutputType([System.Collections.ArrayList])]
 542    param (
 543        [Parameter()]
 544        [string[]]$SearchPatterns
 545    )
 546
 547    begin {
 548
 549        ## Set Registry paths for installed applications
 550        [string[]]$UninstallPaths = @(
 551            'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
 552            'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
 553            'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 554            'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
 555        )
 556
 557        ## Initialize application lists
 558        $InstalledApplications = [System.Collections.ArrayList]::new()
 559        $FilteredApplications = [System.Collections.ArrayList]::new()
 560
 561        ## Log the start of the process
 562        Write-Log -Message 'Retrieving installed applications...' -FormatOptions @{ AddEmptyRow = 'After' }
 563    }
 564    process {
 565        try {
 566
 567            ## Get registry application uninstall keys
 568            $UninstallKeys = Get-ItemProperty -Path $UninstallPaths -ErrorAction SilentlyContinue
 569
 570            ## Process items registry uninstall keys
 571            foreach ($UninstallKey in $UninstallKeys) {
 572                if (-not [string]::IsNullOrWhiteSpace($UninstallKey.DisplayName)) {
 573                    $Application = [PSCustomObject]@{
 574                        DisplayName          = $UninstallKey.DisplayName
 575                        DisplayVersion       = $UninstallKey.DisplayVersion
 576                        Publisher            = $UninstallKey.Publisher
 577                        UninstallString      = $UninstallKey.UninstallString
 578                        QuietUninstallString = $UninstallKey.QuietUninstallString
 579                        InstallerType        = if ($UninstallKey.WindowsInstaller -eq 1) { 'MSI' } else { 'EXE' }
 580                        PSPath               = $UninstallKey.PSPath
 581                    }
 582                    $null = $InstalledApplications.Add($Application)
 583                }
 584            }
 585            #  Log installed applications
 586            Write-Log -Severity Debug -Message $($InstalledApplications | Out-String) -FormatOptions @{ AddEmptyRow = 'After' }
 587
 588            ## If search patterns are supplied, filter the list
 589            if ($SearchPatterns) {
 590                Write-Log -Severity Debug -Message "Filtering applications using search patterns: [$($SearchPatterns -join ', ')]" -FormatOptions @{ AddEmptyRow = 'After' }
 591                foreach ($InstalledApp in $InstalledApplications) {
 592                    foreach ($SearchPattern in $SearchPatterns) {
 593                        if ($InstalledApp.DisplayName -like $SearchPattern) {
 594                            $null = $FilteredApplications.Add($InstalledApp)
 595                            break
 596                        }
 597                    }
 598                }
 599
 600                ## Log the number of matching applications
 601                [int]$MatchCount = $FilteredApplications.Count
 602                Write-Log -Message "Found [$MatchCount/$($InstalledApplications.Count)] matching application(s)" -FormatOptions @{ Mode = 'Default' }
 603
 604                ## Output the filtered list
 605                #  Get longest display name length
 606                [int]$MaxNameLength = ($FilteredApplications.DisplayName | ForEach-Object { $PsItem.Length } | Measure-Object -Maximum).Maximum
 607
 608                #  Format and output the filtered list
 609                foreach ($Application in $FilteredApplications) {
 610                    [string]$PaddedApplicationName = $Application.DisplayName.PadRight($MaxNameLength)
 611                    Write-Log -Message "$PaddedApplicationName | Type: $($Application.InstallerType) | Version: $($Application.DisplayVersion)"
 612                }
 613            }
 614        }
 615        catch {
 616            Write-Log -Severity Error -Message "Error retrieving installed applications: [$($PSItem.Exception.Message)]" -FormatOptions @{ AddEmptyRow = 'After' }
 617        }
 618        finally {
 619
 620            ## Output matching or full list based on presence of search patterns
 621            $OutputList = if ($SearchPatterns) { $FilteredApplications } else { $InstalledApplications }
 622            if ($OutputList) {
 623                Write-Log -Severity Debug -Message ($OutputList -join "`n") -FormatOptions @{ AddEmptyRow = 'After' }
 624            }
 625            Write-Output -InputObject $OutputList -NoEnumerate
 626        }
 627    }
 628}
 629#endregion
 630
 631#region function Get-InstallerCommand
 632function Get-InstallerCommand {
 633<#
 634.SYNOPSIS
 635    Splits an install or uninstall string into executable, arguments, and metadata.
 636.DESCRIPTION
 637    Uses regex to separate the executable path and arguments from a given uninstall string.
 638    Also detects whether known silent uninstall arguments are already present.
 639.PARAMETER CommandString
 640    The installer command string.
 641.EXAMPLE
 642    Get-InstallerCommand -CommandString 'C:\Program Files\App\uninstall.exe /S'
 643.INPUTS
 644    System.String
 645.OUTPUTS
 646    System.PSCustomObject
 647.NOTES
 648    This is an internal script function and should typically not be called directly.
 649#>
 650
 651    [CmdletBinding()]
 652    [OutputType([PSCustomObject])]
 653    param (
 654        [Parameter(Mandatory = $true, Position = 0)]
 655        [ValidateNotNullOrEmpty()]
 656        [string]$CommandString
 657    )
 658
 659    begin {
 660        Write-Log -Severity Debug -Message "Processing command string: [$CommandString]..."
 661
 662        # Regex patterns
 663        [regex]$PathPattern = '^(?:["'']?(?<Path>[a-zA-Z]:\\(?:[^""''\s\\]+\\)*)(?<Exe>[^\\\/]+\.exe)["'']?|(?<Path>.+?\\)(?<Exe>[^\\]+\.exe))(?<Args>.*)?$'
 664        [regex]$SilentArgsPattern = '(?i)(?<=^|\s)(\/s|\/verysilent|\/quiet|\/silent|--force-uninstall|--force-install)(?=\s|$)'
 665    }
 666    process {
 667        try {
 668            if (-not ($CommandString -match $PathPattern)) {
 669                return [PSCustomObject]@{
 670                    Path          = ''
 671                    Name          = ''
 672                    Arguments     = ''
 673                    FullName      = ''
 674                    SilentArgs    = @()
 675                    HasSilentArgs = $false
 676                }
 677            }
 678
 679            ## Normalize and parse components
 680            [string]$Path = $Matches['Path'].Trim('"')
 681            [string]$Name = $Matches['Exe'].Trim('"')
 682            [string]$Arguments = $Matches['Args'].Trim('"')
 683            [string]$FullName = Join-Path -Path $Path -ChildPath $Name
 684
 685            ## Extract silent switches
 686            [string[]]$SilentArgs = [regex]::Matches($Arguments, $SilentArgsPattern) | ForEach-Object { $PSItem.Value.Trim() }
 687
 688            ## Set HasSilentArgs flag
 689            [bool]$HasSilentArgs = $SilentArgs.Count -gt 0
 690
 691            #  If only /SILENT or is present, set HasSilentArgs to false because it's not a fully silent argument
 692            if ($SilentArgs.Count -eq 1 -and $SilentArgs[0] -ceq '/SILENT') { $HasSilentArgs = $false }
 693
 694            ## Output result
 695            return [PSCustomObject]@{
 696                Path          = $Path
 697                Name          = $Name
 698                Arguments     = $Arguments
 699                FullName      = $FullName
 700                SilentArgs    = $SilentArgs
 701                HasSilentArgs = $HasSilentArgs
 702            }
 703        }
 704        catch {
 705            Write-Log -Severity Error -Message "Failed to process command string: $($PSItem.Exception.Message)"
 706        }
 707    }
 708}
 709#endregion
 710
 711#region function Get-SilentUninstallCommand
 712function Get-SilentUninstallCommand {
 713<#
 714.SYNOPSIS
 715    Builds a silent uninstall command based on known installer types.
 716.DESCRIPTION
 717    Identifies installer type from EXE name and metadata, and appends proper silent flags.
 718.PARAMETER UninstallString
 719    Raw uninstall string from the registry.
 720.PARAMETER FilenameRules
 721    Optional array of filename-based uninstall detection rules.
 722.PARAMETER MetadataRules
 723    Optional array of metadata-based uninstall detection rules.
 724.EXAMPLE
 725    Get-SilentUninstallCommand -UninstallString 'C:\Program Files\Example\uninstall.exe'
 726    Returns a command with silent arguments based on the installer type.
 727.INPUTS
 728    None.
 729.OUTPUTS
 730    System.String
 731        - Fully assembled, shell-ready uninstall command string.
 732        - The silent uninstall command string.
 733        - If no silent arguments are found, returns the original uninstall string.
 734.NOTES
 735    This is an internal script function and should typically not be called directly.
 736.LINK
 737    https://MEM.Zone
 738.LINK
 739    https://MEM.Zone/GIT
 740.LINK
 741    https://MEM.Zone/ISSUES
 742#>
 743    [CmdletBinding()]
 744    [OutputType([System.String])]
 745    param (
 746        [Parameter(Mandatory = $true)]
 747        [ValidateNotNullOrEmpty()]
 748        [string]$UninstallString,
 749
 750        [Parameter()]
 751        [array]$MetadataRules = $Script:UninstallerMetadataDetectionRules,
 752
 753        [Parameter()]
 754        [array]$FilenameRules = $Script:UninstallerFileNameDetectionRules
 755    )
 756    begin {
 757
 758        ## Log the uninstall string
 759        Write-Log -Severity Debug -Message "Inferring silent uninstall string from: [$UninstallString]..."
 760
 761        ## Initialize variables
 762        [string]$DetectedType = ''
 763        [string]$SilentArgs = ''
 764    }
 765
 766    process {
 767        try {
 768            ## Parse the uninstall string
 769            [PSCustomObject]$UninstallParameters = Get-InstallerCommand -CommandString $UninstallString
 770            [string]$ExecutableName = $UninstallParameters.Name
 771            [string]$ExecutableFullName = $UninstallParameters.FullName
 772            [string]$Arguments = $UninstallParameters.Arguments
 773            [bool]$HasSilentArgs = $UninstallParameters.HasSilentArgs
 774
 775            ## If we already have silent args, return early
 776            If ($HasSilentArgs) {
 777                Write-Log -Message 'Silent arguments already present'
 778                return
 779            }
 780
 781            ## Attempt metadata-based detection first if have a valid executable
 782            if (Test-Path -Path $ExecutableFullName) {
 783                try {
 784                    #  Get executable metadata
 785                    $VersionInfo = (Get-Item -Path $ExecutableFullName).VersionInfo
 786                    $MetaString = "$($VersionInfo.ProductName) | $($VersionInfo.CompanyName) | $($VersionInfo.FileDescription)"
 787                    Write-Log -Severity Debug -Message "EXE Metadata: $MetaString"
 788                    #  Loop through metadata rules to find a match and exit immediately if found
 789                    foreach ($MetadataRule in $MetadataRules) {
 790                        if ($MetaString -like $MetadataRule.Pattern) {
 791                            $SilentArgs = $MetadataRule.SilentArgs
 792                            $DetectedType = $MetadataRule.Type
 793                            break
 794                        }
 795                    }
 796
 797                    if (-not [string]::IsNullOrWhiteSpace($SilentArgs)) {
 798                        Write-Log -Message "Detected via metadata [$DetectedType]: [$SilentArgs]"
 799                    }
 800                }
 801                catch {
 802                    Write-Log -Severity Warning -Message "Failed to read EXE metadata: $($PSItem.Exception.Message)"
 803                }
 804            }
 805
 806            ## Fallback to filename-based detection if metadata detection failed (silent args are still empty)
 807            if ([string]::IsNullOrWhiteSpace($SilentArgs)) {
 808
 809                #  Loop through filename rules to find a match and exit immediately if found
 810                foreach ($FilenameRule in $FilenameRules) {
 811                    if ($ExecutableName -match $FilenameRule.Pattern) {
 812                        $SilentArgs = $FilenameRule.SilentArgs
 813                        $DetectedType = $FilenameRule.Type
 814                        break
 815                    }
 816                }
 817
 818                ## Default fallback if nothing matched
 819                if ([string]::IsNullOrWhiteSpace($SilentArgs)) {
 820                    $SilentArgs = '/S'
 821                    $DetectedType = 'Unknown'
 822                }
 823                Write-Log -Message "Detected via fallback [$DetectedType]: [$SilentArgs]"
 824            }
 825
 826            ## Construct final argument list, make sure to remove any leading/trailing/internal extra spaces
 827            [string]$MergedArguments = if ([string]::IsNullOrWhiteSpace($SilentArgs)) {
 828                $Arguments
 829            }
 830            else {
 831                [string]::Join(' ', @(($SilentArgs, ($Arguments -split '\s+' -join ' ')) | Where-Object { $PSItem }))
 832            }
 833
 834            ## Construct final uninstall command
 835            [string]$UninstallString = [string]::Join(' ', @($ExecutableFullName, $MergedArguments))
 836            Write-Log -Severity Debug -Message "Detected silent uninstall string: [$UninstallString]"
 837        }
 838        catch {
 839            Write-Log -Severity Error -Message "Failed to process uninstall string: $($PSItem.Exception.Message)"
 840        }
 841        finally {
 842            Write-Output -InputObject $UninstallString
 843        }
 844    }
 845}
 846#endregion
 847
 848#region function Get-DefaultBrowserExecutable
 849function Get-DefaultBrowser {
 850<#
 851.SYNOPSIS
 852    Gets the executable name of the system's default web browser.
 853.DESCRIPTION
 854    Resolves the executable name (e.g., chrome, msedge) of the currently configured default browser
 855    by querying the user's URL protocol association and resolving the open command via the registry.
 856.EXAMPLE
 857    Get-DefaultBrowserExecutable
 858.INPUTS
 859    None.
 860.OUTPUTS
 861    System.String
 862.NOTES
 863    This is an internal script function and should typically not be called directly.
 864.LINK
 865    https://MEM.Zone
 866.LINK
 867    https://MEM.Zone/GIT
 868.LINK
 869    https://MEM.Zone/ISSUES
 870#>
 871    [CmdletBinding()]
 872    [OutputType([string])]
 873    param ()
 874
 875    process {
 876        try {
 877
 878            ## Get the ProgID for the default HTTP handler
 879            $ProgId = Get-ItemPropertyValue -Path 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name 'ProgId'
 880
 881            ## Open the open\command subkey from HKEY_CLASSES_ROOT using .NET
 882            $KeyPath = "$ProgId\shell\open\command"
 883            $RegKey = [Microsoft.Win32.Registry]::ClassesRoot.OpenSubKey($keyPath)
 884
 885            if (-not $RegKey) {
 886                Write-Log -Severity Debug -Message "Registry key not found for ProgID: [$ProgId]"
 887                return $null
 888            }
 889
 890            ## Get the key value
 891            $Command = $RegKey.GetValue('')
 892            $RegKey.Close()
 893
 894            ## Extract executable name from command string
 895            if ($Command -match '"?([^"\s]+\.exe)"?') {
 896                $ExeName = [System.IO.Path]::GetFileNameWithoutExtension($Matches[1])
 897            }
 898            else {
 899                Write-Log -Severity Debug -Message "Failed to parse default browser command: [$Command]"
 900                return $null
 901            }
 902
 903            Write-Log -Severity Debug -Message "Default browser executable detected: [$ExeName]"
 904            return $ExeName
 905        }
 906        catch {
 907            Write-Log -Severity Debug -Message "Failed to detect default browser executable. $($PSItem.Exception.Message)"
 908            return $null
 909        }
 910    }
 911}
 912#endregion
 913
 914#region function Test-MsiExecuteMutex
 915function Test-MsiExecuteMutex {
 916<#
 917.SYNOPSIS
 918    Tests if the Windows Installer is currently in use.
 919.DESCRIPTION
 920    Tests if the Windows Installer is currently in use by checking for the MSI Mutex.
 921.EXAMPLE
 922    Test-MsiExecuteMutex
 923.INPUTS
 924    None.
 925.OUTPUTS
 926    System.Boolean
 927.NOTES
 928    This is an internal script function and should typically not be called directly.
 929.LINK
 930    https://MEM.Zone
 931.LINK
 932    https://MEM.Zone/GIT
 933.LINK
 934    https://MEM.Zone/ISSUES
 935#>
 936    [CmdletBinding()]
 937    [OutputType([bool])]
 938    param ()
 939
 940    process {
 941        try {
 942
 943            ## Check for the _MSIExecute mutex - this indicates if Windows Installer is in use
 944            $Mutex = [System.Threading.Mutex]::OpenExisting('Global\_MSIExecute')
 945            $Mutex.Dispose()
 946
 947            ## If we can open the mutex, it means Windows Installer is in use
 948            Write-Log -Severity Debug -Message 'MSI mutex present: [_MSIExecute]'
 949            return $true
 950        }
 951        catch {
 952
 953            ## If we can't open the mutex, it means Windows Installer is not in use
 954            Write-Log -Severity Debug 'MSI mutex not present: [_MSIExecute]'
 955            return $false
 956        }
 957    }
 958}
 959#endregion
 960
 961#region function Wait-ForMsiExecuteMutex
 962function  Wait-ForMsiExecuteMutex {
 963<#
 964.SYNOPSIS
 965    Tests if the Windows Installer is currently in use.
 966.DESCRIPTION
 967    Tests if the Windows Installer is currently in use by checking for the MSI Mutex.
 968.PARAMETER TimeoutSeconds
 969    Specifies the maximum time to wait for the Windows Installer to become available.
 970.PARAMETER IntervalSeconds
 971    Specifies the interval time to wait between checks.
 972.EXAMPLE
 973    Wait-ForMsiExecuteMutex -TimeoutSeconds 300 -IntervalSeconds 5
 974.INPUTS
 975    None.
 976.OUTPUTS
 977    System.Boolean
 978.NOTES
 979    This is an internal script function and should typically not be called directly.
 980.LINK
 981    https://MEM.Zone
 982.LINK
 983    https://MEM.Zone/GIT
 984.LINK
 985    https://MEM.Zone/ISSUES
 986#>
 987    [CmdletBinding()]
 988    [OutputType([bool])]
 989    param (
 990        [Parameter(Position = 0)]
 991        [int]$TimeoutSeconds = 120,
 992
 993        [Parameter(Position = 1)]
 994        [int]$IntervalSeconds = 5
 995    )
 996
 997    process {
 998        try {
 999
1000            $Elapsed = 0
1001            While (Test-MsiExecuteMutex) {
1002                if ($Elapsed -ge $TimeoutSeconds) {
1003                    Write-Log -Severity Error -Message "Timeout: Windows Installer is still unavailable after [$TimeoutSeconds`s]"
1004                    return $false
1005                }
1006                Write-Log -Message "Waiting: Windows Installer is unavailable, [$Elapsed`s] elapsed..."
1007                Start-Sleep -Seconds $IntervalSeconds
1008                $Elapsed += $IntervalSeconds
1009            }
1010
1011            Write-Log -Message 'Windows Installer is now available!s'
1012            return $true
1013        }
1014        catch {
1015            Write-Log -Severity Debug -Message "Windows Installer status check failed: $($PSItem.Exception.Message)"
1016            return $false
1017        }
1018    }
1019}
1020#endregion
1021
1022#region function Lock-Mutex
1023function Lock-Mutex {
1024<#
1025.SYNOPSIS
1026    Locks a global mutex to prevent concurrent operations.
1027.DESCRIPTION
1028    Attempts to acquire a named global mutex with a timeout. Returns a result object
1029    indicating whether the lock was successfully acquired.
1030.PARAMETER Name
1031    The name of the mutex to lock.
1032.PARAMETER TimeoutSeconds
1033    Maximum number of seconds to wait for the mutex. Default is 600 seconds.
1034.PARAMETER ExitOnFailure
1035    If true (default), exits the script if the mutex cannot be acquired.
1036.PARAMETER Global
1037    If true, the mutex is created as a global mutex. Default is false.
1038.EXAMPLE
1039    Lock-Mutex -Name 'MyMutex' -TimeoutSeconds 300 -Global -ExitOnFailure
1040.INPUTS
1041    None.
1042.OUTPUTS
1043    PSCustomObject with:
1044        - Success:      [System.Boolean]
1045        - Mutex:        [System.Threading.Mutex]
1046        - Name:         [System.String]
1047        - ErrorMessage: [System.String]
1048.NOTES
1049    This is an internal script function and should typically not be called directly.
1050#>
1051    [CmdletBinding()]
1052    param (
1053        [Parameter(Mandatory = $true, Position = 0)]
1054        [string]$Name,
1055
1056        [Parameter(Position = 1)]
1057        [int]$TimeoutSeconds = 600,
1058
1059        [Parameter()]
1060        [switch]$ExitOnFailure,
1061
1062        [Parameter()]
1063        [switch]$Global
1064    )
1065
1066    begin {
1067
1068        ## Set mutex name to global if specified
1069        if ($PSBoundParameters['Global']) { $Name = "Global\$Name" }
1070
1071        ## Initialize result object early
1072        $Result = [PSCustomObject]@{
1073            Success      = $false
1074            Mutex        = $null
1075            Name         = $Name
1076            ErrorMessage = ''
1077        }
1078
1079        ## Check if the mutex name is valid
1080        if ($Name -notmatch '^[\w\\-]+$') {
1081            $Result.ErrorMessage = "Invalid mutex name [$Name]: Only letters, numbers, underscores, and hyphens allowed"
1082            Write-Log -Severity Error -Message $Result.ErrorMessage
1083            return $Result
1084        }
1085
1086        ## Check if the mutex name is too long
1087        if ($Name.Length -gt 260) {
1088            $Result.ErrorMessage = "Mutex name [$Name] exceeds maximum length of 260 characters"
1089            Write-Log -Severity Error -Message $Result.ErrorMessage
1090            return $Result
1091        }
1092    }
1093
1094    process {
1095        try {
1096            Write-Log -Severity Debug -Message "Acquiring mutex: [$Name]..."
1097            $Mutex = [System.Threading.Mutex]::new($false, $Name)
1098            if ($Mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds), $false)) {
1099
1100                #  Set the result object
1101                $Result.Success = $true
1102                $Result.Mutex = $Mutex
1103
1104                #  Log the successful acquisition of the mutex
1105                $Message = "Mutex acquired: [$Name]"
1106                Write-Log -Severity Debug -Message $Message
1107            }
1108            else {
1109
1110                ## If the mutex acquisition timed out, set and log the error message
1111                $Message = "Timeout: Waited [$TimeoutSeconds`s] for mutex [$Name]"
1112                $Result.ErrorMessage = $Message
1113                Write-Log -Severity Warning -Message $Message
1114            }
1115        }
1116        catch {
1117            $Result.ErrorMessage = $PSItem.Exception.Message
1118            Write-Log -Severity Error -Message "Mutex acquisition failed for [$Name]: $($PSItem.Exception.Message)"
1119        }
1120        finally {
1121
1122            ## Output the result object
1123            Write-Output -InputObject $Result
1124
1125            ## If the lock failed and ExitOnFailure is set, exit here
1126            if (-not $Result.Success -and $ExitOnFailure) {
1127                Write-Log -Message -Severity Error "Exit triggered by mutex conflict [$Name]: Already held by another process"
1128
1129                ## Exit the script with a non-zero (failure) exit code
1130                exit 1
1131            }
1132        }
1133    }
1134}
1135#endregion
1136
1137#region function Unlock-Mutex
1138function Unlock-Mutex {
1139<#
1140.SYNOPSIS
1141    Releases and disposes a mutex.
1142.DESCRIPTION
1143    Releases and disposes a mutex, allowing other processes to acquire it.
1144.PARAMETER Mutex
1145    The System.Threading.Mutex object to release.
1146.PARAMETER Name
1147    The name of the mutex to release.
1148.EXAMPLE
1149    Unlock-Mutex -Mutex $MyMutex
1150.INPUTS
1151    None.
1152.OUTPUTS
1153    None.
1154.NOTES
1155    This is an internal script function and should typically not be called directly.
1156.LINK
1157    https://MEM.Zone
1158.LINK
1159    https://MEM.Zone/GIT
1160.LINK
1161    https://MEM.Zone/ISSUES
1162#>
1163    [CmdletBinding()]
1164    param (
1165        [Parameter(Mandatory = $true)]
1166        [System.Threading.Mutex]$Mutex,
1167
1168        [Parameter()]
1169        [string]$Name = '<Undefined>'
1170    )
1171
1172    try {
1173        Write-Log -Severity Debug -Message "Releasing mutex: [$Name]"
1174        $Mutex.ReleaseMutex()
1175        Write-Log -Severity Debug -Message "Successfully released mutex: [$Name]"
1176    }
1177    catch {
1178        Write-Log -Severity Error -Message "Failed to release mutex [$Name]: $($PSItem.Exception.Message)"
1179    }
1180    finally {
1181        $Mutex.Dispose()
1182    }
1183}
1184#endregion
1185
1186#region function Start-ProcessWithTimeout
1187function Start-ProcessWithTimeout {
1188<#
1189.SYNOPSIS
1190    Starts a process with timeout handling.
1191.DESCRIPTION
1192    Starts a process with the specified executable and arguments, and waits for it to complete with a timeout.
1193.PARAMETER FilePath
1194    Specifies the path to the executable file.
1195.PARAMETER Arguments
1196    Specifies the arguments to pass to the executable.
1197.PARAMETER TimeoutSeconds
1198    Specifies the timeout in seconds.
1199.EXAMPLE
1200    Start-ProcessWithTimeout -FilePath 'msiexec.exe' -Arguments '/x {ProductCode} /qn' -TimeoutSeconds 600
1201.INPUTS
1202    None.
1203.OUTPUTS
1204    System.Int32
1205        -1 = Timed out, process killed
1206        -2 = Failed to kill process
1207        -3 = Failed to start process
1208         0 = Normal process exit code
1209.NOTES
1210    This is an internal script function and should typically not be called directly.
1211.LINK
1212    https://MEM.Zone
1213.LINK
1214    https://MEM.Zone/GIT
1215.LINK
1216    https://MEM.Zone/ISSUES
1217#>
1218    [CmdletBinding()]
1219    [OutputType([int])]
1220    param (
1221        [Parameter(Mandatory = $true, Position = 0)]
1222        [Alias ('Path')]
1223        [ValidateNotNullOrEmpty()]
1224        [string]$FilePath,
1225
1226        [Parameter(Position = 1)]
1227        [string]$Arguments = '',
1228
1229        [Parameter(Position = 2)]
1230        [ValidateRange(1, 3600)]
1231        [int]$TimeoutSeconds = 600
1232    )
1233
1234    process {
1235        try {
1236
1237            ## log the start of the process
1238            Write-Log -Severity Debug -Message "Starting [$FilePath $Arguments] (Timeout: [$TimeoutSeconds`s])"
1239
1240            ## Get default browser and check if it's running
1241            $DefaultBrowser = Get-DefaultBrowser
1242            $IsBrowserRunning = [bool]$(Get-Process -Name $DefaultBrowser -Verbose:$false -ErrorAction SilentlyContinue)
1243
1244            ## Create process start info
1245            $ProcessStartInfo = [System.Diagnostics.ProcessStartInfo]::new()
1246            $ProcessStartInfo.FileName = $FilePath
1247            $ProcessStartInfo.Arguments = $Arguments
1248            $ProcessStartInfo.UseShellExecute = $false
1249            $ProcessStartInfo.CreateNoWindow = $true
1250
1251            ## Start the process
1252            $Process = [System.Diagnostics.Process]::Start($ProcessStartInfo)
1253
1254            if (-not $Process) {
1255                Write-Log -Severity Error -Message "Failed to start process: [$FilePath]"
1256                return -3
1257            }
1258
1259            #  Wait briefly for UI to initialize
1260            Start-Sleep -Seconds 1
1261
1262            #  Check if the process is running interactively
1263            if ($Process.MainWindowHandle -ne 0) { Write-Log -Severity Warning -Message 'Uninstall may run interactive' } else { Write-Log -Message 'Uninstall is running silent' }
1264
1265            #  Wait for the process to exit with timeout
1266            $ExitedNormally = $Process.WaitForExit($TimeoutSeconds * 1000)
1267
1268            ## Detect and kill browser if it launched during execution
1269            if (-not $IsBrowserRunning) {
1270                $HasBrowserStarted = [bool]$(Get-Process -Name $DefaultBrowser -Verbose:$false -ErrorAction SilentlyContinue)
1271                if ($HasBrowserStarted) {
1272                    Write-Log -Severity Warning -Message "Terminating post process [$DefaultBrowser]..."
1273                    Stop-Process -Name $DefaultBrowser -Force -Verbose:$false -ErrorAction SilentlyContinue
1274                }
1275            }
1276
1277            ## Kill any child processes matching post*
1278            $PostProcesses = Get-Process | Where-Object { ($PSItem.Parent -eq $Process.Id -and $PSItem.ProcessName -like 'post*') -or $PSItem.ProcessName -match $Script:PostProcessDetectionRules }
1279            $PostProcesses | ForEach-Object {
1280                Write-Log -Severity Warning -Message "Terminating post process [$($PSItem.ProcessName)]..."
1281                Stop-Process -Id $PSItem.Id -Force -Verbose:$false -ErrorAction SilentlyContinue
1282            }
1283
1284            if (-not $ExitedNormally) {
1285                try {
1286                    $Process.Kill()
1287                    Write-Log -Severity Warning -Message "Terminated [$FilePath]: timeout after [$TimeoutSeconds`s]"
1288                    return -1
1289                }
1290                catch {
1291                    if ($Process.HasExited) {
1292                        Write-Log -Severity Warning -Message "Process [$FilePath] exited just before kill attempt (Exit Code: $($Process.ExitCode))"
1293                        return $Process.ExitCode
1294                    }
1295                    else {
1296                        Write-Log -Severity Error -Message "Failed to terminate [$FilePath]: $($PSItem.Exception.Message)"
1297                        return -2
1298                    }
1299                }
1300            }
1301            else {
1302                Write-Log -Severity Debug -Message "Process [$FilePath] exited normally (Exit Code: $($Process.ExitCode))"
1303                return $Process.ExitCode
1304            }
1305        }
1306        catch {
1307            Write-Log -Severity Error -Message "Error starting [$FilePath]: $($PSItem.Exception.Message)"
1308            return -3
1309        }
1310    }
1311}
1312#endregion
1313
1314#region function Remove-Application
1315function Remove-Application {
1316<#
1317.SYNOPSIS
1318    Uninstalls an application.
1319.DESCRIPTION
1320    Uninstalls an application using the appropriate method (MSI or EXE).
1321    Uses a mutex to prevent concurrent uninstallation operations.
1322.PARAMETER Application
1323    Specifies the application to uninstall.
1324.EXAMPLE
1325    Remove-Application -Application $Application
1326.INPUTS
1327    None.
1328.OUTPUTS
1329   System.PSCustomObject
1330.NOTES
1331    This is an internal script function and should typically not be called directly.
1332.LINK
1333    https://MEM.Zone
1334.LINK
1335    https://MEM.Zone/GIT
1336.LINK
1337    https://MEM.Zone/ISSUES
1338#>
1339    [CmdletBinding()]
1340    [OutputType([PSCustomObject])]
1341    param (
1342        [Parameter(Mandatory = $true, Position = 0)]
1343        [ValidateNotNullorEmpty()]
1344        [Alias('App')]
1345        [PSCustomObject]$Application
1346    )
1347
1348    begin {
1349        [string]$ApplicationName      = $Application.DisplayName
1350        [string]$ApplicationVersion   = $Application.DisplayVersion
1351        [string]$UninstallString      = $Application.UninstallString
1352        [string]$QuietUninstallString = $Application.QuietUninstallString
1353        [string]$InstallerType        = $Application.InstallerType
1354        [bool]$IsQuietUninstallString = -not [string]::IsNullOrEmpty($QuietUninstallString)
1355        [int]$TimeoutSeconds          = 600
1356
1357        ## Create result object
1358        [PSCustomObject]$Result = [PSCustomObject]@{
1359            Success            = $false
1360            ApplicationName    = $ApplicationName
1361            ApplicationVersion = $ApplicationVersion
1362            ExitCode           = $null
1363            ErrorMessage       = ''
1364        }
1365
1366        ##  Log the start of the uninstallation
1367        Write-Log -Message "==> $ApplicationName v$ApplicationVersion [$InstallerType]" -FormatOptions @{ AddEmptyRow = 'Before' }
1368
1369        ## Set the mutex name
1370        [string]$MutexName = $Script:Name + '_UninstallTask'
1371    }
1372    process {
1373        try {
1374
1375            ## Wait 10 minutes for windows installer to be free before uninstalling
1376            if (-not (Wait-ForMsiExecuteMutex -TimeoutSeconds 120 -IntervalSeconds 5)) {
1377                return
1378            }
1379
1380            ## Try to acquire the mutex to ensure only one uninstall process runs at a time
1381            $UninstallMutex = Lock-Mutex -Name $MutexName -TimeoutSeconds 600 -Global
1382
1383            ## If mutex acquisition failed, skip the uninstallation
1384            if (-not $UninstallMutex.Success) {
1385                Write-Log -Message -Severity Warning 'Mutex acquisition failed, skipping uninstallation...'
1386                $Result.ExitCode = $UninstallMutex.ErrorMessage
1387                return
1388            }
1389
1390            ## Check if the application is already uninstalled, might happen if the main application uninstall process already removed it (e.g. Firefox uninstaller also removing Firefox Helper Service)
1391            $IsInstalled = [boolean](Get-Application -SearchPatterns $ApplicationName -Verbose:$false)
1392            if (-not $IsInstalled) {
1393                Write-Log -Message "Application [$ApplicationName] is already uninstalled, skipping uninstallation..."
1394                $Result.Success = $true
1395                return
1396            }
1397
1398            ## Check if it's an MSI uninstall
1399            if ($InstallerType -eq 'MSI') {
1400
1401                ## Extract product code from uninstall string
1402                if ($UninstallString -match '{[0-9A-Fa-f\-]{36}}') {
1403                    [string]$ProductCode = $Matches[0]
1404                    Write-Log -Severity Debug -Message "Detected MSI product code: [$ProductCode]"
1405
1406                    ## Start the uninstall process
1407                    Write-Log -Message 'Starting uninstall (EXE)...'
1408
1409                    #  Set up the executable and arguments
1410                    [string]$FilePath = 'msiexec.exe'
1411                    [string]$MsiLogFullName = Join-Path -Path $Script:LogPath -ChildPath $($ApplicationName + '_Uninstall.log')
1412                    [string]$Arguments = "/x $ProductCode /qn /norestart /L*v `"$MsiLogFullName`""
1413                    [int]$ExitCode = Start-ProcessWithTimeout -FilePath $FilePath -Arguments $Arguments -TimeoutSeconds $TimeoutSeconds
1414                    $Result.ExitCode = $ExitCode
1415
1416                    ## Check the exit code and handle accordingly
1417                    switch ($ExitCode) {
1418
1419                        { $PSItem -in @(0, 3010, 19) } {
1420
1421                            #  Exit code 19: Possibly non-fatal condition (e.g., partial uninstall or app still running)
1422                            Write-Log -Message "SUCCESSFUL (Exit Code: $ExitCode)"
1423                            $Result.Success = $true
1424                            break
1425                        }
1426
1427                        1618 {
1428                            $Result.ErrorMessage = 'FAILED - Another install in progress (Exit Code: $ExitCode)'
1429                            Write-Log -Severity Warning -Message $Result.ErrorMessage
1430                            break
1431                        }
1432
1433                        -1 {
1434                            $Result.ErrorMessage = "FAILED - Uninstall timed out (Exit Code: $ExitCode)"
1435                            Write-Log -Severity Warning -Message $Result.ErrorMessage
1436                            break
1437                        }
1438
1439                        default {
1440                            $Result.ErrorMessage = "FAILED - Failed to uninstall MSI application (Exit Code: $ExitCode)"
1441                            Write-Log -Severity Warning -Message $Result.ErrorMessage
1442                        }
1443                    }
1444                }
1445                else {
1446                    $Result.ErrorMessage = "FAILED - Could not extract MSI product code (Uninstall string: $UninstallString)"
1447                    Write-Log -Severity Warning -Message $Result.ErrorMessage
1448                }
1449            }
1450
1451            ## Check if it's an EXE uninstall
1452            if ($InstallerType -eq 'EXE') {
1453
1454                ## Use quiet uninstall string if available
1455                [string]$EffectiveUninstallString = if ($IsQuietUninstallString) {
1456                    Write-Log -Message 'Detected silent uninstall string (Registry)'
1457                    $QuietUninstallString
1458                }
1459                else {
1460                    Write-Log -Message 'Missing silent uninstall string (Registry)'
1461                    $UninstallString
1462                }
1463
1464                ## Always disassemble the uninstall string to get the executable and arguments, will return 'HasSilentArgs = $false' if only '/SILENT' is present
1465                $UninstallParameters = Get-InstallerCommand -CommandString $EffectiveUninstallString
1466
1467                ## If it lacks silent args, try to get a silent command
1468                if (-not $UninstallParameters.HasSilentArgs) {
1469
1470                    ## If the uninstall string is not fully silent log a warning
1471                    If ($UninstallParameters.SilentArgs.Count -gt 0) {
1472                        Write-Log -Severity Warning -Message "Uninstall string is not fully silent: [$($UninstallParameters.SilentArgs)]"
1473                    }
1474
1475                    ## Get the silent uninstall command
1476                    $SilentParams = Get-SilentUninstallCommand -UninstallString $EffectiveUninstallString
1477                    $UninstallParameters.Arguments = $SilentParams
1478                }
1479
1480                ## Start the uninstall process
1481                Write-Log -Message 'Starting uninstall (MSI)...'
1482
1483                #  Set up the executable and arguments
1484                [string]$FilePath = $UninstallParameters.FullName
1485                [string]$Arguments = $UninstallParameters.Arguments
1486
1487                #  Execute the uninstall command
1488                [int]$ExitCode = Start-ProcessWithTimeout -FilePath $FilePath -Arguments $Arguments -TimeoutSeconds $TimeoutSeconds
1489                $Result.ExitCode = $ExitCode
1490
1491                ## Check the exit code and handle accordingly
1492                switch ($ExitCode) {
1493                    { $PSItem -in 0, 3010, 19 } {
1494
1495                        #  Exit code 19: Possibly non-fatal condition (e.g., partial uninstall or app still running)
1496                        Write-Log -Message "SUCCESSFUL (Exit Code: $ExitCode)"
1497                        $Result.Success = $true
1498                        break
1499                    }
1500
1501                    -1 {
1502                        $Result.ErrorMessage = "FAILED - Uninstall timed out (Exit Code: $ExitCode)"
1503                        Write-Log -Severity Warning -Message $Result.ErrorMessage
1504                        break
1505                    }
1506
1507                    default {
1508                        $Result.ErrorMessage = "FAILED - Failed to uninstall EXE application (Exit Code: $ExitCode))"
1509                        Write-Log -Severity Warning -Message $Result.ErrorMessage
1510                    }
1511                }
1512            }
1513        }
1514        catch {
1515            $Result.ErrorMessage = "FAILED - Failed to uninstall EXE application: $($PSItem.Exception.Message)"
1516            Write-Log -Severity Warning -Message $Result.ErrorMessage
1517        }
1518        finally {
1519
1520            ## Release the mutex if we acquired it and the uninstallation was successful
1521            Unlock-Mutex -Mutex $UninstallMutex.Mutex -Name $UninstallMutex.Name
1522
1523            ## Return result
1524            Write-Output -InputObject $Result
1525        }
1526    }
1527}
1528#endregion
1529
1530#endregion
1531##*=============================================
1532##* END FUNCTION LISTINGS
1533##*=============================================
1534
1535##*=============================================
1536##* SCRIPT BODY
1537##*=============================================
1538#region ScriptBody
1539
1540## Check if log path exists or if the log file exceeds size limit
1541Test-LogFile -LogFile $Script:LogFullName -MaxSizeMB $Script:LogMaxSizeMB
1542
1543## Check if another instance of the script is already running and exit gracefully if so
1544[string]$MutexName = $Script:Name + '_Script'
1545$ScriptMutex = Lock-Mutex -Name $MutexName -TimeoutSeconds 0 -Global
1546if (-not $ScriptMutex.Success) {
1547    Write-Log -Severity Debug -Message 'Another instance of the script is already running. Exiting...' -FormatOptions { Mode = 'Default' }
1548    exit 0
1549}
1550
1551## Write initial log entries
1552Write-Log -Message "$Script:NameAndVersion Started" -FormatOptions @{ Mode = 'CenteredBlock'; AddEmptyRow = 'After' }
1553Write-Log -Message 'ENVIRONMENT' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'After' }
1554Write-Log -Message "Running elevated: [$Script:RunningAsAdmin]" -FormatOptions @{ Mode = 'Default' }
1555Write-Log -Message "Running as:       [$Script:RunningAs]" -FormatOptions @{ Mode = 'Default' }
1556Write-Log -Message "Script path:      [$Script:Path]" -FormatOptions @{ Mode = 'Default' }
1557Write-Log -Message "Log path:         [$Script:LogPath]" -FormatOptions @{ Mode = 'Default' }
1558Write-Log -Message 'DISCOVERY' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'BeforeAndAfter' }
1559
1560## Check for administrative privileges
1561if (-not $Script:RunningAsAdmin) { Write-Log -Severity Warning-Message 'Script is not running with administrative privileges. Uninstallation may fail!' -FormatOptions @{ Mode = 'AddSpace'; AddEmptyRow = 'After' } }
1562
1563try {
1564
1565    ## Initialize variables
1566    [int]$SuccessCount = 0
1567    [int]$FailureCount = 0
1568    $UninstallResults = [System.Collections.ArrayList]::new()
1569
1570    ## Get matching applications
1571    $InstalledApplications = Get-Application -SearchPatterns $SearchPatterns
1572
1573    ## Loop through each installed application and uninstall it
1574    Write-Log -Message 'UNINSTALL' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'Before' }
1575
1576    #  If no applications were found, log a message
1577    if ($InstalledApplications.Count -eq 0) { Write-Log -Message 'No applications to uninstall.' -FormatOptions @{ AddEmptyRow = 'Before' } }
1578    foreach ($Application in $InstalledApplications) {
1579        $UninstallResult = Remove-Application -Application $Application
1580        $null = $UninstallResults.Add($UninstallResult)
1581
1582        #  Increment success or failure count based on the result
1583        if ($UninstallResult.Success) { $SuccessCount++ } else { $FailureCount++ }
1584
1585        ## Add a small delay between uninstalls to prevent resource contention
1586        Start-Sleep -Seconds 2
1587    }
1588
1589    ## Detailed results
1590    Write-Log -Message 'RESULTS' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'BeforeAndAfter' }
1591
1592    #  If no applications were uninstalled, log a message
1593    if ($UninstallResults.ApplicationName.Count -eq 0) { Write-Log -Message 'No applications were uninstalled.' }
1594
1595    #  Calculate maximum widths for padding
1596    [int]$MaxNameLength = ($UninstallResults | ForEach-Object { $PSItem.ApplicationName.Length } | Measure-Object -Maximum).Maximum
1597    [int]$MaxVersionLength = ($UninstallResults | ForEach-Object { $PSItem.ApplicationVersion.Length } | Measure-Object -Maximum).Maximum
1598
1599    #  Pad the application names and versions for better readability
1600    foreach ($UninstallResult in $UninstallResults) {
1601        [string]$ApplicationName = $UninstallResult.ApplicationName
1602        [string]$ApplicationVersion = $UninstallResult.ApplicationVersion
1603
1604        #  Set the status based on the success of the uninstallation
1605        [string]$Status = if ($UninstallResult.Success) { '[SUCCESSFUL]' } else { '[FAILED]' }
1606
1607        $ApplicationName = $ApplicationName.PadRight($MaxNameLength)
1608        $ApplicationVersion = $ApplicationVersion.PadRight($MaxVersionLength)
1609
1610        #  Log the results
1611        Write-Log -Message "$ApplicationName | Version: $ApplicationVersion | Status: $Status"
1612    }
1613
1614    ## Output summary
1615    [string]$InstalledApplicationsCount = $InstalledApplications.DisplayName.Count
1616    Write-Log -Message 'SUMMARY' -FormatOptions @{ Mode = 'InlineHeader'; AddEmptyRow = 'BeforeAndAfter' }
1617    Write-Log -Message "Successfully uninstalled:     [$SuccessCount]" -FormatOptions @{ Mode = 'Default' }
1618    Write-Log -Message "Failed to uninstall:          [$FailureCount]" -FormatOptions @{ Mode = 'Default' }
1619    Write-Log -Message "Total applications processed: [$InstalledApplicationsCount]" -FormatOptions @{ Mode = 'Default'; AddEmptyRow = 'After' }
1620}
1621catch {
1622
1623    ## If the script fails for any reason, return a clear failure message and assume non-compliance
1624    [string]$ComplianceState = "Compliance state: [NonCompliant] - Script Failed: [$($PSItem.Exception.Message)]"
1625    Write-Log -Severity Error -Message $ComplianceState -FormatOptions @{ AddEmptyRow = 'BeforeAndAfter' }
1626
1627    ## Exit with non-zero code to indicate failure
1628    exit 1
1629}
1630finally {
1631
1632    ## Release the mutex if it was created
1633    Unlock-Mutex -Mutex $ScriptMutex.Mutex -Name $ScriptMutex.Name
1634
1635    ## Make sure to flush any buffered log entries
1636    Write-LogBuffer
1637
1638    ## End logging
1639    Write-Log -Message "$Script:NameAndVersion Completed" -FormatOptions @{ Mode = 'CenteredBlock'; AddEmptyRow = 'Before' }
1640
1641    ## Ensure final flush of log buffer
1642    Write-LogBuffer
1643
1644    ## Exit with appropriate code based on uninstallation results
1645    if ($FailureCount -gt 0) { exit 2 } else { exit 0 }
1646}
1647
1648#endregion
1649##*=============================================
1650##* END SCRIPT BODY
1651##*=============================================

👍 Liked this? Share it with your team and consider subscribing and following—it helps us keep the good stuff coming. 😎

SHARE

article card image dark article card image light

Published by · Jun 3, 2024 tools · 2 mins read

Introducing: Windows User Rights Assignment Tool - Part 3

Add, Remove, or Replace Windows Rights Assignment with our PowerShell Tool. ...

See More
article card image dark article card image light

Published by · May 28, 2024 tools · 2 mins read

Introducing: Windows User Rights Assignment Tool - Part 2

Get and Report Windows Rights Assignment with our PowerShell Tool. ...

See More