Files
Recordly/electron/ipc/cursor/interaction.ts

228 lines
5.2 KiB
TypeScript

import { createRequire } from "node:module";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
cursorCaptureStartTimeMs,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
hasLoggedInteractionHookFailure,
setHasLoggedInteractionHookFailure,
lastLeftClick,
setLastLeftClick,
setLinuxCursorScreenPoint,
} from "../state";
import {
getNormalizedCursorPoint,
getHookCursorScreenPoint,
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 loadUiohookModule() {
const moduleExports = nodeRequire("uiohook-napi") as 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;
}
export async function startInteractionCapture() {
if (!isCursorCaptureActive) {
return;
}
if (!["darwin", "win32", "linux"].includes(process.platform)) {
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) => {
if (!isCursorCaptureActive) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = Date.now() - cursorCaptureStartTimeMs;
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);
};
const onMouseUp = () => {
if (!isCursorCaptureActive) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = Date.now() - cursorCaptureStartTimeMs;
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
};
const onMouseMove = (event: HookMouseEvent) => {
if (process.platform !== "linux" || !isCursorCaptureActive) {
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);
}
}
}