| 1 | import type { MouseInputEvent } from "electron"; |
| 2 | import type { GuestFrame, GuestPage } from "./guestView.js"; |
| 3 | import { abortable } from "./captureQueue.js"; |
| 4 | import { browserFailure } from "./errors.js"; |
| 5 | import { FrameRuntime, frameOperationSignal } from "./frameRuntime.js"; |
| 6 | import { scriptCall } from "./pageScripts.js"; |
| 7 | import { runInFrame } from "./snapshot.js"; |
| 8 | |
| 9 | // Chromium's root CDP hit-test graph can still describe the preceding surface |
| 10 | // after native reparenting or emulation changes. Resolve the current frame in |
| 11 | // its isolated DOM world, then send to that frame's owning renderer. In-process |
| 12 | // targets use Electron's direct root-widget input path; OOPIFs use their own |
| 13 | // CDP session, never the root's asynchronous cross-process hit-test routing. |
| 14 | export async function dispatchMouseInput(page: GuestPage, event: MouseInputEvent, inputScale: () => number, verify: () => void = () => {}): Promise<void> { |
| 15 | const signal = frameOperationSignal(); |
| 16 | const main = page.mainFrame, zoom = page.getZoomFactor(), scale = inputScale(); |
| 17 | const path: Array<{ parent: GuestFrame; frame: GuestFrame; index: number; url: string }> = []; |
| 18 | let runtime: FrameRuntime | undefined; |
| 19 | const children = (frame: GuestFrame) => frame.frames ?? frame.framesInSubtree.filter(child => child.parent?.frameTreeNodeId === frame.frameTreeNodeId); |
| 20 | const checkpoint = () => { |
| 21 | signal?.throwIfAborted(); verify(); |
| 22 | if (page.isDestroyed() || page.mainFrame !== main || main.detached || page.getZoomFactor() !== zoom || inputScale() !== scale || path.some(item => item.frame.detached || item.frame.url !== item.url || children(item.parent)[item.index] !== item.frame)) throw browserFailure("stale_document", "pointer frame or viewport changed during input preparation"); |
| 23 | }; |
| 24 | const evaluate = async (frame: GuestFrame, method: string, input: unknown) => { |
| 25 | checkpoint(); |
| 26 | if (frame !== main) runtime ??= new FrameRuntime(page); |
| 27 | const pending = runInFrame(page, frame, scriptCall(method, input), runtime); |
| 28 | const result = await (signal ? abortable(pending, signal) : pending); |
| 29 | checkpoint(); |
| 30 | return result; |
| 31 | }; |
| 32 | try { |
| 33 | let frame = main, point = { x: event.x / scale, y: event.y / scale }; |
| 34 | const points = new Map<GuestFrame, { x: number; y: number }>(); |
| 35 | for (;;) { |
| 36 | points.set(frame, point); |
| 37 | if (event.type === "mouseMove" && !event.button && await evaluate(frame, "pageInputReady", point) !== true) throw browserFailure("page_not_ready", "input target has not reached a stable rendered frame; observe the page again"); |
| 38 | const child = await evaluate(frame, "pageInputFrame", point) as { index: number; x: number; y: number; unsupported?: boolean } | null; |
| 39 | if (!child) break; |
| 40 | if (child.unsupported) throw browserFailure("capability_unsupported", "pointer frame has an unsupported transform"); |
| 41 | if (path.length >= 16) throw browserFailure("capability_unsupported", "pointer frame nesting exceeds the supported depth"); |
| 42 | const next = children(frame)[child.index]; |
| 43 | if (!next || next.detached) throw browserFailure("stale_document", "pointer frame disappeared"); |
| 44 | path.push({ parent: frame, frame: next, index: child.index, url: next.url }); |
| 45 | frame = next; point = child; |
| 46 | } |
| 47 | checkpoint(); |
| 48 | if (frame === main) { page.sendInputEvent(event); return; } |
| 49 | await runtime!.run(frame, "", async (_context, sessionId, send, sessionFrame) => { |
| 50 | checkpoint(); |
| 51 | if (!sessionId) { page.sendInputEvent(event); return; } |
| 52 | const ownerPoint = sessionFrame ? points.get(sessionFrame) : undefined; |
| 53 | if (!ownerPoint) throw browserFailure("stale_document", "pointer no longer belongs to the resolved renderer"); |
| 54 | const buttons = event.type === "mouseUp" ? 0 : event.button === "left" ? 1 : event.button === "right" ? 2 : event.button === "middle" ? 4 : 0; |
| 55 | // A target session takes CSS pixels local to its renderer. The outer |
| 56 | // Electron display scale has already been removed during frame traversal; |
| 57 | // applying it again can hit the same large button at the wrong point. |
| 58 | await send("Input.dispatchMouseEvent", { type: event.type === "mouseDown" ? "mousePressed" : event.type === "mouseUp" ? "mouseReleased" : "mouseMoved", x: ownerPoint.x, y: ownerPoint.y, button: event.button ?? "none", buttons, clickCount: event.clickCount ?? 0 }, sessionId); |
| 59 | }); |
| 60 | } catch (error) { await runtime?.close(true); throw error; } |
| 61 | finally { await runtime?.close(); } |
| 62 | } |
| 63 |