diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 3bf20d40e..48ab5ba96 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -41,6 +41,8 @@ interface Window { opened: boolean; reason?: string; }>; + /** Sources are picked in Apple's system picker (macOS 15.2+), not in an app list. */ + usesSystemSourcePicker?: () => Promise; openNotes: () => Promise<{ opened: boolean; reason?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index a7d1b25cb..27e90028b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -83,6 +83,14 @@ import { import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; +import { + isMacPickerSourceId, + MAC_PICKER_SOURCE_PREFIX, + type MacPickerSelection, + MacPickerSession, + macSystemPickerEnabled, + markMacSystemPickerUnavailable, +} from "../native-bridge/screen/macPickerSession"; import { getMacPermissions, showPermissionsWindow } from "../permissions"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; import { @@ -781,6 +789,11 @@ async function removeNativeWindowsCaptureOutputs( } } let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; +/** + * Apple's system picker session (macOS 15.2+), started on first use and kept for the app's + * life: the pick it holds cannot leave that process. See macPickerSession.ts. + */ +let macPickerSession: MacPickerSession | null = null; let nativeMacCaptureOutput = ""; let nativeMacCaptureTargetPath: string | null = null; let nativeMacCaptureRecordingId: number | null = null; @@ -1005,7 +1018,55 @@ function resolveAssetBasePath() { } } +/** Whether sources come from Apple's picker in this run. */ +function macPickerOwnsSources() { + return macSystemPickerEnabled(); +} + +async function getMacPickerSession(): Promise { + if (!macPickerOwnsSources()) { + return null; + } + if (macPickerSession) { + return macPickerSession; + } + const helperPath = await findNativeMacCaptureHelperPath(); + const session = helperPath ? new MacPickerSession(helperPath) : null; + if (!session || !(await session.start())) { + // A helper that predates `--picker-session`, or none at all: the app's own picker + // and the Screen Recording grant, for the rest of this run. + console.warn("[mac-picker] falling back to the app's own source picker"); + markMacSystemPickerUnavailable(); + return null; + } + macPickerSession = session; + return session; +} + +function selectedSourceFromPick(pick: MacPickerSelection): SelectedSource { + const display = + pick.displayId !== null + ? screen.getAllDisplays().find((candidate) => candidate.id === pick.displayId) + : undefined; + const name = + pick.kind === "window" ? pick.title || pick.appName || "Window" : display?.label || "Screen"; + return { + id: `${MAC_PICKER_SOURCE_PREFIX}${pick.kind}:${pick.windowId ?? pick.displayId ?? 0}`, + name, + display_id: pick.displayId !== null ? String(pick.displayId) : "", + }; +} + function getSelectedSourceBounds() { + // A pick from Apple's picker carries its own frame; there is no desktopCapturer + // display to look up for it. + if (isMacPickerSourceId(selectedSource?.id)) { + const pick = macPickerSession?.getSelection(); + if (pick) { + return pick.bounds; + } + } + // Single-window capture records only the window's region, not the whole display. // Normalizing the cursor against display bounds leaves a fixed offset in the export, // so prefer the helper-reported window frame when capturing a window. @@ -1996,6 +2057,54 @@ export function registerIpcHandlers( }, ); + async function presentMacSystemPicker(session: MacPickerSession) { + // The HUD and the notes window belong to this process, not to the helper, so a display + // pick would record them unless the picker is told to leave them out. + const appWindowSourceIds = [getMainWindow(), getNotesWindow()] + .filter((window): window is BrowserWindow => !!window && !window.isDestroyed()) + .map((window) => window.getMediaSourceId()); + const excludedWindowIds = collectMacCaptureExcludedWindowIds(appWindowSourceIds); + // Out of the way while the picker is up. The HUD window is far larger than the bar + // it draws (a transparent reserve above it), and Apple's picker targets windows by + // their frame, not by where clicks land -- so that invisible rectangle hid every + // window behind it from the picker. Its exclusion from the capture is by window id, + // so hiding it does not bring it back into a display pick. + const hud = getMainWindow(); + const hideHud = !!hud && !hud.isDestroyed() && hud.isVisible(); + if (hideHud) { + hud.hide(); + } + let pick: MacPickerSelection | null; + try { + pick = await session.present(excludedWindowIds); + } finally { + if (hideHud && !hud.isDestroyed()) { + hud.showInactive(); + } + } + if (!pick) { + // Same signal our own picker window sends when it closes without a choice: the HUD + // stops waiting to record after a selection. + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("source-selector-closed"); + } + } + return; + } + selectedSource = selectedSourceFromPick(pick); + selectedDesktopSource = null; + broadcastSelectedSource(selectedSource); + } + + app.on("will-quit", () => { + macPickerSession?.dispose(); + }); + + // For the renderer's own source lists (the AI editor's recording stage): with Apple's + // picker they must hand the choice to `open-source-selector` rather than enumerate. + ipcMain.handle("uses-system-source-picker", () => macPickerOwnsSources()); + ipcMain.handle("get-selected-source", async () => { const previousSelectedSource = selectedSource; if (process.platform === "linux" && findPipeWireCursorHelperPath()) { @@ -2006,6 +2115,22 @@ export function registerIpcHandlers( } return null; } + if (macPickerOwnsSources()) { + // Apple's picker owns the choice. A pick lives only as long as the helper session + // that holds it, so there is nothing to restore -- and enumerating here would ask + // for the very Screen Recording grant the picker makes unnecessary. A source the + // CLI selected by id in this run is still answered as it is: `record` picks by + // name, headless, and keeps the per-take helper. + if (!isMacPickerSourceId(selectedSource?.id)) { + return selectedDesktopSource ? selectedSource : null; + } + if (!macPickerSession?.getSelection()) { + selectedSource = null; + selectedDesktopSource = null; + broadcastSelectedSource(null); + } + return selectedSource; + } const lastSource = appSettings.getSnapshot().lastSource; const liveSelected = selectedSource?.id != null @@ -2139,6 +2264,14 @@ export function registerIpcHandlers( return { opened: false, reason: "portal-owns-selection" }; } + const pickerSession = await getMacPickerSession(); + if (pickerSession) { + // Answered at once: the pick arrives later through `selected-source-changed`, which + // is how the HUD already learns about a choice made in our own picker window. + void presentMacSystemPicker(pickerSession); + return { opened: true }; + } + // Chromium's picker can only list sources once THIS process can capture, which on // macOS means granted, and granted before launch: the app's own read is cached for // the life of the process. Anything short of that belongs in the permissions @@ -2801,13 +2934,40 @@ export function registerIpcHandlers( const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); const cursorCaptureMode = normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; - try { - await desktopCapturer.getSources({ - types: ["screen"], - thumbnailSize: { width: 1, height: 1 }, - }); - } catch { - // The helper reports the final ScreenCaptureKit permission status. + // A source from Apple's picker records through the session that holds the pick, + // and needs no Screen Recording grant -- so nothing here may go near one. + const pickerSession = isMacPickerSourceId(request.source.sourceId) ? macPickerSession : null; + const pick = pickerSession?.getSelection() ?? null; + if (isMacPickerSourceId(request.source.sourceId) && !pick) { + selectedSource = null; + broadcastSelectedSource(null); + return { + success: false, + error: "The screen or window you picked is no longer available. Pick it again.", + }; + } + if (!pickerSession) { + try { + await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1, height: 1 }, + }); + } catch { + // The helper reports the final ScreenCaptureKit permission status. + } + } + // System audio is the one thing a pick does not cover: without the Screen Recording + // grant ScreenCaptureKit delivers it as silence (measured). Recording silence and + // calling it system audio would be worse than saying so, so the take goes ahead + // without it and the permissions window explains what would bring it back. + let systemAudioUnavailable = false; + if (pickerSession && request.audio?.system?.enabled) { + const permissions = await getMacPermissions().read(); + if (permissions.screen !== "granted") { + systemAudioUnavailable = true; + request = { ...request, audio: { ...request.audio, system: { enabled: false } } }; + showPermissionsWindow(); + } } if (request.audio?.microphone?.enabled) { const micStatus = systemPreferences.getMediaAccessStatus("microphone"); @@ -2820,7 +2980,8 @@ export function registerIpcHandlers( ? (screen.getAllDisplays().find((display) => display.id === request.source.displayId) ?? null) : getSelectedDisplay(); - const bounds = request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds(); + const bounds = + pick?.bounds ?? request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds(); const captureExcludedWindowSourceIds: string[] = []; if (request.source.type === "display") { for (const window of [getMainWindow(), getNotesWindow()]) { @@ -2890,10 +3051,12 @@ export function registerIpcHandlers( pendingCursorRecordingData = null; } - const proc = spawn(helperPath, [JSON.stringify(config)], { - cwd: RECORDINGS_DIR, - stdio: ["pipe", "pipe", "pipe"], - }); + const proc = pickerSession + ? pickerSession.startTake(config) + : spawn(helperPath, [JSON.stringify(config)], { + cwd: RECORDINGS_DIR, + stdio: ["pipe", "pipe", "pipe"], + }); nativeMacCaptureProcess = proc; // When the take ends without the user — the helper reported an error or // exited — this drives the renderer's own stop, the same one the tray's Stop @@ -2931,6 +3094,7 @@ export function registerIpcHandlers( path: outputPath, helperPath, microphoneDefaulted, + systemAudioUnavailable, }; } catch (error) { console.error("Failed to start native macOS recording:", error); diff --git a/electron/native-bridge/screen/macPickerSession.test.ts b/electron/native-bridge/screen/macPickerSession.test.ts new file mode 100644 index 000000000..1c139905e --- /dev/null +++ b/electron/native-bridge/screen/macPickerSession.test.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { + isMacPickerSourceId, + MacPickerSession, + parsePickerSelection, + supportsMacSystemPicker, +} from "./macPickerSession"; + +/** The `--picker-session` helper, driven by hand: what it hears, and a way to speak. */ +class FakeSessionHelper extends EventEmitter { + stdin = new PassThrough(); + stdout = new PassThrough(); + stderr = new PassThrough(); + commands: string[] = []; + + constructor() { + super(); + this.stdin.on("data", (chunk: Buffer) => { + this.commands.push(...chunk.toString().split("\n").filter(Boolean)); + }); + } + + say(event: Record) { + this.stdout.write(`${JSON.stringify(event)}\n`); + } + + kill() { + this.emit("close", null, "SIGTERM"); + return true; + } +} + +const DISPLAY_PICK = { + event: "picker-selected", + kind: "display", + displayId: 1, + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + pointPixelScale: 2, +}; + +async function flush() { + await new Promise((resolve) => setImmediate(resolve)); +} + +async function readySession() { + const helper = new FakeSessionHelper(); + const spawnHelper = vi.fn(() => helper); + const session = new MacPickerSession("/helper", spawnHelper as never); + const started = session.start(); + helper.say({ event: "picker-session-ready" }); + expect(await started).toBe(true); + expect(spawnHelper).toHaveBeenCalledWith("/helper", ["--picker-session"], expect.anything()); + return { helper, session }; +} + +async function pickDisplay() { + const ready = await readySession(); + const pick = ready.session.present([7, 8]); + await flush(); + ready.helper.say(DISPLAY_PICK); + expect(await pick).not.toBeNull(); + return ready; +} + +function lines(stream: PassThrough) { + const seen: string[] = []; + stream.on("data", (chunk: Buffer) => seen.push(...chunk.toString().split("\n").filter(Boolean))); + return seen; +} + +describe("supportsMacSystemPicker", () => { + it("starts at 15.2, where the pick can be located on screen", () => { + expect(supportsMacSystemPicker("15.1.1")).toBe(false); + expect(supportsMacSystemPicker("15.2")).toBe(true); + expect(supportsMacSystemPicker("26.5.0")).toBe(true); + expect(supportsMacSystemPicker("14.7")).toBe(false); + }); +}); + +describe("parsePickerSelection", () => { + it("reads a window pick with its title and frame", () => { + expect( + parsePickerSelection({ + event: "picker-selected", + kind: "window", + windowId: 42, + displayId: 1, + title: "Notes", + appName: "Notes", + bounds: { x: 10, y: 20, width: 300, height: 200 }, + }), + ).toEqual({ + kind: "window", + windowId: 42, + displayId: 1, + title: "Notes", + appName: "Notes", + bounds: { x: 10, y: 20, width: 300, height: 200 }, + }); + }); + + it("refuses a pick it cannot place on screen", () => { + expect(parsePickerSelection({ event: "picker-selected", kind: "display" })).toBeNull(); + }); +}); + +describe("MacPickerSession", () => { + it("falls back when the helper never reports ready", async () => { + vi.useFakeTimers(); + try { + const helper = new FakeSessionHelper(); + const session = new MacPickerSession("/helper", (() => helper) as never); + const started = session.start(); + await vi.advanceTimersByTimeAsync(5_000); + expect(await started).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("presents the picker with the app's windows excluded, and keeps the pick", async () => { + const { helper, session } = await readySession(); + + const pick = session.present([7, 8]); + await flush(); + expect(JSON.parse(helper.commands[0] ?? "{}")).toMatchObject({ + command: "present", + excludedWindowIds: [7, 8], + }); + + helper.say(DISPLAY_PICK); + expect(await pick).toMatchObject({ kind: "display", displayId: 1 }); + expect(session.getSelection()).toMatchObject({ bounds: { width: 1920 } }); + }); + + it("answers null when the user cancels", async () => { + const { helper, session } = await readySession(); + const pick = session.present([]); + await flush(); + helper.say({ event: "picker-cancelled" }); + expect(await pick).toBeNull(); + expect(session.getSelection()).toBeNull(); + }); + + it("refuses to start a take before anything was picked", async () => { + const { session } = await readySession(); + expect(() => session.startTake({})).toThrow(/Pick a screen or window/); + }); + + it("hands each take only its own events, and ends it on take-ended", async () => { + const { helper, session } = await pickDisplay(); + + const first = session.startTake({ video: { fps: 30 } }); + const firstLines = lines(first.stdout as PassThrough); + const closed = new Promise((resolve) => first.once("close", (code) => resolve(code))); + await flush(); + expect(JSON.parse(helper.commands.at(-1) ?? "{}")).toMatchObject({ command: "start" }); + + helper.say({ + event: "recording-started", + captureBounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }); + first.stdin.write("stop\n"); + await flush(); + expect(helper.commands.at(-1)).toBe("stop"); + helper.say({ event: "recording-stopped", screenPath: "/a.mp4" }); + helper.say({ event: "take-ended" }); + expect(await closed).toBe(0); + expect(firstLines.map((line) => JSON.parse(line).event)).toEqual([ + "recording-started", + "recording-stopped", + ]); + + // The second take starts from the same pick and sees none of the first one's output. + const second = session.startTake({ video: { fps: 30 } }); + const secondLines = lines(second.stdout as PassThrough); + helper.say({ event: "recording-started" }); + await flush(); + expect(secondLines.map((line) => JSON.parse(line).event)).toEqual(["recording-started"]); + }); + + it("reports a take that failed without a file as a failed exit", async () => { + const { helper, session } = await pickDisplay(); + const take = session.startTake({}); + const closed = new Promise((resolve) => take.once("close", (code) => resolve(code))); + helper.say({ event: "error", code: "helper-error", message: "boom" }); + helper.say({ event: "take-ended" }); + expect(await closed).toBe(1); + }); + + it("stops the take, not the session, when the take is killed", async () => { + const { helper, session } = await pickDisplay(); + const take = session.startTake({}); + take.kill(); + await flush(); + expect(helper.commands.at(-1)).toBe("stop"); + }); + + it("ends a running take and forgets the pick when the session dies", async () => { + const { helper, session } = await pickDisplay(); + const take = session.startTake({}); + const closed = new Promise((resolve) => take.once("close", (_code, signal) => resolve(signal))); + helper.kill(); + expect(await closed).toBe("SIGTERM"); + expect(session.getSelection()).toBeNull(); + }); +}); + +describe("isMacPickerSourceId", () => { + it("tells a picked source from a desktopCapturer id", () => { + expect(isMacPickerSourceId("mac-picker:display:1")).toBe(true); + expect(isMacPickerSourceId("screen:1:0")).toBe(false); + expect(isMacPickerSourceId(undefined)).toBe(false); + }); +}); diff --git a/electron/native-bridge/screen/macPickerSession.ts b/electron/native-bridge/screen/macPickerSession.ts new file mode 100644 index 000000000..b8b4397c7 --- /dev/null +++ b/electron/native-bridge/screen/macPickerSession.ts @@ -0,0 +1,368 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; + +/** + * Apple's system picker on macOS 15.2+, and the long-lived helper that records from it. + * + * A capture started from `SCContentSharingPicker` needs no Screen Recording grant and never + * raises macOS 15's "bypass the system private window picker" alert: the user's pick is + * the consent. That consent lives in a filter that cannot leave the helper process, so one + * helper (`--picker-session`) stays up for the whole app session, shows the picker, and + * records every take from the retained pick. See PickerSession.swift. + * + * The rest of the macOS recording code was written against one helper process per take: + * it reads a take's events from `proc.stdout`, sends `pause`/`resume`/`stop` on + * `proc.stdin`, and treats `close` as "this take's output is complete". `startTake` + * therefore hands back a `TakeProcess` with exactly that surface, carrying only this + * take's lines and "exiting" on the session's `take-ended`, so start, stop, salvage and + * the mid-capture error watch all run unchanged. + */ + +/** The id prefix of a source picked in Apple's picker. It is not a desktopCapturer id. */ +export const MAC_PICKER_SOURCE_PREFIX = "mac-picker:"; + +export function isMacPickerSourceId(id: unknown): boolean { + return typeof id === "string" && id.startsWith(MAC_PICKER_SOURCE_PREFIX); +} + +/** + * 15.2, not 14.0 where the picker API starts: the filter only reports which display or + * window was picked, and where, from 15.2 -- and the cursor telemetry needs that. + */ +export function supportsMacSystemPicker(systemVersion: string): boolean { + const [major = 0, minor = 0] = systemVersion.split(".").map((part) => Number.parseInt(part, 10)); + return major > 15 || (major === 15 && minor >= 2); +} + +let systemPickerUnavailable = false; + +/** + * For the rest of this run, sources come from the app's own picker: the session could not + * start (a helper that predates `--picker-session`, or none at all). + */ +export function markMacSystemPickerUnavailable() { + systemPickerUnavailable = true; +} + +/** + * Whether this Mac picks sources in Apple's picker. Also true while the helper has not + * been tried yet; `OPENSCREEN_MAC_SOURCE_PICKER=legacy` forces the app's own picker. + */ +export function macSystemPickerEnabled(): boolean { + return ( + process.platform === "darwin" && + !systemPickerUnavailable && + process.env.OPENSCREEN_MAC_SOURCE_PICKER !== "legacy" && + supportsMacSystemPicker(process.getSystemVersion()) + ); +} + +export interface MacPickerSelection { + kind: "display" | "window"; + displayId: number | null; + windowId: number | null; + /** Window title, empty for a display. */ + title: string; + /** Owning app of a picked window, empty for a display. */ + appName: string; + /** Global frame, points, top-left origin: what the cursor telemetry normalises against. */ + bounds: { x: number; y: number; width: number; height: number }; +} + +type HelperEvent = Record & { event?: unknown }; + +const READY_TIMEOUT_MS = 5_000; + +function parseEvent(line: string): HelperEvent | null { + try { + const parsed: unknown = JSON.parse(line); + return parsed && typeof parsed === "object" ? (parsed as HelperEvent) : null; + } catch { + return null; + } +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function parsePickerSelection(event: HelperEvent): MacPickerSelection | null { + const bounds = event.bounds as Record | undefined; + const x = asNumber(bounds?.x); + const y = asNumber(bounds?.y); + const width = asNumber(bounds?.width); + const height = asNumber(bounds?.height); + if (x === null || y === null || width === null || height === null) { + return null; + } + return { + kind: event.kind === "window" ? "window" : "display", + displayId: asNumber(event.displayId), + windowId: asNumber(event.windowId), + title: typeof event.title === "string" ? event.title : "", + appName: typeof event.appName === "string" ? event.appName : "", + bounds: { x, y, width, height }, + }; +} + +/** + * One take, dressed as the per-take helper process the recording code expects. + * Only the members that code touches exist; the cast at `startTake` says so. + */ +class TakeProcess extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdin: Writable; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killed = false; + private sawError = false; + private sawStopped = false; + + constructor(private readonly command: (line: string) => boolean) { + super(); + const take = this; + this.stdin = new Writable({ + write(chunk, _encoding, callback) { + // The single-take protocol's bare words (`pause`, `resume`, `stop`) are also + // the session's, so they pass through as they are. + for (const line of String(chunk).split(/\r?\n/)) { + if (line.trim() && !take.ended) { + take.command(line.trim()); + } + } + callback(); + }, + }); + } + + get ended() { + return this.exitCode !== null || this.signalCode !== null; + } + + deliver(line: string, event: HelperEvent | null) { + if (event?.event === "error") { + this.sawError = true; + } + if (event?.event === "recording-stopped") { + this.sawStopped = true; + } + this.stdout.write(`${line}\n`); + } + + /** + * The session's `take-ended`, read as the single-take helper's exit: 0 when the take + * finished its file or never failed, 1 when it reported an error and no file. + */ + end(signal: NodeJS.Signals | null = null) { + if (this.ended) { + return; + } + if (signal) { + this.signalCode = signal; + } else { + this.exitCode = this.sawError && !this.sawStopped ? 1 : 0; + } + this.stdin.end(); + this.stdout.end(); + this.stderr.end(); + this.emit("exit", this.exitCode, this.signalCode); + // `close` after the streams have flushed, as for a real child process. + setImmediate(() => this.emit("close", this.exitCode, this.signalCode)); + } + + /** A take cannot be killed on its own without killing every later one: stop it. */ + kill() { + this.killed = true; + if (!this.ended) { + this.command("stop"); + } + return true; + } +} + +export class MacPickerSession { + private proc: ChildProcessWithoutNullStreams | null = null; + private starting: Promise | null = null; + private selection: MacPickerSelection | null = null; + private take: TakeProcess | null = null; + private pendingPick: ((selection: MacPickerSelection | null) => void) | null = null; + private lineBuffer = ""; + + constructor( + private readonly helperPath: string, + private readonly spawnHelper: typeof spawn = spawn, + ) {} + + getSelection(): MacPickerSelection | null { + return this.selection; + } + + /** Starts the helper if it is not running. Resolves false when it cannot. */ + start(): Promise { + if (this.proc) { + return Promise.resolve(true); + } + this.starting ??= new Promise((resolve) => { + let proc: ChildProcessWithoutNullStreams; + try { + proc = this.spawnHelper(this.helperPath, ["--picker-session"], { + stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + } catch (error) { + console.warn("[mac-picker] could not start the picker session:", error); + this.starting = null; + resolve(false); + return; + } + + const timer = setTimeout(() => { + console.warn("[mac-picker] the picker session never reported ready"); + proc.kill(); + settle(false); + }, READY_TIMEOUT_MS); + const settle = (ok: boolean) => { + clearTimeout(timer); + this.starting = null; + resolve(ok); + }; + + proc.stdout.on("data", (chunk: Buffer) => { + this.lineBuffer += chunk.toString(); + const lines = this.lineBuffer.split(/\r?\n/); + this.lineBuffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.trim()) { + continue; + } + const event = parseEvent(line); + if (event?.event === "picker-session-ready") { + this.proc = proc; + settle(true); + continue; + } + this.route(line, event); + } + }); + proc.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + if (this.take) { + this.take.stderr.write(text); + } else { + console.warn(`[mac-picker] ${text.trim()}`); + } + }); + proc.stdin.on("error", (error) => console.warn("[mac-picker] command pipe error:", error)); + proc.on("error", (error) => { + console.warn("[mac-picker] helper error:", error); + settle(false); + }); + proc.once("close", (_code, signal) => { + this.onSessionGone(signal ?? "SIGTERM"); + settle(false); + }); + }); + return this.starting; + } + + /** + * Shows Apple's picker. Resolves with what the user picked, or null when they cancelled + * or the picker could not open. No timeout: a person is choosing. + */ + async present(excludedWindowIds: number[]): Promise { + if (!(await this.start())) { + return null; + } + // A second click while the picker is up re-presents it; the first caller gets the + // same answer as the second. + const previous = this.pendingPick; + const answer = new Promise((resolve) => { + this.pendingPick = (selection) => { + previous?.(selection); + resolve(selection); + }; + }); + this.send({ command: "present", excludedWindowIds, modes: ["display", "window"] }); + return answer; + } + + /** + * Starts a take from the retained pick. The returned object stands in for the per-take + * helper process; see the module comment. + */ + startTake(request: unknown): ChildProcessWithoutNullStreams { + if (!this.proc || !this.selection) { + throw new Error("Pick a screen or window in the system picker first."); + } + if (this.take && !this.take.ended) { + throw new Error("A recording is already running in the picker session."); + } + const take = new TakeProcess((line) => this.send(line)); + this.take = take; + this.send({ command: "start", request }); + return take as unknown as ChildProcessWithoutNullStreams; + } + + /** Ends the session with the app. Closing stdin makes the helper stop and exit. */ + dispose() { + this.proc?.stdin.end(); + this.proc = null; + } + + private send(command: string | Record): boolean { + const proc = this.proc; + if (!proc || !proc.stdin.writable) { + return false; + } + proc.stdin.write(`${typeof command === "string" ? command : JSON.stringify(command)}\n`); + return true; + } + + private route(line: string, event: HelperEvent | null) { + switch (event?.event) { + case "picker-presented": + return; + case "picker-selected": { + const selection = parsePickerSelection(event); + if (selection) { + this.selection = selection; + } + this.resolvePick(selection); + return; + } + case "picker-cancelled": + case "picker-failed": + if (event.event === "picker-failed") { + console.warn("[mac-picker] the picker failed:", event.message); + } + this.resolvePick(null); + return; + case "take-ended": + this.take?.end(); + this.take = null; + return; + } + if (this.take) { + this.take.deliver(line, event); + } else if (event?.event === "error" || event?.event === "warning") { + console.warn("[mac-picker] session:", line); + } + } + + private resolvePick(selection: MacPickerSelection | null) { + const pending = this.pendingPick; + this.pendingPick = null; + pending?.(selection); + } + + private onSessionGone(signal: NodeJS.Signals) { + this.proc = null; + this.lineBuffer = ""; + // The pick lived in that process; it is gone with it. + this.selection = null; + this.take?.end(signal); + this.take = null; + this.resolvePick(null); + } +} diff --git a/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/PickerSessionCommand.swift b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/PickerSessionCommand.swift new file mode 100644 index 000000000..b8051bfcb --- /dev/null +++ b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/PickerSessionCommand.swift @@ -0,0 +1,68 @@ +import Foundation + +/// What Apple's system picker may offer. Mirrors the helper's own source types. +public enum PickerMode: String, Equatable { + case display + case window +} + +/// One line of the `--picker-session` stdin protocol. +/// +/// The single-take helper takes its whole request on argv and reads only `pause`, `resume` +/// and `stop` afterwards. A picker session outlives any one take, so it needs a few more +/// verbs, and `start` carries the take's request instead of argv. The bare words stay bare +/// so both modes read the same three commands the same way. +public enum PickerSessionCommand: Equatable { + /// Show Apple's picker. `excludedWindowIDs` keeps OpenScreen's own HUD and notes out of + /// a display capture: the filter the picker hands back cannot be edited afterwards, so + /// the exclusion has to be part of the picker's configuration. + case present(excludedWindowIDs: [Int], modes: [PickerMode]) + /// Start a take from the retained choice. The request JSON is kept raw for the + /// recorder's own decoder. + case start(request: Data) + case pause + case resume + case stop + case quit +} + +public func parsePickerSessionCommand(_ line: String) -> PickerSessionCommand? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + switch trimmed { + case "pause": return .pause + case "resume": return .resume + case "stop": return .stop + case "quit": return .quit + default: break + } + + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let command = object["command"] as? String + else { + return nil + } + + switch command { + case "present": + let excluded = (object["excludedWindowIds"] as? [Any] ?? []).compactMap { ($0 as? NSNumber)?.intValue } + let requestedModes = (object["modes"] as? [String] ?? []).compactMap(PickerMode.init(rawValue:)) + return .present( + excludedWindowIDs: excluded, + modes: requestedModes.isEmpty ? [.display, .window] : requestedModes + ) + case "start": + guard let request = object["request"], + JSONSerialization.isValidJSONObject(request), + let requestData = try? JSONSerialization.data(withJSONObject: request) + else { + return nil + } + return .start(request: requestData) + case "pause": return .pause + case "resume": return .resume + case "stop": return .stop + case "quit": return .quit + default: return nil + } +} diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/PickerSession.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/PickerSession.swift new file mode 100644 index 000000000..b7a38d789 --- /dev/null +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/PickerSession.swift @@ -0,0 +1,252 @@ +import AppKit +import CoreGraphics +import Foundation +import OpenScreenCaptureCore +import ScreenCaptureKit + +/// `--picker-session`: Apple's system picker, and every take recorded from what it returns. +/// +/// Why a session instead of the usual one process per take: +/// +/// - A capture that starts from `SCContentSharingPicker` needs no Screen Recording grant and +/// never raises macOS 15's "bypass the system private window picker" alert: the user's +/// pick is the consent. Measured on macOS 26.5 for display and window picks, from a +/// helper process like this one. +/// - That consent lives in the `SCContentFilter` the picker hands back, and a filter cannot +/// leave the process (it is not NSSecureCoding). So the process that asked has to be the +/// one that records -- and has to stay alive to record the same source again without +/// asking the user to pick it again for every take. +/// +/// Each take still gets its own `ScreenCaptureRecorder` and its own `SCStream`, so pause, +/// the audio timeline and the writer behave exactly as in the single-take helper. +/// +/// Protocol: one command per stdin line (`PickerSessionCommand`), events on stdout as +/// everywhere else, plus `picker-session-ready`, `picker-presented`, `picker-selected`, +/// `picker-cancelled`, `picker-failed` and `take-ended`. Closing stdin ends the session: nothing here may +/// outlive the app. +/// +/// 15.2, not 14.0 where the picker API starts: `includedDisplays` and `includedWindows` +/// arrive in 15.2, and without them the pick cannot be mapped to a screen position, which +/// the cursor telemetry needs. Before 15.2 the app keeps its own picker. +/// +/// `@unchecked Sendable`: every piece of state here is read and written on the main queue +/// only -- commands and picker callbacks are all hopped onto it before they touch anything. +@available(macOS 15.2, *) +final class PickerSession: NSObject, SCContentSharingPickerObserver, @unchecked Sendable { + private var picked: PickedSource? + private var recorder: ScreenCaptureRecorder? + + func run() -> Never { + let app = NSApplication.shared + // No Dock icon and no menu bar: the picker is system UI and needs no window here. + app.setActivationPolicy(.accessory) + SCContentSharingPicker.shared.add(self) + + let reader = Thread { + while let line = readLine() { + guard let command = parsePickerSessionCommand(line) else { + emit([ + "event": "warning", + "code": "picker-session-unknown-command", + "message": line, + ]) + continue + } + DispatchQueue.main.async { self.handle(command) } + } + // stdin closed: the app is gone, or never meant to keep us. + DispatchQueue.main.async { self.shutdown() } + } + reader.start() + + emit(["event": "picker-session-ready"]) + app.run() + exit(0) + } + + private func handle(_ command: PickerSessionCommand) { + switch command { + case .present(let excludedWindowIDs, let modes): + present(excludedWindowIDs: excludedWindowIDs, modes: modes) + case .start(let request): + startTake(request) + case .pause: + recorder?.pause() + case .resume: + recorder?.resume() + case .stop: + stopTake() + case .quit: + shutdown() + } + } + + // MARK: - Picker + + private func present(excludedWindowIDs: [Int], modes: [PickerMode]) { + var configuration = SCContentSharingPickerConfiguration() + var allowed: SCContentSharingPickerMode = [] + if modes.contains(.display) { allowed.insert(.singleDisplay) } + if modes.contains(.window) { allowed.insert(.singleWindow) } + configuration.allowedPickerModes = allowed + // Swapping the source mid-take would change the frame size under a writer whose + // dimensions are fixed when the take starts. A new source is a new pick. + configuration.allowsChangingSelectedContent = false + // A picked display filter hides this process's own windows, but not the app's: + // the HUD and the notes window belong to Electron. Measured: a window owned by the + // parent app shows in the capture unless it is listed here. + configuration.excludedWindowIDs = excludedWindowIDs + + let picker = SCContentSharingPicker.shared + picker.defaultConfiguration = configuration + picker.isActive = true + picker.present() + emit(["event": "picker-presented"]) + } + + func contentSharingPicker( + _ picker: SCContentSharingPicker, + didUpdateWith filter: SCContentFilter, + for stream: SCStream? + ) { + DispatchQueue.main.async { self.didPick(filter, stream: stream) } + } + + func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) { + DispatchQueue.main.async { + // A cancel for a running stream is the user stopping it from the menu bar, which + // the take's own stop path already reports. + if stream == nil { + emit(["event": "picker-cancelled"]) + } + } + } + + func contentSharingPickerStartDidFailWithError(_ error: Error) { + DispatchQueue.main.async { + emit([ + "event": "picker-failed", + "message": "\(error)", + ]) + } + } + + private func didPick(_ filter: SCContentFilter, stream: SCStream?) { + // Updates for an existing stream are Apple's "change what you share", switched off + // by allowsChangingSelectedContent; one that arrives anyway must not swap the source. + guard stream == nil else { + return + } + + let display = filter.includedDisplays.first + let window = filter.includedWindows.first + let frame = window?.frame ?? display?.frame ?? filter.contentRect + let displayId = display?.displayID ?? Self.display(containing: frame) + picked = PickedSource(filter: filter, frame: frame, displayId: displayId) + + var event: [String: Any] = [ + "event": "picker-selected", + "kind": window != nil ? "window" : "display", + "bounds": [ + "x": frame.origin.x, + "y": frame.origin.y, + "width": frame.size.width, + "height": frame.size.height, + ], + "pointPixelScale": filter.pointPixelScale, + ] + if let displayId { + event["displayId"] = displayId + } + if let window { + event["windowId"] = window.windowID + event["title"] = window.title ?? "" + event["appName"] = window.owningApplication?.applicationName ?? "" + } + emit(event) + } + + private static func display(containing frame: CGRect) -> CGDirectDisplayID? { + var displays = [CGDirectDisplayID](repeating: 0, count: 8) + var count: UInt32 = 0 + let center = CGRect(x: frame.midX, y: frame.midY, width: 1, height: 1) + guard CGGetDisplaysWithRect(center, UInt32(displays.count), &displays, &count) == .success, + count > 0 + else { + return nil + } + return displays[0] + } + + // MARK: - Takes + + private func startTake(_ requestData: Data) { + guard recorder == nil else { + emitError(code: "take-already-running", message: "A take is already recording in this session.") + return + } + guard let picked else { + emitError(code: "no-source-picked", message: "Nothing was picked in the system picker yet.") + emit(["event": "take-ended"]) + return + } + + let request: RecordingRequest + do { + request = try JSONDecoder().decode(RecordingRequest.self, from: requestData) + } catch { + emitError(code: "helper-error", message: "\(error)") + emit(["event": "take-ended"]) + return + } + + let recorder = ScreenCaptureRecorder(request: request, picked: picked) + self.recorder = recorder + let take = ObjectIdentifier(recorder) + Task { + do { + try await recorder.start() + } catch { + // Same code the single-take helper reports on its way out, so the app reads a + // failed start the same way on both paths. + let message = (error as? HelperError)?.description ?? "\(error)" + emitError(code: "helper-error", message: message) + await recorder.stop() + DispatchQueue.main.async { self.finishTake(take) } + } + } + } + + private func stopTake() { + guard let recorder else { + emit(["event": "take-ended"]) + return + } + let take = ObjectIdentifier(recorder) + Task { + await recorder.stop() + DispatchQueue.main.async { self.finishTake(take) } + } + } + + /// The session's replacement for the single-take helper exiting: the one event that + /// says this take's output is complete. + private func finishTake(_ finished: ObjectIdentifier) { + guard let current = recorder, ObjectIdentifier(current) == finished else { + return + } + recorder = nil + emit(["event": "take-ended"]) + } + + private func shutdown() { + SCContentSharingPicker.shared.isActive = false + guard let recorder else { + exit(0) + } + Task { + await recorder.stop() + exit(0) + } + } +} diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 8b08e1554..97a7569e9 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -103,6 +103,14 @@ enum HelperError: Error, CustomStringConvertible { } } +/// A source chosen in Apple's system picker, kept by `PickerSession` for the takes after it. +struct PickedSource { + let filter: SCContentFilter + /// Global frame (points, top-left origin) of what was picked, for cursor mapping. + let frame: CGRect + let displayId: CGDirectDisplayID? +} + @available(macOS 13.0, *) final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private struct CaptureTarget { @@ -146,19 +154,30 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var captureFrame = CGRect.zero private let microphoneOutputTypeRawValue = 2 private let hostClock = CMClockGetHostTimeClock() + private let picked: PickedSource? - init(request: RecordingRequest) { + /// `picked` is a choice already made in Apple's system picker (`PickerSession`). The take + /// then records exactly that filter: no source lookup, and no Screen Recording check, + /// because the user's pick IS the consent -- asking for the grant here would put back + /// the very prompt the picker path exists to remove. + init(request: RecordingRequest, picked: PickedSource? = nil) { self.request = request + self.picked = picked } func start() async throws { - try ensureRequestedPermissions() + try ensureRequestedPermissions(screen: picked == nil) - let content = try await SCShareableContent.excludingDesktopWindows( - false, - onScreenWindowsOnly: true - ) - let target = try makeCaptureTarget(from: content) + let target: CaptureTarget + if let picked { + target = makeCaptureTarget(picked: picked) + } else { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + target = try makeCaptureTarget(from: content) + } outputWidth = target.width outputHeight = target.height captureFrame = target.captureFrame @@ -401,8 +420,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { ]) } - private func ensureRequestedPermissions() throws { - if !CGPreflightScreenCaptureAccess() { + private func ensureRequestedPermissions(screen: Bool) throws { + if screen && !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() if !granted { throw HelperError.permissionDenied("Screen recording permission is required for ScreenCaptureKit capture.") @@ -437,6 +456,20 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { ] } + private func makeCaptureTarget(picked: PickedSource) -> CaptureTarget { + let size = captureSize( + for: picked.filter, + fallbackPointSize: picked.frame.size, + fallbackDisplayId: picked.displayId ?? CGMainDisplayID() + ) + return CaptureTarget( + filter: picked.filter, + width: size.width, + height: size.height, + captureFrame: picked.frame + ) + } + private func makeCaptureTarget(from content: SCShareableContent) throws -> CaptureTarget { switch request.source.type { case "display": @@ -682,6 +715,25 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { return } + // A take that ended before its first frame -- a start that failed after the writer + // was set up, or a stop right away -- never called `startWriting`. Finishing (or + // even marking inputs finished on) a writer in that state raises an Objective-C + // exception, which kills the process: here, in a picker session, that would take + // every later take and the user's pick down with it. There is nothing to finalise: + // drop whatever file the writer may have created and say the take produced none. + if writer.status == .unknown { + sampleQueue.sync { + audioTicker?.cancel() + audioTicker = nil + } + try? FileManager.default.removeItem(atPath: request.outputs.screenPath) + emitError( + code: "writer-failed", + message: "The recording stopped before its first frame was written." + ) + return + } + // Capture has stopped, so nothing is in flight on the sample queue any more; hopping // onto it once is what makes the mixer's final flush safe without a lock, and it is // also where the ticker has to die, since that is the queue it fires on. @@ -935,6 +987,10 @@ struct OpenScreenScreenCaptureKitHelper { /// screen", printed as the usual single JSON line and nothing else. private static let screenAccessStatusFlag = "--screen-access-status" + /// The flag that turns this helper into a long-lived session around Apple's system + /// picker. See `PickerSession`. + private static let pickerSessionFlag = "--picker-session" + static func main() async { do { initializeCoreGraphicsWindowServerConnection() @@ -960,6 +1016,17 @@ struct OpenScreenScreenCaptureKitHelper { exit(0) } + if CommandLine.arguments.count == 2, CommandLine.arguments[1] == pickerSessionFlag { + guard #available(macOS 15.2, *) else { + emitError( + code: "picker-session-unsupported", + message: "The system picker session needs macOS 15.2 or later." + ) + exit(2) + } + PickerSession().run() + } + guard CommandLine.arguments.count == 2 else { throw HelperError.invalidArguments } diff --git a/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/PickerSessionCommandTests.swift b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/PickerSessionCommandTests.swift new file mode 100644 index 000000000..3391da9ec --- /dev/null +++ b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/PickerSessionCommandTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import OpenScreenCaptureCore + +final class PickerSessionCommandTests: XCTestCase { + func testReadsTheBareWordsTheSingleTakeHelperAlreadyUses() { + XCTAssertEqual(parsePickerSessionCommand("pause\n"), .pause) + XCTAssertEqual(parsePickerSessionCommand("resume"), .resume) + XCTAssertEqual(parsePickerSessionCommand(" stop "), .stop) + XCTAssertEqual(parsePickerSessionCommand("quit"), .quit) + } + + func testPresentCarriesTheWindowsToKeepOutOfTheCapture() { + XCTAssertEqual( + parsePickerSessionCommand(#"{"command":"present","excludedWindowIds":[12,34],"modes":["display"]}"#), + .present(excludedWindowIDs: [12, 34], modes: [.display]) + ) + } + + func testPresentOffersScreensAndWindowsByDefault() { + XCTAssertEqual( + parsePickerSessionCommand(#"{"command":"present"}"#), + .present(excludedWindowIDs: [], modes: [.display, .window]) + ) + } + + func testStartKeepsTheRequestForTheRecordersDecoder() throws { + guard case .start(let data)? = parsePickerSessionCommand( + #"{"command":"start","request":{"video":{"fps":60}}}"# + ) else { + return XCTFail("expected a start command") + } + let request = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual((request["video"] as? [String: Any])?["fps"] as? Int, 60) + } + + func testRejectsWhatItCannotRead() { + XCTAssertNil(parsePickerSessionCommand("")) + XCTAssertNil(parsePickerSessionCommand("record")) + XCTAssertNil(parsePickerSessionCommand(#"{"command":"start"}"#)) + XCTAssertNil(parsePickerSessionCommand(#"{"command":"explode"}"#)) + XCTAssertNil(parsePickerSessionCommand("{not json")) + } +} diff --git a/electron/permissions/index.ts b/electron/permissions/index.ts index 60f0f4898..896d2a0a4 100644 --- a/electron/permissions/index.ts +++ b/electron/permissions/index.ts @@ -8,6 +8,7 @@ import { shell, systemPreferences, } from "electron"; +import { macSystemPickerEnabled } from "../native-bridge/screen/macPickerSession"; import { readMacScreenCaptureAccess } from "../native-bridge/screen/macScreenAccess"; import { createPermissionsWindow } from "../windows"; import { @@ -90,6 +91,7 @@ export function getMacPermissions(): MacPermissions { permissions ??= createMacPermissions({ platform: process.platform, macosMajor: macosMajor(), + systemPickerOwnsScreen: macSystemPickerEnabled, probeScreen: async () => { const probe = await readMacScreenCaptureAccess(); return probe.status === "granted" || probe.status === "denied" diff --git a/electron/permissions/macPermissions.test.ts b/electron/permissions/macPermissions.test.ts index 61baf213c..b06662cd9 100644 --- a/electron/permissions/macPermissions.test.ts +++ b/electron/permissions/macPermissions.test.ts @@ -13,6 +13,7 @@ function setup(overrides: Partial = {}) { const deps: MacPermissionsDeps = { platform: "darwin", macosMajor: 26, + systemPickerOwnsScreen: () => false, probeScreen: vi.fn(async (): Promise => ({ answered: true, granted: false })), appScreenGranted: vi.fn(() => false), accessibilityTrusted: vi.fn(() => false), @@ -217,6 +218,49 @@ describe("shouldShowAtLaunch", () => { }); }); +describe("with Apple's system picker", () => { + const picker = { systemPickerOwnsScreen: () => true }; + + it("does not require Screen Recording to record", async () => { + const { permissions } = setup(picker); + expect(await permissions.read()).toMatchObject({ + screenRequired: false, + screen: "not-requested", + }); + }); + + it("never offers a relaunch, since nothing reads the app's cached refusal", async () => { + const { permissions } = setup({ + ...picker, + probeScreen: async () => ({ answered: true, granted: true }), + appScreenGranted: () => false, + }); + expect(await permissions.read()).toMatchObject({ + screen: "granted", + screenRequiresRelaunch: false, + }); + }); + + it("shows the window once, as an offer, while something is still unasked", async () => { + const { permissions } = setup(picker); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(true); + + permissions.noteWindowClosed(await permissions.read()); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(false); + }); + + it("stays away from someone who already answered everything", async () => { + const { permissions } = setup({ + ...picker, + probeScreen: async () => ({ answered: true, granted: true }), + appScreenGranted: () => true, + accessibilityTrusted: () => true, + mediaStatus: () => "denied", + }); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(false); + }); +}); + describe("permissionSettingsUrl", () => { it("uses the pane anchors that open on every supported macOS", () => { expect(permissionSettingsUrl("screen")).toBe( diff --git a/electron/permissions/macPermissions.ts b/electron/permissions/macPermissions.ts index 14ee5b9be..777392fe7 100644 --- a/electron/permissions/macPermissions.ts +++ b/electron/permissions/macPermissions.ts @@ -38,6 +38,12 @@ export interface PermissionsSnapshot { supported: boolean; /** macOS major version (13, 14, 15, 26...), 0 when unknown or off macOS. */ macosMajor: number; + /** + * Whether recording needs Screen Recording at all. Not when sources come from Apple's + * system picker (macOS 15.2+): the pick is the consent, and the grant only still + * matters for system audio. + */ + screenRequired: boolean; screen: PermissionStatus; /** * Screen Recording is granted, but this process still reads its cached refusal, so the @@ -66,6 +72,8 @@ export interface PermissionsStore { export interface MacPermissionsDeps { platform: NodeJS.Platform; macosMajor: number; + /** Sources are picked in Apple's system picker, which needs no Screen Recording grant. */ + systemPickerOwnsScreen(): boolean; probeScreen(): Promise; /** The app's own, per-process cached Screen Recording read. */ appScreenGranted(): boolean; @@ -112,6 +120,7 @@ function mediaPermissionStatus(status: string): PermissionStatus { const OFF_MACOS: PermissionsSnapshot = { supported: false, macosMajor: 0, + screenRequired: false, screen: "granted", screenRequiresRelaunch: false, accessibility: "granted", @@ -133,7 +142,12 @@ export function createMacPermissions(deps: MacPermissionsDeps) { if (!granted) { return { status: notedStatus("screen"), requiresRelaunch: false }; } - return { status: "granted", requiresRelaunch: !appGranted }; + // The relaunch only ever mattered to Chromium's picker, which reads the app's cached + // refusal. With Apple's picker nothing reads it. + return { + status: "granted", + requiresRelaunch: !appGranted && !deps.systemPickerOwnsScreen(), + }; } async function read(): Promise { @@ -145,6 +159,7 @@ export function createMacPermissions(deps: MacPermissionsDeps) { return { supported: true, macosMajor: deps.macosMajor, + screenRequired: !deps.systemPickerOwnsScreen(), screen: screen.status, screenRequiresRelaunch: screen.requiresRelaunch, accessibility: deps.accessibilityTrusted(false) ? "granted" : notedStatus("accessibility"), @@ -212,15 +227,24 @@ export function createMacPermissions(deps: MacPermissionsDeps) { if (!snapshot.supported) { return false; } + if (!snapshot.screenRequired) { + // Nothing blocks a recording, so the window is an offer, made once: until it has + // been closed, and only while something in it is still unasked. + if (deps.store.isCompleted()) { + return false; + } + const rows = ["screen", "accessibility", "microphone", "camera"] as const; + return rows.some((kind) => snapshot[kind] === "not-requested"); + } if (snapshot.screen !== "granted" || snapshot.screenRequiresRelaunch) { return true; } return deps.store.hasRequested("screen") && !deps.store.isCompleted(); } - /** Called when the window closes: done once Screen Recording is in hand. */ + /** Called when the window closes: done once recording no longer waits on it. */ function noteWindowClosed(snapshot: PermissionsSnapshot): void { - if (snapshot.screen === "granted") { + if (snapshot.screen === "granted" || !snapshot.screenRequired) { deps.store.markCompleted(); } } diff --git a/electron/preload.ts b/electron/preload.ts index ebefc7a1d..80145ea12 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -119,6 +119,9 @@ contextBridge.exposeInMainWorld("electronAPI", { openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, + usesSystemSourcePicker: (): Promise => { + return ipcRenderer.invoke("uses-system-source-picker"); + }, openNotes: () => { return ipcRenderer.invoke("open-notes"); }, diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 7c4e5612d..005fb53b2 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -117,6 +117,25 @@ describe("RecStage controls", () => { (window as unknown as { electronAPI?: unknown }).electronAPI = undefined; }); + it("hands the choice to Apple's picker instead of listing sources itself", async () => { + stubRecordingPrefs({ micEnabled: false }); + const api = window.electronAPI as unknown as Record; + const getSources = vi.fn(async () => []); + const openSourceSelector = vi.fn(async () => ({ opened: true })); + const usesSystemSourcePicker = vi.fn(async () => true); + Object.assign(api, { getSources, openSourceSelector, usesSystemSourcePicker }); + renderRecStage(); + await waitFor(() => expect(usesSystemSourcePicker).toHaveBeenCalled()); + + await act(async () => { + screen.getByRole("button", { name: "rec.selectSource" }).click(); + }); + + // Enumerating would go through the Screen Recording grant the picker makes unnecessary. + await waitFor(() => expect(openSourceSelector).toHaveBeenCalled()); + expect(getSources).not.toHaveBeenCalled(); + }); + it("does not render an auto-zoom toggle button (auto-zoom is systematic)", async () => { const { getRecordingPrefs } = stubRecordingPrefs({ micEnabled: false, diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 883360716..47c6af4a1 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -174,7 +174,29 @@ export function RecStage({ const [sourceTab, setSourceTab] = useState<"screen" | "window">("screen"); const [sources, setSources] = useState([]); const [loadingSources, setLoadingSources] = useState(false); + const [systemSourcePicker, setSystemSourcePicker] = useState(false); + useEffect(() => { + let active = true; + void window.electronAPI + ?.usesSystemSourcePicker?.() + .then((uses) => { + if (active) { + setSystemSourcePicker(uses === true); + } + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, []); const openSourceModal = async () => { + // With Apple's system picker (macOS 15.2+) the choice is made there, and the pick + // comes back through `onSelectedSourceChanged` like any other. Listing sources here + // would go through the Screen Recording grant the picker makes unnecessary. + if (systemSourcePicker) { + await window.electronAPI?.openSourceSelector?.(); + return; + } setSourceModalOpen(true); setLoadingSources(true); try { diff --git a/src/components/permissions/PermissionsWindow.test.tsx b/src/components/permissions/PermissionsWindow.test.tsx index 3b64f57f6..1b92a1936 100644 --- a/src/components/permissions/PermissionsWindow.test.tsx +++ b/src/components/permissions/PermissionsWindow.test.tsx @@ -15,6 +15,7 @@ type Snapshot = Awaited>; const FIRST_RUN: Snapshot = { supported: true, macosMajor: 26, + screenRequired: true, screen: "not-requested", screenRequiresRelaunch: false, accessibility: "not-requested", @@ -113,6 +114,21 @@ describe("PermissionsWindow", () => { expect(screen.queryByText("permissions.help.screenRecurring")).not.toBeInTheDocument(); }); + it("with Apple's picker, offers system audio as optional and lets the user start", async () => { + await renderWith({ screenRequired: false }); + + expect(screen.getByText("permissions.rows.systemAudio.name")).toBeInTheDocument(); + expect(screen.queryByText("permissions.rows.screen.name")).not.toBeInTheDocument(); + const row = screen.getByTestId("permission-screen"); + expect(within(row).getByText("permissions.level.optional")).toBeInTheDocument(); + expect(screen.getByTestId("permissions-start")).toBeEnabled(); + }); + + it("with Apple's picker, never warns about the bypass alert, which it does not raise", async () => { + await renderWith({ screenRequired: false, screen: "granted", macosMajor: 26 }); + expect(screen.queryByText("permissions.help.screenRecurring")).not.toBeInTheDocument(); + }); + it("shows a policy-restricted permission without a button", async () => { await renderWith({ microphone: "restricted" }); diff --git a/src/components/permissions/PermissionsWindow.tsx b/src/components/permissions/PermissionsWindow.tsx index 066f24e9e..2f3a57454 100644 --- a/src/components/permissions/PermissionsWindow.tsx +++ b/src/components/permissions/PermissionsWindow.tsx @@ -92,7 +92,10 @@ export function PermissionsWindow() { return
; } - const screenReady = snapshot.screen === "granted" && !snapshot.screenRequiresRelaunch; + // With Apple's system picker (macOS 15.2+) a recording needs no Screen Recording grant; + // the grant only still gives it system audio, so the row says that and is optional. + const screenReady = + !snapshot.screenRequired || (snapshot.screen === "granted" && !snapshot.screenRequiresRelaunch); const needsRelaunch = snapshot.screen === "granted" && snapshot.screenRequiresRelaunch; return ( @@ -101,8 +104,11 @@ export function PermissionsWindow() {

{t("permissions.subtitle")}

    - {ROWS.map(({ kind, level, Icon }) => { + {ROWS.map(({ kind, level: requiredLevel, Icon }) => { const status = snapshot[kind]; + const systemAudioOnly = kind === "screen" && !snapshot.screenRequired; + const level = systemAudioOnly ? "optional" : requiredLevel; + const rowKey = systemAudioOnly ? "systemAudio" : kind; return (
  • - {t(`permissions.rows.${kind}.name`)} + {t(`permissions.rows.${rowKey}.name`)} {t(`permissions.level.${level}`)}

    - {t(`permissions.rows.${kind}.description`)} + {t(`permissions.rows.${rowKey}.description`)}

= RECURRING_SCREEN_ALERT_FROM_MACOS) { + // Captures started from Apple's picker never raise that alert, so there is nothing to + // warn about when the picker owns the choice. + if ( + snapshot.screenRequired && + snapshot.screen === "granted" && + snapshot.macosMajor >= RECURRING_SCREEN_ALERT_FROM_MACOS + ) { lines.push(t("permissions.help.screenRecurring")); } if (lines.length === 0) { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c3fd3b05b..ed34171da 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1415,6 +1415,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (result.microphoneDefaulted) { toast.error(t("recording.microphoneDefaulted")); } + if (result.systemAudioUnavailable) { + toast.error(t("recording.systemAudioUnavailable")); + } // The IPC call above only resolves once the helper's stdout confirms its // screen capture has truly started (see waitForNativeMacCaptureStart in diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index b30147f99..77ff36a09 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -109,6 +109,10 @@ "name": "الشاشة وصوت النظام", "description": "لتسجيل شاشتك والصوت الصادر من جهاز Mac." }, + "systemAudio": { + "name": "صوت النظام", + "description": "لتسجيل الصوت الصادر من جهاز Mac. لا يتطلب اختيار شاشة أو نافذة أي إذن." + }, "accessibility": { "name": "تسهيلات الاستخدام", "description": "لإظهار المؤشر الصحيح (سهم، نص) في تسجيلاتك." diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index c1e8d145d..32599f897 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -109,6 +109,10 @@ "name": "Obrazovka a zvuk systému", "description": "K nahrávání obrazovky a zvuku z vašeho Macu." }, + "systemAudio": { + "name": "Zvuk systému", + "description": "K nahrávání zvuku z vašeho Macu. Výběr obrazovky nebo okna žádné oprávnění nepotřebuje." + }, "accessibility": { "name": "Zpřístupnění", "description": "Aby nahrávky zobrazovaly správný kurzor (šipka, text)." diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index 0f68c3070..b297962f0 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -109,6 +109,10 @@ "name": "Bildschirm & Systemaudio", "description": "Um deinen Bildschirm und den Ton deines Mac aufzunehmen." }, + "systemAudio": { + "name": "Systemaudio", + "description": "Um den Ton deines Mac aufzunehmen. Für die Auswahl eines Bildschirms oder Fensters ist keine Berechtigung nötig." + }, "accessibility": { "name": "Bedienungshilfen", "description": "Um in Aufnahmen den richtigen Zeiger (Pfeil, Text) anzuzeigen." diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 0830e722d..f2d562b83 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -109,6 +109,10 @@ "name": "Screen & system audio", "description": "To record your screen and the sound your Mac plays." }, + "systemAudio": { + "name": "System audio", + "description": "To record the sound your Mac plays. Choosing a screen or window needs no permission." + }, "accessibility": { "name": "Accessibility", "description": "To show the right cursor (pointer, text) in your recordings." diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 2255a9d7e..c766fb3a9 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -109,6 +109,10 @@ "name": "Pantalla y audio del sistema", "description": "Para grabar tu pantalla y el sonido de tu Mac." }, + "systemAudio": { + "name": "Audio del sistema", + "description": "Para grabar el sonido de tu Mac. Elegir una pantalla o una ventana no necesita ningún permiso." + }, "accessibility": { "name": "Accesibilidad", "description": "Para mostrar el cursor correcto (flecha, texto) en tus grabaciones." diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 8713a0bb1..28c625df8 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -109,6 +109,10 @@ "name": "Écran et son du système", "description": "Pour enregistrer votre écran et le son de votre Mac." }, + "systemAudio": { + "name": "Son du système", + "description": "Pour enregistrer le son de votre Mac. Choisir un écran ou une fenêtre ne demande aucune autorisation." + }, "accessibility": { "name": "Accessibilité", "description": "Pour afficher le bon curseur (flèche, texte) dans vos enregistrements." diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 76352d0b8..f5b5544fe 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -109,6 +109,10 @@ "name": "Schermo e audio di sistema", "description": "Per registrare lo schermo e l'audio del tuo Mac." }, + "systemAudio": { + "name": "Audio di sistema", + "description": "Per registrare l'audio del tuo Mac. Scegliere uno schermo o una finestra non richiede alcun permesso." + }, "accessibility": { "name": "Accessibilità", "description": "Per mostrare il cursore giusto (freccia, testo) nelle registrazioni." diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 1b19f0a3d..ba98dacc6 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -109,6 +109,10 @@ "name": "画面とシステムオーディオ", "description": "画面と Mac のサウンドを録画するため。" }, + "systemAudio": { + "name": "システムオーディオ", + "description": "Mac のサウンドを録音するため。画面やウインドウの選択には許可は不要です。" + }, "accessibility": { "name": "アクセシビリティ", "description": "録画に正しいカーソル(矢印、テキスト)を表示するため。" diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index bb16d5206..477faa1fa 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -109,6 +109,10 @@ "name": "화면 및 시스템 오디오", "description": "화면과 Mac의 소리를 녹화하기 위해 필요합니다." }, + "systemAudio": { + "name": "시스템 오디오", + "description": "Mac의 소리를 녹음하기 위해 필요합니다. 화면이나 창을 선택하는 데에는 권한이 필요 없습니다." + }, "accessibility": { "name": "손쉬운 사용", "description": "녹화에 올바른 커서(화살표, 텍스트)를 표시하기 위해 필요합니다." diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index ff20303d7..ec625bd68 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -109,6 +109,10 @@ "name": "Tela e áudio do sistema", "description": "Para gravar sua tela e o som do seu Mac." }, + "systemAudio": { + "name": "Áudio do sistema", + "description": "Para gravar o som do seu Mac. Escolher uma tela ou janela não precisa de permissão." + }, "accessibility": { "name": "Acessibilidade", "description": "Para mostrar o cursor certo (seta, texto) nas suas gravações." diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index bd1963f6b..138b61375 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -109,6 +109,10 @@ "name": "Экран и системный звук", "description": "Чтобы записывать экран и звук вашего Mac." }, + "systemAudio": { + "name": "Системный звук", + "description": "Чтобы записывать звук вашего Mac. Для выбора экрана или окна разрешение не нужно." + }, "accessibility": { "name": "Универсальный доступ", "description": "Чтобы в записях отображался правильный курсор (стрелка, текст)." diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 23fece26d..7c05c2509 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -109,6 +109,10 @@ "name": "Ekran ve sistem sesi", "description": "Ekranınızı ve Mac'inizin sesini kaydetmek için." }, + "systemAudio": { + "name": "Sistem sesi", + "description": "Mac'inizin sesini kaydetmek için. Ekran veya pencere seçmek için izin gerekmez." + }, "accessibility": { "name": "Erişilebilirlik", "description": "Kayıtlarınızda doğru imleci (ok, metin) göstermek için." diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 82581c48e..5789ffdb7 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -109,6 +109,10 @@ "name": "Màn hình & âm thanh hệ thống", "description": "Để ghi màn hình và âm thanh của máy Mac." }, + "systemAudio": { + "name": "Âm thanh hệ thống", + "description": "Để ghi âm thanh của máy Mac. Chọn màn hình hoặc cửa sổ không cần quyền nào." + }, "accessibility": { "name": "Trợ năng", "description": "Để hiển thị đúng con trỏ (mũi tên, văn bản) trong bản ghi." diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 54d6304ad..e9576bf3f 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -109,6 +109,10 @@ "name": "屏幕与系统音频", "description": "用于录制你的屏幕和 Mac 播放的声音。" }, + "systemAudio": { + "name": "系统音频", + "description": "用于录制 Mac 播放的声音。选择屏幕或窗口无需任何权限。" + }, "accessibility": { "name": "辅助功能", "description": "用于在录制中显示正确的光标(箭头、文本)。" diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index c2e457aae..e6940c5ea 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -109,6 +109,10 @@ "name": "螢幕與系統音訊", "description": "用於錄製你的螢幕與 Mac 播放的聲音。" }, + "systemAudio": { + "name": "系統音訊", + "description": "用於錄製 Mac 播放的聲音。選擇螢幕或視窗不需要任何權限。" + }, "accessibility": { "name": "輔助使用", "description": "用於在錄製中顯示正確的游標(箭頭、文字)。" diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index 2ad449270..e705d09c5 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -92,6 +92,11 @@ export type NativeMacRecordingStartResult = { helperPath?: string; /** The helper could not resolve the selected device and is using the system default. */ microphoneDefaulted?: boolean; + /** + * System audio was asked for but left out: recording from Apple's picker needs no Screen + * Recording grant, but system audio still does, and the grant is missing. + */ + systemAudioUnavailable?: boolean; error?: string; };