feat: add macos screencapturekit helper
This commit is contained in:
Vendored
+18
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
console.error(
|
||||
"macOS ScreenCaptureKit helper sources are not implemented yet. See docs/engineering/macos-native-recorder-roadmap.md.",
|
||||
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(
|
||||
[
|
||||
"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",
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -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} ${
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user