Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Every platform has a recommended route below. On Windows that is the Microsoft S
### System requirements

- **Windows**: version 1903+ (build 18362) with Intel 8th Gen / AMD Ryzen 2000 series or newer minimum; Windows 11 with Intel 12th Gen / Ryzen 4000 series or newer recommended
- **macOS**: 12.3 (Monterey) or later — required by ScreenCaptureKit for native capture
- **macOS**: 13 (Ventura) or later — required by ScreenCaptureKit for capture
- **Linux**: `xdg-desktop-portal` and PipeWire for native capture and system audio; recording still works without them through the browser-capture fallback, with fewer capabilities (see [Platform differences](#platform-differences))
- **RAM**: 8 GB minimum, 16 GB recommended

Expand Down Expand Up @@ -174,7 +174,7 @@ Everything in the editor and export is the same on macOS, Windows, and Linux: zo
- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so cursor themes and the editable cursor overlay work there too — but the portal reports no mouse button events, so **click effects remain macOS and Windows only**.
- **Webcam**: Windows muxes the webcam natively into the recording; macOS and Linux record it alongside as a separate file. It works as a picture-in-picture overlay on all three.
- **System audio** support varies by OS:
- **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works).
- **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission.
- **Windows**: works out of the box.
- **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work).

Expand Down
11 changes: 11 additions & 0 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@
],

"mac": {
// Declared, not merely documented. Electron 41's own LSMinimumSystemVersion is 12.0
// and the .app inherits it verbatim when this key is absent — so before this line the
// bundle advertised macOS 12 while its native payload was built for 13, and a
// Monterey user got as far as the record button before anything went wrong (#515).
// LaunchServices now refuses to open the app below 13 instead, which is the honest
// signal.
//
// macOS 13 because ScreenCaptureKit capture requires it: ScreenCaptureRecorder is
// `@available(macOS 13.0, *)`. Keep in step with README.md, website/docs/
// installation.md, and electron/native/screencapturekit/Package.swift.
"minimumSystemVersion": "13.0",
"notarize": false,
"hardenedRuntime": true,
"entitlements": "macos.entitlements",
Expand Down
5 changes: 4 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ interface Window {
requestNativeMacCursorAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
// "not-determined" is the only genuine denial; the rest mean the helper
// never got to ask. See macNativeCursorRecordingSession.ts.
status: "granted" | "not-determined" | "missing-helper" | "error" | "exited" | "timeout";
accessibilityTrusted: boolean;
error?: string;
}>;
assetBaseUrl: string;
Expand Down
28 changes: 22 additions & 6 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ import {
LinuxNativeCaptureSession,
} from "../native-bridge/capture/linuxNativeCaptureSession";
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
import {
isMacCursorHelperUnavailable,
requestMacCursorAccessibilityAccess,
} from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession";
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { toHelperRect } from "../native-bridge/helperCoordinates";
Expand Down Expand Up @@ -1913,14 +1916,27 @@ export function registerIpcHandlers(
ipcMain.handle("request-native-mac-cursor-access", async () => {
const access = await requestMacCursorAccessibilityAccess();

// When the editable cursor can't get Accessibility trust, pop a native dialog
// that deep-links to the Accessibility pane (mirrors the Screen Recording flow).
// Pop the native Accessibility dialog ONLY for a genuine denial — the helper ran,
// asked, and was told no. Every other !granted status means the helper never got
// to ask (absent from the build, killed by the loader, crashed, hung), and telling
// the user to grant a permission they may well already hold is what made #515
// impossible to escape. Those degrade silently instead; the recorder falls back to
// position-only cursor telemetry and the countdown still runs.
if (process.platform === "darwin" && !access.granted) {
if (isMacCursorHelperUnavailable(access.status)) {
console.warn(
`[cursor-macos] editable cursor unavailable (status=${access.status}${
access.error ? `, error=${access.error}` : ""
}); the app ${
access.accessibilityTrusted ? "does" : "does not"
} hold Accessibility trust. Recording continues with position-only cursor telemetry.`,
);
return access;
}

const mainWin = getMainWindow();
const detail =
access.status === "missing-helper"
? "The cursor helper couldn't be found in this build, so the editable cursor can't be enabled. Rebuild the native helper (npm run build:native:mac) or switch the HUD cursor mode to system."
: "Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown.";
"Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown.";
const messageOptions = {
type: "warning",
buttons: ["Open Accessibility Settings", "Cancel"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

/**
* The cast on `actual` is written out in the factory rather than shared in a
* helper: `vi.mock` calls are HOISTED above every top-level statement, so a
* module-scope helper is still in its temporal dead zone when the factory runs.
*/
type WithDefault = { default?: Record<string, unknown> };

vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
const spawn = vi.fn();
return { ...actual, spawn, default: { ...((actual as WithDefault).default ?? {}), spawn } };
});

const mocks = vi.hoisted(() => ({
isTrustedAccessibilityClient: vi.fn(() => true),
// Shared rather than two separate vi.fn()s so a test can make every candidate path
// unreadable and reach the missing-helper branch.
accessSync: vi.fn(),
}));

vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
// No helper binary exists in a test checkout; by default pretend the first candidate
// path is executable so path resolution is not what is under test.
return {
...actual,
accessSync: mocks.accessSync,
default: { ...((actual as WithDefault).default ?? {}), accessSync: mocks.accessSync },
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

vi.mock("electron", () => ({
systemPreferences: { isTrustedAccessibilityClient: mocks.isTrustedAccessibilityClient },
screen: {
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
getDisplayNearestPoint: () => ({ scaleFactor: 2 }),
},
}));

import { spawn } from "node:child_process";
import {
isMacCursorHelperUnavailable,
requestMacCursorAccessibilityAccess,
} from "./macNativeCursorRecordingSession";

/** Minimal stand-in for the cursor helper: stdio pipes plus kill bookkeeping. */
class FakeHelper extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
killed = false;

kill() {
this.killed = true;
return true;
}

/** Feeds one NDJSON line, the way the real helper emits them. */
emitEvent(event: Record<string, unknown>) {
this.stdout.write(`${JSON.stringify(event)}\n`);
}
}

const spawnMock = vi.mocked(spawn);
let helper: FakeHelper;
let originalPlatform: PropertyDescriptor | undefined;

beforeEach(() => {
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
helper = new FakeHelper();
spawnMock.mockReset();
spawnMock.mockReturnValue(helper as unknown as ReturnType<typeof spawn>);
mocks.isTrustedAccessibilityClient.mockReset();
mocks.isTrustedAccessibilityClient.mockReturnValue(true);
mocks.accessSync.mockReset();
const silence = () => {
// The access probe logs every helper diagnostic; keep the test output readable.
};
vi.spyOn(console, "warn").mockImplementation(silence);
vi.spyOn(console, "error").mockImplementation(silence);
});

afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
vi.restoreAllMocks();
});

/** Lets the spawn listeners attach before the fake helper speaks. */
async function settle<T>(pending: Promise<T>, act: () => void): Promise<T> {
await Promise.resolve();
act();
return pending;
}

describe("requestMacCursorAccessibilityAccess", () => {
it("grants when the helper reports Accessibility trust", async () => {
const access = await settle(requestMacCursorAccessibilityAccess(), () =>
helper.emitEvent({ type: "ready", timestampMs: 1, accessibilityTrusted: true }),
);

expect(access).toMatchObject({ success: true, granted: true, status: "granted" });
});

it("reports a genuine denial when the helper ran and was told no", async () => {
const access = await settle(requestMacCursorAccessibilityAccess(), () =>
helper.emitEvent({ type: "ready", timestampMs: 1, accessibilityTrusted: false }),
);

expect(access).toMatchObject({ granted: false, status: "not-determined" });
// The ONLY status that should ever raise the "grant Accessibility" dialog.
expect(isMacCursorHelperUnavailable(access.status)).toBe(false);
});

/**
* The regression test for #515. On macOS 12 the helper was stamped with a macOS 13
* deployment target, so it died in the loader before printing its `ready` line — and
* the app answered by telling the user to grant a permission they already held.
* A helper that never got to ask must never be reported as a denial.
*/
it("does not call a helper that died before ready a denied permission", async () => {
const access = await settle(requestMacCursorAccessibilityAccess(), () =>
helper.emit("exit", null, "SIGABRT"),
);

expect(access.granted).toBe(false);
expect(access.status).toBe("exited");
// The app itself IS trusted — proof this is a broken build, not a missing grant.
expect(access.accessibilityTrusted).toBe(true);
expect(isMacCursorHelperUnavailable(access.status)).toBe(true);
});

it("distinguishes a helper that could not be spawned at all", async () => {
const access = await settle(requestMacCursorAccessibilityAccess(), () =>
helper.emit("error", new Error("spawn ENOENT")),
);

expect(access).toMatchObject({ granted: false, status: "error" });
expect(isMacCursorHelperUnavailable(access.status)).toBe(true);
});

it("distinguishes a helper that hung without ever answering", async () => {
vi.useFakeTimers();
try {
const pending = requestMacCursorAccessibilityAccess();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(5_000);
const access = await pending;

expect(access).toMatchObject({ granted: false, status: "timeout" });
expect(isMacCursorHelperUnavailable(access.status)).toBe(true);
} finally {
vi.useRealTimers();
}
});

/**
* The other half of #515's conflation, and the branch whose dialog used to tell the
* user to run a build script. No helper on disk is not a permission problem either.
*/
it("reports an absent helper as unavailable, not as a denial", async () => {
mocks.accessSync.mockImplementation(() => {
throw new Error("ENOENT");
});
mocks.isTrustedAccessibilityClient.mockReturnValue(false);

const access = await requestMacCursorAccessibilityAccess();

expect(access).toMatchObject({ success: true, granted: false, status: "missing-helper" });
expect(access.accessibilityTrusted).toBe(false);
expect(isMacCursorHelperUnavailable(access.status)).toBe(true);
// Nothing was spawned: there was nothing to spawn.
expect(spawnMock).not.toHaveBeenCalled();
});

/**
* The probe must not raise the macOS Accessibility prompt. It runs before the helper
* is even located, so on every unavailable branch it would be asking for a grant that
* is not what is missing.
*/
it("reads Accessibility trust without prompting", async () => {
await settle(requestMacCursorAccessibilityAccess(), () =>
helper.emitEvent({ type: "ready", timestampMs: 1, accessibilityTrusted: true }),
);

expect(mocks.isTrustedAccessibilityClient).toHaveBeenCalledWith(false);
expect(mocks.isTrustedAccessibilityClient).not.toHaveBeenCalledWith(true);
});

it("keeps the app's own trust separate from the helper's fate", async () => {
mocks.isTrustedAccessibilityClient.mockReturnValue(false);

const access = await settle(requestMacCursorAccessibilityAccess(), () =>
helper.emit("exit", 1, null),
);

expect(access.accessibilityTrusted).toBe(false);
expect(access.status).toBe("exited");
});
});
Loading
Loading