Skills/windows-gui-screenshot-capture/scripts/WindowCapture.ps1
|
#requires -Version 5.1 Set-StrictMode -Version Latest # --------------------------------------------------------------------------------------------- # Capture a window that is ALREADY OPEN and owned by the user. # # Sibling of DialogCapture.ps1, with the opposite lifecycle contract: that helper owns a process # it started and may close it; this one must never launch, close, or kill anything. It implements # the "attempt, validate, then escalate" ladder from ../SKILL.md, section "Step 2 - Apply the # GPU-composited rule", so a composited top-level frame (a browser window) is captured by the # cheap path when that works and by a composited desktop read when it does not. # --------------------------------------------------------------------------------------------- function Initialize-OpenWindowCapture { [CmdletBinding()] param() # A distinct type name from GuiDoc.WindowCapture: both helpers can be dot-sourced into one # session, and a loaded type cannot be redefined. if (-not ('GuiDoc.OpenWindowCapture' -as [type])) { Add-Type -Namespace 'GuiDoc' -Name 'OpenWindowCapture' -MemberDefinition @' [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)] public static extern bool GetWindowRect(System.IntPtr hWnd, out RECT lpRect); [System.Runtime.InteropServices.DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(System.IntPtr hWnd, int attribute, out RECT value, int size); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool PrintWindow(System.IntPtr hWnd, System.IntPtr hdcBlt, uint nFlags); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool IsWindow(System.IntPtr hWnd); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool IsIconic(System.IntPtr hWnd); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint lpdwProcessId); [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)] public static extern bool SetProcessDpiAwarenessContext(System.IntPtr value); public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } '@ } } function Confirm-OpenWindowOwnership { <# .SYNOPSIS Verifies that a window handle still belongs to the expected process. .PARAMETER Process The process the caller resolved the window from. Never adopted from a title match. .PARAMETER Handle The top-level window handle to verify. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [System.Diagnostics.Process]$Process, [Parameter(Mandatory)] [System.IntPtr]$Handle ) Initialize-OpenWindowCapture if ($Process.HasExited) { throw "The target process has exited: $($Process.Id)." } if (-not [GuiDoc.OpenWindowCapture]::IsWindow($Handle)) { throw "The window handle is no longer valid: $Handle." } $windowProcessId = [uint32]0 $null = [GuiDoc.OpenWindowCapture]::GetWindowThreadProcessId($Handle, [ref]$windowProcessId) if ($windowProcessId -ne $Process.Id) { throw "Window $Handle belongs to process $windowProcessId, not $($Process.Id)." } } function Test-WindowCaptureContent { <# .SYNOPSIS Rejects a uniform or predominantly black capture. .DESCRIPTION PrintWindow can return $true for an unpainted frame, so the pixels themselves are the gate. Raise MaximumDarkRatio for a legitimately dark-theme target: a universal black threshold rejects valid dark screenshots. .PARAMETER Bitmap The captured bitmap to sample. .PARAMETER MinimumDistinctColor Distinct sampled colours required before the frame counts as painted. .PARAMETER MaximumDarkRatio Highest tolerated fraction of near-black samples. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [ValidateNotNull()] [System.Drawing.Bitmap]$Bitmap, [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int]$MinimumDistinctColor = 24, [Parameter()] [ValidateRange(0.0, 1.0)] [double]$MaximumDarkRatio = 0.5 ) $distinctColor = [System.Collections.Generic.HashSet[int]]::new() $darkSample = 0 $sample = 0 for ($x = 4; $x -lt $Bitmap.Width; $x += 17) { for ($y = 4; $y -lt $Bitmap.Height; $y += 17) { $pixel = $Bitmap.GetPixel($x, $y) $null = $distinctColor.Add($pixel.ToArgb()) if (($pixel.R + $pixel.G + $pixel.B) -lt 24) { $darkSample++ } $sample++ } } if ($sample -eq 0) { return $false } $darkRatio = $darkSample / $sample Write-Verbose "Samples=$sample DistinctColors=$($distinctColor.Count) DarkRatio=$([math]::Round($darkRatio, 4))" return ($distinctColor.Count -ge $MinimumDistinctColor -and $darkRatio -lt $MaximumDarkRatio) } function Get-WindowFrameRectangle { <# .SYNOPSIS Reads a window's visible frame, excluding the invisible DWM resize border. .PARAMETER Handle The top-level window handle to measure. #> [CmdletBinding()] [OutputType([GuiDoc.OpenWindowCapture+RECT])] param( [Parameter(Mandatory)] [System.IntPtr]$Handle ) Initialize-OpenWindowCapture $bounds = New-Object 'GuiDoc.OpenWindowCapture+RECT' # DWMWA_EXTENDED_FRAME_BOUNDS (9); GetWindowRect would add the invisible resize border. $dwmResult = [GuiDoc.OpenWindowCapture]::DwmGetWindowAttribute( $Handle, 9, [ref]$bounds, [System.Runtime.InteropServices.Marshal]::SizeOf($bounds) ) if ($dwmResult -ne 0) { Write-Verbose "DwmGetWindowAttribute returned $dwmResult; falling back to GetWindowRect." if (-not [GuiDoc.OpenWindowCapture]::GetWindowRect($Handle, [ref]$bounds)) { throw "Could not read the bounds of window $Handle." } } if (($bounds.Right - $bounds.Left) -le 0 -or ($bounds.Bottom - $bounds.Top) -le 0) { throw "Window $Handle reported an empty rectangle." } $bounds } function Save-OpenWindowCapture { <# .SYNOPSIS Captures a window that is already open to a PNG, without launching or closing anything. .DESCRIPTION Applies the SKILL.md Step 2 ladder: PrintWindow with PW_RENDERFULLCONTENT first, pixel validation, then a composited desktop read through CopyFromScreen. Restores a window it un-minimized and never terminates the target, which is user-owned. The screen fallback copies whatever occupies those pixels, so it needs the window restored and unobscured. Review the result before sharing it. .PARAMETER Process The process that owns the window. Ownership is re-checked against the handle. .PARAMETER Handle The top-level window handle to capture. .PARAMETER Path Destination PNG path. Missing parent folders are created. .PARAMETER MaximumDarkRatio Passed to Test-WindowCaptureContent. Raise it for a dark-theme target. .EXAMPLE $edge = Get-Process msedge | Where-Object MainWindowTitle -match 'WELT' Save-OpenWindowCapture -Process $edge -Handle $edge.MainWindowHandle -Path "$env:TEMP\edge.png" #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNull()] [System.Diagnostics.Process]$Process, [Parameter(Mandatory)] [System.IntPtr]$Handle, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter()] [ValidateRange(0.0, 1.0)] [double]$MaximumDarkRatio = 0.5 ) Initialize-OpenWindowCapture Add-Type -AssemblyName System.Drawing Confirm-OpenWindowOwnership -Process $Process -Handle $Handle # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4): without it a virtualized host reads # scaled bounds and the screen fallback lands on the wrong pixels. Fails harmlessly when # awareness is already set by the host. try { $null = [GuiDoc.OpenWindowCapture]::SetProcessDpiAwarenessContext([System.IntPtr]::new(-4)) } catch { Write-Verbose "Could not set DPI awareness: $($_.Exception.Message)" } $wasMinimized = [GuiDoc.OpenWindowCapture]::IsIconic($Handle) $bitmap = $null $graphics = $null try { if ($wasMinimized) { $null = [GuiDoc.OpenWindowCapture]::ShowWindow($Handle, 9) # SW_RESTORE } $null = [GuiDoc.OpenWindowCapture]::SetForegroundWindow($Handle) $bounds = Get-WindowFrameRectangle -Handle $Handle $width = $bounds.Right - $bounds.Left $height = $bounds.Bottom - $bounds.Top $bitmap = [System.Drawing.Bitmap]::new( $width, $height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb ) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) $deviceContext = $graphics.GetHdc() try { # PW_RENDERFULLCONTENT (0x2): required for a composited frame, still not a guarantee. $printed = [GuiDoc.OpenWindowCapture]::PrintWindow($Handle, $deviceContext, [uint32]2) } finally { $graphics.ReleaseHdc($deviceContext) } $method = 'PrintWindow' $contentParameters = @{ Bitmap = $bitmap MaximumDarkRatio = $MaximumDarkRatio } if (-not $printed -or -not (Test-WindowCaptureContent @contentParameters)) { Write-Verbose 'PrintWindow failed its content gate; reading the composited desktop.' $graphics.CopyFromScreen( $bounds.Left, $bounds.Top, 0, 0, $bitmap.Size, [System.Drawing.CopyPixelOperation]::SourceCopy ) $method = 'CopyFromScreen' if (-not (Test-WindowCaptureContent @contentParameters)) { throw "Both capture paths produced an unpainted image for window $Handle." } } $directory = Split-Path -Path $Path -Parent if ($directory -and -not (Test-Path -LiteralPath $directory)) { $null = New-Item -ItemType Directory -Path $directory -Force } $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) [pscustomobject]@{ Path = $Path Width = $width Height = $height Method = $method } } finally { if ($graphics) { $graphics.Dispose() } if ($bitmap) { $bitmap.Dispose() } # Restore only what this helper changed; the process stays untouched. if ($wasMinimized -and [GuiDoc.OpenWindowCapture]::IsWindow($Handle)) { $null = [GuiDoc.OpenWindowCapture]::ShowWindow($Handle, 6) # SW_MINIMIZE } } } |