Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions electron/ipc/cursor/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,21 @@ vi.mock("electron", () => ({
},
}));

import { repairBundledUiohookBinaryForCurrentArch } from "./interaction";
import {
repairBundledUiohookBinaryForCurrentArch,
shouldStartGlobalInteractionHook,
} from "./interaction";

describe("shouldStartGlobalInteractionHook", () => {
it("does not start the synchronous uiohook event tap on macOS", () => {
expect(shouldStartGlobalInteractionHook("darwin")).toBe(false);
});

it("keeps global interaction capture enabled on Windows and Linux", () => {
expect(shouldStartGlobalInteractionHook("win32")).toBe(true);
expect(shouldStartGlobalInteractionHook("linux")).toBe(true);
});
});

describe("repairBundledUiohookBinaryForCurrentArch", () => {
const tempRoots: string[] = [];
Expand Down Expand Up @@ -68,4 +82,4 @@ describe("repairBundledUiohookBinaryForCurrentArch", () => {
expect(repaired).toBe(false);
expect(await fs.readFile(buildPath, "utf8")).toBe("existing-build");
});
});
});
132 changes: 75 additions & 57 deletions electron/ipc/cursor/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
hasLoggedInteractionHookFailure,
setHasLoggedInteractionHookFailure,
interactionCaptureCleanup,
isCursorCaptureActive,
lastLeftClick,
setHasLoggedInteractionHookFailure,
setInteractionCaptureCleanup,
setLastLeftClick,
setLinuxCursorScreenPoint,
} from "../state";
import type {
CursorInteractionType,
HookMouseEvent,
UiohookLike,
UiohookModuleNamespace,
} from "../types";
import {
getNormalizedCursorPoint,
getCursorCaptureElapsedMs,
getHookCursorScreenPoint,
getNormalizedCursorPoint,
isCursorCapturePaused,
pushCursorSample,
} from "./telemetry";
Expand Down Expand Up @@ -172,6 +177,62 @@ function loadUiohookModule() {
}
}

export function shouldStartGlobalInteractionHook(platform: NodeJS.Platform = process.platform) {
// On macOS, uiohook can block forever while its native event tap starts
// (notably when Accessibility permission is unavailable or stale). Because
// start() executes synchronously, that freezes Electron's main thread and
// makes every window, including the recording HUD, unresponsive. Cursor
// position and visual-state telemetry still come from the existing native
// macOS monitor and Electron sampler.
return platform !== "darwin";
}

export function recordCursorMouseDown(button: 1 | 2 | 3) {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}

const point = getNormalizedCursorPoint();
if (!point) {
return;
}

const timeMs = getCursorCaptureElapsedMs();
let interactionType: CursorInteractionType = "click";

if (button === 2) {
interactionType = "right-click";
} else if (button === 3) {
interactionType = "middle-click";
} else {
const thresholdMs = 350;
const distance = lastLeftClick
? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy)
: Number.POSITIVE_INFINITY;

if (lastLeftClick && timeMs - lastLeftClick.timeMs <= thresholdMs && distance <= 0.04) {
interactionType = "double-click";
}

setLastLeftClick({ timeMs, cx: point.cx, cy: point.cy });
}

pushCursorSample(point.cx, point.cy, timeMs, interactionType);
}

export function recordCursorMouseUp() {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}

const point = getNormalizedCursorPoint();
if (!point) {
return;
}

pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "mouseup");
}

export async function startInteractionCapture() {
if (!isCursorCaptureActive) {
return;
Expand All @@ -181,6 +242,11 @@ export async function startInteractionCapture() {
return;
}

if (!shouldStartGlobalInteractionHook()) {
console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS.");
return;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
stopInteractionCapture();

try {
Expand All @@ -203,63 +269,15 @@ export async function startInteractionCapture() {
}

const onMouseDown = (event: HookMouseEvent) => {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}

const point = getNormalizedCursorPoint();
if (!point) {
return;
}

const timeMs = getCursorCaptureElapsedMs();
const button = getHookMouseButton(event);
let interactionType: CursorInteractionType = "click";

if (button === 2) {
interactionType = "right-click";
} else if (button === 3) {
interactionType = "middle-click";
} else {
const thresholdMs = 350;
const distance = lastLeftClick
? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy)
: Number.POSITIVE_INFINITY;

if (
lastLeftClick &&
timeMs - lastLeftClick.timeMs <= thresholdMs &&
distance <= 0.04
) {
interactionType = "double-click";
}

setLastLeftClick({ timeMs, cx: point.cx, cy: point.cy });
}

pushCursorSample(point.cx, point.cy, timeMs, interactionType);
recordCursorMouseDown(getHookMouseButton(event));
};

const onMouseUp = () => {
if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}

const point = getNormalizedCursorPoint();
if (!point) {
return;
}

const timeMs = getCursorCaptureElapsedMs();
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
recordCursorMouseUp();
};

const onMouseMove = (event: HookMouseEvent) => {
if (
process.platform !== "linux" ||
!isCursorCaptureActive ||
isCursorCapturePaused()
) {
if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) {
return;
}

Expand Down
16 changes: 14 additions & 2 deletions electron/ipc/cursor/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { BrowserWindow } from "electron";
import type { CursorVisualType } from "../types";
import { ensureNativeCursorMonitorBinary, getCursorMonitorExePath } from "../paths/binaries";
import {
currentCursorVisualType,
nativeCursorMonitorOutputBuffer,
Expand All @@ -11,7 +11,8 @@ import {
setNativeCursorMonitorOutputBuffer,
setNativeCursorMonitorProcess,
} from "../state";
import { getCursorMonitorExePath, ensureNativeCursorMonitorBinary } from "../paths/binaries";
import type { CursorVisualType } from "../types";
import { recordCursorMouseDown, recordCursorMouseUp } from "./interaction";

export function emitCursorStateChanged(cursorType: CursorVisualType) {
BrowserWindow.getAllWindows().forEach((window) => {
Expand All @@ -27,6 +28,17 @@ export function handleCursorMonitorStdout(chunk: Buffer) {
setNativeCursorMonitorOutputBuffer(lines.pop() ?? "");

for (const line of lines) {
const interactionMatch = line.match(/^INTERACTION:(mousedown|mouseup)(?::([123]))?$/);
if (interactionMatch) {
if (interactionMatch[1] === "mouseup") {
recordCursorMouseUp();
} else {
const button = Number(interactionMatch[2]);
recordCursorMouseDown(button === 2 || button === 3 ? button : 1);
}
continue;
}

const match = line.match(/^STATE:(.+)$/);
if (!match) continue;
const next = match[1].trim() as CursorVisualType;
Expand Down
43 changes: 43 additions & 0 deletions electron/ipc/recording/mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,49 @@ export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStream
});
}

export function waitForNativeCaptureCommand(
process: ChildProcessWithoutNullStreams,
marker: "Recording paused" | "Recording resumed",
) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ScreenCaptureKit helper: ${marker}`));
}, 5000);

let stdoutBuffer = "";
const onStdout = (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (stdoutBuffer.includes(marker)) {
cleanup();
resolve();
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onExit = (code: number | null) => {
cleanup();
reject(
new Error(
`Native capture helper exited before ${marker.toLowerCase()} (code ${code ?? "unknown"})`,
),
);
};
const cleanup = () => {
clearTimeout(timer);
process.stdout.off("data", onStdout);
process.off("error", onError);
process.off("exit", onExit);
};

process.stdout.on("data", onStdout);
process.once("error", onError);
process.once("exit", onExit);
});
}

export function waitForNativeCaptureStop(process: ChildProcessWithoutNullStreams) {
return new Promise<string>((resolve, reject) => {
const onClose = (code: number | null) => {
Expand Down
11 changes: 11 additions & 0 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
finalizeStoredVideo,
muxNativeMacRecordingWithAudio,
recoverNativeMacCaptureOutput,
waitForNativeCaptureCommand,
waitForNativeCaptureStart,
waitForNativeCaptureStop,
} from "../recording/mac";
Expand Down Expand Up @@ -1305,7 +1306,12 @@ export function registerRecordingHandlers(
}

try {
const commandApplied = waitForNativeCaptureCommand(
nativeCaptureProcess,
"Recording paused",
);
nativeCaptureProcess.stdin.write("pause\n");
await commandApplied;
setNativeCapturePaused(true);
return { success: true };
} catch (error) {
Expand Down Expand Up @@ -1356,7 +1362,12 @@ export function registerRecordingHandlers(
}

try {
const commandApplied = waitForNativeCaptureCommand(
nativeCaptureProcess,
"Recording resumed",
);
nativeCaptureProcess.stdin.write("resume\n");
await commandApplied;
setNativeCapturePaused(false);
return { success: true };
} catch (error) {
Expand Down
Loading
Loading