From 9d1985d51fb1ce853b26e9d531cde058fe3e34d8 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 21:52:05 +0200 Subject: [PATCH 1/4] feat(macos-capture): record from Apple's system picker in a helper session `openscreen-screencapturekit-helper --picker-session` stays alive for the app's session, shows Apple's SCContentSharingPicker on request and records each take from the filter it returns. On macOS 26.5 a picker-based capture needs no Screen Recording grant and never raises macOS 15's 'bypass the system private window picker' alert, for display and window picks alike, from a helper process like this one. The filter carries that consent and cannot leave the process, so the process that asked is the one that records, and it keeps the filter to record the same source again without a new pick. - ScreenCaptureRecorder takes an optional picked source: no source lookup and no Screen Recording check for it. One recorder and one SCStream per take, so pause, the audio timeline and the writer are unchanged. - The picker configuration excludes the windows the app names: a display pick hides the capturing process's own windows but not the app's (the HUD would show otherwise; measured with a window owned by the parent process). - 15.2+, where the filter reports the picked display/window and its frame, which the cursor telemetry needs. - The stdin protocol parses in OpenScreenCaptureCore, with tests. --- .../PickerSessionCommand.swift | 68 +++++ .../PickerSession.swift | 252 ++++++++++++++++++ .../ScreenCaptureRecorder.swift | 66 ++++- .../PickerSessionCommandTests.swift | 43 +++ 4 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 electron/native/screencapturekit/Sources/OpenScreenCaptureCore/PickerSessionCommand.swift create mode 100644 electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/PickerSession.swift create mode 100644 electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/PickerSessionCommandTests.swift 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..34688e5ef 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": @@ -935,6 +968,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 +997,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")) + } +} From 3910b50ad99b1dce404ced8f91375f4e667f80f6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 22:03:34 +0200 Subject: [PATCH 2/4] feat(macos): pick sources in Apple's system picker on macOS 15.2+ On macOS 15.2+ the source button and Record open Apple's SCContentSharingPicker instead of the app's own picker, and every take records through the helper session that holds the pick. Recording then needs no Screen Recording grant, never raises the 'bypass the system private window picker' alert, and never asks for a relaunch. - MacPickerSession owns the long-lived `--picker-session` helper and hands each take a stand-in for the per-take helper process (stdin, stdout, close), so start, stop, salvage and the mid-capture error watch run unchanged. It excludes the HUD and notes windows from the picker, since a display pick would record them otherwise. - get-selected-source answers the pick without enumerating (which would go through the grant); the CLI keeps selecting by id on the per-take helper. The AI editor's recording stage hands its source row to the same picker. - System audio still needs the grant (ScreenCaptureKit delivers silence without it, measured): a take that asks for it without the grant records without it, says so, and opens the permissions window. - The permissions window treats Screen Recording as optional there, relabelled 'System audio', shows once as an offer, and drops the bypass-alert warning, which picker captures never raise. - A helper that cannot start the session (older build) falls back to the app's own picker for the run; OPENSCREEN_MAC_SOURCE_PICKER=legacy forces it. --- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 170 +++++++- .../screen/macPickerSession.test.ts | 217 +++++++++++ .../native-bridge/screen/macPickerSession.ts | 368 ++++++++++++++++++ electron/permissions/index.ts | 2 + electron/permissions/macPermissions.test.ts | 44 +++ electron/permissions/macPermissions.ts | 30 +- electron/preload.ts | 3 + .../ai-edition/v4/RecStage.test.tsx | 19 + src/components/ai-edition/v4/RecStage.tsx | 22 ++ .../permissions/PermissionsWindow.test.tsx | 16 + .../permissions/PermissionsWindow.tsx | 22 +- src/hooks/useScreenRecorder.ts | 3 + src/i18n/locales/ar/launch.json | 4 + src/i18n/locales/cs/launch.json | 4 + src/i18n/locales/de/launch.json | 4 + src/i18n/locales/en/launch.json | 4 + src/i18n/locales/es/launch.json | 4 + src/i18n/locales/fr/launch.json | 4 + src/i18n/locales/it/launch.json | 4 + src/i18n/locales/ja-JP/launch.json | 4 + src/i18n/locales/ko-KR/launch.json | 4 + src/i18n/locales/pt-BR/launch.json | 4 + src/i18n/locales/ru/launch.json | 4 + src/i18n/locales/tr/launch.json | 4 + src/i18n/locales/vi/launch.json | 4 + src/i18n/locales/zh-CN/launch.json | 4 + src/i18n/locales/zh-TW/launch.json | 4 + src/lib/nativeMacRecording.ts | 5 + 29 files changed, 963 insertions(+), 20 deletions(-) create mode 100644 electron/native-bridge/screen/macPickerSession.test.ts create mode 100644 electron/native-bridge/screen/macPickerSession.ts 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..e6417c4df 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,36 @@ 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 pick = await session.present(collectMacCaptureExcludedWindowIds(appWindowSourceIds)); + 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 +2097,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 +2246,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 +2916,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 +2962,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 +3033,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 +3076,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/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; }; From da9ecd316b25514021d7bbda4f6dec51848c6206 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 23:01:29 +0200 Subject: [PATCH 3/4] fix(macos): hide the HUD while Apple's picker is up The HUD window is far larger than the bar it draws: a transparent reserve sits above it. Clicks pass through it, but Apple's picker targets windows by their frame, so that invisible rectangle hid every window behind it from the picker (reported on the first in-app run). The HUD is hidden for the picker's lifetime and shown again, inactive, once the user picks or cancels. Its exclusion from a display capture is by window id, so hiding it does not bring it back into the pick. --- electron/ipc/handlers.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e6417c4df..27e90028b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2063,7 +2063,25 @@ export function registerIpcHandlers( const appWindowSourceIds = [getMainWindow(), getNotesWindow()] .filter((window): window is BrowserWindow => !!window && !window.isDestroyed()) .map((window) => window.getMediaSourceId()); - const pick = await session.present(collectMacCaptureExcludedWindowIds(appWindowSourceIds)); + 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. From 3b4b49b5540e93b748281067acbb69d91e0e90bd Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 23:22:22 +0200 Subject: [PATCH 4/4] fix(macos-capture): never finish a writer that never started A take that ends before its first frame (a start that fails after the writer is set up, or a stop right away) reached finishWriter with the AVAssetWriter still in .unknown. finishWriting and markAsFinished raise an Objective-C exception in that state, which kills the helper: in a picker session that takes every later take and the user's pick with it. finishWriter now drops whatever file the writer created and reports writer-failed, which Electron already reads as a take with no file. Checked with a stop sent the moment the helper reports ready: three runs, each ending in writer-failed, exit 0, no file left behind. --- .../ScreenCaptureRecorder.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 34688e5ef..97a7569e9 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -715,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.