Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
666 changes: 666 additions & 0 deletions cli/openscreen.mjs

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,32 @@ interface Window {
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
getCliRecordConfig: () => Promise<{
durationMs: number;
source?: string;
sourceType?: "screen" | "window" | "any";
systemAudio?: boolean;
}>;
cliRecordMessage: (message: { type: string; data: unknown }) => void;
getCliRenderConfig: () => Promise<{
project: {
media?: {
screenVideoPath?: string;
webcamVideoPath?: string;
};
videoPath?: string;
editor: unknown;
};
output: string;
format: "mp4" | "gif";
quality?: "medium" | "good" | "source";
width?: number;
height?: number;
gifFrameRate?: 15 | 20 | 25 | 30;
gifSizePreset?: "medium" | "large" | "original";
gifLoop?: boolean;
}>;
cliRenderMessage: (message: { type: string; data: unknown }) => void;
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
Expand Down
4 changes: 4 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function approveFilePath(filePath: string): void {
approvedPaths.add(path.resolve(filePath));
}

export function approveReadablePath(filePath: string): void {
approveFilePath(filePath);
}

function getAllowedReadDirs(): string[] {
return [RECORDINGS_DIR];
}
Expand Down
293 changes: 291 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
app,
BrowserWindow,
Expand All @@ -13,7 +13,7 @@ import {
Tray,
} from "electron";
import { mainT, setMainLocale } from "./i18n";
import { registerIpcHandlers } from "./ipc/handlers";
import { approveReadablePath, registerIpcHandlers } from "./ipc/handlers";
import {
createCountdownOverlayWindow,
createEditorWindow,
Expand Down Expand Up @@ -74,6 +74,10 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;

function getAssetBaseUrlArg() {
return `--asset-base-url=${pathToFileURL(`${process.env.VITE_PUBLIC}${path.sep}`).toString()}`;
}

// Window references
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
Expand Down Expand Up @@ -444,8 +448,293 @@ app.on("activate", () => {
}
});

// CLI record mode runs the existing Electron capture stack without showing the HUD.
// The Node CLI owns argument parsing and project output; the renderer owns
// getUserMedia/MediaRecorder so capture behavior stays aligned with the app.
const isCliRecord = process.argv.includes("--cli-record");
const isCliRender = process.argv.includes("--cli-render");

type CliRecordConfig = {
durationMs: number;
source?: string;
sourceType?: "screen" | "window" | "any";
systemAudio?: boolean;
};

type CliRenderConfig = {
project: {
media?: {
screenVideoPath?: string;
webcamVideoPath?: string;
};
videoPath?: string;
editor: unknown;
};
output: string;
format: "mp4" | "gif";
quality?: "medium" | "good" | "source";
width?: number;
height?: number;
gifFrameRate?: 15 | 20 | 25 | 30;
gifSizePreset?: "medium" | "large" | "original";
gifLoop?: boolean;
};

function getCliArg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index !== -1 && index + 1 < process.argv.length ? process.argv[index + 1] : undefined;
}

function writeCliMessage(type: string, data: unknown) {
process.stdout.write(`${JSON.stringify({ __cli: true, type, data })}\n`);
}

async function readCliRecordConfig(): Promise<CliRecordConfig> {
const configPath = getCliArg("--config");
if (!configPath) {
throw new Error("Missing --config argument.");
}

const rawConfig = await fs.readFile(configPath, "utf8");
const parsed = JSON.parse(rawConfig) as Partial<CliRecordConfig>;
const durationMs = Number(parsed.durationMs);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
throw new Error("CLI record config requires a positive durationMs.");
}

const sourceType =
parsed.sourceType === "screen" || parsed.sourceType === "window" || parsed.sourceType === "any"
? parsed.sourceType
: "any";

return {
durationMs,
source: typeof parsed.source === "string" ? parsed.source : undefined,
sourceType,
systemAudio: parsed.systemAudio === true,
};
}

async function runCliRecord() {
const config = await readCliRecordConfig();
await ensureRecordingsDir();

const createHiddenWindow = () =>
new BrowserWindow({
width: 1,
height: 1,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
},
});

registerIpcHandlers(
() => {
/* no editor window in CLI mode */
},
createHiddenWindow,
createHiddenWindow,
() => null,
() => null,
() => null,
);

ipcMain.handle("get-cli-record-config", () => config);

let exiting = false;
let safetyTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (exitCode: number) => {
if (exiting) return;
exiting = true;
if (safetyTimer) clearTimeout(safetyTimer);
setTimeout(() => app.exit(exitCode), 250);
};

ipcMain.on("cli-record-message", (_, message: { type: string; data: unknown }) => {
writeCliMessage(message.type, message.data);
if (message.type === "done") finish(0);
if (message.type === "error") finish(1);
});

const win = new BrowserWindow({
width: 1280,
height: 720,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
},
});

if (VITE_DEV_SERVER_URL) {
await win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=cli-record`);
} else {
await win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "cli-record" },
});
}

safetyTimer = setTimeout(
() => {
writeCliMessage("error", { message: "Recording timed out." });
finish(1);
},
Math.max(config.durationMs + 120_000, 180_000),
);
}

async function readCliRenderConfig(): Promise<CliRenderConfig> {
const configPath = getCliArg("--config");
if (!configPath) {
throw new Error("Missing --config argument.");
}

approveReadablePath(configPath);
const rawConfig = await fs.readFile(configPath, "utf8");
const parsed = JSON.parse(rawConfig) as Partial<CliRenderConfig>;
if (!parsed.project || typeof parsed.project !== "object") {
throw new Error("CLI render config requires a project.");
}
if (typeof parsed.output !== "string" || !parsed.output.trim()) {
throw new Error("CLI render config requires an output path.");
}

const output = path.resolve(parsed.output);
await fs.mkdir(path.dirname(output), { recursive: true });

const screenVideoPath =
typeof parsed.project.media?.screenVideoPath === "string"
? parsed.project.media.screenVideoPath
: typeof parsed.project.videoPath === "string"
? parsed.project.videoPath
: undefined;
if (screenVideoPath) {
approveReadablePath(screenVideoPath);
}
if (typeof parsed.project.media?.webcamVideoPath === "string") {
approveReadablePath(parsed.project.media.webcamVideoPath);
}

return {
project: parsed.project as CliRenderConfig["project"],
output,
format: parsed.format === "gif" ? "gif" : "mp4",
quality: parsed.quality === "medium" || parsed.quality === "source" ? parsed.quality : "good",
width: Number.isFinite(parsed.width) ? Math.floor(Number(parsed.width) / 2) * 2 : undefined,
height: Number.isFinite(parsed.height) ? Math.floor(Number(parsed.height) / 2) * 2 : undefined,
gifFrameRate: parsed.gifFrameRate,
gifSizePreset:
parsed.gifSizePreset === "large" || parsed.gifSizePreset === "original"
? parsed.gifSizePreset
: "medium",
gifLoop: parsed.gifLoop,
};
Comment on lines +624 to +637
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

input validation is a bit loose for cli render config.

a few things sneaking through here:

  • width/height: Math.floor(Number(parsed.width) / 2) * 2 accepts 0, negatives, and NaN-after-coercion. a width of 0 will silently propagate to the renderer's calculateCustomMp4Dimensions(0, 0) and then to a VideoExporter call that'll fail somewhere downstream with a less obvious error.
  • gifFrameRate: passed through with zero validation. user supplies gifFrameRate: 999, it gets cast to GifFrameRate in the renderer. better to clamp to the 15 | 20 | 25 | 30 set or reject.
  • gifLoop: same — accepts any truthy/falsy without coercing to boolean.

a small validator block here would fail fast with a useful message instead of crashing in the renderer:

🛡️ proposed validation
+const VALID_GIF_FRAME_RATES = new Set([15, 20, 25, 30]);
+
+function parseDimension(value: unknown): number | undefined {
+	if (typeof value !== "number" || !Number.isFinite(value) || value < 2) return undefined;
+	return Math.floor(value / 2) * 2;
+}
+
 	return {
 		project: parsed.project as CliRenderConfig["project"],
 		output,
 		format: parsed.format === "gif" ? "gif" : "mp4",
 		quality: parsed.quality === "medium" || parsed.quality === "source" ? parsed.quality : "good",
-		width: Number.isFinite(parsed.width) ? Math.floor(Number(parsed.width) / 2) * 2 : undefined,
-		height: Number.isFinite(parsed.height) ? Math.floor(Number(parsed.height) / 2) * 2 : undefined,
-		gifFrameRate: parsed.gifFrameRate,
+		width: parseDimension(parsed.width),
+		height: parseDimension(parsed.height),
+		gifFrameRate: VALID_GIF_FRAME_RATES.has(Number(parsed.gifFrameRate))
+			? (parsed.gifFrameRate as 15 | 20 | 25 | 30)
+			: undefined,
 		gifSizePreset:
 			parsed.gifSizePreset === "large" || parsed.gifSizePreset === "original"
 				? parsed.gifSizePreset
 				: "medium",
-		gifLoop: parsed.gifLoop,
+		gifLoop: typeof parsed.gifLoop === "boolean" ? parsed.gifLoop : undefined,
 	};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/main.ts` around lines 624 - 637, The returned CLI render config
currently trusts parsed values; add a small validation block before returning
that: validate width and height by coercing parsed.width/parsed.height to
Number, ensure Number.isFinite and >= 2, then floor to the nearest even integer
(e.g. Math.max(2, Math.floor(num/2)*2)) or set undefined if not provided;
validate gifFrameRate by coercing to Number and clamping/whitelisting to one of
[15,20,25,30] (or throw a descriptive Error if out of range); coerce gifLoop to
a strict boolean (Boolean(parsed.gifLoop)); keep existing enum checks for
format, quality, gifSizePreset but throw a clear error when values are invalid
so the caller fails fast rather than letting bad
width/height/gifFrameRate/gifLoop propagate to downstream functions that use
project, output, format, quality, width, height, gifFrameRate, gifSizePreset,
gifLoop.

}

async function runCliRender() {
const config = await readCliRenderConfig();
await ensureRecordingsDir();

registerIpcHandlers(
() => {
/* no editor window in CLI mode */
},
() => new BrowserWindow({ show: false }),
() => new BrowserWindow({ show: false }),
() => null,
() => null,
() => null,
);

ipcMain.handle("get-cli-render-config", () => config);
ipcMain.removeHandler("save-exported-video");
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer) => {
try {
await fs.writeFile(config.output, Buffer.from(videoData));
return { success: true, path: config.output, message: "Export saved" };
} catch (error) {
return { success: false, message: String(error) };
}
});

let exiting = false;
let safetyTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (exitCode: number) => {
if (exiting) return;
exiting = true;
if (safetyTimer) clearTimeout(safetyTimer);
setTimeout(() => app.exit(exitCode), 250);
};

ipcMain.on("cli-render-message", (_, message: { type: string; data: unknown }) => {
writeCliMessage(message.type, message.data);
if (message.type === "done") finish(0);
if (message.type === "error") finish(1);
});

const win = new BrowserWindow({
width: 1920,
height: 1080,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [getAssetBaseUrlArg()],
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
},
});

if (VITE_DEV_SERVER_URL) {
await win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=cli-render`);
} else {
await win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "cli-render" },
});
}

safetyTimer = setTimeout(
() => {
writeCliMessage("error", { message: "Render timed out." });
finish(1);
},
10 * 60 * 1000,
);
}

// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
if (isCliRecord) {
try {
await runCliRecord();
} catch (error) {
writeCliMessage("error", {
message: error instanceof Error ? error.message : String(error),
});
app.exit(1);
}
return;
}

if (isCliRender) {
try {
await runCliRender();
} catch (error) {
writeCliMessage("error", {
message: error instanceof Error ? error.message : String(error),
});
app.exit(1);
}
return;
}
Comment on lines +714 to +736
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm permission handlers are only registered in the non-CLI branch
rg -nP --type=ts -C2 'setPermission(Check|Request)Handler|askForMediaAccess' electron/

Repository: siddharthvaddem/openscreen

Length of output: 1334


🏁 Script executed:

#!/bin/bash
# Check the exact content of the lines mentioned and surrounding context
sed -n '710,765p' electron/main.ts | cat -n

Repository: siddharthvaddem/openscreen

Length of output: 2095


🏁 Script executed:

#!/bin/bash
# Check if runCliRecord or runCliRender have any permission handling
rg -nP --type=ts 'function runCli(Record|Render)|const runCli(Record|Render)' -A 20 electron/

Repository: siddharthvaddem/openscreen

Length of output: 2043


🏁 Script executed:

#!/bin/bash
# Find registerIpcHandlers definition and check for permission handling
rg -nP --type=ts 'function registerIpcHandlers|const registerIpcHandlers' -A 50 electron/ | head -100

Repository: siddharthvaddem/openscreen

Length of output: 3142


🏁 Script executed:

#!/bin/bash
# Check if systemAudio or audio capture is used in CLI paths
rg -nP --type=ts 'systemAudio|getAudioSources|desktopCapturer' electron/ | grep -E '(runCli|handlers.ts)' -C 3

Repository: siddharthvaddem/openscreen

Length of output: 370


🏁 Script executed:

#!/bin/bash
# Check how systemAudio is used and if it calls desktopCapturer
rg -nP --type=ts 'systemAudio' electron/ipc/handlers.ts -B 3 -A 10

Repository: siddharthvaddem/openscreen

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check what desktopCapturer.getSources is called with and around systemAudio
sed -n '660,680p' electron/ipc/handlers.ts | cat -n

Repository: siddharthvaddem/openscreen

Length of output: 832


🏁 Script executed:

#!/bin/bash
# Find the full handler that calls desktopCapturer.getSources
rg -nP --type=ts 'desktopCapturer.getSources' -B 30 electron/ipc/handlers.ts | head -60

Repository: siddharthvaddem/openscreen

Length of output: 943


🏁 Script executed:

#!/bin/bash
# Look for how systemAudio config is used overall
rg -nP --type=ts 'systemAudio' electron/ -B 2 -A 2

Repository: siddharthvaddem/openscreen

Length of output: 836


🏁 Script executed:

#!/bin/bash
# Check how CLI record config (with systemAudio) is actually used to start recording
rg -nP --type=ts 'recordingConfig|cliRecordConfig' electron/main.ts -A 5 -B 2

Repository: siddharthvaddem/openscreen

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check if desktopCapturer.getSources is called with systemAudio in the handlers
rg -nP --type=ts 'get-sources|get-cli-record-config' electron/ -A 3 -B 3

Repository: siddharthvaddem/openscreen

Length of output: 1576


🏁 Script executed:

#!/bin/bash
# Check if there's renderer code in CLI mode that actually calls getSources
find electron -name "*.ts" -type f | xargs grep -l "getSources\|getCliRecordConfig" | head -5

Repository: siddharthvaddem/openscreen

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Look for actual usage of systemAudio in any renderer or preload context
rg -nP --type=ts 'systemAudio.*getSources|getSources.*systemAudio|recordConfig.*systemAudio' electron/

Repository: siddharthvaddem/openscreen

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check the full runCliRecord implementation to see if it uses getSources
sed -n '518,620p' electron/main.ts | cat -n

Repository: siddharthvaddem/openscreen

Length of output: 3575


macOS first-run permission flow is kinda broken in CLI mode.

The early return statements skip the session.defaultSession.setPermissionCheckHandler / setPermissionRequestHandler registration and the systemPreferences.askForMediaAccess("microphone") prompt. When --cli-record --systemAudio is used on a fresh macOS install:

  • Audio capture permission gets hit with no graceful pre-prompt — either silently fails or throws a janky single-use permission dialog mid-recording
  • Screen recording is OS-mediated by desktopCapturer.getSources() itself, so that's probably fine, but the missing handler still risks noisy warnings

At minimum, register the same permission handlers in the CLI paths. Ideally pre-prompt mic if systemAudio is requested. Running the CLI cold on macOS without ever launching the GUI is currently a confusing mess.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/main.ts` around lines 714 - 736, The CLI paths (isCliRecord /
isCliRender) return before registering
session.defaultSession.setPermissionCheckHandler and setPermissionRequestHandler
and before calling systemPreferences.askForMediaAccess("microphone"), causing
macOS first-run permission issues; move or duplicate the permission handler
registration and the microphone pre-prompt into the CLI branch before calling
runCliRecord/runCliRender (or extract to a helper used by both GUI and CLI), and
if the runCliRecord path detects the systemAudio flag, explicitly call
systemPreferences.askForMediaAccess("microphone") (and surface failures via
writeCliMessage("error", ...)) before awaiting runCliRecord so the OS pre-prompt
occurs when running headless.


// Force the app into "regular" activation policy so the Dock icon appears.
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
// window we open, and AppKit otherwise classifies us as an accessory app.
Expand Down
12 changes: 12 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
hideCountdownOverlay: (runId: number) => {
return ipcRenderer.invoke("countdown-overlay-hide", runId);
},
getCliRecordConfig: () => {
return ipcRenderer.invoke("get-cli-record-config");
},
cliRecordMessage: (message: { type: string; data: unknown }) => {
ipcRenderer.send("cli-record-message", message);
},
getCliRenderConfig: () => {
return ipcRenderer.invoke("get-cli-render-config");
},
cliRenderMessage: (message: { type: string; data: unknown }) => {
ipcRenderer.send("cli-render-message", message);
},
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
const listener = (_event: unknown, value: number | null) => callback(value);
ipcRenderer.on("countdown-overlay-value", listener);
Expand Down
Loading
Loading