Skip to content

fix(wgc): make the Windows recorder DPI-aware and stop guessing its monitor - #351

Merged
EtienneLescot merged 2 commits into
mainfrom
claude/github-issue-346-6f7a9f
Aug 12, 2026
Merged

fix(wgc): make the Windows recorder DPI-aware and stop guessing its monitor#351
EtienneLescot merged 2 commits into
mainfrom
claude/github-issue-346-6f7a9f

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

cursor-sampler.exe opted into per-monitor-v2 DPI awareness in 5efe5e6. wgc-capture.exe — the recorder itself — never did, and its monitor lookup quietly depended on staying unaware: findMonitorForCapture matched the bounds it was handed against the rects from EnumDisplayMonitors, which a DPI-unaware process gets virtualized, divided by the primary display's scale factor whatever monitor they describe. The bounds on the other side came from Display.bounds, which is DIPs. Two coordinate spaces, one comparison.

Measured on a 1920×1080 panel at 150 %: Electron reports scaleFactor 1.875 (DIP bounds 1024x576) while Win32 virtualization divides by 1.5 (1280x720). The two spaces already diverge on a single display — the issue's assumption that one monitor works by accident does not hold here.

What kept it working was the overlap heuristic, and that is the real defect. A DIP rect and a physical rect anchored on the primary always overlap, so the fallback always answered — correctly, right up to the arrangement where a non-primary display's two origins drift apart by more than a screen width. Then it answered "the primary", and recorded the wrong screen in silence.

What changed

The fix lands as a pair, so both sides move to physical at once.

  • dpi_awareness.h is now the one place that states every native helper runs per-monitor-v2 aware. Both binaries include it. wgc-capture refuses to start if it cannot — after the change below, an unaware process is guaranteed to mismatch, so continuing would ship a known-broken state. cursor-sampler warns and carries on, because a misplaced overlay still leaves a usable recording.
  • helperCoordinates.ts is the TypeScript half: one toHelperRect, used by the capture config and by the cursor telemetry, which previously converted on its own while the recorder did not. The platform guard is load-bearing — dipToScreenRect is @platform win32 and simply absent elsewhere.
  • findMonitorForCapture drops the overlap heuristic and the fall-to-primary. It matches within 8 px or refuses, printing both rects. The tolerance is not slack: at 175 % (scaleFactor 2.1875) Electron's DIP bounds 878x494 round-trip back as 1921x1081, because Chromium scales with enclosing rects in both directions. An exact compare would reject the correct monitor.
  • The two non-Electron producers of the same wire format stop sending a hardcoded 1920x1080 fiction and omit the bounds instead, which lands on the primary deterministically rather than by accident.

macOS and Linux are unaffected: the ScreenCaptureKit helper reports points, the same space Electron uses, and the PipeWire helper normalizes against its own stream dimensions and is never handed a rect.

Behaviour change worth reviewing

The helper now refuses where it used to guess. In practice that fires when the display topology changes inside the ~1 s between reading screen.getAllDisplays() and the helper's EnumDisplayMonitors — plug/unplug, resolution or scale change, docking. The user sees "recording didn't start" and reclicks, instead of getting a video of the wrong screen an hour later. That trade is the point of the PR, but it is a trade.

Not verifiable on the hardware I have: mixed-DPI multi-monitor. The 8 px tolerance comes from an arithmetic sweep (origins −6000..8000 × common panel sizes, floor and round anchoring: worst 4 px up to 300 % scaling, 8 px at 450 %), not from hardware.

Related issue

Fixes #346

Type of change

  • Bug fix
  • Feature
  • Enhancement
  • Documentation
  • Refactor / maintenance
  • Performance
  • Security

Release impact

  • Patch
  • Minor
  • Major / breaking change
  • No release note needed

Desktop impact

  • Windows
  • macOS
  • Linux
  • Installer / packaging
  • Not platform-specific

Screenshots / video

The green box is drawn at the target point (physical 1440,538), zoom ×5 on raw pixels.

system pointer, raw video custom pointer, exported MP4 1080p
tip on the target I-beam stem on the target

Frame geometry detected automatically in the export: content rect x 192..1727, y 108..971 (1536×864, ratio 1.7778). Expected cursor (1344, 538.4); measured stem at x 1345.5 (1.5 px off) and glyph centre y 535 (3 px). Without the conversion it would be at x 1632 — 288 px to the right.

Testing

Real hardware, single 1920×1080 display, driven through the app's HUD at 150 % and 175 % scaling.

The awareness flip

  • GetProcessDpiAwareness on the live process: 0 (UNAWARE) with the old binary, 2 (PER_MONITOR_AWARE) with the new one.
  • Recorded resolution is 1920×1080 with both old and new helper at 150 %, so GraphicsCaptureItem::Size() is physical regardless of awareness — this changes no recording's dimensions. That was the highest-risk unknown; it is now measured, not assumed.
  • Neither binary picked up a VC++ redist import (/MT invariant from the 1.9.1 Store rejection holds).

The bug, reproduced

  • A helper rebuilt without the awareness call, fed the app's real bounds, mismatches: requested 0,0 1024x576 vs enumerated 0,0 1280x720.
  • With the fix, the same path is an exact match at 1920x1080.

End to end through the app

  • 22 s recording started and stopped from the HUD, editor opened, exported to MP4 1080p.
  • Zero monitor-mismatch errors across the whole session.
  • Cursor telemetry: 508/508 samples inside [0,1], cx 0.75000 against 0.750000 computed for the parked pointer.

Refusal paths

  • Forcing the process DPI-unaware with an app-compat shim: the helper exits 1 with ERROR: Could not enable per-monitor-v2 DPI awareness.
  • Bounds that genuinely disagree (878x494, 1024x576, a phantom monitor at 5760,0): refused, both rects in the message.
  • Bounds that only differ by rounding (1921x1081 at 175 %): accepted, three consecutive clean runs.

Suite

  • npx tsc --noEmit, npx tsc -p tsconfig.test.json --noEmit, npm run lint, node scripts/check-docs.mjs: clean.
  • npm run test: 144 files, 1702 passed, 4 skipped.
  • Both helpers rebuilt with npm run build:native:win (no CI job compiles them on a PR).

Summary by CodeRabbit

  • Bug Fixes

    • Improved Windows display capture across mixed-DPI and scaled-monitor setups.
    • Corrected display bounds handling to use physical screen coordinates.
    • Improved monitor matching reliability and prevented incorrect fallback selections.
    • Added clearer diagnostics when display initialization or monitor matching fails.
    • Improved startup handling when DPI configuration cannot be initialized.
  • Tests

    • Added coverage for coordinate conversion across Windows, macOS, and Linux.
    • Updated diagnostic capture scenarios to use automatic primary-monitor selection.

…onitor

`cursor-sampler.exe` opted into per-monitor-v2 DPI awareness in 5efe5e6.
`wgc-capture.exe` -- the recorder itself -- never did, and its monitor lookup
quietly depended on staying unaware: `findMonitorForCapture` matched the bounds
it was handed against the rects from `EnumDisplayMonitors`, which a DPI-unaware
process gets *virtualized*, divided by the primary display's scale factor
whatever monitor they describe. The bounds on the other side came from
`Display.bounds`, which is DIPs. Two coordinate spaces, one comparison.

Measured on a 1920x1080 panel at 150%: Electron reports `scaleFactor 1.875`
(DIP bounds 1024x576) while Win32 virtualization divides by 1.5 (1280x720). So
the two spaces already diverge on a *single* display -- the issue's assumption
that one monitor works by accident does not hold here. What kept it working was
the overlap heuristic, and that is the real defect: a DIP rect and a physical
rect anchored on the primary always overlap, so the fallback always answered,
correctly, right up to the arrangement where a non-primary display's two origins
drift apart by more than a screen width. Then it answered "the primary", and
recorded the wrong screen in silence.

The fix has to land as a pair, so both sides move to physical at once:

- `dpi_awareness.h` is now the one place that says every native helper runs
  per-monitor-v2 aware. Both binaries include it; wgc-capture refuses to start
  if it cannot (after the change below, an unaware process is guaranteed to
  mismatch, so continuing would ship a known-broken state), cursor-sampler warns
  and carries on because a misplaced overlay still leaves a usable recording.
- `helperCoordinates.ts` is the TypeScript half: one `toHelperRect`, used by the
  capture config *and* by the cursor telemetry, which previously converted on its
  own while the recorder did not. The platform guard is load-bearing --
  `dipToScreenRect` is `@platform win32` and simply absent elsewhere. macOS and
  Linux are unaffected: the SCK helper reports points, and the PipeWire helper
  normalizes against its own stream dimensions.
- `findMonitorForCapture` drops the overlap heuristic and the fall-to-primary.
  It matches within 8 px or refuses, printing both rects. The tolerance is not
  slack: at 175% (scaleFactor 2.1875) Electron's DIP bounds 878x494 round-trip
  back as 1921x1081, because Chromium scales with enclosing rects in both
  directions. An exact compare would reject the correct monitor.
- The two non-Electron producers of the same wire format stop sending a
  hardcoded 1920x1080 fiction and omit the bounds instead, which lands on the
  primary deterministically rather than by accident.

Verified on real hardware at 150% and 175%, single 1920x1080 display:

- `GetProcessDpiAwareness` on the live process: 0 (UNAWARE) before, 2
  (PER_MONITOR_AWARE) after.
- Recorded resolution is 1920x1080 with both the old and the new helper, so
  `GraphicsCaptureItem::Size()` is physical regardless of awareness and this
  changes no recording's dimensions.
- A helper rebuilt without the awareness call reproduces the mismatch against
  the app's real bounds: `1024x576` vs `enumerated 1280x720`.
- Cursor telemetry: 508/508 samples inside [0,1], cx 0.75000 against 0.750000
  computed for the parked pointer.
- In the exported MP4 the drawn cursor lands 1.5 px from the computed point; the
  system pointer's tip is on the target pixel in the raw video. Without the
  conversion it would be 288 px off.
- Forcing the process unaware with an app-compat shim exercises the refusal path.

Fixes #346
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 37c7f628-f4f4-4d82-9f1f-e14442d195a5

📥 Commits

Reviewing files that changed from the base of the PR and between 2881e86 and cd08855.

📒 Files selected for processing (2)
  • electron/native-bridge/cursor/recording/windowsNativeRecordingSession.test.ts
  • electron/native/wgc-capture/src/cursor-sampler.cpp

📝 Walkthrough

Walkthrough

The change aligns Electron and native Windows capture coordinates in physical pixels. It adds shared conversion and DPI-awareness helpers, updates monitor matching and diagnostics, adds conversion tests, and removes fixed display bounds from diagnostic scripts.

Changes

Windows DPI-aware capture coordinates

Layer / File(s) Summary
Coordinate conversion and capture wiring
electron/native-bridge/helperCoordinates.ts, electron/native-bridge/helperCoordinates.test.ts, electron/ipc/handlers.ts, electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts, electron/native-bridge/cursor/recording/windowsNativeRecordingSession.test.ts
toHelperRect converts Windows DIP rectangles to physical pixels and preserves non-Windows rectangles. Capture configuration, source metadata, cursor recording, diagnostics, and tests use the shared conversion.
Native DPI awareness and monitor matching
electron/native/wgc-capture/src/dpi_awareness.h, electron/native/wgc-capture/src/main.cpp, electron/native/wgc-capture/src/cursor-sampler.cpp, electron/native/wgc-capture/src/monitor_utils.cpp, electron/native/wgc-capture/CMakeLists.txt
Native targets enable per-monitor-V2 DPI awareness. Monitor matching accepts an 8-pixel tolerance, uses int64_t arithmetic, and returns nullptr when no monitor matches.
Capture configuration defaults
scripts/diagnostic-tool/diagnostic.mjs, scripts/test-windows-wgc-helper.mjs
Diagnostic and helper test configurations disable fixed display-bound matching.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ElectronIPCHandlers
  participant toHelperRect
  participant wgc-capture
  participant findMonitorForCapture
  ElectronIPCHandlers->>toHelperRect: Convert DIP display bounds
  toHelperRect-->>ElectronIPCHandlers: Return physical helper bounds
  ElectronIPCHandlers->>wgc-capture: Send capture configuration
  wgc-capture->>findMonitorForCapture: Match physical bounds
  findMonitorForCapture-->>wgc-capture: Return monitor or nullptr
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: Windows DPI awareness and reliable monitor selection.
Description check ✅ Passed The description includes all required sections, issue linkage, scope, screenshots, and detailed testing evidence.
Linked Issues check ✅ Passed The changes satisfy #346 by aligning DPI awareness and physical bounds, removing unsafe fallbacks, and adding mismatch refusal.
Out of Scope Changes check ✅ Passed The code, tests, native changes, and diagnostic updates directly support the Windows DPI and monitor-selection objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/github-issue-346-6f7a9f

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
electron/ipc/handlers.ts (1)

2308-2369: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add boundary tests for both helper-coordinate call sites.

helperCoordinates.test.ts tests the utility only. It does not prove that the capture config and cursor telemetry preserve the physical-coordinate contract.

  • electron/ipc/handlers.ts#L2308-L2369: Add an IPC-level test that asserts the spawned Windows helper config uses converted bounds in both top-level fields and source.bounds.
  • electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts#L231-L238: Add tests that sampler-provided bounds remain unchanged and Electron-provided bounds are converted.

As per coding guidelines, “Add a test for every new behavior in the same package as the code under test.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/handlers.ts` around lines 2308 - 2369, Add same-package boundary
tests for both affected call sites: in electron/ipc/handlers.ts (2308-2369), add
an IPC-level test asserting the spawned Windows helper config uses converted
physical bounds in both the top-level display fields and source.bounds; in
electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
(231-238), add tests confirming sampler-provided bounds remain unchanged while
Electron-provided bounds are converted.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/native/wgc-capture/src/cursor-sampler.cpp`:
- Around line 421-429: Update the startup flow around
enablePerMonitorV2DpiAwareness() to fail with a nonzero status before emitting
ready when DPI awareness cannot be enabled; do not continue sampling with
virtualized cursor coordinates or merely log a warning. Ensure the parent treats
this failure as disabling or dropping the cursor overlay.

---

Outside diff comments:
In `@electron/ipc/handlers.ts`:
- Around line 2308-2369: Add same-package boundary tests for both affected call
sites: in electron/ipc/handlers.ts (2308-2369), add an IPC-level test asserting
the spawned Windows helper config uses converted physical bounds in both the
top-level display fields and source.bounds; in
electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
(231-238), add tests confirming sampler-provided bounds remain unchanged while
Electron-provided bounds are converted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d857535b-0276-4255-b7b8-8891f387f831

📥 Commits

Reviewing files that changed from the base of the PR and between 056cfc3 and 2881e86.

📒 Files selected for processing (11)
  • electron/ipc/handlers.ts
  • electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts
  • electron/native-bridge/helperCoordinates.test.ts
  • electron/native-bridge/helperCoordinates.ts
  • electron/native/wgc-capture/CMakeLists.txt
  • electron/native/wgc-capture/src/cursor-sampler.cpp
  • electron/native/wgc-capture/src/dpi_awareness.h
  • electron/native/wgc-capture/src/main.cpp
  • electron/native/wgc-capture/src/monitor_utils.cpp
  • scripts/diagnostic-tool/diagnostic.mjs
  • scripts/test-windows-wgc-helper.mjs

Comment thread electron/native/wgc-capture/src/cursor-sampler.cpp
Review catch on #351. Warning and carrying on was wrong: for a display capture
the caller normalizes the sampler's coordinates against a *physical* rect
(`toHelperRect`), so an unaware sampler puts every point at 1/scale of its real
offset -- exactly #272, silently reintroduced. The comment claimed "the caller
can drop the overlay", but nothing told the caller anything.

Exiting non-zero before `ready` does tell it: `start()` rejects on the
exit-before-ready path, handlers.ts catches it and clears the session, and the
recording proceeds with no cursor data. No overlay beats an overlay in the
wrong place.

Verified with an app-compat shim forcing the process unaware: exit=1, no `ready`
emitted, `ERROR: Could not enable per-monitor-v2 DPI awareness` on stderr.

Also adds the call-site test the review asked for. The existing test covers
`toHelperRect` itself, which cannot prove this file still calls it -- and the
recorder and the cursor telemetry each deciding their own space is what #346
was. It pins both branches: Electron bounds get converted, sampler bounds
(GetWindowRect, already physical) pass through. Confirmed to go red when the
conversion is removed.

Not added: an IPC-level test for the capture config. No test in the repo imports
electron/ipc/handlers.ts -- it pulls 36 modules and boots Electron transitively
through RECORDINGS_DIR -- so making it importable is a larger change than this
fix.
@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

Both points actioned — the first one caught a real defect in my reasoning, thanks.

Fail before ready in cursor-sampler — fixed, you were right.

I traced it rather than take it on faith, and the mechanism is exactly as described: for a display capture the caller normalizes the sampler's coordinates against a physical rect (toHelperRect), so an unaware sampler puts every point at 1/scale of its real offset — #272, silently reintroduced. (Window captures were never at risk: the sampler supplies its own GetWindowRect bounds, so numerator and denominator are virtualized together and the ratio survives.)

My comment claimed "the caller can drop the overlay", which was wrong — nothing told the caller anything. Exiting non-zero before ready does: start() rejects on the exit-before-ready path (windowsNativeRecordingSession.ts:109-119), handlers.ts:1060-1064 catches it and clears the session, and the recording proceeds with no cursor data. No overlay beats an overlay in the wrong place.

Verified with an app-compat shim forcing the process unaware:

exit=1  ready emis=false  stderr=ERROR: Could not enable per-monitor-v2 DPI awareness

Boundary tests — one added, one declined with a reason.

Added windowsNativeRecordingSession.test.ts. Your framing is the right one: the existing test covers toHelperRect itself and cannot prove this file still calls it, and "the recorder and the cursor telemetry each deciding their own space" is precisely what #346 was. It pins both branches — Electron bounds get converted, sampler bounds pass through untouched — and I confirmed it goes red when the conversion is removed, so it is not a test that only proves itself green.

Not added: the IPC-level test for the capture config. No test in the repo imports electron/ipc/handlers.ts — it pulls 36 modules and boots Electron transitively through RECORDINGS_DIR (electron/main.ts:67 calls app.getPath("userData") at module scope). Making it importable is a bigger change than this fix and belongs in its own PR. Worth noting that this is the one seam of the fix that has no automated guard, so I leaned on hardware instead: measured end to end at 150% and 175% through the app's HUD, 508/508 cursor samples in range and zero monitor mismatches over the session.

Full suite after the change: 145 files, 1704 passed.

@EtienneLescot
EtienneLescot merged commit 71cc88d into main Aug 12, 2026
17 checks passed
@EtienneLescot
EtienneLescot deleted the claude/github-issue-346-6f7a9f branch August 12, 2026 13:22
EtienneLescot added a commit that referenced this pull request Aug 12, 2026
Review catch on #351. Warning and carrying on was wrong: for a display capture
the caller normalizes the sampler's coordinates against a *physical* rect
(`toHelperRect`), so an unaware sampler puts every point at 1/scale of its real
offset -- exactly #272, silently reintroduced. The comment claimed "the caller
can drop the overlay", but nothing told the caller anything.

Exiting non-zero before `ready` does tell it: `start()` rejects on the
exit-before-ready path, handlers.ts catches it and clears the session, and the
recording proceeds with no cursor data. No overlay beats an overlay in the
wrong place.

Verified with an app-compat shim forcing the process unaware: exit=1, no `ready`
emitted, `ERROR: Could not enable per-monitor-v2 DPI awareness` on stderr.

Also adds the call-site test the review asked for. The existing test covers
`toHelperRect` itself, which cannot prove this file still calls it -- and the
recorder and the cursor telemetry each deciding their own space is what #346
was. It pins both branches: Electron bounds get converted, sampler bounds
(GetWindowRect, already physical) pass through. Confirmed to go red when the
conversion is removed.

Not added: an IPC-level test for the capture config. No test in the repo imports
electron/ipc/handlers.ts -- it pulls 36 modules and boots Electron transitively
through RECORDINGS_DIR -- so making it importable is a larger change than this
fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wgc-capture.exe is DPI-unaware, and its monitor lookup silently depends on it

1 participant