Files

334 lines
8.2 KiB
TypeScript

import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import {
hasLoggedInteractionHookFailure,
interactionCaptureCleanup,
isCursorCaptureActive,
lastLeftClick,
setHasLoggedInteractionHookFailure,
setInteractionCaptureCleanup,
setLastLeftClick,
setLinuxCursorScreenPoint,
} from "../state";
import type {
CursorInteractionType,
HookMouseEvent,
UiohookLike,
UiohookModuleNamespace,
} from "../types";
import {
getCursorCaptureElapsedMs,
getHookCursorScreenPoint,
getNormalizedCursorPoint,
isCursorCapturePaused,
pushCursorSample,
} from "./telemetry";
const nodeRequire = createRequire(import.meta.url);
export function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 {
if (typeof rawButton !== "number" || !Number.isFinite(rawButton)) {
return 1;
}
if (rawButton === 2 || rawButton === 39) {
return 2;
}
if (rawButton === 3 || rawButton === 38) {
return 3;
}
return 1;
}
export function getHookMouseButton(event: HookMouseEvent | null | undefined): 1 | 2 | 3 {
return normalizeHookMouseButton(
event?.button ?? event?.mouseButton ?? event?.data?.button ?? event?.data?.mouseButton,
);
}
export function stopInteractionCapture() {
if (interactionCaptureCleanup) {
interactionCaptureCleanup();
setInteractionCaptureCleanup(null);
}
}
function isUiohookLike(value: unknown): value is UiohookLike {
const candidate = value as Partial<UiohookLike> | null;
return typeof candidate?.on === "function" && typeof candidate?.start === "function";
}
function resolveUiohookModule(moduleExports: UiohookModuleNamespace) {
const defaultExport = moduleExports.default;
if (moduleExports.uIOhook) {
return moduleExports.uIOhook;
}
if (moduleExports.uiohook) {
return moduleExports.uiohook;
}
if (moduleExports.Uiohook) {
return moduleExports.Uiohook;
}
if (isUiohookLike(defaultExport)) {
return defaultExport;
}
if (defaultExport?.uIOhook) {
return defaultExport.uIOhook;
}
if (defaultExport?.uiohook) {
return defaultExport.uiohook;
}
if (defaultExport?.Uiohook) {
return defaultExport.Uiohook;
}
return null;
}
function shouldRepairBundledUiohookBinary(error: unknown): error is NodeJS.ErrnoException {
if (process.platform !== "darwin") {
return false;
}
if (process.arch !== "arm64") {
return false;
}
const candidate = error as NodeJS.ErrnoException | null;
return (
candidate?.code === "ERR_DLOPEN_FAILED" &&
typeof candidate.message === "string" &&
candidate.message.includes("incompatible architecture")
);
}
export function repairBundledUiohookBinaryForCurrentArch(
error: unknown,
options?: {
packageRoot?: string;
platform?: NodeJS.Platform;
arch?: string;
log?: (message: string) => void;
},
) {
const platform = options?.platform ?? process.platform;
const arch = options?.arch ?? process.arch;
if (platform !== "darwin" || arch !== "arm64") {
return false;
}
const candidate = error as NodeJS.ErrnoException | null;
if (
candidate?.code !== "ERR_DLOPEN_FAILED" ||
typeof candidate.message !== "string" ||
!candidate.message.includes("incompatible architecture")
) {
return false;
}
const packageRoot =
options?.packageRoot ?? path.dirname(nodeRequire.resolve("uiohook-napi/package.json"));
const prebuildPath = path.join(packageRoot, "prebuilds", `darwin-${arch}`, "node.napi.node");
const buildPath = path.join(packageRoot, "build", "Release", "uiohook_napi.node");
if (!fs.existsSync(prebuildPath)) {
return false;
}
try {
fs.mkdirSync(path.dirname(buildPath), { recursive: true });
fs.copyFileSync(prebuildPath, buildPath);
(options?.log ?? console.warn)(
"[CursorTelemetry] Repaired stale uiohook-napi binary using bundled darwin-arm64 prebuild.",
);
return true;
} catch {
return false;
}
}
function loadUiohookModule() {
try {
const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace;
return resolveUiohookModule(moduleExports);
} catch (error) {
if (!shouldRepairBundledUiohookBinary(error)) {
throw error;
}
if (!repairBundledUiohookBinaryForCurrentArch(error)) {
throw error;
}
delete nodeRequire.cache[nodeRequire.resolve("uiohook-napi")];
const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace;
return resolveUiohookModule(moduleExports);
}
}
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;
}
if (!["darwin", "win32", "linux"].includes(process.platform)) {
return;
}
if (!shouldStartGlobalInteractionHook()) {
console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS.");
return;
}
stopInteractionCapture();
try {
const hook = loadUiohookModule();
console.log(
"[CursorTelemetry] hook loaded:",
!!hook,
"has.on:",
typeof hook?.on,
"has.start:",
typeof hook?.start,
);
if (!isCursorCaptureActive) {
return;
}
if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") {
console.log("[CursorTelemetry] hook unusable — aborting interaction capture");
return;
}
const onMouseDown = (event: HookMouseEvent) => {
recordCursorMouseDown(getHookMouseButton(event));
};
const onMouseUp = () => {
recordCursorMouseUp();
};
const onMouseMove = (event: HookMouseEvent) => {
if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
const point = getHookCursorScreenPoint(event);
if (!point) {
return;
}
setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() });
};
hook.on("mousedown", onMouseDown);
hook.on("mouseup", onMouseUp);
if (process.platform === "linux") {
hook.on("mousemove", onMouseMove);
}
setInteractionCaptureCleanup(() => {
try {
if (typeof hook.off === "function") {
hook.off("mousedown", onMouseDown);
hook.off("mouseup", onMouseUp);
if (process.platform === "linux") {
hook.off("mousemove", onMouseMove);
}
} else if (typeof hook.removeListener === "function") {
hook.removeListener("mousedown", onMouseDown);
hook.removeListener("mouseup", onMouseUp);
if (process.platform === "linux") {
hook.removeListener("mousemove", onMouseMove);
}
}
} catch {
// ignore listener cleanup errors
}
try {
if (typeof hook.stop === "function") {
hook.stop();
}
} catch {
// ignore hook shutdown errors
}
});
hook.start();
} catch (error) {
if (!hasLoggedInteractionHookFailure) {
setHasLoggedInteractionHookFailure(true);
console.warn("[CursorTelemetry] Global interaction capture unavailable:", error);
}
}
}