diff --git a/electron/native/mouseclamp/clamp_decision.h b/electron/native/mouseclamp/clamp_decision.h new file mode 100644 index 00000000..4c81fb43 --- /dev/null +++ b/electron/native/mouseclamp/clamp_decision.h @@ -0,0 +1,44 @@ +// Pure geometry for the off-window mouse-release clamp: no AppKit, no state, no I/O, +// so the SAME code that ships in production (mouseclamp.mm includes this) can be +// hammered by a standalone property test (clamp_decision_test.c). Keeping the +// decision here, not inline in the .mm, is the whole point: the misfire risk lives +// in this arithmetic, so this arithmetic is what the machine must prove. +// +// Contract (mirrors NSPointInRect: min-edge inclusive, max-edge exclusive): +// release INSIDE the window -> {clamp=false}, location returned UNCHANGED. +// release OUTSIDE the window -> {clamp=true}, location snapped strictly inside +// the content rect (so Chromium's hit-test can +// never return null, which is the crash). +#ifndef MOUSECLAMP_DECISION_H +#define MOUSECLAMP_DECISION_H + +#include +#include + +typedef struct { + bool clamp; // replace the event with one at (x,y)? false => leave it alone + double x, y; // snapped location; meaningful only when clamp == true +} ClampDecision; + +// p = release location in window coords +// w = window size (winW, winH) +// c = content rect in window coords (cx, cy, cw, ch) +static inline ClampDecision clamp_decision(double px, double py, + double winW, double winH, + double cx, double cy, + double cw, double ch) { + ClampDecision d; + // inside the window (titlebar included) -> untouched, byte-for-byte the same event + if (px >= 0.0 && px < winW && py >= 0.0 && py < winH) { + d.clamp = false; + d.x = px; + d.y = py; + return d; + } + d.clamp = true; + d.x = fmin(fmax(px, cx + 1.0), cx + cw - 1.0); + d.y = fmin(fmax(py, cy + 1.0), cy + ch - 1.0); + return d; +} + +#endif // MOUSECLAMP_DECISION_H diff --git a/electron/native/mouseclamp/clamp_decision_test.c b/electron/native/mouseclamp/clamp_decision_test.c new file mode 100644 index 00000000..971b3060 --- /dev/null +++ b/electron/native/mouseclamp/clamp_decision_test.c @@ -0,0 +1,73 @@ +// Property test for clamp_decision() (the only misfire-prone part of the fix). +// Compiles with plain clang, no node/electron/AppKit, so it runs anywhere fast. +// Hammers millions of random window/content/point combinations and asserts the +// three invariants the fix's safety rests on. Run via ./run-tests.sh. +#include "clamp_decision.h" +#include +#include + +static unsigned long g_seed = 88172645463325252ULL; +static double frnd(double lo, double hi) { // xorshift -> [lo,hi) + g_seed ^= g_seed << 13; g_seed ^= g_seed >> 7; g_seed ^= g_seed << 17; + return lo + (hi - lo) * ((double)(g_seed >> 11) / (double)(1ULL << 53)); +} + +static long fails = 0; +static void check(int cond, const char *msg, + double px, double py, double w, double h, + double cx, double cy, double cw, double ch, ClampDecision d) { + if (cond) return; + if (fails < 12) + fprintf(stderr, + "FAIL %s: p=(%.2f,%.2f) win=(%.2f,%.2f) content=(%.2f,%.2f,%.2f,%.2f) " + "=> clamp=%d (%.2f,%.2f)\n", + msg, px, py, w, h, cx, cy, cw, ch, d.clamp, d.x, d.y); + fails++; +} + +static void run(double px, double py, double w, double h, + double cx, double cy, double cw, double ch) { + ClampDecision d = clamp_decision(px, py, w, h, cx, cy, cw, ch); + int inside = (px >= 0.0 && px < w && py >= 0.0 && py < h); + if (inside) { + // INVARIANT 1 (no misfire on normal clicks): in-window release is untouched. + check(!d.clamp, "inside-must-not-clamp", px, py, w, h, cx, cy, cw, ch, d); + check(d.x == px && d.y == py, "inside-must-be-identical", px, py, w, h, cx, cy, cw, ch, d); + } else { + // INVARIANT 2 (crash-safe): off-window release is clamped strictly inside the + // content rect, so Chromium's hit-test can never return null. + check(d.clamp, "outside-must-clamp", px, py, w, h, cx, cy, cw, ch, d); + check(d.x >= cx + 1.0 && d.x <= cx + cw - 1.0, "x-in-content", px, py, w, h, cx, cy, cw, ch, d); + check(d.y >= cy + 1.0 && d.y <= cy + ch - 1.0, "y-in-content", px, py, w, h, cx, cy, cw, ch, d); + } + // INVARIANT 3: never produces NaN/inf. + check(d.x == d.x && d.y == d.y, "no-nan", px, py, w, h, cx, cy, cw, ch, d); +} + +int main(void) { + // Hand-picked edges: exact corners, the off-by-one boundaries, second-display + // negatives, the literal 650->499 case I verified on real events. + run(0, 0, 500, 350, 0, 0, 500, 350); // top-left corner (inside) + run(499, 349, 500, 350, 0, 0, 500, 350); // last inside pixel + run(500, 175, 500, 350, 0, 0, 500, 350); // exactly on right edge (outside) + run(650, 175, 500, 350, 0, 0, 500, 350); // the verified real case -> expect x=499 + run(-300, -900, 500, 350, 0, 0, 500, 350); // second display up-left (negatives) + run(99999, 99999, 500, 350, 0, 0, 500, 350); // far off-window + + const long N = 5000000; + for (long i = 0; i < N; i++) { + double w = frnd(80, 4000), h = frnd(80, 4000); + double cw = frnd(50, w), ch = frnd(50, h); + double cx = frnd(0, w - cw), cy = frnd(0, h - ch); + double px = frnd(-3000, w + 3000), py = frnd(-3000, h + 3000); + run(px, py, w, h, cx, cy, cw, ch); + } + + if (fails) { + fprintf(stderr, "\nclamp_decision: %ld FAILURES across %ld cases\n", fails, N + 6); + return 1; + } + printf("clamp_decision: ALL %ld cases pass (invariants: in-window untouched, " + "off-window snapped strictly inside content, no NaN)\n", N + 6); + return 0; +} diff --git a/electron/native/mouseclamp/mouseclamp.mm b/electron/native/mouseclamp/mouseclamp.mm index ff088d53..ff1b5f9d 100644 --- a/electron/native/mouseclamp/mouseclamp.mm +++ b/electron/native/mouseclamp/mouseclamp.mm @@ -12,7 +12,7 @@ #include #import -#include +#include "clamp_decision.h" static id gMonitor = nil; @@ -28,15 +28,16 @@ static NSEvent *ClampOffWindowRelease(NSEvent *event) { 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); + // all the misfire-prone arithmetic lives in clamp_decision() so the property + // test exercises the exact code that ships + ClampDecision d = clamp_decision(p.x, p.y, ws.width, ws.height, + cb.origin.x, cb.origin.y, + cb.size.width, cb.size.height); + if (!d.clamp) return event; NSEvent *clamped = [NSEvent mouseEventWithType:t - location:NSMakePoint(x, y) + location:NSMakePoint(d.x, d.y) modifierFlags:[event modifierFlags] timestamp:[event timestamp] windowNumber:[event windowNumber] diff --git a/electron/native/mouseclamp/run-tests.sh b/electron/native/mouseclamp/run-tests.sh new file mode 100755 index 00000000..8f4c30e0 --- /dev/null +++ b/electron/native/mouseclamp/run-tests.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Compile + run the clamp_decision property test (plain clang, no electron deps). +set -euo pipefail +if ! command -v clang >/dev/null 2>&1; then + echo "clang not found; skipping clamp_decision property test"; exit 0 +fi +HERE="$(cd "$(dirname "$0")" && pwd)" +BIN="$(mktemp -t clamp_decision_test)" +trap 'rm -f "$BIN"' EXIT +clang -O2 -Wall -Wextra -Werror -o "$BIN" "$HERE/clamp_decision_test.c" +"$BIN" diff --git a/electron/package.json b/electron/package.json index 7419c4d7..cd402307 100644 --- a/electron/package.json +++ b/electron/package.json @@ -14,7 +14,8 @@ "dist:win": "electron-builder --win --x64 --publish never", "dist:win:publish": "electron-builder --win --x64 --publish always", "dist:all": "electron-builder --mac --win --linux", - "test": "node --test affiliateTracking.test.js" + "test": "node --test affiliateTracking.test.js", + "test:mouseclamp": "bash native/mouseclamp/run-tests.sh" }, "dependencies": { "electron-updater": "6.8.3",