Show-ADTInstallationWelcome v4.1.8 Noticed errors

Something changed between v4.1.7 and 4.1.8 regarding the Show-ADTInstallationWelcome cmdlet. 4.1.8 runs better than 4.1.7, but still not as it should.
With 4.1.7 the install would fail completely (explained here PSADTv4.1.7 - Show-ADTInstallationWelcome Error - The Toolkit / General Discussion - PSAppDeployToolkit Community).

With 4.1.8 everything works fine if the process is running as the logged on user. Often times we must elevate processes and this is where the issue arises. If I run a simple script that looks to close Notepad, running with elevated rights (not with the Logged On User account), and I use AllowDefer = $true in the $saiwparams, the install pops the message up, but it doesn’t list the process to close.

Defer works and Install work.

If I do the exact same thing, but instead of AllowDefer = $true, I change that to AllowDeferCloseProcesses = $true, the script closes the process, but doesn’t provide the popup. When the install completes, I do see the completion popup.

This is what strikes me as strange, the log entry:
[Pre-Install] :: Force closing application(s) ['Notepad'] that the user had no permissions to close.
Why would the System account not be able to close a process running as System?

Here is the full log…

I am not pointing the finger at the Toolkit as the source of the issue. I don’t believe it is.
Does anyone have any ideas on what could be in my environment that could cause this sort of behavior? I have already removed or disabled our security tools.

It looks like in the psm1 I am going down this path…

The question is why is it that we can identify running processes that need to be closed, but we don’t ask the user to close them or defer? I would think you would want to popup the Show-ADTInstallation Welcome prompt if the processes will be closed regardless of the account used to open them.

I have not run through the entire logic here, so I do not know the reason for running the install interactively with processes running, but not prompting the user to close the processes, but still forcing closed the processes silently.

The processes are closed via the client/server client and it runs as the user, as highly privileged as possible. If the user is an admin, it'll run elevated. It sounds like your user account is now and their it's running as a standard user. As such, it won't have visibility of other user processes to close, hence that fallback code for remaining processes that are still open, which we simply terminate.

Yes, you are absolutely right. Our logged on user accounts do not have the necessary rights. But these same users could, and often do, have secondary accounts that do have admin rights.

I would think that we would want to address this scenario, since it is a common practice.

So these users have enough rights to elevate with an alternate account, but simply because their logged on user account doesn’t have those rights, the Toolkit, which we use with MECM, which would run the install as SYSTEM, cannot close elevated processes? This is not the experience we saw in 3.10. Maybe your reasoning is to secure the toolkit from malicious code, but that seems unlikely, since you just terminate the processes anyway.

I am just trying to understand what is gained by doing it this way. I certainly see the downside of it.

For now, I have written a function to deal with this scenario. It checks if any processes are running that are not running as the logged on user, then uses the Show-ADTInstallationPrompt to alert the user that the processes are running elevated, and gives them the option to close the processes and continue the install, or to display the Show-ADTInstallationWelcome, to allow for deferral. This is less than optimal, since it adds an additional touchpoint for users in this scenario. For any processes that are running under the logged on user account we use the $saiwparams standard method, along with the AllowDeferCloseProcesses parameter.

I do thank you for responding already to my somewhat silly messages. I’m sure they often come off as low-level ramblings.

It might be a common practice in your environment, but this is the first report I've seen of such a situation. Usually the only problem that's arisen from the new client/server setup is people placing services in the CloseProcesses list, which if you think about it is silly as why would you ask the user to close something they have no chance in hell of being able to do.

There's many reasons for the client/server refactoring:

  1. Intune users have never had a "Allow users to view and interact with the program installation" like ConfigMgr does, so people have relied on things like ServiceUI. This utility can be rather dangerous, and now is hard to find since MDT has been required.

  2. ConfigMgr's "Allow users to view and interact with the program installation" option is simply just as dangerous as it's basically a built-in version of ServiceUI, and offers all the same perils and pitfalls. Imagine allowing an interactive install of Git so users could pick their settings, only to leave "Open a Git terminal" option at the end of a deployment, giving them a SYSTEM-running interactive command prompt. Ideally you want this checkbox unchecked at all times and in 4.1.0 or higher, you can.

  3. The toolkit can now be used on more platforms with ease, and certain platforms where we've never been able to provide support. Omnissa Workspace ONE is a primary example where ServiceUI never worked, but our client/server process does. This is a massive win for that community and unlocks new customers.

  4. The client/server approach is paving the way towards better support of multi-session environments, something we've never supported well. You might have multiple users on a terminal server or AVD device. In older releases, some lucky user would get the dialog, hit the "Close Programs" button and kill off everyone's app on the host. That's completely unacceptable and is a real problem that requires resolving. We've got a lot on our plate for 4.2.0 so we may not get there with this, but we're working really hard.

The whole example around Git alongside "Allow users to view and interact with the program installation" is the real big one in terms of security. It's something Microsoft should have never provided and unless you're experienced in developing against Win32 APIs, simply wouldn't understand how it works except for the fact that it does work and achieve the result it sets out to do. With our toolkit not requiring that be checked anymore, we're doing our part to secure your deployments and environment by not requiring you opt into it anymore.

The last part where we simply close out processes that we cannot ask the user to close is not ideal at all, however it really is a stop-gap until we get that multi-session environment support going, which will help with your current requirement also.

The silliest messages of all are the ones never typed and asked. You've got a legitmate use case that's not working as it did previously and you want to know more. There's nothing silly about that at all.

It might be a common practice in your environment, but this is the first report I've seen of such a situation. “ - I am unsure how you have not seen this before. I could be wrong, but I think either in your documentation or in a thread somewhere I read that you must initiate the toolkit as SYSTEM, which is elevated. Having one account to logon, that has very little rights, and one to elevate, is a way of securing the environment. If my logon account was compromised, the malicious actor would not be able to do much. They would also need my elevated account and credentials, which doesn’t have logon rights, to do any real harm. The likelihood of getting both would be slim.

I would ask you confirm these findings from my testing.

So, my thought process here was what would happen if the user had say Notepad running elevated, but not at all running as the logged on user. What would happen if I opened another instance of notepad as the user, then ran the installation from a system command prompt (pwsh -ExecutionPolicy Bypass -file .\Invoke-AppDeployToolkit.ps1) using the Show-ADTInstallationWelcome cmdlet (Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -AllowDefer -DeferTimes 50). When I chose “Close Apps & Install”, low and behold, it close all instances of notepad, not just the ones as the user. So for me, in my testing your comment did not hold true. The user, in this scenario, indeed was able to close elevated, and their own, notepad instances. And, if this holds true for you as well, then why not change the Show-ADTInstallationWelcome cmdlet to prompt the user for elevated processes as well.

So, the way I could make the Show-ADTInstallationWelcome to work as I need it to, is to check and see if the process is not running as the user, but running elevated, then use Start-ADTProcessAsUser to open a new Notepad process as the user, but hidden, then just running Show-ADTInstallationWelcome as normal. This would close the user process and the elevated process for Notepad.

Hopefully that all made sense, and hopefully you, or someone else, can confirm what I am experiencing.

Just to clarify on this, it's not that I haven't seen people running elevated processes as another account, that in of itself is quite common. What isn't common in my opinion is end users doing this. Usually it's something relegated to admins who run daily driver accounts but elevate management tools, etc.

It wouldn't work in the way that you think. The code retrieves processes via [System.Diagnostics.Process]::GetProcesses() and you can only retrieve process information for processes you can access (either your own, or if you're admin, anything). It'll allow you to close your own notepad.exe process, but not every other notepad.exe process.

You'll probably have to wait for [Feature] Improve client/server experience on multi-session operating systems (terminal services, AVD, etc). · Issue #1536 · PSAppDeployToolkit/PSAppDeployToolkit · GitHub to do what you want to do, as the rework we have intended for this will solve your issue also because we'll likely move process closing code back to the SYSTEM process, then have the SYSTEM process tell the client what to display for closure. That way, even if it's an elevated process, SYSTEM will get it and if it's in your session even if running as another user, we'll be able to show it.

The above is earmarked for 4.2.0 and the desire is there as I really want to get open issues as close to zero as possible, however I'll be honest it's not the highest priority issue on my list to solve and I'm probably reaching a level of burn out for this release. We'll have to see how we go but if it's not in for 4.2.0, it'll be in for 4.3.0.

I look forward to the update should you move back to the SYSTEM process of closing apps.

In the meantime, I've just made a workaround (with the help of Copilot) to create a couple of functions to identify which processes are running as the current logged on user and which processes are running as a different user. (The functions return a bunch of values to present the results as needed for our purposes).

We enter our "AppProcessesToClose" list as normal and the function runs pre-install/pre-uninstall etc so we can identify which apps are "closeable" for the currently logged user, and which processes are "non-closeable" i.e. any running process under another user account (typically elevated, but that's not really important here).

Then it's a case of Show-ADTInstallationWelcome as usual for the "closeable" processes, and then some creative use of Show-InstallationPrompt and Show-ADTInstallationWelcome with the "-silent" argument for the "non-closeable" processes (to kill the process, if they can be bothered to manually close the app before pressing "Continue".

There's obviously more to it, but below is what we end up running:

$ourProcessHandlingState = Get-OurProcessHandlingState -ProcessName $adtSession.AppProcessesToClose

# For "logged on user" processes 
If ($ourProcessHandlingState.Closeable) { Show-ADTInstallationWelcome @welcomeParams }
	
# For "other user" processes
If ($ourProcessHandlingState.NonCloseable) {
	Show-ADTInstallationPrompt @nonCloseablePromptParams
	Show-ADTInstallationWelcome @nonCloseableWelcomeParams
}

For the sake of just a handful of apps, which are typically used by admins, elevated, it'll do.

Here's an image of the two functions's output (names edited). Copliot's first function essentially does the job of identifying the "closeable" apps and the "non-closeable" apps, and you could take this info and use it how you like. The next function does it bit more (including calling the first function along with Get-ADTLoggedOnUser, plenty of logging etc) and pulls together a bunch of other values that I can make use of, whether it's to be displayed in a prompt, or just in the ADT log.

@paulh all of this shouldn't be necessary as we already kill off the processes that are running that the client/server user didn't have access to: PSAppDeployToolkit/src/PSAppDeployToolkit/Public/Show-ADTInstallationWelcome.ps1 at 2296b87dd970f6795faadc6a105bfe3ca476c754 · PSAppDeployToolkit/PSAppDeployToolkit · GitHub

Yes, that's what I always thought until a colleague pointed out his example case, where the app to-be-closed is running as another user account (their elevated admin account), and when Show-ADTInstallationWelcome runs, the ADT log confirms the process is running and needs to be closed (so far, so good), but graphically the ADT welcome prompt doesn't display the app (process) to be closed. If you press "Install" with out closing the app, it will re-check, confirm the process is still running and come back with the same Welcome prompt.

Fair enough, you could just put some text in, telling the user they must close this app first and then press "Install", but I like the "Close Apps & Install" button that normally would show at this point. I was surprised that it knows the process needs to be closed yet doesn't tell the user. I took the earlier chat in this post to mean: standard users cannot close other-user opened apps, by design with consideration for RDP multi sessions. So I thought I would have to workaround this for now by identifying these "other user" processes, use an installation prompt with button followed by the the InstallationWelcome function with the "silent" switch to force-close them, if the user hasn't already closed them.

In my first screenshot below, I've set "cmtrace" to be closed, and I've opened cmtrace with my admin account, then run the script (as the SYSTEM account). You'll see the log shows cmtrace needs to be closed but doesn't show it in the Welcome box, and only an "Install" labelled button.

This is what we would expect to see: the app to be closed, with "Close Apps & Install" button.

This is my Installation Prompt for processes running under a different user account:

@mjr4077au - Argh! Sorry, I just realised I'm still using 4.1.7. Rookie mistake! I just dropped in the 4.1.8 module, and like you said, Show-ADTInstallationWelcome does indeed kill off the processes that are running that the client/server user doesn't have access to ... but of course it doesn't stop to ask/warn the user to save/close it first, which in our situation would be some admin-elevated app, like the SCCM console, or Explorer++ etc etc. Hmm. Knowing how 4.1.8 handles things differently, I could trim and tidy up what I've done a bit, but I still like being able prompt/warning the client/server user to close apps that are running with their other account.

This is what I did...
I dot source this file in the invocation section of the script and placed this file in the Support Files folder...

PURPOSE:

Inspect $adtSession.AppProcessesToClose and determine which processes

are currently running ONLY under elevated / non-logged-on-user accounts.



For each such process:

- Capture the executable image path

- Capture the PIDs of the elevated instances



OUTPUT:

Returns a hashtable keyed by process name:

@{

 = @{

ImageFilePath = 

Pids          = <int
 of running elevated PIDs>

}

}



NOTES:

- Detection only (no process start/stop)

- Safe to call from SYSTEM context

- Supports PSADT v4.1.x ProcessDefinition objects as well as hashtables/strings



function Get-ADTProcessesRunningOnlyElevated {
[CmdletBinding()]
param(
# Optional: pass a session explicitly for testing; defaults to $adtSession if present
[Parameter(Mandatory = $false)]
$Session = $script:adtSession
)

# Hashtable that will store results keyed by process name
$RunningOnlyElevated = @{}


# Ensure we have something to evaluate
if (-not $Session -or -not $Session.PSObject.Properties['AppProcessesToClose'] -or -not $Session.AppProcessesToClose) {

    $reason =
        if (-not $Session) { "Session is null" }
        elseif (-not $Session.PSObject.Properties['AppProcessesToClose']) { "Session missing AppProcessesToClose property" }
        else { "AppProcessesToClose is null/empty" }

    Write-ADTLogEntry -Message "Get-ADTProcessesRunningOnlyElevated: Early return because $reason." -Severity Warning -LogType CMTrace
    return $RunningOnlyElevated
}


# Determine the currently logged-on (active) user session
# Get-ADTLoggedOnUser can return multiple sessions (console/RDP); prefer the active user session when available.
$louCandidates = @(Get-ADTLoggedOnUser)

Write-ADTLogEntry -Message "Get-ADTProcessesRunningOnlyElevated: Get-ADTLoggedOnUser returned [$($louCandidates.Count)] session(s)." -Severity Warning -LogType CMTrace

# Optional (useful when debugging): log each session succinctly
foreach ($s in $louCandidates) {
    $typeName = $s.GetType().FullName
    $nt       = if ($s.PSObject.Properties['NTAccount']) { $s.NTAccount } else { '<null>' }
    $sid      = if ($s.PSObject.Properties['SessionId']) { $s.SessionId } else { '<null>' }
    $state    = if ($s.PSObject.Properties['ConnectState']) { $s.ConnectState } else { '<null>' }
    $active   = if ($s.PSObject.Properties['IsActiveUserSession']) { $s.IsActiveUserSession } else { '<no prop>' }

    Write-ADTLogEntry -Message "  LOU candidate: Type=[$typeName], NTAccount=[$nt], SessionId=[$sid], ConnectState=[$state], IsActiveUserSession=[$active]" -Severity Warning -LogType CMTrace
}

$LOU = $louCandidates |
    Where-Object { $_.PSObject.Properties['IsActiveUserSession'] -and $_.IsActiveUserSession } |
    Select-Object -First 1

if (-not $LOU) {
    Write-ADTLogEntry -Message "Get-ADTProcessesRunningOnlyElevated: No IsActiveUserSession=$true session found; falling back to first returned session (if any)." -Severity Warning -LogType CMTrace
    $LOU = $louCandidates | Select-Object -First 1
}

if (-not $LOU) {
    Write-ADTLogEntry -Message "Get-ADTProcessesRunningOnlyElevated: No logged-on user sessions detected (LOU is null). Returning empty results." -Severity Warning -LogType CMTrace
    return $RunningOnlyElevated
}

if (-not $LOU.NTAccount) {
    Write-ADTLogEntry -Message "Get-ADTProcessesRunningOnlyElevated: Logged-on user session found but NTAccount is null/empty (SessionId=$($LOU.SessionId)). Returning empty results." -Severity Warning -LogType CMTrace
    return $RunningOnlyElevated
}

Write-ADTLogEntry -Message "Get-ADTProcessesRunningOnlyElevated: Selected LOU NTAccount=[$($LOU.NTAccount)] SessionId=[$($LOU.SessionId)] ConnectState=[$($LOU.ConnectState)] IsActiveUserSession=[$($LOU.IsActiveUserSession)]" -Severity Warning -LogType CMTrace

# Cache NTAccount for comparison (Domain\User)
$LouAccount = [string]$LOU.NTAccount

foreach ($procDef in $Session.AppProcessesToClose) {

    # Support: ProcessDefinition (object with Name), hashtable (Name key), or string (name or full path)
    $rawName = $null
    if ($procDef -is [string]) {
        $rawName = $procDef
    }
    elseif ($procDef -is [hashtable]) {
        if ($procDef.ContainsKey('Name')) { $rawName = $procDef['Name'] }
    }
    elseif ($null -ne $procDef -and $null -ne $procDef.PSObject.Properties['Name']) {
        $rawName = $procDef.Name
    }

    if ([string]::IsNullOrWhiteSpace($rawName)) { continue }

    # If a full path was provided, reduce it to leaf name
    $leafName = [System.IO.Path]::GetFileName([string]$rawName)

    # Normalize process name by removing .exe if present
    $procName = [System.IO.Path]::GetFileNameWithoutExtension($leafName).Trim()
    if ([string]::IsNullOrWhiteSpace($procName)) { continue }

    # Build WMI filter safely
    $procExe = "$procName.exe"
    $procExeEscaped = $procExe -replace "'", "''"

    # Query Win32_Process to retrieve:
    #   - ExecutablePath (Image File Path)
    #   - Ownership information via GetOwner()
    $wmiProcs = Get-CimInstance -ClassName Win32_Process -Filter "Name='$procExeEscaped'" -ErrorAction SilentlyContinue
    if (-not $wmiProcs) { continue }

    # Track whether ANY instance is running as the logged-on user
    $runningAsLou    = $false

    # Collect all instances running as SYSTEM or other users
    $runningElevated = @()

    foreach ($wp in $wmiProcs) {

        try {
            $owner = Invoke-CimMethod -InputObject $wp -MethodName GetOwner -ErrorAction Stop
            $ownerNt = if ($owner.Domain) { "$($owner.Domain)\$($owner.User)" } else { $null }
        }
        catch {
            continue
        }

        # If any instance is running as the logged-on user,
        # the process is NOT considered "only elevated"
        if ($ownerNt -and ($ownerNt -ieq $LouAccount)) {
            $runningAsLou = $true
            break
        }

        # Capture instances running under SYSTEM or other accounts
        if ($ownerNt -and ($ownerNt -ine $LouAccount)) {
            $runningElevated += $wp
        }
    }

    # Only record the process if:
    #   - It is NOT running as the logged-on user
    #   - It IS running under one or more elevated / other-user contexts
    if (-not $runningAsLou -and $runningElevated.Count -gt 0) {

        $first = $runningElevated | Select-Object -First 1

        $RunningOnlyElevated[$procName] = @{
            ImageFilePath = $first.ExecutablePath
            Pids          = @($runningElevated.ProcessId)
        }
    }
}

return $RunningOnlyElevated

}

I then add this prior to the Show-ADTInstallationWelcome cmdlet...

if ($adtSession.AppProcessesToClose.Count -gt 0) {

Write-ADTLogEntry -Message "Pre-Install: AppProcessesToClose count=$($adtSession.AppProcessesToClose.Count). Proceeding with elevated-process evaluation." -Severity Warning -LogType CMTrace

$saiwParams.Add('CloseProcesses', $adtSession.AppProcessesToClose)

$RunningOnlyElevated = Get-ADTProcessesRunningOnlyElevated

if (-not $RunningOnlyElevated -or $RunningOnlyElevated.Count -eq 0) {

Write-ADTLogEntry -Message "Pre-Install: No processes detected running as elevated only." -Severity Warning -LogType CMTrace

}

else {Write-ADTLogEntry -Message "Pre-Install: Processes running only as elevated detected: $($RunningOnlyElevated.Keys -join ', ')" -Severity Warning -LogType CMTrace}

# The [0] on the next line is called an array index and it referenced the first element in the array. A [1] would be the second element, and so on...

if ($RunningOnlyElevated.Count -gt 0 -and $RunningOnlyElevated.ContainsKey($adtSession.AppProcessesToClose[0].Name)) {

$exe = $RunningOnlyElevated[$adtSession.AppProcessesToClose[0].Name].ImageFilePath

Write-ADTLogEntry -Message "Pre-Install: Launching $($adtSession.AppProcessesToClose[0].Name) as user from elevated instance path [$exe]." -Severity Warning -LogType CMTrace

Start-ADTProcessAsUser -FilePath $exe -NoWait -WindowStyle Hidden -ErrorAction SilentlyContinue

}

# Remove the next 'If' block if there is only one process to close. If you have more than 2 processes to close you will need to add an 'If' block.

if ($RunningOnlyElevated.Count -gt 0 -and $RunningOnlyElevated.ContainsKey($adtSession.AppProcessesToClose[1].Name)) {

$exe = $RunningOnlyElevated[$adtSession.AppProcessesToClose[1].Name].ImageFilePath

Write-ADTLogEntry -Message "Pre-Install: Launching $($adtSession.AppProcessesToClose[1].Name) as user from elevated instance path [$exe]." -Severity Warning -LogType CMTrace

Start-ADTProcessAsUser -FilePath $exe -WindowStyle Hidden -NoWait -ErrorAction SilentlyContinue

}

Start-Sleep -Seconds 2 #Allow for a few seconds for the running processes to start

Show-ADTInstallationWel@saiwParamsome @saiwParams

## Show Progress Message (with the default message).

#Show-ADTInstallationProgress

}

else {

Write-ADTLogEntry -Message "Pre-Install: Skipping elevated-process logic (AppProcessesToClose count=$($adtSession.AppProcessesToClose.Count)." -Severity Warning -LogType CMTr@saiwParamsce

Show-ADTInstallationWelcome @saiwParams

## Show Progress Message (with the default message).

#Show-ADTInstallationProgress

}

This has been working for me. What is does is check if the app is only running elevated. If so, it opens the app as the logged on user so that the user sees that the app needs to close, then when the app is closed, it closes all instances of an app, regardless of which account opened it. At least this is how it is working for me.

Hi @Tropheusman. Thanks, that's an interesting solution. I think that would work well for most apps. I'm trying think of any apps that we manage that can only be launched with an elevated-admin account (i.e. UAC prompt would prevent it being launched with Start-ADTProcessAsUser).

It's good to share thoughts and solutions. But as I already have something in place that works now, I'll probably just stick with that for the time being. I'm happy with my "alternative" Show-ADTInstallationPrompt solution for the rare occasion there are processes opened by other users (elevated, or not).

I've updated our template to use 4.1.8 (a task of mine that was overdue) and I'm pleased to include the new "AllowMove" parameter for Show-ADTInstallationWelcome. That's a nice addition.

In case it's of any use to anyone, I'll share my function here (which is now a single function rather than two).

function Get-ADTProcessHandlingState {
    <#
    .SYNOPSIS
    Evaluates running processes and determines whether they are closeable or non-closeable.

    .DESCRIPTION
    Accepts both string process names and PSADT ProcessDefinition objects.
    Resolves the interactive logged-on user, enumerates matching processes,
    determines owner and elevation, classifies each process, and returns
    a single deployment-ready state object.

    .PARAMETER ProcessName
    One or more process names OR ProcessDefinition objects.

    .PARAMETER NonCloseablePromptTimeout
    Timeout in seconds before automatic continuation (for use with Show-ADTInstallationPrompt). 
	Returned AutoContinueTime is calculated from EvaluationTime + NonCloseablePromptTimeout 
	and formatted as HH:mm.

    .EXAMPLE
    Get-ADTProcessHandlingState -ProcessName 'notepad', 'winword'

    .EXAMPLE
    Get-ADTProcessHandlingState -ProcessName $adtSession.AppProcessesToClose

    .NOTES
	
	$processHandlingState = Get-ADTProcessHandlingState -ProcessName $adtSession.AppProcessesToClose
	If ($processHandlingState.closeable) {
		Show-ADTInstallationWelcome @welcomeParams
	} 
	
	If ($processHandlingState.NonCloseable) { 
		Show-ADTInstallationPrompt @nonCloseablePromptParams
		Show-ADTInstallationWelcome @nonCloseableWelcomeParams
	}

    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object[]]$ProcessName,

        [Parameter(Mandatory = $false)]
        [int]$NonCloseablePromptTimeout = 5400
    )

    #region Helper Functions

    function Get-ADTValueOrDefault {
        param (
            $Value,
            [string]$Default = ''
        )

        if ($null -ne $Value -and $Value -ne '') {
            return $Value
        }

        return $Default
    }

    #endregion

    try {
        # --- NORMALISE INPUT ---
        # Supports:
        # - plain strings, e.g. 'notepad++'
        # - objects with a Name property, e.g. PSADT ProcessDefinition objects
        $targetProcesses = @(
            $ProcessName |
                ForEach-Object {
                    if ($_ -is [string]) {
                        $_
                    }
                    elseif ($null -ne $_ -and $_.PSObject.Properties['Name']) {
                        $_.Name
                    }
                    else {
                        [string]$_
                    }
                } |
                Where-Object { $null -ne $_ -and $_ -ne '' } |
                ForEach-Object { $_ -replace '\.exe$', '' } |
                Sort-Object -Unique
        )

        Write-ADTLogEntry -Message ("Evaluating processes: [{0}]" -f ($targetProcesses -join ', ')) -Severity 1

        # --- TIMING ---
        $evaluationTime = Get-Date
        $autoContinueTime = $evaluationTime.AddSeconds($NonCloseablePromptTimeout).ToString('HH:mm')

        # --- RESOLVE INTERACTIVE USER USING PSADT ---
        $interactiveUser = $null
        $sessionId = $null
        $sessionName = $null
        $isRdpSession = $null

        try {
            $loggedOnUsers = Get-ADTLoggedOnUser

            if ($loggedOnUsers) {
                $candidate = $loggedOnUsers | Where-Object { $_.IsActiveUserSession -eq $true } | Select-Object -First 1

                if (-not $candidate) {
                    $candidate = $loggedOnUsers | Where-Object { $_.IsCurrentSession -eq $true } | Select-Object -First 1
                }

                if (-not $candidate) {
                    $candidate = $loggedOnUsers | Where-Object { $_.IsValidUserSession -eq $true } | Select-Object -First 1
                }

                if ($candidate) {
                    if ($candidate.NTAccount) {
                        $interactiveUser = [string]$candidate.NTAccount
                    }
                    elseif ($candidate.DomainName -and $candidate.UserName) {
                        $interactiveUser = '{0}\{1}' -f $candidate.DomainName, $candidate.UserName
                    }

                    $sessionId = $candidate.SessionId
                    $sessionName = $candidate.SessionName
                    $isRdpSession = $candidate.IsRdpSession
                }
            }
        }
        catch {
            Write-ADTLogEntry -Message ("Get-ADTLoggedOnUser failed: {0}" -f $_.Exception.Message) -Severity 2
        }

        # Fallback if PSADT user-session resolution did not return a usable user
        if ([string]::IsNullOrWhiteSpace($interactiveUser)) {
            try {
                $interactiveUser = (Get-CimInstance Win32_ComputerSystem).UserName
            }
            catch {
            }
        }

        Write-ADTLogEntry -Message ("Interactive user resolved as: [{0}]" -f (Get-ADTValueOrDefault -Value $interactiveUser -Default 'Unknown')) -Severity 1

        # --- ADD TOKEN HELPER ONCE ---
        if (-not ([System.Management.Automation.PSTypeName]'TokenTools').Type) {
            Add-Type @"
using System;
using System.Runtime.InteropServices;

public class TokenTools {
    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool OpenProcessToken(IntPtr ProcessHandle, UInt32 DesiredAccess, out IntPtr TokenHandle);

    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool GetTokenInformation(
        IntPtr TokenHandle,
        int TokenInformationClass,
        IntPtr TokenInformation,
        int TokenInformationLength,
        out int ReturnLength
    );

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool CloseHandle(IntPtr hObject);
}
"@
        }

        $TOKEN_QUERY = 0x0008
        $TokenElevation = 20

        # --- BUILD LOOKUP TABLE FOR MATCHING PROCESS NAMES ---
        $lookup = @{}
        foreach ($name in $targetProcesses) {
            $clean = ($name -replace '\.exe$', '').ToLowerInvariant()
            $lookup[$clean] = $true
        }

        # --- ENUMERATE MATCHING PROCESSES ---
        $all = @()

        $processes = Get-CimInstance Win32_Process | Where-Object {
            $procName = ($_.Name -replace '\.exe$', '').ToLowerInvariant()
            $lookup.ContainsKey($procName)
        }

        foreach ($proc in $processes) {
            $owner = $null
            $isElevated = $null
            $context = 'Unknown'
            $canClose = $false

            # --- GET OWNER ---
            try {
                $ownerInfo = Invoke-CimMethod -InputObject $proc -MethodName GetOwner
                if ($ownerInfo.ReturnValue -eq 0) {
                    $owner = "$($ownerInfo.Domain)\$($ownerInfo.User)"
                }
            }
            catch {
                Write-ADTLogEntry -Message ("Failed to get owner for PID {0}" -f $proc.ProcessId) -Severity 2
            }

            # --- CHECK TOKEN ELEVATION ---
            $tokenHandle = [IntPtr]::Zero
            $ptr = [IntPtr]::Zero

            try {
                $p = Get-Process -Id $proc.ProcessId -ErrorAction Stop

                if ([TokenTools]::OpenProcessToken($p.Handle, $TOKEN_QUERY, [ref]$tokenHandle)) {
                    $ptr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(4)
                    $returnLength = 0

                    $success = [TokenTools]::GetTokenInformation(
                        $tokenHandle,
                        $TokenElevation,
                        $ptr,
                        4,
                        [ref]$returnLength
                    )

                    if ($success) {
                        $isElevated = ([System.Runtime.InteropServices.Marshal]::ReadInt32($ptr) -eq 1)
                    }
                }
            }
            catch {
                Write-ADTLogEntry -Message ("Failed to inspect elevation for PID {0}" -f $proc.ProcessId) -Severity 2
            }
            finally {
                if ($ptr -ne [IntPtr]::Zero) {
                    [System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptr) | Out-Null
                }

                if ($tokenHandle -ne [IntPtr]::Zero) {
                    [TokenTools]::CloseHandle($tokenHandle) | Out-Null
                }
            }

            # --- CLASSIFY PROCESS CONTEXT ---
            if (-not $owner) {
                $context = 'Unknown'
            }
            elseif ([string]::IsNullOrWhiteSpace($interactiveUser)) {
                $context = 'UnknownInteractiveUser'
            }
            elseif ($owner.Equals($interactiveUser, [System.StringComparison]::OrdinalIgnoreCase)) {
                if ($isElevated -eq $true) {
                    $context = 'SameUser-Elevated'
                }
                else {
                    $context = 'SameUser-Standard'
                }
            }
            else {
                $context = 'DifferentUser'
            }

            # Preserve current model:
            # only same interactive user / standard token is considered closeable
            $canClose = ($context -eq 'SameUser-Standard')

            # --- LOG PER-PROCESS RESULT ---
            $logOwner = Get-ADTValueOrDefault -Value $owner -Default 'Unknown'
            $logElev = Get-ADTValueOrDefault -Value $isElevated -Default 'Unknown'

            Write-ADTLogEntry -Message (
                'Process [{0}] PID [{1}] Owner [{2}] Elevated [{3}] Context [{4}] CanClose [{5}]' -f
                $proc.Name,
                $proc.ProcessId,
                $logOwner,
                $logElev,
                $context,
                $canClose
            ) -Severity 1

            # --- OUTPUT RAW PROCESS RESULT ---
            $all += [PSCustomObject]@{
                ProcessName = ($proc.Name -replace '\.exe$', '')
                ProcessId   = [int]$proc.ProcessId
                Owner       = $owner
                Elevated    = $isElevated
                Context     = $context
                CanClose    = $canClose
            }
        }

        if (-not $all) {
            Write-ADTLogEntry -Message 'No matching running processes detected.' -Severity 1
        }

        # --- DERIVED BUCKETS ---
        $closeable = @($all | Where-Object { $_.CanClose -eq $true })
        $nonCloseable = @($all | Where-Object { $_.CanClose -ne $true })

        # --- PROCESS-NAME LISTS ---
        $closeableNames = @(
            $closeable |
                Select-Object -ExpandProperty ProcessName -ErrorAction SilentlyContinue |
                Where-Object { $_ } |
                ForEach-Object { $_ -replace '\.exe$', '' } |
                Sort-Object -Unique
        )

        $nonCloseableNames = @(
            $nonCloseable |
                Select-Object -ExpandProperty ProcessName -ErrorAction SilentlyContinue |
                Where-Object { $_ } |
                ForEach-Object { $_ -replace '\.exe$', '' } |
                Sort-Object -Unique
        )

        $closeableList = $closeableNames -join ', '
        $nonCloseableList = $nonCloseableNames -join ', '

        # --- DISPLAY TEXT ---
        $nonCloseableDisplayText = (
            $nonCloseable |
                Sort-Object ProcessName, Owner, ProcessId |
                ForEach-Object {
                    "{0} ({1}; {2})" -f `
                        (Get-ADTValueOrDefault $_.ProcessName 'UnknownProcess'),
                        (Get-ADTValueOrDefault $_.Owner 'UnknownOwner'),
                        (Get-ADTValueOrDefault $_.Context 'UnknownContext')
                }
        ) -join [Environment]::NewLine

        # --- RESULT OBJECT ---
        $state = [PSCustomObject]@{
            PSTypeName = 'ADT.ProcessHandlingState'

            TargetProcesses           = $targetProcesses
            NonCloseablePromptTimeout = $NonCloseablePromptTimeout
            EvaluationTime            = $evaluationTime
            AutoContinueTime          = $autoContinueTime

            InteractiveUser           = $interactiveUser
            SessionId                 = $sessionId
            SessionName               = $sessionName
            IsRdpSession              = $isRdpSession

            All                       = $all
            Closeable                 = $closeable
            NonCloseable              = $nonCloseable

            CloseableProcessList      = $closeableNames
            NonCloseableProcessList   = $nonCloseableNames

            CloseableList             = $closeableList
            NonCloseableList          = $nonCloseableList
            NonCloseableDisplayText   = $nonCloseableDisplayText

            HasRunningProcesses       = ($all.Count -gt 0)
            HasCloseable              = ($closeable.Count -gt 0)
            HasNonCloseable           = ($nonCloseable.Count -gt 0)
            TotalCount                = $all.Count
            CloseableCount            = $closeable.Count
            NonCloseableCount         = $nonCloseable.Count
        }

        if ($state.TotalCount -gt 0) {
            Write-ADTLogEntry -Message (
                'Total [{0}] Closeable [{1}] NonCloseable [{2}] User [{3}] AutoContinue [{4}]' -f
                $state.TotalCount,
                $state.CloseableCount,
                $state.NonCloseableCount,
                (Get-ADTValueOrDefault $state.InteractiveUser 'Unknown'),
                $state.AutoContinueTime
            ) -Severity 1
        }

        return $state
    }
    catch {
        Write-ADTLogEntry -Message ("Get-ADTProcessHandlingState failed: {0}" -f $_.Exception.Message) -Severity 3
        throw
    }
}

I have found that for some apps, my solution will not open as the logged on user if the app is already running elevated, for example regedit. So, this method isn't 100% foolproof.

I may try your solution and see if I have better luck. I don't like adding an extra prompt to the user, but if it makes sense, and it this case, it does, then so be it.

Question: What happens if the @nonCloseablePromptParams hits the timeout? The documentation says it continues, but I assume it leaves the process running. Is this correct?

Question: What happens if the @nonCloseablePromptParams hits the timeout? The documentation says it continues, but I assume it leaves the process running. Is this correct?

Yes, it just continues with the script, nothing else. So it depends on what command you decide to run next. For us, the next action is Show-ADTInstallationWelcome to quietly kill the running processes. But you may choose to do something else, like close the session with a 1618 (fast retry) code.