diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 958ef6d96..1aa71507a 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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, @@ -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 () => { @@ -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." }; diff --git a/electron/native/README.md b/electron/native/README.md index 8ff2e3cdd..287a4104e 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -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. diff --git a/electron/recording/nativeMacCaptureSupport.test.ts b/electron/recording/nativeMacCaptureSupport.test.ts new file mode 100644 index 000000000..4b9d93f2d --- /dev/null +++ b/electron/recording/nativeMacCaptureSupport.test.ts @@ -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(); + }); + + 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(); + }); +}); diff --git a/electron/recording/nativeMacCaptureSupport.ts b/electron/recording/nativeMacCaptureSupport.ts new file mode 100644 index 000000000..382ff4a2e --- /dev/null +++ b/electron/recording/nativeMacCaptureSupport.ts @@ -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, +) { + 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 }; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 14eec5ab4..1c19d2d34 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -11,6 +11,7 @@ import { type NativeMacRecordingRequest, parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId, + shouldRequestMacCursorAccess, } from "@/lib/nativeMacRecording"; import { type NativeWindowsRecordingRequest, @@ -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; } @@ -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); diff --git a/src/lib/nativeMacRecording.test.ts b/src/lib/nativeMacRecording.test.ts index fce88f6a7..e53bbe21b 100644 --- a/src/lib/nativeMacRecording.test.ts +++ b/src/lib/nativeMacRecording.test.ts @@ -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); diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index e5137c3de..6b84efcb4 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -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;