Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { createCursorRecordingSession } from "../native-bridge/cursor/recording/
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession";
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { toHelperRect } from "../native-bridge/helperCoordinates";
import {
terminateNativeWindowsCapture,
waitForNativeWindowsCaptureStop,
Expand Down Expand Up @@ -2304,6 +2305,11 @@ export function registerIpcHandlers(
null)
: getSelectedDisplay();
const bounds = sourceDisplay?.bounds ?? getSelectedSourceBounds();
// `bounds` is DIPs; the helper matches it against physical monitor rects
// (getopenscreen/openscreen#346). Converted here, at the wire, and not in
// `getSelectedSourceBounds` — the cursor session shares that getter and
// converts on its own side.
const helperBounds = toHelperRect(bounds);
const displayId =
typeof request.source.displayId === "number" && Number.isFinite(request.source.displayId)
? request.source.displayId
Expand Down Expand Up @@ -2332,10 +2338,10 @@ export function registerIpcHandlers(
fps: request.video.fps,
videoWidth: request.video.width,
videoHeight: request.video.height,
displayX: bounds.x,
displayY: bounds.y,
displayW: bounds.width,
displayH: bounds.height,
displayX: helperBounds.x,
displayY: helperBounds.y,
displayW: helperBounds.width,
displayH: helperBounds.height,
hasDisplayBounds: true,
captureSystemAudio: request.audio.system.enabled,
captureMic: request.audio.microphone.enabled,
Expand All @@ -2360,7 +2366,7 @@ export function registerIpcHandlers(
sourceId: request.source.sourceId,
displayId: Number.isFinite(displayId) ? displayId : null,
windowHandle: request.source.windowHandle ?? null,
bounds,
bounds: helperBounds,
},
video: request.video,
audio: request.audio,
Expand All @@ -2377,7 +2383,10 @@ export function registerIpcHandlers(
webcam: request.webcam,
encoder: { preferSoftwareEncoder },
cursor: { mode: cursorCaptureMode },
bounds,
// Both spaces, deliberately: the helper's own errors quote the physical
// rect, and a report that only carried the DIP one would be read against
// numbers it never saw (getopenscreen/openscreen#346).
bounds: { dip: bounds, helper: helperBounds },
sourceId: selectedSource?.id ?? null,
usedDisplayMatch: Boolean(sourceDisplay),
outputPath,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const spawn = vi.fn();
const dipToScreenRect = vi.fn();

vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawn(...args) }));
vi.mock("electron", () => ({
app: { getAppPath: () => "C:\\app", isPackaged: false },
screen: {
dipToScreenRect: (...args: unknown[]) => dipToScreenRect(...args),
screenToDipPoint: (p: unknown) => p,
getDisplayNearestPoint: () => ({ scaleFactor: 1, bounds: DIP_BOUNDS }),
getPrimaryDisplay: () => ({ bounds: DIP_BOUNDS }),
},
}));

import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession";

/** A 1920x1080 panel at 187.5%, the arrangement measured while fixing #346. */
const DIP_BOUNDS = { x: 0, y: 0, width: 1024, height: 576 };
const PHYSICAL_BOUNDS = { x: 0, y: 0, width: 1920, height: 1080 };

function fakeHelper() {
// Push-only streams: the test feeds them, so `_read` has nothing to do.
const noPull = () => undefined;
const stdout = new Readable({ read: noPull });
const stderr = new Readable({ read: noPull });
const child = Object.assign(new EventEmitter(), { stdout, stderr, pid: 4242, kill: vi.fn() });
return { child, stdout };
}

/**
* The unit test on `toHelperRect` proves the conversion works; it cannot prove
* this file still calls it. That is the half that broke in #346 — the recorder
* and the cursor telemetry each decided for themselves which space they were in
* — so pin the two branches that decision has.
*/
describe("WindowsNativeRecordingSession bounds handling", () => {
const REAL_PLATFORM = process.platform;
const setPlatform = (value: NodeJS.Platform) =>
Object.defineProperty(process, "platform", { value, configurable: true });

beforeEach(() => {
setPlatform("win32");
// First candidate in the lookup, so nothing has to exist on disk but this.
process.env.OPENSCREEN_CURSOR_SAMPLER_EXE = __filename;
dipToScreenRect.mockReturnValue(PHYSICAL_BOUNDS);
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "info").mockImplementation(() => undefined);
});

afterEach(() => {
setPlatform(REAL_PLATFORM);
process.env.OPENSCREEN_CURSOR_SAMPLER_EXE = undefined;
spawn.mockReset();
dipToScreenRect.mockReset();
vi.restoreAllMocks();
});

const run = async (sample: Record<string, unknown>) => {
const { child, stdout } = fakeHelper();
spawn.mockReturnValue(child);
const session = new WindowsNativeRecordingSession({
getDisplayBounds: () => DIP_BOUNDS,
maxSamples: 10,
sampleIntervalMs: 33,
startTimeMs: 1000,
});
const started = session.start();
stdout.push(`${JSON.stringify({ type: "ready", timestampMs: 1000 })}\n`);
await started;
stdout.push(`${JSON.stringify(sample)}\n`);
await new Promise((resolve) => setImmediate(resolve));
return (await session.stop()).samples;
};

it("normalizes a display capture against the physical rect, not the DIP one", async () => {
const samples = await run({
type: "sample",
timestampMs: 1100,
x: 1440,
y: 538,
visible: true,
handle: null,
asset: null,
});

expect(dipToScreenRect).toHaveBeenCalledWith(null, DIP_BOUNDS);
// 1440/1920, not 1440/1024 — the latter is 1.40625 and lands off-frame.
expect(samples[0].cx).toBeCloseTo(0.75, 5);
expect(samples[0].cy).toBeCloseTo(538 / 1080, 5);
});

it("uses the sampler's own bounds as-is for a window capture", async () => {
const samples = await run({
type: "sample",
timestampMs: 1100,
x: 900,
y: 500,
visible: true,
handle: "0x10003",
bounds: { x: 100, y: 100, width: 800, height: 600 },
asset: null,
});

// GetWindowRect is already physical, so converting it again would be wrong.
expect(dipToScreenRect).not.toHaveBeenCalled();
expect(samples[0].cx).toBeCloseTo((900 - 100) / 800, 5);
expect(samples[0].cy).toBeCloseTo((500 - 100) / 600, 5);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CursorRecordingSample,
NativeCursorAsset,
} from "../../../../src/native/contracts";
import { toHelperRect } from "../../helperCoordinates";
import type { CursorRecordingSession } from "./session";
import type {
WindowsCursorEvent,
Expand Down Expand Up @@ -232,12 +233,9 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession {
// The cursor-sampler reports raw x/y in physical screen pixels (Win32
// GetCursorInfo). `payload.bounds` from the sampler's GetWindowRect is also
// physical, so use it as-is. Bounds from Electron's `screen` API (or the
// fallback for display captures) are in DIPs — convert to physical screen
// coordinates via `dipToScreenRect`, which correctly handles the virtual-screen
// origin across multi-monitor and mixed-DPI setups (a naive
// `bounds.x * scaleFactor` would misplace the origin on non-primary
// displays).
const physicalBounds = payload.bounds != null ? bounds : screen.dipToScreenRect(null, bounds);
// fallback for display captures) are in DIPs, so they go through the shared
// conversion the capture helper's bounds also use.
const physicalBounds = payload.bounds != null ? bounds : toHelperRect(bounds);
const physicalX = physicalBounds.x;
const physicalY = physicalBounds.y;
const width = Math.max(1, physicalBounds.width);
Expand Down
50 changes: 50 additions & 0 deletions electron/native-bridge/helperCoordinates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { toHelperRect } from "./helperCoordinates";

const dipToScreenRect = vi.fn();

vi.mock("electron", () => ({
screen: {
get dipToScreenRect() {
return dipToScreenRect;
},
},
}));

/**
* The bug this pins is invisible at 100% scaling, which is what every dev machine
* and all of CI runs at — so the assertion cannot be "the numbers come out right",
* it has to be "the conversion is reached at all, and only where it exists".
* `dipToScreenRect` is `@platform win32` in Electron: on darwin it is not a
* function, so calling it unconditionally would throw rather than mis-convert.
*/
describe("toHelperRect", () => {
const REAL_PLATFORM = process.platform;
const setPlatform = (value: NodeJS.Platform) =>
Object.defineProperty(process, "platform", { value, configurable: true });

afterEach(() => {
setPlatform(REAL_PLATFORM);
dipToScreenRect.mockReset();
});

const DIP = { x: 1920, y: 0, width: 2560, height: 1440 };

it("converts DIP bounds to physical screen pixels on Windows", () => {
const physical = { x: 3840, y: 0, width: 5120, height: 2880 };
dipToScreenRect.mockReturnValue(physical);
setPlatform("win32");

expect(toHelperRect(DIP)).toEqual(physical);
// `null` as the window: scale relative to the display nearest the rect,
// which is the part that holds up on a mixed-DPI desktop.
expect(dipToScreenRect).toHaveBeenCalledWith(null, DIP);
});

it.each(["darwin", "linux"] as const)("passes the rect through on %s", (platform) => {
setPlatform(platform);

expect(toHelperRect(DIP)).toBe(DIP);
expect(dipToScreenRect).not.toHaveBeenCalled();
});
});
36 changes: 36 additions & 0 deletions electron/native-bridge/helperCoordinates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type Rectangle, screen } from "electron";

/**
* Converts a rect from Electron's coordinate space into the one OpenScreen's
* native capture and cursor helpers speak. This is the single place that knows
* the difference; every rect crossing into a helper goes through it.
*
* Electron's `screen` module reports display geometry in **DIPs**. The Windows
* helpers (`wgc-capture.exe`, `cursor-sampler.exe`) opt into per-monitor-v2 DPI
* awareness — see `electron/native/wgc-capture/src/dpi_awareness.h` — so Win32
* hands them unvirtualized **physical pixels**. The two spaces are equal at 100%
* scaling and only there, which is how both bugs of this class shipped: #272
* (cursor drawn short of its real position) and #346 (`findMonitorForCapture`
* comparing DIP bounds against virtualized monitor rects, and silently recording
* the primary display instead of the chosen one).
*
* macOS and Linux need no conversion. The ScreenCaptureKit helper reports its
* capture frame in points, the same space Electron's `screen` uses, and the
* PipeWire helper normalizes the cursor against its own stream dimensions and is
* never handed a rect at all. The platform check is not cosmetic: Electron marks
* `dipToScreenRect` `@platform win32`, so it is simply absent from `screen`
* elsewhere.
*
* @param dipBounds a rect in Electron DIPs, e.g. `Display.bounds`.
*/
export function toHelperRect(dipBounds: Rectangle): Rectangle {
if (process.platform !== "win32") {
return dipBounds;
}

// Not `x * scaleFactor`: that misplaces the origin of every non-primary
// display. `dipToScreenRect(null, rect)` scales relative to the display
// nearest the rect, which is what makes it correct on mixed-DPI desktops —
// the arrangement #346 is about.
return screen.dipToScreenRect(null, dipBounds);
}
2 changes: 2 additions & 0 deletions electron/native/wgc-capture/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(wgc-capture
src/audio_sample_utils.cpp
src/audio_sample_utils.h
src/dpi_awareness.h
src/dshow_webcam_capture.cpp
src/dshow_webcam_capture.h
src/main.cpp
Expand Down Expand Up @@ -74,6 +75,7 @@ target_link_libraries(wgc-capture PRIVATE

add_executable(cursor-sampler
src/cursor-sampler.cpp
src/dpi_awareness.h
)

target_compile_definitions(cursor-sampler PRIVATE
Expand Down
14 changes: 13 additions & 1 deletion electron/native/wgc-capture/src/cursor-sampler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#include <gdiplus.h>
#include <objbase.h>

#include "dpi_awareness.h"

#include <atomic>
#include <algorithm>
#include <chrono>
Expand Down Expand Up @@ -416,7 +418,17 @@ int main(int argc, char* argv[]) {
// capture and the consumer both work in physical pixels. On any scaled
// display the cursor then lands short of its real position, by more the
// further it is from the origin (getopenscreen/openscreen#272).
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
//
// Bail out rather than sample, and bail out before `ready`: for a display
// capture the caller normalizes these coordinates against a *physical* rect,
// so an unaware sampler puts every point at 1/scale of its real offset. The
// caller turns an exit-before-ready into "record without cursor data"
// (handlers.ts catches the failed start and drops the session), which is the
// outcome we want -- no overlay beats an overlay in the wrong place.
if (!enablePerMonitorV2DpiAwareness()) {
std::cerr << "ERROR: Could not enable per-monitor-v2 DPI awareness" << std::endl;
return 1;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (argc < 2) {
std::cerr << "Usage: cursor-sampler <intervalMs> [windowHandle]" << std::endl;
Expand Down
35 changes: 35 additions & 0 deletions electron/native/wgc-capture/src/dpi_awareness.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#pragma once

#include <Windows.h>

// Every OpenScreen native helper runs per-monitor-v2 DPI aware, and this is the
// one place that says so.
//
// A DPI-unaware process does not get real coordinates from Win32: every rect and
// point it reads back is *virtualized*, divided by the PRIMARY display's scale
// factor whatever monitor it actually describes. The helper and its TypeScript
// caller then live in two different coordinate spaces that happen to coincide at
// 100% scaling -- which is exactly why both bugs of this class shipped unnoticed
// (getopenscreen/openscreen#272, cursor offset; #346, wrong monitor recorded).
//
// The TypeScript side converts Electron's DIP rects before handing them over, in
// electron/native-bridge/helperCoordinates.ts. The contract both sides implement
// is: helpers speak physical screen pixels, always.
//
// Returns false when the process could not be put in that state. In practice
// that only happens when something outside our control -- an app-compat
// "override high DPI scaling behaviour" setting on the .exe -- already pinned a
// different context. Callers decide what that means for them; nothing here can
// safely *guess* which space the numbers are in.
inline bool enablePerMonitorV2DpiAwareness() {
if (SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) {
return true;
}

// ERROR_ACCESS_DENIED means an awareness context was already selected for
// this process. That is fine when it is the one we wanted, and fatal when it
// is not, so check rather than assuming either way.
return GetLastError() == ERROR_ACCESS_DENIED &&
AreDpiAwarenessContextsEqual(GetThreadDpiAwarenessContext(),
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
}
13 changes: 13 additions & 0 deletions electron/native/wgc-capture/src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "audio_sample_utils.h"
#include "dpi_awareness.h"
#include "mf_encoder.h"
#include "monitor_utils.h"
#include "wasapi_loopback_capture.h"
Expand Down Expand Up @@ -641,6 +642,18 @@ void readCaptureCommands(CaptureControl& control, const std::function<void(bool)
} // namespace

int main(int argc, char* argv[]) {
// Before anything reads a coordinate. `findMonitorForCapture` matches the
// config's display bounds against the rects `EnumDisplayMonitors` reports,
// and the caller sends those bounds in physical pixels; a DPI-unaware
// process would compare them against virtualized ones and silently record
// the wrong screen (getopenscreen/openscreen#346). Refusing to start is the
// honest outcome -- a recording of the wrong monitor is discovered far too
// late to be worth salvaging.
if (!enablePerMonitorV2DpiAwareness()) {
std::cerr << "ERROR: Could not enable per-monitor-v2 DPI awareness" << std::endl;
return 1;
}

if (argc < 2) {
std::cerr << "ERROR: Missing JSON config argument" << std::endl;
return 1;
Expand Down
Loading
Loading