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
        }
    }
}