Skip to content

Commit dbdf00a

Browse files
author
albi
committed
fix(capture): align Windows cursor coordinates with DPI
1 parent e6d370a commit dbdf00a

11 files changed

Lines changed: 354 additions & 25 deletions

File tree

packages/capture/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ windows = { version = "0.62", features = [
3636
"Win32_System_Threading",
3737
"Win32_UI_HiDpi",
3838
"Win32_UI_Input_KeyboardAndMouse",
39+
"Win32_UI_Shell",
40+
"Win32_UI_Shell_Common",
3941
"Win32_UI_WindowsAndMessaging",
4042
] }
4143

packages/capture/src/bin/capture-engine.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,50 @@ fn main() {
2222
}
2323

2424
fn run() -> Result<(), capture::CaptureError> {
25+
#[cfg(windows)]
26+
configure_windows_dpi_awareness()?;
2527
capture::parent_watch::install_parent_death_guard()?;
2628
#[cfg(target_os = "macos")]
2729
return run_with_main_thread_cursor_sampling();
2830
#[cfg(not(target_os = "macos"))]
2931
run_blocking_protocol()
3032
}
3133

34+
#[cfg(windows)]
35+
fn configure_windows_dpi_awareness() -> Result<(), capture::CaptureError> {
36+
use windows::Win32::{
37+
System::Threading::GetCurrentProcess,
38+
UI::HiDpi::{
39+
AreDpiAwarenessContextsEqual, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
40+
GetDpiAwarenessContextForProcess, SetProcessDpiAwarenessContext,
41+
},
42+
};
43+
44+
// This process owns no UI. Set its coordinate system before discovery creates
45+
// WGC items or worker threads so frames, source bounds and cursor positions
46+
// all use physical per-monitor pixels.
47+
// SAFETY: PMv2 is supported by every Windows version that supports WGC.
48+
let error = match unsafe {
49+
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
50+
} {
51+
Ok(()) => return Ok(()),
52+
Err(error) => error,
53+
};
54+
// SAFETY: the current-process pseudo-handle is always valid and needs no close.
55+
let process = unsafe { GetCurrentProcess() };
56+
// SAFETY: querying the current process needs no caller-owned storage.
57+
let current = unsafe { GetDpiAwarenessContextForProcess(process) };
58+
// SAFETY: both handles are valid queried/predefined DPI contexts.
59+
if unsafe { AreDpiAwarenessContextsEqual(current, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }
60+
.as_bool()
61+
{
62+
return Ok(());
63+
}
64+
Err(capture::CaptureError::Backend(format!(
65+
"Windows capture engine could not enable per-monitor DPI awareness: {error}",
66+
)))
67+
}
68+
3269
#[cfg(not(target_os = "macos"))]
3370
fn run_blocking_protocol() -> Result<(), capture::CaptureError> {
3471
let mut reader = BufReader::new(io::stdin().lock());

packages/capture/src/bin/capture_engine_tests/mod.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ use capture::{
99
use super::prepare_snapshot;
1010
use super::{Engine, handle};
1111

12+
#[cfg(windows)]
13+
use std::process::{Command as ProcessCommand, Stdio};
14+
15+
#[cfg(windows)]
16+
use windows::Win32::{
17+
System::Threading::GetCurrentProcess,
18+
UI::HiDpi::{
19+
AreDpiAwarenessContextsEqual, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
20+
GetDpiAwarenessContextForProcess, GetThreadDpiAwarenessContext,
21+
},
22+
};
23+
1224
#[test]
1325
fn idle_status_reports_screen_available() {
1426
let mut engine = Engine::default();
@@ -58,3 +70,100 @@ fn prepare_reuses_the_successful_portal_discovery_snapshot() -> Result<(), captu
5870
assert_eq!(prepared, discovered);
5971
Ok(())
6072
}
73+
74+
#[cfg(windows)]
75+
#[test]
76+
fn capture_engine_sets_process_dpi_awareness_in_an_isolated_process() {
77+
const CHILD_MARKER: &str = "BEAM_CAPTURE_ENGINE_DPI_TEST_CHILD";
78+
79+
if std::env::var_os(CHILD_MARKER).is_some() {
80+
let run_result = super::run();
81+
assert!(
82+
run_result.is_ok(),
83+
"capture-engine startup failed: {:?}",
84+
run_result.err()
85+
);
86+
87+
// SAFETY: the current process pseudo-handle is valid and needs no close.
88+
let process = unsafe { GetCurrentProcess() };
89+
// SAFETY: querying the current process DPI context requires no extra storage.
90+
let process_context = unsafe { GetDpiAwarenessContextForProcess(process) };
91+
// SAFETY: both handles are valid queried/predefined context values.
92+
assert!(
93+
unsafe {
94+
AreDpiAwarenessContextsEqual(
95+
process_context,
96+
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
97+
)
98+
}
99+
.as_bool(),
100+
"capture-engine process must use per-monitor-v2 coordinates"
101+
);
102+
103+
let new_thread_is_per_monitor_v2 = std::thread::spawn(|| {
104+
// SAFETY: reading this thread's current DPI context requires no extra storage.
105+
let context = unsafe { GetThreadDpiAwarenessContext() };
106+
// SAFETY: both handles are valid DPI-awareness context values.
107+
unsafe {
108+
AreDpiAwarenessContextsEqual(context, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
109+
}
110+
.as_bool()
111+
})
112+
.join();
113+
assert!(
114+
matches!(new_thread_is_per_monitor_v2, Ok(true)),
115+
"new capture threads must inherit per-monitor-v2 awareness"
116+
);
117+
return;
118+
}
119+
120+
let executable = std::env::current_exe();
121+
assert!(
122+
executable.is_ok(),
123+
"failed to resolve the capture-engine test executable: {:?}",
124+
executable.as_ref().err()
125+
);
126+
let Ok(executable) = executable else {
127+
return;
128+
};
129+
130+
let child_result = ProcessCommand::new(executable)
131+
.args([
132+
"--exact",
133+
"tests::capture_engine_sets_process_dpi_awareness_in_an_isolated_process",
134+
"--nocapture",
135+
])
136+
.env(CHILD_MARKER, "1")
137+
.env_remove(capture::parent_watch::PARENT_PID_ENV)
138+
.stdin(Stdio::piped())
139+
.stdout(Stdio::piped())
140+
.stderr(Stdio::piped())
141+
.spawn();
142+
assert!(
143+
child_result.is_ok(),
144+
"failed to launch the isolated capture-engine test process: {:?}",
145+
child_result.as_ref().err()
146+
);
147+
let Ok(mut child) = child_result else {
148+
return;
149+
};
150+
151+
// EOF lets run_blocking_protocol finish; the process-global DPI change stays
152+
// in this subprocess and cannot affect the parallel test harness.
153+
drop(child.stdin.take());
154+
let output = child.wait_with_output();
155+
assert!(
156+
output.is_ok(),
157+
"failed to collect the isolated capture-engine test output: {:?}",
158+
output.as_ref().err()
159+
);
160+
let Ok(output) = output else {
161+
return;
162+
};
163+
assert!(
164+
output.status.success(),
165+
"isolated capture-engine DPI test failed\nstdout:\n{}\nstderr:\n{}",
166+
String::from_utf8_lossy(&output.stdout),
167+
String::from_utf8_lossy(&output.stderr)
168+
);
169+
}

packages/capture/src/cursor/backend.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ pub struct CursorShapeCatalogEntry {
8282
rename_all_fields = "camelCase"
8383
)]
8484
pub enum CursorEvent {
85+
Metadata {
86+
session_ns: u64,
87+
display_scale_factor: f64,
88+
},
8589
Move {
8690
session_ns: u64,
8791
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -176,7 +180,8 @@ pub fn telemetry_from_events(events: &[CursorEvent]) -> CursorTelemetrySidecar {
176180
interaction_type: Some(interaction_type),
177181
});
178182
}
179-
CursorEvent::Shape { .. }
183+
CursorEvent::Metadata { .. }
184+
| CursorEvent::Shape { .. }
180185
| CursorEvent::Visibility { .. }
181186
| CursorEvent::CropChanged { .. } => {}
182187
}

packages/capture/src/cursor/recording_support_tests.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,79 @@ fn successful_worker_publishes_cursor_events_telemetry_shapes_and_input() {
257257
assert!(!temporary.path().join("input.partial.jsonl").exists());
258258
}
259259

260+
#[test]
261+
fn display_scale_metadata_survives_finalization_without_telemetry_or_shapes() {
262+
let temporary = tempfile::tempdir().expect("temporary directory");
263+
let paths = paths(temporary.path());
264+
let events = [
265+
CursorEvent::Metadata {
266+
session_ns: 0,
267+
display_scale_factor: 1.5,
268+
},
269+
CursorEvent::Move {
270+
session_ns: 33_000_000,
271+
cursor_id: None,
272+
pixel_x: 300,
273+
pixel_y: 200,
274+
normalized_x: 0.3,
275+
normalized_y: 0.2,
276+
visible: true,
277+
},
278+
];
279+
let cursor_lines = events
280+
.iter()
281+
.map(|event| serde_json::to_string(event).expect("cursor event JSON"))
282+
.collect::<Vec<_>>()
283+
.join("\n");
284+
std::fs::write(&paths.partial, format!("{cursor_lines}\n")).expect("cursor partial");
285+
std::fs::write(&paths.input_partial, "").expect("input partial");
286+
287+
finalize_after_worker(Ok(()), paths).expect("finalization");
288+
289+
let cursor_json = std::fs::read(temporary.path().join("cursor.json")).expect("cursor JSON");
290+
let raw_events: Vec<serde_json::Value> =
291+
serde_json::from_slice(&cursor_json).expect("raw cursor events");
292+
assert_eq!(raw_events[0]["event"], "metadata");
293+
assert_eq!(raw_events[0]["displayScaleFactor"], 1.5);
294+
let events: Vec<CursorEvent> = serde_json::from_slice(&cursor_json).expect("cursor events");
295+
assert!(matches!(
296+
events.first(),
297+
Some(CursorEvent::Metadata {
298+
session_ns: 0,
299+
display_scale_factor,
300+
}) if *display_scale_factor == 1.5
301+
));
302+
assert!(matches!(
303+
events.get(1),
304+
Some(CursorEvent::Move {
305+
session_ns: 33_000_000,
306+
pixel_x: 300,
307+
pixel_y: 200,
308+
normalized_x,
309+
normalized_y,
310+
..
311+
}) if *normalized_x == 0.3 && *normalized_y == 0.2
312+
));
313+
314+
let telemetry: CursorTelemetrySidecar = serde_json::from_slice(
315+
&std::fs::read(temporary.path().join("telemetry.json")).expect("telemetry JSON"),
316+
)
317+
.expect("telemetry sidecar");
318+
assert_eq!(telemetry.samples.len(), 1);
319+
assert_eq!(telemetry.samples[0].time_ms, 33);
320+
assert_eq!(
321+
telemetry.samples[0].interaction_type,
322+
Some(crate::cursor::CursorInteractionType::Move)
323+
);
324+
325+
let shapes: std::collections::BTreeMap<String, CursorShapeCatalogEntry> =
326+
serde_json::from_slice(
327+
&std::fs::read(temporary.path().join("shapes.json")).expect("shape catalog JSON"),
328+
)
329+
.expect("shape catalog");
330+
assert!(shapes.is_empty());
331+
}
332+
260333
#[test]
261334
fn invalid_cursor_partial_is_preserved_for_recovery() {
262335
let temporary = tempfile::tempdir().expect("temporary directory");

packages/capture/src/cursor/win/capture.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ use std::{ffi::c_void, mem::size_of, ptr, sync::OnceLock};
33
use windows_capture::{monitor::Monitor, window::Window};
44

55
use windows::Win32::{
6-
Foundation::POINT,
6+
Foundation::{HWND, POINT},
77
Graphics::Gdi::{
88
BI_RGB, BITMAP, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleDC, CreateDIBSection,
99
DIB_RGB_COLORS, DeleteDC, DeleteObject, GetMonitorInfoW, GetObjectW, HGDIOBJ, HMONITOR,
1010
MONITORINFO, SelectObject,
1111
},
1212
UI::{
1313
Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON, VK_MBUTTON, VK_RBUTTON},
14+
Shell::GetScaleFactorForMonitor,
1415
WindowsAndMessaging::{
1516
CURSOR_SHOWING, CURSORINFO, DI_NORMAL, DrawIconEx, GetCursorInfo, GetIconInfo,
1617
GetPhysicalCursorPos, HICON, ICONINFO, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, IDC_HAND,
@@ -44,6 +45,12 @@ pub struct WindowsCursorSample {
4445
pub shape: Option<WindowsCursorShape>,
4546
}
4647

48+
#[derive(Debug, Clone, Copy, PartialEq)]
49+
pub struct WindowsCursorSourceContext {
50+
pub region: CaptureRegion,
51+
pub display_scale_factor: Option<f64>,
52+
}
53+
4754
pub fn sample_cursor(
4855
region: CaptureRegion,
4956
include_shape: bool,
@@ -78,7 +85,7 @@ pub fn sample_cursor(
7885
})
7986
}
8087

81-
pub fn source_region(source_id: &SourceId) -> Result<CaptureRegion, CaptureError> {
88+
pub fn source_context(source_id: &SourceId) -> Result<WindowsCursorSourceContext, CaptureError> {
8289
let _physical_coordinates = super::dpi::PhysicalCoordinates::enter()?;
8390
if let Some(device_name) = source_id.as_str().strip_prefix("wgc:monitor:") {
8491
let monitor = Monitor::enumerate()
@@ -94,7 +101,11 @@ pub fn source_region(source_id: &SourceId) -> Result<CaptureRegion, CaptureError
94101
if !unsafe { GetMonitorInfoW(HMONITOR(monitor.as_raw_hmonitor()), &mut info) }.as_bool() {
95102
return Err(CaptureError::Backend("GetMonitorInfoW failed".into()));
96103
}
97-
return region_from_rect(info.rcMonitor);
104+
let monitor = HMONITOR(monitor.as_raw_hmonitor());
105+
return Ok(WindowsCursorSourceContext {
106+
region: region_from_rect(info.rcMonitor)?,
107+
display_scale_factor: display_scale_factor(monitor),
108+
});
98109
}
99110
if source_id.as_str().starts_with("wgc:window:") {
100111
let window = Window::enumerate()
@@ -104,13 +115,34 @@ pub fn source_region(source_id: &SourceId) -> Result<CaptureRegion, CaptureError
104115
source_id.as_str() == format!("wgc:window:{:x}", window.as_raw_hwnd() as usize)
105116
})
106117
.ok_or_else(|| CaptureError::SourceNotFound(source_id.to_string()))?;
107-
return region_from_rect(window.rect().map_err(backend_error)?);
118+
let monitor = unsafe {
119+
windows::Win32::Graphics::Gdi::MonitorFromWindow(
120+
HWND(window.as_raw_hwnd()),
121+
windows::Win32::Graphics::Gdi::MONITOR_DEFAULTTONEAREST,
122+
)
123+
};
124+
return Ok(WindowsCursorSourceContext {
125+
region: region_from_rect(window.rect().map_err(backend_error)?)?,
126+
display_scale_factor: (!monitor.is_invalid())
127+
.then(|| display_scale_factor(monitor))
128+
.flatten(),
129+
});
108130
}
109131
Err(CaptureError::InvalidConfiguration(format!(
110132
"{source_id} is not a Windows visual source"
111133
)))
112134
}
113135

136+
pub fn source_region(source_id: &SourceId) -> Result<CaptureRegion, CaptureError> {
137+
Ok(source_context(source_id)?.region)
138+
}
139+
140+
fn display_scale_factor(monitor: HMONITOR) -> Option<f64> {
141+
// SAFETY: the handle comes from monitor enumeration or MonitorFromWindow.
142+
let percentage = unsafe { GetScaleFactorForMonitor(monitor) }.ok()?.0;
143+
(percentage > 0).then(|| f64::from(percentage) / 100.0)
144+
}
145+
114146
fn region_from_rect(rect: windows::Win32::Foundation::RECT) -> Result<CaptureRegion, CaptureError> {
115147
Ok(CaptureRegion {
116148
x: rect.left,

0 commit comments

Comments
 (0)