mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
334 lines
8.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|