Public/Invoke-EnvkStartup.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Top-level orchestrator that coordinates config loading, boot guard, schedule detection, and application launching into a complete startup workflow. .DESCRIPTION Invoke-EnvkStartup is the single entry point that ties all prior phases together. It loads configuration, checks the boot guard, detects the active environment via schedule rules, resolves the app list, groups apps by priority, and dispatches each priority group to the appropriate launcher (Start-EnvkAppBatch on PS 7+ for parallel execution, or Start-EnvkAppSequence on PS 5.1 for sequential execution). After all groups have been dispatched, a single retry pass re-launches any failed or partially-failed apps. A summary table is logged at the end. Execution flow: 1. Load config (Get-EnvkConfig). 2. Boot guard check — skip when -SkipBootGuard is set. Returns $null if already ran this boot session. 3. Update registry (Set-LastRunTime) — skip when -SkipBootGuard is set. 4. Detect environment (Select-EnvkEnvironment). Returns $null if no environment matched and no fallback is configured. 5. Read settings: parallelLimit, forceSequential, windowTimeoutSeconds. 6. Resolve app list (Resolve-AppConfig). 7. Determine launch mode (parallel vs sequential). 8. Log launch mode. 9. Group apps by Priority ascending and dispatch each group. 10. Collect all results. 11. Retry pass: re-launch Failed apps or apps with maximize failure. 12. Log summary table. 13. Return results array. Priority groups and batching: - Apps are grouped by their Priority integer (ascending order: 0 before 1, etc.). - In parallel mode, each group is dispatched to Start-EnvkAppBatch as a batch. LogLines from each batch are flushed via Write-EnvkLog after the batch completes. - In sequential mode, Start-EnvkAppSequence is called once with the full config and environment name. Priority is not reordered in sequential mode — config order is the expected behavior on PS 5.1 (see CONTEXT.md). .PARAMETER ConfigPath Optional explicit path to a config.json file. When provided, passed directly to Get-EnvkConfig -ConfigPath. When omitted, Get-EnvkConfig uses the default three-location search order. .PARAMETER Environment Optional environment name override. When provided, schedule detection is skipped and this environment name is used directly. Passed to Select-EnvkEnvironment -Environment. .PARAMETER SkipBootGuard When set, the boot guard check and registry update are skipped, allowing repeated test runs without modifying registry state. Does not affect logging verbosity. .PARAMETER EnableDebug When set, enables DEBUG-level log entries. Sets $script:Config['EnableDebug'] to $true so that Write-EnvkLog emits DEBUG messages. Passed through to downstream functions (Start-EnvkAppBatch, Start-EnvkAppSequence) for their own debug behaviors (e.g., detailed UIAutomation logging). .PARAMETER Sequential When set, forces sequential launch mode regardless of the running PS version or the forceSequential config flag. Useful for debugging launch issues or environments where parallel execution is undesirable. .OUTPUTS [PSCustomObject[]] Array of per-app result objects (same shape as Start-EnvkAppBatch / Start-EnvkAppSequence): AppName [string] Status [string] -- 'Launched' | 'AlreadyRunning' | 'Failed' | 'Skipped' ElapsedMs [int] MaximizeResult [PSCustomObject] or $null ErrorMessage [string] or $null Returns $null on early exit (boot guard rejection or no environment match). .NOTES Author: Aaron AlAnsari Created: 2026-02-26 REGISTRY: The registry path and key for the boot guard are defined as module-scope constants in this file. They match the Test-BootGuard / Set-LastRunTime convention. RETRY PASS: Only one retry pass is performed. Two strategies are used: - Maximize-only retry (Status != 'Failed', MaximizeResult.Success=$false): calls Invoke-WindowMaximize directly with freshly-detected PIDs. Preserves original Status ('Launched' or 'AlreadyRunning') -- only MaximizeResult is updated. This prevents re-launching an already-running app (which would open a duplicate window). - Full-relaunch retry (Status = 'Failed'): runs Invoke-AppWorker again (full path check + launch + maximize). The retry result entirely replaces the Failed result. Apps that fail the retry are included in the summary with their final (Failed) status. SUMMARY TABLE: Written via Write-EnvkLog (one line per row). Fixed-width columns for readability in log files. Column widths: Name 20, Status 16, Elapsed 8. SUMMARY FORMAT: ======================================== Startup Summary ======================================== App Name Status Elapsed ---------------------------------------- Firefox Launched 2.1s Teams AlreadyRunning 0.2s ---------------------------------------- Total: 2 apps | 2 launched | 0 failed | 2.3s ======================================== LOG DIRECTORY OVERRIDE: The logDirectory config setting takes effect after initial log rotation. The Backup-LogFile call at startup always uses the default log path (%LOCALAPPDATA%\Envoke\Logs\ApplicationStartup.log). Once config is loaded, $script:LogFilePath is updated to the configured directory and all subsequent Write-EnvkLog calls write to that location. #> # PSUseShouldProcessForStateChangingFunctions: Invoke-EnvkStartup is an orchestration # function. State changes (registry write, process launch) are delegated to Set-LastRunTime # (which has SupportsShouldProcess) and Start-Process (which natively supports -WhatIf). # Adding ShouldProcess at this level would leave the launch pipeline in an undefined state # in -WhatIf mode and provide no practical benefit. # PSUseOutputTypeCorrectly: unary comma operator forces an Object[] wrapper around the # result array to prevent single-element unboxing in the PowerShell pipeline. # The declared OutputType [PSCustomObject[]] accurately describes the element type. [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] param () # --------------------------------------------------------------------------- # Module-scope registry constants for boot guard persistence. # --------------------------------------------------------------------------- $script:BootGuardRegistryPath = 'HKCU:\Software\Envoke' $script:BootGuardRegistryKey = 'LastRunTime' function Invoke-EnvkStartup { [CmdletBinding()] [OutputType([PSCustomObject[]])] param ( [Parameter()] [string]$ConfigPath, [Parameter()] [string]$Environment, [Parameter()] [switch]$SkipBootGuard, [Parameter()] [switch]$EnableDebug, [Parameter()] [switch]$Sequential ) # ------------------------------------------------------------------ # INT-02: Wire -EnableDebug into the Write-EnvkLog DEBUG gate. # Must precede all Write-EnvkLog calls so early startup messages # are captured at DEBUG level. # ------------------------------------------------------------------ if ($EnableDebug) { $script:Config['EnableDebug'] = $true } # ------------------------------------------------------------------ # INT-01: Run log rotation before config loading or any Write-EnvkLog calls. # Failure is non-fatal — log warning and continue startup. # ------------------------------------------------------------------ try { Backup-LogFile } catch { Write-EnvkLog -Level 'WARNING' -Message "Invoke-EnvkStartup: log rotation failed -- $($_.Exception.Message)" } # ------------------------------------------------------------------ # Step 1: Load config. # ------------------------------------------------------------------ $loadParams = @{} if (-not [string]::IsNullOrEmpty($ConfigPath)) { $loadParams['ConfigPath'] = $ConfigPath } $config = Get-EnvkConfig @loadParams # ------------------------------------------------------------------ # Step 1a: Apply enableDebug config override (if CLI flag was not set). # CLI -EnableDebug takes priority; config is the fallback. # ------------------------------------------------------------------ if ($script:Config['EnableDebug'] -ne $true -and $null -ne $config.PSObject.Properties['settings'] -and $null -ne $config.settings -and $null -ne $config.settings.PSObject.Properties['enableDebug'] -and $config.settings.enableDebug -eq $true) { $script:Config['EnableDebug'] = $true Write-EnvkLog -Level 'DEBUG' -Message 'Invoke-EnvkStartup: debug logging enabled via config setting' } # ------------------------------------------------------------------ # Step 1b: Apply logDirectory config override (if set). # Placed after Backup-LogFile (above) so log rotation uses the default # path. All subsequent Write-EnvkLog calls use the overridden path. # Environment variables in logDirectory are expanded via ExpandEnvironmentVariables. # ------------------------------------------------------------------ if ($null -ne $config.PSObject.Properties['settings'] -and $null -ne $config.settings -and $null -ne $config.settings.PSObject.Properties['logDirectory'] -and -not [string]::IsNullOrEmpty($config.settings.logDirectory)) { $expandedDir = [System.Environment]::ExpandEnvironmentVariables($config.settings.logDirectory) $script:LogFilePath = Join-Path -Path $expandedDir -ChildPath 'ApplicationStartup.log' Write-EnvkLog -Level 'DEBUG' -Message "Invoke-EnvkStartup: log directory overridden to '$expandedDir'" } # ------------------------------------------------------------------ # Step 2: Boot guard check (skipped when -SkipBootGuard is set). # ------------------------------------------------------------------ if (-not $SkipBootGuard) { $approved = Test-BootGuard ` -RegistryPath $script:BootGuardRegistryPath ` -RegistryKey $script:BootGuardRegistryKey if (-not $approved) { Write-EnvkLog -Level 'INFO' -Message 'Invoke-EnvkStartup: already ran this boot session -- exiting' return $null } # ------------------------------------------------------------------ # Step 3: Record this run in the registry so subsequent invocations # are blocked for the rest of this boot session. # ------------------------------------------------------------------ Set-LastRunTime ` -RegistryPath $script:BootGuardRegistryPath ` -RegistryKey $script:BootGuardRegistryKey ` -Time (Get-Date) } # ------------------------------------------------------------------ # Step 4: Detect environment. # Priority: CLI -Environment > config forceEnvironment > schedule detection. # ------------------------------------------------------------------ $selectParams = @{ Config = $config } if (-not [string]::IsNullOrEmpty($Environment)) { # CLI flag takes highest priority. $selectParams['Environment'] = $Environment Write-EnvkLog -Level 'DEBUG' -Message "Invoke-EnvkStartup: environment source: CLI flag ('$Environment')" } elseif ($null -ne $config.PSObject.Properties['settings'] -and $null -ne $config.settings -and $null -ne $config.settings.PSObject.Properties['forceEnvironment'] -and -not [string]::IsNullOrEmpty($config.settings.forceEnvironment)) { # Config forceEnvironment takes second priority (overrides schedule detection). $selectParams['Environment'] = $config.settings.forceEnvironment Write-EnvkLog -Level 'DEBUG' -Message "Invoke-EnvkStartup: environment source: config forceEnvironment ('$($config.settings.forceEnvironment)')" } else { Write-EnvkLog -Level 'DEBUG' -Message 'Invoke-EnvkStartup: environment source: schedule detection' } if ($EnableDebug) { $selectParams['EnableDebug'] = $true } $envName = Select-EnvkEnvironment @selectParams if ($null -eq $envName) { Write-EnvkLog -Level 'WARNING' -Message 'Invoke-EnvkStartup: no environment matched -- exiting' return $null } # ------------------------------------------------------------------ # Step 5: Read settings from config. # ------------------------------------------------------------------ $parallelLimit = 5 $forceSequential = $false $windowTimeoutMs = 120000 if ($null -ne $config.PSObject.Properties['settings'] -and $null -ne $config.settings) { $settings = $config.settings if ($null -ne $settings.PSObject.Properties['parallelLimit']) { $parallelLimit = [int]$settings.parallelLimit } if ($null -ne $settings.PSObject.Properties['forceSequential']) { $forceSequential = [bool]$settings.forceSequential } if ($null -ne $settings.PSObject.Properties['windowTimeoutSeconds']) { $windowTimeoutMs = [int]$settings.windowTimeoutSeconds * 1000 } } # -Sequential CLI parameter overrides config forceSequential. if ($Sequential) { $forceSequential = $true } # ------------------------------------------------------------------ # Step 6: Resolve app list. # ------------------------------------------------------------------ $apps = Resolve-AppConfig -Config $config -EnvironmentName $envName # ------------------------------------------------------------------ # Step 7: Determine launch mode. # ------------------------------------------------------------------ $psVersion = Get-PSMajorVersion $isPS7Plus = $psVersion -ge 7 $useParallel = $isPS7Plus -and (-not $forceSequential) # ------------------------------------------------------------------ # Step 8: Log launch mode. # ------------------------------------------------------------------ if ($useParallel) { Write-EnvkLog -Level 'INFO' -Message "Launch mode: Parallel (PS $psVersion, limit: $parallelLimit)" } elseif (-not $isPS7Plus) { Write-EnvkLog -Level 'INFO' -Message "Launch mode: Sequential (PS 5.1 -- upgrade to PS 7+ for parallel launching)" } else { Write-EnvkLog -Level 'INFO' -Message "Launch mode: Sequential (forced via -Sequential or config)" } # ------------------------------------------------------------------ # Step 9 + 10: Group apps by Priority ascending and dispatch. # ------------------------------------------------------------------ $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() if ($useParallel) { # Group by Priority ascending and dispatch each group to Start-EnvkAppBatch. $priorityGroups = $apps | Group-Object -Property Priority | Sort-Object { [int]$_.Name } foreach ($group in $priorityGroups) { $batch = @($group.Group) try { $batchResults = Start-EnvkAppBatch ` -Apps $batch ` -ThrottleLimit $parallelLimit ` -WindowTimeoutMs $windowTimeoutMs ` -EnableDebug:$EnableDebug # Flush buffered log lines from parallel workers sequentially. foreach ($r in $batchResults) { if ($null -ne $r.LogLines) { foreach ($line in $r.LogLines) { Write-EnvkLog -Level $line.Level -Message $line.Message } } $allResults.Add($r) } } catch { # RunspacePool creation failure or other fatal Start-EnvkAppBatch error. # Log and fall back to sequential for this group. Write-EnvkLog -Level 'ERROR' -Message "Invoke-EnvkStartup: Start-EnvkAppBatch failed for priority group $($group.Name) -- $($_.Exception.Message)" foreach ($app in $batch) { $allResults.Add([PSCustomObject]@{ AppName = $app.Name Status = 'Failed' ElapsedMs = 0 MaximizeResult = $null ErrorMessage = $_.Exception.Message LogLines = [System.Collections.Generic.List[PSCustomObject]]::new() }) } } } } else { # Sequential mode: call Start-EnvkAppSequence once with full config. # Pass the already-resolved $apps to avoid a redundant Resolve-AppConfig call. # It handles its own iteration and logging; no LogLines flush needed. $seqResults = Start-EnvkAppSequence -Config $config -EnvironmentName $envName -Apps $apps -EnableDebug:$EnableDebug foreach ($r in $seqResults) { $allResults.Add($r) } } # ------------------------------------------------------------------ # Step 11: Single retry pass for Failed or partial-failure apps. # # Two distinct retry strategies based on why the app needs a retry: # # MAXIMIZE-ONLY retry (Status != 'Failed', MaximizeResult.Success=$false): # The app was launched (or was already running) but window maximization timed # out. The process IS running -- re-launching it would open a second instance. # Correct action: detect the running PIDs via Get-AppProcessIds and call # Invoke-WindowMaximize directly. Preserve the original Status ('Launched' or # 'AlreadyRunning') -- only MaximizeResult is updated from the retry. # # FULL-RELAUNCH retry (Status = 'Failed'): # The launch itself failed (Start-Process threw, path validation failed, etc.). # Correct action: full Invoke-AppWorker re-run (path check + launch + maximize). # The retry result Status replaces the original Failed result entirely. # ------------------------------------------------------------------ $maximizeRetryApps = [System.Collections.Generic.List[PSCustomObject]]::new() $relaunchRetryApps = [System.Collections.Generic.List[PSCustomObject]]::new() $retryIndices = [System.Collections.Generic.Dictionary[string, int]]::new() for ($i = 0; $i -lt $allResults.Count; $i++) { $r = $allResults[$i] $hasMaximizeFailure = $null -ne $r.MaximizeResult -and $r.MaximizeResult.Success -eq $false if ($r.Status -eq 'Failed') { $matchingApp = $apps | Where-Object { $_.Name -eq $r.AppName } | Select-Object -First 1 if ($null -ne $matchingApp) { $relaunchRetryApps.Add($matchingApp) $retryIndices[$r.AppName] = $i } } elseif ($hasMaximizeFailure) { $matchingApp = $apps | Where-Object { $_.Name -eq $r.AppName } | Select-Object -First 1 if ($null -ne $matchingApp) { $maximizeRetryApps.Add($matchingApp) $retryIndices[$r.AppName] = $i } } } $totalRetryCount = $maximizeRetryApps.Count + $relaunchRetryApps.Count if ($totalRetryCount -gt 0) { Write-EnvkLog -Level 'INFO' -Message "Retry pass: $totalRetryCount app(s) ($($maximizeRetryApps.Count) maximize-only, $($relaunchRetryApps.Count) relaunch)" # ------------------------------------------------------------------ # Maximize-only retry: detect running PIDs and call Invoke-WindowMaximize. # Preserve the original Status -- only update MaximizeResult. # This prevents re-launching an app that is already running (which would # open a duplicate window), and preserves 'Launched' in the summary for # apps that were freshly started in this session. # ------------------------------------------------------------------ foreach ($retryApp in $maximizeRetryApps) { try { $originalIdx = $retryIndices[$retryApp.Name] $originalResult = $allResults[$originalIdx] Write-EnvkLog -Level 'INFO' -Message "Invoke-EnvkStartup: maximize-only retry for '$($retryApp.Name)'" # Detect the currently running PIDs for this app. # Do NOT pass -RequireWindow here: the window may not yet have a # MainWindowHandle even though the process is running. $currentPids = Get-AppProcessIds -ProcessName $retryApp.ProcessName -ConfigPath $retryApp.Path -Arguments $retryApp.Arguments if ($currentPids.Count -gt 0) { $retryMaxResult = Invoke-WindowMaximize ` -Name $retryApp.Name ` -ProcessIds $currentPids ` -SkipMaximize:$retryApp.SkipMaximize ` -EnableDebug:$EnableDebug # Update only MaximizeResult; preserve the original Status # ('Launched' or 'AlreadyRunning') so the summary reflects # what actually happened in this session. $allResults[$originalIdx] = [PSCustomObject]@{ AppName = $originalResult.AppName Status = $originalResult.Status ElapsedMs = $originalResult.ElapsedMs MaximizeResult = $retryMaxResult ErrorMessage = $originalResult.ErrorMessage LogLines = $originalResult.LogLines } } else { Write-EnvkLog -Level 'WARNING' -Message "Invoke-EnvkStartup: maximize-only retry for '$($retryApp.Name)' -- no running PIDs found, skipping" } } catch { Write-EnvkLog -Level 'ERROR' -Message "Invoke-EnvkStartup: maximize-only retry failed for '$($retryApp.Name)' -- $($_.Exception.Message)" } } # ------------------------------------------------------------------ # Full-relaunch retry: re-run Invoke-AppWorker for Failed apps. # ------------------------------------------------------------------ if ($relaunchRetryApps.Count -gt 0) { if ($useParallel) { try { $retryResults = Start-EnvkAppBatch ` -Apps $relaunchRetryApps.ToArray() ` -ThrottleLimit $parallelLimit ` -WindowTimeoutMs $windowTimeoutMs ` -EnableDebug:$EnableDebug foreach ($r in $retryResults) { if ($null -ne $r.LogLines) { foreach ($line in $r.LogLines) { Write-EnvkLog -Level $line.Level -Message $line.Message } } if ($retryIndices.ContainsKey($r.AppName)) { $allResults[$retryIndices[$r.AppName]] = $r } } } catch { Write-EnvkLog -Level 'ERROR' -Message "Invoke-EnvkStartup: relaunch retry batch failed -- $($_.Exception.Message)" } } else { foreach ($retryApp in $relaunchRetryApps) { try { $retryResult = Invoke-AppWorker ` -App $retryApp ` -WindowTimeoutMs $windowTimeoutMs ` -EnableDebug ([bool]$EnableDebug) if ($null -ne $retryResult.LogLines) { foreach ($line in $retryResult.LogLines) { Write-EnvkLog -Level $line.Level -Message $line.Message } } if ($retryIndices.ContainsKey($retryApp.Name)) { $allResults[$retryIndices[$retryApp.Name]] = $retryResult } } catch { Write-EnvkLog -Level 'ERROR' -Message "Invoke-EnvkStartup: relaunch retry failed for '$($retryApp.Name)' -- $($_.Exception.Message)" } } } } } # ------------------------------------------------------------------ # Step 12: Log summary table. # ------------------------------------------------------------------ Write-EnvkLog -Level 'INFO' -Message '========================================' Write-EnvkLog -Level 'INFO' -Message 'Startup Summary' Write-EnvkLog -Level 'INFO' -Message '========================================' Write-EnvkLog -Level 'INFO' -Message ( '{0,-20} {1,-16} {2}' -f 'App Name', 'Status', 'Elapsed' ) Write-EnvkLog -Level 'INFO' -Message '----------------------------------------' $launchedCount = 0 $failedCount = 0 $totalMs = 0 foreach ($r in $allResults) { $elapsedSecs = [math]::Round($r.ElapsedMs / 1000, 1) Write-EnvkLog -Level 'INFO' -Message ( '{0,-20} {1,-16} {2}s' -f $r.AppName, $r.Status, $elapsedSecs ) if ($r.Status -eq 'Launched') { $launchedCount++ } elseif ($r.Status -eq 'Failed') { $failedCount++ } $totalMs += $r.ElapsedMs } Write-EnvkLog -Level 'INFO' -Message '----------------------------------------' $totalSecs = [math]::Round($totalMs / 1000, 1) Write-EnvkLog -Level 'INFO' -Message ( "Total: $($allResults.Count) apps | $launchedCount launched | $failedCount failed | $($totalSecs)s" ) Write-EnvkLog -Level 'INFO' -Message '========================================' # ------------------------------------------------------------------ # Step 13: Return results array. # ------------------------------------------------------------------ return , $allResults.ToArray() } |