From 2f9bd1c4414f9334179aa3c6be6b1f0595aa3a46 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 28 Aug 2026 20:19:42 +0200 Subject: [PATCH 01/13] fix(macos): drop the native deployment floor to macOS 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor helper was being stamped minos 13.0, so on Monterey dyld killed it before it could print its `ready` line — and the app reported that death as a denied Accessibility grant, re-prompting forever however many times the user granted it (#515). The floor was never meant to cover this binary. b9e21347 set .macOS(.v13) when ScreenCaptureKit was the package's only target; b2f9afab added openscreen-macos-cursor-helper beside it and the package-wide `platforms:` block silently applied to it too, though its deepest requirement is CryptoKit (10.15). Note the mechanism is NOT a loader version gate: dyld does not refuse a binary whose minos exceeds the running OS (verified — a minos 99.0 binary execs fine). It is the linker. At >= 13 the Swift Foundation overlay symbols resolve against Foundation.framework and libswiftFoundation.dylib is dropped from the load commands; on macOS 12 those symbols live only in that dylib. Measured, arm64 release: before minos 13.0, 0 undefined symbols from libswiftFoundation, not loaded after minos 12.0, 26 undefined symbols from libswiftFoundation, loaded Native capture still requires macOS 13 — enforced in Swift, not by the floor. ScreenCaptureKit stays weak-linked at .v12, so a 12.0-12.2 host reaches the legible unsupportedMacOS error instead of dying in dyld. Refs #515 --- .../native/screencapturekit/Package.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/electron/native/screencapturekit/Package.swift b/electron/native/screencapturekit/Package.swift index b865f8ae6..4c7dc4efa 100644 --- a/electron/native/screencapturekit/Package.swift +++ b/electron/native/screencapturekit/Package.swift @@ -4,8 +4,27 @@ import PackageDescription let package = Package( name: "OpenScreenScreenCaptureKitHelper", + // PACKAGE-WIDE, and SwiftPM has no per-target override — so this floor is also + // the floor of `openscreen-macos-cursor-helper`, which needs nothing newer than + // 10.15. It was set to .v13 in b9e21347, when ScreenCaptureKit was the only thing + // in here; the cursor helper arrived in b2f9afab and silently inherited it. + // + // That is not a cosmetic mismatch. At a deployment target >= 13 the linker resolves + // the Swift Foundation overlay symbols against Foundation.framework directly and + // DROPS /usr/lib/swift/libswiftFoundation.dylib from the load commands (the SDK's + // `$ld$previous$/usr/lib/swift/libswiftFoundation.dylib$1.0.0$1$10.15$13.0$…` + // directives are the cutover). On macOS 12 those symbols live only in that dylib, + // which the binary no longer loads, so dyld kills the helper before it prints its + // `ready` line — which the app then reported as a denied Accessibility grant. + // See issue #515. + // + // .v12 and not "12.3": at 12.0 ScreenCaptureKit is WEAK-linked, so a 12.0–12.2 host + // still execs the capture helper and reaches the legible `HelperError.unsupportedMacOS` + // guard in ScreenCaptureRecorder.main(). At 12.3 it becomes a hard LC_LOAD_DYLIB and + // that host dies in dyld instead. Native capture still requires macOS 13 — that floor + // is enforced in Swift by `@available(macOS 13.0, *)` on ScreenCaptureRecorder. platforms: [ - .macOS(.v13) + .macOS(.v12) ], products: [ .executable( From bda9f73fcd5900a0022273eb5f2079a5c26ae1a6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 28 Aug 2026 20:23:04 +0200 Subject: [PATCH 02/13] fix(recording): stop reporting an unrunnable helper as a denied permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `requestMacCursorAccessibilityAccess` collapsed five outcomes into one boolean, so "the helper could not run" and "the user said no" arrived at the UI indistinguishable. Only `not-determined` is a real denial — the helper ran, asked, and was told no. The other four mean it never got to ask. That conflation is what made #515 inescapable: on macOS 12 the helper died in dyld, the app read that as a missing grant, and told the user to allow a permission they had already allowed. Pressing record could never do anything else, whatever they did in System Settings. - macNativeCursorRecordingSession: narrow `status` to a union, and return the app's own Accessibility trust (already computed, previously discarded) so callers can tell "broken build" from "missing grant". - handlers: dialog only for a genuine denial; the rest log and continue. Drops the missing-helper detail string, which told users to run a build script. - useScreenRecorder: block the countdown only for a genuine denial. Nothing was bought by blocking otherwise — the session already degrades to position-only telemetry and the editor draws the cursor from bundled sprites, so only the pointer/text shape hints and click-bounce are lost. - Gate native capture on macOS 13, the floor ScreenCaptureRecorder actually declares, and fall back to browser capture below it as Windows and Linux do. - Force the system cursor whenever a take goes through browser capture on a platform that cannot exclude it. Only the win32 branch uses getDisplayMedia (`cursor: "never"`); the desktop-capture path bakes the real cursor into the pixels, so keeping "editable-overlay" would composite a second synthetic cursor on top. This also fixes the same latent defect on the Linux fallback. Refs #515 --- electron/electron-env.d.ts | 7 +- electron/ipc/handlers.ts | 50 ++++- .../macNativeCursorRecordingSession.ts | 179 +++++++++++------- src/hooks/useScreenRecorder.ts | 48 ++++- 4 files changed, 196 insertions(+), 88 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eb288e14..c4ccbaa8c 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; @@ -132,7 +135,7 @@ interface Window { success: boolean; available: boolean; helperPath?: string; - reason?: "unsupported-platform" | "missing-helper" | string; + reason?: "unsupported-platform" | "unsupported-os" | "missing-helper" | string; error?: string; }>; startNativeWindowsRecording: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 958ef6d96..22586f140 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"; @@ -978,6 +981,24 @@ async function findNativeMacCaptureHelperPath() { return null; } +/** + * ScreenCaptureRecorder is `@available(macOS 13.0, *)` and its `main()` hard-guards + * `#available(macOS 13.0, *)`, so on macOS 12 the helper binary exists, execs, and then + * exits with `unsupportedMacOS`. Answering that here lets the renderer take the browser + * fallback deliberately, the way Windows and Linux already do, instead of discovering it + * as an opaque spawn failure (#515). + */ +function isMacScreenCaptureKitOsSupported() { + if (process.platform !== "darwin") { + return false; + } + + const [major] = process.getSystemVersion().split(".").map(Number); + // Fail OPEN on an unparseable version: refusing would push every healthy Mac onto + // the browser pipeline, which is far worse than letting the helper answer for itself. + return !Number.isFinite(major) || major >= 13; +} + function isWindowsGraphicsCaptureOsSupported() { if (process.platform !== "win32") { return false; @@ -1913,14 +1934,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"], @@ -2089,6 +2123,10 @@ export function registerIpcHandlers( return { success: true, available: false, reason: "unsupported-platform" }; } + if (!isMacScreenCaptureKitOsSupported()) { + return { success: true, available: false, reason: "unsupported-os" }; + } + const helperPath = await findNativeMacCaptureHelperPath(); return helperPath ? { success: true, available: true, helperPath } diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index e274b681f..7baa33474 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -80,98 +80,131 @@ 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. + let accessibilityTrusted = false; try { - systemPreferences.isTrustedAccessibilityClient(true); + accessibilityTrusted = systemPreferences.isTrustedAccessibilityClient(true); } catch { // Continue with helper probing; it can trigger the same macOS prompt. } 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 { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 14eec5ab4..a1154c13b 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1216,6 +1216,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return false; } + // macOS 12: the helper is present but ScreenCaptureKit capture is gated to + // 13+, so fall back to the browser pipeline rather than refusing to record. + // Windows does the same at the equivalent branch above, and Linux below. + if (availability.reason === "unsupported-os") { + console.warn("Native macOS capture needs macOS 13 or later; using browser capture."); + return false; + } + throw new Error( availability.reason === "missing-helper" ? "Native macOS capture helper is not available." @@ -1546,13 +1554,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 +1675,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn { countdownRunToken?: number, preparedRecordingId?: number | null, ) => { + const platform = window.electronAPI.getPlatform(); + + // Only the win32 branch below reaches the browser pipeline through + // getDisplayMedia, which is 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 reporting + // "editable-overlay" to the main process 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. + const browserCursorCaptureMode: CursorCaptureMode = + platform === "win32" ? cursorCaptureMode : "system"; + try { if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); @@ -1688,8 +1724,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 +1954,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; From 2d659e08006ef0fac69301bbecc4abfbb9a66238 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 28 Aug 2026 20:26:04 +0200 Subject: [PATCH 03/13] test(macos): pin the helper-unavailable taxonomy and the deployment floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guards for #515, at the two levels the bug crossed. macNativeCursorAccess.test.ts covers the runtime contract that did not exist before: a helper that died, could not be spawned, or hung is reported as unavailable, not as a denied grant — while the app's own Accessibility trust is carried alongside, so a broken build is distinguishable from a missing permission. The `exited` case is the reported bug: the helper is killed before `ready` while the app IS trusted. check-macos-deployment-target.test.mjs guards the root cause itself. Verified it fails against the original defect rather than merely passing now: AssertionError: Package.swift declares macOS 13, above the app's supported floor of 12. [...] expected 13 to be less than or equal to 12 A text assertion, not a build, so it also runs on the Linux and Windows CI legs where no Swift toolchain exists. Docs: README and website/docs/installation.md both claimed macOS 12.3 "required by ScreenCaptureKit", which was wrong twice over — the shipped binaries were minos 13.0, and this code has always gated native capture at 13 via @available. They now say macOS 12 minimum, 13+ for native capture, with the browser fallback below that. Refs #515 --- README.md | 4 +- .../recording/macNativeCursorAccess.test.ts | 168 ++++++++++++++++++ .../check-macos-deployment-target.test.mjs | 70 ++++++++ website/docs/installation.md | 4 +- 4 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts create mode 100644 scripts/check-macos-deployment-target.test.mjs diff --git a/README.md b/README.md index 679bc12d3..43c477e2e 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**: 12 (Monterey) or later; macOS 13 (Ventura) or later for native ScreenCaptureKit capture. On macOS 12 recording falls back to the browser pipeline, with fewer capabilities (see [Platform differences](#platform-differences)) - **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 @@ -170,7 +170,7 @@ You may need to grant screen recording permissions depending on your desktop env Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be: -- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. +- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. The browser pipeline stays as an automatic fallback on Linux if the helper isn't available, and on macOS 12, where ScreenCaptureKit capture requires macOS 13. - **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: 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..51a3f2c1c --- /dev/null +++ b/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts @@ -0,0 +1,168 @@ +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 } }; +}); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + // No helper binary exists in a test checkout; pretend the first candidate path + // is executable so path resolution is not what is under test. + return { + ...actual, + accessSync: vi.fn(), + default: { ...((actual as WithDefault).default ?? {}), accessSync: vi.fn() }, + }; +}); + +const mocks = vi.hoisted(() => ({ + isTrustedAccessibilityClient: vi.fn(() => true), +})); + +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); + 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(); + } + }); + + 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/scripts/check-macos-deployment-target.test.mjs b/scripts/check-macos-deployment-target.test.mjs new file mode 100644 index 000000000..311e0c2d6 --- /dev/null +++ b/scripts/check-macos-deployment-target.test.mjs @@ -0,0 +1,70 @@ +// Guards the macOS deployment floor of the native Swift helpers (issue #515). +// +// The floor is declared ONCE, package-wide, and SwiftPM offers no per-target +// override — so it silently governs every executable in the package. That is +// exactly how the bug happened: b9e21347 set `.macOS(.v13)` when ScreenCaptureKit +// was the only target, then b2f9afab added `openscreen-macos-cursor-helper` +// beside it, which needs nothing newer than 10.15 and inherited 13 anyway. +// +// The consequence is not cosmetic. 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 dies in the +// loader before it can speak — which the app then reported to the user 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. Native ScreenCaptureKit capture still +// requires macOS 13 — that floor is enforced in Swift by `@available`, and is +// deliberately NOT this file's business. + +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"); + +/** The lowest macOS anything can ship on: Electron 41's own floor. */ +const SUPPORTED_FLOOR = 12; + +/** + * Reads the major version out of the `platforms:` block, accepting both spellings + * SwiftPM allows — `.macOS(.v12)` and `.macOS("12.3")`. + */ +function declaredMacOsFloor(source) { + const enumMatch = source.match(/\.macOS\(\s*\.v(\d+)(?:_\d+)?\s*\)/); + if (enumMatch) { + return Number(enumMatch[1]); + } + + const stringMatch = source.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 the shipped app can actually run on", () => { + const floor = declaredMacOsFloor(source); + + expect(floor, `no .macOS(...) platform found in ${PACKAGE_SWIFT}`).not.toBeNull(); + expect( + floor, + `Package.swift declares macOS ${floor}, above the app's supported floor of ` + + `${SUPPORTED_FLOOR}. This block is package-wide and also governs ` + + "openscreen-macos-cursor-helper, which needs nothing newer than 10.15. " + + "Raising it strands every macOS " + + `${SUPPORTED_FLOOR} user: the helper dies in the loader and the app reports ` + + "it as a denied Accessibility grant. See issue #515.", + ).toBeLessThanOrEqual(SUPPORTED_FLOOR); + }); + + 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(); + }); +}); diff --git a/website/docs/installation.md b/website/docs/installation.md index 5d2c7545f..db23078b6 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 12 (Monterey); macOS 13 (Ventura) or later for native ScreenCaptureKit capture — on macOS 12 recording falls back to the [browser pipeline](#platform-differences), with fewer capabilities | 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 | @@ -131,7 +131,7 @@ The editing tools are the same everywhere — zooms, backgrounds, crop/trim/spee | | macOS | Windows | Linux | |---|---|---|---| -| Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Browser pipeline | +| Capture pipeline | Native (ScreenCaptureKit) on macOS 13+; browser pipeline on macOS 12 | 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+) | From d5b16763c6ce4dd7f35ebcb40252dded5926a957 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 16:55:38 +0200 Subject: [PATCH 04/13] fix(recording): address CodeRabbit review on #527 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four findings held up against the code. Verified each rather than applying them on faith; none was a false positive, and two are defects I introduced. 1. Do not prompt from the Accessibility status probe (macNativeCursorRecordingSession.ts). The call is now a status read feeding `accessibilityTrusted`, but it still passed `true`, so it raised the macOS prompt BEFORE discovering whether the helper can run — asking for a grant that is not what is missing on exactly the branches this PR stops blaming on permissions. The code contradicted its own comment. Nothing is lost on the one path that does ask the user: reaching `not-determined` means the helper ran, and it calls AXIsProcessTrustedWithOptions with kAXTrustedCheckOptionPrompt itself on every start. The call in start() keeps `true` deliberately — its return value is discarded, so prompting is the point there; now commented so the asymmetry does not read as an oversight. 2. Persist the cursor mode the take actually used (useScreenRecorder.ts). The browser finalizer stored the REQUESTED mode while the main process had been told the forced one, so a macOS 12 or Linux fallback recording claimed "editable-overlay" having baked the system cursor in. User-visible: `openscreen project show` prints it. Both sites now derive it from one function rather than repeating the expression ~1200 lines apart, which is how they drifted. 3. Scope the Package.swift floor parser to the platforms block and strip comments from it (check-macos-deployment-target.test.mjs). It matched file-wide, and the block is preceded by a long comment discussing these very version numbers — one careless edit from reading the prose and passing for the exact bug it guards. Not a live defect today; the manifest has a single `.macOS(`. Added decoy cases above and inside the block, both of which the old regex got wrong. 4. Cover the absent-helper path (macNativeCursorAccess.test.ts). The fs mock made every candidate executable, so `missing-helper` — the other half of #515's conflation, and the branch whose dialog used to tell users to run a build script — was never exercised. Also pinned finding 1 with a test asserting the probe is called with `false` and never `true`; confirmed it fails when the change is reverted. Refs #515 --- .../recording/macNativeCursorAccess.test.ts | 53 ++++++++++++++++--- .../macNativeCursorRecordingSession.ts | 19 ++++++- .../check-macos-deployment-target.test.mjs | 35 +++++++++++- src/hooks/useScreenRecorder.ts | 50 ++++++++++++----- 4 files changed, 131 insertions(+), 26 deletions(-) diff --git a/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts b/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts index 51a3f2c1c..a8a7dfd8c 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorAccess.test.ts @@ -15,21 +15,24 @@ vi.mock("node:child_process", async (importOriginal) => { 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; pretend the first candidate path - // is executable so path resolution is not what is under test. + // 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: vi.fn(), - default: { ...((actual as WithDefault).default ?? {}), accessSync: vi.fn() }, + accessSync: mocks.accessSync, + default: { ...((actual as WithDefault).default ?? {}), accessSync: mocks.accessSync }, }; }); -const mocks = vi.hoisted(() => ({ - isTrustedAccessibilityClient: vi.fn(() => true), -})); - vi.mock("electron", () => ({ systemPreferences: { isTrustedAccessibilityClient: mocks.isTrustedAccessibilityClient }, screen: { @@ -73,6 +76,7 @@ beforeEach(() => { 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. }; @@ -155,6 +159,39 @@ describe("requestMacCursorAccessibilityAccess", () => { } }); + /** + * 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); diff --git a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts index 7baa33474..a8d916a59 100644 --- a/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts @@ -124,11 +124,22 @@ export async function requestMacCursorAccessibilityAccess(): Promise { 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 a1154c13b..939448185 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, }); @@ -1676,19 +1710,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { preparedRecordingId?: number | null, ) => { const platform = window.electronAPI.getPlatform(); - - // Only the win32 branch below reaches the browser pipeline through - // getDisplayMedia, which is 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 reporting - // "editable-overlay" to the main process 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. - const browserCursorCaptureMode: CursorCaptureMode = - platform === "win32" ? cursorCaptureMode : "system"; + const browserCursorCaptureMode = effectiveBrowserCursorMode(platform, cursorCaptureMode); try { if (!isCountdownRunActive(countdownRunToken)) { From ecfac807bb5a4bdaa7b2bdd4016a5e1cc64210b6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 20:20:03 +0200 Subject: [PATCH 05/13] fix(macos): declare macOS 13 as the floor instead of accommodating 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the direction of this PR's first commit. The inconsistency behind #515 was that the app advertised macOS 12 while shipping native helpers built for 13; that had to be resolved one way or the other, and supporting 12 is the wrong way. The deciding argument is not Monterey's age. It is that the support would be unverifiable: nobody on the team has a Monterey machine, CI runs macos-latest, and ScreenCaptureKit capture is gated at 13 in the code regardless — so macOS 12 users would land on a browser-capture fallback that nothing ever exercises. An untested promise is how #515 happened in the first place. - Package.swift returns to .macOS(.v13), now documented as deliberate rather than inherited. - electron-builder.json5 declares mac.minimumSystemVersion 13.0. Declaring without enforcing is the actual defect: with the key unset the bundle inherited Electron's own 12.0, so a Monterey user got all the way to the record button. LaunchServices now refuses to open the app below 13, which is the honest signal and strictly better than today's permission loop. - Drops the unsupported-os gate and the macOS browser fallback added earlier in this branch: unreachable once the app cannot launch below 13, and an unreachable branch is the cost this decision exists to avoid. - README and installation.md say 13. Also drops the now-noise "macOS 12 and below cannot capture system audio" notes. Kept, because they are correct at any floor: - The helper-unavailable/permission-denied taxonomy. That conflation is a real bug whatever the floor is, and it is what turns any future helper failure into a legible message instead of an unwinnable permission dialog. - The double-cursor fix, which matters for Linux, where the browser fallback is live. check-macos-deployment-target.test.mjs now reads the floor from electron-builder.json5 rather than hardcoding it, and asserts Package.swift never rises above what the .app advertises — the exact invariant #515 broke. Verified it fails at .v14 against a declared 13. Refs #515 --- README.md | 6 +- electron-builder.json5 | 11 +++ electron/electron-env.d.ts | 2 +- electron/ipc/handlers.ts | 22 ------ .../native/screencapturekit/Package.swift | 35 +++++----- .../check-macos-deployment-target.test.mjs | 68 ++++++++++++------- src/hooks/useScreenRecorder.ts | 8 --- website/docs/installation.md | 6 +- 8 files changed, 78 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 43c477e2e..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 (Monterey) or later; macOS 13 (Ventura) or later for native ScreenCaptureKit capture. On macOS 12 recording falls back to the browser pipeline, with fewer capabilities (see [Platform differences](#platform-differences)) +- **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 @@ -170,11 +170,11 @@ You may need to grant screen recording permissions depending on your desktop env Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be: -- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. The browser pipeline stays as an automatic fallback on Linux if the helper isn't available, and on macOS 12, where ScreenCaptureKit capture requires macOS 13. +- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. - **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 c4ccbaa8c..e140a4e37 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -135,7 +135,7 @@ interface Window { success: boolean; available: boolean; helperPath?: string; - reason?: "unsupported-platform" | "unsupported-os" | "missing-helper" | string; + reason?: "unsupported-platform" | "missing-helper" | string; error?: string; }>; startNativeWindowsRecording: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 22586f140..aa2014670 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -981,24 +981,6 @@ async function findNativeMacCaptureHelperPath() { return null; } -/** - * ScreenCaptureRecorder is `@available(macOS 13.0, *)` and its `main()` hard-guards - * `#available(macOS 13.0, *)`, so on macOS 12 the helper binary exists, execs, and then - * exits with `unsupportedMacOS`. Answering that here lets the renderer take the browser - * fallback deliberately, the way Windows and Linux already do, instead of discovering it - * as an opaque spawn failure (#515). - */ -function isMacScreenCaptureKitOsSupported() { - if (process.platform !== "darwin") { - return false; - } - - const [major] = process.getSystemVersion().split(".").map(Number); - // Fail OPEN on an unparseable version: refusing would push every healthy Mac onto - // the browser pipeline, which is far worse than letting the helper answer for itself. - return !Number.isFinite(major) || major >= 13; -} - function isWindowsGraphicsCaptureOsSupported() { if (process.platform !== "win32") { return false; @@ -2123,10 +2105,6 @@ export function registerIpcHandlers( return { success: true, available: false, reason: "unsupported-platform" }; } - if (!isMacScreenCaptureKitOsSupported()) { - return { success: true, available: false, reason: "unsupported-os" }; - } - const helperPath = await findNativeMacCaptureHelperPath(); return helperPath ? { success: true, available: true, helperPath } diff --git a/electron/native/screencapturekit/Package.swift b/electron/native/screencapturekit/Package.swift index 4c7dc4efa..e478693b1 100644 --- a/electron/native/screencapturekit/Package.swift +++ b/electron/native/screencapturekit/Package.swift @@ -4,27 +4,26 @@ import PackageDescription let package = Package( name: "OpenScreenScreenCaptureKitHelper", - // PACKAGE-WIDE, and SwiftPM has no per-target override — so this floor is also - // the floor of `openscreen-macos-cursor-helper`, which needs nothing newer than - // 10.15. It was set to .v13 in b9e21347, when ScreenCaptureKit was the only thing - // in here; the cursor helper arrived in b2f9afab and silently inherited it. + // 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. // - // That is not a cosmetic mismatch. At a deployment target >= 13 the linker resolves - // the Swift Foundation overlay symbols against Foundation.framework directly and - // DROPS /usr/lib/swift/libswiftFoundation.dylib from the load commands (the SDK's - // `$ld$previous$/usr/lib/swift/libswiftFoundation.dylib$1.0.0$1$10.15$13.0$…` - // directives are the cutover). On macOS 12 those symbols live only in that dylib, - // which the binary no longer loads, so dyld kills the helper before it prints its - // `ready` line — which the app then reported as a denied Accessibility grant. - // See issue #515. + // 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. // - // .v12 and not "12.3": at 12.0 ScreenCaptureKit is WEAK-linked, so a 12.0–12.2 host - // still execs the capture helper and reaches the legible `HelperError.unsupportedMacOS` - // guard in ScreenCaptureRecorder.main(). At 12.3 it becomes a hard LC_LOAD_DYLIB and - // that host dies in dyld instead. Native capture still requires macOS 13 — that floor - // is enforced in Swift by `@available(macOS 13.0, *)` on ScreenCaptureRecorder. + // 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(.v12) + .macOS(.v13) ], products: [ .executable( diff --git a/scripts/check-macos-deployment-target.test.mjs b/scripts/check-macos-deployment-target.test.mjs index 3bae0f29f..1e3e66ea0 100644 --- a/scripts/check-macos-deployment-target.test.mjs +++ b/scripts/check-macos-deployment-target.test.mjs @@ -1,22 +1,24 @@ // Guards the macOS deployment floor of the native Swift helpers (issue #515). // -// The floor is declared ONCE, package-wide, and SwiftPM offers no per-target -// override — so it silently governs every executable in the package. That is -// exactly how the bug happened: b9e21347 set `.macOS(.v13)` when ScreenCaptureKit -// was the only target, then b2f9afab added `openscreen-macos-cursor-helper` -// beside it, which needs nothing newer than 10.15 and inherited 13 anyway. +// 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 consequence is not cosmetic. 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 dies in the -// loader before it can speak — which the app then reported to the user as a -// denied Accessibility grant. +// 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). // -// A text assertion rather than a build: this has to fail on Linux and Windows CI -// too, where no Swift toolchain exists. Native ScreenCaptureKit capture still -// requires macOS 13 — that floor is enforced in Swift by `@available`, and is -// deliberately NOT this file's business. +// 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"; @@ -25,9 +27,20 @@ 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 lowest macOS anything can ship on: Electron 41's own floor. */ -const SUPPORTED_FLOOR = 12; +/** + * 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 @@ -57,19 +70,24 @@ function declaredMacOsFloor(source) { describe("macOS native helper deployment target", () => { const source = readFileSync(PACKAGE_SWIFT, "utf8"); - it("declares a floor the shipped app can actually run on", () => { + 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 declares macOS ${floor}, above the app's supported floor of ` + - `${SUPPORTED_FLOOR}. This block is package-wide and also governs ` + - "openscreen-macos-cursor-helper, which needs nothing newer than 10.15. " + - "Raising it strands every macOS " + - `${SUPPORTED_FLOOR} user: the helper dies in the loader and the app reports ` + - "it as a denied Accessibility grant. See issue #515.", - ).toBeLessThanOrEqual(SUPPORTED_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", () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 939448185..fcab5452b 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1250,14 +1250,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return false; } - // macOS 12: the helper is present but ScreenCaptureKit capture is gated to - // 13+, so fall back to the browser pipeline rather than refusing to record. - // Windows does the same at the equivalent branch above, and Linux below. - if (availability.reason === "unsupported-os") { - console.warn("Native macOS capture needs macOS 13 or later; using browser capture."); - return false; - } - throw new Error( availability.reason === "missing-helper" ? "Native macOS capture helper is not available." diff --git a/website/docs/installation.md b/website/docs/installation.md index db23078b6..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 (Monterey); macOS 13 (Ventura) or later for native ScreenCaptureKit capture — on macOS 12 recording falls back to the [browser pipeline](#platform-differences), with fewer capabilities | 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 | @@ -131,10 +131,10 @@ The editing tools are the same everywhere — zooms, backgrounds, crop/trim/spee | | macOS | Windows | Linux | |---|---|---|---| -| Capture pipeline | Native (ScreenCaptureKit) on macOS 13+; browser pipeline on macOS 12 | Native (Windows Graphics Capture) | Browser pipeline | +| 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 | From 5bf5ef46f63ab6ab8797e8c4e1651f7163ee042f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 30 Aug 2026 10:08:19 +0200 Subject: [PATCH 06/13] test(macos): stop the app-floor parser being fooled by prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect as the Package.swift parser hardened one commit earlier, in the function written directly beside it: declaredAppFloor() took the first "minimumSystemVersion" in the whole of electron-builder.json5. One parser got scoped and its twin did not. Not a live bug — the config has a single occurrence today — but the comment block directly above that key discusses the key by name, and that is precisely the shape that defeats a file-wide match. A guard fooled by prose passes for the bug it exists to catch. Confirmed the old regex reads 12 from a decoy comment where the mac block says 13. Now scoped to the `mac` object by brace matching, with comments stripped first. The stripper is string-aware rather than a plain line regex because the config carries URLs, whose `//` a naive strip would eat, taking the mac block with it. Verified the URL survives and the real config still reads 13. Decoys cover all three shapes the real file invites: a commented-out value above the block, the same key in a sibling platform block, and a URL. Refs #515 --- .../check-macos-deployment-target.test.mjs | 108 +++++++++++++++++- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/scripts/check-macos-deployment-target.test.mjs b/scripts/check-macos-deployment-target.test.mjs index 1e3e66ea0..7d9c8b5f0 100644 --- a/scripts/check-macos-deployment-target.test.mjs +++ b/scripts/check-macos-deployment-target.test.mjs @@ -29,19 +29,83 @@ 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"); +/** + * Drops `//` comments, ignoring any that appear inside a string — electron-builder.json5 + * is heavily commented, and its comments discuss the very keys parsed below. + * + * String-aware rather than a plain `s.replace(/\/\/.*$/gm, "")` because the config also + * carries URLs, whose `//` a naive strip would eat. + */ +function stripJson5Comments(source) { + let out = ""; + let inString = false; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + if (inString) { + out += ch; + if (ch === "\\") { + out += source[++i] ?? ""; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + out += ch; + continue; + } + if (ch === "/" && source[i + 1] === "/") { + while (i < source.length && source[i] !== "\n") i++; + out += "\n"; + continue; + } + out += ch; + } + return out; +} + +/** The body of a top-level `"": { ... }` object, brace-matched. */ +function objectBody(source, key) { + const opener = new RegExp(`"${key}"\\s*:\\s*{`).exec(source); + if (!opener) { + return null; + } + let depth = 0; + for (let i = opener.index + opener[0].length - 1; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) { + return source.slice(opener.index + opener[0].length, i); + } + } + return null; +} + /** * 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. + * Scoped to the `mac` object with comments stripped, not the first match in the file, for + * the same reason declaredMacOsFloor() is scoped to `platforms:`: a guard fooled by prose + * passes for the bug it exists to catch. Both parsers had this shape; only one of them had + * been hardened. + * + * Hand-rolled rather than a JSON5 parse to keep this dependency-free and runnable on every + * CI platform. */ -function declaredAppFloor() { - const source = readFileSync(BUILDER_CONFIG, "utf8"); - const match = source.match(/"minimumSystemVersion"\s*:\s*"(\d+)(?:\.\d+)*"/); +function declaredAppFloorFrom(source) { + const mac = objectBody(stripJson5Comments(source), "mac"); + if (!mac) { + return null; + } + const match = mac.match(/"minimumSystemVersion"\s*:\s*"(\d+)(?:\.\d+)*"/); return match ? Number(match[1]) : null; } +function declaredAppFloor() { + return declaredAppFloorFrom(readFileSync(BUILDER_CONFIG, "utf8")); +} + /** * Reads the major version out of the `platforms:` block, accepting both spellings * SwiftPM allows — `.macOS(.v12)` and `.macOS("12.3")`. @@ -97,6 +161,40 @@ describe("macOS native helper deployment target", () => { expect(declaredMacOsFloor("platforms: [ .iOS(.v16) ]")).toBeNull(); }); + it("reads the mac block's floor, not the first match in the file", () => { + // Both failure shapes the real config invites: a comment discussing the key + // (electron-builder.json5 carries a long one directly above it), and another + // platform block that could grow the same key later. + const decoyComment = [ + '// was "minimumSystemVersion": "12.0" before #515', + '"mac": {', + '\t"minimumSystemVersion": "13.0",', + "}", + ].join("\n"); + expect(declaredAppFloorFrom(decoyComment)).toBe(13); + + const decoySibling = [ + '"win": {', + '\t"minimumSystemVersion": "99.0",', + "},", + '"mac": {', + '\t"minimumSystemVersion": "13.0",', + "}", + ].join("\n"); + expect(declaredAppFloorFrom(decoySibling)).toBe(13); + + // A URL's `//` must survive the comment strip, or the mac block is lost with it. + const withUrl = [ + '"publish": [{ "url": "https://example.invalid/feed" }],', + '"mac": {', + '\t"minimumSystemVersion": "13.0",', + "}", + ].join("\n"); + expect(declaredAppFloorFrom(withUrl)).toBe(13); + + expect(declaredAppFloorFrom('"win": { "minimumSystemVersion": "13.0" }')).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 From 075b1fa446472d15560d88ca3179bb05a33eede4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 30 Aug 2026 10:11:31 +0200 Subject: [PATCH 07/13] refactor(scripts): give the app-floor parser one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared macOS floor is read by two guards — the Package.swift check here and before-pack's pack-time payload check — and a second copy of the parser is exactly how one ends up hardened and the other not. That already happened: the Package.swift parser was scoped to its declaration block after review while the function written directly beside it still took the first match in the file. Moves the string-aware comment strip and the brace matcher into scripts/macos-floor.mjs so there is one implementation to harden. No behaviour change; the decoy cases move with it. Refs #515 --- .../check-macos-deployment-target.test.mjs | 75 +---------------- scripts/macos-floor.mjs | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 73 deletions(-) create mode 100644 scripts/macos-floor.mjs diff --git a/scripts/check-macos-deployment-target.test.mjs b/scripts/check-macos-deployment-target.test.mjs index 7d9c8b5f0..d405fa241 100644 --- a/scripts/check-macos-deployment-target.test.mjs +++ b/scripts/check-macos-deployment-target.test.mjs @@ -25,83 +25,12 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { declaredAppFloorFrom } from "./macos-floor.mjs"; + 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"); -/** - * Drops `//` comments, ignoring any that appear inside a string — electron-builder.json5 - * is heavily commented, and its comments discuss the very keys parsed below. - * - * String-aware rather than a plain `s.replace(/\/\/.*$/gm, "")` because the config also - * carries URLs, whose `//` a naive strip would eat. - */ -function stripJson5Comments(source) { - let out = ""; - let inString = false; - for (let i = 0; i < source.length; i++) { - const ch = source[i]; - if (inString) { - out += ch; - if (ch === "\\") { - out += source[++i] ?? ""; - } else if (ch === '"') { - inString = false; - } - continue; - } - if (ch === '"') { - inString = true; - out += ch; - continue; - } - if (ch === "/" && source[i + 1] === "/") { - while (i < source.length && source[i] !== "\n") i++; - out += "\n"; - continue; - } - out += ch; - } - return out; -} - -/** The body of a top-level `"": { ... }` object, brace-matched. */ -function objectBody(source, key) { - const opener = new RegExp(`"${key}"\\s*:\\s*{`).exec(source); - if (!opener) { - return null; - } - let depth = 0; - for (let i = opener.index + opener[0].length - 1; i < source.length; i++) { - if (source[i] === "{") depth++; - else if (source[i] === "}" && --depth === 0) { - return source.slice(opener.index + opener[0].length, i); - } - } - return null; -} - -/** - * 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. - * - * Scoped to the `mac` object with comments stripped, not the first match in the file, for - * the same reason declaredMacOsFloor() is scoped to `platforms:`: a guard fooled by prose - * passes for the bug it exists to catch. Both parsers had this shape; only one of them had - * been hardened. - * - * Hand-rolled rather than a JSON5 parse to keep this dependency-free and runnable on every - * CI platform. - */ -function declaredAppFloorFrom(source) { - const mac = objectBody(stripJson5Comments(source), "mac"); - if (!mac) { - return null; - } - const match = mac.match(/"minimumSystemVersion"\s*:\s*"(\d+)(?:\.\d+)*"/); - return match ? Number(match[1]) : null; -} - function declaredAppFloor() { return declaredAppFloorFrom(readFileSync(BUILDER_CONFIG, "utf8")); } diff --git a/scripts/macos-floor.mjs b/scripts/macos-floor.mjs new file mode 100644 index 000000000..347685bc6 --- /dev/null +++ b/scripts/macos-floor.mjs @@ -0,0 +1,80 @@ +// Reads the macOS floor the app declares to LaunchServices out of electron-builder.json5. +// +// Its own module because the number is asserted from two different guards — the +// Package.swift floor check and before-pack's pack-time payload check — and a second copy +// of the parser is precisely how one of them ends up hardened and the other not. That +// already happened once: declaredMacOsFloor() was scoped to its declaration block after +// review, while the function written directly beside it still took the first match in the +// whole file. +// +// Hand-rolled rather than a JSON5 parse to stay dependency-free and runnable on every CI +// platform. + +/** + * Drops `//` comments, ignoring any that appear inside a string — electron-builder.json5 + * is heavily commented, and its comments discuss the very keys parsed below. + * + * String-aware rather than a plain `s.replace(/\/\/.*$/gm, "")` because the config also + * carries URLs, whose `//` a naive strip would eat. + */ +function stripJson5Comments(source) { + let out = ""; + let inString = false; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + if (inString) { + out += ch; + if (ch === "\\") { + out += source[++i] ?? ""; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + out += ch; + continue; + } + if (ch === "/" && source[i + 1] === "/") { + while (i < source.length && source[i] !== "\n") i++; + out += "\n"; + continue; + } + out += ch; + } + return out; +} + +/** The body of a top-level `"": { ... }` object, brace-matched. */ +function objectBody(source, key) { + const opener = new RegExp(`"${key}"\\s*:\\s*{`).exec(source); + if (!opener) { + return null; + } + let depth = 0; + for (let i = opener.index + opener[0].length - 1; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) { + return source.slice(opener.index + opener[0].length, i); + } + } + return null; +} + +/** The declared macOS major floor, or null if the `mac` block does not carry one. */ +export function declaredAppFloorFrom(source) { + const mac = objectBody(stripJson5Comments(source), "mac"); + if (!mac) { + return null; + } + const match = mac.match(/"minimumSystemVersion"\s*:\s*"(\d+)(?:\.\d+)*"/); + return match ? Number(match[1]) : null; +} + +/** The full declared version string (e.g. "13.0"), for callers comparing exactly. */ +export function declaredAppVersionFrom(source) { + const mac = objectBody(stripJson5Comments(source), "mac"); + const match = mac?.match(/"minimumSystemVersion"\s*:\s*"([\d.]+)"/); + return match ? match[1] : null; +} From ea2166d970dafc874b5759ec56ee405f87d98220 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 16:39:41 +0200 Subject: [PATCH 08/13] fix(build): pin the macOS deployment target for ffmpeg and whisper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither build set one, so clang and CMake defaulted to the BUILD MACHINE's SDK and the shipped binaries inherited whatever macOS compiled them. Measured on the installed, notarized v1.10.0 arm64 payload: every ffmpeg dylib, every ggml/whisper/parakeet dylib and whisper-stt-server stamped minos 26.0, inside an app whose Info.plist declares LSMinimumSystemVersion 12.0. The minos number is NOT itself the bug. dyld does not refuse a binary — or a dylib — whose minos exceeds the running OS; both were verified to load here (a dylib stamped 27.0 loads fine on 26.5, with only a link-time warning). What the deployment target actually controls is which symbols the toolchain is willing to import from the OS, and that is where the damage is. Measured by rebuilding at 12.0 and diffing imports against the shipped binaries: ffmpeg identical import sets, 0 symbols either way. The claim that the compositor addon cannot load on macOS 12 is NOT supported; these would very likely have loaded. whisper 9 STRONG (non-weak) undefined refs to libc++ symbols that the 12.0 build does not reference at all: __ZTVNSt3__117bad_function_callE and friends __ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE4openEPKcj vtable/VTT for basic_ifstream / basic_ofstream Those are version-gated by libc++ itself. The SDK's availability header declares the bad_function_call key function as `availability(macos, strict, introduced = 15.4)`, and the cutovers reproduce exactly on a three-line test program: the fstream symbols start being imported at a 13.0 target, the bad_function_call ones at 15.4. Below those the toolchain emits local definitions instead — which is precisely what it does now. So the shipped STT helper carries strong references to symbols the toolchain says do not exist before macOS 15.4, well above the Monterey case that prompted this. Not observed on an old macOS — no such machine here — but that annotation is Apple's own statement about where the symbol ships. After the pin, every rebuilt Mach-O reports minos 12.0, whisper-stt-server carries 0 of those 9 refs, and it still loads and runs. This is the macOS twin of the ubuntu-22.04 pin in build-whisper-stt.yml: a shipped binary's floor decided by the runner rather than by the project. Refs #515 --- scripts/build-whisper-stt.sh | 20 ++++++++++++++++++++ scripts/fetch-ffmpeg-macos.mjs | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index dc4728141..71cfa6e6f 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -73,6 +73,11 @@ os_arch_tag() { readonly OS_ARCH="$(os_arch_tag)" readonly OUT_DIR="${OUT_ROOT}/${OS_ARCH}" +# Kept beside the other build constants so it is greppable next to the ffmpeg one in +# scripts/fetch-ffmpeg-macos.mjs; the two must agree, and both must be <= the app's +# LSMinimumSystemVersion. +readonly MACOS_DEPLOYMENT_TARGET="12.0" + # Determine the default backend flag for this host. backend_flag_for_host() { case "${OS_ARCH}" in @@ -305,6 +310,21 @@ BUILD_FLAGS=() if [[ -n "${DEFAULT_FLAG}" ]]; then BUILD_FLAGS+=("${DEFAULT_FLAG}") fi +# Pin the macOS floor the app actually ships against (Electron's own +# LSMinimumSystemVersion, and what README/installation.md promise). Without it CMake +# defaults the deployment target to the BUILD MACHINE's SDK, so whisper-stt-server and +# the libwhisper/libggml*/libparakeet dylibs inherit whatever macOS built them — +# measured 26.0 on the shipped v1.10.0 payload, and ~15.x from CI's `macos-latest`, +# a floor that moves on its own whenever GitHub rolls that image. +# +# This is the macOS twin of the ubuntu-22.04 pin in build-whisper-stt.yml: same defect +# (a shipped binary's floor decided by the runner rather than by the project), different +# libc. Note it is NOT a loader version gate — dyld does not refuse a binary whose minos +# exceeds the running OS. Setting it is what makes the linker enforce macOS 12 symbol +# availability, which is what actually fails at load time. See issue #515. +if [[ "${OS_ARCH}" == darwin-* ]]; then + BUILD_FLAGS+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=${MACOS_DEPLOYMENT_TARGET}") +fi # See the comment in build_variant() re: bash 3.2 + `set -u` + empty arrays # (macOS x64/CPU has no DEFAULT_FLAG, so BUILD_FLAGS is genuinely empty here). build_variant "default" ${BUILD_FLAGS[@]+"${BUILD_FLAGS[@]}"} diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index d611aa627..52563ee97 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -29,6 +29,18 @@ const ROOT = path.join(__dirname, ".."); const CRATES_DIR = path.join(ROOT, "crates"); /** Pinned release. The directory name is what build.rs looks for. */ +// The macOS floor the app ships against (Electron 41's own LSMinimumSystemVersion, and +// what README/installation.md promise). Without it, clang defaults the deployment target +// to the BUILD MACHINE's SDK, so the vendored dylibs inherit whatever macOS built them — +// measured 26.0 on a local build and ~15.x from CI's `macos-latest`, a floor that moves on +// its own every time GitHub rolls that image. That is the same class of leak the configure +// comment below guards against for Homebrew packages, and it is the one it missed. +// +// Note this is NOT a loader version gate: dyld does not refuse a dylib whose minos exceeds +// the running OS (verified). Setting it is what makes the LINKER enforce macOS 12 symbol +// availability, which is the thing that actually breaks at load time. See issue #515. +const MACOS_DEPLOYMENT_TARGET = "12.0"; + const VERSION = "8.1.2"; const TARBALL_SHA256 = "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c"; const DEST = path.join(CRATES_DIR, "thirdparty", `ffmpeg-n${VERSION}-macos64-lgpl-shared`); @@ -202,6 +214,10 @@ run( "--disable-x86asm", `--arch=${process.arch === "arm64" ? "arm64" : "x86_64"}`, "--cc=clang", + // Both, not just cflags: the deployment target has to reach the link step too, or + // the dylibs are stamped with the build machine's floor however they were compiled. + `--extra-cflags=-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}`, + `--extra-ldflags=-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}`, ], { cwd: src }, ); From 56556a5bb5d45cc4885ee1212fd6f93c112df5a3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 16:48:17 +0200 Subject: [PATCH 09/13] feat(build): refuse to pack a macOS binary built above the supported floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before-pack.cjs already refuses an incomplete macOS payload. "Complete" is not the same property as "runnable on the macOS we claim", and #515 was the second kind: the payload was whole, and one helper in it was built for macOS 13 while the app advertised 12. Nothing in the pipeline looked. Walks electron/native/bin/darwin-* and fails the pack if any Mach-O declares a minimum macOS above MAC_MIN_OS_FLOOR. Verified both directions against real binaries rather than only fixtures — on this branch, which still carries the original .macOS(.v13): $ node scripts/build-macos-screencapturekit-helper.mjs && node scripts/before-pack.cjs Refusing to package binaries that demand a newer macOS than the 12.0 floor - openscreen-macos-cursor-helper is built for macOS 13.0.0 (floor 12.0) - openscreen-screencapturekit-helper is built for macOS 13.0.0 (floor 12.0) and exit 0 once Package.swift is at .v12. Pointed at the installed, notarized v1.10.0 payload it names all 25 ffmpeg/whisper dylibs at 26.0. Parses LC_BUILD_VERSION (and LC_VERSION_MIN_MACOSX) out of the file rather than shelling out to `vtool`. Same reason neededSymbolVersions() does not use readelf and importedDlls() does not use dumpbin, plus one specific to this hook: it runs for the Windows and Linux packs too, and vtool exists on neither — so a subprocess would have to be skipped on exactly the hosts where skipping is silent. Parsing makes the guard host-independent instead of conditionally absent. Cross-checked against `vtool -show-build` on all 44 Mach-O files across two real payloads: 0 mismatches. Universal binaries take the highest slice, since an x86_64 half built on a newer machine strands Intel users just as thoroughly. The message names the file, its measured floor, the constant, and #515, and states the mechanism — dyld does NOT gate on the minos number; the deployment target decides which symbols get resolved against the OS, and a too-high floor leaves strong references to symbols the target macOS never had. Without that, the obvious "fix" is to raise the constant until it passes. Carries the same parser-sanity assertion as its Linux sibling: reading no deployment target from any Mach-O means the parser broke, not that the payload is unusually clean. Tests synthesise Mach-O headers instead of invoking clang, so they run on the Linux and Windows CI legs as well, and tie MAC_MIN_OS_FLOOR to README.md. That assertion is one-directional on purpose — building for older than advertised is harmless, building for newer is the bug — so it holds both before and after the README correction in the #515 branch. Refs #515 --- scripts/before-pack.cjs | 180 +++++++++++++++++++++++++++++++++- scripts/before-pack.test.mjs | 184 +++++++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+), 2 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 70a2d6ccb..b54060ba9 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -419,13 +419,19 @@ function checkWinNativePayload() { } function checkMacNativePayload(context) { + const dir = path.join(ROOT, "electron", "native", "bin", `darwin-${archTagFor(context)}`); checkNativePayload({ - dir: path.join(ROOT, "electron", "native", "bin", `darwin-${archTagFor(context)}`), + dir, required: MAC_REQUIRED, osLabel: "macOS", bundleNoun: "the .app", emptyDirFix: `${FIX_MAC}\n\nThe STT helper and the capture helper are separate builds — see\ntechnical-documentation/engineering/build-and-packaging.md.`, }); + + // "Complete" is not the same property as "runnable on the macOS we claim". This file + // exists because a payload can be whole and still broken; a floor above the supported + // one is the second way that happens. See checkMacOsVersionFloor(). + checkMacOsVersionFloor(dir); } function checkLinuxNativePayload(context) { @@ -504,6 +510,21 @@ function checkLinuxNativePayload(context) { */ const MAX_SYMBOL_VERSION = { GLIBC: "2.35", GLIBCXX: "3.4.30", CXXABI: "1.3.13" }; +/** + * The oldest macOS anything in the payload may demand — the macOS twin of + * MAX_SYMBOL_VERSION above, and the same class of bug on a different libc. + * + * Keep in step with README.md's system requirements and with Electron's own + * LSMinimumSystemVersion, which the .app inherits verbatim (electron-builder writes the + * key only when `mac.minimumSystemVersion` is set, and it is not). + * + * before-pack.test.mjs ties this to the README so the two cannot drift apart quietly. The + * invariant it asserts is one-directional on purpose: this floor must be no HIGHER than + * the oldest macOS the README promises. Building for something older than we advertise is + * harmless; building for something newer is #515. + */ +const MAC_MIN_OS_FLOOR = "12.0"; + /** * The one supported way past the ceiling, for the one case it does not fit: a developer * on a distro newer than the floor, building a package for their own machine. @@ -718,7 +739,13 @@ function resolveSymbolCeiling() { // are the only things standing between this escape hatch and a published package that // starts on nobody's machine but the builder's, and they are reachable from a test // without a payload to scan — so they are tested rather than trusted. -exports.__testing = { resolveSymbolCeiling, MAX_SYMBOL_VERSION }; +exports.__testing = { + resolveSymbolCeiling, + MAX_SYMBOL_VERSION, + machoMinOs, + checkMacOsVersionFloor, + MAC_MIN_OS_FLOOR, +}; /** Every ELF under `dir`, recursively — the helper's ffmpeg sits in a subdirectory. */ function elfFilesUnder(dir) { @@ -820,6 +847,155 @@ function checkLinuxSymbolVersionFloor(dir) { ); } +/** + * The macOS minimum-OS a Mach-O declares, as "12.0", or null if it declares none. + * + * Reads LC_BUILD_VERSION (and LC_VERSION_MIN_MACOSX, which is what anything built + * against an older SDK carries) straight out of the file. Parsed here rather than + * shelled out to `vtool -show-build` for the same reason neededSymbolVersions() does not + * use readelf and importedDlls() does not use dumpbin — but with an extra one on top: + * this hook runs for the Windows and Linux packs too, and vtool exists on neither, so a + * subprocess would have to be skipped on exactly the hosts where skipping is silent. + * Parsing makes the guard host-independent instead of conditionally absent. + * + * Universal binaries are walked slice by slice and the HIGHEST floor wins: an x86_64 half + * built on a newer machine strands Intel users just as thoroughly as a thin binary would. + */ +function machoMinOs(file) { + const b = fs.readFileSync(file); + const FAT_MAGIC = 0xcafebabe; + const FAT_MAGIC_64 = 0xcafebabf; + const MH_MAGIC_64 = 0xfeedfacf; + const MH_MAGIC_32 = 0xfeedface; + const LC_VERSION_MIN_MACOSX = 0x24; + const LC_BUILD_VERSION = 0x32; + const PLATFORM_MACOS = 1; + + /** X.Y.Z packed as nibbles: 0x000c0000 is 12.0.0. */ + const decode = (packed) => `${packed >>> 16}.${(packed >> 8) & 0xff}.${packed & 0xff}`; + + const sliceMinOs = (start) => { + const magic = b.readUInt32LE(start); + if (magic !== MH_MAGIC_64 && magic !== MH_MAGIC_32) return null; + const ncmds = b.readUInt32LE(start + 16); + // 32 bytes of mach_header_64 (28 + 4 bytes of `reserved`); 28 for the 32-bit one. + let off = start + (magic === MH_MAGIC_64 ? 32 : 28); + for (let i = 0; i < ncmds; i++) { + if (off + 8 > b.length) return null; + const cmd = b.readUInt32LE(off); + const cmdsize = b.readUInt32LE(off + 4); + if (cmdsize < 8) return null; + if (cmd === LC_BUILD_VERSION && b.readUInt32LE(off + 8) === PLATFORM_MACOS) { + return decode(b.readUInt32LE(off + 12)); + } + if (cmd === LC_VERSION_MIN_MACOSX) { + return decode(b.readUInt32LE(off + 8)); + } + off += cmdsize; + } + return null; + }; + + const fat = b.readUInt32BE(0); + if (fat === FAT_MAGIC || fat === FAT_MAGIC_64) { + const wide = fat === FAT_MAGIC_64; + const nfat = b.readUInt32BE(4); + let best = null; + for (let i = 0; i < nfat; i++) { + const entry = 8 + i * (wide ? 32 : 20); + const offset = wide ? Number(b.readBigUInt64BE(entry + 8)) : b.readUInt32BE(entry + 8); + const found = sliceMinOs(offset); + if (found && (!best || compareVersions(found, best) > 0)) best = found; + } + return best; + } + + return sliceMinOs(0); +} + +/** Every Mach-O under `dir`, recursively. Symlinks are skipped — see elfFilesUnder(). */ +function machoFilesUnder(dir) { + const found = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...machoFilesUnder(full)); + continue; + } + if (!entry.isFile()) continue; + // By magic, not by extension: the helpers and whisper-stt-server have none, and + // the ggml/whisper dylibs come as chains of symlinks onto one real file. + const magic = Buffer.alloc(4); + const fd = fs.openSync(full, "r"); + try { + fs.readSync(fd, magic, 0, 4, 0); + } finally { + fs.closeSync(fd); + } + const le = magic.readUInt32LE(0); + const be = magic.readUInt32BE(0); + if (le === 0xfeedfacf || le === 0xfeedface || be === 0xcafebabe || be === 0xcafebabf) { + found.push(full); + } + } + return found; +} + +/** Nothing we ship may demand a newer macOS than MAC_MIN_OS_FLOOR. */ +function checkMacOsVersionFloor(dir) { + const scanned = machoFilesUnder(dir).map((file) => ({ + name: path.relative(dir, file), + minOs: machoMinOs(file), + })); + + // Same assertion the Linux floor makes, for the same reason: a guard that quietly + // stops looking reports "clean" for the rest of the project's life. Every binary we + // ship is built with a deployment target, so reading none from any of them means the + // parser broke rather than that the payload is unusually clean. + if (scanned.length > 0 && !scanned.some((entry) => entry.minOs)) { + throw new Error( + `Refusing to package: read no macOS deployment target from any of the ${scanned.length} ` + + `Mach-O files in ${path.relative(ROOT, dir)}.\n\n` + + "Every one of them carries LC_BUILD_VERSION, so this is a bug in machoMinOs()\n" + + "(scripts/before-pack.cjs), not an unusually clean payload. Fix the parser — leaving\n" + + "it is how a build that cannot start on the supported macOS gets shipped again.", + ); + } + + const offenders = scanned.filter( + (entry) => entry.minOs && compareVersions(entry.minOs, MAC_MIN_OS_FLOOR) > 0, + ); + if (offenders.length === 0) { + return; + } + + throw new Error( + `Refusing to package binaries that demand a newer macOS than the ${MAC_MIN_OS_FLOOR} floor\n` + + "the app claims to support.\n\n" + + ` looked in: ${path.relative(ROOT, dir)}\n\n` + + `${offenders.map((o) => ` - ${o.name} is built for macOS ${o.minOs} (floor ${MAC_MIN_OS_FLOOR})`).join("\n")}\n\n` + + "Almost certainly nothing asked for this: clang and CMake default the deployment\n" + + "target to the BUILD MACHINE's SDK, so this usually means a build script forgot to\n" + + "pin one and the floor followed whatever image compiled it. CI's macos-latest moves\n" + + "on its own, so the same source can ship a different floor month to month.\n\n" + + "The number itself is not what breaks: dyld does NOT refuse a binary whose minos\n" + + "exceeds the running OS. The damage is done at link time — the deployment target\n" + + "decides which symbols the linker resolves against the OS instead of emitting\n" + + "locally, so a too-high floor leaves strong references to symbols the target macOS\n" + + "has never had, and the binary dies in dyld with 'Symbol not found'. That is issue\n" + + "#515: a helper built for 13 stranded every macOS 12 user, and the app reported it\n" + + "as a denied Accessibility permission.\n\n" + + "Pin the deployment target in whichever script built the file:\n\n" + + " Swift platforms: [.macOS(.v12)] electron/native/screencapturekit/Package.swift\n" + + " CMake -DCMAKE_OSX_DEPLOYMENT_TARGET scripts/build-whisper-stt.sh\n" + + " clang -mmacosx-version-min scripts/fetch-ffmpeg-macos.mjs\n" + + " rustc MACOSX_DEPLOYMENT_TARGET scripts/build-macos-compositor-addon.mjs\n\n" + + "To see it yourself:\n\n" + + " vtool -show-build \n\n" + + `Raising MAC_MIN_OS_FLOOR drops a macOS version the README claims to support.`, + ); +} + /** Newest mtime under `target` (file or directory), or 0 if it does not exist. */ function newestMtimeMs(target) { let stat; diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 905283510..3e993a665 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -88,3 +88,187 @@ describe("symbol-version ceiling", () => { }); }); }); + +// --------------------------------------------------------------------------- +// macOS deployment floor (issue #515) +// --------------------------------------------------------------------------- +// +// Mach-O headers are synthesised here rather than compiled with clang, so this runs on +// the Linux and Windows CI legs too. That is the same reason the guard parses the file +// itself instead of shelling out to `vtool`: the check has to be present everywhere the +// hook is, not conditionally absent on the hosts where nobody would notice. +// +// The parser is separately cross-checked against the real thing — on a machine with a +// staged macOS payload, every Mach-O in it agreed with `vtool -show-build` (44/44). + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +/** Packs X.Y.Z the way LC_BUILD_VERSION does: one byte per component, X in the top half. */ +function packVersion(text) { + const [x = 0, y = 0, z = 0] = text.split(".").map(Number); + return ((x & 0xffff) << 16) | ((y & 0xff) << 8) | (z & 0xff); +} + +/** A 64-bit Mach-O carrying exactly one load command: LC_BUILD_VERSION for macOS. */ +function thinMachO(minOs) { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); // MH_MAGIC_64 + header.writeUInt32LE(1, 16); // ncmds + const lc = Buffer.alloc(24); + lc.writeUInt32LE(0x32, 0); // LC_BUILD_VERSION + lc.writeUInt32LE(24, 4); // cmdsize + lc.writeUInt32LE(1, 8); // PLATFORM_MACOS + lc.writeUInt32LE(packVersion(minOs), 12); + return Buffer.concat([header, lc]); +} + +/** The older spelling, which anything built against an older SDK carries instead. */ +function thinMachOVersionMin(minOs) { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + header.writeUInt32LE(1, 16); + const lc = Buffer.alloc(16); + lc.writeUInt32LE(0x24, 0); // LC_VERSION_MIN_MACOSX + lc.writeUInt32LE(16, 4); + lc.writeUInt32LE(packVersion(minOs), 8); + return Buffer.concat([header, lc]); +} + +/** A universal binary whose slices disagree — the highest floor is the one that counts. */ +function fatMachO(minOsPerSlice) { + const headerSize = 8 + minOsPerSlice.length * 20; + const head = Buffer.alloc(headerSize); + head.writeUInt32BE(0xcafebabe, 0); + head.writeUInt32BE(minOsPerSlice.length, 4); + const slices = minOsPerSlice.map(thinMachO); + let offset = headerSize; + slices.forEach((slice, i) => { + const entry = 8 + i * 20; + head.writeUInt32BE(offset, entry + 8); // offset + head.writeUInt32BE(slice.length, entry + 12); // size + offset += slice.length; + }); + return Buffer.concat([head, ...slices]); +} + +function withPayload(files, body) { + const dir = mkdtempSync(path.join(tmpdir(), "openscreen-minos-")); + try { + for (const [name, bytes] of Object.entries(files)) { + writeFileSync(path.join(dir, name), bytes); + } + return body(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const testing = () => require(BEFORE_PACK).__testing; + +describe("machoMinOs", () => { + it("reads LC_BUILD_VERSION", () => { + withPayload({ helper: thinMachO("12.0") }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "helper"))).toBe("12.0.0"); + }); + }); + + it("reads the older LC_VERSION_MIN_MACOSX spelling", () => { + withPayload({ helper: thinMachOVersionMin("11.3") }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "helper"))).toBe("11.3.0"); + }); + }); + + it("takes the HIGHEST floor across a universal binary's slices", () => { + // An arm64 half built correctly does not rescue an x86_64 half that was not: + // Intel users are stranded just as thoroughly. + withPayload({ fat: fatMachO(["12.0", "26.0"]) }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "fat"))).toBe("26.0.0"); + }); + }); + + it("returns null for a Mach-O that declares no deployment target", () => { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + withPayload({ bare: header }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "bare"))).toBeNull(); + }); + }); +}); + +describe("checkMacOsVersionFloor", () => { + it("passes a payload built at the floor", () => { + withPayload({ a: thinMachO("12.0"), b: thinMachO("11.0") }, (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).not.toThrow(); + }); + }); + + /** + * The regression test for #515: the helper that stranded Monterey was built for 13, + * and nothing in the pipeline looked. The message has to carry enough for whoever + * hits it to understand the consequence rather than just raise the constant. + */ + it("refuses a binary built above the floor, and says which and why", () => { + withPayload({ "openscreen-macos-cursor-helper": thinMachO("13.0") }, (dir) => { + let message = ""; + try { + testing().checkMacOsVersionFloor(dir); + } catch (err) { + message = err.message; + } + expect(message).toContain("openscreen-macos-cursor-helper"); + expect(message).toContain("macOS 13.0.0"); + expect(message).toContain("12.0"); + expect(message).toContain("#515"); + // The mechanism, so nobody "fixes" it by assuming dyld gates on the number. + expect(message).toContain("Symbol not found"); + }); + }); + + it("reports every offender, not just the first", () => { + withPayload( + { ok: thinMachO("12.0"), bad1: thinMachO("13.0"), bad2: thinMachO("26.0") }, + (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).toThrow(/bad1[\s\S]*bad2/); + }, + ); + }); + + it("shouts if it parsed nothing, rather than reporting a clean payload", () => { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + withPayload({ bare: header }, (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).toThrow(/bug in machoMinOs/); + }); + }); + + it("says nothing about a directory with no Mach-O in it", () => { + // Non-macOS packs reach this only if the tree exists; an empty one is not an error + // here — checkNativePayload already owns "the payload is incomplete". + withPayload({ "notes.txt": Buffer.from("hello") }, (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).not.toThrow(); + }); + }); +}); + +describe("MAC_MIN_OS_FLOOR", () => { + it("is no higher than the oldest macOS the README promises", () => { + const readme = readFileSync(path.join(path.dirname(BEFORE_PACK), "..", "README.md"), "utf8"); + const claimed = readme.match(/^-\s+\*\*macOS\*\*:\s*(\d+(?:\.\d+)?)/m); + expect(claimed, "no macOS line found in README.md system requirements").not.toBeNull(); + + const { MAC_MIN_OS_FLOOR } = testing(); + const asNumbers = (v) => v.split(".").map(Number); + const [floorMajor, floorMinor = 0] = asNumbers(MAC_MIN_OS_FLOOR); + const [claimedMajor, claimedMinor = 0] = asNumbers(claimed[1]); + + // One-directional: building for older than advertised is harmless, building for + // newer is the bug. So floor <= claimed, not floor === claimed. + expect( + floorMajor * 1000 + floorMinor, + `before-pack.cjs builds for macOS ${MAC_MIN_OS_FLOOR} but README.md promises ` + + `${claimed[1]} or later. Shipping binaries that cannot run on a version the ` + + "README claims to support is issue #515.", + ).toBeLessThanOrEqual(claimedMajor * 1000 + claimedMinor); + }); +}); From 5831f20efa67c3c4f7c85f56679f5f58a88881a3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 20:26:18 +0200 Subject: [PATCH 10/13] fix(build): retarget the macOS floor to 13, matching the declared support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the decision in the parent branch to declare macOS 13 rather than accommodate 12. The pins and the pack-time guard move with it: 13.0 in fetch-ffmpeg-macos.mjs, build-whisper-stt.sh and MAC_MIN_OS_FLOOR, all now described as tracking `mac.minimumSystemVersion` in electron-builder.json5, which is the number the .app actually tells LaunchServices. This does NOT weaken the fix — it is the whole point of it. The defect was never Monterey specifically: the shipped v1.10.0 binaries carried 9 strong undefined references to libc++ symbols that the toolchain dates to macOS 15.4 (`availability(macos, strict, introduced = 15.4)` on the bad_function_call key function), so STT was expected to fail to load on Ventura and Sonoma too — the versions this project still supports, one of which the README recommends. Rebuilt at 13.0 and re-measured rather than assumed: whisper-stt-server 15.4-gated (bad_function_call) 9 -> 0 13.0-gated (fstream/filebuf) 7 (correct at this floor) The second row is the point of pinning rather than merely lowering: at a 13.0 target the toolchain still imports the fstream symbols, which exist on 13.0, and stops importing the 15.4 ones. Both halves are the deployment target doing its job. Every shipped Mach-O now reports 13.0 (compositor_view.node stays at rustc's 11.0, below the floor), whisper-stt-server still loads and runs, the compositor addon links the rebuilt ffmpeg, and `node scripts/before-pack.cjs` exits 0 on the complete payload. before-pack.test.mjs now asserts MAC_MIN_OS_FLOOR EQUALS the declared minimumSystemVersion, not merely that it is no higher: a pack-time guard looser than the app's own declaration would wave through exactly the binaries LaunchServices then refuses to run. Its fixtures are derived from the floor instead of hardcoding versions — the previous literals silently turned from offenders into compliant binaries when the floor moved, so the guard's own tests stopped testing it. Refs #515 --- scripts/before-pack.cjs | 15 ++++----- scripts/before-pack.test.mjs | 58 +++++++++++++++++++++------------- scripts/build-whisper-stt.sh | 10 +++--- scripts/fetch-ffmpeg-macos.mjs | 8 +++-- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index b54060ba9..b06568008 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -514,16 +514,13 @@ const MAX_SYMBOL_VERSION = { GLIBC: "2.35", GLIBCXX: "3.4.30", CXXABI: "1.3.13" * The oldest macOS anything in the payload may demand — the macOS twin of * MAX_SYMBOL_VERSION above, and the same class of bug on a different libc. * - * Keep in step with README.md's system requirements and with Electron's own - * LSMinimumSystemVersion, which the .app inherits verbatim (electron-builder writes the - * key only when `mac.minimumSystemVersion` is set, and it is not). - * - * before-pack.test.mjs ties this to the README so the two cannot drift apart quietly. The - * invariant it asserts is one-directional on purpose: this floor must be no HIGHER than - * the oldest macOS the README promises. Building for something older than we advertise is - * harmless; building for something newer is #515. + * Must equal `mac.minimumSystemVersion` in electron-builder.json5, which is what the .app + * tells LaunchServices; before-pack.test.mjs asserts exactly that, so the two cannot drift + * apart quietly. Not read from the config at runtime because this hook must keep working + * if that file is ever restructured — a guard that throws while parsing is a guard that + * gets deleted. */ -const MAC_MIN_OS_FLOOR = "12.0"; +const MAC_MIN_OS_FLOOR = "13.0"; /** * The one supported way past the ceiling, for the one case it does not fit: a developer diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 3e993a665..5c34f59cd 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -197,8 +197,19 @@ describe("machoMinOs", () => { }); describe("checkMacOsVersionFloor", () => { - it("passes a payload built at the floor", () => { - withPayload({ a: thinMachO("12.0"), b: thinMachO("11.0") }, (dir) => { + /** + * Fixtures are derived from the floor rather than written as literals. An earlier + * revision hardcoded the then-current floor as "the offending version", and raising + * the floor silently turned the offender into a compliant binary — the guard's own + * tests stopped testing it. The exact versions were never the point; being on the + * wrong side of the floor is. + */ + const floorMajor = Number(testing().MAC_MIN_OS_FLOOR.split(".")[0]); + const above = (bump = 1) => `${floorMajor + bump}.0`; + const below = () => `${floorMajor - 1}.0`; + + it("passes a payload built at or below the floor", () => { + withPayload({ a: thinMachO(testing().MAC_MIN_OS_FLOOR), b: thinMachO(below()) }, (dir) => { expect(() => testing().checkMacOsVersionFloor(dir)).not.toThrow(); }); }); @@ -209,7 +220,7 @@ describe("checkMacOsVersionFloor", () => { * hits it to understand the consequence rather than just raise the constant. */ it("refuses a binary built above the floor, and says which and why", () => { - withPayload({ "openscreen-macos-cursor-helper": thinMachO("13.0") }, (dir) => { + withPayload({ "openscreen-macos-cursor-helper": thinMachO(above()) }, (dir) => { let message = ""; try { testing().checkMacOsVersionFloor(dir); @@ -217,8 +228,8 @@ describe("checkMacOsVersionFloor", () => { message = err.message; } expect(message).toContain("openscreen-macos-cursor-helper"); - expect(message).toContain("macOS 13.0.0"); - expect(message).toContain("12.0"); + expect(message).toContain(`macOS ${above()}.0`); + expect(message).toContain(testing().MAC_MIN_OS_FLOOR); expect(message).toContain("#515"); // The mechanism, so nobody "fixes" it by assuming dyld gates on the number. expect(message).toContain("Symbol not found"); @@ -227,7 +238,11 @@ describe("checkMacOsVersionFloor", () => { it("reports every offender, not just the first", () => { withPayload( - { ok: thinMachO("12.0"), bad1: thinMachO("13.0"), bad2: thinMachO("26.0") }, + { + ok: thinMachO(testing().MAC_MIN_OS_FLOOR), + bad1: thinMachO(above(1)), + bad2: thinMachO(above(2)), + }, (dir) => { expect(() => testing().checkMacOsVersionFloor(dir)).toThrow(/bad1[\s\S]*bad2/); }, @@ -252,23 +267,22 @@ describe("checkMacOsVersionFloor", () => { }); describe("MAC_MIN_OS_FLOOR", () => { - it("is no higher than the oldest macOS the README promises", () => { - const readme = readFileSync(path.join(path.dirname(BEFORE_PACK), "..", "README.md"), "utf8"); - const claimed = readme.match(/^-\s+\*\*macOS\*\*:\s*(\d+(?:\.\d+)?)/m); - expect(claimed, "no macOS line found in README.md system requirements").not.toBeNull(); + it("matches the floor the .app declares to LaunchServices", () => { + const config = readFileSync( + path.join(path.dirname(BEFORE_PACK), "..", "electron-builder.json5"), + "utf8", + ); + const declared = config.match(/"minimumSystemVersion"\s*:\s*"([\d.]+)"/); + expect( + declared, + 'no "minimumSystemVersion" in electron-builder.json5 — without it the .app ' + + "inherits Electron's own floor, which is what let #515 ship", + ).not.toBeNull(); const { MAC_MIN_OS_FLOOR } = testing(); - const asNumbers = (v) => v.split(".").map(Number); - const [floorMajor, floorMinor = 0] = asNumbers(MAC_MIN_OS_FLOOR); - const [claimedMajor, claimedMinor = 0] = asNumbers(claimed[1]); - - // One-directional: building for older than advertised is harmless, building for - // newer is the bug. So floor <= claimed, not floor === claimed. - expect( - floorMajor * 1000 + floorMinor, - `before-pack.cjs builds for macOS ${MAC_MIN_OS_FLOOR} but README.md promises ` + - `${claimed[1]} or later. Shipping binaries that cannot run on a version the ` + - "README claims to support is issue #515.", - ).toBeLessThanOrEqual(claimedMajor * 1000 + claimedMinor); + const norm = (v) => v.split(".").concat(["0", "0"]).slice(0, 2).join("."); + // Equal, not merely <=: a pack-time guard looser than the app's own declaration + // would wave through exactly the binaries LaunchServices then refuses to run. + expect(norm(MAC_MIN_OS_FLOOR)).toBe(norm(declared[1])); }); }); diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index 71cfa6e6f..74f5be046 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -74,9 +74,9 @@ readonly OS_ARCH="$(os_arch_tag)" readonly OUT_DIR="${OUT_ROOT}/${OS_ARCH}" # Kept beside the other build constants so it is greppable next to the ffmpeg one in -# scripts/fetch-ffmpeg-macos.mjs; the two must agree, and both must be <= the app's -# LSMinimumSystemVersion. -readonly MACOS_DEPLOYMENT_TARGET="12.0" +# scripts/fetch-ffmpeg-macos.mjs; the two must agree, and both must match +# `mac.minimumSystemVersion` in electron-builder.json5. +readonly MACOS_DEPLOYMENT_TARGET="13.0" # Determine the default backend flag for this host. backend_flag_for_host() { @@ -310,8 +310,8 @@ BUILD_FLAGS=() if [[ -n "${DEFAULT_FLAG}" ]]; then BUILD_FLAGS+=("${DEFAULT_FLAG}") fi -# Pin the macOS floor the app actually ships against (Electron's own -# LSMinimumSystemVersion, and what README/installation.md promise). Without it CMake +# Pin the macOS floor the app actually ships against (`mac.minimumSystemVersion` in +# electron-builder.json5). Without it CMake # defaults the deployment target to the BUILD MACHINE's SDK, so whisper-stt-server and # the libwhisper/libggml*/libparakeet dylibs inherit whatever macOS built them — # measured 26.0 on the shipped v1.10.0 payload, and ~15.x from CI's `macos-latest`, diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index 52563ee97..48d92669b 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -29,8 +29,10 @@ const ROOT = path.join(__dirname, ".."); const CRATES_DIR = path.join(ROOT, "crates"); /** Pinned release. The directory name is what build.rs looks for. */ -// The macOS floor the app ships against (Electron 41's own LSMinimumSystemVersion, and -// what README/installation.md promise). Without it, clang defaults the deployment target +// The macOS floor the app ships against — keep in step with `mac.minimumSystemVersion` +// in electron-builder.json5, which is what the .app tells LaunchServices. +// +// Without it, clang defaults the deployment target // to the BUILD MACHINE's SDK, so the vendored dylibs inherit whatever macOS built them — // measured 26.0 on a local build and ~15.x from CI's `macos-latest`, a floor that moves on // its own every time GitHub rolls that image. That is the same class of leak the configure @@ -39,7 +41,7 @@ const CRATES_DIR = path.join(ROOT, "crates"); // Note this is NOT a loader version gate: dyld does not refuse a dylib whose minos exceeds // the running OS (verified). Setting it is what makes the LINKER enforce macOS 12 symbol // availability, which is the thing that actually breaks at load time. See issue #515. -const MACOS_DEPLOYMENT_TARGET = "12.0"; +const MACOS_DEPLOYMENT_TARGET = "13.0"; const VERSION = "8.1.2"; const TARBALL_SHA256 = "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c"; From f0fc8a7fdc76e5c324a3f072c806fc228168284c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 30 Aug 2026 10:12:46 +0200 Subject: [PATCH 11/13] test(build): read the declared floor through the shared parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before-pack.test.mjs had its own `"minimumSystemVersion"` regex over the whole of electron-builder.json5 — the same shape review flagged in the Package.swift guard, in the third copy of it. The config is heavily commented and its comments name that key, so a file-wide match is one edit away from asserting against prose. Uses scripts/macos-floor.mjs instead, which scopes to the `mac` block and strips comments string-aware so URLs survive. One implementation to harden. Refs #515 --- scripts/before-pack.test.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 5c34f59cd..bc204c737 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -103,6 +103,7 @@ describe("symbol-version ceiling", () => { import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import { declaredAppVersionFrom } from "./macos-floor.mjs"; /** Packs X.Y.Z the way LC_BUILD_VERSION does: one byte per component, X in the top half. */ function packVersion(text) { @@ -268,21 +269,22 @@ describe("checkMacOsVersionFloor", () => { describe("MAC_MIN_OS_FLOOR", () => { it("matches the floor the .app declares to LaunchServices", () => { - const config = readFileSync( - path.join(path.dirname(BEFORE_PACK), "..", "electron-builder.json5"), - "utf8", + // Shared parser rather than a regex of its own: electron-builder.json5 is heavily + // commented, its comments name this very key, and a private copy here is how the + // two guards drift into one hardened and one not (see scripts/macos-floor.mjs). + const declared = declaredAppVersionFrom( + readFileSync(path.join(path.dirname(BEFORE_PACK), "..", "electron-builder.json5"), "utf8"), ); - const declared = config.match(/"minimumSystemVersion"\s*:\s*"([\d.]+)"/); expect( declared, - 'no "minimumSystemVersion" in electron-builder.json5 — without it the .app ' + - "inherits Electron's own floor, which is what let #515 ship", + 'no "minimumSystemVersion" in the mac block of electron-builder.json5 — without ' + + "it the .app inherits Electron's own floor, which is what let #515 ship", ).not.toBeNull(); const { MAC_MIN_OS_FLOOR } = testing(); const norm = (v) => v.split(".").concat(["0", "0"]).slice(0, 2).join("."); // Equal, not merely <=: a pack-time guard looser than the app's own declaration // would wave through exactly the binaries LaunchServices then refuses to run. - expect(norm(MAC_MIN_OS_FLOOR)).toBe(norm(declared[1])); + expect(norm(MAC_MIN_OS_FLOOR)).toBe(norm(declared)); }); }); From 3da56729eaa034e8300cd629480cd3ab6f15124a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 30 Aug 2026 11:15:53 +0200 Subject: [PATCH 12/13] fix(build): stop the floor guard advising a version it would reject The remediation block still told whoever tripped the guard to set `platforms: [.macOS(.v12)]`, left over from when the floor was 12. Following it would reinstate the mismatch the guard exists to catch, in the other direction: helpers built below the version the .app declares. Derived from MAC_MIN_OS_FLOOR rather than spelled out, so the advice cannot disagree with the floor it is enforcing again. Refs #515 --- scripts/before-pack.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index b06568008..d9701c654 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -983,7 +983,10 @@ function checkMacOsVersionFloor(dir) { "#515: a helper built for 13 stranded every macOS 12 user, and the app reported it\n" + "as a denied Accessibility permission.\n\n" + "Pin the deployment target in whichever script built the file:\n\n" + - " Swift platforms: [.macOS(.v12)] electron/native/screencapturekit/Package.swift\n" + + // Derived, not spelled out: this line said `.v12` for a while after the floor + // moved to 13, i.e. the guard's own remediation advice contradicted the floor + // it was enforcing. + ` Swift platforms: [.macOS(.v${MAC_MIN_OS_FLOOR.split(".")[0]})] electron/native/screencapturekit/Package.swift\n` + " CMake -DCMAKE_OSX_DEPLOYMENT_TARGET scripts/build-whisper-stt.sh\n" + " clang -mmacosx-version-min scripts/fetch-ffmpeg-macos.mjs\n" + " rustc MACOSX_DEPLOYMENT_TARGET scripts/build-macos-compositor-addon.mjs\n\n" + From 1caf33ed70d87890a7e27aa33b01dd2564dd9695 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 30 Aug 2026 12:02:20 +0200 Subject: [PATCH 13/13] docs(linux): align the two guides on what Linux actually captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims went stale when evdev click capture landed, and they disagreed with each other across the two public guides. README said "click effects remain macOS and Windows only". That was true of the portal, which still reports no mouse button events, but no longer true of the app: the capture helper reads the left button from evdev instead. Rewritten to say so, with the condition that actually matters to a user — the `input` group — and what happens without it (recording unaffected, every sample a move). website/docs/installation.md called the Linux capture pipeline "Browser pipeline" while README described native PipeWire capture with a browser fallback. The README was right: startNativeLinuxRecordingIfAvailable takes the native path and only returns false on a missing helper, where the comment reads "Falling back beats refusing to record". The table now says the same thing, and names what the fallback costs. Verified against the source rather than the docs — input.rs (left button only, `input` group, OPENSCREEN_DISABLE_CLICK_CAPTURE), pipeWireCursorRecordingSession and the Linux branch of useScreenRecorder — since writing a capability claim we cannot keep is the defect this PR exists to fix. Neither line was introduced here; both arrived on main with the Linux click work. Corrected here rather than left to drift because this PR already touches both files' macOS rows. --- README.md | 2 +- website/docs/installation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a43aa38b..e0b38d8ef 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ You may need to grant screen recording permissions depending on your desktop env Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be: - **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. -- **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**. +- **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. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs your user in the `input` group. Without that, recording is unaffected and every cursor sample is simply a move. - **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**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission. diff --git a/website/docs/installation.md b/website/docs/installation.md index 71309012b..528769017 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -131,7 +131,7 @@ The editing tools are the same everywhere — zooms, backgrounds, crop/trim/spee | | macOS | Windows | Linux | |---|---|---|---| -| Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Browser pipeline | +| Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Native (PipeWire via the ScreenCast portal); browser fallback without the helper, losing hardware encode and cursor telemetry | | 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 | 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+) |