Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,8 @@ Connect to `ws://localhost:9223` to receive frames and send input:

**Send mouse events:**

Coordinates are CSS pixels in the page's device-independent pixel (DIP) space. Map positions from the displayed frame using the `metadata.deviceWidth` and `metadata.deviceHeight` of the latest frame, not the status message's `viewportWidth`/`viewportHeight` (the configured viewport can differ from the actual content area).

```json
{
"type": "input_mouse",
Expand Down
32 changes: 20 additions & 12 deletions cli/src/native/e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5487,18 +5487,11 @@ async fn e2e_stream_frame_metadata_respects_custom_viewport() {
serde_json::from_str(message.to_text().expect("text message should be readable"))
.expect("stream payload should be valid JSON");
if parsed.get("type") == Some(&json!("frame")) {
let meta = &parsed["metadata"];
assert_eq!(
meta["deviceWidth"], 800,
"frame metadata deviceWidth should match custom viewport, got: {}",
meta
);
assert_eq!(
meta["deviceHeight"], 600,
"frame metadata deviceHeight should match custom viewport, got: {}",
meta
);

// Early frames may arrive before Chrome fully applies the viewport
// resize, with stale image and metadata dimensions. Skip those and
// only assert once the settled frame arrives, since the metadata
// now reflects the real CDP values rather than the configured
// viewport.
let data_str = parsed
.get("data")
.and_then(|v| v.as_str())
Expand All @@ -5513,6 +5506,21 @@ async fn e2e_stream_frame_metadata_respects_custom_viewport() {
continue;
}

let meta = &parsed["metadata"];
// Real CDP metadata arrives as floats (e.g. 800.0).
assert_eq!(
meta["deviceWidth"].as_f64(),
Some(800.0),
"frame metadata deviceWidth should match custom viewport, got: {}",
meta
);
assert_eq!(
meta["deviceHeight"].as_f64(),
Some(600.0),
"frame metadata deviceHeight should match custom viewport, got: {}",
meta
);

found_frame = true;
break;
}
Expand Down
9 changes: 7 additions & 2 deletions cli/src/native/stream/cdp_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,20 @@ pub(super) async fn cdp_event_loop(
if let Some(data) = evt.params.get("data").and_then(|v| v.as_str()) {
let meta = evt.params.get("metadata");
let seq = super::next_frame_seq();
// Forward the real CDP frame metadata. Clients map input
// coordinates into this DIP space, so substituting the
// configured viewport here would misplace clicks whenever
// the actual content area differs from it (window chrome,
// scrollbars, device emulation, manual resizes).
let msg = json!({
"type": "frame",
"seq": seq,
"data": data,
"metadata": {
"offsetTop": meta.and_then(|m| m.get("offsetTop")).and_then(|v| v.as_f64()).unwrap_or(0.0),
"pageScaleFactor": meta.and_then(|m| m.get("pageScaleFactor")).and_then(|v| v.as_f64()).unwrap_or(1.0),
"deviceWidth": vw,
"deviceHeight": vh,
"deviceWidth": meta.and_then(|m| m.get("deviceWidth")).and_then(|v| v.as_f64()).unwrap_or(vw as f64),
"deviceHeight": meta.and_then(|m| m.get("deviceHeight")).and_then(|v| v.as_f64()).unwrap_or(vh as f64),
"scrollOffsetX": meta.and_then(|m| m.get("scrollOffsetX")).and_then(|v| v.as_f64()).unwrap_or(0.0),
"scrollOffsetY": meta.and_then(|m| m.get("scrollOffsetY")).and_then(|v| v.as_f64()).unwrap_or(0.0),
"timestamp": frame_timestamp_ms(meta),
Expand Down
2 changes: 2 additions & 0 deletions docs/src/app/streaming/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ Send input events to control the browser remotely. Mouse, keyboard, and touch in

### Mouse events

Mouse coordinates are CSS pixels in the page's device-independent pixel (DIP) space. Map positions from the displayed frame using the `metadata.deviceWidth` and `metadata.deviceHeight` of the latest frame message, not the `viewportWidth`/`viewportHeight` from the status message. The status values describe the configured viewport and can differ from the actual content area (window chrome, scrollbars, emulation).

```json
// Click
{
Expand Down
16 changes: 13 additions & 3 deletions packages/dashboard/src/components/viewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
currentFrameAtom,
viewportWidthAtom,
viewportHeightAtom,
frameDeviceWidthAtom,
frameDeviceHeightAtom,
browserConnectedAtom,
screencastingAtom,
recordingAtom,
Expand Down Expand Up @@ -125,6 +127,8 @@ export function Viewport() {
const frame = useAtomValue(currentFrameAtom);
const viewportWidth = useAtomValue(viewportWidthAtom);
const viewportHeight = useAtomValue(viewportHeightAtom);
const frameDeviceWidth = useAtomValue(frameDeviceWidthAtom);
const frameDeviceHeight = useAtomValue(frameDeviceHeightAtom);
const browserConnected = useAtomValue(browserConnectedAtom);
const screencasting = useAtomValue(screencastingAtom);
const recording = useAtomValue(recordingAtom);
Expand Down Expand Up @@ -310,15 +314,21 @@ export function Viewport() {
(e: React.MouseEvent): { x: number; y: number } | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
// Map into the frame's true DIP space (from CDP metadata) when known.
// The configured viewport can differ from the actual content area
// (window chrome, scrollbars, emulation), and clicks would land off
// target if scaled by the wrong dimensions.
const targetWidth = frameDeviceWidth > 0 ? frameDeviceWidth : viewportWidth;
const targetHeight = frameDeviceHeight > 0 ? frameDeviceHeight : viewportHeight;
const rect = canvas.getBoundingClientRect();
const scaleX = viewportWidth / rect.width;
const scaleY = viewportHeight / rect.height;
const scaleX = targetWidth / rect.width;
const scaleY = targetHeight / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY),
};
},
[viewportWidth, viewportHeight],
[viewportWidth, viewportHeight, frameDeviceWidth, frameDeviceHeight],
);

const handleMouseEvent = useCallback(
Expand Down
17 changes: 15 additions & 2 deletions packages/dashboard/src/store/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const screencastingAtom = atom(false);
export const recordingAtom = atom(false);
export const viewportWidthAtom = atom(1280);
export const viewportHeightAtom = atom(720);
// True DIP dimensions of the latest screencast frame, from CDP metadata.
// These can differ from the configured viewport (window chrome, scrollbars,
// emulation), and input coordinates must be mapped into this space.
export const frameDeviceWidthAtom = atom(0);
export const frameDeviceHeightAtom = atom(0);
export const currentFrameAtom = atom<string | null>(null);
export const streamEventsAtom = atom<ActivityEvent[]>([]);
export const consoleLogsAtom = atom<ConsoleEntry[]>([]);
Expand Down Expand Up @@ -80,6 +85,8 @@ export function useStreamSync(port: number) {
const setRecording = useSetAtom(recordingAtom);
const setVpWidth = useSetAtom(viewportWidthAtom);
const setVpHeight = useSetAtom(viewportHeightAtom);
const setFrameDeviceWidth = useSetAtom(frameDeviceWidthAtom);
const setFrameDeviceHeight = useSetAtom(frameDeviceHeightAtom);
const setFrame = useSetAtom(currentFrameAtom);
const setEvents = useSetAtom(streamEventsAtom);
const setConsoleLogs = useSetAtom(consoleLogsAtom);
Expand Down Expand Up @@ -108,13 +115,15 @@ export function useStreamSync(port: number) {
setRecording(false);
setVpWidth(1280);
setVpHeight(720);
setFrameDeviceWidth(0);
setFrameDeviceHeight(0);
setFrame(null);
setEvents([]);
setConsoleLogs([]);
setTabs([]);
setEngine("");
}
}, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]);
}, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrameDeviceWidth, setFrameDeviceHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]);

const connect = useCallback(() => {
if (port <= 0) return;
Expand Down Expand Up @@ -151,6 +160,10 @@ export function useStreamSync(port: number) {
switch (msg.type) {
case "frame":
setFrame(msg.data);
if (msg.metadata?.deviceWidth > 0 && msg.metadata?.deviceHeight > 0) {
setFrameDeviceWidth(msg.metadata.deviceWidth);
setFrameDeviceHeight(msg.metadata.deviceHeight);
}
break;

case "status":
Expand Down Expand Up @@ -218,7 +231,7 @@ export function useStreamSync(port: number) {
break;
}
};
}, [port, setWsRef, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine, setTabCache, setEngineCache]);
}, [port, setWsRef, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrameDeviceWidth, setFrameDeviceHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine, setTabCache, setEngineCache]);

useEffect(() => {
connect();
Expand Down
Loading