Public/Start-EnvkAppSequence.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Sequential app launch loop: resolves, validates, launches, and maximizes each app in configuration order, returning a structured per-app result array. .DESCRIPTION Start-EnvkAppSequence is the core value delivery of Phase 6. It composes all prior building blocks (Resolve-AppConfig, Resolve-EnvkExecutablePath, Invoke-WindowMaximize) into an end-to-end sequential launch function suitable for PS 5.1 (and used as fallback in PS 7+ parallel mode from Phase 7). Execution flow: 1. Use pre-resolved Apps if provided; otherwise call Resolve-AppConfig. 2. Read windowTimeoutSeconds from $Config.settings (default 120 if absent). 3. Set $script:TimeoutMs for Invoke-WindowMaximize (module-scope variable). 4. Start total stopwatch for summary timing. 5. For each app (sequential foreach): a. Start per-app stopwatch. b. Resolve-EnvkExecutablePath for just-in-time path validation. - Valid=$false -> Skipped result, continue. c. Get-AppProcessIds by ProcessName + path to check if already running. - Returns PIDs -> AlreadyRunning result, call Invoke-WindowMaximize with existing PIDs, continue. d. Launch: - UWP (path -like 'shell:AppsFolder\*'): Start-Process without -PassThru. Poll Get-Process by ProcessName until found or $script:TimeoutMs elapsed. - Win32: Start-Process -PassThru to capture PID directly. - If Start-Process throws -> Failed result, continue. e. Grace period: Start-Sleep -Milliseconds $script:GracePeriodMs. f. Invoke-WindowMaximize with resolved PID. - Success=$false -> WARNING log; Status is still 'Launched'. g. Add Launched result. 6. Emit summary log line. 7. Return results array. Result object shape (per app): AppName [string] -- Display name from catalog key Status [string] -- 'Launched' | 'AlreadyRunning' | 'Failed' | 'Skipped' ElapsedMs [int] -- Milliseconds for this app (launch + maximize) MaximizeResult [PSCustomObject] -- From Invoke-WindowMaximize, or $null on Failed/Skipped ErrorMessage [string] -- Non-null on Failed or Skipped; $null on success Summary log line format (CONTEXT.md): "Launch complete: N launched, N already running, N failed (AppName). Total: N apps, Ns" Skipped count included only if > 0. .PARAMETER Config The parsed config PSCustomObject returned by Get-EnvkConfig (ConvertFrom-Json output). Used to call Resolve-AppConfig and to read settings.windowTimeoutSeconds. .PARAMETER EnvironmentName The resolved environment name from Select-EnvkEnvironment. Passed directly to Resolve-AppConfig. .PARAMETER Apps Optional pre-resolved app list. When provided, the internal Resolve-AppConfig call is skipped and this array is used directly. Used by Invoke-EnvkStartup to avoid resolving the app list twice when it has already called Resolve-AppConfig. .PARAMETER EnableDebug When set, passed through to Invoke-WindowMaximize for detailed UIAutomation logging. .OUTPUTS [PSCustomObject[]] Array of per-app result objects. One element per app in the resolved config order. .NOTES Author: Aaron AlAnsari Created: 2026-02-25 TIMING: $script:TimeoutMs is set at the start of each invocation from $Config.settings.windowTimeoutSeconds (default 120s). This overwrites the value set by Invoke-WindowMaximize.ps1 at import time. Sequential execution means there is no race condition on this shared module-scope variable. GRACE PERIOD: $script:GracePeriodMs = 2000 is set at file scope. Tests override it to 0 inside InModuleScope to avoid real sleeps. Same pattern as PollIntervalMs in Invoke-WindowMaximize.ps1. #> # PSUseOutputTypeCorrectly: the unary comma operator forces a PowerShell Object[] wrapper # around the List.ToArray() result to prevent single-element unboxing through the pipeline. # The element type is always PSCustomObject; the Object[] container is an implementation detail. # PSUseShouldProcessForStateChangingFunctions: Start-EnvkAppSequence is an orchestration function # that delegates all state changes to Start-Process (which supports -WhatIf natively) and to # Invoke-WindowMaximize. Adding SupportsShouldProcess at this level would leave $launchedPid # null in -WhatIf mode and break the post-launch window maximization path. [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] param () # --------------------------------------------------------------------------- # Script-scope configuration -- overridable in tests via InModuleScope. # --------------------------------------------------------------------------- $script:GracePeriodMs = 2000 function Start-EnvkAppSequence { [CmdletBinding()] [OutputType([PSCustomObject[]])] param ( [Parameter(Mandatory)] [PSCustomObject]$Config, [Parameter(Mandatory)] [string]$EnvironmentName, [Parameter()] [PSCustomObject[]]$Apps, [Parameter()] [switch]$EnableDebug ) # ------------------------------------------------------------------ # Step 1: Use pre-resolved apps if provided; otherwise resolve internally. # ------------------------------------------------------------------ if ($null -ne $Apps -and $Apps.Count -gt 0) { $apps = $Apps } else { $apps = Resolve-AppConfig -Config $Config -EnvironmentName $EnvironmentName } # ------------------------------------------------------------------ # Step 2: Read timeout from config.settings; default 120s. # ------------------------------------------------------------------ $windowTimeoutSeconds = 120 if ($null -ne $Config.PSObject.Properties['settings'] -and $null -ne $Config.settings.PSObject.Properties['windowTimeoutSeconds']) { $windowTimeoutSeconds = [int]$Config.settings.windowTimeoutSeconds } # ------------------------------------------------------------------ # Step 3: Set module-scope TimeoutMs for Invoke-WindowMaximize. # Sequential execution: no race condition on this shared variable. # ------------------------------------------------------------------ $script:TimeoutMs = $windowTimeoutSeconds * 1000 # ------------------------------------------------------------------ # Step 4: Start total stopwatch for summary timing. # ------------------------------------------------------------------ $totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Accumulate results and status counters. $results = [System.Collections.Generic.List[PSCustomObject]]::new() $launchedCount = 0 $alreadyCount = 0 $failedCount = 0 $skippedCount = 0 $failedNames = [System.Collections.Generic.List[string]]::new() # ------------------------------------------------------------------ # Step 5: Sequential per-app launch loop. # ------------------------------------------------------------------ foreach ($app in $apps) { $appStopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { # ---------------------------------------------------------- # Step 5a: Just-in-time path validation. # ---------------------------------------------------------- $pathResult = Resolve-EnvkExecutablePath -Path $app.Path if (-not $pathResult.Valid) { $errMsg = "Path validation failed for '$($app.Name)': $($pathResult.Reason)" Write-EnvkLog -Level 'WARNING' -Message "Start-EnvkAppSequence: $errMsg" $appStopwatch.Stop() $results.Add([PSCustomObject]@{ AppName = $app.Name Status = 'Skipped' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $null ErrorMessage = $errMsg }) $skippedCount++ continue } # ---------------------------------------------------------- # Step 5b: 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 ($existingPids.Count -gt 0) { Write-EnvkLog -Level 'INFO' -Message "Start-EnvkAppSequence: '$($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 $appStopwatch.Stop() $results.Add([PSCustomObject]@{ AppName = $app.Name Status = 'AlreadyRunning' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $maxResult ErrorMessage = $null }) $alreadyCount++ continue } # ---------------------------------------------------------- # Step 5c: 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 } if ($isUwp) { # UWP: Start-Process without -PassThru (returned PID would be the # intermediate shell process, not the real app process). # Assign to $null to prevent any pipeline output from being collected. $null = Start-Process @startParams # Grace period: wait for UWP app to initialize before polling. 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. Passing all # PIDs to Invoke-WindowMaximize via -ProcessIds ensures the window search # covers every path-matching process. $uwpAllPids = $polledPids $launchedPid = $polledPids[0] } else { Start-Sleep -Milliseconds $uwpPollInterval $uwpPollInterval = [Math]::Min($uwpPollInterval * $script:BackoffFactor, $script:MaxPollMs) } } $pollStart.Stop() if ($null -eq $launchedPid) { $errMsg = "UWP process '$($app.ProcessName)' did not appear within timeout for '$($app.Name)'" Write-EnvkLog -Level 'WARNING' -Message "Start-EnvkAppSequence: $errMsg" # Use PID 0 as sentinel -- Invoke-WindowMaximize will timeout and return failure $launchedPid = 0 } } else { # Win32: Start-Process -PassThru captures the launched PID directly. $startParams['PassThru'] = $true $launchedProcess = Start-Process @startParams $launchedPid = $launchedProcess.Id # Grace period: wait for Win32 app window to initialize. Start-Sleep -Milliseconds $script:GracePeriodMs } # ---------------------------------------------------------- # Step 5d: 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) { Write-EnvkLog -Level 'WARNING' -Message "Start-EnvkAppSequence: window maximize did not succeed for '$($app.Name)' (Strategy=$($maxResult.Strategy)) -- app still launched, continuing" } $appStopwatch.Stop() $results.Add([PSCustomObject]@{ AppName = $app.Name Status = 'Launched' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $maxResult ErrorMessage = $null }) $launchedCount++ } catch { # Safety net: catch unanticipated errors (including Start-Process throws). $errMsg = $_.Exception.Message Write-EnvkLog -Level 'ERROR' -Message "Start-EnvkAppSequence: failed to launch '$($app.Name)' -- $errMsg" $appStopwatch.Stop() $results.Add([PSCustomObject]@{ AppName = $app.Name Status = 'Failed' ElapsedMs = [int]$appStopwatch.ElapsedMilliseconds MaximizeResult = $null ErrorMessage = $errMsg }) $failedCount++ $failedNames.Add($app.Name) } } # ------------------------------------------------------------------ # Step 6: Emit summary log line. # Format: "Launch complete: N launched, N already running, N failed (Names). Total: N apps, Xs" # ------------------------------------------------------------------ $totalStopwatch.Stop() $totalSecs = [math]::Round($totalStopwatch.Elapsed.TotalSeconds, 1) $totalApps = $results.Count $summaryParts = [System.Collections.Generic.List[string]]::new() $summaryParts.Add("$launchedCount launched") $summaryParts.Add("$alreadyCount already running") if ($failedCount -gt 0) { $failedNamesStr = $failedNames -join ', ' $summaryParts.Add("$failedCount failed ($failedNamesStr)") } else { $summaryParts.Add('0 failed') } if ($skippedCount -gt 0) { $summaryParts.Add("$skippedCount skipped") } $summaryLine = "Launch complete: $($summaryParts -join ', '). Total: $totalApps apps, $($totalSecs)s" Write-EnvkLog -Level 'INFO' -Message $summaryLine # ------------------------------------------------------------------ # Step 7: Return results array. # ------------------------------------------------------------------ return , $results.ToArray() } |