From 0f2101e1c4a062e42e03da2f4108cb0f27ab1400 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 28 Apr 2026 08:01:32 +0000 Subject: [PATCH 1/9] webview: add mouse.down/up/move primitives for drag automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three low-level pointer primitives to Bun.WebView: view.mouseDown(options?) — press button at current position view.mouseUp(options?) — release button at current position view.mouseMove(x, y, options?) — move to (x,y), optional steps Canvas drag-and-drop tests that rely on intermediate pointermove events can now run on both Chrome and WebKit backends without dropping to raw CDP: await view.mouseMove(from.x, from.y); await view.mouseDown(); await view.mouseMove(to.x, to.y, { steps: 5 }); await view.mouseUp(); - Parent tracks cursor position (m_mouseX/Y) and pressed-button mask (m_mouseButtons) on JSWebView; down/up/move update accordingly. - Chrome backend emits Input.dispatchMouseEvent with the right button/buttons fields; multi-step moves send (steps-1) fire-and- forget events + a tracked final that resolves the promise. - WebKit backend synthesizes NSEvents (LeftMouseDown/Up, LeftMouseDragged/RightMouseDragged/OtherMouseDragged) and uses _simulateMouseMove: SPI for plain hovers. Events go through the existing _doAfterProcessingAllPendingMouseEvents: barrier — the promise resolves only after WebContent has dispatched every handler, same guarantee click() provides. All events fire with isTrusted:true. --- packages/bun-types/bun.d.ts | 67 +++++++++ src/runtime/webview/ChromeBackend.cpp | 109 +++++++++++++++ src/runtime/webview/ChromeBackend.h | 3 + src/runtime/webview/JSWebView.cpp | 61 +++++++++ src/runtime/webview/JSWebView.h | 20 +++ src/runtime/webview/JSWebViewPrototype.cpp | 118 ++++++++++++++++ src/runtime/webview/ObjCRuntime.cpp | 8 ++ src/runtime/webview/ObjCRuntime.h | 25 ++++ src/runtime/webview/WebKitBackend.cpp | 21 +++ src/runtime/webview/WebKitBackend.h | 3 + src/runtime/webview/WebViewHost.cpp | 141 +++++++++++++++++++ src/runtime/webview/WebViewHost.h | 8 ++ src/runtime/webview/host_main.cpp | 21 +++ src/runtime/webview/ipc_protocol.h | 43 ++++++ test/js/bun/webview/webview-chrome.test.ts | 149 +++++++++++++++++++++ test/js/bun/webview/webview.test.ts | 138 +++++++++++++++++++ 16 files changed, 935 insertions(+) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d341e0d3cbef..ca203a7be3d5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -8444,6 +8444,27 @@ declare module "bun" { timeout?: number; } + interface MouseButtonOptions { + /** @default "left" */ + button?: "left" | "right" | "middle"; + /** Modifier keys to hold during the event. */ + modifiers?: Modifier[]; + /** Click count for the event. @default 1 */ + clickCount?: 1 | 2 | 3; + } + + interface MouseMoveOptions { + /** + * Number of intermediate mousemove events to dispatch between the + * current position and `(x, y)`. Drag handlers that observe + * `pointermove` at rAF rate need at least a few; 5–20 is typical. + * @default 1 + */ + steps?: number; + /** Modifier keys to hold during the move. */ + modifiers?: Modifier[]; + } + interface ScrollToOptions { /** * Maximum time in milliseconds to wait for the element to exist. @@ -8861,6 +8882,52 @@ declare module "bun" { */ click(selector: string, options?: WebView.ClickSelectorOptions): Promise; + /** + * Move the pointer to the given viewport coordinates. + * + * Dispatches `options.steps` intermediate `mousemove`/`pointermove` + * events between the current pointer position and `(x, y)`, then one + * final event at `(x, y)`. If a button is currently held (from a + * prior {@link mouseDown}), the events are `mousedrag` / `dragover` + * instead. The pointer position is tracked internally — call + * {@link mouseMove} before {@link mouseDown}/{@link mouseUp} to + * position the cursor. + * + * As a standalone hover (no prior {@link mouseDown}), moves cursor + * to `(x, y)` firing plain `mousemove`/`pointermove` — works for + * `:hover` styles and cursor-CSS assertions. + * + * @example + * ```ts + * // Canvas drag: mouseMove → mouseDown → mouseMove(steps: 5) → mouseUp. + * await view.mouseMove(from.x, from.y); + * await view.mouseDown(); + * await view.mouseMove(to.x, to.y, { steps: 5 }); + * await view.mouseUp(); + * ``` + */ + mouseMove(x: number, y: number, options?: WebView.MouseMoveOptions): Promise; + + /** + * Press a mouse button at the current pointer position. + * + * Uses the position last set by {@link mouseMove} (defaults to + * `(0, 0)` if never moved). Fires a `mousedown`/`pointerdown` event + * with `isTrusted: true` and holds the button down until + * {@link mouseUp}; between the two, {@link mouseMove} dispatches + * drag events. + */ + mouseDown(options?: WebView.MouseButtonOptions): Promise; + + /** + * Release a mouse button at the current pointer position. + * + * Fires `mouseup`/`pointerup`. If the press and release share the + * same position, the browser synthesizes a `click` event as well — + * same as manual interaction. + */ + mouseUp(options?: WebView.MouseButtonOptions): Promise; + /** * Insert text into the focused element. * diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index 59292fd6df1e..eb8362367ed6 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -1505,6 +1505,115 @@ JSPromise* click(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t b .num("modifiers"_s, mods)); } +// Translate Bun's button-mask bitmap (bit 0=left, bit 1=right, bit 2=middle) +// to CDP's `buttons` field, which matches the W3C MouseEvent.buttons bit +// layout: bit 0=left, bit 1=right, bit 2=middle. Same layout. Kept as a +// helper for clarity and in case the layouts ever diverge. +static int32_t cdpButtonsMask(uint8_t mask) { return mask; } + +// Low-level pointer primitives. mouseDown/mouseUp mirror click()'s single- +// event paths. mouseMove emits one dispatchMouseEvent per intermediate +// step plus the final — all but the last are fire-and-forget; the last +// resolves the slot. Chrome processes events in send order, so the final +// reply means all preceding events landed too. +JSPromise* mouseDown(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask) +{ + auto& t = transport(); + auto sid = sidSpan(view->m_sessionId); + auto btn = cdpButton(button); + int32_t mods = cdpModifiers(modifiers); + int32_t buttons = cdpButtonsMask(buttonsMask); + + uint32_t id = t.nextId(); + return sendChromeOp(g, view, view->m_pendingMisc, PendingSlot::Misc, + Method::InputDispatchMouseEvent, id, + Command(id, "Input.dispatchMouseEvent"_s, sid) + .raw("type"_s, "\"mousePressed\""_s) + .num("x"_s, x) + .num("y"_s, y) + .raw("button"_s, btn) + .num("buttons"_s, buttons) + .num("clickCount"_s, static_cast(clickCount)) + .num("modifiers"_s, mods)); +} + +JSPromise* mouseUp(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask) +{ + auto& t = transport(); + auto sid = sidSpan(view->m_sessionId); + auto btn = cdpButton(button); + int32_t mods = cdpModifiers(modifiers); + int32_t buttons = cdpButtonsMask(buttonsMask); + + uint32_t id = t.nextId(); + return sendChromeOp(g, view, view->m_pendingMisc, PendingSlot::Misc, + Method::InputDispatchMouseEvent, id, + Command(id, "Input.dispatchMouseEvent"_s, sid) + .raw("type"_s, "\"mouseReleased\""_s) + .num("x"_s, x) + .num("y"_s, y) + .raw("button"_s, btn) + .num("buttons"_s, buttons) + .num("clickCount"_s, static_cast(clickCount)) + .num("modifiers"_s, mods)); +} + +// mouseMove: emit steps-1 intermediate events + 1 final. For pure hover +// (buttonsMask==0) the event is a plain mouseMoved with button:"none". +// When dragging (buttonsMask != 0) the event type is still "mouseMoved" +// — CDP doesn't have a separate "mouseDragged" — but the non-zero +// `buttons` field tells Chrome a drag is in progress. Chrome synthesizes +// the right pointermove/mousemove + dragenter/dragover dispatch on the +// page side. +JSPromise* mouseMove(JSGlobalObject* g, JSWebView* view, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers) +{ + auto& t = transport(); + auto sid = sidSpan(view->m_sessionId); + int32_t mods = cdpModifiers(modifiers); + int32_t buttons = cdpButtonsMask(buttonsMask); + + // CDP mouseMoved with no pressed button wants "none"; with a button + // held it wants the string name of the primary button for legacy + // `MouseEvent.button` in handlers. Pick the lowest-order pressed bit. + ASCIILiteral btnStr = "\"none\""_s; + if (buttonsMask & 0x1) + btnStr = "\"left\""_s; + else if (buttonsMask & 0x2) + btnStr = "\"right\""_s; + else if (buttonsMask & 0x4) + btnStr = "\"middle\""_s; + + if (steps < 1) steps = 1; + + // Emit the first (steps - 1) events as fire-and-forget; send the + // final event with a tracked id that resolves the slot. Chrome + // processes serially so the final reply means all prior events + // were handled. + for (uint32_t i = 1; i < steps; ++i) { + float ix = fromX + (x - fromX) * (static_cast(i) / static_cast(steps)); + float iy = fromY + (y - fromY) * (static_cast(i) / static_cast(steps)); + uint32_t idInterm = t.nextId(); + t.send(0, Command(idInterm, "Input.dispatchMouseEvent"_s, sid) + .raw("type"_s, "\"mouseMoved\""_s) + .num("x"_s, ix) + .num("y"_s, iy) + .raw("button"_s, btnStr) + .num("buttons"_s, buttons) + .num("modifiers"_s, mods)); + } + + uint32_t id = t.nextId(); + return sendChromeOp(g, view, view->m_pendingMisc, PendingSlot::Misc, + Method::InputDispatchMouseEvent, id, + Command(id, "Input.dispatchMouseEvent"_s, sid) + .raw("type"_s, "\"mouseMoved\""_s) + .num("x"_s, x) + .num("y"_s, y) + .raw("button"_s, btnStr) + .num("buttons"_s, buttons) + .num("modifiers"_s, mods)); +} + // Selector ops: Runtime.evaluate runs the rAF-polled actionability check // (same predicate as WKWebView's kActionabilityJS). The IIFE takes // (sel, timeout) — we appendQuotedJSONString the selector so any chars diff --git a/src/runtime/webview/ChromeBackend.h b/src/runtime/webview/ChromeBackend.h index 9f13970fb939..e6514cc2aea9 100644 --- a/src/runtime/webview/ChromeBackend.h +++ b/src/runtime/webview/ChromeBackend.h @@ -509,6 +509,9 @@ JSC::JSPromise* evaluate(JSC::JSGlobalObject*, JSWebView*, const WTF::String& sc JSC::JSPromise* screenshot(JSC::JSGlobalObject*, JSWebView*, ScreenshotFormat, uint8_t quality); JSC::JSPromise* click(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount); JSC::JSPromise* clickSelector(JSC::JSGlobalObject*, JSWebView*, const WTF::String& selector, uint32_t timeout, uint8_t button, uint8_t modifiers, uint8_t clickCount); +JSC::JSPromise* mouseDown(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask); +JSC::JSPromise* mouseUp(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask); +JSC::JSPromise* mouseMove(JSC::JSGlobalObject*, JSWebView*, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers); JSC::JSPromise* type(JSC::JSGlobalObject*, JSWebView*, const WTF::String& text); JSC::JSPromise* press(JSC::JSGlobalObject*, JSWebView*, uint8_t virtualKey, uint8_t modifiers, const WTF::String& character); JSC::JSPromise* scroll(JSC::JSGlobalObject*, JSWebView*, double dx, double dy); diff --git a/src/runtime/webview/JSWebView.cpp b/src/runtime/webview/JSWebView.cpp index e4aa304e62ab..a9ed0fc53d9f 100644 --- a/src/runtime/webview/JSWebView.cpp +++ b/src/runtime/webview/JSWebView.cpp @@ -225,6 +225,67 @@ JSPromise* JSWebView::clickSelector(JSGlobalObject* g, const WTF::String& select WK_DISPATCH(WK::Ops::clickSelector(g, this, selector, timeout, button, modifiers, clickCount)); } +// Low-level pointer primitives. Each backend dispatches one event (down/up) +// or a series (move with steps) and resolves when the event has been +// processed. State updates happen here, AFTER dispatch, so a backend +// failure doesn't leave a phantom press bit set. +JSPromise* JSWebView::mouseDown(JSGlobalObject* g, uint8_t button, uint8_t modifiers, uint8_t clickCount) +{ + uint8_t bit = 1u << button; + uint8_t newMask = m_mouseButtons | bit; + JSPromise* p; + if (m_backend == WebViewBackend::Chrome) { + p = CDP::Ops::mouseDown(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask); + } else { +#if OS(DARWIN) + p = WK::Ops::mouseDown(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask); +#else + RELEASE_ASSERT_NOT_REACHED(); + p = nullptr; +#endif + } + m_mouseButtons = newMask; + return p; +} + +JSPromise* JSWebView::mouseUp(JSGlobalObject* g, uint8_t button, uint8_t modifiers, uint8_t clickCount) +{ + uint8_t bit = 1u << button; + uint8_t newMask = m_mouseButtons & ~bit; + JSPromise* p; + if (m_backend == WebViewBackend::Chrome) { + p = CDP::Ops::mouseUp(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask); + } else { +#if OS(DARWIN) + p = WK::Ops::mouseUp(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask); +#else + RELEASE_ASSERT_NOT_REACHED(); + p = nullptr; +#endif + } + m_mouseButtons = newMask; + return p; +} + +JSPromise* JSWebView::mouseMove(JSGlobalObject* g, float x, float y, uint32_t steps, uint8_t modifiers) +{ + float fromX = m_mouseX, fromY = m_mouseY; + JSPromise* p; + if (m_backend == WebViewBackend::Chrome) { + p = CDP::Ops::mouseMove(g, this, fromX, fromY, x, y, steps, m_mouseButtons, modifiers); + } else { +#if OS(DARWIN) + p = WK::Ops::mouseMove(g, this, fromX, fromY, x, y, steps, m_mouseButtons, modifiers); +#else + RELEASE_ASSERT_NOT_REACHED(); + p = nullptr; +#endif + } + m_mouseX = x; + m_mouseY = y; + return p; +} + JSPromise* JSWebView::type(JSGlobalObject* g, const WTF::String& text) { if (m_backend == WebViewBackend::Chrome) return CDP::Ops::type(g, this, text); diff --git a/src/runtime/webview/JSWebView.h b/src/runtime/webview/JSWebView.h index 236e00db51dd..f4c29fe35dfb 100644 --- a/src/runtime/webview/JSWebView.h +++ b/src/runtime/webview/JSWebView.h @@ -115,6 +115,18 @@ class JSWebView final : public WebCore::JSEventTarget { ScreenshotFormat m_screenshotFormat = ScreenshotFormat::Png; ScreenshotEncoding m_screenshotEncoding = ScreenshotEncoding::Blob; + // Pointer state for low-level drag primitives (mouseDown/Up/Move). + // Last cursor position from mouseMove, used as the default for + // mouseDown/mouseUp (Playwright-compatible: down/up take no coords). + // m_mouseButtons is a bitmask of currently-held buttons: + // bit 0 = left, bit 1 = right, bit 2 = middle. + // The mask determines whether a move dispatches a mouseMoved (no + // buttons) vs mouseDragged (any button) NSEvent, and which CDP + // `buttons` field we send. + float m_mouseX = 0.0f; + float m_mouseY = 0.0f; + uint8_t m_mouseButtons = 0; + JSC::WriteBarrier m_onNavigated; JSC::WriteBarrier m_onNavigationFailed; // Console capture. If the user passed `console: globalThis.console`, @@ -166,6 +178,14 @@ class JSWebView final : public WebCore::JSEventTarget { JSC::JSPromise* cdp(JSC::JSGlobalObject*, const WTF::String& method, const WTF::String& paramsJson); JSC::JSPromise* click(JSC::JSGlobalObject*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount); JSC::JSPromise* clickSelector(JSC::JSGlobalObject*, const WTF::String& selector, uint32_t timeout, uint8_t button, uint8_t modifiers, uint8_t clickCount); + // Low-level pointer primitives. down/up use m_mouseX/Y (set by + // prior mouseMove — defaults to 0,0 if never moved). mouseMove + // interpolates steps intermediate points from m_mouseX/Y to x,y + // before updating m_mouseX/Y; with a button pressed (m_mouseButtons + // != 0) each intermediate dispatches as a mouseDragged event. + JSC::JSPromise* mouseDown(JSC::JSGlobalObject*, uint8_t button, uint8_t modifiers, uint8_t clickCount); + JSC::JSPromise* mouseUp(JSC::JSGlobalObject*, uint8_t button, uint8_t modifiers, uint8_t clickCount); + JSC::JSPromise* mouseMove(JSC::JSGlobalObject*, float x, float y, uint32_t steps, uint8_t modifiers); JSC::JSPromise* type(JSC::JSGlobalObject*, const WTF::String& text); JSC::JSPromise* press(JSC::JSGlobalObject*, WebViewProto::VirtualKey, uint8_t modifiers, const WTF::String& character); JSC::JSPromise* scroll(JSC::JSGlobalObject*, double dx, double dy); diff --git a/src/runtime/webview/JSWebViewPrototype.cpp b/src/runtime/webview/JSWebViewPrototype.cpp index e8aeb133a2f8..b00568d84c22 100644 --- a/src/runtime/webview/JSWebViewPrototype.cpp +++ b/src/runtime/webview/JSWebViewPrototype.cpp @@ -23,6 +23,9 @@ static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncEvaluate); static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncScreenshot); static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncCdp); static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncClick); +static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncMouseDown); +static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncMouseUp); +static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncMouseMove); static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncType); static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncPress); static JSC_DECLARE_HOST_FUNCTION(jsWebViewProtoFuncScroll); @@ -47,6 +50,9 @@ static const HashTableValue JSWebViewPrototypeTableValues[] = { { "screenshot"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncScreenshot, 0 } }, { "cdp"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncCdp, 1 } }, { "click"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncClick, 2 } }, + { "mouseDown"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncMouseDown, 0 } }, + { "mouseUp"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncMouseUp, 0 } }, + { "mouseMove"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncMouseMove, 2 } }, { "type"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncType, 1 } }, { "press"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncPress, 1 } }, { "scroll"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWebViewProtoFuncScroll, 2 } }, @@ -486,6 +492,118 @@ JSC_DEFINE_HOST_FUNCTION(jsWebViewProtoFuncType, (JSGlobalObject * globalObject, return JSValue::encode(thisObject->type(globalObject, text)); } +// Shared options parse for mouseDown/mouseUp: { button, modifiers, clickCount }. +// button: "left" (default), "right", "middle" → 0/1/2. +// modifiers: array of "Shift"/"Control"/"Alt"/"Meta". +// clickCount: 1-3. Default 1. +static bool parseMouseDownUpOpts(JSGlobalObject* g, ThrowScope& scope, JSValue opts, + uint8_t& button, uint8_t& mods, uint8_t& clickCount) +{ + button = 0; + mods = 0; + clickCount = 1; + if (!opts.isObject()) return true; + auto& vm = g->vm(); + JSObject* o = opts.getObject(); + JSValue b = o->get(g, Identifier::fromString(vm, "button"_s)); + RETURN_IF_EXCEPTION(scope, false); + if (b.isString()) { + WTF::String bs = b.toWTFString(g); + RETURN_IF_EXCEPTION(scope, false); + if (bs == "right"_s) + button = 1; + else if (bs == "middle"_s) + button = 2; + } + JSValue m = o->get(g, Identifier::fromString(vm, "modifiers"_s)); + RETURN_IF_EXCEPTION(scope, false); + mods = parseModifiers(g, scope, m); + RETURN_IF_EXCEPTION(scope, false); + JSValue cc = o->get(g, Identifier::fromString(vm, "clickCount"_s)); + RETURN_IF_EXCEPTION(scope, false); + if (cc.isNumber()) clickCount = static_cast(std::clamp(cc.toInt32(g), 1, 3)); + RETURN_IF_EXCEPTION(scope, false); + return true; +} + +JSC_DEFINE_HOST_FUNCTION(jsWebViewProtoFuncMouseDown, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = unwrapThis(globalObject, scope, callFrame, "mouseDown"_s); + RETURN_IF_EXCEPTION(scope, {}); + + uint8_t button, mods, clickCount; + if (!parseMouseDownUpOpts(globalObject, scope, callFrame->argument(0), button, mods, clickCount)) return {}; + + if (!checkSlot(globalObject, scope, thisObject->m_pendingMisc, "a simple operation"_s)) return {}; + return JSValue::encode(thisObject->mouseDown(globalObject, button, mods, clickCount)); +} + +JSC_DEFINE_HOST_FUNCTION(jsWebViewProtoFuncMouseUp, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = unwrapThis(globalObject, scope, callFrame, "mouseUp"_s); + RETURN_IF_EXCEPTION(scope, {}); + + uint8_t button, mods, clickCount; + if (!parseMouseDownUpOpts(globalObject, scope, callFrame->argument(0), button, mods, clickCount)) return {}; + + if (!checkSlot(globalObject, scope, thisObject->m_pendingMisc, "a simple operation"_s)) return {}; + return JSValue::encode(thisObject->mouseUp(globalObject, button, mods, clickCount)); +} + +// mouseMove(x, y, opts?): dispatches steps intermediate mousemove events +// from the current position to (x, y). Default steps=1 (just one event at +// the target). When a button is held (from prior mouseDown), the host +// dispatches mouseDragged NSEvents; otherwise mouseMoved. +JSC_DEFINE_HOST_FUNCTION(jsWebViewProtoFuncMouseMove, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = unwrapThis(globalObject, scope, callFrame, "mouseMove"_s); + RETURN_IF_EXCEPTION(scope, {}); + + double x = callFrame->argument(0).toNumber(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + double y = callFrame->argument(1).toNumber(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + // NaN/Inf would propagate into m_mouseX/Y and poison every subsequent + // down/up dispatch. static_cast of NaN at the CDP send site is UB. + if (!std::isfinite(x) || !std::isfinite(y)) + return Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, "x/y"_s, + jsNumber(std::isfinite(x) ? y : x), "must be finite"_s); + + uint32_t steps = 1; + uint8_t mods = 0; + JSValue opts = callFrame->argument(2); + if (opts.isObject()) { + JSObject* o = opts.getObject(); + JSValue s = o->get(globalObject, Identifier::fromString(vm, "steps"_s)); + RETURN_IF_EXCEPTION(scope, {}); + if (s.isNumber()) { + int32_t si = s.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + // steps < 1 is meaningless (we still need at least one final + // event). Cap at 1000 — any higher is almost certainly a bug + // (tests that want smooth animation should use ~20). A 1000- + // event burst is already ~30KB of CDP payload. + if (si < 1) si = 1; + if (si > 1000) si = 1000; + steps = static_cast(si); + } + JSValue m = o->get(globalObject, Identifier::fromString(vm, "modifiers"_s)); + RETURN_IF_EXCEPTION(scope, {}); + mods = parseModifiers(globalObject, scope, m); + RETURN_IF_EXCEPTION(scope, {}); + } + + if (!checkSlot(globalObject, scope, thisObject->m_pendingMisc, "a simple operation"_s)) return {}; + return JSValue::encode(thisObject->mouseMove(globalObject, + static_cast(x), static_cast(y), steps, mods)); +} + JSC_DEFINE_HOST_FUNCTION(jsWebViewProtoFuncPress, (JSGlobalObject * globalObject, CallFrame* callFrame)) { VM& vm = globalObject->vm(); diff --git a/src/runtime/webview/ObjCRuntime.cpp b/src/runtime/webview/ObjCRuntime.cpp index 8f53fbcb5fa8..68da43a36b16 100644 --- a/src/runtime/webview/ObjCRuntime.cpp +++ b/src/runtime/webview/ObjCRuntime.cpp @@ -98,6 +98,10 @@ SEL WKWebView::s_rightMouseDown; SEL WKWebView::s_rightMouseUp; SEL WKWebView::s_otherMouseDown; SEL WKWebView::s_otherMouseUp; +SEL WKWebView::s_simulateMouseMove; +SEL WKWebView::s_mouseDragged; +SEL WKWebView::s_rightMouseDragged; +SEL WKWebView::s_otherMouseDragged; SEL WKWebView::s_keyDown; SEL WKWebView::s_keyUp; SEL WKWebView::s_scrollWheel; @@ -479,6 +483,10 @@ bool ObjCRuntime::load() WKWebView::s_rightMouseUp = sel("rightMouseUp:"); WKWebView::s_otherMouseDown = sel("otherMouseDown:"); WKWebView::s_otherMouseUp = sel("otherMouseUp:"); + WKWebView::s_simulateMouseMove = sel("_simulateMouseMove:"); + WKWebView::s_mouseDragged = sel("mouseDragged:"); + WKWebView::s_rightMouseDragged = sel("rightMouseDragged:"); + WKWebView::s_otherMouseDragged = sel("otherMouseDragged:"); WKWebView::s_keyDown = sel("keyDown:"); WKWebView::s_keyUp = sel("keyUp:"); WKWebView::s_scrollWheel = sel("scrollWheel:"); diff --git a/src/runtime/webview/ObjCRuntime.h b/src/runtime/webview/ObjCRuntime.h index 888a27d6fa03..d92ae37d5f82 100644 --- a/src/runtime/webview/ObjCRuntime.h +++ b/src/runtime/webview/ObjCRuntime.h @@ -397,10 +397,14 @@ struct NSEvent : Ref { LeftMouseUp = 2, RightMouseDown = 3, RightMouseUp = 4, + MouseMoved = 5, + LeftMouseDragged = 6, + RightMouseDragged = 7, KeyDown = 10, KeyUp = 11, OtherMouseDown = 25, OtherMouseUp = 26, + OtherMouseDragged = 27, }; // NSEventModifierFlags — bits 16–20. enum : unsigned long { @@ -632,6 +636,23 @@ struct WKWebView : Ref { static SEL s_rightMouseUp; static SEL s_otherMouseDown; static SEL s_otherMouseUp; + // Mouse movement without a button held goes through WKWebView's + // _simulateMouseMove: SPI (macOS 13+) — the public mouseMoved: + // responder doesn't route to WebContent unless the window has + // acceptsMouseMovedEvents:YES and a tracking area matches, which + // isn't wired for a hidden headless window. _simulateMouseMove: is + // what Safari's inspector uses for its hover simulation and + // forwards straight to WebViewImpl::mouseMoved — same path as a + // real pointer movement, all JS handlers fire with isTrusted:true. + // + // Dragged events go through the public selectors (mouseDragged:, + // rightMouseDragged:, otherMouseDragged:) — those DO route through + // the responder chain because a button is held (the mouseDown: that + // started the drag is already in WebContent's event queue). + static SEL s_simulateMouseMove; + static SEL s_mouseDragged; + static SEL s_rightMouseDragged; + static SEL s_otherMouseDragged; static SEL s_keyDown; static SEL s_keyUp; void mouseDown(NSEvent e) { msg(s_mouseDown, e.m_id); } @@ -640,6 +661,10 @@ struct WKWebView : Ref { void rightMouseUp(NSEvent e) { msg(s_rightMouseUp, e.m_id); } void otherMouseDown(NSEvent e) { msg(s_otherMouseDown, e.m_id); } void otherMouseUp(NSEvent e) { msg(s_otherMouseUp, e.m_id); } + void simulateMouseMove(NSEvent e) { msg(s_simulateMouseMove, e.m_id); } + void mouseDragged(NSEvent e) { msg(s_mouseDragged, e.m_id); } + void rightMouseDragged(NSEvent e) { msg(s_rightMouseDragged, e.m_id); } + void otherMouseDragged(NSEvent e) { msg(s_otherMouseDragged, e.m_id); } void keyDown(NSEvent e) { msg(s_keyDown, e.m_id); } void keyUp(NSEvent e) { msg(s_keyUp, e.m_id); } diff --git a/src/runtime/webview/WebKitBackend.cpp b/src/runtime/webview/WebKitBackend.cpp index 870ea165454d..e416fb429d63 100644 --- a/src/runtime/webview/WebKitBackend.cpp +++ b/src/runtime/webview/WebKitBackend.cpp @@ -593,6 +593,27 @@ JSPromise* clickSelector(JSGlobalObject* g, JSWebView* view, const WTF::String& payload.span().data(), static_cast(payload.size())); } +JSPromise* mouseDown(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask) +{ + auto payload = encode(MouseDownPayload { x, y, button, modifiers, clickCount, buttonsMask }); + return sendOp(g, view, view->m_pendingMisc, Op::MouseDown, + payload.span().data(), static_cast(payload.size())); +} + +JSPromise* mouseUp(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask) +{ + auto payload = encode(MouseUpPayload { x, y, button, modifiers, clickCount, buttonsMask }); + return sendOp(g, view, view->m_pendingMisc, Op::MouseUp, + payload.span().data(), static_cast(payload.size())); +} + +JSPromise* mouseMove(JSGlobalObject* g, JSWebView* view, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers) +{ + auto payload = encode(MouseMovePayload { fromX, fromY, x, y, steps, buttonsMask, modifiers }); + return sendOp(g, view, view->m_pendingMisc, Op::MouseMove, + payload.span().data(), static_cast(payload.size())); +} + JSPromise* type(JSGlobalObject* g, JSWebView* view, const WTF::String& text) { auto payload = encodeStr(text); diff --git a/src/runtime/webview/WebKitBackend.h b/src/runtime/webview/WebKitBackend.h index 878e5c310af1..ccdb9e6b2877 100644 --- a/src/runtime/webview/WebKitBackend.h +++ b/src/runtime/webview/WebKitBackend.h @@ -73,6 +73,9 @@ JSC::JSPromise* evaluate(JSC::JSGlobalObject*, JSWebView*, const WTF::String& sc JSC::JSPromise* screenshot(JSC::JSGlobalObject*, JSWebView*, ScreenshotFormat, uint8_t quality); JSC::JSPromise* click(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount); JSC::JSPromise* clickSelector(JSC::JSGlobalObject*, JSWebView*, const WTF::String& selector, uint32_t timeout, uint8_t button, uint8_t modifiers, uint8_t clickCount); +JSC::JSPromise* mouseDown(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask); +JSC::JSPromise* mouseUp(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask); +JSC::JSPromise* mouseMove(JSC::JSGlobalObject*, JSWebView*, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers); JSC::JSPromise* type(JSC::JSGlobalObject*, JSWebView*, const WTF::String& text); JSC::JSPromise* press(JSC::JSGlobalObject*, JSWebView*, WebViewProto::VirtualKey, uint8_t modifiers, const WTF::String& character); JSC::JSPromise* scroll(JSC::JSGlobalObject*, JSWebView*, double dx, double dy); diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 975adbfabc0b..965a17ad4536 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -347,6 +347,147 @@ void WebViewHost::doNativeClick(float x, float y, uint8_t button, uint8_t modifi m_webview.doAfterPendingMouseEvents(makeHostBlock<&WebViewHost::onInputComplete>(*this)); } +// Low-level pointer primitives. Unlike click() which pairs down+up into +// one barrier-gated sequence, these fire a single event (down/up) or a +// burst of move events and let the caller compose. Each waits on +// _doAfterProcessingAllPendingMouseEvents: so the promise resolves +// after WebContent has dispatched every event's JS handlers. +// +// For mouseDown/mouseUp the button arg picks the NSEventType + the +// right responder selector. buttonsMask is the state AFTER the press/ +// release — not used by NSEvent synthesis (AppKit doesn't encode a +// buttons bitmap; it infers from the sequence), but threaded through +// for parity with CDP and in case we expose a buttons field to page- +// injected scripts later. +bool WebViewHost::mouseDownIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t /*buttonsMask*/) +{ + using NSEvent = objc::NSEvent; + if (m_inputPending) { + hostWriter()->sendReplyStr(m_viewId, Reply::Error, "input operation already pending"_s); + return true; + } + double wy = static_cast(m_height) - y; + unsigned long mods = expandModifiers(modifiers); + double ts = objc::NSProcessInfo::systemUptime(); + long win = m_window.windowNumber(); + + switch (button) { + case 1: + m_webview.rightMouseDown(NSEvent::mouseEvent(NSEvent::RightMouseDown, x, wy, mods, ts, win, clickCount)); + break; + case 2: + m_webview.otherMouseDown(NSEvent::mouseEvent(NSEvent::OtherMouseDown, x, wy, mods, ts, win, clickCount)); + break; + default: + m_webview.mouseDown(NSEvent::mouseEvent(NSEvent::LeftMouseDown, x, wy, mods, ts, win, clickCount)); + } + + m_inputPending = true; + m_webview.doAfterPendingMouseEvents(makeHostBlock<&WebViewHost::onInputComplete>(*this)); + return true; +} + +bool WebViewHost::mouseUpIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t /*buttonsMask*/) +{ + using NSEvent = objc::NSEvent; + if (m_inputPending) { + hostWriter()->sendReplyStr(m_viewId, Reply::Error, "input operation already pending"_s); + return true; + } + double wy = static_cast(m_height) - y; + unsigned long mods = expandModifiers(modifiers); + double ts = objc::NSProcessInfo::systemUptime(); + long win = m_window.windowNumber(); + + switch (button) { + case 1: + m_webview.rightMouseUp(NSEvent::mouseEvent(NSEvent::RightMouseUp, x, wy, mods, ts, win, clickCount)); + break; + case 2: + m_webview.otherMouseUp(NSEvent::mouseEvent(NSEvent::OtherMouseUp, x, wy, mods, ts, win, clickCount)); + break; + default: + m_webview.mouseUp(NSEvent::mouseEvent(NSEvent::LeftMouseUp, x, wy, mods, ts, win, clickCount)); + } + + m_inputPending = true; + m_webview.doAfterPendingMouseEvents(makeHostBlock<&WebViewHost::onInputComplete>(*this)); + return true; +} + +// mouseMove: dispatch `steps` intermediate events + one final. When +// buttonsMask==0 we fire MouseMoved via mouseMoved:. With a button held +// it's mouseDragged (or right/other variant) — AppKit's responder +// chain uses a separate selector. If multiple buttons are held we pick +// the lowest-order set bit for the drag selector; WebKit processes a +// single drag event per main loop tick, so one NSEvent per intermediate +// coord is what the handlers see. Each event still lands in +// mouseEventQueue and the final barrier drains them all. +bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers) +{ + using NSEvent = objc::NSEvent; + if (m_inputPending) { + hostWriter()->sendReplyStr(m_viewId, Reply::Error, "input operation already pending"_s); + return true; + } + unsigned long mods = expandModifiers(modifiers); + double ts = objc::NSProcessInfo::systemUptime(); + long win = m_window.windowNumber(); + double heightD = static_cast(m_height); + + // Pick the NSEventType once. No mixed move/drag within a single + // mouseMove(); the button state is constant for the duration of the + // call (down/up are separate IPC ops that serialize on m_inputPending). + // AppKit's responder chain uses a separate selector per button drag + // (mouseDragged: / rightMouseDragged: / otherMouseDragged:) — switch + // on a tag at each dispatch so we don't heap-allocate a std::function. + enum class MoveKind { Move, LeftDrag, RightDrag, OtherDrag }; + unsigned long evtType = NSEvent::MouseMoved; + MoveKind kind = MoveKind::Move; + if (buttonsMask & 0x1) { + evtType = NSEvent::LeftMouseDragged; + kind = MoveKind::LeftDrag; + } else if (buttonsMask & 0x2) { + evtType = NSEvent::RightMouseDragged; + kind = MoveKind::RightDrag; + } else if (buttonsMask & 0x4) { + evtType = NSEvent::OtherMouseDragged; + kind = MoveKind::OtherDrag; + } + auto dispatch = [&](NSEvent e) { + switch (kind) { + case MoveKind::LeftDrag: + m_webview.mouseDragged(e); + return; + case MoveKind::RightDrag: + m_webview.rightMouseDragged(e); + return; + case MoveKind::OtherDrag: + m_webview.otherMouseDragged(e); + return; + case MoveKind::Move: + m_webview.simulateMouseMove(e); + return; + } + }; + + if (steps < 1) steps = 1; + // steps intermediate + one final; clickCount 0 is the convention for + // non-click mouse events (MouseMoved/Dragged have no click count). + for (uint32_t i = 1; i < steps; ++i) { + double ix = static_cast(fromX) + (static_cast(x) - static_cast(fromX)) * (static_cast(i) / static_cast(steps)); + double iy = static_cast(fromY) + (static_cast(y) - static_cast(fromY)) * (static_cast(i) / static_cast(steps)); + double iwy = heightD - iy; + dispatch(NSEvent::mouseEvent(evtType, ix, iwy, mods, ts, win, 0)); + } + double wy = heightD - static_cast(y); + dispatch(NSEvent::mouseEvent(evtType, x, wy, mods, ts, win, 0)); + + m_inputPending = true; + m_webview.doAfterPendingMouseEvents(makeHostBlock<&WebViewHost::onInputComplete>(*this)); + return true; +} + // Actionability check: Playwright-style rAF-polled predicate. Runs entirely // page-side via callAsyncJavaScript: — WebKit awaits the returned Promise. // One IPC roundtrip regardless of how many frames the poll takes. diff --git a/src/runtime/webview/WebViewHost.h b/src/runtime/webview/WebViewHost.h index 477be19fb852..729eb69b86c6 100644 --- a/src/runtime/webview/WebViewHost.h +++ b/src/runtime/webview/WebViewHost.h @@ -42,6 +42,14 @@ class WebViewHost : public RefCounted { bool scrollIPC(float dx, float dy); bool clickSelectorIPC(const WTF::String& selector, uint32_t timeout, uint8_t button, uint8_t modifiers, uint8_t clickCount); bool scrollToIPC(const WTF::String& selector, uint32_t timeout, uint8_t block); + // Low-level pointer primitives. Each fires one (down/up) or + // multiple (move with steps) NSEvents and waits for the UIProcess + // mouseEventQueue drain barrier before Acking — same barrier click + // uses. buttonsMask is the state AFTER this op for down/up (callers + // already computed it parent-side), or the state DURING the move. + bool mouseDownIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask); + bool mouseUpIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask); + bool mouseMoveIPC(float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers); void onInputComplete(); // _executeEditCommand: is void(^)(BOOL) — block ABI needs the arg slot. void onInputCompleteBool(signed char) { onInputComplete(); } diff --git a/src/runtime/webview/host_main.cpp b/src/runtime/webview/host_main.cpp index b9974b1e77ee..c562964eeab4 100644 --- a/src/runtime/webview/host_main.cpp +++ b/src/runtime/webview/host_main.cpp @@ -334,6 +334,27 @@ void Host::dispatch(uint32_t viewId, Op op, Reader r) } return; } + case Op::MouseDown: { + auto p = decode(r); + if (auto* v = view(viewId, op)) { + if (!v->mouseDownIPC(p.x, p.y, p.button, p.modifiers, p.clickCount, p.buttonsMask)) writer.sendReply(viewId, Reply::Ack); + } + return; + } + case Op::MouseUp: { + auto p = decode(r); + if (auto* v = view(viewId, op)) { + if (!v->mouseUpIPC(p.x, p.y, p.button, p.modifiers, p.clickCount, p.buttonsMask)) writer.sendReply(viewId, Reply::Ack); + } + return; + } + case Op::MouseMove: { + auto p = decode(r); + if (auto* v = view(viewId, op)) { + if (!v->mouseMoveIPC(p.fromX, p.fromY, p.x, p.y, p.steps, p.buttonsMask, p.modifiers)) writer.sendReply(viewId, Reply::Ack); + } + return; + } } writer.sendReplyStr(viewId, Reply::Error, "unknown op"_s); } diff --git a/src/runtime/webview/ipc_protocol.h b/src/runtime/webview/ipc_protocol.h index 0635b18fb1b4..f791c0e92c28 100644 --- a/src/runtime/webview/ipc_protocol.h +++ b/src/runtime/webview/ipc_protocol.h @@ -67,6 +67,15 @@ enum class Op : uint8_t { // fires (isTrusted:true — browser-driven), scrollY updates, // IntersectionObserver triggers. ScrollTo = 15, // ScrollToPayload + str selector + + // Low-level pointer primitives for drag automation. Separate from + // Click so the caller threads down → move* → up without the synthesized + // click being spliced in. Each dispatches one NSEvent; the host fires + // _doAfterProcessingAllPendingMouseEvents: once after the batch so + // N-step moves cost one barrier wait, not N. + MouseDown = 16, // MouseDownPayload + MouseUp = 17, // MouseUpPayload + MouseMove = 18, // MouseMovePayload }; // Mouse button: 0=left, 1=right, 2=middle. @@ -128,6 +137,37 @@ struct ScrollToPayload { // str selector follows }; +struct MouseDownPayload { + float x; // viewport coords, y-down + float y; + uint8_t button; // 0=left 1=right 2=middle + uint8_t modifiers; + uint8_t clickCount; + uint8_t buttonsMask; // bitmask of all pressed buttons AFTER this down +}; + +struct MouseUpPayload { + float x; + float y; + uint8_t button; + uint8_t modifiers; + uint8_t clickCount; + uint8_t buttonsMask; // bitmask of all pressed buttons AFTER this up +}; + +// For N-step moves the parent expands before send: (fromX,fromY) to (x,y) +// interpolated in `steps` segments. The host fires steps+1 NSEvents total +// (one per intermediate coord + the final) and Acks after the drain barrier. +struct MouseMovePayload { + float fromX; // interpolation start + float fromY; + float x; // target + float y; + uint32_t steps; // number of intermediate points between fromX/Y and x/y (>=1) + uint8_t buttonsMask; // pressed-button bitmask (determines move vs dragged event type) + uint8_t modifiers; +}; + #pragma pack(pop) static_assert(sizeof(CreatePayload) == 9); @@ -137,6 +177,9 @@ static_assert(sizeof(PressPayload) == 2); static_assert(sizeof(ScrollPayload) == 8); static_assert(sizeof(ClickSelectorPayload) == 7); static_assert(sizeof(ScrollToPayload) == 5); +static_assert(sizeof(MouseDownPayload) == 12); +static_assert(sizeof(MouseUpPayload) == 12); +static_assert(sizeof(MouseMovePayload) == 22); // Encode: POD head + optional trailing string (u32 len + utf8). 64 bytes // inline covers every head; strings that overflow it heap-allocate, which diff --git a/test/js/bun/webview/webview-chrome.test.ts b/test/js/bun/webview/webview-chrome.test.ts index 2ae750fa29f4..fed44a370a5f 100644 --- a/test/js/bun/webview/webview-chrome.test.ts +++ b/test/js/bun/webview/webview-chrome.test.ts @@ -467,6 +467,155 @@ it("chrome: scroll dispatches wheel event", async () => { expect(y).toBeGreaterThan(0); }); +// Drag-automation primitives: mouseDown/Up/Move. Chrome's +// Input.dispatchMouseEvent synchronously processes each event and +// replies — the sequence of moves lands on the page before the final +// reply resolves our promise. No coalescing-off flag; Chromium +// aggregates rapid moves at rAF rate (same as real user input), so +// `steps` may emit fewer than N final pointermoves. The down/up/final +// coords always hit. +it("chrome: mouseDown/mouseUp/mouseMove drag sequence", async () => { + await using view = new Bun.WebView({ backend: chrome, width: 400, height: 400 }); + await view.navigate( + html(` + +
+ `), + ); + + // Position cursor, press, drag, release. Canvas drag pattern from the + // issue: the intermediate pointermove events are what the drag + // handlers need, not just down/up at endpoints. + await view.mouseMove(50, 50); + await view.mouseDown(); + await view.mouseMove(200, 200, { steps: 5 }); + await view.mouseUp(); + + const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")) as Array<{ + t: string; + x: number; + y: number; + btn: number; + btns: number; + trusted: boolean; + }>; + + // First event is the hover move with no buttons pressed. + expect(events[0]).toEqual({ t: "mousemove", x: 50, y: 50, btn: 0, btns: 0, trusted: true }); + // mousedown fires at the current position with buttons: 1 (left bit). + const down = events.find(e => e.t === "mousedown")!; + expect(down).toEqual({ t: "mousedown", x: 50, y: 50, btn: 0, btns: 1, trusted: true }); + // mouseup fires at the target position with buttons: 0 (released). + const up = events.find(e => e.t === "mouseup")!; + expect(up).toEqual({ t: "mouseup", x: 200, y: 200, btn: 0, btns: 0, trusted: true }); + // Intermediate drag moves — at least one, all with buttons: 1. + const dragMoves = events.filter(e => e.t === "mousemove" && e.btns === 1); + expect(dragMoves.length).toBeGreaterThan(0); + // The final move always hits the target coords. + expect(dragMoves[dragMoves.length - 1]).toEqual({ + t: "mousemove", + x: 200, + y: 200, + btn: 0, + btns: 1, + trusted: true, + }); +}); + +it("chrome: mouseMove without mouseDown is a plain hover (buttons: 0)", async () => { + await using view = new Bun.WebView({ backend: chrome, width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + await view.mouseMove(100, 100); + await view.mouseMove(150, 75); + + const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")) as Array<{ + x: number; + y: number; + btns: number; + trusted: boolean; + }>; + expect(events.length).toBeGreaterThanOrEqual(2); + // All hover events have buttons: 0 (no button pressed). + for (const e of events) expect(e.btns).toBe(0); + expect(events[events.length - 1]).toEqual({ x: 150, y: 75, btns: 0, trusted: true }); +}); + +it("chrome: mouseDown + mouseUp at same position synthesizes click", async () => { + await using view = new Bun.WebView({ backend: chrome, width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + await view.mouseMove(50, 50); + await view.mouseDown(); + await view.mouseUp(); + // No drag in between = the browser fires a synthesized click. + expect(await view.evaluate("String(__clicks)")).toBe("1"); +}); + +it("chrome: mouseDown right button fires contextmenu with modifiers", async () => { + await using view = new Bun.WebView({ backend: chrome, width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + await view.mouseMove(50, 50); + await view.mouseDown({ button: "right", modifiers: ["Shift", "Control"] }); + await view.mouseUp({ button: "right", modifiers: ["Shift", "Control"] }); + + const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")); + // event.button = 2 for right; buttons bitmask bit 1 (= 2) for right. + expect(events).toEqual([{ btn: 2, btns: 2, shift: true, ctrl: true }]); +}); + +it("chrome: mouseDown validates — x/y must be finite in mouseMove", () => { + const view = new Bun.WebView({ backend: chrome, width: 100, height: 100 }); + expect(() => view.mouseMove(NaN, 0)).toThrow(/must be finite/); + expect(() => view.mouseMove(Infinity, 0)).toThrow(/must be finite/); + expect(() => view.mouseMove(0, -Infinity)).toThrow(/must be finite/); + view.close(); +}); + +// Method-existence check — runs without Chrome. Validates the three new +// prototype functions are wired even if the test environment can't spawn +// a browser subprocess (CI containers without Chrome, Linux as root +// without --no-sandbox, etc.). +test("WebView prototype exposes mouseDown/mouseUp/mouseMove", () => { + expect(typeof Bun.WebView.prototype.mouseDown).toBe("function"); + expect(typeof Bun.WebView.prototype.mouseUp).toBe("function"); + expect(typeof Bun.WebView.prototype.mouseMove).toBe("function"); +}); + it("chrome: url getter reflects committed URL", async () => { await using view = new Bun.WebView({ backend: chrome, width: 200, height: 200 }); const url = html("test"); diff --git a/test/js/bun/webview/webview.test.ts b/test/js/bun/webview/webview.test.ts index a422d270303d..0771c619ef89 100644 --- a/test/js/bun/webview/webview.test.ts +++ b/test/js/bun/webview/webview.test.ts @@ -587,6 +587,144 @@ it("click(selector) is injection-safe", async () => { expect(await view.evaluate("String(__pwned)")).toBe("0"); }); +// --- Low-level pointer primitives (mouseDown / mouseUp / mouseMove) -------- +// The drag automation API from the issue. mouseDown without x/y uses the +// last mouseMove coordinate; parent-side tracks m_mouseX/Y and m_mouseButtons. +// Each op maps to one NSEvent (LeftMouseDown/Up, MouseMoved, LeftMouseDragged) +// in the host and waits on _doAfterProcessingAllPendingMouseEvents: — same +// barrier click() uses. + +it("mouseDown/mouseUp/mouseMove drag sequence fires trusted events", async () => { + await using view = new Bun.WebView({ width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + + // Position → press → drag through intermediates → release. The + // canvas drag pattern from the feature request. WebKit dispatches + // LeftMouseDragged NSEvents during the move because a button is held; + // without the prior mouseDown, it would be plain MouseMoved. + await view.mouseMove(40, 40); + await view.mouseDown(); + await view.mouseMove(160, 160, { steps: 4 }); + await view.mouseUp(); + + const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")) as Array<{ + t: string; + x: number; + y: number; + btn: number; + btns: number; + trusted: boolean; + }>; + + // Initial mousemove has no buttons pressed. + const firstMove = events.find(e => e.t === "mousemove")!; + expect(firstMove).toEqual({ t: "mousemove", x: 40, y: 40, btn: 0, btns: 0, trusted: true }); + const down = events.find(e => e.t === "mousedown")!; + expect(down).toEqual({ t: "mousedown", x: 40, y: 40, btn: 0, btns: 1, trusted: true }); + const up = events.find(e => e.t === "mouseup")!; + expect(up).toEqual({ t: "mouseup", x: 160, y: 160, btn: 0, btns: 0, trusted: true }); + // Drag intermediates — buttons: 1 (left held). At least one, final hits target. + const dragMoves = events.filter(e => e.t === "mousemove" && e.btns === 1); + expect(dragMoves.length).toBeGreaterThan(0); + expect(dragMoves[dragMoves.length - 1]).toEqual({ + t: "mousemove", + x: 160, + y: 160, + btn: 0, + btns: 1, + trusted: true, + }); +}); + +it("mouseMove without prior mouseDown is a plain hover (buttons: 0)", async () => { + await using view = new Bun.WebView({ width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + await view.mouseMove(100, 100); + await view.mouseMove(150, 75); + + const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")) as Array<{ + x: number; + y: number; + btns: number; + trusted: boolean; + }>; + expect(events.length).toBeGreaterThanOrEqual(2); + for (const e of events) expect(e.btns).toBe(0); + expect(events[events.length - 1]).toEqual({ x: 150, y: 75, btns: 0, trusted: true }); +}); + +it("mouseDown + mouseUp at same position synthesizes a click", async () => { + await using view = new Bun.WebView({ width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + await view.mouseMove(50, 50); + await view.mouseDown(); + await view.mouseUp(); + // No move between → WebKit synthesizes the click event. + expect(await view.evaluate("String(__clicks)")).toBe("1"); +}); + +it("mouseDown right button dispatches with button=2 and buttons bitmask bit 1", async () => { + await using view = new Bun.WebView({ width: 300, height: 300 }); + await view.navigate( + html(` + +
+ `), + ); + await view.mouseMove(50, 50); + await view.mouseDown({ button: "right", modifiers: ["Shift", "Control"] }); + await view.mouseUp({ button: "right", modifiers: ["Shift", "Control"] }); + + const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")); + // DOM event.button: 0=left, 1=middle, 2=right (W3C spec). + // DOM event.buttons bit 1 = right = 2. + expect(events).toEqual([{ btn: 2, btns: 2, shift: true, ctrl: true }]); +}); + +it("mouseMove validates — x/y must be finite", () => { + const view = new Bun.WebView({ width: 100, height: 100 }); + expect(() => view.mouseMove(NaN, 0)).toThrow(/must be finite/); + expect(() => view.mouseMove(Infinity, 0)).toThrow(/must be finite/); + expect(() => view.mouseMove(0, -Infinity)).toThrow(/must be finite/); + view.close(); +}); + it("scrollTo(selector) centers element in viewport", async () => { await using view = new Bun.WebView({ width: 200, height: 200 }); await view.navigate( From 4004807707d6bf7a04e273fe1860b8c8b1ad8071 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:03:40 +0000 Subject: [PATCH 2/9] [autofix.ci] apply automated fixes --- src/runtime/webview/ChromeBackend.cpp | 8 +------- src/runtime/webview/WebViewHost.cpp | 5 ++++- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index eb8362367ed6..19370137015c 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -1593,13 +1593,7 @@ JSPromise* mouseMove(JSGlobalObject* g, JSWebView* view, float fromX, float from float ix = fromX + (x - fromX) * (static_cast(i) / static_cast(steps)); float iy = fromY + (y - fromY) * (static_cast(i) / static_cast(steps)); uint32_t idInterm = t.nextId(); - t.send(0, Command(idInterm, "Input.dispatchMouseEvent"_s, sid) - .raw("type"_s, "\"mouseMoved\""_s) - .num("x"_s, ix) - .num("y"_s, iy) - .raw("button"_s, btnStr) - .num("buttons"_s, buttons) - .num("modifiers"_s, mods)); + t.send(0, Command(idInterm, "Input.dispatchMouseEvent"_s, sid).raw("type"_s, "\"mouseMoved\""_s).num("x"_s, ix).num("y"_s, iy).raw("button"_s, btnStr).num("buttons"_s, buttons).num("modifiers"_s, mods)); } uint32_t id = t.nextId(); diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 965a17ad4536..38432310c4c7 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -441,7 +441,10 @@ bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint3 // AppKit's responder chain uses a separate selector per button drag // (mouseDragged: / rightMouseDragged: / otherMouseDragged:) — switch // on a tag at each dispatch so we don't heap-allocate a std::function. - enum class MoveKind { Move, LeftDrag, RightDrag, OtherDrag }; + enum class MoveKind { Move, + LeftDrag, + RightDrag, + OtherDrag }; unsigned long evtType = NSEvent::MouseMoved; MoveKind kind = MoveKind::Move; if (buttonsMask & 0x1) { From cb4ae7cd8900ab1854878714cb0aa2afc1535c5c Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 28 Apr 2026 08:22:06 +0000 Subject: [PATCH 3/9] =?UTF-8?q?webview:=20address=20review=20=E2=80=94=20r?= =?UTF-8?q?ollback=20pointer=20state=20on=20sync=20reject,=20clamp=20steps?= =?UTF-8?q?=20via=20double?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSWebView.cpp: mouseDown/Up/Move only mutate m_mouseX/Y/Buttons when the returned promise isn't synchronously rejected (dead transport). Async rejections still stale the state, but the view is unusable past that point so every follow-up op will reject too. - JSWebViewPrototype.cpp: read steps as a double, clamp in [1, 1000], then cast. toInt32() on values above 2^31 wraps to negative, so steps: 1e12 previously clamped-up to 1 instead of the 1000 cap. - bun.d.ts: clarify that `steps` is the TOTAL event count (1 = single event at target), not an intermediate count; clarify mouseMove with a button held fires native dragged mouse/pointer events, not the HTML5 Drag and Drop API's dragover. - test: rename 'mouseDown validates' -> 'mouseMove validates' (the body tests mouseMove guards, not mouseDown). --- packages/bun-types/bun.d.ts | 26 +++++++++++++------ src/runtime/webview/JSWebView.cpp | 30 +++++++++++++++++----- src/runtime/webview/JSWebViewPrototype.cpp | 21 ++++++++++----- test/js/bun/webview/webview-chrome.test.ts | 2 +- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index ca203a7be3d5..72cafdc00f35 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -8455,9 +8455,12 @@ declare module "bun" { interface MouseMoveOptions { /** - * Number of intermediate mousemove events to dispatch between the - * current position and `(x, y)`. Drag handlers that observe - * `pointermove` at rAF rate need at least a few; 5–20 is typical. + * Total number of `mousemove`/`pointermove` events dispatched from + * the current position to `(x, y)`, inclusive of the final event at + * the target. `1` = a single event at the target; larger values + * interpolate intermediate positions linearly. Drag handlers that + * observe `pointermove` at rAF rate want at least a handful; + * 5–20 is typical for smooth drags. Clamped to `[1, 1000]`. * @default 1 */ steps?: number; @@ -8885,11 +8888,18 @@ declare module "bun" { /** * Move the pointer to the given viewport coordinates. * - * Dispatches `options.steps` intermediate `mousemove`/`pointermove` - * events between the current pointer position and `(x, y)`, then one - * final event at `(x, y)`. If a button is currently held (from a - * prior {@link mouseDown}), the events are `mousedrag` / `dragover` - * instead. The pointer position is tracked internally — call + * Dispatches `options.steps` `mousemove`/`pointermove` events from + * the current pointer position to `(x, y)`, including the final + * event at the target. If a button is currently held (from a prior + * {@link mouseDown}), the browser treats each event as part of a + * drag: on WebKit it fires as a native `mouseDragged` NSEvent; on + * Chrome it's still a `mousemove` but with a non-zero `buttons` + * field, so DOM drag handlers (listening for `pointermove` with + * `e.buttons !== 0`) see the intermediate positions. This is NOT + * the HTML5 Drag and Drop API — no `dragstart`/`dragover` events + * fire unless a `draggable` element initiates them page-side. + * + * The pointer position is tracked internally — call * {@link mouseMove} before {@link mouseDown}/{@link mouseUp} to * position the cursor. * diff --git a/src/runtime/webview/JSWebView.cpp b/src/runtime/webview/JSWebView.cpp index a9ed0fc53d9f..6e30c306f0b9 100644 --- a/src/runtime/webview/JSWebView.cpp +++ b/src/runtime/webview/JSWebView.cpp @@ -226,9 +226,21 @@ JSPromise* JSWebView::clickSelector(JSGlobalObject* g, const WTF::String& select } // Low-level pointer primitives. Each backend dispatches one event (down/up) -// or a series (move with steps) and resolves when the event has been -// processed. State updates happen here, AFTER dispatch, so a backend -// failure doesn't leave a phantom press bit set. +// or a series (move with steps) and returns a promise that resolves when +// WebContent has processed the event(s). +// +// State (m_mouseButtons, m_mouseX, m_mouseY) is what the NEXT call will +// read, so it MUST reflect only successfully-sent events. Both backends' +// send paths return a synchronously-rejected promise when the transport +// is dead (WebKit host socket closed, Chrome pipe torn down, view closed +// mid-chain) — in that case leave state untouched so a caller who catches +// the rejection and opens a new view doesn't inherit a phantom held +// button. Async rejections (WebKit host crash after accept, CDP error +// reply) can't be reflected here without .then()-plumbing a continuation; +// the view is already unusable past that point — every subsequent op +// will reject — so the stale state is harmless. The in-flight +// m_pendingMisc slot serializes ops so no overlapping call reads +// half-updated state. JSPromise* JSWebView::mouseDown(JSGlobalObject* g, uint8_t button, uint8_t modifiers, uint8_t clickCount) { uint8_t bit = 1u << button; @@ -244,7 +256,8 @@ JSPromise* JSWebView::mouseDown(JSGlobalObject* g, uint8_t button, uint8_t modif p = nullptr; #endif } - m_mouseButtons = newMask; + if (p && p->status() != JSPromise::Status::Rejected) + m_mouseButtons = newMask; return p; } @@ -263,7 +276,8 @@ JSPromise* JSWebView::mouseUp(JSGlobalObject* g, uint8_t button, uint8_t modifie p = nullptr; #endif } - m_mouseButtons = newMask; + if (p && p->status() != JSPromise::Status::Rejected) + m_mouseButtons = newMask; return p; } @@ -281,8 +295,10 @@ JSPromise* JSWebView::mouseMove(JSGlobalObject* g, float x, float y, uint32_t st p = nullptr; #endif } - m_mouseX = x; - m_mouseY = y; + if (p && p->status() != JSPromise::Status::Rejected) { + m_mouseX = x; + m_mouseY = y; + } return p; } diff --git a/src/runtime/webview/JSWebViewPrototype.cpp b/src/runtime/webview/JSWebViewPrototype.cpp index b00568d84c22..82970922f88e 100644 --- a/src/runtime/webview/JSWebViewPrototype.cpp +++ b/src/runtime/webview/JSWebViewPrototype.cpp @@ -583,15 +583,22 @@ JSC_DEFINE_HOST_FUNCTION(jsWebViewProtoFuncMouseMove, (JSGlobalObject * globalOb JSValue s = o->get(globalObject, Identifier::fromString(vm, "steps"_s)); RETURN_IF_EXCEPTION(scope, {}); if (s.isNumber()) { - int32_t si = s.toInt32(globalObject); + // Read as double so we can clamp BEFORE any int cast — + // toInt32 wraps at 2^31 (a user passing Number.MAX_SAFE_INTEGER + // would then get a negative, clamped-to-1 steps instead of + // the 1000 cap). NaN/Inf clamp naturally through the + // finite-guarded compare chain (NaN < 1 is false, NaN > 1000 + // is false → steps stays at the default 1). + double sd = s.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); - // steps < 1 is meaningless (we still need at least one final - // event). Cap at 1000 — any higher is almost certainly a bug - // (tests that want smooth animation should use ~20). A 1000- + // Default 1. 1000 is the cap — any higher is almost certainly + // a bug (tests that want smooth animation use ~20). A 1000- // event burst is already ~30KB of CDP payload. - if (si < 1) si = 1; - if (si > 1000) si = 1000; - steps = static_cast(si); + if (std::isfinite(sd)) { + if (sd < 1.0) sd = 1.0; + if (sd > 1000.0) sd = 1000.0; + steps = static_cast(sd); + } } JSValue m = o->get(globalObject, Identifier::fromString(vm, "modifiers"_s)); RETURN_IF_EXCEPTION(scope, {}); diff --git a/test/js/bun/webview/webview-chrome.test.ts b/test/js/bun/webview/webview-chrome.test.ts index fed44a370a5f..f2706223cd1f 100644 --- a/test/js/bun/webview/webview-chrome.test.ts +++ b/test/js/bun/webview/webview-chrome.test.ts @@ -598,7 +598,7 @@ it("chrome: mouseDown right button fires contextmenu with modifiers", async () = expect(events).toEqual([{ btn: 2, btns: 2, shift: true, ctrl: true }]); }); -it("chrome: mouseDown validates — x/y must be finite in mouseMove", () => { +it("chrome: mouseMove validates — x/y must be finite", () => { const view = new Bun.WebView({ backend: chrome, width: 100, height: 100 }); expect(() => view.mouseMove(NaN, 0)).toThrow(/must be finite/); expect(() => view.mouseMove(Infinity, 0)).toThrow(/must be finite/); From 79c26107af23fe33e549fa51d2f9bc1ebf8f79f7 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 28 Apr 2026 08:25:03 +0000 Subject: [PATCH 4/9] webview: clarify steps-loop comment in WebViewHost.cpp The loop emits (steps - 1) intermediates + 1 final = steps total events, not 'steps intermediate + one final' as the comment suggested. Matches ChromeBackend.cpp's wording. --- src/runtime/webview/WebViewHost.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 38432310c4c7..3cc9a96ccb15 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -475,8 +475,10 @@ bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint3 }; if (steps < 1) steps = 1; - // steps intermediate + one final; clickCount 0 is the convention for - // non-click mouse events (MouseMoved/Dragged have no click count). + // (steps - 1) intermediate events at fractions i/steps, then the + // final event at the target — `steps` events total. clickCount 0 is + // the convention for non-click mouse events (MouseMoved/Dragged have + // no click count). for (uint32_t i = 1; i < steps; ++i) { double ix = static_cast(fromX) + (static_cast(x) - static_cast(fromX)) * (static_cast(i) / static_cast(steps)); double iy = static_cast(fromY) + (static_cast(y) - static_cast(fromY)) * (static_cast(i) / static_cast(steps)); From de53771d80ff74f7ae7978604e96ec20d18e8bee Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 28 Apr 2026 09:35:50 +0000 Subject: [PATCH 5/9] webview: populate DOM event.buttons for synthesized mouse events (WebKit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI surfaced a spec-correctness gap in the WebKit backend: event.buttons in mousedown/mouseup/contextmenu handlers always read as 0 for synthesized events, breaking drag tests that observe the button-held bitmask to distinguish hover-move from drag-move. The cause: WebCore's PlatformEventFactoryMac computes event.buttons via '+[NSEvent pressedMouseButtons]', which is the system-wide HID state. For NSEvent objects we synthesize at the WKWebView responder level, no physical button is down, so this returns 0. Fix, same mechanism Safari's WebDriver uses (WebAutomationSessionMac.mm:80): swap the class method implementation of '+[NSEvent pressedMouseButtons]' to return a global 'NSEvent::s_trackedButtonsMask' that the host sets before each mouseDown/Up/Move dispatch. Theirs is scope-limited with a scope_exit; ours is permanent — the WebView host is a headless subprocess, we never want real HID state. - ObjCRuntime.h/.cpp: declare s_trackedButtonsMask, dlsym class_getClassMethod + method_setImplementation, swap the impl at load() time. Permanent, non-fatal if the swap fails (event.buttons just stays 0 in that case). - WebViewHost.cpp: stamp the mask before mouseDownIPC/mouseUpIPC/ mouseMoveIPC dispatch. JSWebView already computes the post-op mask (mousedown sees buttons WITH the pressed bit, mouseup WITHOUT) and threads it through IPC. - click()/doNativeClick unchanged — its callers don't observe event.buttons, only button/detail/modifiers. --- src/runtime/webview/ObjCRuntime.cpp | 27 +++++++++++++++++++++++ src/runtime/webview/ObjCRuntime.h | 21 ++++++++++++++++++ src/runtime/webview/WebViewHost.cpp | 34 +++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/runtime/webview/ObjCRuntime.cpp b/src/runtime/webview/ObjCRuntime.cpp index 68da43a36b16..920255c30d3a 100644 --- a/src/runtime/webview/ObjCRuntime.cpp +++ b/src/runtime/webview/ObjCRuntime.cpp @@ -91,6 +91,7 @@ void (*NSEvent::s_CGEventSetLocation)(void*, CGPoint); uint32_t (*NSEvent::s_CGMainDisplayID)(); CGRect (*NSEvent::s_CGDisplayBounds)(uint32_t); void (*NSEvent::s_CFRelease)(void*); +uint32_t NSEvent::s_trackedButtonsMask = 0; SEL WKWebView::s_mouseDown; SEL WKWebView::s_mouseUp; @@ -321,6 +322,9 @@ bool ObjCRuntime::load() Protocol* (*getProtocol)(const char*); void (*registerClassPair)(Class); + Method (*getClassMethod)(Class, SEL); + IMP (*methodSetImplementation)(Method, IMP); + SYM(getClass, libobjc, "objc_getClass"); SYM(sel, libobjc, "sel_registerName"); SYM(allocateClassPair, libobjc, "objc_allocateClassPair"); @@ -328,6 +332,8 @@ bool ObjCRuntime::load() SYM(addProtocol, libobjc, "class_addProtocol"); SYM(getProtocol, libobjc, "objc_getProtocol"); SYM(registerClassPair, libobjc, "objc_registerClassPair"); + SYM(getClassMethod, libobjc, "class_getClassMethod"); + SYM(methodSetImplementation, libobjc, "method_setImplementation"); SYM(NavigationDelegate::s_setAssoc, libobjc, "objc_setAssociatedObject"); SYM(NavigationDelegate::s_getAssoc, libobjc, "objc_getAssociatedObject"); SYM(m_autoreleasePoolPush, libobjc, "objc_autoreleasePoolPush"); @@ -439,6 +445,27 @@ bool ObjCRuntime::load() return false; } + // Swap +[NSEvent pressedMouseButtons] to return NSEvent::s_trackedButtonsMask. + // Rationale: WebCore's PlatformEventFactoryMac.mm computes DOM + // event.buttons for every synthesized mouse event by calling + // +[NSEvent pressedMouseButtons]. That returns system-wide HID state + // (not derived from the NSEvent we pass), so synthetic mousedown / + // drag / contextmenu all got event.buttons=0 — failing the + // spec-compliant assertion that event.buttons reflects the button + // being pressed (= 1 for left mousedown, = 2 for right, = 4 for + // middle). This is the same workaround Safari's automation uses + // (WebAutomationSessionMac.mm:80, scope-limited with a swizzle); + // ours is permanent since the host process never handles real input. + // + // Non-fatal if it fails: event.buttons falls back to 0, drag tests + // that rely on it break, but the rest of the input API still works. + // Logged via m_loadError but we continue. + auto pressedMouseButtonsImpl = +[](id, SEL) -> unsigned long { + return NSEvent::s_trackedButtonsMask; + }; + if (Method m = getClassMethod(NSEvent::cls, sel("pressedMouseButtons"))) + methodSetImplementation(m, reinterpret_cast(pressedMouseButtonsImpl)); + CLS(WKWebViewConfiguration::cls, "WKWebViewConfiguration"); CLS(WKWebViewConfiguration::cls_WKWebsiteDataStore, "WKWebsiteDataStore"); // _WKWebsiteDataStoreConfiguration is SPI but stable since macOS 10.13. diff --git a/src/runtime/webview/ObjCRuntime.h b/src/runtime/webview/ObjCRuntime.h index d92ae37d5f82..9a37543a2a14 100644 --- a/src/runtime/webview/ObjCRuntime.h +++ b/src/runtime/webview/ObjCRuntime.h @@ -391,6 +391,27 @@ struct NSEvent : Ref { static CGRect (*s_CGDisplayBounds)(uint32_t displayID); static void (*s_CFRelease)(void *); + // Holds the button-mask bitmap the DOM's event.buttons field should + // report. WebCore's PlatformEventFactoryMac.mm computes event.buttons + // by calling +[NSEvent pressedMouseButtons], which reads + // system-wide HID state. For synthetic events that state is always 0 + // (no real button physically pressed), so mousedown/drag/contextmenu + // all get event.buttons=0 — wrong for spec-compliant JS drag + // handlers. + // + // WebAutomationSessionMac.mm:80 solves this by method-swizzling + // +[NSEvent pressedMouseButtons] to return its tracked state for the + // scope of each event dispatch. We take the same approach but make + // the swap permanent at host init — we never want the system HID + // answer here (the host is a headless subprocess; no real mouse + // would ever be over its window). Set by WebViewHost before each + // mouseDown/mouseUp/mouseMove dispatch. + // + // Bits use DOM MouseEvent.buttons order: bit 0=left, bit 1=right, + // bit 2=middle — the same layout [NSEvent pressedMouseButtons] + // returns natively. + static uint32_t s_trackedButtonsMask; + // NSEventType — the ones we use. enum : unsigned long { LeftMouseDown = 1, diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 3cc9a96ccb15..9a1bf2a033db 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -326,6 +326,13 @@ void WebViewHost::doNativeClick(float x, float y, uint8_t button, uint8_t modifi // handleMouseEvent → mouseEventQueue → XPC. WebContent synthesizes click // from the pair: pointerdown/mousedown/pointerup/mouseup/click all fire, // isTrusted:true, :active CSS applies. + // + // No buttons-bitmap stamping here: click() is down+up at the same + // spot and our callers (click(x,y), click(selector)) don't assert on + // event.buttons — only event.button / event.detail / event.modifiers. + // The low-level mouseDown/Up/Move primitives below DO set + // NSEvent::s_trackedButtonsMask because drag test suites observe + // event.buttons explicitly. switch (button) { case 1: m_webview.rightMouseDown(NSEvent::mouseEvent(NSEvent::RightMouseDown, x, wy, mods, ts, win, clickCount)); @@ -354,12 +361,16 @@ void WebViewHost::doNativeClick(float x, float y, uint8_t button, uint8_t modifi // after WebContent has dispatched every event's JS handlers. // // For mouseDown/mouseUp the button arg picks the NSEventType + the -// right responder selector. buttonsMask is the state AFTER the press/ -// release — not used by NSEvent synthesis (AppKit doesn't encode a -// buttons bitmap; it infers from the sequence), but threaded through -// for parity with CDP and in case we expose a buttons field to page- -// injected scripts later. -bool WebViewHost::mouseDownIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t /*buttonsMask*/) +// right responder selector. buttonsMask is the post-op bitmap for the +// DOM event.buttons field — set into NSEvent::s_trackedButtonsMask +// before dispatch. WebCore reads +[NSEvent pressedMouseButtons] (which +// we swapped in ObjCRuntime::load to return s_trackedButtonsMask) +// synchronously inside [WKWebView mouseDown:]; the value captured +// becomes event.buttons on the DOM event. Per spec, mousedown reports +// buttons WITH the pressing bit set, mouseup reports WITHOUT it; the +// caller (JSWebView::mouseDown/Up) already computed that, we just +// publish it. +bool WebViewHost::mouseDownIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask) { using NSEvent = objc::NSEvent; if (m_inputPending) { @@ -371,6 +382,7 @@ bool WebViewHost::mouseDownIPC(float x, float y, uint8_t button, uint8_t modifie double ts = objc::NSProcessInfo::systemUptime(); long win = m_window.windowNumber(); + NSEvent::s_trackedButtonsMask = buttonsMask; switch (button) { case 1: m_webview.rightMouseDown(NSEvent::mouseEvent(NSEvent::RightMouseDown, x, wy, mods, ts, win, clickCount)); @@ -387,7 +399,7 @@ bool WebViewHost::mouseDownIPC(float x, float y, uint8_t button, uint8_t modifie return true; } -bool WebViewHost::mouseUpIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t /*buttonsMask*/) +bool WebViewHost::mouseUpIPC(float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask) { using NSEvent = objc::NSEvent; if (m_inputPending) { @@ -399,6 +411,7 @@ bool WebViewHost::mouseUpIPC(float x, float y, uint8_t button, uint8_t modifiers double ts = objc::NSProcessInfo::systemUptime(); long win = m_window.windowNumber(); + NSEvent::s_trackedButtonsMask = buttonsMask; switch (button) { case 1: m_webview.rightMouseUp(NSEvent::mouseEvent(NSEvent::RightMouseUp, x, wy, mods, ts, win, clickCount)); @@ -435,6 +448,13 @@ bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint3 long win = m_window.windowNumber(); double heightD = static_cast(m_height); + // Publish the buttons state to +[NSEvent pressedMouseButtons] for + // this entire dispatch — every synthesized move event will see the + // same mask when WebCore's PlatformEventFactoryMac reads it. During + // a mouseMove the button state doesn't change, so one set is enough; + // the value stays stable for all (steps - 1) intermediates + final. + NSEvent::s_trackedButtonsMask = buttonsMask; + // Pick the NSEventType once. No mixed move/drag within a single // mouseMove(); the button state is constant for the duration of the // call (down/up are separate IPC ops that serialize on m_inputPending). From ec2285b739b1f4d00266a730f689e9506827415b Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 28 Apr 2026 10:48:52 +0000 Subject: [PATCH 6/9] webview(WebKit): skip NSEvent dispatch for hover moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag-sequence test timed out on macOS 14/15 aarch64 only: the first view.mouseMove(x, y) call (no button held) uses _simulateMouseMove: which enqueues into mouseEventQueue but WebContent never drains it on that OS/arch combo. The _doAfterProcessingAllPendingMouseEvents: barrier then waits forever and bun:test hits its 90s timeout. Fix: on WebKit, skip the NSEvent dispatch for hover (buttonsMask==0) moves. Parent-side cursor tracking (m_mouseX/Y on JSWebView) already runs, so a following mouseDown fires at the correct position — which is the primary use case (drag automation). DOM :hover / cursor CSS won't update on WebKit until someone wires a CGEvent-based hover (screen-level, following WebAutomationSessionMac's wheel path); left for a future PR. Drag moves (button held) still dispatch normally via mouseDragged: / rightMouseDragged: / otherMouseDragged: — those route through the responder chain and the barrier drains them because the prior mouseDown: already queued onto WebContent. Test update: the drag-sequence test no longer asserts on an initial hover mousemove event (it's not dispatched anymore). Chrome-backend tests are unchanged since CDP's mouseMoved dispatch works for hover at any button state. Confirmed working on macOS 26 aarch64 (full suite passed last run) and the reference darwin-14 aarch64 shard hung specifically at the test's first view.mouseMove(40, 40) call. --- src/runtime/webview/ObjCRuntime.h | 27 +++++++------ src/runtime/webview/WebViewHost.cpp | 61 ++++++++++++++++++----------- test/js/bun/webview/webview.test.ts | 51 ++++++++---------------- 3 files changed, 69 insertions(+), 70 deletions(-) diff --git a/src/runtime/webview/ObjCRuntime.h b/src/runtime/webview/ObjCRuntime.h index 9a37543a2a14..27743a969173 100644 --- a/src/runtime/webview/ObjCRuntime.h +++ b/src/runtime/webview/ObjCRuntime.h @@ -657,19 +657,22 @@ struct WKWebView : Ref { static SEL s_rightMouseUp; static SEL s_otherMouseDown; static SEL s_otherMouseUp; - // Mouse movement without a button held goes through WKWebView's - // _simulateMouseMove: SPI (macOS 13+) — the public mouseMoved: - // responder doesn't route to WebContent unless the window has - // acceptsMouseMovedEvents:YES and a tracking area matches, which - // isn't wired for a hidden headless window. _simulateMouseMove: is - // what Safari's inspector uses for its hover simulation and - // forwards straight to WebViewImpl::mouseMoved — same path as a - // real pointer movement, all JS handlers fire with isTrusted:true. + // Dragged events route through the public responder selectors + // (mouseDragged: / rightMouseDragged: / otherMouseDragged:) — + // WKWebView → WebViewImpl → mouseEventQueue → XPC, and the + // _doAfterProcessingAllPendingMouseEvents: barrier fires normally + // because a button was pressed (the prior mouseDown: already + // queued onto WebContent). // - // Dragged events go through the public selectors (mouseDragged:, - // rightMouseDragged:, otherMouseDragged:) — those DO route through - // the responder chain because a button is held (the mouseDown: that - // started the drag is already in WebContent's event queue). + // Pure hover (no button held) is handled parent-side in + // WebViewHost::mouseMoveIPC by ack-without-dispatch. WKWebView has + // a _simulateMouseMove: SPI (macOS 13+) but it hangs on the + // barrier on macOS 14/15 aarch64 (event enqueues into + // mouseEventQueue but WebContent never drains it — probably the + // headless window's layer tree is ineligible for hover hit-test on + // that OS/arch combo). The SEL stays wired in case we later add a + // CGEvent-based hover (screen-level, like WebAutomationSessionMac's + // wheel path). static SEL s_simulateMouseMove; static SEL s_mouseDragged; static SEL s_rightMouseDragged; diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 9a1bf2a033db..0c163e35d7de 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -443,34 +443,53 @@ bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint3 hostWriter()->sendReplyStr(m_viewId, Reply::Error, "input operation already pending"_s); return true; } + + // Hover path (no button held): the caller intends to reposition the + // cursor for a subsequent mouseDown, not to trigger :hover CSS. We + // intentionally skip the NSEvent dispatch and just Ack — observations: + // + // 1. On macOS 14/15 aarch64 the _simulateMouseMove: SPI enqueues + // into mouseEventQueue but WebContent never drains it (likely + // because the headless window's layer tree is ineligible for + // hover hit-test on this OS/arch combo). The + // _doAfterProcessingAllPendingMouseEvents: barrier then waits + // forever and the test times out. + // 2. Drag handlers in real use look at `pointermove` with + // `event.buttons != 0` — hover moves with buttons=0 are + // semantically a "position the cursor" signal, not drag. + // 3. Parent-side state tracking (m_mouseX/Y on JSWebView) has + // already happened, so the next mouseDown fires at (x, y) + // regardless of whether we dispatched an event here. + // + // If a user needs trusted :hover on WebKit in the future, the fix + // is to post a CGEvent at screen coords (the same path + // WebAutomationSessionMac.mm uses for wheel events) — expensive + // since it moves the real cursor, so deferred until someone asks. + if (!buttonsMask) { + // Sync Ack — no barrier needed because we didn't dispatch. + return false; + } + unsigned long mods = expandModifiers(modifiers); double ts = objc::NSProcessInfo::systemUptime(); long win = m_window.windowNumber(); double heightD = static_cast(m_height); - // Publish the buttons state to +[NSEvent pressedMouseButtons] for - // this entire dispatch — every synthesized move event will see the - // same mask when WebCore's PlatformEventFactoryMac reads it. During - // a mouseMove the button state doesn't change, so one set is enough; - // the value stays stable for all (steps - 1) intermediates + final. + // Publish the buttons state to +[NSEvent pressedMouseButtons] so + // every synthesized drag event gets the correct DOM event.buttons. + // See ObjCRuntime.cpp for the +[NSEvent pressedMouseButtons] swap. NSEvent::s_trackedButtonsMask = buttonsMask; - // Pick the NSEventType once. No mixed move/drag within a single - // mouseMove(); the button state is constant for the duration of the - // call (down/up are separate IPC ops that serialize on m_inputPending). // AppKit's responder chain uses a separate selector per button drag - // (mouseDragged: / rightMouseDragged: / otherMouseDragged:) — switch - // on a tag at each dispatch so we don't heap-allocate a std::function. - enum class MoveKind { Move, - LeftDrag, + // (mouseDragged: / rightMouseDragged: / otherMouseDragged:). Pick + // the lowest-order set button; multi-button drags are rare enough + // that one event per tick is fine. + enum class MoveKind { LeftDrag, RightDrag, OtherDrag }; - unsigned long evtType = NSEvent::MouseMoved; - MoveKind kind = MoveKind::Move; - if (buttonsMask & 0x1) { - evtType = NSEvent::LeftMouseDragged; - kind = MoveKind::LeftDrag; - } else if (buttonsMask & 0x2) { + unsigned long evtType = NSEvent::LeftMouseDragged; + MoveKind kind = MoveKind::LeftDrag; + if (buttonsMask & 0x2) { evtType = NSEvent::RightMouseDragged; kind = MoveKind::RightDrag; } else if (buttonsMask & 0x4) { @@ -488,17 +507,13 @@ bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint3 case MoveKind::OtherDrag: m_webview.otherMouseDragged(e); return; - case MoveKind::Move: - m_webview.simulateMouseMove(e); - return; } }; if (steps < 1) steps = 1; // (steps - 1) intermediate events at fractions i/steps, then the // final event at the target — `steps` events total. clickCount 0 is - // the convention for non-click mouse events (MouseMoved/Dragged have - // no click count). + // the convention for non-click mouse events. for (uint32_t i = 1; i < steps; ++i) { double ix = static_cast(fromX) + (static_cast(x) - static_cast(fromX)) * (static_cast(i) / static_cast(steps)); double iy = static_cast(fromY) + (static_cast(y) - static_cast(fromY)) * (static_cast(i) / static_cast(steps)); diff --git a/test/js/bun/webview/webview.test.ts b/test/js/bun/webview/webview.test.ts index 0771c619ef89..0a2ba63af710 100644 --- a/test/js/bun/webview/webview.test.ts +++ b/test/js/bun/webview/webview.test.ts @@ -610,10 +610,18 @@ it("mouseDown/mouseUp/mouseMove drag sequence fires trusted events", async () => `), ); - // Position → press → drag through intermediates → release. The - // canvas drag pattern from the feature request. WebKit dispatches - // LeftMouseDragged NSEvents during the move because a button is held; - // without the prior mouseDown, it would be plain MouseMoved. + // Position cursor → press → drag through intermediates → release — + // the canvas drag pattern from the issue. WebKit dispatches + // LeftMouseDragged NSEvents for the intermediate moves (because a + // button is held) and _doAfterProcessingAllPendingMouseEvents: + // drains them before each promise resolves. + // + // On WebKit the initial mouseMove (no buttons) is a position-only + // update and does NOT dispatch a DOM mousemove — the + // _simulateMouseMove: SPI hangs on macOS 14/15 aarch64. Tests can + // still assert on the drag semantics, which is what matters for the + // feature. (Chrome fires the hover mousemove normally; see + // webview-chrome.test.ts for the Chrome assertions.) await view.mouseMove(40, 40); await view.mouseDown(); await view.mouseMove(160, 160, { steps: 4 }); @@ -628,14 +636,14 @@ it("mouseDown/mouseUp/mouseMove drag sequence fires trusted events", async () => trusted: boolean; }>; - // Initial mousemove has no buttons pressed. - const firstMove = events.find(e => e.t === "mousemove")!; - expect(firstMove).toEqual({ t: "mousemove", x: 40, y: 40, btn: 0, btns: 0, trusted: true }); + // mousedown fires at the position set by the preceding mouseMove, + // with event.buttons=1 (left held). const down = events.find(e => e.t === "mousedown")!; expect(down).toEqual({ t: "mousedown", x: 40, y: 40, btn: 0, btns: 1, trusted: true }); + // mouseup fires at the final drag position with event.buttons=0. const up = events.find(e => e.t === "mouseup")!; expect(up).toEqual({ t: "mouseup", x: 160, y: 160, btn: 0, btns: 0, trusted: true }); - // Drag intermediates — buttons: 1 (left held). At least one, final hits target. + // Drag intermediates — all with buttons: 1, final at target. const dragMoves = events.filter(e => e.t === "mousemove" && e.btns === 1); expect(dragMoves.length).toBeGreaterThan(0); expect(dragMoves[dragMoves.length - 1]).toEqual({ @@ -648,33 +656,6 @@ it("mouseDown/mouseUp/mouseMove drag sequence fires trusted events", async () => }); }); -it("mouseMove without prior mouseDown is a plain hover (buttons: 0)", async () => { - await using view = new Bun.WebView({ width: 300, height: 300 }); - await view.navigate( - html(` - -
- `), - ); - await view.mouseMove(100, 100); - await view.mouseMove(150, 75); - - const events = JSON.parse(await view.evaluate("JSON.stringify(__ev)")) as Array<{ - x: number; - y: number; - btns: number; - trusted: boolean; - }>; - expect(events.length).toBeGreaterThanOrEqual(2); - for (const e of events) expect(e.btns).toBe(0); - expect(events[events.length - 1]).toEqual({ x: 150, y: 75, btns: 0, trusted: true }); -}); - it("mouseDown + mouseUp at same position synthesizes a click", async () => { await using view = new Bun.WebView({ width: 300, height: 300 }); await view.navigate( From 4d32e6e22471c091f6a21b6f316f99eef3d80185 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 29 Apr 2026 01:32:45 +0000 Subject: [PATCH 7/9] webview: fix multi-button drag selector + stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from review: - WebViewHost::mouseMoveIPC picked RightMouseDragged when buttonsMask had bit 0 AND bit 1 set (left + right held) — the 0x2 check landed first and there was no explicit 0x1 guard before it, so the LeftDrag default only survived when bit 0 was the ONLY bit set, not when it was the lowest set bit. Same for 0x5 (left + middle) falling through to OtherDrag. Adds an explicit `if (buttonsMask & 0x1)` branch that keeps LeftDrag so 0x3/0x5/0x7 all dispatch as left-drag NSEvents, matching the Chrome backend's selector priority in ChromeBackend::mouseMove. - Function header above mouseMoveIPC was stale: said "steps intermediate events + one final" (now correctly "(steps-1) intermediate + 1 final = steps total") and "fire MouseMoved via mouseMoved:" when buttonsMask==0 (actually sync-Acks without dispatching — _simulateMouseMove: hangs the barrier on macOS 14/15 aarch64). - ipc_protocol.h MouseMovePayload comment said "the parent expands before send" (host does the interpolation) and "fires steps+1 NSEvents" (fires exactly `steps`). - ObjCRuntime.cpp comment claimed swizzle failure is "Logged via m_loadError" but the getClassMethod branch is silent — clarified that failure is intentionally silent because every AppKit target has the method. - bun.d.ts JSDoc promised standalone hover "works for :hover styles" unconditionally, but WebKit short-circuits no-button moves (see the hover-path rationale in WebViewHost.cpp). Qualified to say Chrome only; users needing :hover on WebKit know to pick the Chrome backend. --- packages/bun-types/bun.d.ts | 8 ++++++-- src/runtime/webview/ObjCRuntime.cpp | 5 ++++- src/runtime/webview/WebViewHost.cpp | 31 +++++++++++++++++++---------- src/runtime/webview/ipc_protocol.h | 9 +++++---- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 72cafdc00f35..3466ad01f78d 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -8904,8 +8904,12 @@ declare module "bun" { * position the cursor. * * As a standalone hover (no prior {@link mouseDown}), moves cursor - * to `(x, y)` firing plain `mousemove`/`pointermove` — works for - * `:hover` styles and cursor-CSS assertions. + * to `(x, y)`. On the Chrome backend this fires plain + * `mousemove`/`pointermove` events, so `:hover` styles apply and + * cursor-CSS assertions work. On the WebKit backend (macOS + * default) it currently only updates the internal cursor position + * without dispatching a DOM event — use the Chrome backend if you + * need `:hover` or `mousemove` assertions without a button held. * * @example * ```ts diff --git a/src/runtime/webview/ObjCRuntime.cpp b/src/runtime/webview/ObjCRuntime.cpp index 920255c30d3a..8c3df022839d 100644 --- a/src/runtime/webview/ObjCRuntime.cpp +++ b/src/runtime/webview/ObjCRuntime.cpp @@ -459,7 +459,10 @@ bool ObjCRuntime::load() // // Non-fatal if it fails: event.buttons falls back to 0, drag tests // that rely on it break, but the rest of the input API still works. - // Logged via m_loadError but we continue. + // Failure is silent (no m_loadError) because every AppKit class we + // care about has this class method — if it's missing we're on a + // compatibility build where a log line wouldn't be actionable + // anyway. auto pressedMouseButtonsImpl = +[](id, SEL) -> unsigned long { return NSEvent::s_trackedButtonsMask; }; diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index 0c163e35d7de..f39fe8fc0d65 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -428,14 +428,18 @@ bool WebViewHost::mouseUpIPC(float x, float y, uint8_t button, uint8_t modifiers return true; } -// mouseMove: dispatch `steps` intermediate events + one final. When -// buttonsMask==0 we fire MouseMoved via mouseMoved:. With a button held -// it's mouseDragged (or right/other variant) — AppKit's responder -// chain uses a separate selector. If multiple buttons are held we pick -// the lowest-order set bit for the drag selector; WebKit processes a -// single drag event per main loop tick, so one NSEvent per intermediate -// coord is what the handlers see. Each event still lands in -// mouseEventQueue and the final barrier drains them all. +// mouseMove: fire `steps` NSEvents total = (steps - 1) intermediate +// drag events interpolated from (fromX,fromY) → (x,y), then one final +// event at the target. When buttonsMask==0 we sync-Ack without +// dispatching any NSEvent (see the hover-path rationale below — the +// _simulateMouseMove: SPI hangs the barrier on macOS 14/15 aarch64). +// With a button held it's mouseDragged: (or right/other variant) — +// AppKit's responder chain uses a separate selector per button. If +// multiple buttons are held we pick the lowest-order set bit (left > +// right > middle) for the drag selector. WebKit processes a single +// drag event per main loop tick, so one NSEvent per intermediate coord +// is what the handlers see. Each event lands in mouseEventQueue and +// the final barrier drains them all. bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers) { using NSEvent = objc::NSEvent; @@ -482,14 +486,19 @@ bool WebViewHost::mouseMoveIPC(float fromX, float fromY, float x, float y, uint3 // AppKit's responder chain uses a separate selector per button drag // (mouseDragged: / rightMouseDragged: / otherMouseDragged:). Pick - // the lowest-order set button; multi-button drags are rare enough - // that one event per tick is fine. + // the lowest-order set button (left wins over right wins over + // middle); multi-button drags are rare enough that one event per + // tick is fine. Matches the Chrome backend's priority in + // ChromeBackend::mouseMove. enum class MoveKind { LeftDrag, RightDrag, OtherDrag }; unsigned long evtType = NSEvent::LeftMouseDragged; MoveKind kind = MoveKind::LeftDrag; - if (buttonsMask & 0x2) { + if (buttonsMask & 0x1) { + // Left — keep LeftDrag default (explicit so 0x3, 0x5, 0x7 all + // pick left instead of falling through to right/middle). + } else if (buttonsMask & 0x2) { evtType = NSEvent::RightMouseDragged; kind = MoveKind::RightDrag; } else if (buttonsMask & 0x4) { diff --git a/src/runtime/webview/ipc_protocol.h b/src/runtime/webview/ipc_protocol.h index f791c0e92c28..7cdce249114c 100644 --- a/src/runtime/webview/ipc_protocol.h +++ b/src/runtime/webview/ipc_protocol.h @@ -155,15 +155,16 @@ struct MouseUpPayload { uint8_t buttonsMask; // bitmask of all pressed buttons AFTER this up }; -// For N-step moves the parent expands before send: (fromX,fromY) to (x,y) -// interpolated in `steps` segments. The host fires steps+1 NSEvents total -// (one per intermediate coord + the final) and Acks after the drain barrier. +// For N-step moves the parent sends (fromX,fromY) → (x,y) plus a `steps` +// count; the host interpolates in `steps` segments and fires `steps` +// NSEvents total ((steps - 1) intermediates + the final) and Acks after +// the drain barrier. struct MouseMovePayload { float fromX; // interpolation start float fromY; float x; // target float y; - uint32_t steps; // number of intermediate points between fromX/Y and x/y (>=1) + uint32_t steps; // total number of move events to dispatch (>=1) uint8_t buttonsMask; // pressed-button bitmask (determines move vs dragged event type) uint8_t modifiers; }; From 07315be355ef9eac15e8fd890de2ad9260b899fc Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 29 Apr 2026 02:13:02 +0000 Subject: [PATCH 8/9] webview: drop stale dragenter/dragover claim from ChromeBackend header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same stale wording swept out of bun.d.ts in c120e99 and WebViewHost.cpp / ipc_protocol.h in 6b13ba6 survived in ChromeBackend.cpp's mouseMove function header. CDP `Input.dispatchMouseEvent type:"mouseMoved"` with a non-zero `buttons` field fires `mousemove`/`pointermove` only — HTML5 DnD events require a `draggable` element starting the drag page-side (or `Input.dispatchDragEvent`, not used). Comment-only. --- src/runtime/webview/ChromeBackend.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index 19370137015c..b9de36f91c30 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -1562,9 +1562,11 @@ JSPromise* mouseUp(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t // (buttonsMask==0) the event is a plain mouseMoved with button:"none". // When dragging (buttonsMask != 0) the event type is still "mouseMoved" // — CDP doesn't have a separate "mouseDragged" — but the non-zero -// `buttons` field tells Chrome a drag is in progress. Chrome synthesizes -// the right pointermove/mousemove + dragenter/dragover dispatch on the -// page side. +// `buttons` field tells Chrome a drag is in progress, so page-side +// `mousemove` / `pointermove` handlers see `e.buttons !== 0`. This is +// NOT the HTML5 Drag and Drop API — no `dragenter`/`dragover` events +// fire unless a `draggable` element initiates them page-side (via the +// separate `Input.dispatchDragEvent` CDP method, not used here). JSPromise* mouseMove(JSGlobalObject* g, JSWebView* view, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers) { auto& t = transport(); From c9237abf5d912d815b9d254e864e2607e1001ac1 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 29 Apr 2026 03:06:02 +0000 Subject: [PATCH 9/9] webview: stamp pressedMouseButtons mask around doNativeClick events too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-wide +[NSEvent pressedMouseButtons] swizzle (from ObjCRuntime.cpp) lets synthesized NSEvents report the correct DOM event.buttons. The low-level mouseDown/Up/Move primitives stamp the static before dispatch; doNativeClick (backing view.click(x,y) / view.click(selector)) did not, so a prior unbalanced mouseDown on this view or a sibling view would leak its mask into click()'s DOM events — mousedown/mouseup would report whatever the static was last set to. Fix: stamp the pressing bit before each mouseDown and clear before each mouseUp in doNativeClick, matching the primitives. Bonus: click()'s event.buttons is now spec-correct (mousedown includes the pressing bit, mouseup excludes it) instead of whatever the real HID state was pre-PR (always 0 in a headless host). --- src/runtime/webview/WebViewHost.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/runtime/webview/WebViewHost.cpp b/src/runtime/webview/WebViewHost.cpp index f39fe8fc0d65..c86c9ed8a99f 100644 --- a/src/runtime/webview/WebViewHost.cpp +++ b/src/runtime/webview/WebViewHost.cpp @@ -327,23 +327,32 @@ void WebViewHost::doNativeClick(float x, float y, uint8_t button, uint8_t modifi // from the pair: pointerdown/mousedown/pointerup/mouseup/click all fire, // isTrusted:true, :active CSS applies. // - // No buttons-bitmap stamping here: click() is down+up at the same - // spot and our callers (click(x,y), click(selector)) don't assert on - // event.buttons — only event.button / event.detail / event.modifiers. - // The low-level mouseDown/Up/Move primitives below DO set - // NSEvent::s_trackedButtonsMask because drag test suites observe - // event.buttons explicitly. + // Stamp NSEvent::s_trackedButtonsMask around each event so WebCore's + // PlatformEventFactoryMac reads the correct DOM event.buttons for + // this click (pressing bit set for mousedown, cleared for mouseup). + // The static is process-wide — without stamping here, a prior + // unbalanced mouseDown on this view or a sibling view would leak + // its mask into this click's DOM events. Setting it explicitly + // also makes click()'s event.buttons spec-correct: mousedown sees + // the pressing bit, mouseup sees 0. + uint8_t pressBit = 1u << button; switch (button) { case 1: + NSEvent::s_trackedButtonsMask = pressBit; m_webview.rightMouseDown(NSEvent::mouseEvent(NSEvent::RightMouseDown, x, wy, mods, ts, win, clickCount)); + NSEvent::s_trackedButtonsMask = 0; m_webview.rightMouseUp(NSEvent::mouseEvent(NSEvent::RightMouseUp, x, wy, mods, ts, win, clickCount)); break; case 2: + NSEvent::s_trackedButtonsMask = pressBit; m_webview.otherMouseDown(NSEvent::mouseEvent(NSEvent::OtherMouseDown, x, wy, mods, ts, win, clickCount)); + NSEvent::s_trackedButtonsMask = 0; m_webview.otherMouseUp(NSEvent::mouseEvent(NSEvent::OtherMouseUp, x, wy, mods, ts, win, clickCount)); break; default: + NSEvent::s_trackedButtonsMask = pressBit; m_webview.mouseDown(NSEvent::mouseEvent(NSEvent::LeftMouseDown, x, wy, mods, ts, win, clickCount)); + NSEvent::s_trackedButtonsMask = 0; m_webview.mouseUp(NSEvent::mouseEvent(NSEvent::LeftMouseUp, x, wy, mods, ts, win, clickCount)); }