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
23 changes: 17 additions & 6 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { toHelperRect } from "../native-bridge/helperCoordinates";
import { scoreDeviceNameMatch } from "../recording/deviceNameMatching";
import { resolveNativeMacCaptureHelper } from "../recording/nativeMacCaptureSupport";
import {
isSalvageableFragmentedCapture,
NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES,
Expand Down Expand Up @@ -2089,10 +2090,12 @@ export function registerIpcHandlers(
return { success: true, available: false, reason: "unsupported-platform" };
}

const helperPath = await findNativeMacCaptureHelperPath();
return helperPath
? { success: true, available: true, helperPath }
: { success: true, available: false, reason: "missing-helper" };
const availability = await resolveNativeMacCaptureHelper(
process.platform,
process.getSystemVersion(),
findNativeMacCaptureHelperPath,
);
return { success: true, ...availability };
});

ipcMain.handle("is-native-linux-capture-available", async () => {
Expand Down Expand Up @@ -2621,10 +2624,18 @@ export function registerIpcHandlers(
return { success: false, error: "Native macOS capture is already running." };
}

const helperPath = await findNativeMacCaptureHelperPath();
if (!helperPath) {
const availability = await resolveNativeMacCaptureHelper(
process.platform,
process.getSystemVersion(),
findNativeMacCaptureHelperPath,
);
if (!availability.available && availability.reason === "unsupported-os") {
return { success: false, error: "Native macOS capture requires macOS 13 or later." };
}
if (!availability.available) {
return { success: false, error: "Native macOS capture helper is not available." };
}
const { helperPath } = availability;

if (!request?.source?.sourceId) {
return { success: false, error: "Native macOS capture request is missing a source." };
Expand Down
2 changes: 1 addition & 1 deletion electron/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ On non-macOS hosts this command exits successfully and does not affect Windows/L

The current helper implementation supports display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, MP4 muxing (with `AVAssetWriter.movieFragmentInterval` at 1s, so a helper that dies before `finishWriting()` still leaves a readable file — same reasoning as the Windows fragmented sink below), and ScreenCaptureKit system audio. It also attempts native ScreenCaptureKit microphone capture when the running macOS version exposes that capability. Webcam recording currently stays as an Electron sidecar and is attached to the same recording session after the native screen capture stops.

Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present. When available, macOS recording routes screen/window capture through the native helper so editable cursor recordings do not bake the system cursor into the video. Cursor positions are sampled in Electron; when the cursor helper is available and Accessibility is granted, samples are also tagged with link/text cursor hints such as `pointer`.
Electron exposes `is-native-mac-capture-available` for capability probing. It reports `unsupported-os` below macOS 13, where recording falls back to Chromium without asking for the native cursor helper's Accessibility permission. On newer releases it resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present. When available, macOS recording routes screen/window capture through the native helper so editable cursor recordings do not bake the system cursor into the video. Cursor positions are sampled in Electron; when the cursor helper is available and Accessibility is granted, samples are also tagged with link/text cursor hints such as `pointer`.

See `technical-documentation/architecture/recording.md` for the contract, rollout phases, and SSOT rules.

Expand Down
61 changes: 61 additions & 0 deletions electron/recording/nativeMacCaptureSupport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import {
isNativeMacCaptureOsSupported,
resolveNativeMacCaptureHelper,
} from "./nativeMacCaptureSupport";

describe("isNativeMacCaptureOsSupported", () => {
it("rejects Monterey before probing the macOS 13-only helper", () => {
expect(isNativeMacCaptureOsSupported("darwin", "12.7.6")).toBe(false);
});

it("accepts Ventura and later macOS releases", () => {
expect(isNativeMacCaptureOsSupported("darwin", "13.0")).toBe(true);
expect(isNativeMacCaptureOsSupported("darwin", "26.5.1")).toBe(true);
});

it("rejects other platforms and malformed macOS versions", () => {
expect(isNativeMacCaptureOsSupported("win32", "13.0")).toBe(false);
expect(isNativeMacCaptureOsSupported("darwin", "unknown")).toBe(false);
expect(isNativeMacCaptureOsSupported("darwin", "13.invalid")).toBe(false);
expect(isNativeMacCaptureOsSupported("darwin", "13beta")).toBe(false);
expect(isNativeMacCaptureOsSupported("darwin", "")).toBe(false);
});

it("does not look up the helper on Monterey", async () => {
const findHelper = vi.fn(async () => "/Applications/OpenScreen/helper");

await expect(resolveNativeMacCaptureHelper("darwin", "12.7.6", findHelper)).resolves.toEqual({
available: false,
reason: "unsupported-os",
});
expect(findHelper).not.toHaveBeenCalled();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("does not look up the helper on unsupported platforms", async () => {
const findHelper = vi.fn(async () => "/Applications/OpenScreen/helper");

await expect(resolveNativeMacCaptureHelper("win32", "13.0", findHelper)).resolves.toEqual({
available: false,
reason: "unsupported-platform",
});
expect(findHelper).not.toHaveBeenCalled();
});

it("reports missing and available helpers on supported macOS versions", async () => {
const findMissingHelper = vi.fn(async () => null);
await expect(
resolveNativeMacCaptureHelper("darwin", "13.0", findMissingHelper),
).resolves.toEqual({ available: false, reason: "missing-helper" });
expect(findMissingHelper).toHaveBeenCalledOnce();

const findAvailableHelper = vi.fn(async () => "/Applications/OpenScreen/helper");
await expect(
resolveNativeMacCaptureHelper("darwin", "13.0", findAvailableHelper),
).resolves.toEqual({
available: true,
helperPath: "/Applications/OpenScreen/helper",
});
expect(findAvailableHelper).toHaveBeenCalledOnce();
});
});
27 changes: 27 additions & 0 deletions electron/recording/nativeMacCaptureSupport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** ScreenCaptureKit recording in OpenScreen is built with a macOS 13 deployment target. */
export function isNativeMacCaptureOsSupported(platform: NodeJS.Platform, version: string) {
if (platform !== "darwin" || !/^\d+(?:\.\d+)*$/.test(version)) {
return false;
}

return Number(version.split(".")[0]) >= 13;
}

/** Resolve the native helper only on macOS versions that can execute it. */
export async function resolveNativeMacCaptureHelper(
platform: NodeJS.Platform,
version: string,
findHelper: () => Promise<string | null>,
) {
if (platform !== "darwin") {
return { available: false as const, reason: "unsupported-platform" as const };
}
if (!isNativeMacCaptureOsSupported(platform, version)) {
return { available: false as const, reason: "unsupported-os" as const };
}

const helperPath = await findHelper();
return helperPath
? { available: true as const, helperPath }
: { available: false as const, reason: "missing-helper" as const };
}
23 changes: 16 additions & 7 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type NativeMacRecordingRequest,
parseMacDisplayIdFromSourceId,
parseMacWindowIdFromSourceId,
shouldRequestMacCursorAccess,
} from "@/lib/nativeMacRecording";
import {
type NativeWindowsRecordingRequest,
Expand Down Expand Up @@ -1212,7 +1213,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {

const availability = await window.electronAPI.isNativeMacCaptureAvailable();
if (!availability.success || !availability.available) {
if (availability.reason === "unsupported-platform") {
if (
availability.reason === "unsupported-platform" ||
availability.reason === "unsupported-os"
) {
return false;
}

Expand Down Expand Up @@ -1546,13 +1550,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const platform = window.electronAPI.getPlatform();
if (platform === "darwin" && cursorCaptureMode === "editable-overlay") {
// The main process shows a native dialog that deep-links to the
// Accessibility settings pane when access is missing, so we just stop
// here and let the user grant it and press record again.
const access = await window.electronAPI.requestNativeMacCursorAccess();
if (!access.granted) {
return;
const availability = await window.electronAPI.isNativeMacCaptureAvailable();
if (shouldRequestMacCursorAccess(platform, cursorCaptureMode, availability)) {
// The main process shows a native dialog that deep-links to the
// Accessibility settings pane when access is missing, so we just stop
// here and let the user grant it and press record again.
const access = await window.electronAPI.requestNativeMacCursorAccess();
if (!access.granted) {
return;
}
}
// macOS 12 records through Chromium, which does not use the native cursor helper.
// Asking for Accessibility there can never improve the take or gate the countdown.
}
} catch (error) {
console.warn("Failed to preflight macOS cursor accessibility before countdown:", error);
Expand Down
34 changes: 33 additions & 1 deletion src/lib/nativeMacRecording.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import { describe, expect, it } from "vitest";
import { parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId } from "./nativeMacRecording";
import {
parseMacDisplayIdFromSourceId,
parseMacWindowIdFromSourceId,
shouldRequestMacCursorAccess,
} from "./nativeMacRecording";

describe("nativeMacRecording source parsing", () => {
it("requests cursor Accessibility only when native macOS capture can run", () => {
expect(
shouldRequestMacCursorAccess("darwin", "editable-overlay", {
success: true,
available: true,
}),
).toBe(true);
expect(
shouldRequestMacCursorAccess("darwin", "editable-overlay", {
success: true,
available: false,
reason: "unsupported-os",
}),
).toBe(false);
expect(
shouldRequestMacCursorAccess("darwin", "system", {
success: true,
available: true,
}),
).toBe(false);
expect(
shouldRequestMacCursorAccess("win32", "editable-overlay", {
success: true,
available: true,
}),
).toBe(false);
});

it("parses Electron window source ids into ScreenCaptureKit window ids", () => {
expect(parseMacWindowIdFromSourceId("window:12345:0")).toBe(12345);
expect(parseMacWindowIdFromSourceId("window:987")).toBe(987);
Expand Down
20 changes: 20 additions & 0 deletions src/lib/nativeMacRecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@ export type NativeMacRecordingStartResult = {
error?: string;
};

type NativeMacCaptureAvailability = {
success: boolean;
available: boolean;
reason?: string;
};

/** Accessibility is needed only by the editable cursor paired with native capture. */
export function shouldRequestMacCursorAccess(
platform: NodeJS.Platform,
cursorMode: CursorCaptureMode,
availability: NativeMacCaptureAvailability,
) {
return (
platform === "darwin" &&
cursorMode === "editable-overlay" &&
availability.success &&
availability.available
);
}

export function parseMacWindowIdFromSourceId(sourceId?: string | null) {
if (!sourceId?.startsWith("window:")) {
return null;
Expand Down
Loading