1073b0c214
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Bump Nix package on release / bump (release) Has been cancelled
Update Homebrew Cask / update-cask (release) Has been cancelled
3413 lines
103 KiB
TypeScript
3413 lines
103 KiB
TypeScript
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import type { DesktopCapturerSource, Rectangle } from "electron";
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
desktopCapturer,
|
|
dialog,
|
|
globalShortcut,
|
|
ipcMain,
|
|
screen,
|
|
shell,
|
|
systemPreferences,
|
|
} from "electron";
|
|
import type { GuideMarkerCapturedPayload } from "../../src/guide/contracts";
|
|
import type { NativeMacRecordingRequest } from "../../src/lib/nativeMacRecording";
|
|
import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording";
|
|
import {
|
|
type CursorCaptureMode,
|
|
normalizeCursorCaptureMode,
|
|
normalizeProjectMedia,
|
|
normalizeRecordingSession,
|
|
type ProjectMedia,
|
|
type RecordedVideoAssetInput,
|
|
type RecordingSession,
|
|
type StoreRecordedSessionInput,
|
|
} from "../../src/lib/recordingSession";
|
|
import type {
|
|
CursorRecordingData,
|
|
CursorRecordingSample,
|
|
NativeCursorAsset,
|
|
ProjectFileResult,
|
|
ProjectPathResult,
|
|
} from "../../src/native/contracts";
|
|
import { DeepSeekSettingsStore } from "../guide/ai/deepseekSettingsStore";
|
|
import { registerGuideIpcHandlers } from "../guide/guideIpc";
|
|
import { GuideStore } from "../guide/guideStore";
|
|
import { mainT } from "../i18n";
|
|
import { RECORDINGS_DIR } from "../main";
|
|
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
|
|
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
|
|
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
|
|
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
|
|
import { registerNativeBridgeHandlers } from "./nativeBridge";
|
|
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";
|
|
|
|
const PROJECT_FILE_EXTENSION = "openscreen";
|
|
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
|
const RECORDING_FILE_PREFIX = "recording-";
|
|
const RECORDING_SESSION_SUFFIX = ".session.json";
|
|
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
|
|
const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio");
|
|
const nativeMacCaptureEvents = new EventEmitter();
|
|
|
|
/**
|
|
* Paths explicitly approved by the user via file picker dialogs or project loads.
|
|
* These are added at runtime when the user selects files from outside the default directories.
|
|
*/
|
|
const approvedPaths = new Set<string>();
|
|
|
|
function approveFilePath(filePath: string): void {
|
|
approvedPaths.add(path.resolve(filePath));
|
|
}
|
|
|
|
function getAllowedReadDirs(): string[] {
|
|
return [RECORDINGS_DIR];
|
|
}
|
|
|
|
function isPathWithinDir(filePath: string, dirPath: string): boolean {
|
|
const resolved = path.resolve(filePath);
|
|
const resolvedDir = path.resolve(dirPath);
|
|
return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep);
|
|
}
|
|
|
|
function isPathAllowed(filePath: string): boolean {
|
|
const resolved = path.resolve(filePath);
|
|
if (approvedPaths.has(resolved)) return true;
|
|
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
|
|
}
|
|
|
|
function resolveApprovedVideoPath(videoPath?: string | null): string | null {
|
|
const normalizedPath = normalizeVideoSourcePath(videoPath);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
if (!hasAllowedImportVideoExtension(normalizedPath) || !isPathAllowed(normalizedPath)) {
|
|
return null;
|
|
}
|
|
|
|
return normalizedPath;
|
|
}
|
|
|
|
/**
|
|
* Helper function to build dialog options with a parent window only when it's valid.
|
|
* This prevents passing stale or destroyed BrowserWindow references to dialog calls.
|
|
*/
|
|
function buildDialogOptions<T extends Electron.OpenDialogOptions | Electron.SaveDialogOptions>(
|
|
baseOptions: T,
|
|
parentWindow: BrowserWindow | null,
|
|
): T & { parent?: BrowserWindow } {
|
|
const mainWindow = parentWindow;
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
return { ...baseOptions, parent: mainWindow };
|
|
}
|
|
return baseOptions;
|
|
}
|
|
|
|
function hasAllowedImportVideoExtension(filePath: string): boolean {
|
|
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
}
|
|
|
|
function runProcess(
|
|
command: string,
|
|
args: string[],
|
|
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
function parseAfinfoAudioTrackBitrates(output: string): number[] {
|
|
const bitrates: number[] = [];
|
|
const trackSections = output.split(/\n----\n/g).slice(1);
|
|
for (const section of trackSections) {
|
|
const match = section.match(/\bbit rate:\s*([0-9]+)\s*bits per second/i);
|
|
bitrates.push(match ? Number(match[1]) : 0);
|
|
}
|
|
return bitrates;
|
|
}
|
|
|
|
async function prepareSupplementalPreviewAudioTrack(videoPath: string) {
|
|
const normalizedPath = await approveReadableVideoPath(videoPath);
|
|
if (!normalizedPath) {
|
|
return {
|
|
success: false,
|
|
message: "File path is not approved or is not a supported video file",
|
|
};
|
|
}
|
|
|
|
if (process.platform !== "darwin" || path.extname(normalizedPath).toLowerCase() !== ".mp4") {
|
|
return { success: true, path: null };
|
|
}
|
|
|
|
const afinfo = await runProcess("/usr/bin/afinfo", [normalizedPath]);
|
|
if (afinfo.code !== 0) {
|
|
return { success: true, path: null };
|
|
}
|
|
|
|
const bitrates = parseAfinfoAudioTrackBitrates(`${afinfo.stdout}\n${afinfo.stderr}`);
|
|
if (bitrates.length <= 1) {
|
|
return { success: true, path: null };
|
|
}
|
|
|
|
let supplementalTrackIndex = 1;
|
|
for (let index = 2; index < bitrates.length; index += 1) {
|
|
if (bitrates[index] > bitrates[supplementalTrackIndex]) {
|
|
supplementalTrackIndex = index;
|
|
}
|
|
}
|
|
|
|
await fs.mkdir(PREVIEW_AUDIO_DIR, { recursive: true });
|
|
const sourceStat = await fs.stat(normalizedPath);
|
|
const parsedPath = path.parse(normalizedPath);
|
|
const outputPath = path.join(
|
|
PREVIEW_AUDIO_DIR,
|
|
`${parsedPath.name}.track-${supplementalTrackIndex}.${Math.round(sourceStat.mtimeMs)}.m4a`,
|
|
);
|
|
|
|
try {
|
|
const outputStat = await fs.stat(outputPath);
|
|
if (outputStat.mtimeMs >= sourceStat.mtimeMs) {
|
|
return { success: true, path: pathToFileURL(outputPath).toString() };
|
|
}
|
|
} catch {
|
|
// Generate below.
|
|
}
|
|
|
|
const conversion = await runProcess("/usr/bin/afconvert", [
|
|
"--read-track",
|
|
String(supplementalTrackIndex),
|
|
"-f",
|
|
"m4af",
|
|
"-d",
|
|
"aac",
|
|
normalizedPath,
|
|
outputPath,
|
|
]);
|
|
if (conversion.code !== 0) {
|
|
return {
|
|
success: false,
|
|
message: conversion.stderr || conversion.stdout || "Failed to prepare preview audio",
|
|
};
|
|
}
|
|
|
|
return { success: true, path: pathToFileURL(outputPath).toString() };
|
|
}
|
|
|
|
async function approveReadableVideoPath(
|
|
filePath?: string | null,
|
|
trustedDirs?: string[],
|
|
): Promise<string | null> {
|
|
const normalizedPath = normalizeVideoSourcePath(filePath);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
if (isPathAllowed(normalizedPath)) {
|
|
return normalizedPath;
|
|
}
|
|
|
|
if (!hasAllowedImportVideoExtension(normalizedPath)) {
|
|
return null;
|
|
}
|
|
|
|
// When called with trustedDirs (e.g. from project load), only auto-approve
|
|
// paths within those directories. This prevents malicious project files from
|
|
// approving reads to arbitrary filesystem locations.
|
|
if (trustedDirs) {
|
|
const resolved = path.resolve(normalizedPath);
|
|
const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir));
|
|
if (!withinTrusted) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const stats = await fs.stat(normalizedPath);
|
|
if (!stats.isFile()) {
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
approveFilePath(normalizedPath);
|
|
return normalizedPath;
|
|
}
|
|
|
|
function resolveRecordingOutputPath(fileName: string): string {
|
|
const trimmed = fileName.trim();
|
|
if (!trimmed) {
|
|
throw new Error("Invalid recording file name");
|
|
}
|
|
|
|
const parsedPath = path.parse(trimmed);
|
|
const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === "..");
|
|
const isNestedPath =
|
|
parsedPath.dir !== "" ||
|
|
path.isAbsolute(trimmed) ||
|
|
trimmed.includes("/") ||
|
|
trimmed.includes("\\");
|
|
if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) {
|
|
throw new Error("Recording file name must not contain path segments");
|
|
}
|
|
|
|
return path.join(RECORDINGS_DIR, parsedPath.base);
|
|
}
|
|
|
|
function isValidDurationMs(value: number | undefined): value is number {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
/**
|
|
* Finalize a single recording file: if it was streamed to disk, flush and close
|
|
* the stream; otherwise (a short recording, or the stream failed to open and the
|
|
* renderer fell back to in-memory buffering) write the buffered bytes. Returns
|
|
* whether the file was streamed, which the caller uses to decide whether the
|
|
* WebM duration needs patching on disk.
|
|
*/
|
|
async function finalizeRecordingFile(
|
|
registry: RecordingStreamRegistry,
|
|
fileName: string,
|
|
filePath: string,
|
|
videoData?: ArrayBuffer,
|
|
): Promise<boolean> {
|
|
const streamed = await registry.finalize(fileName);
|
|
if (!streamed && videoData && videoData.byteLength > 0) {
|
|
await fs.writeFile(filePath, Buffer.from(videoData));
|
|
}
|
|
return streamed;
|
|
}
|
|
|
|
async function getApprovedProjectSession(
|
|
project: unknown,
|
|
projectFilePath?: string,
|
|
): Promise<RecordingSession | null> {
|
|
if (!project || typeof project !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
|
const media: ProjectMedia | null =
|
|
normalizeProjectMedia(rawProject.media) ??
|
|
(typeof rawProject.videoPath === "string"
|
|
? {
|
|
screenVideoPath: normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
|
}
|
|
: null);
|
|
|
|
if (!media) {
|
|
return null;
|
|
}
|
|
|
|
// Only auto-approve media paths within the project's directory or RECORDINGS_DIR.
|
|
// This prevents crafted project files from approving reads to arbitrary locations.
|
|
const trustedDirs = [RECORDINGS_DIR];
|
|
if (projectFilePath) {
|
|
trustedDirs.push(path.dirname(path.resolve(projectFilePath)));
|
|
}
|
|
|
|
const screenVideoPath = await approveReadableVideoPath(media.screenVideoPath, trustedDirs);
|
|
if (!screenVideoPath) {
|
|
throw new Error("Project references an invalid or unsupported screen video path");
|
|
}
|
|
|
|
const webcamVideoPath = media.webcamVideoPath
|
|
? await approveReadableVideoPath(media.webcamVideoPath, trustedDirs)
|
|
: undefined;
|
|
if (media.webcamVideoPath && !webcamVideoPath) {
|
|
throw new Error("Project references an invalid or unsupported webcam video path");
|
|
}
|
|
|
|
return webcamVideoPath
|
|
? { screenVideoPath, webcamVideoPath, createdAt: Date.now() }
|
|
: { screenVideoPath, createdAt: Date.now() };
|
|
}
|
|
|
|
type SelectedSource = {
|
|
name: string;
|
|
id?: string;
|
|
display_id?: string;
|
|
displayId?: number;
|
|
displayIndex?: number;
|
|
screenIndex?: number;
|
|
displayLabel?: string;
|
|
bounds?: SourceBounds;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type SourceBounds = { x: number; y: number; width: number; height: number };
|
|
|
|
type AttachNativeMacWebcamRecordingInput = {
|
|
screenVideoPath?: string;
|
|
recordingId?: number;
|
|
webcam?: RecordedVideoAssetInput;
|
|
cursorCaptureMode?: CursorCaptureMode;
|
|
};
|
|
|
|
let selectedSource: SelectedSource | null = null;
|
|
let selectedDesktopSource: DesktopCapturerSource | null = null;
|
|
let lastEnumeratedSources = new Map<string, DesktopCapturerSource>();
|
|
let currentProjectPath: string | null = null;
|
|
let currentRecordingSession: RecordingSession | null = null;
|
|
|
|
/**
|
|
* Returns the cached DesktopCapturerSource set when the user picked a source.
|
|
* Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
|
|
*/
|
|
export function getSelectedDesktopSource(): DesktopCapturerSource | null {
|
|
return selectedDesktopSource;
|
|
}
|
|
let currentVideoPath: string | null = null;
|
|
|
|
function normalizePath(filePath: string) {
|
|
return path.resolve(filePath);
|
|
}
|
|
|
|
function normalizeVideoSourcePath(videoPath?: string | null): string | null {
|
|
if (typeof videoPath !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = videoPath.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/^file:\/\//i.test(trimmed)) {
|
|
try {
|
|
return fileURLToPath(trimmed);
|
|
} catch {
|
|
// Fall through and keep best-effort string path below.
|
|
}
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
function isTrustedProjectPath(filePath?: string | null) {
|
|
if (!filePath || !currentProjectPath) {
|
|
return false;
|
|
}
|
|
return normalizePath(filePath) === normalizePath(currentProjectPath);
|
|
}
|
|
|
|
const CURSOR_TELEMETRY_VERSION = 2;
|
|
const CURSOR_SAMPLE_INTERVAL_MS = 33;
|
|
const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz
|
|
|
|
let cursorRecordingSession: CursorRecordingSession | null = null;
|
|
let pendingCursorRecordingData: CursorRecordingData | null = null;
|
|
let nativeWindowsCaptureProcess: ChildProcessWithoutNullStreams | null = null;
|
|
let nativeWindowsCaptureOutput = "";
|
|
let nativeWindowsCaptureTargetPath: string | null = null;
|
|
let nativeWindowsCaptureWebcamTargetPath: string | null = null;
|
|
let nativeWindowsCaptureRecordingId: number | null = null;
|
|
let nativeWindowsCursorOffsetMs = 0;
|
|
let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay";
|
|
let nativeWindowsCursorRecordingStartMs = 0;
|
|
let nativeWindowsPauseStartedAtMs: number | null = null;
|
|
let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = [];
|
|
let nativeWindowsIsPaused = false;
|
|
let nativeWindowsCaptureStopping = false;
|
|
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";
|
|
let nativeMacCursorRecordingStartMs = 0;
|
|
let nativeMacPauseStartedAtMs: number | null = null;
|
|
let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = [];
|
|
let nativeMacIsPaused = false;
|
|
let guideHotkeyListenerProcess: ChildProcessWithoutNullStreams | null = null;
|
|
const GUIDE_MARKER_HOTKEY = "Control+F12";
|
|
const GUIDE_MARKER_HOTKEY_LABEL = "Ctrl+F12";
|
|
type GuideMarkerTrigger = GuideMarkerCapturedPayload["trigger"];
|
|
type GuideHotkeyBounds = { x: number; y: number; width: number; height: number };
|
|
type GuideHotkeyRecordingState = {
|
|
recordingId: number;
|
|
startedAtMs: number;
|
|
accumulatedPausedMs: number;
|
|
pausedAtMs: number | null;
|
|
bounds: GuideHotkeyBounds;
|
|
};
|
|
let activeGuideHotkeyRecording: GuideHotkeyRecordingState | null = null;
|
|
let activeGuideHotkeySessionId: number | null = null;
|
|
let guideMarkerHotkeyRegistered = false;
|
|
let lastGuideHotkeyCaptureAtMs = 0;
|
|
const GUIDE_HOTKEY_CAPTURE_DEBOUNCE_MS = 250;
|
|
|
|
function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
|
|
if (!sample || typeof sample !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const point = sample as Partial<CursorRecordingSample>;
|
|
const interactionType =
|
|
point.interactionType === "click" ||
|
|
point.interactionType === "mouseup" ||
|
|
point.interactionType === "move"
|
|
? point.interactionType
|
|
: "move";
|
|
return {
|
|
timeMs:
|
|
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
|
|
? Math.max(0, point.timeMs)
|
|
: 0,
|
|
cx: typeof point.cx === "number" && Number.isFinite(point.cx) ? point.cx : 0.5,
|
|
cy: typeof point.cy === "number" && Number.isFinite(point.cy) ? point.cy : 0.5,
|
|
assetId: typeof point.assetId === "string" ? point.assetId : null,
|
|
visible: typeof point.visible === "boolean" ? point.visible : true,
|
|
cursorType: typeof point.cursorType === "string" ? point.cursorType : null,
|
|
interactionType,
|
|
};
|
|
}
|
|
|
|
function normalizeCursorAsset(asset: unknown): NativeCursorAsset | null {
|
|
if (!asset || typeof asset !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const candidate = asset as Partial<NativeCursorAsset>;
|
|
if (typeof candidate.id !== "string" || typeof candidate.imageDataUrl !== "string") {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: candidate.id,
|
|
platform:
|
|
candidate.platform === "win32" ? "win32" : process.platform === "darwin" ? "darwin" : "linux",
|
|
imageDataUrl: candidate.imageDataUrl,
|
|
width:
|
|
typeof candidate.width === "number" && Number.isFinite(candidate.width)
|
|
? Math.max(1, Math.round(candidate.width))
|
|
: 1,
|
|
height:
|
|
typeof candidate.height === "number" && Number.isFinite(candidate.height)
|
|
? Math.max(1, Math.round(candidate.height))
|
|
: 1,
|
|
hotspotX:
|
|
typeof candidate.hotspotX === "number" && Number.isFinite(candidate.hotspotX)
|
|
? Math.max(0, Math.round(candidate.hotspotX))
|
|
: 0,
|
|
hotspotY:
|
|
typeof candidate.hotspotY === "number" && Number.isFinite(candidate.hotspotY)
|
|
? Math.max(0, Math.round(candidate.hotspotY))
|
|
: 0,
|
|
scaleFactor:
|
|
typeof candidate.scaleFactor === "number" && Number.isFinite(candidate.scaleFactor)
|
|
? Math.max(0.1, candidate.scaleFactor)
|
|
: undefined,
|
|
cursorType: typeof candidate.cursorType === "string" ? candidate.cursorType : null,
|
|
};
|
|
}
|
|
|
|
async function readCursorRecordingFile(targetVideoPath: string): Promise<CursorRecordingData> {
|
|
const telemetryPath = `${targetVideoPath}.cursor.json`;
|
|
try {
|
|
const content = await fs.readFile(telemetryPath, "utf-8");
|
|
const parsed = JSON.parse(content);
|
|
const rawSamples = Array.isArray(parsed)
|
|
? parsed
|
|
: Array.isArray(parsed?.samples)
|
|
? parsed.samples
|
|
: [];
|
|
const rawAssets = Array.isArray(parsed?.assets) ? parsed.assets : [];
|
|
|
|
const samples = rawSamples
|
|
.map((sample: unknown) => normalizeCursorSample(sample))
|
|
.filter((sample: CursorRecordingSample | null): sample is CursorRecordingSample =>
|
|
Boolean(sample),
|
|
)
|
|
.sort((a: CursorRecordingSample, b: CursorRecordingSample) => a.timeMs - b.timeMs);
|
|
|
|
const assets = rawAssets
|
|
.map((asset: unknown) => normalizeCursorAsset(asset))
|
|
.filter((asset: NativeCursorAsset | null): asset is NativeCursorAsset => Boolean(asset));
|
|
|
|
return {
|
|
version:
|
|
typeof parsed?.version === "number" && Number.isFinite(parsed.version) ? parsed.version : 1,
|
|
provider: parsed?.provider === "native" ? "native" : "none",
|
|
samples,
|
|
assets,
|
|
};
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code === "ENOENT") {
|
|
return {
|
|
version: CURSOR_TELEMETRY_VERSION,
|
|
provider: "none",
|
|
samples: [],
|
|
assets: [],
|
|
};
|
|
}
|
|
|
|
console.error("Failed to load cursor telemetry:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readCursorTelemetryFile(targetVideoPath: string) {
|
|
try {
|
|
const recordingData = await readCursorRecordingFile(targetVideoPath);
|
|
return {
|
|
success: true,
|
|
samples: recordingData.samples.map((sample) => ({
|
|
timeMs: sample.timeMs,
|
|
cx: sample.cx,
|
|
cy: sample.cy,
|
|
})),
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to load cursor telemetry:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to load cursor telemetry",
|
|
error: String(error),
|
|
samples: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
function resolveAssetBasePath() {
|
|
try {
|
|
if (app.isPackaged) {
|
|
const assetPath = path.join(process.resourcesPath, "assets");
|
|
return pathToFileURL(`${assetPath}${path.sep}`).toString();
|
|
}
|
|
const assetPath = path.join(app.getAppPath(), "public", "assets");
|
|
return pathToFileURL(`${assetPath}${path.sep}`).toString();
|
|
} catch (err) {
|
|
console.error("Failed to resolve asset base path:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseDesktopCapturerScreenIndex(sourceId?: string | null): number | null {
|
|
if (!sourceId?.startsWith("screen:")) {
|
|
return null;
|
|
}
|
|
const indexPart = sourceId.split(":")[1];
|
|
if (!indexPart || !/^\d+$/.test(indexPart)) {
|
|
return null;
|
|
}
|
|
const index = Number(indexPart);
|
|
return Number.isInteger(index) && index >= 0 ? index : null;
|
|
}
|
|
|
|
function normalizeSourceBounds(input: unknown): SourceBounds | undefined {
|
|
if (!input || typeof input !== "object") {
|
|
return undefined;
|
|
}
|
|
const bounds = input as Partial<SourceBounds>;
|
|
const x = Number(bounds.x);
|
|
const y = Number(bounds.y);
|
|
const width = Number(bounds.width);
|
|
const height = Number(bounds.height);
|
|
if (
|
|
!Number.isFinite(x) ||
|
|
!Number.isFinite(y) ||
|
|
!Number.isFinite(width) ||
|
|
!Number.isFinite(height) ||
|
|
width <= 0 ||
|
|
height <= 0
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
x: Math.round(x),
|
|
y: Math.round(y),
|
|
width: Math.round(width),
|
|
height: Math.round(height),
|
|
};
|
|
}
|
|
|
|
function toSourceBounds(bounds: Rectangle): SourceBounds {
|
|
return {
|
|
x: Math.round(bounds.x),
|
|
y: Math.round(bounds.y),
|
|
width: Math.round(bounds.width),
|
|
height: Math.round(bounds.height),
|
|
};
|
|
}
|
|
|
|
function findDisplayForSource(
|
|
source: Pick<DesktopCapturerSource, "id" | "display_id">,
|
|
screenSourceIndex?: number,
|
|
) {
|
|
const displays = screen.getAllDisplays();
|
|
const displayId = Number(source.display_id);
|
|
const displayById = Number.isFinite(displayId)
|
|
? displays.find((display) => display.id === displayId)
|
|
: undefined;
|
|
if (displayById) {
|
|
return { display: displayById, displayIndex: displays.indexOf(displayById) };
|
|
}
|
|
|
|
const sourceIndex = parseDesktopCapturerScreenIndex(source.id) ?? screenSourceIndex;
|
|
if (sourceIndex !== null && sourceIndex !== undefined && sourceIndex < displays.length) {
|
|
return { display: displays[sourceIndex], displayIndex: sourceIndex };
|
|
}
|
|
|
|
return { display: null, displayIndex: undefined };
|
|
}
|
|
|
|
function getSelectedSourceDisplay() {
|
|
const displays = screen.getAllDisplays();
|
|
const explicitDisplayId =
|
|
typeof selectedSource?.displayId === "number"
|
|
? selectedSource.displayId
|
|
: Number(selectedSource?.display_id);
|
|
const displayById = Number.isFinite(explicitDisplayId)
|
|
? displays.find((display) => display.id === explicitDisplayId)
|
|
: undefined;
|
|
if (displayById) {
|
|
return displayById;
|
|
}
|
|
|
|
const sourceIndex =
|
|
typeof selectedSource?.displayIndex === "number"
|
|
? selectedSource.displayIndex
|
|
: typeof selectedSource?.screenIndex === "number"
|
|
? selectedSource.screenIndex
|
|
: parseDesktopCapturerScreenIndex(selectedSource?.id);
|
|
if (sourceIndex !== null && sourceIndex !== undefined && sourceIndex < displays.length) {
|
|
return displays[sourceIndex];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getSelectedSourceBounds() {
|
|
const cursor = screen.getCursorScreenPoint();
|
|
const selectedBounds = normalizeSourceBounds(selectedSource?.bounds);
|
|
if (selectedBounds) {
|
|
return selectedBounds;
|
|
}
|
|
|
|
const sourceDisplay = getSelectedSourceDisplay();
|
|
return (sourceDisplay ?? screen.getDisplayNearestPoint(cursor)).bounds;
|
|
}
|
|
|
|
function normalizeGuideHotkeyRecordingId(recordingId: unknown): number | null {
|
|
if (typeof recordingId === "number" && Number.isFinite(recordingId)) {
|
|
return Math.trunc(recordingId);
|
|
}
|
|
if (typeof recordingId === "string" && recordingId.trim()) {
|
|
const numeric = Number(recordingId);
|
|
return Number.isFinite(numeric) ? Math.trunc(numeric) : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sanitizeGuideHotkeyBounds(bounds: GuideHotkeyBounds): GuideHotkeyBounds {
|
|
return {
|
|
x: Number.isFinite(bounds.x) ? bounds.x : 0,
|
|
y: Number.isFinite(bounds.y) ? bounds.y : 0,
|
|
width: Number.isFinite(bounds.width) && bounds.width > 0 ? bounds.width : 1,
|
|
height: Number.isFinite(bounds.height) && bounds.height > 0 ? bounds.height : 1,
|
|
};
|
|
}
|
|
|
|
function startGuideHotkeyRecording(
|
|
recordingIdInput: unknown,
|
|
bounds: GuideHotkeyBounds = getSelectedSourceBounds(),
|
|
) {
|
|
const recordingId = normalizeGuideHotkeyRecordingId(recordingIdInput);
|
|
if (recordingId === null) {
|
|
return;
|
|
}
|
|
|
|
activeGuideHotkeyRecording = {
|
|
recordingId,
|
|
startedAtMs: Date.now(),
|
|
accumulatedPausedMs: 0,
|
|
pausedAtMs: null,
|
|
bounds: sanitizeGuideHotkeyBounds(bounds),
|
|
};
|
|
}
|
|
|
|
function clearGuideHotkeyRecording() {
|
|
activeGuideHotkeyRecording = null;
|
|
activeGuideHotkeySessionId = null;
|
|
}
|
|
|
|
function activateGuideHotkeySession(recordingIdInput: unknown) {
|
|
const recordingId = normalizeGuideHotkeyRecordingId(recordingIdInput);
|
|
if (recordingId !== null) {
|
|
activeGuideHotkeySessionId = recordingId;
|
|
}
|
|
}
|
|
|
|
function deactivateGuideHotkeySession(recordingIdInput: unknown) {
|
|
const recordingId = normalizeGuideHotkeyRecordingId(recordingIdInput);
|
|
if (recordingId === null || activeGuideHotkeySessionId === recordingId) {
|
|
activeGuideHotkeySessionId = null;
|
|
}
|
|
}
|
|
|
|
function pauseGuideHotkeyRecording() {
|
|
if (activeGuideHotkeyRecording && activeGuideHotkeyRecording.pausedAtMs === null) {
|
|
activeGuideHotkeyRecording.pausedAtMs = Date.now();
|
|
}
|
|
}
|
|
|
|
function resumeGuideHotkeyRecording() {
|
|
if (!activeGuideHotkeyRecording || activeGuideHotkeyRecording.pausedAtMs === null) {
|
|
return;
|
|
}
|
|
|
|
activeGuideHotkeyRecording.accumulatedPausedMs += Math.max(
|
|
0,
|
|
Date.now() - activeGuideHotkeyRecording.pausedAtMs,
|
|
);
|
|
activeGuideHotkeyRecording.pausedAtMs = null;
|
|
}
|
|
|
|
function getGuideHotkeyRecordingTimeMs(recording: GuideHotkeyRecordingState): number {
|
|
const now = recording.pausedAtMs ?? Date.now();
|
|
return Math.max(0, now - recording.startedAtMs - recording.accumulatedPausedMs);
|
|
}
|
|
|
|
function getGuideHotkeyPoint(boundsInput: GuideHotkeyBounds) {
|
|
const bounds = sanitizeGuideHotkeyBounds(boundsInput);
|
|
const cursor = screen.getCursorScreenPoint();
|
|
return {
|
|
normalizedX: clampGuideHotkey01((cursor.x - bounds.x) / bounds.width),
|
|
normalizedY: clampGuideHotkey01((cursor.y - bounds.y) / bounds.height),
|
|
rawX: cursor.x,
|
|
rawY: cursor.y,
|
|
bounds,
|
|
};
|
|
}
|
|
|
|
function clampGuideHotkey01(value: number): number {
|
|
if (!Number.isFinite(value)) {
|
|
return 0;
|
|
}
|
|
return Math.min(1, Math.max(0, value));
|
|
}
|
|
|
|
async function captureGuideHotkeyMarker(
|
|
guideStore: GuideStore,
|
|
trigger: GuideMarkerTrigger = "global-shortcut",
|
|
) {
|
|
const recording = activeGuideHotkeyRecording;
|
|
if (!recording || activeGuideHotkeySessionId !== recording.recordingId) {
|
|
return { captured: false };
|
|
}
|
|
|
|
const captureRequestedAtMs = Date.now();
|
|
if (captureRequestedAtMs - lastGuideHotkeyCaptureAtMs < GUIDE_HOTKEY_CAPTURE_DEBOUNCE_MS) {
|
|
return { captured: false };
|
|
}
|
|
lastGuideHotkeyCaptureAtMs = captureRequestedAtMs;
|
|
|
|
const point = getGuideHotkeyPoint(recording.bounds);
|
|
try {
|
|
const result = await guideStore.addMarker({
|
|
recordingId: recording.recordingId,
|
|
kind: "hotkey",
|
|
timeMs: getGuideHotkeyRecordingTimeMs(recording),
|
|
x: point.normalizedX,
|
|
y: point.normalizedY,
|
|
normalizedX: point.normalizedX,
|
|
normalizedY: point.normalizedY,
|
|
});
|
|
notifyGuideMarkerCaptured({
|
|
recordingId: result.event.recordingId,
|
|
eventId: result.event.id,
|
|
timeMs: result.event.timeMs,
|
|
trigger,
|
|
normalizedX: result.event.normalizedX,
|
|
normalizedY: result.event.normalizedY,
|
|
rawX: point.rawX,
|
|
rawY: point.rawY,
|
|
});
|
|
console.info("[guide-hotkey] marker captured", {
|
|
recordingId: recording.recordingId,
|
|
timeMs: result.event.timeMs,
|
|
trigger,
|
|
normalizedX: result.event.normalizedX,
|
|
normalizedY: result.event.normalizedY,
|
|
rawX: point.rawX,
|
|
rawY: point.rawY,
|
|
bounds: point.bounds,
|
|
});
|
|
return { captured: true, ...result };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn("[guide-hotkey] failed to capture marker:", message);
|
|
return { captured: false, error: message };
|
|
}
|
|
}
|
|
|
|
function notifyGuideMarkerCaptured(payload: GuideMarkerCapturedPayload) {
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
if (!window.isDestroyed()) {
|
|
window.webContents.send("guide:marker-captured", payload);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleGuideHotkeyListenerLine(line: string, guideStore: GuideStore) {
|
|
const text = line.trim();
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const event = JSON.parse(text) as {
|
|
event?: unknown;
|
|
key?: unknown;
|
|
state?: unknown;
|
|
};
|
|
if (event.event === "ready") {
|
|
console.info("[guide-hotkey] native Ctrl listener ready");
|
|
return;
|
|
}
|
|
if (event.event === "guide-hotkey" && event.key === "control" && event.state === "down") {
|
|
void captureGuideHotkeyMarker(guideStore, "global-control");
|
|
return;
|
|
}
|
|
} catch {
|
|
console.warn("[guide-hotkey] native listener emitted invalid JSON:", text);
|
|
}
|
|
}
|
|
|
|
async function startNativeGuideHotkeyListener(guideStore: GuideStore) {
|
|
if (process.platform !== "win32" || guideHotkeyListenerProcess) {
|
|
return;
|
|
}
|
|
|
|
const helperPath = await findNativeGuideHotkeyListenerPath();
|
|
if (!helperPath) {
|
|
console.warn("[guide-hotkey] native Ctrl listener is unavailable");
|
|
return;
|
|
}
|
|
|
|
const proc = spawn(helperPath, [], {
|
|
cwd: path.dirname(helperPath),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
proc.stdin.end();
|
|
guideHotkeyListenerProcess = proc;
|
|
|
|
let stdoutBuffer = "";
|
|
proc.stdout.setEncoding("utf-8");
|
|
proc.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
stdoutBuffer = lines.pop() ?? "";
|
|
for (const line of lines) {
|
|
handleGuideHotkeyListenerLine(line, guideStore);
|
|
}
|
|
});
|
|
|
|
proc.stderr.setEncoding("utf-8");
|
|
proc.stderr.on("data", (chunk: string) => {
|
|
const message = chunk.trim();
|
|
if (message) {
|
|
console.warn("[guide-hotkey] native listener:", message);
|
|
}
|
|
});
|
|
|
|
proc.once("error", (error) => {
|
|
console.warn("[guide-hotkey] failed to start native Ctrl listener:", error);
|
|
if (guideHotkeyListenerProcess === proc) {
|
|
guideHotkeyListenerProcess = null;
|
|
}
|
|
});
|
|
proc.once("exit", (code, signal) => {
|
|
if (guideHotkeyListenerProcess === proc) {
|
|
guideHotkeyListenerProcess = null;
|
|
}
|
|
if (code !== 0 && code !== null) {
|
|
console.warn("[guide-hotkey] native Ctrl listener exited", { code, signal });
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopNativeGuideHotkeyListener() {
|
|
const proc = guideHotkeyListenerProcess;
|
|
guideHotkeyListenerProcess = null;
|
|
if (proc && !proc.killed) {
|
|
proc.kill();
|
|
}
|
|
}
|
|
|
|
function registerGuideMarkerHotkey(guideStore: GuideStore) {
|
|
if (guideMarkerHotkeyRegistered) {
|
|
return;
|
|
}
|
|
|
|
void startNativeGuideHotkeyListener(guideStore);
|
|
|
|
guideMarkerHotkeyRegistered = globalShortcut.register(GUIDE_MARKER_HOTKEY, () => {
|
|
void captureGuideHotkeyMarker(guideStore, "global-shortcut");
|
|
});
|
|
|
|
if (!guideMarkerHotkeyRegistered) {
|
|
console.warn(`[guide-hotkey] failed to register ${GUIDE_MARKER_HOTKEY_LABEL}`);
|
|
return;
|
|
}
|
|
|
|
app.once("will-quit", () => {
|
|
globalShortcut.unregister(GUIDE_MARKER_HOTKEY);
|
|
stopNativeGuideHotkeyListener();
|
|
guideMarkerHotkeyRegistered = false;
|
|
});
|
|
}
|
|
|
|
function getSelectedSourceId() {
|
|
return typeof selectedSource?.id === "string" ? selectedSource.id : null;
|
|
}
|
|
|
|
function getSelectedDisplay() {
|
|
return getSelectedSourceDisplay();
|
|
}
|
|
|
|
function resolveUnpackedAppPath(...segments: string[]) {
|
|
const resolved = path.join(app.getAppPath(), ...segments);
|
|
if (app.isPackaged) {
|
|
return resolved.replace(/\.asar([/\\])/, ".asar.unpacked$1");
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
function resolvePackagedResourcePath(...segments: string[]) {
|
|
if (!app.isPackaged) {
|
|
return null;
|
|
}
|
|
|
|
return path.join(process.resourcesPath, ...segments);
|
|
}
|
|
|
|
function getNativeWindowsCaptureHelperCandidates() {
|
|
const envPath = process.env.OPENSCREEN_WGC_CAPTURE_EXE?.trim();
|
|
const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
|
return [
|
|
envPath,
|
|
resolveUnpackedAppPath(
|
|
"electron",
|
|
"native",
|
|
"wgc-capture",
|
|
"build",
|
|
"Release",
|
|
"wgc-capture.exe",
|
|
),
|
|
resolveUnpackedAppPath("electron", "native", "wgc-capture", "build", "wgc-capture.exe"),
|
|
resolveUnpackedAppPath("electron", "native", "bin", archTag, "wgc-capture.exe"),
|
|
resolvePackagedResourcePath("electron", "native", "bin", archTag, "wgc-capture.exe"),
|
|
].filter((candidate): candidate is string => Boolean(candidate));
|
|
}
|
|
|
|
function getNativeGuideHotkeyListenerCandidates() {
|
|
const envPath = process.env.OPENSCREEN_GUIDE_HOTKEY_LISTENER_EXE?.trim();
|
|
const archTag = process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
|
const helperName = "guide-hotkey-listener.exe";
|
|
return [
|
|
envPath,
|
|
resolveUnpackedAppPath("electron", "native", "wgc-capture", "build", "Release", helperName),
|
|
resolveUnpackedAppPath("electron", "native", "wgc-capture", "build", helperName),
|
|
resolveUnpackedAppPath("electron", "native", "bin", archTag, helperName),
|
|
resolvePackagedResourcePath("electron", "native", "bin", archTag, helperName),
|
|
].filter((candidate): candidate is string => Boolean(candidate));
|
|
}
|
|
|
|
async function findNativeWindowsCaptureHelperPath() {
|
|
if (process.platform !== "win32") {
|
|
return null;
|
|
}
|
|
|
|
for (const candidate of getNativeWindowsCaptureHelperCandidates()) {
|
|
try {
|
|
await fs.access(candidate, fsConstants.X_OK);
|
|
return candidate;
|
|
} catch {
|
|
// Try the next configured helper location.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function findNativeGuideHotkeyListenerPath() {
|
|
if (process.platform !== "win32") {
|
|
return null;
|
|
}
|
|
|
|
for (const candidate of getNativeGuideHotkeyListenerCandidates()) {
|
|
try {
|
|
await fs.access(candidate, fsConstants.X_OK);
|
|
return candidate;
|
|
} catch {
|
|
// Try the next configured helper location.
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const [, , build] = process.getSystemVersion().split(".").map(Number);
|
|
return Number.isFinite(build) && build >= 19041;
|
|
}
|
|
|
|
function normalizeNativeDeviceName(value: string) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function scoreNativeDeviceName(candidateName: string, candidateId: string, requestedName?: string) {
|
|
const candidate = normalizeNativeDeviceName(candidateName);
|
|
const id = normalizeNativeDeviceName(candidateId);
|
|
const requested = normalizeNativeDeviceName(requestedName ?? "");
|
|
if (!requested) {
|
|
return 0;
|
|
}
|
|
if (candidate === requested) {
|
|
return 1000;
|
|
}
|
|
if (candidate.includes(requested) || requested.includes(candidate)) {
|
|
return 900;
|
|
}
|
|
if (id.includes(requested) || requested.includes(id)) {
|
|
return 800;
|
|
}
|
|
|
|
return requested
|
|
.split(/\s+/)
|
|
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
|
|
.reduce((score, word) => {
|
|
if (candidate.includes(word)) return score + 100;
|
|
if (id.includes(word)) return score + 50;
|
|
return score;
|
|
}, 0);
|
|
}
|
|
|
|
function queryDirectShowVideoInputRegistry() {
|
|
return new Promise<string>((resolve) => {
|
|
const proc = spawn(
|
|
"reg.exe",
|
|
["query", "HKCR\\CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance", "/s"],
|
|
{ windowsHide: true },
|
|
);
|
|
let stdout = "";
|
|
proc.stdout.on("data", (chunk: Buffer) => {
|
|
stdout += chunk.toString("utf16le").includes("\u0000")
|
|
? chunk.toString("utf16le")
|
|
: chunk.toString();
|
|
});
|
|
proc.on("close", () => resolve(stdout));
|
|
proc.on("error", () => resolve(""));
|
|
});
|
|
}
|
|
|
|
async function resolveDirectShowWebcamClsid(deviceName?: string) {
|
|
if (process.platform !== "win32" || !deviceName?.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const output = await queryDirectShowVideoInputRegistry();
|
|
let current: { friendlyName?: string; clsid?: string } = {};
|
|
const entries: Array<{ friendlyName?: string; clsid?: string }> = [];
|
|
for (const rawLine of output.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
if (/^HKEY_/i.test(line)) {
|
|
if (current.friendlyName || current.clsid) entries.push(current);
|
|
current = {};
|
|
continue;
|
|
}
|
|
const match = line.match(/^(\S+)\s+REG_SZ\s+(.+)$/);
|
|
if (!match) continue;
|
|
if (match[1] === "FriendlyName") current.friendlyName = match[2].trim();
|
|
if (match[1] === "CLSID") current.clsid = match[2].trim();
|
|
}
|
|
if (current.friendlyName || current.clsid) entries.push(current);
|
|
|
|
let best: { clsid: string; friendlyName?: string; score: number } | null = null;
|
|
for (const entry of entries) {
|
|
if (!entry.clsid) continue;
|
|
const score = scoreNativeDeviceName(entry.friendlyName ?? "", entry.clsid, deviceName);
|
|
if (!best || score > best.score) {
|
|
best = { clsid: entry.clsid, friendlyName: entry.friendlyName, score };
|
|
}
|
|
}
|
|
|
|
if (!best || best.score <= 0) {
|
|
return null;
|
|
}
|
|
|
|
console.info("[native-wgc] resolved DirectShow webcam filter", {
|
|
requestedName: deviceName,
|
|
filterName: best.friendlyName,
|
|
clsid: best.clsid,
|
|
score: best.score,
|
|
});
|
|
return best.clsid;
|
|
}
|
|
|
|
async function startCursorRecording(recordingId?: number) {
|
|
if (cursorRecordingSession) {
|
|
pendingCursorRecordingData = await cursorRecordingSession.stop();
|
|
cursorRecordingSession = null;
|
|
}
|
|
|
|
pendingCursorRecordingData = null;
|
|
cursorRecordingSession = createCursorRecordingSession({
|
|
getDisplayBounds: getSelectedSourceBounds,
|
|
maxSamples: MAX_CURSOR_SAMPLES,
|
|
platform: process.platform,
|
|
sampleIntervalMs: CURSOR_SAMPLE_INTERVAL_MS,
|
|
sourceId: getSelectedSourceId(),
|
|
startTimeMs:
|
|
typeof recordingId === "number" && Number.isFinite(recordingId) ? recordingId : undefined,
|
|
});
|
|
|
|
try {
|
|
await cursorRecordingSession.start();
|
|
} catch (error) {
|
|
console.error("Failed to start cursor recording session:", error);
|
|
cursorRecordingSession = null;
|
|
}
|
|
}
|
|
|
|
async function stopCursorRecording() {
|
|
if (!cursorRecordingSession) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
pendingCursorRecordingData = await cursorRecordingSession.stop();
|
|
} catch (error) {
|
|
console.error("Failed to stop cursor recording session:", error);
|
|
pendingCursorRecordingData = null;
|
|
} finally {
|
|
cursorRecordingSession = null;
|
|
}
|
|
}
|
|
|
|
async function writePendingCursorTelemetry(videoPath: string) {
|
|
const telemetryPath = `${videoPath}.cursor.json`;
|
|
if (pendingCursorRecordingData && pendingCursorRecordingData.samples.length > 0) {
|
|
await fs.writeFile(telemetryPath, JSON.stringify(pendingCursorRecordingData, null, 2), "utf-8");
|
|
}
|
|
pendingCursorRecordingData = null;
|
|
}
|
|
|
|
function shiftPendingCursorTelemetry(offsetMs: number) {
|
|
if (!pendingCursorRecordingData || !Number.isFinite(offsetMs) || offsetMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
pendingCursorRecordingData = {
|
|
...pendingCursorRecordingData,
|
|
samples: pendingCursorRecordingData.samples
|
|
.map((sample) => ({
|
|
...sample,
|
|
timeMs: Math.max(0, sample.timeMs - offsetMs),
|
|
}))
|
|
.sort((a, b) => a.timeMs - b.timeMs),
|
|
};
|
|
}
|
|
|
|
function compactPendingCursorTelemetryPauseRanges(
|
|
ranges: Array<{ startMs: number; endMs: number }>,
|
|
) {
|
|
if (!pendingCursorRecordingData || ranges.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const normalizedRanges = ranges
|
|
.map((range) => ({
|
|
startMs: Math.max(0, Math.min(range.startMs, range.endMs)),
|
|
endMs: Math.max(0, Math.max(range.startMs, range.endMs)),
|
|
}))
|
|
.filter((range) => Number.isFinite(range.startMs) && Number.isFinite(range.endMs))
|
|
.filter((range) => range.endMs > range.startMs)
|
|
.sort((a, b) => a.startMs - b.startMs);
|
|
|
|
if (normalizedRanges.length === 0) {
|
|
return;
|
|
}
|
|
|
|
pendingCursorRecordingData = {
|
|
...pendingCursorRecordingData,
|
|
samples: pendingCursorRecordingData.samples
|
|
.map((sample) => {
|
|
let pausedBeforeSampleMs = 0;
|
|
for (const range of normalizedRanges) {
|
|
if (sample.timeMs >= range.startMs && sample.timeMs <= range.endMs) {
|
|
return null;
|
|
}
|
|
if (sample.timeMs > range.endMs) {
|
|
pausedBeforeSampleMs += range.endMs - range.startMs;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...sample,
|
|
timeMs: Math.max(0, sample.timeMs - pausedBeforeSampleMs),
|
|
};
|
|
})
|
|
.filter((sample): sample is CursorRecordingSample => Boolean(sample))
|
|
.sort((a, b) => a.timeMs - b.timeMs),
|
|
};
|
|
}
|
|
|
|
function completeNativeMacCursorPauseRange(endMs = Date.now()) {
|
|
if (nativeMacPauseStartedAtMs === null || nativeMacCursorRecordingStartMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
nativeMacPauseRanges.push({
|
|
startMs: Math.max(0, nativeMacPauseStartedAtMs - nativeMacCursorRecordingStartMs),
|
|
endMs: Math.max(0, endMs - nativeMacCursorRecordingStartMs),
|
|
});
|
|
nativeMacPauseStartedAtMs = null;
|
|
}
|
|
|
|
function completeNativeWindowsCursorPauseRange(endMs = Date.now()) {
|
|
if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
nativeWindowsPauseRanges.push({
|
|
startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs),
|
|
endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs),
|
|
});
|
|
nativeWindowsPauseStartedAtMs = null;
|
|
}
|
|
|
|
function resetNativeWindowsCaptureState() {
|
|
nativeWindowsCaptureProcess = null;
|
|
nativeWindowsCaptureTargetPath = null;
|
|
nativeWindowsCaptureWebcamTargetPath = null;
|
|
nativeWindowsCaptureRecordingId = null;
|
|
nativeWindowsCursorOffsetMs = 0;
|
|
nativeWindowsCursorCaptureMode = "editable-overlay";
|
|
nativeWindowsCursorRecordingStartMs = 0;
|
|
nativeWindowsPauseStartedAtMs = null;
|
|
nativeWindowsPauseRanges = [];
|
|
nativeWindowsIsPaused = false;
|
|
nativeWindowsCaptureStopping = false;
|
|
clearGuideHotkeyRecording();
|
|
}
|
|
|
|
function hasActiveNativeWindowsCaptureProcess() {
|
|
const proc = nativeWindowsCaptureProcess;
|
|
if (!proc) {
|
|
return false;
|
|
}
|
|
if (proc.exitCode === null && !proc.killed) {
|
|
return true;
|
|
}
|
|
|
|
console.warn("[native-wgc] clearing stale Windows capture process state", {
|
|
exitCode: proc.exitCode,
|
|
killed: proc.killed,
|
|
});
|
|
resetNativeWindowsCaptureState();
|
|
return false;
|
|
}
|
|
|
|
function attachNativeWindowsCaptureLifecycle(
|
|
proc: ChildProcessWithoutNullStreams,
|
|
sourceName: string,
|
|
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
|
|
) {
|
|
const cleanupAfterUnexpectedExit = async () => {
|
|
try {
|
|
await stopCursorRecording();
|
|
} catch (error) {
|
|
console.warn("[native-wgc] failed to stop cursor recording after helper exit", error);
|
|
}
|
|
pendingCursorRecordingData = null;
|
|
resetNativeWindowsCaptureState();
|
|
onRecordingStateChange?.(false, sourceName);
|
|
};
|
|
|
|
function onClose(code: number | null, signal: NodeJS.Signals | null) {
|
|
proc.off("error", onError);
|
|
if (nativeWindowsCaptureProcess !== proc || nativeWindowsCaptureStopping) {
|
|
return;
|
|
}
|
|
|
|
console.warn("[native-wgc] Windows capture helper exited before stop was requested", {
|
|
code,
|
|
signal,
|
|
output: nativeWindowsCaptureOutput.trim(),
|
|
});
|
|
void cleanupAfterUnexpectedExit();
|
|
}
|
|
function onError(error: Error) {
|
|
proc.off("close", onClose);
|
|
if (nativeWindowsCaptureProcess !== proc || nativeWindowsCaptureStopping) {
|
|
return;
|
|
}
|
|
|
|
console.warn("[native-wgc] Windows capture helper errored before stop was requested", error);
|
|
void cleanupAfterUnexpectedExit();
|
|
}
|
|
|
|
proc.once("close", onClose);
|
|
proc.once("error", onError);
|
|
}
|
|
|
|
function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error("Timed out waiting for native Windows capture to start"));
|
|
}, 12000);
|
|
|
|
const onOutput = (chunk: Buffer) => {
|
|
nativeWindowsCaptureOutput += chunk.toString();
|
|
if (nativeWindowsCaptureOutput.includes("Recording started")) {
|
|
cleanup();
|
|
resolve();
|
|
}
|
|
};
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
const onExit = (code: number | null) => {
|
|
cleanup();
|
|
reject(
|
|
new Error(
|
|
nativeWindowsCaptureOutput.trim() ||
|
|
`Native Windows capture exited before recording started (code=${code ?? "unknown"})`,
|
|
),
|
|
);
|
|
};
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
proc.stdout.off("data", onOutput);
|
|
proc.stderr.off("data", onOutput);
|
|
proc.off("error", onError);
|
|
proc.off("exit", onExit);
|
|
};
|
|
|
|
proc.stdout.on("data", onOutput);
|
|
proc.stderr.on("data", onOutput);
|
|
proc.once("error", onError);
|
|
proc.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
function waitForNativeWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
if (!proc.killed) {
|
|
proc.kill();
|
|
}
|
|
reject(
|
|
new Error(
|
|
`Timed out waiting for native Windows capture to stop. Output path: ${
|
|
nativeWindowsCaptureTargetPath ?? "unknown"
|
|
}. Output: ${nativeWindowsCaptureOutput.trim()}`,
|
|
),
|
|
);
|
|
}, NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS);
|
|
const onOutput = (chunk: Buffer) => {
|
|
nativeWindowsCaptureOutput += chunk.toString();
|
|
};
|
|
const onClose = (code: number | null) => {
|
|
cleanup();
|
|
const match = nativeWindowsCaptureOutput.match(/Recording stopped\. Output path: (.+)/);
|
|
if (match?.[1]) {
|
|
resolve(match[1].trim());
|
|
return;
|
|
}
|
|
if (code === 0 && nativeWindowsCaptureTargetPath) {
|
|
resolve(nativeWindowsCaptureTargetPath);
|
|
return;
|
|
}
|
|
reject(
|
|
new Error(
|
|
nativeWindowsCaptureOutput.trim() ||
|
|
`Native Windows 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 readNativeWindowsWebcamFormat(output: string) {
|
|
const lines = output.split(/\r?\n/).filter((line) => line.includes('"event":"webcam-format"'));
|
|
const lastLine = lines.at(-1);
|
|
if (!lastLine) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(lastLine) as {
|
|
width?: number;
|
|
height?: number;
|
|
fps?: number;
|
|
deviceName?: string;
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tryParseNativeHelperEvent(line: string) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function inspectNativeMacCaptureOutput() {
|
|
for (const line of nativeMacCaptureOutput.split(/\r?\n/)) {
|
|
const event = tryParseNativeHelperEvent(line.trim());
|
|
if (event) {
|
|
nativeMacCaptureEvents.emit("helper-event", event);
|
|
}
|
|
}
|
|
}
|
|
|
|
function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) {
|
|
let lineBuffer = "";
|
|
const drain = (chunk: Buffer) => {
|
|
const text = chunk.toString();
|
|
nativeMacCaptureOutput += text;
|
|
lineBuffer += text;
|
|
const lines = lineBuffer.split(/\r?\n/);
|
|
lineBuffer = lines.pop() ?? "";
|
|
for (const line of lines) {
|
|
const event = tryParseNativeHelperEvent(line.trim());
|
|
if (event) {
|
|
nativeMacCaptureEvents.emit("helper-event", event);
|
|
}
|
|
}
|
|
};
|
|
const cleanup = () => {
|
|
proc.stdout.off("data", drain);
|
|
proc.stderr.off("data", drain);
|
|
proc.off("close", cleanup);
|
|
proc.off("error", cleanup);
|
|
};
|
|
|
|
proc.stdout.on("data", drain);
|
|
proc.stderr.on("data", drain);
|
|
proc.once("close", cleanup);
|
|
proc.once("error", cleanup);
|
|
}
|
|
|
|
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 = (event: Record<string, unknown>) => {
|
|
if (event.event === "recording-started") {
|
|
cleanup();
|
|
resolve();
|
|
return;
|
|
}
|
|
if (event.event === "error") {
|
|
cleanup();
|
|
reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed")));
|
|
}
|
|
};
|
|
|
|
const onOutput = (event: Record<string, unknown>) => inspect(event);
|
|
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);
|
|
nativeMacCaptureEvents.off("helper-event", onOutput);
|
|
proc.off("close", onClose);
|
|
proc.off("error", onError);
|
|
};
|
|
|
|
nativeMacCaptureEvents.on("helper-event", onOutput);
|
|
proc.once("close", onClose);
|
|
proc.once("error", onError);
|
|
inspectNativeMacCaptureOutput();
|
|
});
|
|
}
|
|
|
|
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 = (event: Record<string, unknown>) => {
|
|
if (event.event === "recording-stopped") {
|
|
cleanup();
|
|
resolve(String(event.screenPath ?? nativeMacCaptureTargetPath ?? ""));
|
|
return;
|
|
}
|
|
if (event.event === "error") {
|
|
cleanup();
|
|
reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed")));
|
|
}
|
|
};
|
|
|
|
const onOutput = (event: Record<string, unknown>) => inspect(event);
|
|
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);
|
|
nativeMacCaptureEvents.off("helper-event", onOutput);
|
|
proc.off("close", onClose);
|
|
proc.off("error", onError);
|
|
};
|
|
|
|
nativeMacCaptureEvents.on("helper-event", onOutput);
|
|
proc.once("close", onClose);
|
|
proc.once("error", onError);
|
|
inspectNativeMacCaptureOutput();
|
|
});
|
|
}
|
|
|
|
function setCurrentRecordingSessionState(session: RecordingSession | null) {
|
|
currentRecordingSession = session;
|
|
currentVideoPath = session?.screenVideoPath ?? null;
|
|
}
|
|
|
|
function getSessionManifestPathForVideo(videoPath: string) {
|
|
const parsedPath = path.parse(videoPath);
|
|
const baseName = parsedPath.name.endsWith("-webcam")
|
|
? parsedPath.name.slice(0, -"-webcam".length)
|
|
: parsedPath.name;
|
|
return path.join(parsedPath.dir, `${baseName}${RECORDING_SESSION_SUFFIX}`);
|
|
}
|
|
|
|
async function loadRecordedSessionForVideoPath(
|
|
videoPath: string,
|
|
): Promise<RecordingSession | null> {
|
|
try {
|
|
const manifestPath = getSessionManifestPathForVideo(videoPath);
|
|
if (!isPathAllowed(manifestPath)) {
|
|
const parsedVideoPath = path.parse(videoPath);
|
|
if (!isPathWithinDir(path.resolve(manifestPath), parsedVideoPath.dir)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const content = await fs.readFile(manifestPath, "utf-8");
|
|
const session = normalizeRecordingSession(JSON.parse(content));
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedVideoPath = normalizePath(videoPath);
|
|
const matchesScreen = normalizePath(session.screenVideoPath) === normalizedVideoPath;
|
|
const matchesWebcam =
|
|
typeof session.webcamVideoPath === "string" &&
|
|
normalizePath(session.webcamVideoPath) === normalizedVideoPath;
|
|
if (!matchesScreen && !matchesWebcam) {
|
|
return null;
|
|
}
|
|
|
|
if (!isPathAllowed(session.screenVideoPath)) {
|
|
const approvedScreen = await approveReadableVideoPath(session.screenVideoPath, [
|
|
path.dirname(manifestPath),
|
|
RECORDINGS_DIR,
|
|
]);
|
|
if (!approvedScreen) {
|
|
return null;
|
|
}
|
|
session.screenVideoPath = approvedScreen;
|
|
}
|
|
|
|
if (session.webcamVideoPath && !isPathAllowed(session.webcamVideoPath)) {
|
|
const approvedWebcam = await approveReadableVideoPath(session.webcamVideoPath, [
|
|
path.dirname(manifestPath),
|
|
RECORDINGS_DIR,
|
|
]);
|
|
if (!approvedWebcam) {
|
|
session.webcamVideoPath = undefined;
|
|
} else {
|
|
session.webcamVideoPath = approvedWebcam;
|
|
}
|
|
}
|
|
|
|
approveFilePath(session.screenVideoPath);
|
|
if (session.webcamVideoPath) {
|
|
approveFilePath(session.webcamVideoPath);
|
|
}
|
|
return session;
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code !== "ENOENT") {
|
|
console.error("Failed to restore recording session manifest:", error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function registerIpcHandlers(
|
|
createEditorWindow: () => void,
|
|
createSourceSelectorWindow: () => BrowserWindow,
|
|
createCountdownOverlayWindow: () => BrowserWindow,
|
|
getMainWindow: () => BrowserWindow | null,
|
|
getSourceSelectorWindow: () => BrowserWindow | null,
|
|
getCountdownOverlayWindow?: () => BrowserWindow | null,
|
|
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
|
|
_switchToHud?: () => void,
|
|
) {
|
|
async function requestScreenAccess() {
|
|
if (process.platform !== "darwin") {
|
|
return { success: true, granted: true, status: "granted" };
|
|
}
|
|
|
|
try {
|
|
const status = systemPreferences.getMediaAccessStatus("screen");
|
|
if (status === "granted") {
|
|
return { success: true, granted: true, status };
|
|
}
|
|
|
|
// Screen recording has no askForMediaAccess equivalent. Trigger the
|
|
// TCC prompt without opening OpenScreen's source selector above it.
|
|
if (status === "not-determined") {
|
|
const mainWin = getMainWindow();
|
|
if (mainWin && !mainWin.isDestroyed()) {
|
|
if (!mainWin.isVisible()) {
|
|
mainWin.show();
|
|
}
|
|
mainWin.focus();
|
|
}
|
|
app.focus({ steal: true });
|
|
desktopCapturer
|
|
.getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } })
|
|
.catch(() => {
|
|
// Permission probing failure is reported by the explicit status check below.
|
|
});
|
|
return { success: true, granted: false, status: "not-determined" };
|
|
}
|
|
|
|
return { success: true, granted: false, status };
|
|
} catch (error) {
|
|
console.error("Failed to request screen access:", error);
|
|
return { success: false, granted: false, status: "unknown", error: String(error) };
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("get-sources", async (_, opts) => {
|
|
const sources = await desktopCapturer.getSources(opts);
|
|
lastEnumeratedSources = new Map(sources.map((source) => [source.id, source]));
|
|
let screenSourceIndex = 0;
|
|
const processedSources = sources.map((source) => {
|
|
const isScreenSource = source.id.startsWith("screen:");
|
|
const sourceIndex = isScreenSource
|
|
? (parseDesktopCapturerScreenIndex(source.id) ?? screenSourceIndex)
|
|
: undefined;
|
|
const { display, displayIndex } = isScreenSource
|
|
? findDisplayForSource(source, screenSourceIndex)
|
|
: { display: null, displayIndex: undefined };
|
|
if (isScreenSource) {
|
|
screenSourceIndex += 1;
|
|
}
|
|
const bounds = display ? toSourceBounds(display.bounds) : undefined;
|
|
const displayLabel = bounds
|
|
? `Display ${(displayIndex ?? sourceIndex ?? 0) + 1} - ${bounds.width}x${bounds.height} @ ${bounds.x},${bounds.y}`
|
|
: undefined;
|
|
return {
|
|
id: source.id,
|
|
name: source.name,
|
|
display_id: source.display_id,
|
|
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
|
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
|
|
displayId: display?.id,
|
|
displayIndex,
|
|
screenIndex: sourceIndex,
|
|
displayLabel,
|
|
bounds,
|
|
};
|
|
});
|
|
const screenDisplays = screen.getAllDisplays();
|
|
const mappedDisplayIds = new Set(
|
|
processedSources
|
|
.filter((source) => source.id.startsWith("screen:") && typeof source.displayId === "number")
|
|
.map((source) => source.displayId),
|
|
);
|
|
const fallbackScreenSources = screenDisplays
|
|
.map((display, displayIndex) => ({ display, displayIndex }))
|
|
.filter(({ display }) => !mappedDisplayIds.has(display.id))
|
|
.map(({ display, displayIndex }) => {
|
|
const bounds = toSourceBounds(display.bounds);
|
|
return {
|
|
id: `screen:${displayIndex}:fallback:${display.id}`,
|
|
name: `Screen ${displayIndex + 1}`,
|
|
display_id: String(display.id),
|
|
thumbnail: null,
|
|
appIcon: null,
|
|
displayId: display.id,
|
|
displayIndex,
|
|
screenIndex: displayIndex,
|
|
displayLabel: `Display ${displayIndex + 1} - ${bounds.width}x${bounds.height} @ ${bounds.x},${bounds.y}`,
|
|
bounds,
|
|
};
|
|
});
|
|
if (fallbackScreenSources.length > 0) {
|
|
console.warn("[desktop-capturer] added fallback display sources", {
|
|
capturerScreens: processedSources.filter((source) => source.id.startsWith("screen:"))
|
|
.length,
|
|
electronDisplays: screenDisplays.length,
|
|
fallbackScreens: fallbackScreenSources.map((source) => ({
|
|
id: source.id,
|
|
displayId: source.displayId,
|
|
bounds: source.bounds,
|
|
})),
|
|
});
|
|
}
|
|
return [...processedSources, ...fallbackScreenSources];
|
|
});
|
|
|
|
ipcMain.handle("select-source", async (_, source: SelectedSource) => {
|
|
selectedSource = {
|
|
...source,
|
|
bounds: normalizeSourceBounds(source.bounds),
|
|
};
|
|
// Reuse the exact source object returned during enumeration to avoid
|
|
// Windows window-source id mismatches across separate getSources() calls.
|
|
selectedDesktopSource =
|
|
typeof source.id === "string" ? (lastEnumeratedSources.get(source.id) ?? null) : null;
|
|
|
|
if (!selectedDesktopSource && typeof source.id === "string") {
|
|
try {
|
|
const sources = await desktopCapturer.getSources({
|
|
types: ["screen", "window"],
|
|
thumbnailSize: { width: 0, height: 0 },
|
|
fetchWindowIcons: true,
|
|
});
|
|
lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate]));
|
|
selectedDesktopSource = lastEnumeratedSources.get(source.id) ?? null;
|
|
} catch {
|
|
selectedDesktopSource = null;
|
|
}
|
|
}
|
|
const sourceSelectorWin = getSourceSelectorWindow();
|
|
if (sourceSelectorWin) {
|
|
sourceSelectorWin.close();
|
|
}
|
|
return selectedSource;
|
|
});
|
|
|
|
ipcMain.handle("get-selected-source", () => {
|
|
return selectedSource;
|
|
});
|
|
|
|
ipcMain.handle("request-camera-access", async () => {
|
|
if (process.platform !== "darwin") {
|
|
return { success: true, granted: true, status: "granted" };
|
|
}
|
|
|
|
try {
|
|
const status = systemPreferences.getMediaAccessStatus("camera");
|
|
if (status === "granted") {
|
|
return { success: true, granted: true, status };
|
|
}
|
|
|
|
if (status === "not-determined") {
|
|
const granted = await systemPreferences.askForMediaAccess("camera");
|
|
return {
|
|
success: true,
|
|
granted,
|
|
status: granted ? "granted" : systemPreferences.getMediaAccessStatus("camera"),
|
|
};
|
|
}
|
|
|
|
return { success: true, granted: false, status };
|
|
} catch (error) {
|
|
console.error("Failed to request camera access:", error);
|
|
return {
|
|
success: false,
|
|
granted: false,
|
|
status: "unknown",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("request-screen-access", async () => {
|
|
return requestScreenAccess();
|
|
});
|
|
|
|
ipcMain.handle("request-native-mac-cursor-access", async () => {
|
|
return requestMacCursorAccessibilityAccess();
|
|
});
|
|
|
|
ipcMain.handle("open-source-selector", async () => {
|
|
const access = await requestScreenAccess();
|
|
if (!access.granted) {
|
|
if (process.platform === "darwin" && access.status !== "not-determined") {
|
|
const mainWin = getMainWindow();
|
|
const messageOptions = {
|
|
type: "warning",
|
|
buttons: ["Open System Settings", "Cancel"],
|
|
defaultId: 0,
|
|
cancelId: 1,
|
|
message: "Screen Recording permission is required",
|
|
detail:
|
|
"Allow OpenScreen in macOS System Settings, then come back and choose a screen or window.",
|
|
} satisfies Electron.MessageBoxOptions;
|
|
const result =
|
|
mainWin && !mainWin.isDestroyed()
|
|
? await dialog.showMessageBox(mainWin, messageOptions)
|
|
: await dialog.showMessageBox(messageOptions);
|
|
if (result.response === 0) {
|
|
await shell.openExternal(
|
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
opened: false,
|
|
reason: "screen-access-required",
|
|
access,
|
|
};
|
|
}
|
|
|
|
const sourceSelectorWin = getSourceSelectorWindow();
|
|
if (sourceSelectorWin) {
|
|
sourceSelectorWin.focus();
|
|
return { opened: true };
|
|
}
|
|
createSourceSelectorWindow();
|
|
return { opened: true };
|
|
});
|
|
|
|
ipcMain.handle("switch-to-editor", () => {
|
|
const mainWin = getMainWindow();
|
|
if (mainWin) {
|
|
mainWin.close();
|
|
}
|
|
createEditorWindow();
|
|
});
|
|
|
|
ipcMain.handle("switch-to-hud", () => {
|
|
_switchToHud?.();
|
|
return { success: true };
|
|
});
|
|
|
|
ipcMain.handle("start-new-recording", () => {
|
|
_switchToHud?.();
|
|
return { success: true };
|
|
});
|
|
|
|
ipcMain.handle("countdown-overlay-show", async (_, value: number, runId: number) => {
|
|
const overlayWindow = getCountdownOverlayWindow?.() ?? createCountdownOverlayWindow();
|
|
if (overlayWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (!overlayWindow.isVisible()) {
|
|
overlayWindow.showInactive();
|
|
}
|
|
|
|
if (overlayWindow.webContents.isLoading()) {
|
|
await new Promise<void>((resolve) => {
|
|
overlayWindow.webContents.once("did-finish-load", () => resolve());
|
|
});
|
|
}
|
|
|
|
overlayWindow.webContents.send("countdown-overlay-value", value, runId);
|
|
});
|
|
|
|
ipcMain.handle("countdown-overlay-set-value", (_, value: number, runId: number) => {
|
|
const overlayWindow = getCountdownOverlayWindow?.();
|
|
if (!overlayWindow || overlayWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
overlayWindow.webContents.send("countdown-overlay-value", value, runId);
|
|
});
|
|
|
|
ipcMain.handle("countdown-overlay-hide", (_, runId: number) => {
|
|
const overlayWindow = getCountdownOverlayWindow?.();
|
|
if (!overlayWindow || overlayWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
overlayWindow.webContents.send("countdown-overlay-value", null, runId);
|
|
overlayWindow.hide();
|
|
});
|
|
|
|
ipcMain.handle("is-native-windows-capture-available", async () => {
|
|
if (!isWindowsGraphicsCaptureOsSupported()) {
|
|
return { success: true, available: false, reason: "unsupported-os" };
|
|
}
|
|
|
|
const helperPath = await findNativeWindowsCaptureHelperPath();
|
|
return helperPath
|
|
? { success: true, available: true, helperPath }
|
|
: { 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) => {
|
|
try {
|
|
if (!isWindowsGraphicsCaptureOsSupported()) {
|
|
return {
|
|
success: false,
|
|
error: "Windows Graphics Capture requires Windows 10 build 19041 or newer.",
|
|
};
|
|
}
|
|
if (hasActiveNativeWindowsCaptureProcess()) {
|
|
return { success: false, error: "Native Windows capture is already running." };
|
|
}
|
|
|
|
const helperPath = await findNativeWindowsCaptureHelperPath();
|
|
if (!helperPath) {
|
|
return { success: false, error: "Native Windows capture helper is not available." };
|
|
}
|
|
|
|
if (!request?.source?.sourceId) {
|
|
return {
|
|
success: false,
|
|
error: "Native Windows 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 webcamOutputPath = path.join(
|
|
RECORDINGS_DIR,
|
|
`${RECORDING_FILE_PREFIX}${recordingId}-webcam.mp4`,
|
|
);
|
|
const requestBounds = normalizeSourceBounds(request.source.bounds);
|
|
const sourceDisplay =
|
|
request.source.type === "display" && typeof request.source.displayId === "number"
|
|
? (screen.getAllDisplays().find((display) => display.id === request.source.displayId) ??
|
|
null)
|
|
: getSelectedDisplay();
|
|
const bounds = requestBounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds();
|
|
const displayId =
|
|
typeof request.source.displayId === "number" && Number.isFinite(request.source.displayId)
|
|
? request.source.displayId
|
|
: typeof selectedSource?.displayId === "number"
|
|
? selectedSource.displayId
|
|
: Number(selectedSource?.display_id);
|
|
const webcamDirectShowClsid = request.webcam.enabled
|
|
? await resolveDirectShowWebcamClsid(request.webcam.deviceName)
|
|
: null;
|
|
const cursorCaptureMode =
|
|
normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay";
|
|
const config = {
|
|
schemaVersion: 2,
|
|
recordingId,
|
|
outputPath,
|
|
sourceType: request.source.type,
|
|
sourceId: request.source.sourceId,
|
|
displayId: Number.isFinite(displayId) ? displayId : 0,
|
|
windowHandle: request.source.windowHandle ?? null,
|
|
fps: request.video.fps,
|
|
videoWidth: request.video.width,
|
|
videoHeight: request.video.height,
|
|
displayX: bounds.x,
|
|
displayY: bounds.y,
|
|
displayW: bounds.width,
|
|
displayH: bounds.height,
|
|
hasDisplayBounds: true,
|
|
captureSystemAudio: request.audio.system.enabled,
|
|
captureMic: request.audio.microphone.enabled,
|
|
microphoneDeviceId: request.audio.microphone.deviceId ?? null,
|
|
microphoneDeviceName: request.audio.microphone.deviceName ?? null,
|
|
microphoneGain: request.audio.microphone.gain,
|
|
webcamEnabled: request.webcam.enabled,
|
|
webcamDeviceId: request.webcam.deviceId ?? null,
|
|
webcamDeviceName: request.webcam.deviceName ?? null,
|
|
webcamDirectShowClsid,
|
|
webcamWidth: request.webcam.width,
|
|
webcamHeight: request.webcam.height,
|
|
webcamFps: request.webcam.fps,
|
|
captureCursor: cursorCaptureMode === "system",
|
|
cursorCaptureMode,
|
|
outputs: {
|
|
screenPath: outputPath,
|
|
webcamPath: webcamOutputPath,
|
|
},
|
|
source: {
|
|
type: request.source.type,
|
|
sourceId: request.source.sourceId,
|
|
displayId: Number.isFinite(displayId) ? displayId : null,
|
|
windowHandle: request.source.windowHandle ?? null,
|
|
bounds,
|
|
},
|
|
video: request.video,
|
|
audio: request.audio,
|
|
webcam: request.webcam,
|
|
cursor: {
|
|
mode: cursorCaptureMode,
|
|
},
|
|
};
|
|
|
|
console.info("[native-wgc] starting Windows capture", {
|
|
helperPath,
|
|
source: request.source,
|
|
audio: request.audio,
|
|
webcam: request.webcam,
|
|
cursor: { mode: cursorCaptureMode },
|
|
bounds,
|
|
sourceId: selectedSource?.id ?? null,
|
|
usedDisplayMatch: Boolean(sourceDisplay),
|
|
outputPath,
|
|
});
|
|
|
|
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
|
nativeWindowsCaptureOutput = "";
|
|
nativeWindowsCaptureTargetPath = outputPath;
|
|
nativeWindowsCaptureWebcamTargetPath = request.webcam.enabled ? webcamOutputPath : null;
|
|
nativeWindowsCaptureRecordingId = recordingId;
|
|
nativeWindowsCursorOffsetMs = 0;
|
|
nativeWindowsCursorCaptureMode = cursorCaptureMode;
|
|
nativeWindowsCursorRecordingStartMs = 0;
|
|
nativeWindowsPauseStartedAtMs = null;
|
|
nativeWindowsPauseRanges = [];
|
|
nativeWindowsIsPaused = false;
|
|
|
|
const cursorStartTimeMs = Date.now();
|
|
if (cursorCaptureMode === "editable-overlay") {
|
|
nativeWindowsCursorRecordingStartMs = cursorStartTimeMs;
|
|
await startCursorRecording(cursorStartTimeMs);
|
|
console.info("[native-wgc] cursor sampler ready", {
|
|
cursorStartTimeMs,
|
|
warmupMs: Date.now() - cursorStartTimeMs,
|
|
});
|
|
} else {
|
|
pendingCursorRecordingData = null;
|
|
}
|
|
|
|
const proc = spawn(helperPath, [JSON.stringify(config)], {
|
|
cwd: RECORDINGS_DIR,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
nativeWindowsCaptureProcess = proc;
|
|
|
|
await waitForNativeWindowsCaptureStart(proc);
|
|
const captureStartedAtMs = Date.now();
|
|
nativeWindowsCursorOffsetMs =
|
|
cursorCaptureMode === "editable-overlay"
|
|
? Math.max(0, captureStartedAtMs - cursorStartTimeMs)
|
|
: 0;
|
|
const webcamFormat = readNativeWindowsWebcamFormat(nativeWindowsCaptureOutput);
|
|
console.info("[native-wgc] capture started", {
|
|
captureStartedAtMs,
|
|
cursorOffsetMs: nativeWindowsCursorOffsetMs,
|
|
webcamFormat,
|
|
});
|
|
|
|
const source = selectedSource || { name: "Screen" };
|
|
attachNativeWindowsCaptureLifecycle(proc, source.name, onRecordingStateChange);
|
|
startGuideHotkeyRecording(recordingId, bounds);
|
|
if (onRecordingStateChange) {
|
|
onRecordingStateChange(true, source.name);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
recordingId,
|
|
path: outputPath,
|
|
helperPath,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to start native Windows recording:", error);
|
|
nativeWindowsCaptureProcess?.kill();
|
|
resetNativeWindowsCaptureState();
|
|
await stopCursorRecording();
|
|
return { success: false, error: String(error) };
|
|
}
|
|
},
|
|
);
|
|
|
|
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";
|
|
try {
|
|
await desktopCapturer.getSources({
|
|
types: ["screen"],
|
|
thumbnailSize: { width: 1, height: 1 },
|
|
});
|
|
} catch {
|
|
// The helper reports the final ScreenCaptureKit permission status.
|
|
}
|
|
if (request.audio?.microphone?.enabled) {
|
|
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
|
if (micStatus !== "granted") {
|
|
await systemPreferences.askForMediaAccess("microphone");
|
|
}
|
|
}
|
|
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",
|
|
},
|
|
webcam: {
|
|
...request.webcam,
|
|
enabled: false,
|
|
},
|
|
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;
|
|
nativeMacCursorRecordingStartMs = 0;
|
|
nativeMacPauseStartedAtMs = null;
|
|
nativeMacPauseRanges = [];
|
|
nativeMacIsPaused = false;
|
|
|
|
const cursorStartTimeMs = Date.now();
|
|
if (cursorCaptureMode === "editable-overlay") {
|
|
nativeMacCursorRecordingStartMs = cursorStartTimeMs;
|
|
await startCursorRecording(cursorStartTimeMs);
|
|
} else {
|
|
pendingCursorRecordingData = null;
|
|
}
|
|
|
|
const proc = spawn(helperPath, [JSON.stringify(config)], {
|
|
cwd: RECORDINGS_DIR,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
nativeMacCaptureProcess = proc;
|
|
attachNativeMacCaptureOutputDrain(proc);
|
|
|
|
await waitForNativeMacCaptureStart(proc);
|
|
const captureStartedAtMs = Date.now();
|
|
nativeMacCursorOffsetMs =
|
|
cursorCaptureMode === "editable-overlay"
|
|
? Math.max(0, captureStartedAtMs - cursorStartTimeMs)
|
|
: 0;
|
|
|
|
const source = selectedSource || { name: "Screen" };
|
|
startGuideHotkeyRecording(recordingId, bounds);
|
|
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";
|
|
nativeMacCursorRecordingStartMs = 0;
|
|
nativeMacPauseStartedAtMs = null;
|
|
nativeMacPauseRanges = [];
|
|
nativeMacIsPaused = false;
|
|
clearGuideHotkeyRecording();
|
|
await stopCursorRecording();
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("pause-native-mac-recording", async () => {
|
|
if (process.platform !== "darwin") {
|
|
return { success: false, error: "Native macOS capture requires macOS." };
|
|
}
|
|
|
|
const proc = nativeMacCaptureProcess;
|
|
if (!proc) {
|
|
return { success: false, error: "Native macOS capture is not running." };
|
|
}
|
|
if (nativeMacIsPaused) {
|
|
return { success: true };
|
|
}
|
|
if (!proc.stdin.writable) {
|
|
return { success: false, error: "Native macOS capture command channel is closed." };
|
|
}
|
|
|
|
try {
|
|
proc.stdin.write("pause\n");
|
|
nativeMacIsPaused = true;
|
|
nativeMacPauseStartedAtMs = Date.now();
|
|
pauseGuideHotkeyRecording();
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("resume-native-mac-recording", async () => {
|
|
if (process.platform !== "darwin") {
|
|
return { success: false, error: "Native macOS capture requires macOS." };
|
|
}
|
|
|
|
const proc = nativeMacCaptureProcess;
|
|
if (!proc) {
|
|
return { success: false, error: "Native macOS capture is not running." };
|
|
}
|
|
if (!nativeMacIsPaused) {
|
|
return { success: true };
|
|
}
|
|
if (!proc.stdin.writable) {
|
|
return { success: false, error: "Native macOS capture command channel is closed." };
|
|
}
|
|
|
|
try {
|
|
proc.stdin.write("resume\n");
|
|
completeNativeMacCursorPauseRange();
|
|
nativeMacIsPaused = false;
|
|
resumeGuideHotkeyRecording();
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("pause-native-windows-recording", async () => {
|
|
const proc = nativeWindowsCaptureProcess;
|
|
if (!proc) {
|
|
return { success: false, error: "Native Windows capture is not running." };
|
|
}
|
|
if (nativeWindowsIsPaused) {
|
|
return { success: true };
|
|
}
|
|
if (!proc.stdin.writable) {
|
|
return { success: false, error: "Native Windows capture command channel is closed." };
|
|
}
|
|
|
|
try {
|
|
proc.stdin.write("pause\n");
|
|
nativeWindowsIsPaused = true;
|
|
nativeWindowsPauseStartedAtMs = Date.now();
|
|
pauseGuideHotkeyRecording();
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("resume-native-windows-recording", async () => {
|
|
const proc = nativeWindowsCaptureProcess;
|
|
if (!proc) {
|
|
return { success: false, error: "Native Windows capture is not running." };
|
|
}
|
|
if (!nativeWindowsIsPaused) {
|
|
return { success: true };
|
|
}
|
|
if (!proc.stdin.writable) {
|
|
return { success: false, error: "Native Windows capture command channel is closed." };
|
|
}
|
|
|
|
try {
|
|
proc.stdin.write("resume\n");
|
|
completeNativeWindowsCursorPauseRange();
|
|
nativeWindowsIsPaused = false;
|
|
resumeGuideHotkeyRecording();
|
|
return { success: true };
|
|
} catch (error) {
|
|
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;
|
|
const preferredWebcamPath = nativeWindowsCaptureWebcamTargetPath;
|
|
const recordingId = nativeWindowsCaptureRecordingId ?? Date.now();
|
|
const cursorCaptureMode = nativeWindowsCursorCaptureMode;
|
|
|
|
if (!proc || proc.exitCode !== null || proc.killed) {
|
|
resetNativeWindowsCaptureState();
|
|
return { success: false, error: "Native Windows capture is not running." };
|
|
}
|
|
|
|
try {
|
|
nativeWindowsCaptureStopping = true;
|
|
completeNativeWindowsCursorPauseRange();
|
|
const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc);
|
|
proc.stdin.write("stop\n");
|
|
const stoppedPath = await stoppedPathPromise;
|
|
const screenVideoPath = stoppedPath || preferredPath;
|
|
if (!screenVideoPath) {
|
|
throw new Error("Native Windows 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 }),
|
|
preferredWebcamPath ? fs.rm(preferredWebcamPath, { force: true }) : Promise.resolve(),
|
|
fs.rm(`${screenVideoPath}.cursor.json`, { force: true }),
|
|
]);
|
|
return { success: true, discarded: true };
|
|
}
|
|
|
|
if (cursorCaptureMode === "editable-overlay") {
|
|
compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges);
|
|
shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs);
|
|
await writePendingCursorTelemetry(screenVideoPath);
|
|
}
|
|
let webcamVideoPath: string | undefined;
|
|
if (preferredWebcamPath) {
|
|
try {
|
|
await fs.access(preferredWebcamPath, fsConstants.R_OK);
|
|
webcamVideoPath = preferredWebcamPath;
|
|
} catch {
|
|
webcamVideoPath = undefined;
|
|
}
|
|
}
|
|
const session: RecordingSession = webcamVideoPath
|
|
? { screenVideoPath, webcamVideoPath, createdAt: recordingId, cursorCaptureMode }
|
|
: { 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 Windows recording session stored successfully",
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to stop native Windows recording:", error);
|
|
await stopCursorRecording();
|
|
return { success: false, error: String(error) };
|
|
} finally {
|
|
resetNativeWindowsCaptureState();
|
|
const source = selectedSource || { name: "Screen" };
|
|
if (onRecordingStateChange) {
|
|
onRecordingStateChange(false, source.name);
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stop-native-mac-recording", async (_, discard?: boolean) => {
|
|
if (process.platform !== "darwin") {
|
|
return { success: false, error: "Native macOS capture requires macOS." };
|
|
}
|
|
|
|
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 {
|
|
completeNativeMacCursorPauseRange();
|
|
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") {
|
|
compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges);
|
|
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";
|
|
nativeMacCursorRecordingStartMs = 0;
|
|
nativeMacPauseStartedAtMs = null;
|
|
nativeMacPauseRanges = [];
|
|
nativeMacIsPaused = false;
|
|
clearGuideHotkeyRecording();
|
|
const source = selectedSource || { name: "Screen" };
|
|
if (onRecordingStateChange) {
|
|
onRecordingStateChange(false, source.name);
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(
|
|
"attach-native-mac-webcam-recording",
|
|
async (_, payload: AttachNativeMacWebcamRecordingInput) => {
|
|
try {
|
|
if (process.platform !== "darwin") {
|
|
return { success: false, error: "Native macOS webcam attachment requires macOS." };
|
|
}
|
|
|
|
const screenVideoPath = normalizeVideoSourcePath(payload.screenVideoPath);
|
|
if (!screenVideoPath || !isPathWithinDir(screenVideoPath, RECORDINGS_DIR)) {
|
|
return {
|
|
success: false,
|
|
error: "Native macOS webcam attachment requires a recording output path.",
|
|
};
|
|
}
|
|
|
|
await fs.access(screenVideoPath, fsConstants.R_OK);
|
|
|
|
if (!payload.webcam?.fileName || !payload.webcam.videoData) {
|
|
return { success: false, error: "Native macOS webcam attachment is missing video data." };
|
|
}
|
|
|
|
const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
|
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
|
|
|
const createdAt =
|
|
typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId)
|
|
? payload.recordingId
|
|
: Date.now();
|
|
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
|
|
const session: RecordingSession = {
|
|
screenVideoPath,
|
|
webcamVideoPath,
|
|
createdAt,
|
|
...(cursorCaptureMode ? { 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 webcam recording attached successfully",
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to attach native macOS webcam recording:", error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// On-disk write streams for in-progress recordings, keyed by output file name.
|
|
// Chunks are appended as they arrive from ondataavailable so the renderer
|
|
// never buffers the full video in memory (the #616 fix).
|
|
const recordingStreams = new RecordingStreamRegistry();
|
|
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);
|
|
const guideAiSettingsStore = new DeepSeekSettingsStore(
|
|
path.join(app.getPath("userData"), "guide-ai-settings.json"),
|
|
);
|
|
const guideStore = new GuideStore(RECORDINGS_DIR, {
|
|
deepSeekConfigProvider: guideAiSettingsStore,
|
|
ocrConfigProvider: guideAiSettingsStore,
|
|
});
|
|
registerGuideMarkerHotkey(guideStore);
|
|
registerGuideIpcHandlers(ipcMain, guideStore, guideAiSettingsStore, {
|
|
onSessionStarted: (session) => activateGuideHotkeySession(session.recordingId),
|
|
onSessionEnded: (recordingId) => deactivateGuideHotkeySession(recordingId),
|
|
});
|
|
ipcMain.handle("guide:capture-pointer-marker", async () => {
|
|
const result = await captureGuideHotkeyMarker(guideStore, "button");
|
|
if (result.error) {
|
|
return {
|
|
success: false,
|
|
code: "guide-internal-error",
|
|
error: result.error,
|
|
retryable: true,
|
|
};
|
|
}
|
|
|
|
return { success: true, data: result };
|
|
});
|
|
|
|
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
|
try {
|
|
return await storeRecordedSessionFiles(payload);
|
|
} catch (error) {
|
|
console.error("Failed to store recording session:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to store recording session",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
|
const createdAt =
|
|
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
|
|
? payload.createdAt
|
|
: Date.now();
|
|
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
|
|
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
|
|
const screenStreamed = await finalizeRecordingFile(
|
|
recordingStreams,
|
|
payload.screen.fileName,
|
|
screenVideoPath,
|
|
payload.screen.videoData,
|
|
);
|
|
|
|
let webcamVideoPath: string | undefined;
|
|
let webcamStreamed = false;
|
|
if (payload.webcam) {
|
|
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
|
webcamStreamed = await finalizeRecordingFile(
|
|
recordingStreams,
|
|
payload.webcam.fileName,
|
|
webcamVideoPath,
|
|
payload.webcam.videoData,
|
|
);
|
|
}
|
|
|
|
// Streamed files lack the WebM Duration header (the renderer no longer holds
|
|
// the blob to patch). Patch on disk so the editor's seek bar and timeline
|
|
// work. Best-effort and independent per file, so the patches run together.
|
|
if (isValidDurationMs(payload.durationMs)) {
|
|
const patches: Promise<unknown>[] = [];
|
|
if (screenStreamed) {
|
|
patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs));
|
|
}
|
|
if (webcamStreamed && webcamVideoPath) {
|
|
patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs));
|
|
}
|
|
await Promise.all(patches);
|
|
}
|
|
|
|
const session: RecordingSession = webcamVideoPath
|
|
? {
|
|
screenVideoPath,
|
|
webcamVideoPath,
|
|
createdAt,
|
|
...(cursorCaptureMode ? { cursorCaptureMode } : {}),
|
|
}
|
|
: { screenVideoPath, createdAt, ...(cursorCaptureMode ? { cursorCaptureMode } : {}) };
|
|
setCurrentRecordingSessionState(session);
|
|
currentProjectPath = null;
|
|
|
|
await writePendingCursorTelemetry(screenVideoPath);
|
|
|
|
const sessionManifestPath = path.join(
|
|
RECORDINGS_DIR,
|
|
`${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`,
|
|
);
|
|
await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8");
|
|
|
|
return {
|
|
success: true,
|
|
path: screenVideoPath,
|
|
session,
|
|
message: "Recording session stored successfully",
|
|
};
|
|
}
|
|
|
|
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
|
try {
|
|
return await storeRecordedSessionFiles({
|
|
screen: { videoData, fileName },
|
|
createdAt: Date.now(),
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to store recorded video:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to store recorded video",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-recorded-video-path", async () => {
|
|
try {
|
|
if (currentRecordingSession?.screenVideoPath) {
|
|
return { success: true, path: currentRecordingSession.screenVideoPath };
|
|
}
|
|
|
|
const files = await fs.readdir(RECORDINGS_DIR);
|
|
const videoFiles = files.filter(
|
|
(file) => file.endsWith(".webm") && !file.endsWith("-webcam.webm"),
|
|
);
|
|
|
|
if (videoFiles.length === 0) {
|
|
return { success: false, message: "No recorded video found" };
|
|
}
|
|
|
|
const latestVideo = videoFiles.sort().reverse()[0];
|
|
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
|
|
|
|
return { success: true, path: videoPath };
|
|
} catch (error) {
|
|
console.error("Failed to get video path:", error);
|
|
return { success: false, message: "Failed to get video path", error: String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(
|
|
"set-recording-state",
|
|
async (_, recording: boolean, recordingId?: number, cursorCaptureMode?: CursorCaptureMode) => {
|
|
const normalizedCursorCaptureMode =
|
|
normalizeCursorCaptureMode(cursorCaptureMode) ?? "editable-overlay";
|
|
if (recording && normalizedCursorCaptureMode === "editable-overlay") {
|
|
await startCursorRecording(recordingId);
|
|
} else {
|
|
await stopCursorRecording();
|
|
}
|
|
if (recording) {
|
|
startGuideHotkeyRecording(recordingId, getSelectedSourceBounds());
|
|
} else {
|
|
clearGuideHotkeyRecording();
|
|
}
|
|
|
|
const source = selectedSource || { name: "Screen" };
|
|
if (onRecordingStateChange) {
|
|
onRecordingStateChange(recording, source.name);
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
|
const targetVideoPath = resolveApprovedVideoPath(
|
|
videoPath ?? currentRecordingSession?.screenVideoPath,
|
|
);
|
|
if (!targetVideoPath) {
|
|
return { success: true, samples: [] };
|
|
}
|
|
|
|
return readCursorTelemetryFile(targetVideoPath);
|
|
});
|
|
|
|
ipcMain.handle("open-external-url", async (_, url: string) => {
|
|
try {
|
|
await shell.openExternal(url);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Failed to open URL:", error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
// Return base path for assets so renderer can resolve file:// paths in production
|
|
ipcMain.handle("get-asset-base-path", () => {
|
|
return resolveAssetBasePath();
|
|
});
|
|
|
|
ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => {
|
|
try {
|
|
const isGif = fileName.toLowerCase().endsWith(".gif");
|
|
const filters = isGif
|
|
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
|
|
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
|
|
|
|
// Prefer the user's last export folder if it still exists, otherwise fall
|
|
// back to ~/Downloads. Validation must happen here because the renderer
|
|
// can't stat the filesystem.
|
|
let defaultDir = app.getPath("downloads");
|
|
if (exportFolder) {
|
|
try {
|
|
const stats = await fs.stat(exportFolder);
|
|
if (stats.isDirectory()) {
|
|
defaultDir = exportFolder;
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
const dialogOptions = buildDialogOptions(
|
|
{
|
|
title: isGif
|
|
? mainT("dialogs", "fileDialogs.saveGif")
|
|
: mainT("dialogs", "fileDialogs.saveVideo"),
|
|
defaultPath: path.join(defaultDir, fileName),
|
|
filters,
|
|
properties: ["createDirectory", "showOverwriteConfirmation"],
|
|
},
|
|
getMainWindow(),
|
|
);
|
|
const result = await dialog.showSaveDialog(dialogOptions);
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return { success: false, canceled: true, message: "Export canceled" };
|
|
}
|
|
|
|
return { success: true, path: path.normalize(result.filePath) };
|
|
} catch (error) {
|
|
console.error("Failed to show save dialog:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to show save dialog",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
|
|
try {
|
|
// Sanity-check the path. The renderer is trusted (contextIsolation is on),
|
|
// but a stale state bug shouldn't be able to clobber arbitrary files.
|
|
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
|
|
return { success: false, message: "Invalid path" };
|
|
}
|
|
const lower = filePath.toLowerCase();
|
|
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
|
|
return { success: false, message: "Invalid file type" };
|
|
}
|
|
|
|
const normalizedPath = path.normalize(filePath);
|
|
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
|
await fs.writeFile(normalizedPath, Buffer.from(videoData));
|
|
|
|
return {
|
|
success: true,
|
|
path: normalizedPath,
|
|
message: "Video exported successfully",
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to write exported video:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to save exported video",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-video-file-picker", async () => {
|
|
try {
|
|
const dialogOptions = buildDialogOptions(
|
|
{
|
|
title: mainT("dialogs", "fileDialogs.selectVideo"),
|
|
defaultPath: RECORDINGS_DIR,
|
|
filters: [
|
|
{
|
|
name: mainT("dialogs", "fileDialogs.videoFiles"),
|
|
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
|
|
},
|
|
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
|
],
|
|
properties: ["openFile"],
|
|
},
|
|
getMainWindow(),
|
|
);
|
|
const result = await dialog.showOpenDialog(dialogOptions);
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
return { success: false, canceled: true };
|
|
}
|
|
|
|
const normalizedPath = await approveReadableVideoPath(result.filePaths[0]);
|
|
if (!normalizedPath) {
|
|
return {
|
|
success: false,
|
|
message: "Selected file is not a supported readable video file",
|
|
};
|
|
}
|
|
|
|
currentProjectPath = null;
|
|
return {
|
|
success: true,
|
|
path: normalizedPath,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to open file picker:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to open file picker",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
|
|
try {
|
|
// shell.showItemInFolder doesn't return a value, it throws on error
|
|
shell.showItemInFolder(filePath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error(`Error revealing item in folder: ${filePath}`, error);
|
|
// Fallback to open the directory if revealing the item fails
|
|
// This might happen if the file was moved or deleted after export,
|
|
// or if the path is somehow invalid for showItemInFolder
|
|
try {
|
|
const openPathResult = await shell.openPath(path.dirname(filePath));
|
|
if (openPathResult) {
|
|
// openPath returned an error message
|
|
return { success: false, error: openPathResult };
|
|
}
|
|
return { success: true, message: "Could not reveal item, but opened directory." };
|
|
} catch (openError) {
|
|
console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("read-binary-file", async (_, filePath: string) => {
|
|
try {
|
|
const normalizedPath = await approveReadableVideoPath(filePath);
|
|
if (!normalizedPath) {
|
|
return {
|
|
success: false,
|
|
message: "File path is not approved or is not a supported video file",
|
|
};
|
|
}
|
|
|
|
const data = await fs.readFile(normalizedPath);
|
|
return {
|
|
success: true,
|
|
data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
|
|
path: normalizedPath,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to read binary file:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to read binary file",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("prepare-preview-audio-track", async (_, filePath: string) => {
|
|
try {
|
|
return await prepareSupplementalPreviewAudioTrack(filePath);
|
|
} catch (error) {
|
|
console.error("Failed to prepare preview audio track:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to prepare preview audio track",
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(
|
|
"save-project-file",
|
|
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
|
return saveProjectFile(projectData, suggestedName, existingProjectPath);
|
|
},
|
|
);
|
|
|
|
async function saveProjectFile(
|
|
projectData: unknown,
|
|
suggestedName?: string,
|
|
existingProjectPath?: string,
|
|
): Promise<ProjectFileResult> {
|
|
try {
|
|
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
|
|
? existingProjectPath
|
|
: null;
|
|
|
|
if (trustedExistingProjectPath) {
|
|
await fs.writeFile(
|
|
trustedExistingProjectPath,
|
|
JSON.stringify(projectData, null, 2),
|
|
"utf-8",
|
|
);
|
|
currentProjectPath = trustedExistingProjectPath;
|
|
return {
|
|
success: true,
|
|
path: trustedExistingProjectPath,
|
|
message: "Project saved successfully",
|
|
};
|
|
}
|
|
|
|
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, "_");
|
|
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
|
|
? safeName
|
|
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
|
|
|
|
const dialogOptions = buildDialogOptions(
|
|
{
|
|
title: mainT("dialogs", "fileDialogs.saveProject"),
|
|
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
|
filters: [
|
|
{
|
|
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
|
extensions: [PROJECT_FILE_EXTENSION],
|
|
},
|
|
{ name: "JSON", extensions: ["json"] },
|
|
],
|
|
properties: ["createDirectory", "showOverwriteConfirmation"],
|
|
},
|
|
getMainWindow(),
|
|
);
|
|
const result = await dialog.showSaveDialog(dialogOptions);
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return {
|
|
success: false,
|
|
canceled: true,
|
|
message: "Save project canceled",
|
|
};
|
|
}
|
|
|
|
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), "utf-8");
|
|
currentProjectPath = result.filePath;
|
|
|
|
return {
|
|
success: true,
|
|
path: result.filePath,
|
|
message: "Project saved successfully",
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to save project file:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to save project file",
|
|
error: String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("load-project-file", async () => {
|
|
return loadProjectFile();
|
|
});
|
|
|
|
async function loadProjectFile(): Promise<ProjectFileResult> {
|
|
try {
|
|
const dialogOptions = buildDialogOptions(
|
|
{
|
|
title: mainT("dialogs", "fileDialogs.openProject"),
|
|
defaultPath: RECORDINGS_DIR,
|
|
filters: [
|
|
{
|
|
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
|
extensions: [PROJECT_FILE_EXTENSION],
|
|
},
|
|
{ name: "JSON", extensions: ["json"] },
|
|
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
|
],
|
|
properties: ["openFile"],
|
|
},
|
|
getMainWindow(),
|
|
);
|
|
const result = await dialog.showOpenDialog(dialogOptions);
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
return { success: false, canceled: true, message: "Open project canceled" };
|
|
}
|
|
|
|
const filePath = result.filePaths[0];
|
|
const content = await fs.readFile(filePath, "utf-8");
|
|
const project = JSON.parse(content);
|
|
currentProjectPath = filePath;
|
|
setCurrentRecordingSessionState(await getApprovedProjectSession(project, filePath));
|
|
|
|
return {
|
|
success: true,
|
|
path: filePath,
|
|
project,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to load project file:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to load project file",
|
|
error: String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("load-current-project-file", async () => {
|
|
return loadCurrentProjectFile();
|
|
});
|
|
|
|
async function loadCurrentProjectFile(): Promise<ProjectFileResult> {
|
|
try {
|
|
if (!currentProjectPath) {
|
|
return { success: false, message: "No active project" };
|
|
}
|
|
|
|
const content = await fs.readFile(currentProjectPath, "utf-8");
|
|
const project = JSON.parse(content);
|
|
setCurrentRecordingSessionState(await getApprovedProjectSession(project, currentProjectPath));
|
|
return {
|
|
success: true,
|
|
path: currentProjectPath,
|
|
project,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to load current project file:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to load current project file",
|
|
error: String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("set-current-video-path", async (_, path: string) => {
|
|
return setCurrentVideoPath(path);
|
|
});
|
|
|
|
ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => {
|
|
const normalizedSession = normalizeRecordingSession(session);
|
|
setCurrentRecordingSessionState(normalizedSession);
|
|
currentVideoPath = normalizedSession?.screenVideoPath ?? null;
|
|
currentProjectPath = null;
|
|
return { success: true, session: currentRecordingSession };
|
|
});
|
|
|
|
ipcMain.handle("get-current-recording-session", () => {
|
|
return currentRecordingSession
|
|
? { success: true, session: currentRecordingSession }
|
|
: { success: false };
|
|
});
|
|
|
|
async function setCurrentVideoPath(path: string): Promise<ProjectPathResult> {
|
|
const normalizedPath = normalizeVideoSourcePath(path);
|
|
if (!normalizedPath || !isPathAllowed(normalizedPath)) {
|
|
return {
|
|
success: false,
|
|
message: "Video path has not been approved",
|
|
};
|
|
}
|
|
|
|
const restoredSession = await loadRecordedSessionForVideoPath(normalizedPath);
|
|
if (restoredSession) {
|
|
setCurrentRecordingSessionState(restoredSession);
|
|
} else {
|
|
setCurrentRecordingSessionState({
|
|
screenVideoPath: normalizedPath,
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
currentProjectPath = null;
|
|
return { success: true, path: currentVideoPath ?? normalizedPath };
|
|
}
|
|
|
|
ipcMain.handle("get-current-video-path", () => {
|
|
return getCurrentVideoPathResult();
|
|
});
|
|
|
|
function getCurrentVideoPathResult(): ProjectPathResult {
|
|
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
|
|
}
|
|
|
|
ipcMain.handle("clear-current-video-path", () => {
|
|
return clearCurrentVideoPath();
|
|
});
|
|
|
|
function clearCurrentVideoPath(): ProjectPathResult {
|
|
currentVideoPath = null;
|
|
return { success: true };
|
|
}
|
|
|
|
ipcMain.handle("get-platform", () => {
|
|
return process.platform;
|
|
});
|
|
|
|
ipcMain.handle("get-shortcuts", async () => {
|
|
try {
|
|
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
|
|
try {
|
|
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Failed to save shortcuts:", error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(
|
|
"save-diagnostic",
|
|
async (
|
|
_,
|
|
payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
|
|
) => {
|
|
const { filePath, canceled } = await dialog.showSaveDialog({
|
|
title: "Save Diagnostic File",
|
|
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
|
|
filters: [{ name: "JSON", extensions: ["json"] }],
|
|
});
|
|
|
|
if (canceled || !filePath) return { success: false, canceled: true };
|
|
|
|
const diagnostic = {
|
|
timestamp: new Date().toISOString(),
|
|
appVersion: app.getVersion(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
osRelease: os.release(),
|
|
osVersion: os.version(),
|
|
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
|
|
nodeVersion: process.versions.node,
|
|
electronVersion: process.versions.electron,
|
|
chromeVersion: process.versions.chrome,
|
|
error: payload.error,
|
|
stack: payload.stack,
|
|
projectState: payload.projectState,
|
|
recentLogs: payload.logs,
|
|
};
|
|
|
|
try {
|
|
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
|
|
return { success: true, path: filePath };
|
|
} catch (error) {
|
|
console.error("Failed to write diagnostic file:", error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
},
|
|
);
|
|
|
|
registerNativeBridgeHandlers({
|
|
getPlatform: () => process.platform,
|
|
getCurrentProjectPath: () => currentProjectPath,
|
|
getCurrentVideoPath: () => currentVideoPath,
|
|
saveProjectFile,
|
|
loadProjectFile,
|
|
loadCurrentProjectFile,
|
|
setCurrentVideoPath,
|
|
getCurrentVideoPathResult,
|
|
clearCurrentVideoPath,
|
|
resolveAssetBasePath,
|
|
resolveVideoPath: (videoPath?: string | null) =>
|
|
normalizeVideoSourcePath(videoPath ?? currentVideoPath),
|
|
loadCursorRecordingData: readCursorRecordingFile,
|
|
loadCursorTelemetry: readCursorTelemetryFile,
|
|
});
|
|
}
|