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);
},