Загрузка данных


#requires -version 5.1

<#
.SYNOPSIS
    Удаление лишних клиентов MTS Link через SCCM.

.DESCRIPTION
    ОСТАВЛЯЕТ:
      - MTS Link Meetings 1.6.0.0
      - MTSLinkMeetings.InstallerMSI 1.6.0.0
        (внутренний MSI-компонент Meetings 1.6.0.0)

    УДАЛЯЕТ:
      - старые MTS Link Meetings
      - MTS Link Outlook Plugin
      - МТС Линк
      - MTS Link
      - MTSLink...
      - пользовательские установки MTS Link

    Особенности:
      - НИЧЕГО НЕ УСТАНАВЛИВАЕТ.
      - НИКОГДА не вызывает Restart-Computer / shutdown / reboot.
      - MSI удаляются с:
            /qn /norestart REBOOT=ReallySuppress
      - код MSI 3010 считается успехом, но наружу SCCM не возвращается.
      - код 1641 НЕ считается нормальным.
      - пользовательские /currentuser uninstall запускаются
        в контексте соответствующего залогиненного пользователя.
      - Win32_Product НЕ используется.
      - после удаления выполняется одна итоговая проверка.

.RECOMMENDED
    SCCM:
      Run whether or not a user is logged on
      Run with administrative rights
      Prefer 64-bit PowerShell

.EXITCODES
      0  = успешно, лишние MTS Link удалены
      10 = после удаления остались лишние MTS Link
      20 = пропал защищённый Meetings 1.6.0.0 / его компонент
      30 = какой-либо uninstall вернул 1641 (restart initiated)
#>

[CmdletBinding()]
param()

$ErrorActionPreference = 'Continue'


# =====================================================================
# CONFIG
# =====================================================================

$OutputDirectory = 'C:\ProgramData\MTSLink-Remediation'

$KeepMeetingsName    = 'MTS Link Meetings'
$KeepMeetingsVersion = '1.6.0.0'

# Внутренний MSI сохранённой версии Meetings.
$KeepInternalName    = 'MTSLinkMeetings.InstallerMSI'
$KeepInternalVersion = '1.6.0.0'

# Небольшая пауза перед финальной проверкой реестра.
$VerificationDelaySeconds = 5


# =====================================================================
# NATIVE SYSTEM32
#
# Если SCCM случайно запустил 32-bit PowerShell на 64-bit Windows,
# используем Sysnative для системных утилит.
# =====================================================================

if ($env:PROCESSOR_ARCHITEW6432) {

    $NativeSystemDirectory =
        Join-Path $env:SystemRoot 'Sysnative'
}
else {

    $NativeSystemDirectory =
        Join-Path $env:SystemRoot 'System32'
}

$NativeMsiexec =
    Join-Path $NativeSystemDirectory 'msiexec.exe'

$NativeRegExe =
    Join-Path $NativeSystemDirectory 'reg.exe'


# Для Scheduled Task, запускаемой самим Windows Task Scheduler,
# используем обычный System32.
$TaskMsiexec =
    Join-Path $env:SystemRoot 'System32\msiexec.exe'


# =====================================================================
# OUTPUT / LOG
# =====================================================================

try {

    [System.IO.Directory]::CreateDirectory(
        $OutputDirectory
    ) | Out-Null
}
catch {

    Write-Output "FATAL: cannot create $OutputDirectory"
    Write-Output $_.Exception.Message

    exit 1
}


$Timestamp =
    Get-Date -Format 'yyyyMMdd_HHmmss'


$LogPath =
    Join-Path `
        $OutputDirectory `
        "MTSLink_Remediation_$Timestamp.log"


$InitialCsvPath =
    Join-Path `
        $OutputDirectory `
        "MTSLink_Before_$Timestamp.csv"


$FinalCsvPath =
    Join-Path `
        $OutputDirectory `
        "MTSLink_After_$Timestamp.csv"


$script:RebootRequiredReported = $false
$script:RestartInitiatedCode   = $false


function Write-Log {

    param(
        [string]$Message,

        [ValidateSet(
            'INFO',
            'WARNING',
            'ERROR',
            'SUCCESS'
        )]
        [string]$Level = 'INFO'
    )


    $line =
        '{0} [{1}] {2}' -f `
        (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),
        $Level,
        $Message


    try {

        Add-Content `
            -LiteralPath $LogPath `
            -Value $line `
            -Encoding UTF8 `
            -ErrorAction SilentlyContinue
    }
    catch {
    }


    Write-Host $line
}


Write-Log '============================================================'
Write-Log 'MTS Link cleanup started'
Write-Log "Computer: $env:COMPUTERNAME"
Write-Log (
    'Running as: ' +
    [Security.Principal.WindowsIdentity]::GetCurrent().Name
)
Write-Log 'Installation of new software: DISABLED / NOT PRESENT'
Write-Log 'Automatic OS reboot: DISABLED'
Write-Log '============================================================'


# =====================================================================
# REGISTRY HELPERS
# =====================================================================

function Get-RegValue {

    param(
        [Microsoft.Win32.RegistryKey]$Key,

        [string]$Name
    )


    try {

        return $Key.GetValue(
            $Name,
            $null,
            [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
        )
    }
    catch {

        return $null
    }
}


function Get-MsiGuid {

    param(
        [string]$KeyName,

        [string]$CommandText
    )


    if (
        $KeyName -match
        '^\{[0-9A-Fa-f-]{36}\}$'
    ) {

        return $KeyName
    }


    if (
        $CommandText -match
        '\{[0-9A-Fa-f-]{36}\}'
    ) {

        return $Matches[0]
    }


    return $null
}


# =====================================================================
# PRODUCT CLASSIFICATION
# =====================================================================

function Test-IsMtsProduct {

    param(
        [string]$DisplayName
    )


    if (
        [string]::IsNullOrWhiteSpace(
            $DisplayName
        )
    ) {

        return $false
    }


    # MTS Link...
    # MTSLink...
    # МТС Линк...
    if (
        $DisplayName -match
        '(?i)^(MTS\s*Link|МТС\s*Линк)'
    ) {

        return $true
    }


    return $false
}


function Test-IsProtectedProduct {

    param(
        $App
    )


    # ---------------------------------------------------------
    # Главный продукт Meetings 1.6.0.0
    # ---------------------------------------------------------

    if (
        $App.DisplayName -ieq
        $KeepMeetingsName -and

        $App.DisplayVersion -eq
        $KeepMeetingsVersion
    ) {

        return $true
    }


    # ---------------------------------------------------------
    # Его внутренний MSI.
    #
    # В нашем аудите:
    #
    # MTSLinkMeetings.InstallerMSI
    # 1.6.0.0
    # {F43F17EB-80C9-4471-B411-97CF3D88D05E}
    #
    # Удалять отдельно его НЕЛЬЗЯ.
    # ---------------------------------------------------------

    if (
        $App.DisplayName -ieq
        $KeepInternalName -and

        $App.DisplayVersion -eq
        $KeepInternalVersion
    ) {

        return $true
    }


    return $false
}


# =====================================================================
# REG.EXE
# =====================================================================

function Invoke-RegExe {

    param(
        [string]$Arguments
    )


    $result = [ordered]@{

        ExitCode = -1
        StdOut   = ''
        StdErr   = ''
    }


    try {

        $psi =
            New-Object System.Diagnostics.ProcessStartInfo


        $psi.FileName =
            $NativeRegExe

        $psi.Arguments =
            $Arguments

        $psi.UseShellExecute =
            $false

        $psi.CreateNoWindow =
            $true

        $psi.RedirectStandardOutput =
            $true

        $psi.RedirectStandardError =
            $true


        $process =
            New-Object System.Diagnostics.Process


        $process.StartInfo =
            $psi


        [void]$process.Start()


        $stdout =
            $process.StandardOutput.ReadToEnd()

        $stderr =
            $process.StandardError.ReadToEnd()


        $process.WaitForExit()


        $result.ExitCode =
            $process.ExitCode

        $result.StdOut =
            $stdout.Trim()

        $result.StdErr =
            $stderr.Trim()


        $process.Dispose()
    }
    catch {

        $result.StdErr =
            $_.Exception.Message
    }


    return [PSCustomObject]$result
}


# =====================================================================
# USER HIVES
# =====================================================================

function Test-HiveLoaded {

    param(
        [string]$SID
    )


    $base = $null
    $key  = $null


    try {

        $base =
            [Microsoft.Win32.RegistryKey]::OpenBaseKey(
                [Microsoft.Win32.RegistryHive]::Users,
                [Microsoft.Win32.RegistryView]::Registry64
            )


        $key =
            $base.OpenSubKey(
                $SID
            )


        return (
            $null -ne $key
        )
    }
    catch {

        return $false
    }
    finally {

        if ($key) {

            $key.Dispose()
        }


        if ($base) {

            $base.Dispose()
        }
    }
}


# =====================================================================
# USER PROFILES
# =====================================================================

function Get-RealUserProfiles {

    $profiles =
        New-Object System.Collections.Generic.List[object]


    $base = $null
    $list = $null


    try {

        $base =
            [Microsoft.Win32.RegistryKey]::OpenBaseKey(
                [Microsoft.Win32.RegistryHive]::LocalMachine,
                [Microsoft.Win32.RegistryView]::Registry64
            )


        $list =
            $base.OpenSubKey(
                'SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
            )


        if (-not $list) {

            Write-Log `
                'ProfileList registry key not found.' `
                'ERROR'

            return
        }


        foreach (
            $sid in $list.GetSubKeyNames()
        ) {

            # -------------------------------------------------
            # SYSTEM / Local Service / Network Service
            # -------------------------------------------------

            if (
                $sid -in @(
                    'S-1-5-18',
                    'S-1-5-19',
                    'S-1-5-20'
                )
            ) {

                continue
            }


            $profileKey = $null


            try {

                $profileKey =
                    $list.OpenSubKey(
                        $sid
                    )


                if (-not $profileKey) {

                    continue
                }


                $profilePath =
                    [string]$profileKey.GetValue(
                        'ProfileImagePath'
                    )


                if (
                    [string]::IsNullOrWhiteSpace(
                        $profilePath
                    )
                ) {

                    continue
                }


                $profilePath =
                    [Environment]::ExpandEnvironmentVariables(
                        $profilePath
                    )


                # -------------------------------------------------
                # Не лезем в:
                # C:\Windows\ServiceProfiles
                # и прочие системные профили Windows.
                # -------------------------------------------------

                if (
                    $profilePath.StartsWith(
                        $env:SystemRoot,
                        [System.StringComparison]::OrdinalIgnoreCase
                    )
                ) {

                    continue
                }


                $ntUserDat =
                    Join-Path `
                        $profilePath `
                        'NTUSER.DAT'


                $isLoaded =
                    Test-HiveLoaded `
                        $sid


                if (
                    -not $isLoaded -and
                    -not (
                        Test-Path `
                            -LiteralPath $ntUserDat
                    )
                ) {

                    continue
                }


                [void]$profiles.Add(

                    [PSCustomObject]@{

                        SID =
                            $sid

                        ProfilePath =
                            $profilePath

                        NTUserDat =
                            $ntUserDat
                    }

                )
            }
            catch {

                Write-Log (
                    "Cannot enumerate profile ${sid}: " +
                    $_.Exception.Message
                ) 'WARNING'
            }
            finally {

                if ($profileKey) {

                    $profileKey.Dispose()
                }
            }
        }
    }
    catch {

        Write-Log (
            'Profile enumeration failed: ' +
            $_.Exception.Message
        ) 'ERROR'
    }
    finally {

        if ($list) {

            $list.Dispose()
        }


        if ($base) {

            $base.Dispose()
        }
    }


    $profiles
}


# =====================================================================
# READ ONE UNINSTALL ROOT
# =====================================================================

function Read-UninstallRoot {

    param(
        [Microsoft.Win32.RegistryHive]$Hive,

        [Microsoft.Win32.RegistryView]$View,

        [string]$RegistryPath,

        [string]$DisplayRegistryPath,

        [ValidateSet(
            'Machine',
            'User'
        )]
        [string]$Scope,

        [string]$SID,

        [string]$ProfilePath,

        [string]$RegistryViewText
    )


    $items =
        New-Object System.Collections.Generic.List[object]


    $base = $null
    $root = $null


    try {

        $base =
            [Microsoft.Win32.RegistryKey]::OpenBaseKey(
                $Hive,
                $View
            )


        $root =
            $base.OpenSubKey(
                $RegistryPath
            )


        if (-not $root) {

            return
        }


        foreach (
            $subKeyName
            in $root.GetSubKeyNames()
        ) {

            $app = $null


            try {

                $app =
                    $root.OpenSubKey(
                        $subKeyName
                    )


                if (-not $app) {

                    continue
                }


                $displayName =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'DisplayName'
                    )


                if (
                    -not (
                        Test-IsMtsProduct `
                            $displayName
                    )
                ) {

                    continue
                }


                $displayVersion =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'DisplayVersion'
                    )


                $publisher =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'Publisher'
                    )


                $installLocation =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'InstallLocation'
                    )


                $uninstallString =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'UninstallString'
                    )


                $quietUninstallString =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'QuietUninstallString'
                    )


                $modifyPath =
                    [string](
                        Get-RegValue `
                            -Key $app `
                            -Name 'ModifyPath'
                    )


                $windowsInstaller =
                    Get-RegValue `
                        -Key $app `
                        -Name 'WindowsInstaller'


                $systemComponent =
                    Get-RegValue `
                        -Key $app `
                        -Name 'SystemComponent'


                $allCommands =
                    "$uninstallString " +
                    "$quietUninstallString " +
                    "$modifyPath"


                # -----------------------------------------------------
                # GUID uninstall key НЕ означает автоматически MSI.
                #
                # WiX Burn bundle тоже может иметь GUID.
                #
                # Считаем MSI только если:
                #  - WindowsInstaller = 1
                #  ИЛИ
                #  - зарегистрированная команда использует msiexec
                # -----------------------------------------------------

                $isMsi =
                    $false


                if (
                    $windowsInstaller -eq 1
                ) {

                    $isMsi =
                        $true
                }


                if (
                    $allCommands -match
                    '(?i)\bmsiexec(?:\.exe)?\b'
                ) {

                    $isMsi =
                        $true
                }


                $productCode =
                    $null


                if ($isMsi) {

                    $productCode =
                        Get-MsiGuid `
                            -KeyName $subKeyName `
                            -CommandText $allCommands
                }


                [void]$items.Add(

                    [PSCustomObject]@{

                        Scope =
                            $Scope

                        UserSID =
                            $SID

                        ProfilePath =
                            $ProfilePath

                        DisplayName =
                            $displayName

                        DisplayVersion =
                            $displayVersion

                        Publisher =
                            $publisher

                        RegistryView =
                            $RegistryViewText

                        RegistryPath =
                            "$DisplayRegistryPath\$subKeyName"

                        RegistryKeyName =
                            $subKeyName

                        InstallLocation =
                            $installLocation

                        UninstallString =
                            $uninstallString

                        QuietUninstallString =
                            $quietUninstallString

                        WindowsInstaller =
                            $windowsInstaller

                        SystemComponent =
                            $systemComponent

                        IsMSI =
                            $isMsi

                        MsiProductCode =
                            $productCode
                    }

                )
            }
            catch {

                Write-Log (
                    'Cannot read uninstall entry ' +
                    "$DisplayRegistryPath\$subKeyName : " +
                    $_.Exception.Message
                ) 'WARNING'
            }
            finally {

                if ($app) {

                    $app.Dispose()
                }
            }
        }
    }
    catch {

        Write-Log (
            'Cannot scan registry path ' +
            "$DisplayRegistryPath : " +
            $_.Exception.Message
        ) 'WARNING'
    }
    finally {

        if ($root) {

            $root.Dispose()
        }


        if ($base) {

            $base.Dispose()
        }
    }


    $items
}


# =====================================================================
# FULL INVENTORY
# =====================================================================

function Get-MtsInventory {

    $all =
        New-Object System.Collections.Generic.List[object]


    # =================================================================
    # HKLM 64-bit
    # =================================================================

    $machine64 =
        @(
            Read-UninstallRoot `
                -Hive (
                    [Microsoft.Win32.RegistryHive]::LocalMachine
                ) `
                -View (
                    [Microsoft.Win32.RegistryView]::Registry64
                ) `
                -RegistryPath (
                    'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
                ) `
                -DisplayRegistryPath (
                    'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
                ) `
                -Scope 'Machine' `
                -SID $null `
                -ProfilePath $null `
                -RegistryViewText '64-bit'
        )


    foreach ($item in $machine64) {

        [void]$all.Add($item)
    }


    # =================================================================
    # HKLM 32-bit
    # =================================================================

    $machine32 =
        @(
            Read-UninstallRoot `
                -Hive (
                    [Microsoft.Win32.RegistryHive]::LocalMachine
                ) `
                -View (
                    [Microsoft.Win32.RegistryView]::Registry32
                ) `
                -RegistryPath (
                    'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
                ) `
                -DisplayRegistryPath (
                    'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
                ) `
                -Scope 'Machine' `
                -SID $null `
                -ProfilePath $null `
                -RegistryViewText '32-bit'
        )


    foreach ($item in $machine32) {

        [void]$all.Add($item)
    }


    # =================================================================
    # USERS
    # =================================================================

    $profiles =
        @(
            Get-RealUserProfiles
        )


    $counter = 0


    foreach ($profile in $profiles) {

        $counter++


        $sid =
            $profile.SID


        $profilePath =
            $profile.ProfilePath


        $ntUserDat =
            $profile.NTUserDat


        $loadedByScript =
            $false


        if (
            Test-HiveLoaded `
                $sid
        ) {

            $mountedName =
                $sid
        }
        else {

            $mountedName =
                "MTSLinkCleanup_${PID}_${counter}"


            $loadResult =
                Invoke-RegExe (
                    'load "HKU\{0}" "{1}"' -f
                    $mountedName,
                    $ntUserDat
                )


            if (
                $loadResult.ExitCode -ne 0
            ) {

                Write-Log (
                    "Cannot load NTUSER.DAT for SID $sid. " +
                    "ExitCode=$($loadResult.ExitCode); " +
                    "Error=$($loadResult.StdErr)"
                ) 'WARNING'

                continue
            }


            $loadedByScript =
                $true
        }


        try {

            # ---------------------------------------------------------
            # Обычный пользовательский uninstall root
            # ---------------------------------------------------------

            $userItems =
                @(
                    Read-UninstallRoot `
                        -Hive (
                            [Microsoft.Win32.RegistryHive]::Users
                        ) `
                        -View (
                            [Microsoft.Win32.RegistryView]::Registry64
                        ) `
                        -RegistryPath (
                            "$mountedName\Software\Microsoft\Windows\CurrentVersion\Uninstall"
                        ) `
                        -DisplayRegistryPath (
                            "HKEY_USERS\$sid\Software\Microsoft\Windows\CurrentVersion\Uninstall"
                        ) `
                        -Scope 'User' `
                        -SID $sid `
                        -ProfilePath $profilePath `
                        -RegistryViewText 'User'
                )


            foreach ($item in $userItems) {

                [void]$all.Add($item)
            }


            # ---------------------------------------------------------
            # На всякий случай проверяем также явный WOW6432Node
            # пользовательского hive.
            # ---------------------------------------------------------

            $userWowItems =
                @(
                    Read-UninstallRoot `
                        -Hive (
                            [Microsoft.Win32.RegistryHive]::Users
                        ) `
                        -View (
                            [Microsoft.Win32.RegistryView]::Registry64
                        ) `
                        -RegistryPath (
                            "$mountedName\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
                        ) `
                        -DisplayRegistryPath (
                            "HKEY_USERS\$sid\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
                        ) `
                        -Scope 'User' `
                        -SID $sid `
                        -ProfilePath $profilePath `
                        -RegistryViewText 'User-WOW6432'
                )


            foreach ($item in $userWowItems) {

                [void]$all.Add($item)
            }
        }
        finally {

            if ($loadedByScript) {

                [GC]::Collect()
                [GC]::WaitForPendingFinalizers()

                Start-Sleep `
                    -Milliseconds 200


                $unloadResult =
                    Invoke-RegExe (
                        'unload "HKU\{0}"' -f
                        $mountedName
                    )


                if (
                    $unloadResult.ExitCode -ne 0
                ) {

                    Write-Log (
                        'Cannot unload temporary hive ' +
                        "HKU\$mountedName. " +
                        "ExitCode=$($unloadResult.ExitCode); " +
                        "Error=$($unloadResult.StdErr)"
                    ) 'WARNING'
                }
            }
        }
    }


    # =================================================================
    # DEDUP
    # =================================================================

    $all |
        Sort-Object `
            Scope,
            UserSID,
            RegistryPath `
            -Unique
}


# =====================================================================
# INTERACTIVE USERS
# =====================================================================

function Get-InteractiveUserMap {

    $map = @{}


    try {

        $explorers =
            Get-CimInstance `
                Win32_Process `
                -Filter "Name='explorer.exe'" `
                -ErrorAction Stop


        foreach ($process in $explorers) {

            try {

                $sidResult =
                    Invoke-CimMethod `
                        -InputObject $process `
                        -MethodName GetOwnerSid `
                        -ErrorAction Stop


                $ownerResult =
                    Invoke-CimMethod `
                        -InputObject $process `
                        -MethodName GetOwner `
                        -ErrorAction Stop


                if (
                    $sidResult.Sid -and
                    $ownerResult.User
                ) {

                    if (
                        $ownerResult.Domain
                    ) {

                        $account =
                            "$($ownerResult.Domain)\$($ownerResult.User)"
                    }
                    else {

                        $account =
                            $ownerResult.User
                    }


                    $map[
                        [string]$sidResult.Sid
                    ] = $account
                }
            }
            catch {

                # Один explorer.exe не должен ломать весь аудит.
            }
        }
    }
    catch {

        Write-Log (
            'Cannot enumerate interactive users: ' +
            $_.Exception.Message
        ) 'WARNING'
    }


    return $map
}


# =====================================================================
# PARSE EXE COMMAND
# =====================================================================

function Split-UninstallCommand {

    param(
        [string]$Command
    )


    if (
        [string]::IsNullOrWhiteSpace(
            $Command
        )
    ) {

        return $null
    }


    $commandText =
        [Environment]::ExpandEnvironmentVariables(
            $Command.Trim()
        )


    # ---------------------------------------------------------
    # "C:\Path With Spaces\uninstall.exe" /arg1 /arg2
    # ---------------------------------------------------------

    if (
        $commandText -match
        '^\s*"([^"]+)"\s*(.*)$'
    ) {

        return [PSCustomObject]@{

            FilePath =
                $Matches[1]

            Arguments =
                $Matches[2].Trim()
        }
    }


    # ---------------------------------------------------------
    # msiexec.exe /x ...
    # rundll32.exe ...
    # ---------------------------------------------------------

    if (
        $commandText -match
        '^\s*([^\s]+)\s*(.*)$'
    ) {

        return [PSCustomObject]@{

            FilePath =
                $Matches[1]

            Arguments =
                $Matches[2].Trim()
        }
    }


    return $null
}


# =====================================================================
# EXIT CODE HANDLING
# =====================================================================

function Test-SuccessExitCode {

    param(
        [int64]$ExitCode
    )


    # 0    = success
    # 1605 = product not installed
    # 1614 = product already uninstalled
    # 3010 = success, reboot required
    #
    # 1641 специально НЕ включён:
    # "restart initiated" для нас не является нормальным результатом.

    if (
        $ExitCode -in @(
            0,
            1605,
            1614,
            3010
        )
    ) {

        return $true
    }


    return $false
}


function Register-UninstallExitCode {

    param(
        [int64]$ExitCode,

        [string]$Description
    )


    if (
        $ExitCode -eq 3010
    ) {

        $script:RebootRequiredReported =
            $true


        Write-Log (
            "$Description returned 3010. " +
            'Removal succeeded and reports that a reboot may be required, ' +
            'but this script WILL NOT reboot the OS and WILL NOT return 3010 to SCCM.'
        ) 'WARNING'

        return
    }


    if (
        $ExitCode -eq 1641
    ) {

        $script:RestartInitiatedCode =
            $true


        Write-Log (
            "$Description returned 1641 (restart initiated). " +
            'This is treated as an ERROR.'
        ) 'ERROR'

        return
    }


    if (
        Test-SuccessExitCode `
            $ExitCode
    ) {

        Write-Log (
            "$Description returned exit code $ExitCode."
        ) 'SUCCESS'
    }
    else {

        Write-Log (
            "$Description returned exit code $ExitCode."
        ) 'WARNING'
    }
}


# =====================================================================
# MSI REMOVE AS SYSTEM
# =====================================================================

function Invoke-MsiRemovalAsSystem {

    param(
        [string]$ProductCode,

        [string]$Description
    )


    $safeGuid =
        $ProductCode -replace '[{}-]', ''


    $msiLog =
        Join-Path `
            $OutputDirectory `
            (
                'MSI_{0}_{1}.log' -f
                $safeGuid,
                (Get-Date -Format 'yyyyMMdd_HHmmss')
            )


    # ---------------------------------------------------------
    # КРИТИЧНО:
    #
    # /norestart
    # REBOOT=ReallySuppress
    #
    # Никакой автоматической перезагрузки от Windows Installer.
    # ---------------------------------------------------------

    $arguments =
        '/x {0} /qn /norestart REBOOT=ReallySuppress /L*v "{1}"' -f
        $ProductCode,
        $msiLog


    Write-Log "MSI REMOVE: $Description"
    Write-Log "$NativeMsiexec $arguments"


    try {

        $process =
            Start-Process `
                -FilePath $NativeMsiexec `
                -ArgumentList $arguments `
                -Wait `
                -PassThru `
                -WindowStyle Hidden `
                -ErrorAction Stop


        $exitCode =
            [int64]$process.ExitCode


        Register-UninstallExitCode `
            -ExitCode $exitCode `
            -Description $Description


        return $exitCode
    }
    catch {

        Write-Log (
            "Cannot execute MSI uninstall for ${Description}: " +
            $_.Exception.Message
        ) 'ERROR'


        return -1
    }
}


# =====================================================================
# EXE REMOVE AS SYSTEM
# =====================================================================

function Invoke-ExeRemovalAsSystem {

    param(
        [string]$Command,

        [string]$Description
    )


    $parsed =
        Split-UninstallCommand `
            $Command


    if (-not $parsed) {

        Write-Log (
            "Cannot parse uninstall command for $Description"
        ) 'ERROR'

        return -1
    }


    $filePath =
        $parsed.FilePath


    $arguments =
        $parsed.Arguments


    # ---------------------------------------------------------
    # Если это WiX Burn bootstrapper, дополнительно принудительно
    # добавляем /quiet и /norestart.
    # ---------------------------------------------------------

    $isBurn =
        (
            $Command -match
            '(?i)/burn\.clean\.room'
        ) -or
        (
            $filePath -match
            '(?i)bootstrapper'
        )


    if ($isBurn) {

        if (
            $arguments -notmatch
            '(?i)(?:^|\s)/quiet(?:\s|$)'
        ) {

            $arguments =
                "$arguments /quiet".Trim()
        }


        if (
            $arguments -notmatch
            '(?i)(?:^|\s)/norestart(?:\s|$)'
        ) {

            $arguments =
                "$arguments /norestart".Trim()
        }
    }


    Write-Log "EXE REMOVE as SYSTEM: $Description"
    Write-Log (
        '"' +
        $filePath +
        '" ' +
        $arguments
    )


    try {

        $startParams = @{

            FilePath =
                $filePath

            Wait =
                $true

            PassThru =
                $true

            WindowStyle =
                'Hidden'

            ErrorAction =
                'Stop'
        }


        if (
            -not [string]::IsNullOrWhiteSpace(
                $arguments
            )
        ) {

            $startParams.ArgumentList =
                $arguments
        }


        $process =
            Start-Process @startParams


        $exitCode =
            [int64]$process.ExitCode


        Register-UninstallExitCode `
            -ExitCode $exitCode `
            -Description $Description


        return $exitCode
    }
    catch {

        Write-Log (
            "Cannot execute EXE uninstall for ${Description}: " +
            $_.Exception.Message
        ) 'ERROR'


        return -1
    }
}


# =====================================================================
# EXE/MSI REMOVE AS INTERACTIVE USER
# =====================================================================

function Invoke-ProcessAsInteractiveUser {

    param(
        [string]$SID,

        [string]$Account,

        [string]$FilePath,

        [string]$Arguments,

        [string]$Description
    )


    $taskName =
        'MTSLinkCleanup_' +
        [Guid]::NewGuid().ToString('N')


    try {

        Write-Log (
            "USER REMOVE: $Description"
        )


        Write-Log (
            "Run as: $Account [$SID]"
        )


        Write-Log (
            '"' +
            $FilePath +
            '" ' +
            $Arguments
        )


        # ---------------------------------------------------------
        # Scheduled Task используется только как безопасный способ
        # стартовать процесс под уже залогиненным пользователем.
        #
        # Никаких паролей не требуется.
        # ---------------------------------------------------------

        if (
            [string]::IsNullOrWhiteSpace(
                $Arguments
            )
        ) {

            $action =
                New-ScheduledTaskAction `
                    -Execute $FilePath
        }
        else {

            $action =
                New-ScheduledTaskAction `
                    -Execute $FilePath `
                    -Argument $Arguments
        }


        $principal =
            New-ScheduledTaskPrincipal `
                -UserId $Account `
                -LogonType Interactive `
                -RunLevel Limited


        Register-ScheduledTask `
            -TaskName $taskName `
            -Action $action `
            -Principal $principal `
            -Force `
            -ErrorAction Stop |
            Out-Null


        $startMark =
            Get-Date


        Start-ScheduledTask `
            -TaskName $taskName `
            -ErrorAction Stop


        $deadline =
            (Get-Date).AddMinutes(10)


        $finished =
            $false


        do {

            Start-Sleep `
                -Seconds 1


            $task =
                Get-ScheduledTask `
                    -TaskName $taskName `
                    -ErrorAction Stop


            $taskInfo =
                Get-ScheduledTaskInfo `
                    -TaskName $taskName `
                    -ErrorAction Stop


            $hasActuallyRun =
                (
                    $taskInfo.LastRunTime -ge
                    $startMark.AddSeconds(-2)
                )


            if (
                $hasActuallyRun -and
                $task.State -notin @(
                    'Running',
                    'Queued'
                )
            ) {

                $finished =
                    $true

                break
            }

        } while (
            (Get-Date) -lt $deadline
        )


        if (-not $finished) {

            Write-Log (
                "Timeout waiting for user uninstall: $Description"
            ) 'ERROR'


            Stop-ScheduledTask `
                -TaskName $taskName `
                -ErrorAction SilentlyContinue


            return -1
        }


        $taskInfo =
            Get-ScheduledTaskInfo `
                -TaskName $taskName `
                -ErrorAction Stop


        $exitCode =
            [int64]$taskInfo.LastTaskResult


        Register-UninstallExitCode `
            -ExitCode $exitCode `
            -Description $Description


        return $exitCode
    }
    catch {

        Write-Log (
            "Cannot run uninstall as ${Account}: " +
            $_.Exception.Message
        ) 'ERROR'


        return -1
    }
    finally {

        Unregister-ScheduledTask `
            -TaskName $taskName `
            -Confirm:$false `
            -ErrorAction SilentlyContinue
    }
}


# =====================================================================
# REMOVE ONE PRODUCT
# =====================================================================

function Remove-MtsProduct {

    param(
        $App,

        [hashtable]$InteractiveUsers
    )


    # =================================================================
    # HARD PROTECTION
    # =================================================================

    if (
        Test-IsProtectedProduct `
            $App
    ) {

        Write-Log (
            'KEEP / PROTECTED: ' +
            "$($App.DisplayName) " +
            "$($App.DisplayVersion)"
        ) 'SUCCESS'

        return
    }


    Write-Log '------------------------------------------------------------'


    $description =
        '{0} {1}' -f
        $App.DisplayName,
        $App.DisplayVersion


    Write-Log "REMOVE: $description"

    Write-Log (
        "Scope: $($App.Scope)"
    )

    Write-Log (
        "Registry: $($App.RegistryPath)"
    )


    if ($App.UserSID) {

        Write-Log (
            "UserSID: $($App.UserSID)"
        )
    }


    # =================================================================
    # MACHINE INSTALLATION
    # =================================================================

    if (
        $App.Scope -eq 'Machine'
    ) {

        # -------------------------------------------------------------
        # MSI
        #
        # Даже если ARP содержит:
        #
        #   MsiExec.exe /I{GUID}
        #
        # намеренно используем:
        #
        #   /x {GUID} /qn /norestart REBOOT=ReallySuppress
        # -------------------------------------------------------------

        if (
            $App.IsMSI -and
            $App.MsiProductCode
        ) {

            [void](
                Invoke-MsiRemovalAsSystem `
                    -ProductCode $App.MsiProductCode `
                    -Description $description
            )


            return
        }


        # -------------------------------------------------------------
        # Non-MSI.
        #
        # В первую очередь используем официальный
        # QuietUninstallString из Windows.
        # -------------------------------------------------------------

        $command =
            $null


        if (
            -not [string]::IsNullOrWhiteSpace(
                $App.QuietUninstallString
            )
        ) {

            $command =
                $App.QuietUninstallString
        }
        elseif (
            -not [string]::IsNullOrWhiteSpace(
                $App.UninstallString
            ) -and
            (
                $App.UninstallString -match
                '(?i)(/burn\.clean\.room|bootstrapper)'
            )
        ) {

            # Для Burn можем безопасно добавить
            # /quiet /norestart самостоятельно.

            $command =
                $App.UninstallString
        }


        if (
            [string]::IsNullOrWhiteSpace(
                $command
            )
        ) {

            Write-Log (
                'No safe silent uninstall command found. ' +
                'Product will NOT be forcibly removed.'
            ) 'ERROR'


            return
        }


        [void](
            Invoke-ExeRemovalAsSystem `
                -Command $command `
                -Description $description
        )


        return
    }


    # =================================================================
    # USER INSTALLATION
    # =================================================================

    $sid =
        [string]$App.UserSID


    if (
        [string]::IsNullOrWhiteSpace(
            $sid
        )
    ) {

        Write-Log (
            "User installation has no SID: $description"
        ) 'ERROR'

        return
    }


    # -------------------------------------------------------------
    # /currentuser от SYSTEM запускать НЕ будем.
    #
    # Нужно найти именно того пользователя, которому принадлежит
    # установка.
    # -------------------------------------------------------------

    if (
        -not $InteractiveUsers.ContainsKey(
            $sid
        )
    ) {

        Write-Log (
            "User SID $sid is not currently logged on with explorer.exe. " +
            'The per-user uninstall will NOT be executed as SYSTEM. ' +
            'This product will remain and final verification will report it.'
        ) 'WARNING'


        return
    }


    $account =
        $InteractiveUsers[$sid]


    # =================================================================
    # USER MSI
    # =================================================================

    if (
        $App.IsMSI -and
        $App.MsiProductCode
    ) {

        $arguments =
            '/x {0} /qn /norestart REBOOT=ReallySuppress' -f
            $App.MsiProductCode


        [void](
            Invoke-ProcessAsInteractiveUser `
                -SID $sid `
                -Account $account `
                -FilePath $TaskMsiexec `
                -Arguments $arguments `
                -Description $description
        )


        return
    }


    # =================================================================
    # USER EXE
    # =================================================================

    if (
        [string]::IsNullOrWhiteSpace(
            $App.QuietUninstallString
        )
    ) {

        Write-Log (
            'Per-user product has no QuietUninstallString. ' +
            'Interactive/non-silent uninstall will NOT be started.'
        ) 'ERROR'


        return
    }


    $parsed =
        Split-UninstallCommand `
            $App.QuietUninstallString


    if (-not $parsed) {

        Write-Log (
            "Cannot parse QuietUninstallString for $description"
        ) 'ERROR'


        return
    }


    $arguments =
        $parsed.Arguments


    # Если вдруг пользовательская запись тоже WiX Burn.
    $isBurn =
        (
            $App.QuietUninstallString -match
            '(?i)/burn\.clean\.room'
        ) -or
        (
            $parsed.FilePath -match
            '(?i)bootstrapper'
        )


    if ($isBurn) {

        if (
            $arguments -notmatch
            '(?i)(?:^|\s)/quiet(?:\s|$)'
        ) {

            $arguments =
                "$arguments /quiet".Trim()
        }


        if (
            $arguments -notmatch
            '(?i)(?:^|\s)/norestart(?:\s|$)'
        ) {

            $arguments =
                "$arguments /norestart".Trim()
        }
    }


    [void](
        Invoke-ProcessAsInteractiveUser `
            -SID $sid `
            -Account $account `
            -FilePath $parsed.FilePath `
            -Arguments $arguments `
            -Description $description
    )
}


# =====================================================================
# PRODUCT IDENTITY
# =====================================================================

function Get-ProductIdentity {

    param(
        $App
    )


    return (
        '{0}|{1}|{2}|{3}|{4}' -f
        $App.Scope,
        $App.UserSID,
        $App.RegistryPath,
        $App.DisplayName,
        $App.DisplayVersion
    )
}


# =====================================================================
# INITIAL INVENTORY
# =====================================================================

Write-Log 'Taking initial inventory...'


$InitialInventory =
    @(
        Get-MtsInventory
    )


Write-Log (
    'Initial MTS Link entries found: ' +
    $InitialInventory.Count
)


foreach ($app in $InitialInventory) {

    if (
        Test-IsProtectedProduct `
            $app
    ) {

        $status =
            'KEEP'
    }
    else {

        $status =
            'REMOVE'
    }


    Write-Log (
        '[{0}] {1} {2} | {3} | {4}' -f
        $status,
        $app.DisplayName,
        $app.DisplayVersion,
        $app.Scope,
        $app.RegistryPath
    )
}


# =====================================================================
# SAVE INITIAL INVENTORY
# =====================================================================

try {

    $InitialInventory |
        Select-Object `
            @{N='Action';E={
                if (
                    Test-IsProtectedProduct $_
                ) {
                    'KEEP'
                }
                else {
                    'REMOVE'
                }
            }},
            DisplayName,
            DisplayVersion,
            Scope,
            UserSID,
            ProfilePath,
            RegistryView,
            RegistryPath,
            IsMSI,
            MsiProductCode,
            UninstallString,
            QuietUninstallString |
        Export-Csv `
            -LiteralPath $InitialCsvPath `
            -Delimiter ';' `
            -NoTypeInformation `
            -Encoding UTF8


    Write-Log (
        "Initial CSV: $InitialCsvPath"
    )
}
catch {

    Write-Log (
        'Cannot save initial CSV: ' +
        $_.Exception.Message
    ) 'WARNING'
}


# =====================================================================
# REMEMBER EXACT PROTECTED ENTRIES
# =====================================================================

$InitialProtected =
    @(
        $InitialInventory |
            Where-Object {
                Test-IsProtectedProduct $_
            }
    )


$InitialProtectedIds =
    @(
        $InitialProtected |
            ForEach-Object {
                Get-ProductIdentity $_
            }
    )


foreach ($item in $InitialProtected) {

    Write-Log (
        'PROTECTED: ' +
        "$($item.DisplayName) " +
        "$($item.DisplayVersion) | " +
        $item.RegistryPath
    ) 'SUCCESS'
}


# =====================================================================
# TARGETS TO REMOVE
# =====================================================================

$Targets =
    @(
        $InitialInventory |
            Where-Object {
                -not (
                    Test-IsProtectedProduct $_
                )
            }
    )


Write-Log (
    'Products selected for removal: ' +
    $Targets.Count
)


# =====================================================================
# INTERACTIVE USERS
# =====================================================================

$InteractiveUsers =
    Get-InteractiveUserMap


if (
    $InteractiveUsers.Count -gt 0
) {

    foreach (
        $sid in $InteractiveUsers.Keys
    ) {

        Write-Log (
            'Interactive user: ' +
            "$($InteractiveUsers[$sid]) [$sid]"
        )
    }
}
else {

    Write-Log (
        'No interactive users detected.'
    ) 'WARNING'
}


# =====================================================================
# REMOVAL
# =====================================================================

foreach ($app in $Targets) {

    Remove-MtsProduct `
        -App $app `
        -InteractiveUsers $InteractiveUsers
}


# =====================================================================
# FINAL VERIFICATION
# =====================================================================

Write-Log '------------------------------------------------------------'

Write-Log (
    "Waiting $VerificationDelaySeconds seconds before final verification..."
)


Start-Sleep `
    -Seconds $VerificationDelaySeconds


Write-Log 'Taking final inventory...'


$FinalInventory =
    @(
        Get-MtsInventory
    )


$FinalUnwanted =
    @(
        $FinalInventory |
            Where-Object {
                -not (
                    Test-IsProtectedProduct $_
                )
            }
    )


$FinalProtected =
    @(
        $FinalInventory |
            Where-Object {
                Test-IsProtectedProduct $_
            }
    )


$FinalIds =
    @(
        $FinalInventory |
            ForEach-Object {
                Get-ProductIdentity $_
            }
    )


# =====================================================================
# VERIFY PROTECTED PRODUCTS
# =====================================================================

$ProtectedMissing =
    New-Object System.Collections.Generic.List[string]


foreach (
    $protectedId
    in $InitialProtectedIds
) {

    if (
        $FinalIds -notcontains
        $protectedId
    ) {

        [void]$ProtectedMissing.Add(
            $protectedId
        )


        Write-Log (
            'CRITICAL: protected Meetings 1.6.0.0 entry disappeared: ' +
            $protectedId
        ) 'ERROR'
    }
}


# =====================================================================
# FINAL CSV
# =====================================================================

try {

    $FinalInventory |
        Select-Object `
            @{N='Status';E={
                if (
                    Test-IsProtectedProduct $_
                ) {
                    'KEEP'
                }
                else {
                    'UNWANTED'
                }
            }},
            DisplayName,
            DisplayVersion,
            Scope,
            UserSID,
            ProfilePath,
            RegistryView,
            RegistryPath,
            IsMSI,
            MsiProductCode,
            UninstallString,
            QuietUninstallString |
        Export-Csv `
            -LiteralPath $FinalCsvPath `
            -Delimiter ';' `
            -NoTypeInformation `
            -Encoding UTF8


    Write-Log (
        "Final CSV: $FinalCsvPath"
    )
}
catch {

    Write-Log (
        'Cannot save final CSV: ' +
        $_.Exception.Message
    ) 'WARNING'
}


# =====================================================================
# FINAL REPORT
# =====================================================================

Write-Log '============================================================'
Write-Log 'FINAL RESULT'
Write-Log '============================================================'


foreach ($item in $FinalProtected) {

    Write-Log (
        'KEEP OK: ' +
        "$($item.DisplayName) " +
        "$($item.DisplayVersion) | " +
        $item.RegistryPath
    ) 'SUCCESS'
}


foreach ($item in $FinalUnwanted) {

    Write-Log (
        'UNWANTED REMAINS: ' +
        "$($item.DisplayName) " +
        "$($item.DisplayVersion) | " +
        "$($item.Scope) | " +
        "SID=$($item.UserSID) | " +
        $item.RegistryPath
    ) 'ERROR'
}


if ($script:RebootRequiredReported) {

    Write-Log (
        'At least one uninstall returned 3010. ' +
        'No reboot was executed and 3010 will not be returned to SCCM.'
    ) 'WARNING'
}


# =====================================================================
# EXIT CODE
# =====================================================================

if (
    $script:RestartInitiatedCode
) {

    Write-Log (
        'RESULT: FAILED - an uninstaller returned 1641 ' +
        '(restart initiated).'
    ) 'ERROR'

    Write-Log 'EXIT CODE: 30'
    Write-Log '============================================================'

    exit 30
}


if (
    $ProtectedMissing.Count -gt 0
) {

    Write-Log (
        'RESULT: FAILED - protected MTS Link Meetings 1.6.0.0 ' +
        'component disappeared.'
    ) 'ERROR'

    Write-Log 'EXIT CODE: 20'
    Write-Log '============================================================'

    exit 20
}


if (
    $FinalUnwanted.Count -gt 0
) {

    Write-Log (
        'RESULT: FAILED - unwanted MTS Link products remain.'
    ) 'ERROR'

    Write-Log 'EXIT CODE: 10'
    Write-Log '============================================================'

    exit 10
}


Write-Log (
    'RESULT: SUCCESS - all unwanted MTS Link products are gone.'
) 'SUCCESS'


if (
    $InitialProtected.Count -gt 0
) {

    Write-Log (
        'Protected MTS Link Meetings 1.6.0.0 components are intact.'
    ) 'SUCCESS'
}


Write-Log (
    'No OS reboot was requested by this script.'
) 'SUCCESS'

Write-Log 'EXIT CODE: 0'
Write-Log '============================================================'

exit 0