[eric] macos: native mouse-clamp addon to dodge the off-window mouse-release RootView::UpdateCursor crash

This commit is contained in:
ciregenz
2026-06-13 21:29:03 -07:00
parent ba30822b5a
commit d9481eb4b6
8 changed files with 170 additions and 0 deletions
+24
View File
@@ -1673,6 +1673,27 @@ function killBackend() {
}
}
// macOS only: dodge the Chromium RootView::UpdateCursor null-deref (a browser-process
// SIGSEGV when the mouse is released OUTSIDE the window mid-drag, easy with a second
// display) by snapping off-window releases to the window edge before Chromium hit-tests
// them. The fault is upstream of our renderer so JS can't catch it; this native addon
// sits on an AppKit local event monitor. Fail-open: any miss leaves behavior as today.
function installMacMouseClamp() {
if (process.platform !== 'darwin') return;
try {
const nodePath = isPackaged
? path.join(process.resourcesPath, 'mouseclamp', 'mouseclamp.node')
: path.join(__dirname, 'build-staging', 'mouseclamp', process.arch, 'mouseclamp.node');
if (!fs.existsSync(nodePath)) {
console.log('[mouseclamp] addon not present, skipping:', nodePath);
return;
}
console.log('[mouseclamp] install =>', require(nodePath).install());
} catch (e) {
console.log('[mouseclamp] install failed (continuing):', e && e.message);
}
}
app.whenReady().then(async () => {
// We made it here, so any prior update swap finished. Drop a stale updating.lock
// (the watchdog never deletes it) so a real crash later isn't silently swallowed.
@@ -1682,6 +1703,9 @@ app.whenReady().then(async () => {
// watchdog itself prevent false-positive relaunches.
spawnCrashWatchdog();
// Off-window mouse-release crash dodge (macOS). Safe to call before windows exist.
installMacMouseClamp();
// Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
// in argv; macOS fires open-url AFTER whenReady which we handle above)
// route through forwardDeepLinkToRenderer so the URL gets stashed under
+1
View File
@@ -0,0 +1 @@
build/
+14
View File
@@ -0,0 +1,14 @@
{
"targets": [
{
"target_name": "mouseclamp",
"sources": ["mouseclamp.mm"],
"xcode_settings": {
"CLANG_ENABLE_OBJC_ARC": "NO",
"OTHER_CFLAGS": ["-fobjc-exceptions"]
},
"libraries": ["-framework Cocoa"],
"conditions": [["OS!=\"mac\"", {"type": "none"}]]
}
]
}
+80
View File
@@ -0,0 +1,80 @@
// Neutralizes a Chromium browser-process crash: releasing the mouse OUTSIDE the
// window during a drag (trivial with a second display) makes RootView::UpdateCursor
// deref a null view (GetEventHandlerForPoint returns null for an off-widget point,
// root_view.cc:852) and SIGSEGVs the whole app. We can't catch it in JS, it's
// upstream of our renderer, so we sit on a supported AppKit local event monitor and
// snap any off-window mouse-UP to the window edge before Chromium hit-tests it; the
// lookup then always finds a view, so the null deref is unreachable. macOS-only.
//
// Fail-open in both directions: anything unexpected falls through to the original
// event, and we only touch releases that land fully outside the window, so the
// worst case is "behaves exactly like today".
#include <node_api.h>
#import <Cocoa/Cocoa.h>
#include <math.h>
static id gMonitor = nil;
static NSEvent *ClampOffWindowRelease(NSEvent *event) {
@try {
NSEventType t = [event type];
if (t != NSEventTypeLeftMouseUp && t != NSEventTypeRightMouseUp &&
t != NSEventTypeOtherMouseUp) {
return event;
}
NSWindow *win = [event window];
NSView *content = [win contentView];
if (!content) return event;
NSPoint p = [event locationInWindow];
NSSize ws = [win frame].size;
// act only when the release is truly off the window (the crash case); any
// release inside the window, titlebar included, is left exactly as-is
if (NSPointInRect(p, NSMakeRect(0.0, 0.0, ws.width, ws.height))) return event;
NSRect cb = [content frame];
CGFloat x = fmin(fmax(p.x, NSMinX(cb) + 1.0), NSMaxX(cb) - 1.0);
CGFloat y = fmin(fmax(p.y, NSMinY(cb) + 1.0), NSMaxY(cb) - 1.0);
NSEvent *clamped =
[NSEvent mouseEventWithType:t
location:NSMakePoint(x, y)
modifierFlags:[event modifierFlags]
timestamp:[event timestamp]
windowNumber:[event windowNumber]
context:nil
eventNumber:[event eventNumber]
clickCount:[event clickCount]
pressure:[event pressure]];
return clamped ? clamped : event;
} @catch (...) {
return event;
}
}
static napi_value Install(napi_env env, napi_callback_info info) {
bool ok = false;
@autoreleasepool {
if (gMonitor == nil) {
NSEventMask mask = NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp |
NSEventMaskOtherMouseUp;
gMonitor = [[NSEvent addLocalMonitorForEventsMatchingMask:mask
handler:^NSEvent *(NSEvent *e) {
return ClampOffWindowRelease(e);
}] retain];
ok = (gMonitor != nil);
} else {
ok = true;
}
}
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
static napi_value Init(napi_env env, napi_value exports) {
napi_value fn;
napi_create_function(env, "install", NAPI_AUTO_LENGTH, Install, NULL, &fn);
napi_set_named_property(env, exports, "install", fn);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
+7
View File
@@ -0,0 +1,7 @@
{
"name": "mouseclamp",
"version": "1.0.0",
"private": true,
"description": "macOS-only native addon: snaps off-window mouse releases to the window edge to dodge a Chromium RootView::UpdateCursor null-deref. See mouseclamp.mm.",
"gypfile": true
}
+7
View File
@@ -148,6 +148,13 @@
"**/*"
]
},
{
"from": "build-staging/mouseclamp/${arch}",
"to": "mouseclamp",
"filter": [
"**/*"
]
},
{
"from": "build-staging/uv-bin/${arch}",
"to": "backend/uv-bin",
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Compile the macOS mouse-clamp native addon for one arch and stage it where
# electron-builder's extraResources picks it up (build-staging/mouseclamp/<arch>).
# See electron/native/mouseclamp/mouseclamp.mm for what it fixes.
set -euo pipefail
ARCH="${1:?usage: build-mouseclamp.sh <arm64|x64>}"
ELECTRON_TARGET="42.0.0"
HERE="$(cd "$(dirname "$0")/.." && pwd)" # electron/
SRC="$HERE/native/mouseclamp"
OUT="$HERE/build-staging/mouseclamp/$ARCH"
NODE_GYP="$HERE/node_modules/.bin/node-gyp"
[[ -x "$NODE_GYP" ]] || NODE_GYP="npx --yes node-gyp" # transitive dep usually, npx if not
echo "[mouseclamp] building for arch=$ARCH (electron $ELECTRON_TARGET)"
cd "$SRC"
rm -rf build
$NODE_GYP rebuild \
--target="$ELECTRON_TARGET" \
--arch="$ARCH" \
--dist-url=https://electronjs.org/headers
mkdir -p "$OUT"
cp "build/Release/mouseclamp.node" "$OUT/mouseclamp.node"
echo "[mouseclamp] staged -> $OUT/mouseclamp.node"
file "$OUT/mouseclamp.node"
+10
View File
@@ -459,6 +459,16 @@ cd "$PROJECT_ROOT/electron"
# npm ci: lockfile-exact, no drift. See frontend note above.
npm ci
# macOS mouse-clamp native addon: compile both arches into build-staging/mouseclamp/<arch>
# so extraResources (mouseclamp/${arch}) is populated whichever target gets packed.
# Cheap (~2s each); fails the build loudly if a slice can't compile rather than
# silently shipping the crash. macOS-only.
if [[ "$(uname)" == "Darwin" ]]; then
echo "Building mouse-clamp native addon (arm64 + x64)..."
bash scripts/build-mouseclamp.sh arm64
bash scripts/build-mouseclamp.sh x64
fi
# Node's default ~4 GB heap OOMs while codesign'ing the .app on dual-arch
# publish runs (the .app is ~4.8 GB and electron-builder walks every file
# to hash + sign, holding paths + metadata in memory). Bump the old-space