be4e2d0c94
Microphone permission is checked at startup via getMediaAccessStatus, and camera has a dedicated request-camera-access IPC handler, but screen recording relied entirely on desktopCapturer.getSources() to implicitly trigger the TCC prompt — causing the permission dialog to reappear on every launch (issue #558). Note: askForMediaAccess() only accepts "microphone" | "camera"; screen recording TCC is triggered via desktopCapturer.getSources() instead. Fix: - Import desktopCapturer in main.ts - Call getMediaAccessStatus("screen") in app.whenReady(); trigger the TCC prompt via getSources when status is "not-determined" - Add request-screen-access IPC handler symmetric to request-camera-access
1412 lines
41 KiB
TypeScript
1412 lines
41 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const nodeRequire = createRequire(import.meta.url);
|
|
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
desktopCapturer,
|
|
dialog,
|
|
ipcMain,
|
|
screen,
|
|
shell,
|
|
systemPreferences,
|
|
} from "electron";
|
|
import {
|
|
type CursorTelemetryPoint,
|
|
createCursorTelemetryBuffer,
|
|
} from "../../src/lib/cursorTelemetryBuffer";
|
|
import {
|
|
normalizeProjectMedia,
|
|
normalizeRecordingSession,
|
|
type ProjectMedia,
|
|
type RecordingSession,
|
|
type StoreRecordedSessionInput,
|
|
} from "../../src/lib/recordingSession";
|
|
import { mainT } from "../i18n";
|
|
import { RECORDINGS_DIR } from "../main";
|
|
|
|
const PROJECT_FILE_EXTENSION = "openscreen";
|
|
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
|
const RECORDING_SESSION_SUFFIX = ".session.json";
|
|
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
let selectedSource: SelectedSource | null = null;
|
|
let currentProjectPath: string | null = null;
|
|
let currentRecordingSession: RecordingSession | 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);
|
|
}
|
|
|
|
function setCurrentRecordingSessionState(session: RecordingSession | null) {
|
|
currentRecordingSession = session;
|
|
}
|
|
|
|
function getSessionManifestPathForVideo(videoPath: string) {
|
|
const parsed = path.parse(videoPath);
|
|
const baseName = parsed.name.endsWith("-webcam")
|
|
? parsed.name.slice(0, -"-webcam".length)
|
|
: parsed.name;
|
|
return path.join(parsed.dir, `${baseName}${RECORDING_SESSION_SUFFIX}`);
|
|
}
|
|
|
|
async function loadRecordedSessionForVideoPath(
|
|
videoPath: string,
|
|
): Promise<RecordingSession | null> {
|
|
const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
|
|
if (!normalizedVideoPath) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const manifestPath = getSessionManifestPathForVideo(normalizedVideoPath);
|
|
const content = await fs.readFile(manifestPath, "utf-8");
|
|
const session = normalizeRecordingSession(JSON.parse(content));
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedSession: RecordingSession = {
|
|
...session,
|
|
screenVideoPath: normalizeVideoSourcePath(session.screenVideoPath) ?? session.screenVideoPath,
|
|
...(session.webcamVideoPath
|
|
? {
|
|
webcamVideoPath:
|
|
normalizeVideoSourcePath(session.webcamVideoPath) ?? session.webcamVideoPath,
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const targetPath = normalizePath(normalizedVideoPath);
|
|
const screenMatches = normalizePath(normalizedSession.screenVideoPath) === targetPath;
|
|
const webcamMatches = normalizedSession.webcamVideoPath
|
|
? normalizePath(normalizedSession.webcamVideoPath) === targetPath
|
|
: false;
|
|
|
|
return screenMatches || webcamMatches ? normalizedSession : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
|
const createdAt =
|
|
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
|
|
? payload.createdAt
|
|
: Date.now();
|
|
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
|
|
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
|
|
|
|
let webcamVideoPath: string | undefined;
|
|
if (payload.webcam) {
|
|
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
|
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
|
}
|
|
|
|
const session: RecordingSession = webcamVideoPath
|
|
? { screenVideoPath, webcamVideoPath, createdAt }
|
|
: { screenVideoPath, createdAt };
|
|
setCurrentRecordingSessionState(session);
|
|
currentProjectPath = null;
|
|
|
|
const telemetryPath = `${screenVideoPath}.cursor.json`;
|
|
const pendingBatch = cursorTelemetryBuffer.takeNextBatch();
|
|
const pendingClicks = takeCursorClickTimestamps();
|
|
if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) {
|
|
try {
|
|
await fs.writeFile(
|
|
telemetryPath,
|
|
JSON.stringify(
|
|
{
|
|
version: CURSOR_TELEMETRY_VERSION,
|
|
samples: pendingBatch?.samples ?? [],
|
|
clicks: pendingClicks,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
} catch (err) {
|
|
if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
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",
|
|
};
|
|
}
|
|
|
|
const CURSOR_TELEMETRY_VERSION = 1;
|
|
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
|
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
|
|
|
let cursorCaptureInterval: NodeJS.Timeout | null = null;
|
|
let cursorCaptureStartTimeMs = 0;
|
|
const cursorTelemetryBuffer = createCursorTelemetryBuffer({
|
|
maxActiveSamples: MAX_CURSOR_SAMPLES,
|
|
});
|
|
|
|
// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility).
|
|
const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour
|
|
let cursorClickTimestampsMs: number[] = [];
|
|
let uioHookInstance: {
|
|
start: () => void;
|
|
stop: () => void;
|
|
on: (...a: unknown[]) => void;
|
|
off?: (...a: unknown[]) => void;
|
|
removeListener?: (...a: unknown[]) => void;
|
|
} | null = null;
|
|
let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null;
|
|
let uioHookFailureLogged = false;
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function loadUioHookForClicks(): typeof uioHookInstance {
|
|
try {
|
|
// Dynamic require + try/catch so a broken native binary doesn't crash startup.
|
|
const mod = nodeRequire("uiohook-napi");
|
|
const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default;
|
|
if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") {
|
|
return candidate;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
if (!uioHookFailureLogged) {
|
|
uioHookFailureLogged = true;
|
|
console.warn("[clickCapture] uiohook-napi unavailable:", error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function startClickCapture() {
|
|
if (process.platform !== "darwin") return;
|
|
if (uioHookInstance) return;
|
|
|
|
// Passive check — the prompt fires from the renderer when the user toggles
|
|
// "Only on clicks" so it doesn't stack with the screen-recording prompt.
|
|
try {
|
|
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
|
|
if (!uioHookFailureLogged) {
|
|
uioHookFailureLogged = true;
|
|
console.warn(
|
|
"[clickCapture] Accessibility permission not granted — click capture disabled.",
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
} catch {
|
|
// fall through; uiohook will fail defensively below
|
|
}
|
|
|
|
const hook = loadUioHookForClicks();
|
|
if (!hook) return;
|
|
|
|
uioHookMouseDownHandler = (event) => {
|
|
const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs);
|
|
void event;
|
|
if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return;
|
|
cursorClickTimestampsMs.push(elapsed);
|
|
};
|
|
|
|
try {
|
|
hook.on("mousedown", uioHookMouseDownHandler);
|
|
hook.start();
|
|
uioHookInstance = hook;
|
|
} catch (error) {
|
|
if (!uioHookFailureLogged) {
|
|
uioHookFailureLogged = true;
|
|
console.warn("[clickCapture] failed to start uiohook:", error);
|
|
}
|
|
uioHookMouseDownHandler = null;
|
|
}
|
|
}
|
|
|
|
function stopClickCapture() {
|
|
if (!uioHookInstance) return;
|
|
try {
|
|
if (uioHookMouseDownHandler) {
|
|
if (typeof uioHookInstance.off === "function") {
|
|
uioHookInstance.off("mousedown", uioHookMouseDownHandler);
|
|
} else if (typeof uioHookInstance.removeListener === "function") {
|
|
uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler);
|
|
}
|
|
}
|
|
uioHookInstance.stop();
|
|
} catch (error) {
|
|
console.warn("[clickCapture] failed to stop uiohook:", error);
|
|
}
|
|
uioHookInstance = null;
|
|
uioHookMouseDownHandler = null;
|
|
}
|
|
|
|
function takeCursorClickTimestamps(): number[] {
|
|
const out = cursorClickTimestampsMs;
|
|
cursorClickTimestampsMs = [];
|
|
return out;
|
|
}
|
|
|
|
function stopCursorCapture() {
|
|
if (cursorCaptureInterval) {
|
|
clearInterval(cursorCaptureInterval);
|
|
cursorCaptureInterval = null;
|
|
}
|
|
stopClickCapture();
|
|
}
|
|
|
|
function sampleCursorPoint() {
|
|
const cursor = screen.getCursorScreenPoint();
|
|
const sourceDisplayId = Number(selectedSource?.display_id);
|
|
const sourceDisplay = Number.isFinite(sourceDisplayId)
|
|
? (screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null)
|
|
: null;
|
|
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor);
|
|
const bounds = display.bounds;
|
|
const width = Math.max(1, bounds.width);
|
|
const height = Math.max(1, bounds.height);
|
|
|
|
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
|
|
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
|
|
|
|
cursorTelemetryBuffer.push({
|
|
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
|
|
cx,
|
|
cy,
|
|
});
|
|
}
|
|
|
|
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,
|
|
) {
|
|
const supportsWindowOpacity = process.platform !== "linux";
|
|
const countdownOverlayState = {
|
|
visible: false,
|
|
value: null as number | null,
|
|
activeRunId: null as number | null,
|
|
hideCommitId: 0,
|
|
hideCommitTimer: null as ReturnType<typeof setTimeout> | null,
|
|
};
|
|
const COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS = 1200;
|
|
|
|
const clearCountdownOverlayHideCommit = () => {
|
|
if (countdownOverlayState.hideCommitTimer) {
|
|
clearTimeout(countdownOverlayState.hideCommitTimer);
|
|
countdownOverlayState.hideCommitTimer = null;
|
|
}
|
|
};
|
|
|
|
const commitCountdownOverlayHide = (win: BrowserWindow, hideCommitId: number) => {
|
|
if (win.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (countdownOverlayState.visible || countdownOverlayState.hideCommitId !== hideCommitId) {
|
|
return;
|
|
}
|
|
|
|
win.hide();
|
|
if (supportsWindowOpacity) {
|
|
// Reset baseline opacity for the next show cycle.
|
|
win.setOpacity(1);
|
|
}
|
|
};
|
|
|
|
const flushCountdownOverlayState = (win: BrowserWindow) => {
|
|
if (win.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
clearCountdownOverlayHideCommit();
|
|
win.webContents.send("countdown-overlay-value", countdownOverlayState.value);
|
|
if (!countdownOverlayState.visible) {
|
|
return;
|
|
}
|
|
|
|
if (win.isVisible()) {
|
|
if (supportsWindowOpacity) {
|
|
win.setOpacity(1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!win.isDestroyed() && countdownOverlayState.visible && !win.isVisible()) {
|
|
if (supportsWindowOpacity) {
|
|
win.setOpacity(0);
|
|
}
|
|
win.showInactive();
|
|
|
|
if (supportsWindowOpacity) {
|
|
setTimeout(() => {
|
|
if (!win.isDestroyed() && countdownOverlayState.visible && win.isVisible()) {
|
|
win.setOpacity(1);
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
}, 16);
|
|
};
|
|
|
|
ipcMain.handle("countdown-overlay-show", (_, value: number, runId: number) => {
|
|
countdownOverlayState.activeRunId = runId;
|
|
countdownOverlayState.visible = true;
|
|
countdownOverlayState.value = value;
|
|
|
|
const win = getCountdownOverlayWindow() ?? createCountdownOverlayWindow();
|
|
if (win.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (win.webContents.isLoading()) {
|
|
win.webContents.once("did-finish-load", () => {
|
|
if (!win.isDestroyed()) {
|
|
flushCountdownOverlayState(win);
|
|
}
|
|
});
|
|
} else {
|
|
flushCountdownOverlayState(win);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("countdown-overlay-set-value", (_, value: number, runId: number) => {
|
|
if (countdownOverlayState.activeRunId !== runId || !countdownOverlayState.visible) {
|
|
return;
|
|
}
|
|
|
|
countdownOverlayState.value = value;
|
|
|
|
const win = getCountdownOverlayWindow();
|
|
if (!win || win.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (win.webContents.isLoading()) {
|
|
return;
|
|
}
|
|
|
|
win.webContents.send("countdown-overlay-value", value);
|
|
});
|
|
|
|
ipcMain.handle("countdown-overlay-hide", (_, runId: number) => {
|
|
if (countdownOverlayState.activeRunId !== runId) {
|
|
return;
|
|
}
|
|
|
|
countdownOverlayState.visible = false;
|
|
countdownOverlayState.hideCommitId += 1;
|
|
const hideCommitId = countdownOverlayState.hideCommitId;
|
|
clearCountdownOverlayHideCommit();
|
|
|
|
const win = getCountdownOverlayWindow();
|
|
if (!win || win.isDestroyed()) {
|
|
countdownOverlayState.value = null;
|
|
return;
|
|
}
|
|
|
|
if (supportsWindowOpacity) {
|
|
// Hide visually immediately to avoid hide/show compositor flashes on rapid restart.
|
|
win.setOpacity(0);
|
|
}
|
|
|
|
countdownOverlayState.value = null;
|
|
if (!win.webContents.isLoading()) {
|
|
win.webContents.send("countdown-overlay-value", countdownOverlayState.value);
|
|
}
|
|
|
|
if (!supportsWindowOpacity) {
|
|
win.hide();
|
|
return;
|
|
}
|
|
|
|
countdownOverlayState.hideCommitTimer = setTimeout(() => {
|
|
countdownOverlayState.hideCommitTimer = null;
|
|
commitCountdownOverlayHide(win, hideCommitId);
|
|
}, COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS);
|
|
});
|
|
|
|
ipcMain.handle("switch-to-hud", () => {
|
|
if (switchToHud) switchToHud();
|
|
});
|
|
ipcMain.handle("start-new-recording", () => {
|
|
try {
|
|
setCurrentRecordingSessionState(null);
|
|
if (switchToHud) {
|
|
switchToHud();
|
|
}
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Failed to start new recording:", error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-sources", async (_, opts) => {
|
|
const ownWindowSourceIds = new Set(
|
|
BrowserWindow.getAllWindows()
|
|
.map((win) => {
|
|
try {
|
|
return win.getMediaSourceId();
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((id): id is string => Boolean(id)),
|
|
);
|
|
const sources = await desktopCapturer.getSources(opts);
|
|
return sources
|
|
.filter((source) => !ownWindowSourceIds.has(source.id))
|
|
.map((source) => ({
|
|
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,
|
|
}));
|
|
});
|
|
|
|
ipcMain.handle("select-source", (_, source: SelectedSource) => {
|
|
selectedSource = source;
|
|
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 () => {
|
|
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 — the TCC prompt
|
|
// is triggered by desktopCapturer.getSources(). Fire it and return so
|
|
// the renderer can re-check status after the user responds.
|
|
if (status === "not-determined") {
|
|
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
|
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) };
|
|
}
|
|
});
|
|
|
|
// macOS Accessibility prompt for global click capture. First call shows the
|
|
// system dialog; the user has to toggle the app in System Settings (no
|
|
// programmatic grant exists for Accessibility).
|
|
ipcMain.handle("request-accessibility-access", () => {
|
|
if (process.platform !== "darwin") {
|
|
return { success: true, granted: true };
|
|
}
|
|
try {
|
|
const granted = systemPreferences.isTrustedAccessibilityClient(true);
|
|
return { success: true, granted };
|
|
} catch (error) {
|
|
console.error("Failed to request accessibility access:", error);
|
|
return { success: false, granted: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-source-selector", () => {
|
|
const sourceSelectorWin = getSourceSelectorWindow();
|
|
if (sourceSelectorWin) {
|
|
sourceSelectorWin.focus();
|
|
return;
|
|
}
|
|
createSourceSelectorWindow();
|
|
});
|
|
|
|
ipcMain.handle("switch-to-editor", () => {
|
|
const mainWin = getMainWindow();
|
|
if (mainWin) {
|
|
mainWin.close();
|
|
}
|
|
createEditorWindow();
|
|
});
|
|
|
|
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),
|
|
};
|
|
}
|
|
});
|
|
|
|
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" };
|
|
}
|
|
|
|
// Sort by most recently modified to reliably get the latest recording.
|
|
// Lexicographic sort is unreliable (e.g. recording-9.webm > recording-10.webm).
|
|
let latestVideo: string | null = null;
|
|
let latestMtimeMs = 0;
|
|
for (const file of videoFiles) {
|
|
try {
|
|
const stat = await fs.stat(path.join(RECORDINGS_DIR, file));
|
|
if (stat.mtimeMs > latestMtimeMs) {
|
|
latestMtimeMs = stat.mtimeMs;
|
|
latestVideo = file;
|
|
}
|
|
} catch {
|
|
// Skip inaccessible files.
|
|
}
|
|
}
|
|
if (!latestVideo) {
|
|
return { success: false, message: "No recorded video found" };
|
|
}
|
|
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("read-binary-file", async (_, inputPath: string) => {
|
|
try {
|
|
const normalizedPath = normalizeVideoSourcePath(inputPath);
|
|
if (!normalizedPath) {
|
|
return { success: false, message: "Invalid file path" };
|
|
}
|
|
|
|
if (!isPathAllowed(normalizedPath)) {
|
|
console.warn(
|
|
"[read-binary-file] Rejected path outside allowed directories:",
|
|
normalizedPath,
|
|
);
|
|
return { success: false, message: "Access denied: path outside allowed directories" };
|
|
}
|
|
|
|
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("set-recording-state", (_, recording: boolean, recordingId?: number) => {
|
|
if (recording) {
|
|
stopCursorCapture();
|
|
// The renderer is the source of truth for the recording id (it
|
|
// uses the same id as the saved fileName). Fall back to a
|
|
// timestamp only if the renderer didn't supply one, so the
|
|
// buffer always has a stable key per session.
|
|
const id = typeof recordingId === "number" ? recordingId : Date.now();
|
|
cursorTelemetryBuffer.startSession(id);
|
|
cursorCaptureStartTimeMs = Date.now();
|
|
cursorClickTimestampsMs = [];
|
|
startClickCapture();
|
|
sampleCursorPoint();
|
|
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
|
|
} else {
|
|
stopCursorCapture();
|
|
cursorTelemetryBuffer.endSession();
|
|
}
|
|
|
|
const source = selectedSource || { name: "Screen" };
|
|
if (onRecordingStateChange) {
|
|
onRecordingStateChange(recording, source.name);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("discard-cursor-telemetry", (_, recordingId: number) => {
|
|
cursorTelemetryBuffer.discardBatch(recordingId);
|
|
});
|
|
|
|
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
|
const targetVideoPath = normalizeVideoSourcePath(
|
|
videoPath ?? currentRecordingSession?.screenVideoPath,
|
|
);
|
|
if (!targetVideoPath) {
|
|
return { success: true, samples: [] };
|
|
}
|
|
|
|
if (!isPathAllowed(targetVideoPath)) {
|
|
console.warn(
|
|
"[get-cursor-telemetry] Rejected path outside allowed directories:",
|
|
targetVideoPath,
|
|
);
|
|
return { success: true, samples: [] };
|
|
}
|
|
|
|
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 samples: CursorTelemetryPoint[] = rawSamples
|
|
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
|
|
.map((sample: unknown) => {
|
|
const point = sample as Partial<CursorTelemetryPoint>;
|
|
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)
|
|
? clamp(point.cx, 0, 1)
|
|
: 0.5,
|
|
cy:
|
|
typeof point.cy === "number" && Number.isFinite(point.cy)
|
|
? clamp(point.cy, 0, 1)
|
|
: 0.5,
|
|
};
|
|
})
|
|
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
|
|
|
|
const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : [];
|
|
const clicks: number[] = rawClicks
|
|
.map((value: unknown) =>
|
|
typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null,
|
|
)
|
|
.filter((v: number | null): v is number => v !== null)
|
|
.sort((a: number, b: number) => a - b);
|
|
|
|
return { success: true, samples, clicks };
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code === "ENOENT") {
|
|
return { success: true, samples: [], clicks: [] };
|
|
}
|
|
console.error("Failed to load cursor telemetry:", error);
|
|
return {
|
|
success: false,
|
|
message: "Failed to load cursor telemetry",
|
|
error: String(error),
|
|
samples: [],
|
|
clicks: [],
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-external-url", async (_, url: string) => {
|
|
try {
|
|
const ALLOWED_SCHEMES = ["http:", "https:", "mailto:"];
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return { success: false, error: "Invalid URL" };
|
|
}
|
|
|
|
if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
|
|
return { success: false, error: `Unsupported URL scheme: ${parsed.protocol}` };
|
|
}
|
|
|
|
await shell.openExternal(parsed.toString());
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Failed to open URL:", error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Handles saving an exported video file.
|
|
* Shows a save dialog, normalizes the file path for the current OS,
|
|
* ensures the directory exists, and writes the video data.
|
|
* @param _ - Unused event parameter.
|
|
* @param videoData - The exported video as an ArrayBuffer.
|
|
* @param fileName - Suggested filename for the save dialog.
|
|
* @returns Object with success status, optional file path, and error details.
|
|
*/
|
|
|
|
ipcMain.handle(
|
|
"save-exported-video",
|
|
async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
|
|
try {
|
|
// Determine file type from extension
|
|
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) {
|
|
// Stat can fail because the folder was moved/deleted (expected) or
|
|
// because of a permission error (worth surfacing). Either way we
|
|
// fall back to Downloads, but log so debugging isn't blind.
|
|
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",
|
|
};
|
|
}
|
|
|
|
// --- FIX: Normalize the path for Windows compatibility ---
|
|
const normalizedPath = path.normalize(result.filePath);
|
|
|
|
// Ensure the parent directory exists (Windows may fail if the folder is missing)
|
|
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
|
// --- END FIX ---
|
|
|
|
await fs.writeFile(normalizedPath, Buffer.from(videoData));
|
|
|
|
return {
|
|
success: true,
|
|
path: normalizedPath,
|
|
message: "Video exported successfully",
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to save 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 approvedPath = await approveReadableVideoPath(result.filePaths[0]);
|
|
if (!approvedPath) {
|
|
return {
|
|
success: false,
|
|
message: "Selected file is not a supported video",
|
|
};
|
|
}
|
|
currentProjectPath = null;
|
|
return {
|
|
success: true,
|
|
path: approvedPath,
|
|
};
|
|
} 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(
|
|
"save-project-file",
|
|
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
|
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 () => {
|
|
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);
|
|
const session = await getApprovedProjectSession(project, filePath);
|
|
currentProjectPath = filePath;
|
|
setCurrentRecordingSessionState(session);
|
|
|
|
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 () => {
|
|
try {
|
|
if (!currentProjectPath) {
|
|
return { success: false, message: "No active project" };
|
|
}
|
|
|
|
const content = await fs.readFile(currentProjectPath, "utf-8");
|
|
const project = JSON.parse(content);
|
|
const session = await getApprovedProjectSession(project, currentProjectPath);
|
|
setCurrentRecordingSessionState(session);
|
|
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-recording-session", (_, session: RecordingSession | null) => {
|
|
const normalized = normalizeRecordingSession(session);
|
|
setCurrentRecordingSessionState(normalized);
|
|
currentProjectPath = null;
|
|
return { success: true, session: normalized ?? undefined };
|
|
});
|
|
|
|
ipcMain.handle("get-current-recording-session", () => {
|
|
return currentRecordingSession
|
|
? { success: true, session: currentRecordingSession }
|
|
: { success: false };
|
|
});
|
|
|
|
ipcMain.handle("set-current-video-path", async (_, path: string) => {
|
|
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) {
|
|
// Approve all media paths from the restored session so they can be read later
|
|
approveFilePath(restoredSession.screenVideoPath);
|
|
if (restoredSession.webcamVideoPath) {
|
|
approveFilePath(restoredSession.webcamVideoPath);
|
|
}
|
|
setCurrentRecordingSessionState(restoredSession);
|
|
} else {
|
|
setCurrentRecordingSessionState({
|
|
screenVideoPath: normalizedPath,
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
currentProjectPath = null;
|
|
return { success: true };
|
|
});
|
|
|
|
ipcMain.handle("get-current-video-path", () => {
|
|
return currentRecordingSession?.screenVideoPath
|
|
? { success: true, path: currentRecordingSession.screenVideoPath }
|
|
: { success: false };
|
|
});
|
|
|
|
ipcMain.handle("clear-current-video-path", () => {
|
|
setCurrentRecordingSessionState(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) };
|
|
}
|
|
},
|
|
);
|
|
}
|