--- name: windows-gui-screenshot-capture compatibility: Requires Windows and PowerShell 5.1+ with .NET WPF, WinForms, and Win32 interop. Every capture API is Windows-only. description: >- Captures Windows GUI screenshots from modifiable apps, third-party executables, and already-open windows, then assembles screenshot-embedded Markdown manuals. Selects the correct API for WPF, WinForms, Win32/GDI, WebView2, Avalonia, and WinUI 3; covers external-process driving, event-based readiness, GPU black frames, native dialogs, state restoration, and content validation. USE FOR: capture Windows GUI screenshot, automate app screenshots, screenshot-based user manual, document existing Windows exe, third-party desktop app screenshots, capture an already-open window, screenshot Edge or Chrome window, CopyFromScreen fallback, WPF, WinForms, Win32, WebView2, Avalonia, WinUI 3, PrintWindow black, SetWinEventHook, RenderTargetBitmap, DrawToBitmap, CapturePreviewAsync, Windows.Graphics.Capture, MessageBox #32770 capture, STA GUI PowerShell. DO NOT USE FOR: cross-platform or mobile screenshots, screen-video capture, general desktop RPA without screenshot-documentation intent, generic GUI tutorials. --- # Windows GUI Screenshot Capture Capture screenshots of a Windows desktop GUI programmatically - using the capture API each rendering engine actually supports - and assemble them into a Markdown user manual with embedded images. Generalises the technique proven in the `D:\guitest` proof-of-concept to real apps, so the recipes below reference that repo as the worked example rather than being tied to it. ## When to use - "Automate screenshots of my WPF / WinForms / Win32 / WebView2 / Avalonia / WinUI 3 app." - "Generate a user manual with real screenshots, unattended (CI-friendly)." - "`PrintWindow` returns a black image for my WebView2 / WinUI 3 window." - "Capture a MessageBox / native dialog to PNG from PowerShell." - "My GUI script blocks the terminal and never exits when capturing." ## Outcome One PNG per predefined UI state, captured with the correct per-engine API into `docs/images//`, plus a Markdown manual that embeds them - all produced by a single orchestrator run with no manual clicking and no persistent change to user settings. ## Dependencies - PowerShell 7+ on Windows, run **STA** (a detached `pwsh` is STA by default; guard and relaunch if the host is MTA). - .NET assemblies: `PresentationFramework` (WPF); `System.Windows.Forms` + `System.Drawing` (WinForms); `user32.dll` / `gdi32.dll` P/Invoke (Win32 + native dialogs). - `dotnet` SDK for compiled engines; the Edge **WebView2 runtime**; `Avalonia.Skia` + `Avalonia.Headless`; the **Windows App SDK** for WinUI 3. - Process-scoped native-dialog helper with checked capture/close results: [`scripts/DialogCapture.ps1`](scripts/DialogCapture.ps1). - Short per-engine recipes: [`references/engine-recipes.md`](references/engine-recipes.md). ## Step 1 - Pick the capture API by rendering engine The single most important decision. Each engine has one capture method that works; the wrong one yields a black or clipped image. | Rendering engine | Typical host | Capture API | Note | |---|---|---|---| | WPF (DirectX / Milcore) | PowerShell + `Add-Type` | `RenderTargetBitmap` | In-process; ignores focus / z-order | | WinForms (GDI+) | PowerShell | `Control.DrawToBitmap` | Renders whole window - size bitmap to `Form.Size` | | Win32 / GDI (CPU) | PowerShell + P/Invoke | `PrintWindow` (`PW_RENDERFULLCONTENT`) | Works off-screen | | WebView2 control in a host | dotnet WinForms host | `CoreWebView2.CapturePreviewAsync` | `PrintWindow` on the host returns black | | Avalonia (Skia) | dotnet headless | `Window.CaptureRenderedFrame` | No visible window needed | | WinUI 3 (Composition / GPU) | dotnet + Windows App SDK | `RenderTargetBitmap.RenderAsync` **or** `Windows.Graphics.Capture` | `PrintWindow` returns black | | External composited window | PowerShell + P/Invoke | Validated `PrintWindow`, else `CopyFromScreen` | A browser frame often does print - prove it per app (Step 2) | A two-to-three-line snippet per engine, each pointing to the full POC implementation, is in [`references/engine-recipes.md`](references/engine-recipes.md). ## Step 2 - Apply the GPU-composited rule `PrintWindow` / `BitBlt` read the window's CPU-side surface, so **GPU-composited content can come back solid black**. Use `PrintWindow` for Win32/GDI, `DrawToBitmap` for in-process WinForms, and `RenderTargetBitmap` for an in-process WPF visual; do not infer external WPF reliability from the Win32 result. Separate two composited cases before reaching for a heavier API: - **A composited control hosted inside your window** - a WebView2 in a WinForms/WPF host, or WinUI 3 / UWP content. Another process composites those pixels, so `PrintWindow` against the host returns black. Go straight to the framework API: `CapturePreviewAsync`, `RenderTargetBitmap.RenderAsync`. - **A composited application's own top-level window** - the Edge or Chrome browser frame. This is *not* automatically black. Measured on Windows `10.0.26200` with Edge `151.0.4129.72`, `PrintWindow(PW_RENDERFULLCONTENT)` returned a fully painted 2560x1540 frame on the first attempt, no fallback needed. One measurement is not a guarantee: the outcome is build-, GPU-, and application-dependent. Never assume in either direction - **attempt, validate, then escalate**: 1. `PrintWindow` with `PW_RENDERFULLCONTENT`, then run the pixel gates from the Verification section. A `true` return is not proof (see Anti-rationalization). 2. On a failed gate, read the composited desktop with `Graphics.CopyFromScreen` over `DWMWA_EXTENDED_FRAME_BOUNDS`. Cheap and engine-agnostic, but it requires the window restored, on top, and unobscured, and it captures whatever actually covers those pixels. 3. If the window must stay off-screen or occluded, use `Windows.Graphics.Capture` (Windows 10 1803+) - the general answer for composited or external windows, at the cost of WinRT interop. ## Step 3 - Choose how to drive scenes Choose the branch by ownership. Do not assume every executable can be modified, and do not assume you are allowed to start or stop the target at all. ### Apps you can modify Give the app two modes so it runs unattended and never blocks the terminal: - **Interactive (default):** `ShowDialog()` / `Application.Run()` - a human uses it. - **Capture:** a `-CaptureDir ` (PowerShell) or `--capture ` (dotnet) switch. A timer steps through predefined *scenes* (named UI states), renders each to `.png`, and **self-terminates** after the last one. Why a timer: it fires on the UI dispatcher after layout, so the visual is measured before you render it; and it keeps ticking through modal dialog loops (Step 5). ```powershell $scenes = @( @{ Name = 'app-01-start'; Action = { <# set UI to state 1 #> } } @{ Name = 'app-02-selected'; Action = { <# set UI to state 2 #> } } ) $script:i = 0 & $scenes[0].Action $timer = [System.Windows.Threading.DispatcherTimer]::new() $timer.Interval = [TimeSpan]::FromMilliseconds(500) $timer.Add_Tick({ $scene = $scenes[$script:i] Save-VisualScreenshot -Visual $root -Path (Join-Path $CaptureDir "$($scene.Name).png") if (++$script:i -ge $scenes.Count) { $timer.Stop(); $window.Close() } else { & $scenes[$script:i].Action } }) $window.Add_ContentRendered({ $timer.Start() }) $app = [System.Windows.Application]::new(); [void]$app.Run($window) ``` Full versions: `D:\guitest\src\Wpf\Show-CapitalFinder.ps1` (PowerShell), and the `--capture` mode in `src\WebView2\Program.cs` and `src\Avalonia\Program.cs` (dotnet). ### Existing or third-party executables Use an external driver when source is unavailable: 1. Record the executable version and open a deterministic, nonprivate sample file or fixture. 2. Launch with `ProcessStartInfo.ArgumentList`; use `WaitForInputIdle` only for initial startup. 3. Scope every window by process ID plus stable identity (owner, class, title, or control). 4. Drive scenes through UI Automation, command/control IDs, or app-specific APIs - never mouse coordinates when a stable interface exists. 5. Wait for the top-level window and known child state through UI Automation or a message-pumping WinEvent hook; a visible dialog and child control can still be unpainted. 6. Snapshot every changed option, restore it in `finally`, close each dialog, and terminate only the process the driver started. For the full external-process workflow, cross-process control handling, race-free event wait, and validation gates, read [`references/external-win32-executables.md`](references/external-win32-executables.md). ### A window that is already open When the user points at a window that is already running - "screenshot my Edge window" - there is no scene list, no launch, and no teardown. The process is **user-owned**, which inverts the cleanup rules above: 1. Resolve the target by process plus window handle, then re-check ownership with `GetWindowThreadProcessId` before touching the handle. Never capture `GetForegroundWindow`. 2. Make the capture host per-monitor DPI aware before reading bounds, or a scaled display yields virtualized coordinates and an offset screen read. 3. Take bounds from `DWMWA_EXTENDED_FRAME_BOUNDS`, not `GetWindowRect`, which includes the invisible DWM resize border. 4. Apply the Step 2 ladder: validated `PrintWindow`, then `CopyFromScreen`. 5. Restore what you changed - re-minimize a window you restored - and **never** close or kill a user-owned process. 6. Capture the window rectangle only, and review the frame for private content before sharing it. A browser frame exposes tabs, profile name, and history. Ready-to-use helper: [`scripts/WindowCapture.ps1`](scripts/WindowCapture.ps1) (`Save-OpenWindowCapture`). ## Step 4 - Name scenes consistently across engines Use identical scene names in every engine (`app-01-start`, `app-02-selected`, ...). Consistent names let one manual template embed any framework's image folder, and make cross-engine comparison trivial - the same scene rendered by each engine side by side (as in the POC's `docs/RenderingTechnologies.md`). ## Step 5 - Capture native dialogs (MessageBox `#32770`) A `MessageBox` is a separate OS window that `RenderTargetBitmap` / `DrawToBitmap` cannot reach. Capture it with the native helper [`scripts/DialogCapture.ps1`](scripts/DialogCapture.ps1) (origin: `D:\guitest\src\Common\DialogCapture.ps1`): 1. Retain the original `Process` object and target the dialog by process + owner + **class `#32770`** + title, never by foreground window. 2. For an in-process app, use its UI timer so capture continues during the modal loop. 3. For an external executable, use UI Automation events or register `SetWinEventHook(EVENT_OBJECT_SHOW, WINEVENT_OUTOFCONTEXT)` on a thread that pumps messages. A blocking wait on the registering thread can prevent callback delivery. 4. Scan before and after hook registration and after each wake; unrelated show events can wake the hook, so always re-evaluate the complete process/owner/class/title predicate. 5. After the top-level dialog appears, require expected child text/state, then run a bounded capture-validation retry. Control visibility proves creation, not painted pixels. 6. Use the helper as `Get-DialogWindowHandle -Process $process ...`, `Save-WindowImage -Process $process ...`, and `Send-WindowClose -Process $process ...`; it rejects cross-process handles and failed `GetWindowRect`, `PrintWindow`, or `PostMessage` calls. The timer keeps firing *during* the modal loop - that is how capture-then-dismiss works. **Never** capture the foreground window (`GetForegroundWindow`) to "find" the dialog: it may be the launching console. Always target the specific `#32770` window by class + title. ## Step 6 - Assemble the manual and verify 1. One orchestrator runs each scene source through its ownership branch: invoke `-CaptureDir` or `--capture` for modifiable apps; launch and drive the recorded process ID for external executables. Apply bounded readiness and shutdown timeouts in both branches. A modifiable app's capture mode self-exits; an external driver owns and closes only the process it started. 2. Collect the PNGs into `docs/images//`. 3. Write the Markdown manual: intro, then a per-scene section with the user steps and `![alt](images//.png)`. 4. Verify (below) before declaring done. ## Gotchas | Symptom | Cause | Fix | |---|---|---| | WPF result area clipped in the shot | Fixed-size window | Set `window.SizeToContent = 'Height'` in capture mode | | WinForms bottom / right edge cut off | `DrawToBitmap` renders the *whole* window | Size the bitmap to `Form.Size`, not `ClientSize` | | WinForms fonts scaled / clipped on a 150% display | DPI scaling | `Application.SetHighDpiMode('DpiUnaware')` before building the form | | Avalonia headless text is blank | No real font registered | `.WithInterFont()` + `AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }` (Skia) | | Hosted WebView2 / WinUI 3 shot is solid black | Another process composites the child surface | Use the framework API or `Windows.Graphics.Capture` | | Reached for WinRT capture and it was never needed | Treated "Chromium" as "always black" | An app's own top-level frame may print; attempt and validate first (Step 2) | | `CopyFromScreen` frame is offset or scaled | Capture host is DPI-virtualized | `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` before reading bounds | | Screen capture includes a strip of desktop around the window | `GetWindowRect` includes the invisible DWM resize border | Read `DWMWA_EXTENDED_FRAME_BOUNDS` via `DwmGetWindowAttribute` | | Dialog title renders but client area is black | Frame appeared before content painted | Wait for expected child state, then retry capture and content validation within a bound | | Cross-process combo remains empty | Text was sent to the combo wrapper | Find its child `Edit` control and send `WM_SETTEXT`, or use UI Automation | | Revised `Add-Type` definition is ignored | The type is already loaded in the session | Run the capture script in a clean `pwsh` process while iterating P/Invoke code | | Apartment-state error / self-relaunch loop | UI needs STA | Run STA (`pwsh` detached is STA); guard and relaunch only if MTA | | Terminal never returns | GUI is blocking | Capture mode must self-terminate; orchestrator waits with a timeout | ## Anti-rationalization | Rationalization | Reality | |---|---| | "I'll just `PrintWindow` everything, it's simpler." | Returns black for a hosted WebView2 control and for WinUI 3. Match the API to the engine or the screenshot is unusable. | | "It's Chromium, so `PrintWindow` is pointless - I need WinRT capture." | That holds for a WebView2 control inside a host window, not for the browser's own top-level frame. Attempt the cheap path and let the pixel gates decide. | | "I'll capture the foreground window to get the dialog." | The foreground window may be the launching console. Use the started `Process` plus owner, class, and title. | | "`ClientSize` is close enough for the WinForms bitmap." | `DrawToBitmap` renders the whole window; `ClientSize` clips the border. Use `Form.Size`. | | "I'll paste the screenshots in by hand this once." | Manual capture is not reproducible and rots. The scene mode + orchestrator must regenerate them. | | "The GUI opened, so capture works - ship it." | Layout may be incomplete or the wrong window captured. Verify each PNG exists and is non-trivial. | | "`PrintWindow` returned true, so the image is valid." | A successful call can still capture an unpainted black client area. Validate image content and expected landmarks. | | "A short sleep will let the dialog settle." | Fixed delays are flaky across machines. Wait for the specific window and ready control event. | | "Changing a few view options is harmless." | External apps can persist them. Snapshot and restore every modified option in `finally`. | ## Red flags - stop if you catch yourself - About to report "screenshots generated" without checking the PNGs exist and are non-empty. - Using foreground or global title-only discovery instead of the started `Process` plus owner/class/title identity. - Capturing a WebView2 / WinUI 3 window with `PrintWindow` and accepting a black image. - Closing, killing, or changing the settings of a window the user already had open. - Writing a capture script that needs a human to click or to close it - it must self-terminate. - Sizing a WinForms capture bitmap to `ClientSize`. - Driving a third-party executable through screen coordinates when stable controls are available. - Capturing immediately after a dialog handle appears without checking a ready child control. - Leaving the target app running or its view settings changed after the orchestrator exits. ## Verification Confirm before reporting done: - Every expected PNG exists, exceeds the minimum dimensions/size, and has non-uniform pixels. File size alone is insufficient: a 2 KB image can contain a title bar over a black client area. - Pixel checks are scene-aware: reject mostly black frames only when the expected scene is not a dark theme, and verify at least one expected visual landmark or control region. - Visually inspect every final frame for blank, clipped, stale, private, or wrong-window content. - Scene count per framework matches the app's scene list. - All capture scripts parse clean: `[System.Management.Automation.Language.Parser]::ParseFile($p, [ref]$null, [ref]$errs)` -> 0 errors (the POC wraps this in `tools/Test-Syntax.ps1`). - The manual renders with every `![...](images/...)` resolving to a file that exists. - The orchestrator leaves no target process it started and restores every changed setting. - Cleanup retains the original `Process` object, checks `HasExited`, and never adopts an existing single-instance process merely because a short-lived launcher forwarded the request. ## Worked example `D:\guitest` implements the same **Capital Finder** app across all six engines, plus a WPF / WinForms **Setup Wizard** whose final step raises the `#32770` completion dialog. Read it for full, working implementations: - Capture helpers: `src/Common/DialogCapture.ps1`, `src/Wpf/Capture.ps1`, `src/WinForms/Capture.ps1`. - Self-capturing apps: `src/Wpf/Show-CapitalFinder.ps1`, `src/Wpf/Start-SetupWizard.ps1`, `src/Win32/Show-CapitalFinder.ps1`, `src/WebView2/Program.cs`, `src/Avalonia/Program.cs`, `src/WinUI3/README.md`. - Orchestration + verify: `docs/Generate-Screenshots.ps1`, `tools/Test-Syntax.ps1`. - Assembled manuals: `docs/UserManual.md`, `docs/UserManual.WinForms.md`, `docs/RenderingTechnologies.md`. ## See also - Per-engine capture snippets: [`references/engine-recipes.md`](references/engine-recipes.md). - Ready-to-use native-dialog helper: [`scripts/DialogCapture.ps1`](scripts/DialogCapture.ps1). - Ready-to-use already-open-window helper: [`scripts/WindowCapture.ps1`](scripts/WindowCapture.ps1). - Eval prompts: [`notes-evals.md`](notes-evals.md).