feat: add macos screencapturekit helper

This commit is contained in:
Etienne
2026-05-12 08:33:18 +02:00
committed by Etienne Lescot
parent 7102110de5
commit b9e2134749
11 changed files with 1064 additions and 11 deletions
+18
View File
@@ -78,6 +78,13 @@ interface Window {
reason?: string;
error?: string;
}>;
isNativeMacCaptureAvailable: () => Promise<{
success: boolean;
available: boolean;
helperPath?: string;
reason?: "unsupported-platform" | "missing-helper" | string;
error?: string;
}>;
startNativeWindowsRecording: (
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
@@ -89,6 +96,17 @@ interface Window {
discarded?: boolean;
error?: string;
}>;
startNativeMacRecording: (
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
stopNativeMacRecording: (discard?: boolean) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
discarded?: boolean;
error?: string;
}>;
discardCursorTelemetry: (recordingId: number) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
+365
View File
@@ -15,6 +15,7 @@ import {
shell,
systemPreferences,
} from "electron";
import type { NativeMacRecordingRequest } from "../../src/lib/nativeMacRecording";
import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording";
import {
type CursorCaptureMode,
@@ -276,6 +277,12 @@ let nativeWindowsCaptureRecordingId: number | null = null;
let nativeWindowsCursorOffsetMs = 0;
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null;
let nativeMacCaptureOutput = "";
let nativeMacCaptureTargetPath: string | null = null;
let nativeMacCaptureRecordingId: number | null = null;
let nativeMacCursorOffsetMs = 0;
let nativeMacCursorCaptureMode: CursorCaptureMode = "editable-overlay";
function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
if (!sample || typeof sample !== "object") {
@@ -499,6 +506,35 @@ async function findNativeWindowsCaptureHelperPath() {
return null;
}
function getNativeMacCaptureHelperCandidates() {
const envPath = process.env.OPENSCREEN_SCK_CAPTURE_EXE?.trim();
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
const helperName = "openscreen-screencapturekit-helper";
return [
envPath,
resolveUnpackedAppPath("electron", "native", "screencapturekit", "build", helperName),
resolveUnpackedAppPath("electron", "native", "bin", archTag, helperName),
resolvePackagedResourcePath("electron", "native", "bin", archTag, helperName),
].filter((candidate): candidate is string => Boolean(candidate));
}
async function findNativeMacCaptureHelperPath() {
if (process.platform !== "darwin") {
return null;
}
for (const candidate of getNativeMacCaptureHelperCandidates()) {
try {
await fs.access(candidate, fsConstants.X_OK);
return candidate;
} catch {
// Try the next configured helper location.
}
}
return null;
}
function isWindowsGraphicsCaptureOsSupported() {
if (process.platform !== "win32") {
return false;
@@ -785,6 +821,134 @@ function readNativeWindowsWebcamFormat(output: string) {
}
}
function tryParseNativeHelperEvent(line: string) {
try {
const parsed = JSON.parse(line);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for native macOS capture to start"));
}, 10_000);
const inspect = (chunk: Buffer) => {
nativeMacCaptureOutput += chunk.toString();
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
const event = tryParseNativeHelperEvent(line.trim());
if (!event) continue;
if (event.event === "recording-started") {
cleanup();
resolve();
return;
}
if (event.event === "error") {
cleanup();
reject(new Error(event.message ?? event.code ?? "Native macOS capture failed"));
return;
}
}
};
const onOutput = (chunk: Buffer) => inspect(chunk);
const onClose = (code: number | null) => {
cleanup();
reject(
new Error(
nativeMacCaptureOutput.trim() ||
`Native macOS capture exited before recording started (code=${code ?? "unknown"})`,
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timer);
proc.stdout.off("data", onOutput);
proc.stderr.off("data", onOutput);
proc.off("close", onClose);
proc.off("error", onError);
};
proc.stdout.on("data", onOutput);
proc.stderr.on("data", onOutput);
proc.once("close", onClose);
proc.once("error", onError);
});
}
function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(
new Error(
`Timed out waiting for native macOS capture to stop. Output path: ${
nativeMacCaptureTargetPath ?? "unknown"
}. Output: ${nativeMacCaptureOutput.trim()}`,
),
);
}, 30_000);
const inspect = (chunk: Buffer) => {
nativeMacCaptureOutput += chunk.toString();
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
const event = tryParseNativeHelperEvent(line.trim());
if (!event) continue;
if (event.event === "recording-stopped") {
cleanup();
resolve(event.screenPath ?? nativeMacCaptureTargetPath ?? "");
return;
}
if (event.event === "error") {
cleanup();
reject(new Error(event.message ?? event.code ?? "Native macOS capture failed"));
return;
}
}
};
const onOutput = (chunk: Buffer) => inspect(chunk);
const onClose = (code: number | null) => {
if (code === 0 && nativeMacCaptureTargetPath) {
cleanup();
resolve(nativeMacCaptureTargetPath);
return;
}
cleanup();
reject(
new Error(
nativeMacCaptureOutput.trim() ||
`Native macOS capture exited with code=${code ?? "unknown"}`,
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timer);
proc.stdout.off("data", onOutput);
proc.stderr.off("data", onOutput);
proc.off("close", onClose);
proc.off("error", onError);
};
proc.stdout.on("data", onOutput);
proc.stderr.on("data", onOutput);
proc.once("close", onClose);
proc.once("error", onError);
});
}
function setCurrentRecordingSessionState(session: RecordingSession | null) {
currentRecordingSession = session;
currentVideoPath = session?.screenVideoPath ?? null;
@@ -1041,6 +1205,17 @@ export function registerIpcHandlers(
: { success: true, available: false, reason: "missing-helper" };
});
ipcMain.handle("is-native-mac-capture-available", async () => {
if (process.platform !== "darwin") {
return { success: true, available: false, reason: "unsupported-platform" };
}
const helperPath = await findNativeMacCaptureHelperPath();
return helperPath
? { success: true, available: true, helperPath }
: { success: true, available: false, reason: "missing-helper" };
});
ipcMain.handle(
"start-native-windows-recording",
async (_, request: NativeWindowsRecordingRequest) => {
@@ -1217,6 +1392,121 @@ export function registerIpcHandlers(
},
);
ipcMain.handle("start-native-mac-recording", async (_, request: NativeMacRecordingRequest) => {
try {
if (process.platform !== "darwin") {
return { success: false, error: "Native macOS capture requires macOS." };
}
if (nativeMacCaptureProcess) {
return { success: false, error: "Native macOS capture is already running." };
}
const helperPath = await findNativeMacCaptureHelperPath();
if (!helperPath) {
return { success: false, error: "Native macOS capture helper is not available." };
}
if (!request?.source?.sourceId) {
return { success: false, error: "Native macOS capture request is missing a source." };
}
const recordingId =
typeof request.recordingId === "number" && Number.isFinite(request.recordingId)
? request.recordingId
: Date.now();
const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`);
const cursorCaptureMode =
normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay";
const sourceDisplay =
request.source.type === "display" && typeof request.source.displayId === "number"
? (screen.getAllDisplays().find((display) => display.id === request.source.displayId) ??
null)
: getSelectedDisplay();
const bounds = request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds();
const config: NativeMacRecordingRequest = {
...request,
schemaVersion: 1,
recordingId,
source: {
...request.source,
bounds,
},
video: {
...request.video,
hideSystemCursor: cursorCaptureMode === "editable-overlay",
},
cursor: {
mode: cursorCaptureMode,
},
outputs: {
screenPath: outputPath,
manifestPath: path.join(
RECORDINGS_DIR,
`${RECORDING_FILE_PREFIX}${recordingId}${RECORDING_SESSION_SUFFIX}`,
),
},
};
console.info("[native-sck] starting macOS capture", {
helperPath,
source: config.source,
audio: config.audio,
webcam: config.webcam,
cursor: config.cursor,
outputPath,
});
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
nativeMacCaptureOutput = "";
nativeMacCaptureTargetPath = outputPath;
nativeMacCaptureRecordingId = recordingId;
nativeMacCursorOffsetMs = 0;
nativeMacCursorCaptureMode = cursorCaptureMode;
const cursorStartTimeMs = Date.now();
if (cursorCaptureMode === "editable-overlay") {
await startCursorRecording(cursorStartTimeMs);
} else {
pendingCursorRecordingData = null;
}
const proc = spawn(helperPath, [JSON.stringify(config)], {
cwd: RECORDINGS_DIR,
stdio: ["pipe", "pipe", "pipe"],
});
nativeMacCaptureProcess = proc;
await waitForNativeMacCaptureStart(proc);
const captureStartedAtMs = Date.now();
nativeMacCursorOffsetMs =
cursorCaptureMode === "editable-overlay"
? Math.max(0, captureStartedAtMs - cursorStartTimeMs)
: 0;
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(true, source.name);
}
return {
success: true,
recordingId,
path: outputPath,
helperPath,
};
} catch (error) {
console.error("Failed to start native macOS recording:", error);
nativeMacCaptureProcess?.kill();
nativeMacCaptureProcess = null;
nativeMacCaptureTargetPath = null;
nativeMacCaptureRecordingId = null;
nativeMacCursorOffsetMs = 0;
nativeMacCursorCaptureMode = "editable-overlay";
await stopCursorRecording();
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => {
const proc = nativeWindowsCaptureProcess;
const preferredPath = nativeWindowsCaptureTargetPath;
@@ -1301,6 +1591,81 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("stop-native-mac-recording", async (_, discard?: boolean) => {
const proc = nativeMacCaptureProcess;
const preferredPath = nativeMacCaptureTargetPath;
const recordingId = nativeMacCaptureRecordingId ?? Date.now();
const cursorCaptureMode = nativeMacCursorCaptureMode;
if (!proc) {
return { success: false, error: "Native macOS capture is not running." };
}
try {
const stoppedPathPromise = waitForNativeMacCaptureStop(proc);
proc.stdin.write("stop\n");
const stoppedPath = await stoppedPathPromise;
const screenVideoPath = stoppedPath || preferredPath;
if (!screenVideoPath) {
throw new Error("Native macOS capture did not return an output path.");
}
if (cursorCaptureMode === "editable-overlay") {
await stopCursorRecording();
} else {
pendingCursorRecordingData = null;
}
if (discard) {
pendingCursorRecordingData = null;
await Promise.all([
fs.rm(screenVideoPath, { force: true }),
fs.rm(`${screenVideoPath}.cursor.json`, { force: true }),
]);
return { success: true, discarded: true };
}
if (cursorCaptureMode === "editable-overlay") {
shiftPendingCursorTelemetry(nativeMacCursorOffsetMs);
await writePendingCursorTelemetry(screenVideoPath);
}
const session: RecordingSession = {
screenVideoPath,
createdAt: recordingId,
cursorCaptureMode,
};
setCurrentRecordingSessionState(session);
currentProjectPath = null;
const sessionManifestPath = path.join(
RECORDINGS_DIR,
`${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`,
);
await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8");
return {
success: true,
path: screenVideoPath,
session,
message: "Native macOS recording session stored successfully",
};
} catch (error) {
console.error("Failed to stop native macOS recording:", error);
await stopCursorRecording();
return { success: false, error: error instanceof Error ? error.message : String(error) };
} finally {
nativeMacCaptureProcess = null;
nativeMacCaptureTargetPath = null;
nativeMacCaptureRecordingId = null;
nativeMacCursorOffsetMs = 0;
nativeMacCursorCaptureMode = "editable-overlay";
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(false, source.name);
}
}
});
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
return await storeRecordedSessionFiles(payload);
+7 -3
View File
@@ -9,19 +9,23 @@ macOS native recording will use a ScreenCaptureKit helper with the same process
3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing.
4. Electron persists the resulting media/session manifest and reports helper errors explicitly.
Expected development helper locations:
Helper locations:
1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics.
2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output.
3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers.
The current macOS helper script is a placeholder:
Build the macOS helper with:
```bash
npm run build:native:mac
```
On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it fails until the Swift ScreenCaptureKit helper lands.
On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it builds the Swift package at `electron/native/screencapturekit`, writes the development binary to `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, and copies the redistributable binary to `electron/native/bin/darwin-${arch}/openscreen-screencapturekit-helper`.
The current helper implementation supports the first native media slice: display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, and MP4 muxing. System audio, microphone capture, webcam composition, and runtime controls are intentionally left as explicit roadmap phases.
Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present; production recording is not routed through the macOS helper yet.
See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules.
@@ -0,0 +1,22 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "OpenScreenScreenCaptureKitHelper",
platforms: [
.macOS(.v13)
],
products: [
.executable(
name: "openscreen-screencapturekit-helper",
targets: ["OpenScreenScreenCaptureKitHelper"]
)
],
targets: [
.executableTarget(
name: "OpenScreenScreenCaptureKitHelper",
path: "Sources/OpenScreenScreenCaptureKitHelper"
)
]
)
@@ -0,0 +1,355 @@
import AVFoundation
import CoreMedia
import Foundation
import ScreenCaptureKit
struct Rectangle: Decodable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct RecordingRequest: Decodable {
struct Source: Decodable {
let type: String
let sourceId: String
let displayId: UInt32?
let windowId: UInt32?
let bounds: Rectangle?
}
struct Video: Decodable {
let fps: Int
let width: Int
let height: Int
let bitrate: Int?
let hideSystemCursor: Bool
}
struct Audio: Decodable {
struct SystemAudio: Decodable {
let enabled: Bool
}
struct Microphone: Decodable {
let enabled: Bool
let deviceId: String?
let deviceName: String?
let gain: Double
}
let system: SystemAudio
let microphone: Microphone
}
struct Webcam: Decodable {
let enabled: Bool
let deviceId: String?
let deviceName: String?
let width: Int
let height: Int
let fps: Int
}
struct Cursor: Decodable {
let mode: String
}
struct Outputs: Decodable {
let screenPath: String
let manifestPath: String?
}
let schemaVersion: Int?
let recordingId: Int?
let source: Source
let video: Video
let audio: Audio
let webcam: Webcam
let cursor: Cursor
let outputs: Outputs
}
enum HelperError: Error, CustomStringConvertible {
case invalidArguments
case unsupportedMacOS
case unsupportedFeature(String)
case sourceNotFound(String)
case invalidSourceType(String)
case writerSetupFailed(String)
var description: String {
switch self {
case .invalidArguments:
return "Expected one JSON recording request argument."
case .unsupportedMacOS:
return "ScreenCaptureKit recording requires macOS 13 or newer."
case .unsupportedFeature(let message):
return message
case .sourceNotFound(let message):
return message
case .invalidSourceType(let sourceType):
return "Unsupported source type: \(sourceType)."
case .writerSetupFailed(let message):
return message
}
}
}
func emit(_ fields: [String: Any]) {
if let data = try? JSONSerialization.data(withJSONObject: fields, options: []),
let line = String(data: data, encoding: .utf8)
{
print(line)
fflush(stdout)
}
}
func emitError(code: String, message: String) {
emit([
"event": "error",
"code": code,
"message": message,
])
}
@available(macOS 13.0, *)
final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private let request: RecordingRequest
private let sampleQueue = DispatchQueue(label: "app.openscreen.sck-helper.samples")
private let stateQueue = DispatchQueue(label: "app.openscreen.sck-helper.state")
private var stream: SCStream?
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var didStartWriting = false
private var isStopping = false
init(request: RecordingRequest) {
self.request = request
}
func start() async throws {
try rejectUnsupportedPhaseFeatures()
let content = try await SCShareableContent.excludingDesktopWindows(
false,
onScreenWindowsOnly: true
)
let filter = try makeContentFilter(from: content)
let configuration = makeStreamConfiguration()
let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue)
try setupWriter()
self.stream = stream
emit(["event": "ready", "schemaVersion": 1])
try await stream.startCapture()
emit([
"event": "recording-started",
"timestampMs": Int(Date().timeIntervalSince1970 * 1000),
])
}
func stop() async {
let shouldStop = stateQueue.sync {
if isStopping {
return false
}
isStopping = true
return true
}
if !shouldStop {
return
}
do {
try await stream?.stopCapture()
} catch {
emit([
"event": "warning",
"code": "stop-capture-failed",
"message": "\(error)",
])
}
await finishWriter()
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
emitError(code: "capture-stopped-with-error", message: "\(error)")
Task {
await stop()
}
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard type == .screen else {
return
}
guard CMSampleBufferDataIsReady(sampleBuffer) else {
return
}
guard let videoInput, let writer else {
return
}
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if !didStartWriting {
writer.startWriting()
writer.startSession(atSourceTime: presentationTime)
didStartWriting = true
}
if videoInput.isReadyForMoreMediaData {
videoInput.append(sampleBuffer)
}
}
private func rejectUnsupportedPhaseFeatures() throws {
if request.audio.system.enabled {
throw HelperError.unsupportedFeature(
"System audio capture is planned for the roadmap system-audio phase."
)
}
if request.audio.microphone.enabled {
throw HelperError.unsupportedFeature(
"Microphone capture is planned for the roadmap microphone phase."
)
}
if request.webcam.enabled {
throw HelperError.unsupportedFeature(
"Webcam composition is planned for the roadmap webcam phase."
)
}
}
private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter {
switch request.source.type {
case "display":
guard let displayId = request.source.displayId else {
throw HelperError.sourceNotFound("Display capture requires source.displayId.")
}
guard let display = content.displays.first(where: { $0.displayID == displayId }) else {
throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).")
}
return SCContentFilter(display: display, excludingWindows: [])
case "window":
guard let windowId = request.source.windowId else {
throw HelperError.sourceNotFound("Window capture requires source.windowId.")
}
guard let window = content.windows.first(where: { $0.windowID == windowId }) else {
throw HelperError.sourceNotFound("No ScreenCaptureKit window found for id \(windowId).")
}
return SCContentFilter(desktopIndependentWindow: window)
default:
throw HelperError.invalidSourceType(request.source.type)
}
}
private func makeStreamConfiguration() -> SCStreamConfiguration {
let configuration = SCStreamConfiguration()
configuration.width = request.video.width
configuration.height = request.video.height
configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, request.video.fps)))
configuration.queueDepth = 6
configuration.showsCursor = !request.video.hideSystemCursor
configuration.pixelFormat = kCVPixelFormatType_32BGRA
return configuration
}
private func setupWriter() throws {
let outputUrl = URL(fileURLWithPath: request.outputs.screenPath)
try? FileManager.default.removeItem(at: outputUrl)
try FileManager.default.createDirectory(
at: outputUrl.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: request.video.width,
AVVideoHeightKey: request.video.height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000,
AVVideoExpectedSourceFrameRateKey: request.video.fps,
],
]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
input.expectsMediaDataInRealTime = true
guard writer.canAdd(input) else {
throw HelperError.writerSetupFailed("Unable to add H.264 video input to AVAssetWriter.")
}
writer.add(input)
self.writer = writer
self.videoInput = input
}
private func finishWriter() async {
guard let writer else {
return
}
videoInput?.markAsFinished()
await withCheckedContinuation { continuation in
writer.finishWriting {
continuation.resume()
}
}
if writer.status == .completed {
emit([
"event": "recording-stopped",
"screenPath": request.outputs.screenPath,
])
} else {
emitError(
code: "writer-failed",
message: writer.error.map { "\($0)" } ?? "AVAssetWriter failed with status \(writer.status.rawValue)."
)
}
}
}
@main
struct OpenScreenScreenCaptureKitHelper {
static func main() async {
do {
guard CommandLine.arguments.count == 2 else {
throw HelperError.invalidArguments
}
guard #available(macOS 13.0, *) else {
throw HelperError.unsupportedMacOS
}
let requestData = Data(CommandLine.arguments[1].utf8)
let decoder = JSONDecoder()
let request = try decoder.decode(RecordingRequest.self, from: requestData)
let recorder = ScreenCaptureRecorder(request: request)
let stopTask = Task.detached {
while let line = readLine() {
let command = line.trimmingCharacters(in: .whitespacesAndNewlines)
if command == "stop" {
await recorder.stop()
exit(0)
}
}
}
try await recorder.start()
await stopTask.value
} catch let error as HelperError {
emitError(code: "helper-error", message: error.description)
exit(1)
} catch {
emitError(code: "helper-error", message: "\(error)")
exit(1)
}
}
}
+10
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
@@ -68,12 +69,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
isNativeWindowsCaptureAvailable: () => {
return ipcRenderer.invoke("is-native-windows-capture-available");
},
isNativeMacCaptureAvailable: () => {
return ipcRenderer.invoke("is-native-mac-capture-available");
},
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
return ipcRenderer.invoke("start-native-windows-recording", request);
},
stopNativeWindowsRecording: (discard?: boolean) => {
return ipcRenderer.invoke("stop-native-windows-recording", discard);
},
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
return ipcRenderer.invoke("start-native-mac-recording", request);
},
stopNativeMacRecording: (discard?: boolean) => {
return ipcRenderer.invoke("stop-native-mac-recording", discard);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
@@ -1,13 +1,75 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
if (process.platform !== "darwin") {
console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS.");
process.exit(0);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
const helperName = "openscreen-screencapturekit-helper";
const packageDir = path.join(root, "electron", "native", "screencapturekit");
const buildDir = path.join(packageDir, "build");
const swiftBuildDir = path.join(buildDir, "swiftpm");
const builtHelperPath = path.join(swiftBuildDir, "release", helperName);
const localHelperPath = path.join(buildDir, helperName);
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
const distributableDir = path.join(root, "electron", "native", "bin", archTag);
const distributablePath = path.join(distributableDir, helperName);
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
cwd: root,
encoding: "utf8",
});
if (xcodebuildVersion.status !== 0) {
const message = `${xcodebuildVersion.stderr ?? ""}${xcodebuildVersion.stdout ?? ""}`.trim();
console.error(
"macOS ScreenCaptureKit helper sources are not implemented yet. See docs/engineering/macos-native-recorder-roadmap.md.",
[
"Unable to build the macOS ScreenCaptureKit helper because full Xcode is not active.",
"",
message,
"",
"Install Xcode from the App Store or Apple Developer downloads, then run:",
" sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer",
" sudo xcodebuild -license accept",
"",
"Command Line Tools alone may not include the Swift SDK/platform metadata required by SwiftPM.",
].join("\n"),
);
process.exit(1);
}
const result = spawnSync(
"swift",
["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir],
{
cwd: root,
stdio: "inherit",
},
);
if (result.error) {
console.error(`Failed to start Swift build: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
fs.mkdirSync(buildDir, { recursive: true });
fs.mkdirSync(distributableDir, { recursive: true });
fs.copyFileSync(builtHelperPath, localHelperPath);
fs.copyFileSync(builtHelperPath, distributablePath);
fs.chmodSync(localHelperPath, 0o755);
fs.chmodSync(distributablePath, 0o755);
console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`);
console.log(`Copied redistributable helper: ${distributablePath}`);
+4 -4
View File
@@ -127,7 +127,7 @@ export function LaunchWindow() {
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const [isWindows, setIsWindows] = useState(false);
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
@@ -192,12 +192,12 @@ export function LaunchWindow() {
.getPlatform()
.then((platform) => {
if (!cancelled) {
setIsWindows(platform === "win32");
setSupportsCursorModeToggle(platform === "win32" || platform === "darwin");
}
})
.catch(() => {
if (!cancelled) {
setIsWindows(false);
setSupportsCursorModeToggle(false);
}
});
@@ -609,7 +609,7 @@ export function LaunchWindow() {
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
{isWindows && (
{supportsCursorModeToggle && (
<button
data-testid="launch-cursor-mode-button"
className={`${hudIconBtnClasses} ${
+178 -1
View File
@@ -2,6 +2,11 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
import {
type NativeMacRecordingRequest,
parseMacDisplayIdFromSourceId,
parseMacWindowIdFromSourceId,
} from "@/lib/nativeMacRecording";
import {
type NativeWindowsRecordingRequest,
parseWindowHandleFromSourceId,
@@ -80,6 +85,11 @@ type NativeWindowsRecordingHandle = {
finalizing: boolean;
};
type NativeMacRecordingHandle = {
recordingId: number;
finalizing: boolean;
};
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
const recorder = new MediaRecorder(stream, options);
const chunks: Blob[] = [];
@@ -118,6 +128,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const screenRecorder = useRef<RecorderHandle | null>(null);
const webcamRecorder = useRef<RecorderHandle | null>(null);
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
const nativeMacRecording = useRef<NativeMacRecordingHandle | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
@@ -455,11 +466,66 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, []);
const finalizeNativeMacRecording = useCallback(async (discard = false) => {
const activeNativeRecording = nativeMacRecording.current;
if (!activeNativeRecording || activeNativeRecording.finalizing) {
return false;
}
activeNativeRecording.finalizing = true;
const clearNativeRecordingState = () => {
nativeMacRecording.current = null;
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
};
try {
const result = await window.electronAPI.stopNativeMacRecording(discard);
if (discard || result.discarded) {
clearNativeRecordingState();
return true;
}
if (!result.success) {
console.error("Failed to stop native macOS recording:", result.error);
toast.error(result.error ?? "Failed to stop native macOS recording");
activeNativeRecording.finalizing = false;
return true;
}
clearNativeRecordingState();
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
return true;
} catch (error) {
console.error("Error saving native macOS recording:", error);
toast.error(error instanceof Error ? error.message : "Failed to save native macOS recording");
activeNativeRecording.finalizing = false;
return true;
} finally {
if (discardRecordingId.current === activeNativeRecording.recordingId) {
discardRecordingId.current = null;
}
}
}, []);
const stopRecording = useRef(() => {
if (nativeWindowsRecording.current) {
void finalizeNativeWindowsRecording(false);
return;
}
if (nativeMacRecording.current) {
void finalizeNativeMacRecording(false);
return;
}
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder) {
@@ -529,6 +595,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (nativeWindowsRecording.current) {
void finalizeNativeWindowsRecording(true);
}
if (nativeMacRecording.current) {
void finalizeNativeMacRecording(true);
}
if (
screenRecorder.current?.recorder.state === "recording" ||
@@ -554,7 +623,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
teardownMedia();
};
}, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]);
}, [
teardownMedia,
safeHideCountdownOverlay,
finalizeNativeWindowsRecording,
finalizeNativeMacRecording,
]);
const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
@@ -677,6 +751,106 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
const startNativeMacRecordingIfAvailable = async (
selectedSource: ProcessedDesktopSource,
countdownRunToken?: number,
) => {
try {
const platform = await window.electronAPI.getPlatform();
if (platform !== "darwin") {
return false;
}
const availability = await window.electronAPI.isNativeMacCaptureAvailable();
if (!availability.success || !availability.available) {
if (availability.reason === "unsupported-platform") {
return false;
}
throw new Error(
availability.reason === "missing-helper"
? "Native macOS capture helper is not available."
: (availability.error ?? "Native macOS capture is not available."),
);
}
if (!isCountdownRunActive(countdownRunToken)) {
return true;
}
const activeRecordingId = Date.now();
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
const displayId =
Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id);
const windowId = parseMacWindowIdFromSourceId(selectedSource.id);
if (webcamEnabled) {
stopWebcamPreviewStream();
}
const request: NativeMacRecordingRequest = {
schemaVersion: 1,
recordingId: activeRecordingId,
source: {
type: sourceType,
sourceId: selectedSource.id,
...(displayId ? { displayId } : {}),
...(windowId ? { windowId } : {}),
},
video: {
fps: TARGET_FRAME_RATE,
width: TARGET_WIDTH,
height: TARGET_HEIGHT,
bitrate: computeBitrate(TARGET_WIDTH, TARGET_HEIGHT),
hideSystemCursor: cursorCaptureMode === "editable-overlay",
},
audio: {
system: {
enabled: systemAudioEnabled,
},
microphone: {
enabled: microphoneEnabled,
deviceId: microphoneDeviceId,
deviceName: microphoneDeviceName,
gain: MIC_GAIN_BOOST,
},
},
webcam: {
enabled: webcamEnabled,
deviceId: webcamDeviceId,
deviceName: webcamDeviceName,
width: WEBCAM_TARGET_WIDTH,
height: WEBCAM_TARGET_HEIGHT,
fps: WEBCAM_TARGET_FRAME_RATE,
},
cursor: {
mode: cursorCaptureMode,
},
outputs: {
screenPath: "",
},
};
const result = await window.electronAPI.startNativeMacRecording(request);
if (!result.success || !result.recordingId) {
throw new Error(result.error ?? "Native macOS capture failed.");
}
recordingId.current = result.recordingId;
nativeMacRecording.current = {
recordingId: result.recordingId,
finalizing: false,
};
accumulatedDurationMs.current = 0;
segmentStartedAt.current = Date.now();
allowAutoFinalize.current = true;
setRecording(true);
setPaused(false);
setElapsedSeconds(0);
return true;
} catch (error) {
console.error("Native macOS capture failed:", error);
throw error;
}
};
const startRecordCountdown = async () => {
if (countdownActive || recording) {
return;
@@ -767,6 +941,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
return;
}
if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) {
return;
}
let screenMediaStream: MediaStream;
const platform = await window.electronAPI.getPlatform();
+35
View File
@@ -4,6 +4,7 @@ import type { CursorCaptureMode } from "./recordingSession";
export type NativeMacSourceType = "display" | "window";
export type NativeMacRecordingRequest = {
schemaVersion: 1;
recordingId?: number;
source: {
type: NativeMacSourceType;
@@ -47,6 +48,40 @@ export type NativeMacRecordingRequest = {
};
};
export type NativeMacHelperReadyEvent = {
event: "ready";
schemaVersion: 1;
};
export type NativeMacHelperRecordingStartedEvent = {
event: "recording-started";
timestampMs: number;
};
export type NativeMacHelperRecordingStoppedEvent = {
event: "recording-stopped";
screenPath: string;
};
export type NativeMacHelperWarningEvent = {
event: "warning";
code: string;
message: string;
};
export type NativeMacHelperErrorEvent = {
event: "error";
code: string;
message: string;
};
export type NativeMacHelperEvent =
| NativeMacHelperReadyEvent
| NativeMacHelperRecordingStartedEvent
| NativeMacHelperRecordingStoppedEvent
| NativeMacHelperWarningEvent
| NativeMacHelperErrorEvent;
export type NativeMacRecordingStartResult = {
success: boolean;
recordingId?: number;
+5
View File
@@ -10,6 +10,11 @@ export default defineConfig({
electron({
main: {
entry: "electron/main.ts",
onstart({ startup }) {
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
return startup(["."], { env });
},
vite: {
build: {},
},