Private/Get-MainWindow.ps1


#Requires -Version 5.1
# PSUseShouldProcessForStateChangingFunctions: New-PropertyCondition and New-OrCondition use
# the New- verb but only construct in-memory .NET objects -- no system state is changed.
# ShouldProcess is not appropriate for pure object construction helpers.
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
param ()
<#
.SYNOPSIS
    Searches the UIAutomation element tree for the main window of the given process IDs.
 
.DESCRIPTION
    Performs a single-attempt search of the UIAutomation RootElement's direct children
    for a window belonging to the specified process IDs. Applies a heuristic filter to
    identify the real main window (not splash screens or update dialogs):
      - IsEnabled must be true
      - IsOffscreen must be false
      - ControlType must be Window or Pane
      - Name must be non-empty (splash screens typically have no title)
      - WindowPattern must not be null (required for both preferred and fallback paths)
      - WindowPattern.CanMaximize=true is preferred; CanMaximize=false is accepted as fallback
 
    Two-result approach: the function collects a preferred candidate (CanMaximize=true) and
    a frameless fallback candidate (CanMaximize=false). The preferred candidate is returned
    when found (loop breaks immediately). If only a frameless candidate is found after examining
    all windows, it is returned with IsFramelessFallback=true so the caller can route it
    directly to WinAPI ShowWindow instead of UIAutomation SetWindowVisualState.
 
    Handles the single-PID vs multi-PID OrCondition edge case: OrCondition requires
    at least two child conditions; a single PID uses PropertyCondition directly.
 
    After the main window is identified, attempts best-effort overlay dismissal on any
    child Window-type elements (e.g. "What's New" dialogs) before returning.
 
    Does NOT poll or retry -- the caller (Invoke-WindowMaximize) handles the polling loop.
 
.PARAMETER ProcessIds
    One or more process IDs to search for. For single-process apps, pass one ID.
    For Electron apps, pass the parent PID plus all descendants from Get-DescendantProcessIds.
 
.PARAMETER Name
    Display name of the application. Used in log messages for context. Defaults to 'Unknown'.
 
.PARAMETER EnableDebug
    When set, logs detailed UIAutomation element properties (Name, ControlType, IsEnabled,
    IsOffscreen, ProcessId, CanMaximize, WindowInteractionState, NativeWindowHandle) for
    every candidate window examined during the search.
 
.OUTPUTS
    [PSCustomObject] with properties:
      Window [AutomationElement] -- The main window element
      IsFramelessFallback [bool] -- True if CanMaximize=false fallback was used
    Returns $null if no window found.
 
.NOTES
    Author: Aaron AlAnsari
    Created: 2026-02-25
 
    This function requires the calling runspace to be in STA (Single-Threaded Apartment) mode.
    UIAutomationClient uses COM under the hood and will throw InvalidOperationException in MTA.
 
    OrCondition guard: System.Windows.Automation.OrCondition requires at least two conditions.
    Constructing it with a single-element array throws ArgumentException at runtime. This
    function always branches on $idConditions.Count before constructing OrCondition.
 
    Frameless fallback: ClickUp, Spark, and other Electron apps may report CanMaximize=false
    because they use frameless windows. The fallback accepts these windows and marks them with
    IsFramelessFallback=true so Invoke-WindowMaximize can skip UIAutomation (which would fail
    on a CanMaximize=false window) and go directly to WinAPI ShowWindow.
#>


# ---------------------------------------------------------------------------
# Private UIAutomation boundary helpers -- mockable in unit tests.
# These thin wrappers isolate all UIAutomation .NET type references from the
# main function logic, allowing Pester to mock them without UIAutomationClient loaded.
# ---------------------------------------------------------------------------

function New-PropertyCondition {
    <#
    .SYNOPSIS
        Creates a UIAutomation PropertyCondition for the given process ID.
    .NOTES
        Private helper -- mockable boundary for UIAutomation type construction.
    #>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param (
        [Parameter(Mandatory)]
        [int]$ProcessId
    )
    return New-Object System.Windows.Automation.PropertyCondition(
        [System.Windows.Automation.AutomationElement]::ProcessIdProperty, [int]$ProcessId)
}

function New-OrCondition {
    <#
    .SYNOPSIS
        Creates a UIAutomation OrCondition from an array of conditions.
    .NOTES
        Private helper -- mockable boundary. Caller must pass 2+ conditions; OrCondition
        throws ArgumentException if given fewer than two. Guard is in Get-MainWindow.
    #>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param (
        [Parameter(Mandatory)]
        [object[]]$Conditions
    )
    return New-Object System.Windows.Automation.OrCondition($Conditions)
}

function Invoke-RootElementSearch {
    <#
    .SYNOPSIS
        Calls AutomationElement.RootElement.FindAll with the given search condition.
    .NOTES
        Private helper -- mockable boundary for the UIAutomation tree traversal.
    #>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param (
        [Parameter(Mandatory)]
        [object]$SearchCondition
    )
    $rootElement = [System.Windows.Automation.AutomationElement]::RootElement
    return $rootElement.FindAll([System.Windows.Automation.TreeScope]::Children, $SearchCondition)
}

function Get-WindowPattern {
    <#
    .SYNOPSIS
        Retrieves the WindowPattern from an AutomationElement.
    .DESCRIPTION
        Returns the WindowPattern if supported, or $null if not. Wraps the
        UIAutomationClient type reference so the call is mockable in Pester tests.
    .NOTES
        Private helper -- mockable boundary for WindowPattern access.
    #>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param (
        [Parameter(Mandatory)]
        [object]$Element
    )
    try {
        return $Element.GetCurrentPattern(
            [System.Windows.Automation.WindowPatternIdentifiers]::Pattern)
    }
    catch {
        return $null
    }
}

function Invoke-ChildElementSearch {
    <#
    .SYNOPSIS
        Searches for direct child Window-type elements under the given parent element.
    .NOTES
        Private helper -- mockable boundary for overlay detection.
    #>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param (
        [Parameter(Mandatory)]
        [object]$ParentElement
    )
    $overlayCondition = New-Object System.Windows.Automation.PropertyCondition(
        [System.Windows.Automation.AutomationElement]::ControlTypeProperty,
        [System.Windows.Automation.ControlType]::Window)
    return $ParentElement.FindAll([System.Windows.Automation.TreeScope]::Children, $overlayCondition)
}

function Invoke-OverlayDismissal {
    <#
    .SYNOPSIS
        Best-effort dismissal of overlay/dialog child windows on the given main window element.
    .DESCRIPTION
        Scans direct children of the main window for Window-type elements and attempts to close
        them via WindowPattern.Close(). This removes "What's New" and update dialogs so the
        desktop is clean after application startup. All operations are wrapped in try/catch --
        failure to dismiss an overlay does not block window maximization.
    .NOTES
        Private helper -- mockable boundary for overlay dismissal side effects.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Mandatory)]
        [object]$MainWindow,

        [Parameter()]
        [string]$Name = 'Unknown'
    )

    try {
        $children = Invoke-ChildElementSearch -ParentElement $MainWindow
        foreach ($child in $children) {
            try {
                if (-not $child.Current.IsEnabled -or $child.Current.IsOffscreen) { continue }
                $childPattern = Get-WindowPattern -Element $child
                if ($null -ne $childPattern) {
                    $childPattern.Close()
                    Write-EnvkLog -Level 'DEBUG' -Message "Get-MainWindow: dismissed overlay child window for '$Name'"
                }
            }
            catch {
                Write-EnvkLog -Level 'DEBUG' -Message "Get-MainWindow: overlay dismissal failed for '$Name' child -- $($_.Exception.Message)"
            }
        }
    }
    catch {
        Write-EnvkLog -Level 'DEBUG' -Message "Get-MainWindow: overlay child search failed for '$Name' -- $($_.Exception.Message)"
    }
}

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

function Get-MainWindow {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [int[]]$ProcessIds,

        [Parameter()]
        [string]$Name = 'Unknown',

        [Parameter()]
        [switch]$EnableDebug
    )

    # ------------------------------------------------------------------
    # Step 1: Build PID search condition with OrCondition guard.
    # OrCondition requires >=2 conditions; use PropertyCondition directly
    # for single-PID apps to avoid ArgumentException.
    # ------------------------------------------------------------------
    $idConditions = @()
    foreach ($procId in $ProcessIds) {
        $idConditions += New-PropertyCondition -ProcessId $procId
    }

    if ($idConditions.Count -gt 1) {
        $searchCondition = New-OrCondition -Conditions $idConditions
    }
    elseif ($idConditions.Count -eq 1) {
        $searchCondition = $idConditions[0]
    }
    else {
        Write-EnvkLog -Level 'ERROR' -Message "Get-MainWindow: no valid PIDs provided for '$Name' -- cannot search"
        return $null
    }

    # ------------------------------------------------------------------
    # Step 2: Search UIAutomation tree for matching windows.
    # Wrapped in try/catch to handle sporadic "Unexpected HRESULT" COM errors
    # that can occur when multiple STA runspaces simultaneously access the
    # UIAutomation COM infrastructure (e.g., parallel worker runspaces in
    # Start-EnvkAppBatch). Returning $null signals the caller (Invoke-WindowMaximize
    # polling loop) to treat this poll attempt as a miss and retry.
    # ------------------------------------------------------------------
    $windows = $null
    try {
        $windows = Invoke-RootElementSearch -SearchCondition $searchCondition
    }
    catch {
        Write-EnvkLog -Level 'DEBUG' -Message "Get-MainWindow: UIAutomation RootElement search failed for '$Name' -- $($_.Exception.Message) (will retry)"
        return $null
    }

    # ------------------------------------------------------------------
    # Step 3: Apply heuristic filtering to find the real main window.
    # Two-result approach: prefer CanMaximize=true, fall back to CanMaximize=false.
    #
    # $mainWindow holds the best candidate found so far.
    # $isFramelessFallback tracks whether the current candidate is a frameless fallback.
    #
    # When CanMaximize=true is found, break immediately (preferred match).
    # When CanMaximize=false is found and no better window exists yet, store as fallback
    # but continue scanning -- a CanMaximize=true window may still appear later.
    #
    # Each window access is wrapped in try/catch for ElementNotAvailableException
    # in case the window closes between FindAll and property access.
    # ------------------------------------------------------------------
    $mainWindow          = $null
    $isFramelessFallback = $false

    foreach ($window in $windows) {
        try {
            $isEnabled    = $window.Current.IsEnabled
            $isOffscreen  = $window.Current.IsOffscreen
            $controlType  = $window.Current.ControlType
            $windowName   = $window.Current.Name
            $processId    = $window.Current.ProcessId
            $nativeHandle = $window.Current.NativeWindowHandle
            $interactState = $window.Current.WindowInteractionState

            # EnableDebug: log all candidates before filtering so the user can
            # see why a window was skipped.
            if ($EnableDebug) {
                $wpDebug       = Get-WindowPattern -Element $window
                $canMaxDebug   = if ($null -ne $wpDebug) { $wpDebug.Current.CanMaximize } else { $false }
                Write-EnvkLog -Level 'DEBUG' -Message (
                    "Get-MainWindow candidate: Name='$windowName', ControlType=$controlType, " +
                    "IsEnabled=$isEnabled, IsOffscreen=$isOffscreen, ProcessId=$processId, " +
                    "CanMaximize=$canMaxDebug, WindowInteractionState=$interactState, " +
                    "NativeWindowHandle=$nativeHandle")
            }

            # Heuristic 1: must be enabled
            if (-not $isEnabled) { continue }

            # Heuristic 2: must be on-screen
            if ($isOffscreen) { continue }

            # Heuristic 3: must be Window or Pane control type.
            # Accept both string form (used in tests/fakes) and UIAutomation enum form
            # (used in production with UIAutomationClient loaded).
            $isWindowType = ($controlType -eq 'Window') -or ($controlType -eq 'Pane')
            if (-not $isWindowType) {
                try {
                    $isWindowType = (
                        $controlType -eq [System.Windows.Automation.ControlType]::Window -or
                        $controlType -eq [System.Windows.Automation.ControlType]::Pane)
                }
                catch {
                    $isWindowType = $false
                }
            }
            if (-not $isWindowType) { continue }

            # Heuristic 4: must have a non-empty title (splash screens have no title)
            if ([string]::IsNullOrEmpty($windowName)) { continue }

            # Heuristic 5: must support WindowPattern (required even for frameless fallback).
            # CanMaximize=true is preferred; CanMaximize=false is accepted as frameless fallback.
            $windowPattern = Get-WindowPattern -Element $window
            if ($null -eq $windowPattern) { continue }

            if ($windowPattern.Current.CanMaximize) {
                # Preferred: full-heuristic match -- this is the main window.
                $mainWindow          = $window
                $isFramelessFallback = $false
                break
            }
            elseif ($null -eq $mainWindow) {
                # Frameless fallback candidate: CanMaximize=false, but all other heuristics pass.
                # Store as candidate but do NOT break -- keep scanning for a CanMaximize=true window.
                $mainWindow          = $window
                $isFramelessFallback = $true
            }
        }
        catch {
            # ElementNotAvailableException or similar -- window closed mid-inspection.
            # Skip this element and continue with remaining candidates.
            Write-EnvkLog -Level 'DEBUG' -Message "Get-MainWindow: skipped stale element for '$Name' -- $($_.Exception.Message)"
            continue
        }
    }

    if ($null -eq $mainWindow) {
        return $null
    }

    # ------------------------------------------------------------------
    # Step 4: Overlay dismissal -- best-effort close of child dialogs
    # before returning the main window (e.g. "What's New" overlays).
    # ------------------------------------------------------------------
    Invoke-OverlayDismissal -MainWindow $mainWindow -Name $Name

    Write-EnvkLog -Level 'DEBUG' -Message "Get-MainWindow: selected main window '$($mainWindow.Current.Name)' for '$Name' (IsFramelessFallback=$isFramelessFallback)"

    return [PSCustomObject]@{
        Window              = $mainWindow
        IsFramelessFallback = $isFramelessFallback
    }
}