Studio: route clicks and scrolls to the browser page on macOS 27 (#14216)

Fixes https://github.com/unslothai/unsloth/issues/13629

## Problem

On macOS 27, web pages in the Desktop browser panel ignore clicks and
scrolling. Hover still works, so a button looks like it reacts, but
nothing happens.

On macOS the page sits under the app's transparent webview (#13122). The
app's webview returns nil from `hitTest:` over the page, so AppKit gives
the click to the page below it. macOS 27 no longer asks `hitTest:` for a
left click, a scroll or a cursor update. `NSWindow` now calls `-[NSView
hitTestForContext:]`, a new method that WKWebView answers on its own
without calling `hitTest:`. The app's webview claims every click and
scroll over the page.

## Fix

`browser_layer.rs` also replaces `hitTestForContext:` when the webview
class has it (macOS 27 and later), with the same rule as `hitTest:`: nil
over the page, unless panel UI covers that point. The context's point is
in window coordinates, not the superview's. If the context ever lacks a
`point`, the call goes to the original method unchanged.

Before macOS 27 the method does not exist, so nothing is replaced and
nothing changes.

## Testing

A standalone AppKit probe copies `browser_layer.rs`: a transparent app
WKWebView over a page WKWebView of the same class, with `hitTest:`
replaced after both exist. It sends real HID mouse and scroll events
through `CGEventPost`. GitHub `macos-26` (26.6.2) and `xcode-27` (macOS
27.0.1) runners:

| macOS | code | click on the page | scroll on the page | click on app
UI next to the page |
|---|---|---|---|---|
| 26.6.2 | before | page | page scrolls 1000 px | app |
| 26.6.2 | after | page | page scrolls 1000 px | app |
| 27.0.1 | before | **app** (page gets nothing) | **app** (page stays at
0) | app |
| 27.0.1 | after | page | page scrolls 1000 px | app |
| 27.0.1 | after, point read as superview coordinates (control) | page |
page scrolls 1000 px | **lost** (neither view) |

- On macOS 27 the window's left-click path is `-[NSWindow
_handleLeftMouseDownEvent:]`. It calls `hitTestForContext:` with an
`NSHitTestContext` for cursor updates (event type 17), left mouse downs
(1) and scroll wheels (22). The context's `point` is the window point,
passed unchanged down the view tree.
- The last column clicks the app 20 px left of the page's edge, with the
app's webview offset 37 px in the window. Read as window coordinates,
the click stays with the app. Read as superview coordinates, the app
gives it up, the page refuses it, and the click is lost.
- `cargo check --all-targets` (debug and `--release`) of
`studio/src-tauri` with this change passes on `macos-26` and `xcode-27`.
This commit is contained in:
Daniel Han 2026-10-19 05:36:17 -07:00 • committed by GitHub
parent 8de2de25bb
commit 88afa84b87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2,8 +2,9 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
//! macOS: browser pages sit under the app's webview, which is transparent over them, so panel
//! overlays draw on top of the live page. The app's webview returns nil from `hitTest:` and
//! `_hitTest:dragTypes:` over the page unless panel UI covers that point (`browser_view_input`).
//! overlays draw on top of the live page. The app's webview returns nil from `hitTest:`,
//! `_hitTest:dragTypes:` and `hitTestForContext:` over the page unless panel UI covers that point
//! (`browser_view_input`).
use crate::browser_webview::ViewBounds;
use block2::RcBlock;
@ -25,12 +26,17 @@ type DragHitTest = unsafe extern "C-unwind" fn(
*mut NSPoint,
*mut AnyObject,
) -> *mut AnyObject;
/// macOS 27+: clicks, scrolls and cursor updates hit-test through `hitTestForContext:`, which
/// WKWebView answers itself without calling `hitTest:`.
type ContextHitTest =
unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> *mut AnyObject;
/// The app's webview, set once on the main thread; it lives as long as the app.
static MAIN: AtomicPtr<AnyObject> = AtomicPtr::new(null_mut());
static INSTALLING: AtomicBool = AtomicBool::new(false);
static HIT_TEST: OnceLock<HitTest> = OnceLock::new();
static DRAG_HIT_TEST: OnceLock<DragHitTest> = OnceLock::new();
static CONTEXT_HIT_TEST: OnceLock<ContextHitTest> = OnceLock::new();
/// Which webview takes mouse moves (`APP` or `PAGE`, 0 before the first). WebKit's tracking
/// areas hand every move in the window to both views whatever is on top, so both would set the
/// cursor and hover in turn, a flicker over links, text and annotate.
@ -170,6 +176,8 @@ unsafe fn install_on(view: *mut AnyObject) {
log::warn!("browser pages stay over the panel: the webview class can't be routed");
return;
};
// Absent before macOS 27.
let context_hit_test = class.instance_method(sel!(hitTestForContext:));
// Safety: same signatures; the originals are stored before the swap can call them.
unsafe {
let _ = HIT_TEST.set(std::mem::transmute::<Imp, HitTest>(
@ -191,6 +199,17 @@ unsafe fn install_on(view: *mut AnyObject) {
std::mem::transmute::<DragHitTest, Imp>(routed_drag_hit_test),
objc2::ffi::method_getTypeEncoding(drag_hit_test),
);
if let Some(context_hit_test) = context_hit_test {
let _ = CONTEXT_HIT_TEST.set(std::mem::transmute::<Imp, ContextHitTest>(
context_hit_test.implementation(),
));
objc2::ffi::class_replaceMethod(
class,
sel!(hitTestForContext:),
std::mem::transmute::<ContextHitTest, Imp>(routed_context_hit_test),
objc2::ffi::method_getTypeEncoding(context_hit_test),
);
}
// Private KVC key, as used by wry's `transparent` option.
let main = &*(view as *const NSView);
main.setValue_forKey(
@ -295,6 +314,30 @@ unsafe extern "C-unwind" fn routed_hit_test(
unsafe { original(this, cmd, point) }
}
unsafe extern "C-unwind" fn routed_context_hit_test(
this: *mut AnyObject,
cmd: Sel,
context: *mut AnyObject,
) -> *mut AnyObject {
if let Some(main) = main_view().filter(|main| std::ptr::eq(*main as *const NSView, this.cast()))
{
// Safety: AppKit's NSHitTestContext, on the main thread. Its point is in window
// coordinates, passed down unchanged (unlike `hitTest:`'s superview coordinates).
if let Some(context) = unsafe { context.as_ref() } {
let has_point: bool = unsafe { msg_send![context, respondsToSelector: sel!(point)] };
if has_point {
let at: NSPoint = unsafe { msg_send![context, point] };
if over_page(main, main.convertPoint_fromView(at, None)) {
return null_mut();
}
}
}
}
let original = CONTEXT_HIT_TEST.get().expect("installed before routing");
// Safety: AppKit's arguments, forwarded to the original implementation.
unsafe { original(this, cmd, context) }
}
/// Files dragged over the page drop on it, as before it sat under the app's webview.
unsafe extern "C-unwind" fn routed_drag_hit_test(
this: *mut AnyObject,