diff --git a/electron/ipc/cursor/interaction.test.ts b/electron/ipc/cursor/interaction.test.ts new file mode 100644 index 00000000..6702f66c --- /dev/null +++ b/electron/ipc/cursor/interaction.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + setPath: vi.fn(), + isReady: vi.fn(() => true), + }, +})); + +import { repairBundledUiohookBinaryForCurrentArch } from "./interaction"; + +describe("repairBundledUiohookBinaryForCurrentArch", () => { + const tempRoots: string[] = []; + + afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true })), + ); + }); + + it("promotes the bundled darwin-arm64 prebuild over a stale incompatible build", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-uiohook-")); + tempRoots.push(tempRoot); + + const packageRoot = path.join(tempRoot, "uiohook-napi"); + const prebuildPath = path.join(packageRoot, "prebuilds", "darwin-arm64", "node.napi.node"); + const buildPath = path.join(packageRoot, "build", "Release", "uiohook_napi.node"); + await fs.mkdir(path.dirname(prebuildPath), { recursive: true }); + await fs.mkdir(path.dirname(buildPath), { recursive: true }); + await fs.writeFile(prebuildPath, "arm64-prebuild"); + await fs.writeFile(buildPath, "x64-build"); + + const log = vi.fn(); + const repaired = repairBundledUiohookBinaryForCurrentArch( + Object.assign(new Error("mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')"), { + code: "ERR_DLOPEN_FAILED", + }), + { packageRoot, platform: "darwin", arch: "arm64", log }, + ); + + expect(repaired).toBe(true); + expect(await fs.readFile(buildPath, "utf8")).toBe("arm64-prebuild"); + expect(log).toHaveBeenCalledWith( + "[CursorTelemetry] Repaired stale uiohook-napi binary using bundled darwin-arm64 prebuild.", + ); + }); + + it("does not rewrite binaries for unrelated load failures", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-uiohook-")); + tempRoots.push(tempRoot); + + const packageRoot = path.join(tempRoot, "uiohook-napi"); + const buildPath = path.join(packageRoot, "build", "Release", "uiohook_napi.node"); + await fs.mkdir(path.dirname(buildPath), { recursive: true }); + await fs.writeFile(buildPath, "existing-build"); + + const repaired = repairBundledUiohookBinaryForCurrentArch( + Object.assign(new Error("some other dlopen failure"), { + code: "ERR_DLOPEN_FAILED", + }), + { packageRoot, platform: "darwin", arch: "arm64" }, + ); + + expect(repaired).toBe(false); + expect(await fs.readFile(buildPath, "utf8")).toBe("existing-build"); + }); +}); \ No newline at end of file diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index c11258f1..9b6f3ac9 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -1,4 +1,6 @@ +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, @@ -54,8 +56,7 @@ function isUiohookLike(value: unknown): value is UiohookLike { return typeof candidate?.on === "function" && typeof candidate?.start === "function"; } -function loadUiohookModule() { - const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace; +function resolveUiohookModule(moduleExports: UiohookModuleNamespace) { const defaultExport = moduleExports.default; if (moduleExports.uIOhook) { @@ -89,6 +90,88 @@ function loadUiohookModule() { 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 async function startInteractionCapture() { if (!isCursorCaptureActive) { return;