feat: add macos screencapturekit helper
This commit is contained in:
Vendored
+18
@@ -78,6 +78,13 @@ interface Window {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
isNativeMacCaptureAvailable: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
available: boolean;
|
||||||
|
helperPath?: string;
|
||||||
|
reason?: "unsupported-platform" | "missing-helper" | string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
startNativeWindowsRecording: (
|
startNativeWindowsRecording: (
|
||||||
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
|
request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest,
|
||||||
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
|
) => Promise<import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingStartResult>;
|
||||||
@@ -89,6 +96,17 @@ interface Window {
|
|||||||
discarded?: boolean;
|
discarded?: boolean;
|
||||||
error?: string;
|
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>;
|
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
shell,
|
shell,
|
||||||
systemPreferences,
|
systemPreferences,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
|
import type { NativeMacRecordingRequest } from "../../src/lib/nativeMacRecording";
|
||||||
import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording";
|
import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording";
|
||||||
import {
|
import {
|
||||||
type CursorCaptureMode,
|
type CursorCaptureMode,
|
||||||
@@ -276,6 +277,12 @@ let nativeWindowsCaptureRecordingId: number | null = null;
|
|||||||
let nativeWindowsCursorOffsetMs = 0;
|
let nativeWindowsCursorOffsetMs = 0;
|
||||||
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
|
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
|
||||||
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
|
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 {
|
function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
|
||||||
if (!sample || typeof sample !== "object") {
|
if (!sample || typeof sample !== "object") {
|
||||||
@@ -499,6 +506,35 @@ async function findNativeWindowsCaptureHelperPath() {
|
|||||||
return null;
|
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() {
|
function isWindowsGraphicsCaptureOsSupported() {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return false;
|
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) {
|
function setCurrentRecordingSessionState(session: RecordingSession | null) {
|
||||||
currentRecordingSession = session;
|
currentRecordingSession = session;
|
||||||
currentVideoPath = session?.screenVideoPath ?? null;
|
currentVideoPath = session?.screenVideoPath ?? null;
|
||||||
@@ -1041,6 +1205,17 @@ export function registerIpcHandlers(
|
|||||||
: { success: true, available: false, reason: "missing-helper" };
|
: { 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(
|
ipcMain.handle(
|
||||||
"start-native-windows-recording",
|
"start-native-windows-recording",
|
||||||
async (_, request: NativeWindowsRecordingRequest) => {
|
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) => {
|
ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => {
|
||||||
const proc = nativeWindowsCaptureProcess;
|
const proc = nativeWindowsCaptureProcess;
|
||||||
const preferredPath = nativeWindowsCaptureTargetPath;
|
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) => {
|
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||||
try {
|
try {
|
||||||
return await storeRecordedSessionFiles(payload);
|
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.
|
3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing.
|
||||||
4. Electron persists the resulting media/session manifest and reports helper errors explicitly.
|
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.
|
1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics.
|
||||||
2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output.
|
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.
|
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
|
```bash
|
||||||
npm run build:native:mac
|
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.
|
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 { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
||||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||||
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||||
@@ -68,12 +69,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
isNativeWindowsCaptureAvailable: () => {
|
isNativeWindowsCaptureAvailable: () => {
|
||||||
return ipcRenderer.invoke("is-native-windows-capture-available");
|
return ipcRenderer.invoke("is-native-windows-capture-available");
|
||||||
},
|
},
|
||||||
|
isNativeMacCaptureAvailable: () => {
|
||||||
|
return ipcRenderer.invoke("is-native-mac-capture-available");
|
||||||
|
},
|
||||||
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
||||||
return ipcRenderer.invoke("start-native-windows-recording", request);
|
return ipcRenderer.invoke("start-native-windows-recording", request);
|
||||||
},
|
},
|
||||||
stopNativeWindowsRecording: (discard?: boolean) => {
|
stopNativeWindowsRecording: (discard?: boolean) => {
|
||||||
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
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) => {
|
getCursorTelemetry: (videoPath?: string) => {
|
||||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,75 @@
|
|||||||
#!/usr/bin/env node
|
#!/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 process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS.");
|
console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
"macOS ScreenCaptureKit helper sources are not implemented yet. See docs/engineering/macos-native-recorder-roadmap.md.",
|
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 [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||||
const [isWindows, setIsWindows] = useState(false);
|
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
|
||||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||||
@@ -192,12 +192,12 @@ export function LaunchWindow() {
|
|||||||
.getPlatform()
|
.getPlatform()
|
||||||
.then((platform) => {
|
.then((platform) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsWindows(platform === "win32");
|
setSupportsCursorModeToggle(platform === "win32" || platform === "darwin");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsWindows(false);
|
setSupportsCursorModeToggle(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,7 +609,7 @@ export function LaunchWindow() {
|
|||||||
? getIcon("webcamOn", "text-green-400")
|
? getIcon("webcamOn", "text-green-400")
|
||||||
: getIcon("webcamOff", "text-white/40")}
|
: getIcon("webcamOff", "text-white/40")}
|
||||||
</button>
|
</button>
|
||||||
{isWindows && (
|
{supportsCursorModeToggle && (
|
||||||
<button
|
<button
|
||||||
data-testid="launch-cursor-mode-button"
|
data-testid="launch-cursor-mode-button"
|
||||||
className={`${hudIconBtnClasses} ${
|
className={`${hudIconBtnClasses} ${
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { fixWebmDuration } from "@fix-webm-duration/fix";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useScopedT } from "@/contexts/I18nContext";
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
|
import {
|
||||||
|
type NativeMacRecordingRequest,
|
||||||
|
parseMacDisplayIdFromSourceId,
|
||||||
|
parseMacWindowIdFromSourceId,
|
||||||
|
} from "@/lib/nativeMacRecording";
|
||||||
import {
|
import {
|
||||||
type NativeWindowsRecordingRequest,
|
type NativeWindowsRecordingRequest,
|
||||||
parseWindowHandleFromSourceId,
|
parseWindowHandleFromSourceId,
|
||||||
@@ -80,6 +85,11 @@ type NativeWindowsRecordingHandle = {
|
|||||||
finalizing: boolean;
|
finalizing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NativeMacRecordingHandle = {
|
||||||
|
recordingId: number;
|
||||||
|
finalizing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||||
const recorder = new MediaRecorder(stream, options);
|
const recorder = new MediaRecorder(stream, options);
|
||||||
const chunks: Blob[] = [];
|
const chunks: Blob[] = [];
|
||||||
@@ -118,6 +128,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||||
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
||||||
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
|
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
|
||||||
|
const nativeMacRecording = useRef<NativeMacRecordingHandle | null>(null);
|
||||||
const stream = useRef<MediaStream | null>(null);
|
const stream = useRef<MediaStream | null>(null);
|
||||||
const screenStream = useRef<MediaStream | null>(null);
|
const screenStream = useRef<MediaStream | null>(null);
|
||||||
const microphoneStream = 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(() => {
|
const stopRecording = useRef(() => {
|
||||||
if (nativeWindowsRecording.current) {
|
if (nativeWindowsRecording.current) {
|
||||||
void finalizeNativeWindowsRecording(false);
|
void finalizeNativeWindowsRecording(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (nativeMacRecording.current) {
|
||||||
|
void finalizeNativeMacRecording(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeScreenRecorder = screenRecorder.current;
|
const activeScreenRecorder = screenRecorder.current;
|
||||||
if (!activeScreenRecorder) {
|
if (!activeScreenRecorder) {
|
||||||
@@ -529,6 +595,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
if (nativeWindowsRecording.current) {
|
if (nativeWindowsRecording.current) {
|
||||||
void finalizeNativeWindowsRecording(true);
|
void finalizeNativeWindowsRecording(true);
|
||||||
}
|
}
|
||||||
|
if (nativeMacRecording.current) {
|
||||||
|
void finalizeNativeMacRecording(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
screenRecorder.current?.recorder.state === "recording" ||
|
screenRecorder.current?.recorder.state === "recording" ||
|
||||||
@@ -554,7 +623,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
webcamRecorder.current = null;
|
webcamRecorder.current = null;
|
||||||
teardownMedia();
|
teardownMedia();
|
||||||
};
|
};
|
||||||
}, [teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording]);
|
}, [
|
||||||
|
teardownMedia,
|
||||||
|
safeHideCountdownOverlay,
|
||||||
|
finalizeNativeWindowsRecording,
|
||||||
|
finalizeNativeMacRecording,
|
||||||
|
]);
|
||||||
|
|
||||||
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||||
try {
|
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 () => {
|
const startRecordCountdown = async () => {
|
||||||
if (countdownActive || recording) {
|
if (countdownActive || recording) {
|
||||||
return;
|
return;
|
||||||
@@ -767,6 +941,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let screenMediaStream: MediaStream;
|
let screenMediaStream: MediaStream;
|
||||||
const platform = await window.electronAPI.getPlatform();
|
const platform = await window.electronAPI.getPlatform();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CursorCaptureMode } from "./recordingSession";
|
|||||||
export type NativeMacSourceType = "display" | "window";
|
export type NativeMacSourceType = "display" | "window";
|
||||||
|
|
||||||
export type NativeMacRecordingRequest = {
|
export type NativeMacRecordingRequest = {
|
||||||
|
schemaVersion: 1;
|
||||||
recordingId?: number;
|
recordingId?: number;
|
||||||
source: {
|
source: {
|
||||||
type: NativeMacSourceType;
|
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 = {
|
export type NativeMacRecordingStartResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
recordingId?: number;
|
recordingId?: number;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export default defineConfig({
|
|||||||
electron({
|
electron({
|
||||||
main: {
|
main: {
|
||||||
entry: "electron/main.ts",
|
entry: "electron/main.ts",
|
||||||
|
onstart({ startup }) {
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
return startup(["."], { env });
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
build: {},
|
build: {},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user