Private/Invoke-AppWorker.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Per-app launch worker: validates path, checks if running, launches, and maximizes. .DESCRIPTION Invoke-AppWorker contains the per-app launch sequence used by Start-EnvkAppBatch's parallel workers. It mirrors Start-EnvkAppSequence's inner foreach body but uses a buffered log approach: all messages are collected in a List[PSCustomObject] and returned in the result object instead of calling Write-EnvkLog directly. This separation exists so the per-app logic can be tested with InModuleScope mocks independently of the RunspacePool infrastructure. Start-EnvkAppBatch's worker scriptblock calls this function after the module is loaded via InitialSessionState. Execution flow: a. Resolve-EnvkExecutablePath for just-in-time path validation. - Valid=$false -> Skipped result, return. b. Get-AppProcessIds by ProcessName + path to check if already running. - Returns PIDs -> AlreadyRunning result, Invoke-WindowMaximize, return. c. Launch: - UWP: Start-Process without PassThru, poll Get-Process by name. - Win32: Start-Process -PassThru to capture PID. - Throws -> Failed result, return. d. Grace period: Start-Sleep -Milliseconds $script:GracePeriodMs. e. Invoke-WindowMaximize with resolved PID. f. Return Launched result. .PARAMETER App Resolved app PSCustomObject with Name, Path, ProcessName, Arguments, SkipMaximize. .PARAMETER WindowTimeoutMs Window detection timeout in milliseconds. Sets $script:TimeoutMs for Invoke-WindowMaximize and the UWP PID poll loop. .PARAMETER EnableDebug When $true, passed through to Invoke-WindowMaximize as a bool. .OUTPUTS [PSCustomObject] Single result object with: AppName [string] Status [string] -- 'Launched' | 'AlreadyRunning' | 'Failed' | 'Skipped' ElapsedMs [int] MaximizeResult [PSCustomObject] or $null ErrorMessage [string] or $null LogLines [System.Collections.Generic.List[PSCustomObject]] -- Level/Message entries .NOTES Author: Aaron AlAnsari Created: 2026-02-25 Called by: Start-EnvkAppBatch (worker scriptblock in each STA runspace) Not in FunctionsToExport -- this is an internal helper. GRACE PERIOD: Uses $script:GracePeriodMs (set to 2000 by Start-EnvkAppBatch workers). In tests, override to 0 via InModuleScope to avoid real sleeps. TIMEOUT: Sets $script:TimeoutMs from $WindowTimeoutMs parameter at invocation start. This aligns with Start-EnvkAppSequence's same pattern. #> function Invoke-AppWorker { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [PSCustomObject]$App, [Parameter(Mandatory)] [int]$WindowTimeoutMs, [Parameter()] [bool]$EnableDebug = $false ) # Set module-scope variables required by Invoke-WindowMaximize. $script:TimeoutMs = $WindowTimeoutMs $logLines = [System.Collections.Generic.List[PSCustomObject]]::new() $appStopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { # ------------------------------------------------------------------ # Step a: Just-in-time path validation. # ------------------------------------------------------------------ $pathResult = Resolve-EnvkExecutablePath -Path $App.Path if ($EnableDebug) { $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' path validation -- Valid=$($pathResult.Valid), Path='$($App.Path)'" }) } if (-not $pathResult.Valid) { $errMsg = "Path validation failed for '$($App.Name)': $($pathResult.Reason)" $logLines.Add([PSCustomObject]@{ Level = 'WARNING'; Message = "Start-EnvkAppBatch: $errMsg" }) $appStopwatch.Stop() return [PSCustomObject]@{ AppName = $App.Name Status = 'Skipped' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $null ErrorMessage = $errMsg LogLines = $logLines } } # ------------------------------------------------------------------ # Step b: Already-running detection. # ------------------------------------------------------------------ # Get-AppProcessIds filters by executable path before expanding descendants, # preventing false positives from process name collisions (e.g., the Claude # desktop app and the Claude Code VSCode extension both run processes named # 'claude'; only the path-matching PIDs should be passed to Invoke-WindowMaximize). # -RequireWindow excludes suspended UWP processes that have no visible window # (MainWindowHandle = IntPtr.Zero), preventing false-positive already-running # detection for apps that leave background processes after being closed. $existingPids = Get-AppProcessIds -ProcessName $App.ProcessName -ConfigPath $App.Path -Arguments $App.Arguments -RequireWindow if ($EnableDebug) { $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' already-running check -- $($existingPids.Count) PID(s) found (RequireWindow=true)" }) } if ($existingPids.Count -gt 0) { $logLines.Add([PSCustomObject]@{ Level = 'INFO' Message = "Start-EnvkAppBatch: '$($App.Name)' already running ($($existingPids.Count) PID(s)) -- skipping launch, maximizing existing window" }) $maxResult = Invoke-WindowMaximize -Name $App.Name -ProcessIds $existingPids -SkipMaximize:$App.SkipMaximize -EnableDebug:$EnableDebug if ($EnableDebug) { $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' maximize result (already-running) -- Success=$($maxResult.Success), Strategy=$($maxResult.Strategy), ElapsedMs=$($maxResult.ElapsedMs)" }) } $appStopwatch.Stop() return [PSCustomObject]@{ AppName = $App.Name Status = 'AlreadyRunning' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $maxResult ErrorMessage = $null LogLines = $logLines } } # ------------------------------------------------------------------ # Step c: Launch the application. # ------------------------------------------------------------------ $isUwp = $App.Path -like 'shell:AppsFolder\*' $launchedPid = $null $startParams = @{ FilePath = $App.Path ErrorAction = 'Stop' } if (-not [string]::IsNullOrEmpty($App.Arguments)) { $startParams['ArgumentList'] = $App.Arguments } $logLines.Add([PSCustomObject]@{ Level = 'INFO' Message = "Start-EnvkAppBatch: Launching '$($App.Name)' from '$($App.Path)'" }) if ($EnableDebug) { $argsDisplay = if ([string]::IsNullOrEmpty($App.Arguments)) { '(none)' } else { $App.Arguments } $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' launch params -- IsUWP=$isUwp, Arguments='$argsDisplay', GracePeriodMs=$($script:GracePeriodMs)" }) } if ($isUwp) { $null = Start-Process @startParams Start-Sleep -Milliseconds $script:GracePeriodMs # Poll using path-filtered Get-AppProcessIds (not raw Get-Process -Name) to # avoid capturing the wrong PID when another process shares the same name # (e.g., Claude Code VSCode extension running alongside Claude UWP desktop app). # Uses exponential backoff: first miss sleeps $script:InitialPollMs, # each subsequent miss doubles up to $script:MaxPollMs. # Do NOT pass -RequireWindow here -- the app may not yet have a visible window. $pollStart = [System.Diagnostics.Stopwatch]::StartNew() $uwpPollInterval = $script:InitialPollMs $uwpAllPids = $null while ($null -eq $uwpAllPids -and $pollStart.ElapsedMilliseconds -lt $script:TimeoutMs) { $polledPids = Get-AppProcessIds -ProcessName $App.ProcessName -ConfigPath $App.Path if ($polledPids.Count -gt 0) { # Keep ALL polled PIDs — UWP apps spawn sibling processes (not parent-child) # and the window-owning PID may not be the first one returned. $uwpAllPids = $polledPids $launchedPid = $polledPids[0] } else { Start-Sleep -Milliseconds $uwpPollInterval $uwpPollInterval = [Math]::Min($uwpPollInterval * $script:BackoffFactor, $script:MaxPollMs) } } $pollStart.Stop() if ($null -eq $launchedPid) { $warnMsg = "UWP process '$($App.ProcessName)' did not appear within timeout for '$($App.Name)'" $logLines.Add([PSCustomObject]@{ Level = 'WARNING'; Message = "Start-EnvkAppBatch: $warnMsg" }) $launchedPid = 0 } elseif ($EnableDebug) { $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' UWP process found -- PID $launchedPid" }) } } else { $startParams['PassThru'] = $true $launchedProcess = Start-Process @startParams $launchedPid = $launchedProcess.Id Start-Sleep -Milliseconds $script:GracePeriodMs if ($EnableDebug) { $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' Win32 process launched -- PID $launchedPid" }) } } # ------------------------------------------------------------------ # Step d: Window maximization. # ------------------------------------------------------------------ # UWP apps spawn sibling processes (not parent-child). If we only pass the first # PID via -ProcessId, descendant expansion won't reach the window-owning sibling. # Pass all path-matching PIDs via -ProcessIds so the window search covers all of them. if ($null -ne $uwpAllPids) { $maxResult = Invoke-WindowMaximize -Name $App.Name -ProcessIds $uwpAllPids -SkipMaximize:$App.SkipMaximize -EnableDebug:$EnableDebug } else { $maxResult = Invoke-WindowMaximize -Name $App.Name -ProcessId $launchedPid -SkipMaximize:$App.SkipMaximize -EnableDebug:$EnableDebug } if (-not $maxResult.Success) { $logLines.Add([PSCustomObject]@{ Level = 'WARNING' Message = "Start-EnvkAppBatch: window maximize did not succeed for '$($App.Name)' (Strategy=$($maxResult.Strategy)) -- app still launched" }) } else { $logLines.Add([PSCustomObject]@{ Level = 'INFO' Message = "Start-EnvkAppBatch: '$($App.Name)' launched and maximized (PID $launchedPid, Strategy=$($maxResult.Strategy))" }) } if ($EnableDebug) { $logLines.Add([PSCustomObject]@{ Level = 'DEBUG' Message = "Start-EnvkAppBatch: '$($App.Name)' maximize result (launched) -- Success=$($maxResult.Success), Strategy=$($maxResult.Strategy), ElapsedMs=$($maxResult.ElapsedMs)" }) } $appStopwatch.Stop() return [PSCustomObject]@{ AppName = $App.Name Status = 'Launched' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $maxResult ErrorMessage = $null LogLines = $logLines } } catch { $errMsg = $_.Exception.Message $logLines.Add([PSCustomObject]@{ Level = 'ERROR' Message = "Start-EnvkAppBatch: failed to launch '$($App.Name)' -- $errMsg" }) $appStopwatch.Stop() return [PSCustomObject]@{ AppName = $App.Name Status = 'Failed' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $null ErrorMessage = $errMsg LogLines = $logLines } } } |