diff --git a/README.md b/README.md index 679bc12d3..7a43aa38b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). diff --git a/electron-builder.json5 b/electron-builder.json5 index 998d38ae3..66ee93af4 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -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", diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eb288e14..e140a4e37 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 958ef6d96..aa2014670 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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"; @@ -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"], diff --git a/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts b/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts new file mode 100644 index 000000000..a8a7dfd8c --- /dev/null +++ b/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts @@ -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 }; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + 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(); + // 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 }, + }; +}); + +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) { + 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); + 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(pending: Promise, act: () => void): Promise { + 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"); + }); +}); diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index e274b681f..a8d916a59 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -80,98 +80,142 @@ export function findMacCursorHelperPath() { return null; } -export async function requestMacCursorAccessibilityAccess() { +/** + * Why `granted: false` is not the same question as "did the user deny Accessibility". + * + * `not-determined` is the ONLY genuine denial: the helper ran, asked, and was told no. + * The other four mean the helper never got to ask — it is absent from the build, the + * loader killed it, it crashed, or it hung. Reporting those as a denial is what made + * #515 unfixable from the user's side: on macOS 12 the helper died in dyld, and the app + * answered by telling the user to grant a permission they had already granted. + */ +export type MacCursorAccessStatus = + | "granted" + | "not-determined" + | "missing-helper" + | "error" + | "exited" + | "timeout"; + +export interface MacCursorAccessResult { + success: boolean; + granted: boolean; + status: MacCursorAccessStatus; + /** + * Whether *the app* holds Accessibility trust, read from the main process rather + * than from the helper. This is what separates the two failure modes: a helper that + * could not run while this is `true` is a broken build, not a missing grant. + */ + accessibilityTrusted: boolean; + error?: string; +} + +/** True when the helper never got far enough to answer the permission question. */ +export function isMacCursorHelperUnavailable(status: MacCursorAccessStatus) { + return ( + status === "missing-helper" || status === "error" || status === "exited" || status === "timeout" + ); +} + +export async function requestMacCursorAccessibilityAccess(): Promise { if (process.platform !== "darwin") { - return { success: true, granted: true, status: "granted" }; + return { success: true, granted: true, status: "granted", accessibilityTrusted: true }; } + // The return value is the signal, not a side effect: it says whether OpenScreen.app + // itself is trusted, independently of whether the child helper can be launched. + // + // `false`, so this is a silent read. Prompting here would ask for Accessibility + // BEFORE discovering whether the helper can run at all — and in every branch below + // where it cannot (missing-helper, error, exited, timeout) the grant is not what is + // missing, so the prompt is exactly the noise this function now exists to stop. + // + // Nothing is lost on the one path that does ask the user for the grant: reaching + // `not-determined` means the helper RAN, and it calls AXIsProcessTrustedWithOptions + // with kAXTrustedCheckOptionPrompt itself on every start + // (OpenScreenMacOSCursorHelper/main.swift), which is what puts OpenScreen in the + // Accessibility list for the user to tick. + let accessibilityTrusted = false; try { - systemPreferences.isTrustedAccessibilityClient(true); + accessibilityTrusted = systemPreferences.isTrustedAccessibilityClient(false); } catch { - // Continue with helper probing; it can trigger the same macOS prompt. + // Continue with helper probing; the helper performs the same check itself. } const helperPath = findMacCursorHelperPath(); if (!helperPath) { - return { success: true, granted: false, status: "missing-helper" }; + return { success: true, granted: false, status: "missing-helper", accessibilityTrusted }; } - return new Promise<{ success: boolean; granted: boolean; status: string; error?: string }>( - (resolve) => { - const child = spawn(helperPath, [JSON.stringify({ sampleIntervalMs: 250 })], { - stdio: ["ignore", "pipe", "pipe"], + return new Promise((resolve) => { + const child = spawn(helperPath, [JSON.stringify({ sampleIntervalMs: 250 })], { + stdio: ["ignore", "pipe", "pipe"], + }); + let settled = false; + let lineBuffer = ""; + const finish = (result: Omit) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (!child.killed) { + child.kill("SIGTERM"); + } + resolve({ ...result, accessibilityTrusted }); + }; + const timer = setTimeout(() => { + finish({ + success: false, + granted: false, + status: "timeout", + error: "Timed out waiting for macOS cursor helper", }); - let settled = false; - let lineBuffer = ""; - const finish = (result: { - success: boolean; - granted: boolean; - status: string; - error?: string; - }) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - if (!child.killed) { - child.kill("SIGTERM"); - } - resolve(result); - }; - const timer = setTimeout(() => { - finish({ - success: false, - granted: false, - status: "timeout", - error: "Timed out waiting for macOS cursor helper", - }); - }, READY_TIMEOUT_MS); + }, READY_TIMEOUT_MS); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - lineBuffer += chunk; - const lines = lineBuffer.split(/\r?\n/); - lineBuffer = lines.pop() ?? ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const event = JSON.parse(trimmed) as MacCursorEvent; - if (event.type === "ready") { - finish({ - success: true, - granted: event.accessibilityTrusted === true, - status: event.accessibilityTrusted === true ? "granted" : "not-determined", - }); - return; - } - } catch { - // Ignore non-JSON helper output. + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + lineBuffer += chunk; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed) as MacCursorEvent; + if (event.type === "ready") { + finish({ + success: true, + granted: event.accessibilityTrusted === true, + status: event.accessibilityTrusted === true ? "granted" : "not-determined", + }); + return; } + } catch { + // Ignore non-JSON helper output. } - }); + } + }); - child.once("error", (error) => { - finish({ - success: false, - granted: false, - status: "error", - error: error.message, - }); + child.once("error", (error) => { + finish({ + success: false, + granted: false, + status: "error", + error: error.message, }); - child.once("exit", (code, signal) => { - finish({ - success: false, - granted: false, - status: "exited", - error: `macOS cursor helper exited before ready (code=${code}, signal=${signal})`, - }); + }); + child.once("exit", (code, signal) => { + finish({ + success: false, + granted: false, + status: "exited", + error: `macOS cursor helper exited before ready (code=${code}, signal=${signal})`, }); - }, - ); + }); + }); } function normalizeCursorType(value: unknown): NativeCursorType | null { @@ -204,6 +248,10 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession { this.previousLeftButtonDown = false; this.consecutiveOutsideSamples = 0; + // `true` here, unlike the silent read in requestMacCursorAccessibilityAccess: the + // return value is discarded, so prompting IS the point. Recording is starting and + // the helper is about to spawn, so this is the moment the grant can still change + // what the take records. try { systemPreferences.isTrustedAccessibilityClient(true); } catch { diff --git a/electron/native/screencapturekit/Package.swift b/electron/native/screencapturekit/Package.swift index b865f8ae6..e478693b1 100644 --- a/electron/native/screencapturekit/Package.swift +++ b/electron/native/screencapturekit/Package.swift @@ -4,6 +4,24 @@ import PackageDescription let package = Package( name: "OpenScreenScreenCaptureKitHelper", + // macOS 13 is DELIBERATE, and it is the same number the app declares in + // electron-builder.json5 (`mac.minimumSystemVersion`) and promises in the README. + // Those three must move together; scripts/check-macos-deployment-target.test.mjs + // asserts this one never rises above what the app declares. + // + // It has to be at least 13 regardless: ScreenCaptureRecorder is + // `@available(macOS 13.0, *)` and its main() hard-guards `#available(macOS 13.0, *)`, + // because SCStream's usable surface starts there. + // + // What this block is NOT allowed to become is higher than the declared floor, which is + // how #515 happened. The floor was set here when ScreenCaptureKit was the only target; + // openscreen-macos-cursor-helper was added later and inherited it, because SwiftPM has + // no per-target override. The app then advertised macOS 12 while shipping a 13-only + // helper, and the damage was not the version number: at a deployment target >= 13 the + // linker resolves the Swift Foundation overlay symbols against Foundation.framework and + // drops /usr/lib/swift/libswiftFoundation.dylib from the load commands, so on macOS 12 + // the helper died in dyld before it could speak — which the app reported to the user as + // a denied Accessibility grant. platforms: [ .macOS(.v13) ], diff --git a/scripts/check-macos-deployment-target.test.mjs b/scripts/check-macos-deployment-target.test.mjs new file mode 100644 index 000000000..1e3e66ea0 --- /dev/null +++ b/scripts/check-macos-deployment-target.test.mjs @@ -0,0 +1,119 @@ +// Guards the macOS deployment floor of the native Swift helpers (issue #515). +// +// The floor is declared in THREE places that must agree: `mac.minimumSystemVersion` in +// electron-builder.json5 (what the .app tells LaunchServices), the README's system +// requirements (what we promise), and the `platforms:` block in Package.swift (what the +// helpers are actually built for). This file ties the third to the first. +// +// The direction matters. Package.swift may not declare a floor HIGHER than the app +// advertises — that is exactly #515: the floor here was set to 13 when ScreenCaptureKit +// was the only target, openscreen-macos-cursor-helper was added later and inherited it +// because SwiftPM has no per-target override, and the bundle went on advertising macOS 12 +// (Electron's own LSMinimumSystemVersion, inherited because the key was unset). +// +// The damage was not the version number. At a deployment target >= 13 the linker resolves +// the Swift Foundation overlay symbols against Foundation.framework and drops +// /usr/lib/swift/libswiftFoundation.dylib from the load commands; on macOS 12 those +// symbols live only in that dylib, so the helper died in the loader before it could speak +// — and the app reported that as a denied Accessibility grant. +// +// A text assertion rather than a build: this has to fail on Linux and Windows CI too, +// where no Swift toolchain exists. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PACKAGE_SWIFT = path.join(ROOT, "electron", "native", "screencapturekit", "Package.swift"); +const BUILDER_CONFIG = path.join(ROOT, "electron-builder.json5"); + +/** + * The floor the .app itself declares, read rather than duplicated — a second copy of this + * number is the thing most likely to drift, and drift is the whole failure mode. + * + * Regex rather than a JSON5 parse to keep this dependency-free and runnable anywhere; the + * key is a plain string literal in a hand-maintained config. + */ +function declaredAppFloor() { + const source = readFileSync(BUILDER_CONFIG, "utf8"); + const match = source.match(/"minimumSystemVersion"\s*:\s*"(\d+)(?:\.\d+)*"/); + return match ? Number(match[1]) : null; +} + +/** + * Reads the major version out of the `platforms:` block, accepting both spellings + * SwiftPM allows — `.macOS(.v12)` and `.macOS("12.3")`. + */ +function declaredMacOsFloor(source) { + // Scoped to the platforms block, with comments stripped from it, rather than matched + // across the whole manifest. That block is preceded by a long comment discussing these + // very version numbers, so a file-wide match is one careless edit away from reading the + // prose instead of the declaration — and reporting a floor the build does not use is + // the one failure this guard must not have. + const block = source.match(/\bplatforms\s*:\s*\[([\s\S]*?)\]/)?.[1]; + if (!block) { + return null; + } + const declarations = block.replace(/\/\/[^\n]*/g, ""); + + const enumMatch = declarations.match(/\.macOS\(\s*\.v(\d+)(?:_\d+)?\s*\)/); + if (enumMatch) { + return Number(enumMatch[1]); + } + + const stringMatch = declarations.match(/\.macOS\(\s*"(\d+)(?:\.\d+)*"\s*\)/); + return stringMatch ? Number(stringMatch[1]) : null; +} + +describe("macOS native helper deployment target", () => { + const source = readFileSync(PACKAGE_SWIFT, "utf8"); + + it("declares a floor no higher than the app itself advertises", () => { + const floor = declaredMacOsFloor(source); + const appFloor = declaredAppFloor(); + + expect(floor, `no .macOS(...) platform found in ${PACKAGE_SWIFT}`).not.toBeNull(); + expect( + appFloor, + 'no "minimumSystemVersion" found in electron-builder.json5 — without it the .app ' + + "inherits Electron's own floor, which is what let #515 ship", + ).not.toBeNull(); + expect( + floor, + `Package.swift builds the native helpers for macOS ${floor}, above the ${appFloor} ` + + "the .app advertises to LaunchServices. This block is package-wide and also " + + "governs openscreen-macos-cursor-helper, which needs nothing newer than 10.15. " + + "Every user between the two versions gets a helper that dies in the loader, " + + "reported as a denied Accessibility grant. See issue #515.", + ).toBeLessThanOrEqual(appFloor); + }); + + it("parses both spellings SwiftPM accepts", () => { + expect(declaredMacOsFloor("platforms: [ .macOS(.v12) ]")).toBe(12); + expect(declaredMacOsFloor("platforms: [ .macOS(.v10_15) ]")).toBe(10); + expect(declaredMacOsFloor('platforms: [ .macOS("12.3") ]')).toBe(12); + expect(declaredMacOsFloor("platforms: [ .iOS(.v16) ]")).toBeNull(); + }); + + it("reads the declaration, not prose that happens to mention a version", () => { + // The real manifest carries exactly this shape: a comment about the floor sitting + // directly above the floor. Matching file-wide would report 12 while the build used + // 13 — a guard that passes for the very bug it exists to catch. + const decoyAbove = [ + "// It was .macOS(.v12) until this changed; see issue #515.", + "platforms: [", + "\t.macOS(.v13)", + "],", + ].join("\n"); + expect(declaredMacOsFloor(decoyAbove)).toBe(13); + + const decoyInside = ["platforms: [", "\t// was .macOS(.v12)", "\t.macOS(.v13)", "],"].join( + "\n", + ); + expect(declaredMacOsFloor(decoyInside)).toBe(13); + + expect(declaredMacOsFloor("// .macOS(.v12) with no platforms block at all")).toBeNull(); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 14eec5ab4..fcab5452b 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -48,6 +48,31 @@ const RECORDING_FILE_PREFIX = "recording-"; const VIDEO_FILE_EXTENSION = ".webm"; const WEBCAM_FILE_SUFFIX = "-webcam"; +/** + * The cursor mode a BROWSER-pipeline take can actually honour, which is not always the + * one the user picked. + * + * Only win32 reaches that pipeline through `getDisplayMedia`, the sole browser API here + * that can exclude the system cursor (`cursor: "never"`). Everywhere else the + * desktop-capture stream bakes the real cursor into the pixels, so keeping + * "editable-overlay" would start cursor telemetry and have the editor composite a + * SECOND, synthetic cursor on top of it. + * + * This only bites when a platform falls back to browser capture with the editable cursor + * selected — on macOS 12 that is now the normal path (#515), and on Linux it is the + * no-PipeWire path, where the same latent defect lives. + * + * One function rather than the expression inlined twice: the mode reported to the main + * process at start and the mode persisted at finalize have to agree, and they are ~1200 + * lines apart. + */ +function effectiveBrowserCursorMode( + platform: string, + requested: CursorCaptureMode, +): CursorCaptureMode { + return platform === "win32" ? requested : "system"; +} + const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; @@ -550,7 +575,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ? { videoData: webcamVideoData, fileName: webcamFileName } : undefined, createdAt: activeRecordingId, - cursorCaptureMode, + // What this take actually did, not what was requested. Only the browser + // pipeline reaches this finalizer (stopRecording returns earlier for all + // three native paths), and off win32 it cannot exclude the system cursor + // — so the mode reported to the main process was forced to "system" and + // the stored metadata has to agree. It is user-visible: `openscreen + // project show` prints it. + cursorCaptureMode: effectiveBrowserCursorMode( + window.electronAPI.getPlatform(), + cursorCaptureMode, + ), durationMs: duration, }); @@ -1546,13 +1580,26 @@ 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. + // Stop before the countdown ONLY when the user genuinely denied + // Accessibility — the main process is showing them a dialog that + // deep-links to the settings pane, so pressing record again after + // granting it will work. + // + // When the helper simply could not run (missing from the build, killed + // by the loader, crashed, hung) there is nothing for the user to grant, + // and blocking here is what left macOS 12 unable to record at all + // (#515). Recording degrades on its own: the session falls back to + // position-only cursor telemetry and the editor draws the cursor from + // its bundled sprites, so only the pointer/text shape hints are lost. const access = await window.electronAPI.requestNativeMacCursorAccess(); - if (!access.granted) { + if (!access.granted && access.status === "not-determined") { return; } + if (!access.granted) { + console.warn( + `Editable cursor unavailable (${access.status}); recording with position-only cursor telemetry.`, + ); + } } } catch (error) { console.warn("Failed to preflight macOS cursor accessibility before countdown:", error); @@ -1654,6 +1701,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { countdownRunToken?: number, preparedRecordingId?: number | null, ) => { + const platform = window.electronAPI.getPlatform(); + const browserCursorCaptureMode = effectiveBrowserCursorMode(platform, cursorCaptureMode); + try { if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); @@ -1688,8 +1738,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // `getUserMedia` calls is the dominant source of the mic-vs-video lag at the // start of the recording (issue #57). const screenCapture = (async (): Promise => { - const platform = window.electronAPI.getPlatform(); - if (platform === "win32") { // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the // pre-selected source. Editable cursor mode excludes the system cursor so @@ -1920,7 +1968,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setRecording(true); setPaused(false); setElapsedSeconds(0); - window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode); + window.electronAPI?.setRecordingState(true, recordingId.current, browserCursorCaptureMode); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current; diff --git a/website/docs/installation.md b/website/docs/installation.md index 5d2c7545f..71309012b 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -23,7 +23,7 @@ Download the latest installer for your platform from the [download page](/downlo | | Minimum | Recommended | |---|---|---| | **Windows** | Windows 10 version 1903 (build 18362) or later, Intel 8th Gen / AMD Ryzen 2000 series or newer | Windows 11, Intel 12th Gen / AMD Ryzen 4000 series or newer | -| **macOS** | macOS 12.3 (Monterey) — required by ScreenCaptureKit for native capture | macOS 14 or later | +| **macOS** | macOS 13 (Ventura) — required by ScreenCaptureKit for capture | macOS 14 or later | | **Linux** | `xdg-desktop-portal` and PipeWire for native capture and system audio (default on Ubuntu 22.04+, Fedora 34+) — recording still works without them through the [browser-capture fallback](#platform-differences), with fewer capabilities. Recording mouse clicks on Wayland additionally needs your user in the `input` group — see [Mouse clicks on Wayland](#mouse-clicks-on-wayland) | Same, kept up to date | | **RAM** | 8 GB | 16 GB | @@ -134,7 +134,7 @@ The editing tools are the same everywhere — zooms, backgrounds, crop/trim/spee | Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Browser pipeline | | Custom cursor themes / click effects | ✅ | ✅ | ✅ on Wayland — click capture needs the `input` group ([details](#mouse-clicks-on-wayland)) | | Webcam | Native capture | Native capture | Browser capture (still works as PiP) | -| System audio | macOS 13+; permission prompt on 14.2+; not available on macOS 12 and below | Works out of the box | Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+) | +| System audio | Works out of the box; permission prompt on macOS 14.2+ | Works out of the box | Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+) | | MP4 export | ✅ | ✅ | ✅ (software encode) | | GIF export | ✅ | ✅ | ✅ | | On-device transcription | Metal (Apple Silicon) / CPU | Vulkan / CPU | Vulkan / CPU |