Private/Invoke-WindowMaximize.ps1


#Requires -Version 5.1
<#
.SYNOPSIS
    Maximizes the main window of a running application via UIAutomation (primary)
    or WinAPI ShowWindow (fallback), and returns a result object for the caller.
 
.DESCRIPTION
    Invoke-WindowMaximize is the main entry point for window maximization. It is called
    by the Phase 6 App Launcher once per application after the process has been started.
 
    Execution flow:
      1. SkipMaximize early return -- for background processes that should not be maximized.
      2. Load UIAutomationClient assembly and define WinAPI type (with PSTypeName guard).
      3. Expand PID set via Get-DescendantProcessIds for Electron apps.
      4. Poll Get-MainWindow with exponential backoff for up to 60 seconds waiting for the window.
         First miss sleeps $script:InitialPollMs; each subsequent miss doubles the interval up to
         $script:MaxPollMs. Already-running apps are detected on the first attempt (no sleep).
         Each poll miss logs a DEBUG message with attempt number, elapsed time, and PID count.
      5. Timeout check -- if UIAutomation found no window, attempt EnumWindows P/Invoke fallback
         (single-shot) before giving up. Apps like ClickUp and Spark have zero UIAutomation presence
         but valid HWNDs discoverable via user32.dll EnumWindows. If EnumWindows finds an HWND,
         maximize via ShowWindow and return Strategy='EnumWindows'. If not found by either method,
         log at WARNING (not ERROR) and return Strategy='Timeout'.
      6. Check if already maximized -- skip if so (DEBUG log).
      7. If the detected window is a frameless Electron window (IsFramelessFallback=true from
         Get-MainWindow), skip UIAutomation SetWindowVisualState entirely and go directly to
         WinAPI ShowWindow. UIAutomation's SetWindowVisualState fails on CanMaximize=false windows.
      8. Attempt UIAutomation WindowPattern.SetWindowVisualState(Maximized) -- primary strategy
         (only for non-frameless windows).
      9. Fall back to WinAPI ShowWindow(hWnd, SW_MAXIMIZE=3) if UIAutomation fails.
     10. Return a PSCustomObject result for Phase 6 summary reporting.
 
    The function does not throw -- all failure paths return a result object with
    Success=$false so the App Launcher can continue to the next application.
 
.PARAMETER Name
    Display name of the application. Used in log messages and in the result object.
 
.PARAMETER ProcessId
    Process ID of the launched application. For Electron apps, child PIDs are
    discovered automatically via Get-DescendantProcessIds. Mutually exclusive
    with -ProcessIds; one of the two must be provided.
 
.PARAMETER ProcessIds
    Pre-expanded array of process IDs to search. Used by the already-running
    detection path when Get-Process returns multiple instances of the same process
    name (e.g., Firefox, ClickUp). When provided, Get-DescendantProcessIds is
    skipped entirely and these PIDs are passed directly to Get-MainWindow.
    Mutually exclusive with -ProcessId; one of the two must be provided.
 
.PARAMETER SkipMaximize
    When set, skip window detection entirely and return an immediate success result.
    Used for background processes, daemons, and system tray apps that have no visible window.
 
.PARAMETER EnableDebug
    When set, passes -EnableDebug through to Get-MainWindow for detailed UIAutomation
    element property logging during window detection.
 
.OUTPUTS
    [PSCustomObject] with the following properties:
      AppName [string] -- Display name of the application (matches -Name parameter)
      Success [bool] -- True if window was maximized or intentionally skipped
      Strategy [string] -- 'UIAutomation' | 'WinAPI' | 'EnumWindows' |
                                'SkipMaximize' | 'AlreadyMaximized' | 'Timeout' |
                                'BothFailed' | 'AssemblyLoadFailed'
      ElapsedMs [int] -- Milliseconds elapsed from function entry to return
      ErrorMessage [string] -- Null on success; description of the failure on error
 
.NOTES
    Author: Aaron AlAnsari
    Created: 2026-02-25
 
    STA REQUIREMENT: This function calls UIAutomationClient which requires the calling
    runspace to be in Single-Threaded Apartment (STA) mode. In production, the Task
    Scheduler action includes -STA. In Phase 7 (parallel execution), explicit STA
    RunspacePool must be used -- see STATE.md blockers.
 
    WinAPI type guard: The WinAPI C# type is defined once per session using a PSTypeName
    guard. Reimporting the module in the same session (common during development) would
    throw 'type name WinAPI already exists' without this guard.
 
    POLLING: Window detection uses exponential backoff. $script:InitialPollMs (default 500)
    is the first sleep duration after a miss. Each subsequent miss multiplies the interval by
    $script:BackoffFactor (default 2), capped at $script:MaxPollMs (default 15000). The first
    Get-MainWindow call is immediate (before any sleep), so already-running apps are detected
    in well under a second. Tests should override $script:TimeoutMs to a small value (e.g. 50)
    rather than relying on $script:InitialPollMs to control test speed.
 
    FRAMELESS WINDOWS: ClickUp, Spark, and other Electron apps with frameless windows report
    CanMaximize=false. Get-MainWindow returns IsFramelessFallback=true for these. This function
    detects that flag and routes directly to WinAPI ShowWindow, bypassing UIAutomation's
    SetWindowVisualState which would fail (or be a no-op) on a CanMaximize=false window.
 
    TIMEOUT LOGGING: Timeout is logged at WARNING (not ERROR) because it is an expected
    transient condition for slow-loading apps, not a hard failure. The caller (App Launcher)
    decides how to handle the Timeout strategy result.
#>


# ---------------------------------------------------------------------------
# Script-scope configuration -- hardcoded per CONTEXT.md decision.
# Not exposed as parameters; these are module implementation details.
# Override $script:TimeoutMs, $script:InitialPollMs, $script:MaxPollMs, and
# $script:BackoffFactor in tests to avoid real waits.
# ---------------------------------------------------------------------------
$script:TimeoutMs      = 60000  # 60 seconds (overridden at runtime by Start-EnvkAppSequence)
$script:InitialPollMs  = 500    # First sleep duration after a miss
$script:MaxPollMs      = 15000  # Cap: never sleep longer than 15 seconds between polls
$script:BackoffFactor  = 2      # Multiply interval by this after each miss

# ---------------------------------------------------------------------------
# WinAPI P/Invoke type definition.
# Stored at script scope so it is accessible to the PSTypeName guard below.
# ---------------------------------------------------------------------------
$script:WinApiCode = @"
using System;
using System.Runtime.InteropServices;
public class WinAPI {
    [DllImport("user32.dll")]
    public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
}
"@


# ---------------------------------------------------------------------------
# WinAPIEnum P/Invoke type definition -- EnumWindows-based window discovery.
# Separate type name from WinAPI to avoid collision. Used by
# Get-WindowByProcessId to find top-level HWNDs by PID when UIAutomation
# finds no window at all (e.g., ClickUp, Spark).
# ---------------------------------------------------------------------------
$script:WinApiEnumCode = @"
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;
 
public class WinAPIEnum {
    private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
 
    [DllImport("user32.dll")]
    private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
 
    [DllImport("user32.dll")]
    private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
 
    [DllImport("user32.dll")]
    private static extern bool IsWindow(IntPtr hWnd);
 
    public static IntPtr FindWindowByProcessIds(int[] targetPids) {
        IntPtr foundHwnd = IntPtr.Zero;
        var pidSet = new HashSet<uint>();
        foreach (var pid in targetPids) pidSet.Add((uint)pid);
 
        EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
            uint processId;
            GetWindowThreadProcessId(hWnd, out processId);
            if (pidSet.Contains(processId)) {
                foundHwnd = hWnd;
                return false; // Stop enumerating
            }
            return true; // Continue
        }, IntPtr.Zero);
 
        return foundHwnd;
    }
}
"@


# ---------------------------------------------------------------------------
# Private boundary helpers -- mockable in unit tests.
# These thin wrappers isolate UIAutomation and WinAPI type references from
# the main function logic, allowing Pester to mock them without the
# UIAutomationClient assembly or WinAPI type loaded in the test session.
# ---------------------------------------------------------------------------

function Invoke-LoadUIAutomationAssembly {
    <#
    .SYNOPSIS
        Loads the UIAutomationClient assembly.
    .NOTES
        Private helper -- mockable boundary for Add-Type assembly loading.
        Separated so tests can mock the load step without intercepting Add-Type globally.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param ()
    Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop
}

function Invoke-SetWindowMaximized {
    <#
    .SYNOPSIS
        Calls WindowPattern.SetWindowVisualState to maximize a window.
    .NOTES
        Private helper -- mockable boundary for UIAutomation WindowPattern maximize call.
        Wraps the [System.Windows.Automation.WindowVisualState]::Maximized enum reference
        so the call is mockable in tests without UIAutomationClient loaded.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Mandatory)]
        [object]$WindowPattern
    )
    $WindowPattern.SetWindowVisualState([System.Windows.Automation.WindowVisualState]::Maximized)
}

function Invoke-ShowWindow {
    <#
    .SYNOPSIS
        Calls WinAPI ShowWindow to maximize the given window handle.
    .NOTES
        Private helper -- mockable boundary for WinAPI P/Invoke interaction.
        Returns $true if the call succeeded, $false otherwise.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [IntPtr]$Handle,

        [Parameter(Mandatory)]
        [int]$Command
    )
    return [WinAPI]::ShowWindow($Handle, $Command)
}

function Get-WindowByProcessId {
    <#
    .SYNOPSIS
        Finds the first top-level window HWND owned by any of the given process IDs.
 
    .DESCRIPTION
        Uses EnumWindows P/Invoke (via WinAPIEnum C# type) to enumerate all top-level
        windows and return the first HWND whose owning PID matches any entry in
        ProcessIds. Returns [IntPtr]::Zero if no match is found.
 
        This is a boundary helper -- it isolates the WinAPIEnum P/Invoke type reference
        so that Pester can mock it without the type compiled in the test session.
        Called by Invoke-WindowMaximize after UIAutomation polling times out.
 
    .PARAMETER ProcessIds
        Array of process IDs to search for. The first top-level HWND owned by any
        of these PIDs is returned.
 
    .OUTPUTS
        [IntPtr] -- HWND of the found window, or [IntPtr]::Zero if not found.
 
    .NOTES
        Author: Aaron AlAnsari
        Created: 2026-02-28
 
        PSTypeName guard prevents 'type already exists' errors on module reimport,
        following the same pattern used by the WinAPI type guard above.
    #>

    [CmdletBinding()]
    [OutputType([IntPtr])]
    param (
        [Parameter(Mandatory)]
        [int[]]$ProcessIds
    )

    if (-not ([System.Management.Automation.PSTypeName]'WinAPIEnum').Type) {
        Add-Type -TypeDefinition $script:WinApiEnumCode -ErrorAction Stop
    }
    return [WinAPIEnum]::FindWindowByProcessIds($ProcessIds)
}

# ---------------------------------------------------------------------------
# Main function
# ---------------------------------------------------------------------------

function Invoke-WindowMaximize {
    [CmdletBinding(DefaultParameterSetName = 'SinglePid')]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [string]$Name,

        # SinglePid: launched-app path -- one PID, descendants expanded automatically.
        [Parameter(Mandatory, ParameterSetName = 'SinglePid')]
        [int]$ProcessId,

        # MultiPid: already-running path -- full PID set provided by caller; no expansion.
        [Parameter(Mandatory, ParameterSetName = 'MultiPid')]
        [int[]]$ProcessIds,

        [Parameter()]
        [switch]$SkipMaximize,

        [Parameter()]
        [switch]$EnableDebug
    )

    # ------------------------------------------------------------------
    # Step 1: Start stopwatch for elapsed timing.
    # ------------------------------------------------------------------
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    # ------------------------------------------------------------------
    # Step 2: SkipMaximize early return (WIN-04).
    # Background processes and daemons should not be maximized.
    # ------------------------------------------------------------------
    if ($SkipMaximize) {
        Write-EnvkLog -Level 'INFO' -Message "Invoke-WindowMaximize: '$Name' has SkipMaximize set -- skipping window management"
        return [PSCustomObject]@{
            AppName      = $Name
            Success      = $true
            Strategy     = 'SkipMaximize'
            ElapsedMs    = 0
            ErrorMessage = $null
        }
    }

    # ------------------------------------------------------------------
    # Step 3: Load UIAutomationClient assembly via boundary helper.
    # Using Invoke-LoadUIAutomationAssembly makes this step independently
    # mockable in tests (the real Add-Type for a named assembly is
    # idempotent once loaded, so it cannot be intercepted by Mock Add-Type).
    # ------------------------------------------------------------------
    try {
        Invoke-LoadUIAutomationAssembly
    }
    catch {
        $errMsg = "Failed to load UIAutomationClient assembly: $($_.Exception.Message)"
        Write-EnvkLog -Level 'ERROR' -Message "Invoke-WindowMaximize: $errMsg"
        $stopwatch.Stop()
        return [PSCustomObject]@{
            AppName      = $Name
            Success      = $false
            Strategy     = 'AssemblyLoadFailed'
            ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
            ErrorMessage = $errMsg
        }
    }

    # ------------------------------------------------------------------
    # Step 4: Define WinAPI type with PSTypeName guard (Pattern 2 from
    # 05-RESEARCH.md). The C# type is compiled once per session; the guard
    # prevents 'type already exists' errors on module reimport.
    # ------------------------------------------------------------------
    if (-not ([System.Management.Automation.PSTypeName]'WinAPI').Type) {
        try {
            Add-Type -TypeDefinition $script:WinApiCode -ErrorAction Stop
        }
        catch {
            $errMsg = "Failed to define WinAPI type: $($_.Exception.Message)"
            Write-EnvkLog -Level 'ERROR' -Message "Invoke-WindowMaximize: $errMsg"
            $stopwatch.Stop()
            return [PSCustomObject]@{
                AppName      = $Name
                Success      = $false
                Strategy     = 'AssemblyLoadFailed'
                ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
                ErrorMessage = $errMsg
            }
        }
    }

    # ------------------------------------------------------------------
    # Step 5: Build the PID set to search.
    #
    # SinglePid (launched-app path): expand descendants via Get-DescendantProcessIds.
    # Handles Electron apps (Teams, ClickUp) where the launched PID is the parent
    # and the window is owned by a child process.
    #
    # MultiPid (already-running path): use the caller-provided PID set directly.
    # The caller collected ALL running instances of the process name and expanded
    # their descendants. Skipping expansion here avoids double-traversal and
    # correctly handles multi-process apps (Firefox, ClickUp) where Get-Process
    # returns many PIDs and the window owner is not predictably [0]'s descendant.
    # ------------------------------------------------------------------
    if ($PSCmdlet.ParameterSetName -eq 'SinglePid') {
        $childPids = Get-DescendantProcessIds -ParentId $ProcessId
        $allPids   = @($ProcessId) + @($childPids)
        Write-EnvkLog -Level 'DEBUG' -Message "Invoke-WindowMaximize: searching $($allPids.Count) PID(s) for '$Name' (parent=$ProcessId, children=$(@($childPids).Count))"
    }
    else {
        $allPids = $ProcessIds
        Write-EnvkLog -Level 'DEBUG' -Message "Invoke-WindowMaximize: searching $($allPids.Count) pre-expanded PID(s) for '$Name' (already-running path)"
    }

    # ------------------------------------------------------------------
    # Step 6: Polling loop -- wait for the main window to appear.
    # Uses exponential backoff: first miss sleeps $script:InitialPollMs,
    # each subsequent miss doubles the interval up to $script:MaxPollMs.
    # The first Get-MainWindow call is immediate (before any sleep), so
    # already-running apps are detected in under a second.
    #
    # Get-MainWindow now returns a PSCustomObject { Window; IsFramelessFallback }
    # or $null when no window is found. Unpack the result after each poll.
    # ------------------------------------------------------------------
    $mainWindow   = $null
    $isFrameless  = $false
    $pollInterval = $script:InitialPollMs
    $pollAttempt  = 0

    do {
        # Wrap Get-MainWindow in try/catch to handle sporadic "Unexpected HRESULT" COM
        # errors that occur when concurrent STA runspaces simultaneously access the
        # UIAutomation COM infrastructure. On COM error, treat the poll as a miss
        # (log DEBUG, sleep, and retry) rather than propagating the exception to
        # Invoke-AppWorker where it would be caught as a launch failure (Status='Failed').
        try {
            $getMainResult = Get-MainWindow -ProcessIds $allPids -Name $Name -EnableDebug:$EnableDebug
        }
        catch {
            $getMainResult = $null
            Write-EnvkLog -Level 'DEBUG' -Message ("Invoke-WindowMaximize: Get-MainWindow threw for '$Name' (COM error, treating as miss) -- " + $_.Exception.Message)
        }
        $mainWindow    = if ($null -ne $getMainResult) { $getMainResult.Window } else { $null }
        $isFrameless   = if ($null -ne $getMainResult) { $getMainResult.IsFramelessFallback } else { $false }

        if ($null -eq $mainWindow) {
            Start-Sleep -Milliseconds $pollInterval
            $pollInterval = [Math]::Min($pollInterval * $script:BackoffFactor, $script:MaxPollMs)
            $pollAttempt++
            Write-EnvkLog -Level 'DEBUG' -Message ("Invoke-WindowMaximize: poll attempt $pollAttempt for '$Name' -- " +
                "elapsed $([int]$stopwatch.ElapsedMilliseconds)ms, searching $($allPids.Count) PID(s), " +
                "next poll in $($pollInterval)ms")
        }
    } while ($null -eq $mainWindow -and $stopwatch.ElapsedMilliseconds -lt $script:TimeoutMs)

    # ------------------------------------------------------------------
    # Step 7: Timeout check -- window never appeared within the timeout.
    #
    # Before giving up, attempt a single-shot EnumWindows P/Invoke fallback.
    # Apps like ClickUp and Spark have zero UIAutomation presence (no
    # AutomationElement is ever returned), but their HWNDs are visible to
    # user32.dll EnumWindows. Get-WindowByProcessId wraps this as a boundary
    # helper so the call is independently mockable in tests.
    #
    # If EnumWindows finds an HWND, maximize via ShowWindow and return
    # Strategy='EnumWindows'. If neither method finds a window, log at
    # WARNING (not ERROR): timeout is an expected transient condition for
    # slow-loading apps, not an unrecoverable error. The caller continues.
    # ------------------------------------------------------------------
    if ($null -eq $mainWindow) {
        # --- EnumWindows fallback: try to find a top-level HWND by PID ---
        # Apps like ClickUp and Spark have zero UIAutomation presence but valid HWNDs.
        Write-EnvkLog -Level 'DEBUG' -Message "Invoke-WindowMaximize: UIAutomation found nothing for '$Name' -- trying EnumWindows fallback"
        $fallbackHwnd = [IntPtr]::Zero
        try {
            $fallbackHwnd = Get-WindowByProcessId -ProcessIds $allPids
        }
        catch {
            Write-EnvkLog -Level 'WARNING' -Message "Invoke-WindowMaximize: EnumWindows fallback failed for '$Name' -- $($_.Exception.Message)"
        }

        if ($fallbackHwnd -ne [IntPtr]::Zero) {
            Write-EnvkLog -Level 'DEBUG' -Message "Invoke-WindowMaximize: EnumWindows found HWND $fallbackHwnd for '$Name' -- calling ShowWindow"
            $SW_MAXIMIZE = 3
            $showResult  = $false
            try {
                $showResult = Invoke-ShowWindow -Handle $fallbackHwnd -Command $SW_MAXIMIZE
            }
            catch {
                Write-EnvkLog -Level 'WARNING' -Message "Invoke-WindowMaximize: ShowWindow on EnumWindows HWND failed for '$Name' -- $($_.Exception.Message)"
            }

            if ($showResult) {
                Write-EnvkLog -Level 'INFO' -Message "Invoke-WindowMaximize: maximized '$Name' via EnumWindows + WinAPI fallback"
                $stopwatch.Stop()
                return [PSCustomObject]@{
                    AppName      = $Name
                    Success      = $true
                    Strategy     = 'EnumWindows'
                    ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
                    ErrorMessage = $null
                }
            }
        }

        # Original timeout path (no window found by either method)
        $errMsg = "Window for '$Name' not found after $($script:TimeoutMs)ms -- " +
            "searched $($allPids.Count) PID(s). Common causes: app not fully loaded, " +
            "window owned by unrelated process, or frameless window without WindowPattern"
        Write-EnvkLog -Level 'WARNING' -Message "Invoke-WindowMaximize: $errMsg"
        $stopwatch.Stop()
        return [PSCustomObject]@{
            AppName      = $Name
            Success      = $false
            Strategy     = 'Timeout'
            ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
            ErrorMessage = $errMsg
        }
    }

    # ------------------------------------------------------------------
    # Step 8: Already-maximized check.
    # If the window is already maximized, skip and return success.
    # Log at DEBUG only -- this is normal behavior for restarts.
    # ------------------------------------------------------------------
    $windowPattern = Get-WindowPattern -Element $mainWindow

    if ($null -ne $windowPattern) {
        $currentState = $windowPattern.Current.WindowVisualState

        # Accept both string form (PSCustomObject fakes in tests) and UIAutomation
        # enum form (production) -- same dual-form pattern used in Get-MainWindow.ps1.
        $isAlreadyMaximized = $false
        if ($currentState -eq 'Maximized') {
            $isAlreadyMaximized = $true
        }
        if (-not $isAlreadyMaximized) {
            try {
                $isAlreadyMaximized = ($currentState -eq [System.Windows.Automation.WindowVisualState]::Maximized)
            }
            catch {
                $isAlreadyMaximized = $false
            }
        }

        if ($isAlreadyMaximized) {
            Write-EnvkLog -Level 'DEBUG' -Message "Invoke-WindowMaximize: '$Name' window already maximized -- skipping"
            $stopwatch.Stop()
            return [PSCustomObject]@{
                AppName      = $Name
                Success      = $true
                Strategy     = 'AlreadyMaximized'
                ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
                ErrorMessage = $null
            }
        }
    }

    # ------------------------------------------------------------------
    # Step 9: UIAutomation maximize attempt -- primary strategy (WIN-01).
    # Skipped for frameless windows (IsFramelessFallback=true) because
    # SetWindowVisualState fails when CanMaximize=false. Frameless windows
    # go directly to WinAPI (Step 10).
    #
    # Uses Invoke-SetWindowMaximized boundary helper so the UIAutomation
    # enum reference [WindowVisualState]::Maximized is independently
    # mockable in tests without UIAutomationClient loaded.
    # ------------------------------------------------------------------
    $uiAutomationError = $null

    if ($isFrameless) {
        Write-EnvkLog -Level 'DEBUG' -Message "Invoke-WindowMaximize: '$Name' is frameless (CanMaximize=false) -- using WinAPI directly"
        $uiAutomationError = 'Skipped: frameless window (CanMaximize=false)'
    }
    elseif ($null -ne $windowPattern) {
        try {
            Invoke-SetWindowMaximized -WindowPattern $windowPattern
            Write-EnvkLog -Level 'INFO' -Message "Invoke-WindowMaximize: maximized '$Name' via UIAutomation"
            $stopwatch.Stop()
            return [PSCustomObject]@{
                AppName      = $Name
                Success      = $true
                Strategy     = 'UIAutomation'
                ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
                ErrorMessage = $null
            }
        }
        catch {
            $uiAutomationError = $_.Exception.Message
            Write-EnvkLog -Level 'WARNING' -Message "Invoke-WindowMaximize: UIAutomation maximize failed for '$Name', attempting WinAPI fallback -- $uiAutomationError"
        }
    }
    else {
        $uiAutomationError = 'WindowPattern not available'
        Write-EnvkLog -Level 'WARNING' -Message "Invoke-WindowMaximize: WindowPattern unavailable for '$Name', attempting WinAPI fallback"
    }

    # ------------------------------------------------------------------
    # Step 10: WinAPI ShowWindow fallback -- secondary strategy (WIN-02).
    # Uses NativeWindowHandle from the UIAutomation element for the HWND.
    # This is also the primary path for frameless Electron windows where
    # UIAutomation's SetWindowVisualState is known to fail.
    # ------------------------------------------------------------------
    $SW_MAXIMIZE = 3
    $winApiResult = $false

    try {
        $hwnd = [IntPtr]$mainWindow.Current.NativeWindowHandle
        $winApiResult = Invoke-ShowWindow -Handle $hwnd -Command $SW_MAXIMIZE
    }
    catch {
        $winApiResult = $false
        Write-EnvkLog -Level 'ERROR' -Message "Invoke-WindowMaximize: WinAPI ShowWindow threw for '$Name' -- $($_.Exception.Message)"
    }

    if ($winApiResult -eq $true) {
        Write-EnvkLog -Level 'INFO' -Message "Invoke-WindowMaximize: maximized '$Name' via WinAPI fallback"
        $stopwatch.Stop()
        return [PSCustomObject]@{
            AppName      = $Name
            Success      = $true
            Strategy     = 'WinAPI'
            ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
            ErrorMessage = $null
        }
    }

    # ------------------------------------------------------------------
    # Step 11: Both strategies failed.
    # Log at ERROR and return failure. Caller continues to next app.
    # ------------------------------------------------------------------
    $errMsg = "Both UIAutomation and WinAPI maximize failed for '$Name'. UIAutomation: $uiAutomationError"
    Write-EnvkLog -Level 'ERROR' -Message "Invoke-WindowMaximize: $errMsg"
    $stopwatch.Stop()
    return [PSCustomObject]@{
        AppName      = $Name
        Success      = $false
        Strategy     = 'BothFailed'
        ElapsedMs    = [int]$stopwatch.ElapsedMilliseconds
        ErrorMessage = $errMsg
    }
}