Private/Get-AppProcessIds.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Returns path-filtered and descendant-expanded process IDs for a given application. .DESCRIPTION Filters Get-Process results by executable path before expanding descendants, solving the process name collision problem where multiple unrelated applications share the same process name (e.g., Claude desktop vs the Claude Code VSCode extension both run processes named 'claude'). For Win32 apps, performs case-insensitive, slash-normalized, environment-variable- expanded path comparison against the config path. For UWP apps configured via shell:AppsFolder, extracts the publisher hash from the AUMID and matches it as a directory-suffix anchor against the process executable path. For bare-executable paths (no directory component, e.g. 'explorer.exe'), always returns @() regardless of running processes. Bare executables like explorer.exe are invariably present as system processes; name-only matching would permanently suppress the configured launch (which typically supplies a folder argument to open a new window). Callers that receive @() will always launch the app fresh, which is correct behavior. Processes with null or inaccessible Path properties are excluded from the result. After path filtering, expands descendants of each matching PID via Get-DescendantProcessIds. This is a private helper used by Start-EnvkAppSequence (sequential) and Invoke-AppWorker (parallel) to replace direct Get-Process + descendant expansion calls. .PARAMETER ProcessName The process name (without .exe extension) to search for, as passed to Get-Process -Name. .PARAMETER ConfigPath The raw executable path from the application config. May be a Win32 filesystem path (with or without environment variables) or a UWP AUMID in shell:AppsFolder format (e.g., 'shell:AppsFolder\Claude_pzs8sxrjxfjjc!Claude'). .PARAMETER Arguments Optional. The configured arguments string for the application. Used only for bare- executable detection to perform argument-aware already-running checks. For apps like explorer.exe that accept a folder path as their argument, passing -Arguments allows detection of existing windows showing that specific folder (matched against window title). When not provided or when no matching window is found, bare executables return @() as usual (caller launches fresh). .PARAMETER RequireWindow When set, filters out path-matching PIDs whose process has MainWindowHandle equal to IntPtr.Zero (suspended or backgrounded UWP processes). Use this switch for already-running detection to prevent false-positive detection of suspended UWP processes from a prior session. Do NOT pass this switch during post-launch polling, where the app may not yet have a visible window. .OUTPUTS [int[]] -- Array of path-matched and descendant-expanded process IDs, or @() if none match. .NOTES Author: Aaron AlAnsari Created: 2026-02-28 #> # PSUseSingularNouns: intentionally plural -- this function returns a collection of # integer process IDs. The plural noun accurately describes the return value. [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] # PSUseOutputTypeCorrectly: @(...) produces System.Object[] as the outer container, # but the declared OutputType [int[]] accurately describes the element type. This is # the same pattern used by Resolve-AppConfig to prevent single-element unboxing. [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] param () function Get-AppProcessIds { [CmdletBinding()] [OutputType([int[]])] param ( [Parameter(Mandatory)] [string]$ProcessName, [Parameter(Mandatory)] [string]$ConfigPath, [Parameter()] [string]$Arguments, [Parameter()] [switch]$RequireWindow ) $allMatches = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue if ($null -eq $allMatches) { return @() } $isUwp = $ConfigPath -like 'shell:AppsFolder\*' # Bare-executable detection: a config path with no directory component (e.g., 'explorer.exe') # cannot be path-compared against a full process path (e.g., 'C:\Windows\explorer.exe'). # Detect this case by checking whether GetDirectoryName returns empty/null after env-var # expansion. When true, return @() immediately -- see .DESCRIPTION for rationale. $isBareExecutable = $false if (-not $isUwp) { $expandedPath = [System.Environment]::ExpandEnvironmentVariables($ConfigPath) $directoryPart = [System.IO.Path]::GetDirectoryName($expandedPath) # GetDirectoryName returns empty string for bare filenames like 'explorer.exe' # and $null for root paths like 'C:\'. Both indicate no meaningful directory component. $isBareExecutable = [string]::IsNullOrEmpty($directoryPart) } if ($isUwp) { # Extract package family name and publisher hash from AUMID: # 'shell:AppsFolder\Claude_pzs8sxrjxfjjc!Claude' -> 'Claude_pzs8sxrjxfjjc' # PackageFamilyName format: {PackageName}_{PublisherHash} # Installed directory format: {PackageName}_{Version}_{Arch}__{PublisherHash} # # The versioned directory name is NOT a simple substring match on the family name # (the double-underscore '__' separator separates Arch from PublisherHash in the # directory, while the family name uses a single '_'). Use the publisher hash as # the stable suffix anchor. Publisher hashes are globally unique (Base32-encoded # public key hashes), preventing cross-publisher false positives. $aumid = $ConfigPath -replace '^shell:AppsFolder\\', '' $packageFamilyName = $aumid.Split('!')[0].ToLowerInvariant() # Publisher hash is everything after the last '_' in the family name $publisherHash = $packageFamilyName.Split('_')[-1] # Directory-suffix-bounded match: __publisherHash\ anchors to the end of the # versioned directory component (e.g., 'claude_1.1.4498.0_x64__pzs8sxrjxfjjc\') $matchToken = "__$publisherHash\" } elseif (-not $isBareExecutable) { # Normalize config path: expand env vars, lowercase, forward -> back slash $normalizedConfig = [System.Environment]::ExpandEnvironmentVariables($ConfigPath).ToLowerInvariant().Replace('/', '\') } $filteredPids = [System.Collections.Generic.List[int]]::new() if ($isBareExecutable) { # Bare executable name (no directory component): cannot perform path-based already-running # detection. 'explorer.exe' would need to match 'C:\Windows\explorer.exe', but no directory # component is available for comparison. More critically, bare executables like explorer.exe # are always running as system processes regardless of whether the config-specified launch # (with its folder argument) has occurred -- name-only matching would always return system # PIDs and prevent the configured launch from ever happening. # # Argument-aware detection (optional): when -Arguments is provided and looks like a folder # path, use Shell.Application COM to enumerate open Explorer folder windows and compare # their location paths against the argument. Get-Process.MainWindowTitle cannot be used # because the shell explorer.exe process reports "Program Manager" as its title (the # desktop), not the folder name shown in the File Explorer title bar. # Shell.Application.Windows() returns all open Explorer windows; each has a Document.Folder # whose Self.Path gives the displayed folder path. This is the standard Windows API for # enumerating shell browser windows. # If a matching window is found, return the first explorer PID as a sentinel so the caller # treats the app as already-running and skips relaunch. If no match, fall through to @() # so the caller launches fresh (correct behavior). if (-not [string]::IsNullOrEmpty($Arguments)) { try { $normalizedTarget = [System.IO.Path]::GetFullPath($Arguments).TrimEnd('\', '/').ToLowerInvariant() $shell = New-Object -ComObject Shell.Application $shellWindows = $shell.Windows() $found = $false foreach ($window in $shellWindows) { try { $docPath = $window.Document.Folder.Self.Path if ($null -ne $docPath -and $docPath.TrimEnd('\', '/').ToLowerInvariant() -eq $normalizedTarget) { $found = $true break } } catch { continue } } if ($found) { # Return the first explorer PID as a sentinel -- the caller only needs a # non-empty array to know the app is already running. For bare executables # with skipMaximize=true (typical for explorer), the PID is never used for # window operations. $sentinelPid = @($allMatches)[0].Id Write-EnvkLog -Level 'DEBUG' -Message "Get-AppProcessIds: bare executable '$ConfigPath' -- Shell.Application found open folder window for '$Arguments'" return @($sentinelPid) } } catch { Write-EnvkLog -Level 'DEBUG' -Message "Get-AppProcessIds: Shell.Application check failed for '$ConfigPath' -- $($_.Exception.Message)" } } Write-EnvkLog -Level 'DEBUG' -Message "Get-AppProcessIds: bare executable '$ConfigPath' -- no matching folder window found, returning @() (will launch fresh)" return @() } else { foreach ($proc in @($allMatches)) { # Guard: .Path can return $null for system/protected processes, or throw # Win32Exception/InvalidOperationException for processes in restricted sessions. try { $procPath = $proc.Path } catch { $procPath = $null } if ($null -eq $procPath) { continue } if ($isUwp) { $normalizedProcPath = $procPath.ToLowerInvariant().Replace('/', '\') if ($normalizedProcPath -notlike "*$matchToken*") { continue } } else { $normalizedProcPath = [System.Environment]::ExpandEnvironmentVariables($procPath).ToLowerInvariant().Replace('/', '\') if ($normalizedProcPath -ne $normalizedConfig) { continue } } $filteredPids.Add($proc.Id) } } if ($filteredPids.Count -eq 0) { Write-EnvkLog -Level 'DEBUG' -Message "Get-AppProcessIds: no path-matching processes found for '$ProcessName' (config path: $ConfigPath)" return @() } # -RequireWindow filtering: exclude PIDs whose process has no visible window # (MainWindowHandle = IntPtr.Zero). UWP apps can leave suspended background # processes from prior sessions; these have no MainWindowHandle and should not # be treated as "already running" when the user has closed the app. # Do NOT use -RequireWindow during post-launch polling, where the window may not # yet have appeared. if ($RequireWindow -and $filteredPids.Count -gt 0) { $activePids = [System.Collections.Generic.List[int]]::new() foreach ($filteredPid in $filteredPids) { try { $proc = Get-Process -Id $filteredPid -ErrorAction SilentlyContinue if ($null -ne $proc -and $proc.MainWindowHandle -ne [IntPtr]::Zero) { $activePids.Add($filteredPid) } } catch { # Process may have exited between path-filtering and window check; skip it Write-EnvkLog -Level 'DEBUG' -Message "Get-AppProcessIds: PID $filteredPid exited during -RequireWindow check -- skipping" } } if ($activePids.Count -eq 0 -and $filteredPids.Count -gt 0) { Write-EnvkLog -Level 'DEBUG' -Message "Get-AppProcessIds: $($filteredPids.Count) path-matching process(es) for '$ProcessName' have no visible window (suspended/background) -- treating as not running" return @() } $filteredPids = $activePids } # Expand descendants of each filtered PID and union all results. $expandedPids = [System.Collections.Generic.List[int]]::new() $expandedPids.AddRange($filteredPids) foreach ($filteredPid in $filteredPids) { $children = Get-DescendantProcessIds -ParentId $filteredPid if ($children) { foreach ($child in $children) { $expandedPids.Add($child) } } } return @($expandedPids | Select-Object -Unique) } |