diff --git a/electron/main.js b/electron/main.js index 00c88c19..4357f909 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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 diff --git a/electron/native/mouseclamp/.gitignore b/electron/native/mouseclamp/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/electron/native/mouseclamp/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/electron/native/mouseclamp/binding.gyp b/electron/native/mouseclamp/binding.gyp new file mode 100644 index 00000000..92104859 --- /dev/null +++ b/electron/native/mouseclamp/binding.gyp @@ -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"}]] + } + ] +} diff --git a/electron/native/mouseclamp/mouseclamp.mm b/electron/native/mouseclamp/mouseclamp.mm new file mode 100644 index 00000000..ff088d53 --- /dev/null +++ b/electron/native/mouseclamp/mouseclamp.mm @@ -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 +#import +#include + +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) diff --git a/electron/native/mouseclamp/package.json b/electron/native/mouseclamp/package.json new file mode 100644 index 00000000..8aae7d8a --- /dev/null +++ b/electron/native/mouseclamp/package.json @@ -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 +} diff --git a/electron/package.json b/electron/package.json index 320d5748..7419c4d7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -148,6 +148,13 @@ "**/*" ] }, + { + "from": "build-staging/mouseclamp/${arch}", + "to": "mouseclamp", + "filter": [ + "**/*" + ] + }, { "from": "build-staging/uv-bin/${arch}", "to": "backend/uv-bin", diff --git a/electron/scripts/build-mouseclamp.sh b/electron/scripts/build-mouseclamp.sh new file mode 100755 index 00000000..f05b0379 --- /dev/null +++ b/electron/scripts/build-mouseclamp.sh @@ -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/). +# See electron/native/mouseclamp/mouseclamp.mm for what it fixes. +set -euo pipefail + +ARCH="${1:?usage: build-mouseclamp.sh }" +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" diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 2a5d92fc..cb51c573 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -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/ +# 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