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