Skip to content

feat: add --headless mode + Unix-socket IPC for programmatic control#674

Open
Karanjot786 wants to merge 6 commits into
siddharthvaddem:mainfrom
Karanjot786:feature/headless-cli
Open

feat: add --headless mode + Unix-socket IPC for programmatic control#674
Karanjot786 wants to merge 6 commits into
siddharthvaddem:mainfrom
Karanjot786:feature/headless-cli

Conversation

@Karanjot786
Copy link
Copy Markdown

@Karanjot786 Karanjot786 commented Jun 1, 2026

Summary

Adds a --headless mode that boots openscreen without showing a window, plus a Unix-domain-socket NDJSON IPC server in the main process. Enables programmatic control by external tools — e.g. AI agent screen recording via MCP.

How it works

# Start openscreen without GUI
openscreen --headless --ipc-path=/tmp/openscreen.sock

# Drive it from any client over NDJSON
echo '{"id":"1","method":"recorder.start","params":{}}' | nc -U /tmp/openscreen.sock
# → {"id":"1","result":{"session_id":"2026-06-01-abc123","state":"recording","out_dir":"..."}}

IPC methods exposed

  • recorder.start — start a new recording (auto-picks first screen source)
  • recorder.stop — stop + return path to .webm + .cursor.json sidecar
  • recorder.status — poll current state
  • recorder.cleanup — delete session files

Why

Closes #290 (CLI request). AI agents and automation tools can now drive openscreen headlessly without any GUI interaction.

Companion project: openscreen-mcp — an open-source Model Context Protocol server that exposes these 4 methods as MCP tools for Claude Code, Claude Desktop, and Cursor.

Files changed

File Type Purpose
electron/cli.ts New argv parser: --headless, --ipc-path, --out-dir, --retention-hours. Pure, no Electron imports. Unit-tested.
electron/ipc-socket-server.ts New Unix-socket NDJSON server, pluggable handlers. Unit-tested.
electron/recorder-bridge.ts New Maps 4 IPC socket methods to renderer recorder hooks. Unit-tested.
electron/preload.ts Modified Exposes onCliStartRecording + notification helpers for the headless recording flow
electron/main.ts Modified Adds --headless CLI dispatch, headless boot path, and IPC server startup
src/hooks/useScreenRecorder.ts Modified Adds cli-start-recording effect + notification callbacks for the main-process bridge
electron/ipc/handlers.ts Modified Exports setSelectedDesktopSource for headless source selection

Total net additions: ~400 LOC + unit tests. No changes to existing recorder, editor, or renderer UI. Existing GUI path is completely unchanged — the headless flag adds a parallel boot path.

Test plan

  • GUI path: npm run dev opens window, records, exports as before
  • --headless flag: process starts with no Dock icon, no visible window
  • All new code unit-tested (19 tests across cli, ipc-socket-server, recorder-bridge)
  • IPC socket: recorder.status returns {"state":"idle"} via nc -U
  • Full record/stop cycle: requires macOS screen-recording permission granted — see README section

Notes

  • ScreenCaptureKit permission must be granted to the openscreen .app bundle before headless mode. The GUI first-launch flow handles this.
  • HEADLESS=true env var also works (for npm run dev during development).
  • Upstream PR companion project uses MIT license, same as openscreen.

Co-authored-by: Claude Opus 4.7 noreply@anthropic.com

Summary by CodeRabbit

Release Notes

  • New Features

    • Headless mode support for CLI-driven screen recording without displaying the app window
    • IPC socket server enabling remote control of recordings via CLI commands (start, stop, status, cleanup)
    • CLI argument parsing for headless mode, socket path, output directory, and retention duration
  • Tests

    • Comprehensive test coverage for CLI parsing, IPC socket communication, and recording state management

Karanjot786 and others added 6 commits June 1, 2026 11:38
… activate + window-all-closed

- Parse argv via electron/cli.ts at the earliest top-level statement and
  set process.env.HEADLESS=true when --headless is present.
- Switch ./windows import to a dynamic import inside app.whenReady so the
  HEADLESS env var is set BEFORE windows.ts evaluates its top-level
  `const HEADLESS = process.env["HEADLESS"] === "true"`. Static imports
  get inlined above main.ts's top-level code by the Rollup bundler, so a
  dynamic import is required for env-var sequencing to work.
- Add headless boot path in app.whenReady that skips dock.show / tray /
  application menu / IPC handler registration, eagerly creates the HUD
  (which respects HEADLESS=true and stays hidden) so the renderer is
  alive to receive future cli-start-recording IPC, logs a clear
  "openscreen --headless: renderer loaded, ipc-path=..." line, and
  early-returns. TODO marker left for T3.3 (IpcSocketServer).
- Gate app.on('window-all-closed') so headless mode keeps the app alive.
- Gate app.on('activate') so dock-click does not re-show a hidden window
  in headless mode.

Verified via dev-mode launch with --headless --ipc-path: main process
stays alive, renderer process loaded (HUD with backgroundThrottling:
false), no Dock icon visible, log line emitted with correct ipc-path,
and global shortcut/tray code paths confirmed skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- electron/recorder-bridge.ts: bridge over cli-recording-started /
  cli-recording-finalized renderer channels; exposes start/stop/status/
  cleanup with promise lifecycle and disk hydration for bytes/click_count
- electron/__tests__/recorder-bridge.test.ts: 10 tests covering all states,
  timeout, cleanup, and cursor-sidecar parsing (all pass)
- electron/preload.ts: expose onCliStartRecording, notifyCliRecordingStarted,
  notifyCliRecordingFinalized on window.electronAPI contextBridge
- electron/electron-env.d.ts: TypeScript declarations for the three new API
  surface entries
- src/hooks/useScreenRecorder.ts: listen for cli-start-recording; call
  notifyCliRecordingStarted after renderer enters recording state;
  notifyCliRecordingFinalized after store-recorded-session resolves;
  add startRecordingRef to avoid stale closure in tray-style useEffect
- electron/main.ts: wire RecorderBridge + IpcSocketServer in headless path;
  register recorder.start/stop/status/cleanup over NDJSON Unix socket;
  auto-select first screen via selectSource JS bridge; register permission +
  display-media handlers; SIGINT/SIGTERM shutdown

store-recorded-session is ipcMain.handle (invoke-style), so the bridge cannot
use ipcOnce on it. Renderer instead calls notifyCliRecordingFinalized which
sends cli-recording-finalized back to main after the session is saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…aScript with direct setSelectedDesktopSource call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds complete headless CLI recording support: parses command-line arguments, establishes a Unix socket IPC server, implements a RecorderBridge state machine managing recording lifecycle, boots the app without GUI when headless, orchestrates recording commands through IPC, and integrates the renderer hook to respond to CLI triggers.

Changes

Headless CLI Recording System

Layer / File(s) Summary
CLI argument parsing and types
electron/cli.ts, electron/__tests__/cli.test.ts
CliOpts interface and parseArgs() function extract --headless, --ipc-path, --out-dir, and --retention-hours from argv; tests verify defaults, flag parsing, numeric coercion, and unknown-flag handling.
IPC socket server over Unix domain sockets
electron/ipc-socket-server.ts, electron/__tests__/ipc-socket-server.test.ts
Newline-delimited JSON-RPC protocol (IpcHandler, IpcRequest, IpcResponse types) and IpcSocketServer class with listen/close lifecycle, handler registration, and per-connection request parsing/dispatching; tests exercise JSON-RPC error codes for missing handlers and parse failures.
RecorderBridge state machine for recording lifecycle
electron/recorder-bridge.ts, electron/__tests__/recorder-bridge.test.ts
RecorderBridge manages idle/recording/encoding state, handles start/stop acknowledgements from renderer via IPC (10s/30s timeouts), computes output paths, parses cursor telemetry for click counts, hydrates video bytes, and validates cleanup artifacts; test harness with fake WebContents/IPC and injectable time verifies state transitions, timing, and error paths.
Main process headless initialization and recording orchestration
electron/main.ts
Early CLI parsing and HEADLESS env-var setting before windows module loads; dynamic module loader; app lifecycle hooks bypass quit/re-show in headless; full headless boot defers windows import, configures permission/screen-capture handlers, registers IPC handlers, creates HUD, instantiates RecorderBridge and IpcSocketServer, registers recorder IPC commands, starts socket listening, and wires SIGINT/SIGTERM shutdown.
Renderer hook integration for CLI-triggered recording
src/hooks/useScreenRecorder.ts
Hook tracks CLI-initiated recording IDs, maintains stable startRecordingRef for external callbacks, subscribes to CLI start-recording event to update cursorCaptureMode and immediately start recording (bypassing countdown), notifies main process of CLI start with recordingId, and extends finalization to notify main process with artifact paths and duration.
Type definitions, IPC bridge, and handlers
electron/electron-env.d.ts, electron/preload.ts, electron/ipc/handlers.ts, .gitignore
electron-env extends window.electronAPI with CLI recording listener/notification methods; preload exposes contextBridge forwarding; new setSelectedDesktopSource handler updates cached source metadata; .gitignore excludes NOTES-HEADLESS.md.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • siddharthvaddem/openscreen#457: CLI recording flow now passes recordingId to recording state and cursor telemetry handling, which aligns with recording-id–scoped telemetry buffering and discard/store isolation.
  • siddharthvaddem/openscreen#217: Both PRs modify recording control flow in useScreenRecorder.ts to handle different recording entrypoints and state transitions.
  • siddharthvaddem/openscreen#460: CLI-driven start/stop bypasses the countdown overlay flow implemented in that PR, affecting the same recording-start orchestration logic.

Suggested reviewers

  • siddharthvaddem
  • FabLrc

Poem

🎥 no GUI, just sockets deep
record headless while servers sleep
state machine's got that idle groove
click_count parsing JSON moves
CLI flows where countdown dies—
automation finally wise ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly summarizes the main change: adding headless mode and Unix-socket IPC for programmatic control of openscreen.
Description check ✅ Passed Description comprehensively covers purpose, motivation, implementation details, test plan, and related files with clear examples and table formatting.
Linked Issues check ✅ Passed PR fully addresses #290 by implementing a CLI tool with headless mode and NDJSON IPC for programmatic screen recording control without GUI interaction.
Out of Scope Changes check ✅ Passed All changes are scoped to headless mode implementation and IPC infrastructure; no unrelated modifications to existing recorder, editor, or renderer UI code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b53b4efaea

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +411 to +414
if (cliInitiatedRecordingIds.current.has(activeRecordingId)) {
cliInitiatedRecordingIds.current.delete(activeRecordingId);
const cursorTelemetryPath = result.path ? `${result.path}.cursor.json` : undefined;
window.electronAPI?.notifyCliRecordingFinalized?.({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Notify CLI finalization from native recorder paths

When native capture is available on macOS or Windows, recorder.stop sends stop-recording-from-tray, which takes the native finalizers instead of the browser finalizeRecording path. Since cli-recording-finalized is only emitted here, the native stop can save the file and switch state locally but RecorderBridge.stop() never sees the completion and waits until its 30s timeout, making the headless start/stop cycle fail on those platforms. Please emit the same CLI finalization notification from the native finalize paths as well.

Useful? React with 👍 / 👎.

Comment thread electron/main.ts
ipcMain.once(channel, wrapped);
return () => ipcMain.removeListener(channel, wrapped);
},
recordingsDir: RECORDINGS_DIR,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor the requested output directory

When launched with --out-dir=/some/path, parseArgs stores the value but the headless recorder is still wired to RECORDINGS_DIR, and the registered recording handlers also continue using the app user-data recordings directory. That means recorder.start reports/saves/cleans up under the default directory regardless of the flag, so callers cannot direct headless output as the CLI option implies. Please thread cliOpts.outDir through the recordings directory used by both the bridge and the storage handlers.

Useful? React with 👍 / 👎.

Comment on lines +720 to +726
const mode = payload?.cursorCaptureMode;
if (mode === "editable-overlay" || mode === "system" || mode === "none") {
setCursorCaptureMode(mode as CursorCaptureMode);
}
void (async () => {
try {
await startRecordingRef.current();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply the requested cursor mode before starting

For recorder.start calls that pass cursorCaptureMode: "system" or "none", this state update is asynchronous and startRecordingRef.current() runs immediately with the previous render's cursorCaptureMode value, usually the default editable-overlay. As a result the API accepts the parameter but starts with the wrong cursor capture behavior for that first recording. Please pass the requested mode directly into the start path or keep it in a ref that is updated before starting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/hooks/useScreenRecorder.ts (1)

522-530: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Native finalize paths never notify the CLI bridge.

The web-media finalize flow emits notifyCliRecordingFinalized, but the native Windows/macOS finalize flows skip it and go straight to switchToEditor(). In headless mode that means RecorderBridge.stop() can wait the full 30s and then fail even though the recording actually finished.

Also applies to: 622-629

🤖 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 `@src/hooks/useScreenRecorder.ts` around lines 522 - 530, The native finalize
paths (in the block that calls clearNativeRecordingState(),
window.electronAPI.setCurrentRecordingSession / setCurrentVideoPath, then
window.electronAPI.switchToEditor()) do not emit the same
notifyCliRecordingFinalized event used by the web-media flow; update both native
finalize blocks (the one using clearNativeRecordingState() and the second
similar block later) to call the CLI bridge notifier (e.g.
window.electronAPI.notifyCliRecordingFinalized or the existing
notifyCliRecordingFinalized helper) with the finalized session/path before
calling switchToEditor(), ensuring the CLI is informed in headless mode.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@electron/__tests__/recorder-bridge.test.ts`:
- Around line 179-191: The test named "start times out if renderer never acks"
is misleading because it acknowledges the start request via
fakes.emit("cli-recording-started", ..., { recordingId: 7 }) and never exercises
the timeout branch; update the test to either rename it to reflect the actual
behavior (e.g., "start sends start message and resolves on ack") or change the
implementation to exercise the timeout path by using fake timers and advancing
them so RecorderBridge.start(...) hits its internal timeout; locate the test
using the RecorderBridge instance and the bridge.start call and either rename
the it(...) description or wrap the test with vitest/jest fake timers and
advance time to trigger the timeout instead of calling fakes.emit.

In `@electron/ipc-socket-server.ts`:
- Around line 28-31: The current listen() unconditionally unlinks this.sockPath
which may remove arbitrary files; instead, first lstat(this.sockPath) and only
unlink if the entry exists and stats.isSocket() is true. Modify the listen()
implementation (reference: function listen and this.sockPath) to: call fs.lstat
or fs.promises.lstat on this.sockPath, if lstat rejects with ENOENT return
silently, if it succeeds check stats.isSocket() and only then call fs.unlink;
for any other lstat errors rethrow or log so we don’t silently delete non-socket
files.

In `@electron/ipc/handlers.ts`:
- Around line 381-392: The setter setSelectedDesktopSource currently only
updates selectedDesktopSource and leaves selectedSource stale when passed null;
update the function so that when source === null you also clear/reset
selectedSource (e.g., set selectedSource = null or reset its fields:
id/name/display_id to empty strings and thumbnail/appIcon to null) so helper
readers like get-selected-source, getSelectedDisplay, and
getSelectedSourceBounds reflect the cleared state. Target the
setSelectedDesktopSource function and the selectedDesktopSource/selectedSource
symbols when making this change.

In `@electron/main.ts`:
- Around line 25-28: The main boot currently only checks cliOpts.headless from
parseArgs, so setting HEADLESS=true in the environment is ignored; update
main.ts to derive the effective headless flag from both sources (e.g., const
isHeadless = cliOpts.headless || process.env.HEADLESS === "true") and use
isHeadless for all headless branches instead of cliOpts.headless (or set
cliOpts.headless from process.env if you prefer), ensuring any code paths that
reference cliOpts.headless (created by parseArgs) are switched to the unified
isHeadless variable so environment-only HEADLESS=true is honored.
- Around line 595-607: The CLI flags --out-dir and --retention-hours are parsed
but not passed into the recorder; replace hardcoded RECORDINGS_DIR usage when
constructing RecorderBridge with the parsed CLI option (e.g., cliOpts.outDir or
outDir) and add a retentionHours (or retention_hours) property set from
cliOpts.retentionHours so the bridge knows where to store recordings and how
long to retain them; also update the other headless boot path that still uses
RECORDINGS_DIR (the second RecorderBridge/recording initialization referenced
around lines 625-638) to accept and forward the same outDir and retentionHours
values instead of the constant.
- Around line 589-638: The socket is opened before the HUD renderer is
guaranteed to be ready, allowing clients to call RecorderBridge.start() before
the renderer has mounted; delay calling IpcSocketServer.listen() (and emitting
the "ipc listening" log) until the HUD renderer signals readiness—e.g. await
mainWindow.webContents to finish loading with await new Promise(resolve =>
mainWindow.webContents.once('did-finish-load', resolve)) or wait for a
renderer-side IPC like ipcMain.once('hud.ready', ...)—then call ipc.listen() and
the console.log; keep all existing ipc.register(...) calls as-is but move the
final await ipc.listen() and log after the renderer-ready await so
RecorderBridge.start() (bridge.start) can't race the renderer mount created by
createHudOverlayWindow().

In `@electron/recorder-bridge.ts`:
- Around line 164-247: The stop method currently ignores StopParams.session_id;
update stop(_params: StopParams = {}) to validate the optional session_id: if
params.session_id is provided and this.state === "idle" then only return
this.latestStopResult when its session_id equals params.session_id, otherwise
throw (same RPC error shape); if params.session_id is provided and this.state
!== "idle" ensure it matches this.currentSessionId before proceeding and throw
if not; use the validated session id when computing sessionId for the StopResult
(fall back to this.currentSessionId when params.session_id is undefined) and
keep existing behavior for clearing this.currentSessionId, pendingStop and
setting latestStopResult. References: stop, StopParams, _params.session_id,
latestStopResult, currentSessionId, pendingStop.

In `@src/hooks/useScreenRecorder.ts`:
- Around line 719-727: The CLI callback should use the provided
payload.cursorCaptureMode directly when starting to avoid a stale
cursorCaptureMode; instead of relying on setCursorCaptureMode to synchronously
update state before invoking startRecordingRef.current, pass the mode into the
start call (or call a variant like startRecordingWithMode) so startRecording
uses the CLI mode immediately; update the onCliStartRecording handler in
useScreenRecorder.ts (the cliCleanup callback that currently calls
setCursorCaptureMode and startRecordingRef.current) to pass the mode into
startRecordingRef.current (or adapt startRecordingRef/current implementation to
accept an explicit CursorCaptureMode) while still calling setCursorCaptureMode
for UI state.

---

Outside diff comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 522-530: The native finalize paths (in the block that calls
clearNativeRecordingState(), window.electronAPI.setCurrentRecordingSession /
setCurrentVideoPath, then window.electronAPI.switchToEditor()) do not emit the
same notifyCliRecordingFinalized event used by the web-media flow; update both
native finalize blocks (the one using clearNativeRecordingState() and the second
similar block later) to call the CLI bridge notifier (e.g.
window.electronAPI.notifyCliRecordingFinalized or the existing
notifyCliRecordingFinalized helper) with the finalized session/path before
calling switchToEditor(), ensuring the CLI is informed in headless mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4471d18e-09a3-47e3-bd1f-afa40084fe37

📥 Commits

Reviewing files that changed from the base of the PR and between d2dd44a and b53b4ef.

📒 Files selected for processing (12)
  • .gitignore
  • electron/__tests__/cli.test.ts
  • electron/__tests__/ipc-socket-server.test.ts
  • electron/__tests__/recorder-bridge.test.ts
  • electron/cli.ts
  • electron/electron-env.d.ts
  • electron/ipc-socket-server.ts
  • electron/ipc/handlers.ts
  • electron/main.ts
  • electron/preload.ts
  • electron/recorder-bridge.ts
  • src/hooks/useScreenRecorder.ts

Comment on lines +179 to +191
it("start times out if renderer never acks", async () => {
fakes = makeFakes();
const bridge = new RecorderBridge(fakes.deps);
// Patch the bridge's internal timeout via a fast race: invoke start with a
// stubbed internal that triggers timeout by manipulating the listener map.
// Simpler: just verify the message is sent. Timeout path is exercised by
// production code; mocking timers here would require vitest fake timers.
const p = bridge.start({}).catch((e) => e);
expect(fakes.sent[0]?.channel).toBe("cli-start-recording");
// Make this test fast by rejecting via finalize-shaped channel won't help;
// just resolve to keep test non-flaky.
fakes.emit("cli-recording-started", {}, { recordingId: 7 });
await p;
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

This test name is lying a bit.

It says timeout, but it acks the start request on Line 190 and never exercises the timeout branch. Either rename it or switch to fake timers so the real timeout path is covered.

🤖 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/__tests__/recorder-bridge.test.ts` around lines 179 - 191, The test
named "start times out if renderer never acks" is misleading because it
acknowledges the start request via fakes.emit("cli-recording-started", ..., {
recordingId: 7 }) and never exercises the timeout branch; update the test to
either rename it to reflect the actual behavior (e.g., "start sends start
message and resolves on ack") or change the implementation to exercise the
timeout path by using fake timers and advancing them so
RecorderBridge.start(...) hits its internal timeout; locate the test using the
RecorderBridge instance and the bridge.start call and either rename the it(...)
description or wrap the test with vitest/jest fake timers and advance time to
trigger the timeout instead of calling fakes.emit.

Comment on lines +28 to +31
async listen(): Promise<void> {
await unlink(this.sockPath).catch(() => {
/* not present, fine */
});
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 | ⚡ Quick win

Only unlink an existing socket, not whatever lives at sockPath.

This deletes any pre-existing filesystem entry at that path before bind. If --ipc-path is mistyped to a real file, we silently remove it, which is kinda cursed for a CLI flag.

🤖 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/ipc-socket-server.ts` around lines 28 - 31, The current listen()
unconditionally unlinks this.sockPath which may remove arbitrary files; instead,
first lstat(this.sockPath) and only unlink if the entry exists and
stats.isSocket() is true. Modify the listen() implementation (reference:
function listen and this.sockPath) to: call fs.lstat or fs.promises.lstat on
this.sockPath, if lstat rejects with ENOENT return silently, if it succeeds
check stats.isSocket() and only then call fs.unlink; for any other lstat errors
rethrow or log so we don’t silently delete non-socket files.

Comment thread electron/ipc/handlers.ts
Comment on lines +381 to +392
export function setSelectedDesktopSource(source: DesktopCapturerSource | null): void {
selectedDesktopSource = source;
if (source !== null) {
selectedSource = {
id: source.id,
name: source.name,
display_id: source.display_id ?? "",
thumbnail: null,
appIcon: null,
};
}
}
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

Clear both source caches on null.

Passing null only clears selectedDesktopSource; selectedSource stays stale. That leaves get-selected-source, getSelectedDisplay(), and getSelectedSourceBounds() out of sync with the actual selection state. lowkey risky state leak for a helper that explicitly accepts null.

nit: cleaner fix
 export function setSelectedDesktopSource(source: DesktopCapturerSource | null): void {
 	selectedDesktopSource = source;
-	if (source !== null) {
-		selectedSource = {
-			id: source.id,
-			name: source.name,
-			display_id: source.display_id ?? "",
-			thumbnail: null,
-			appIcon: null,
-		};
-	}
+	if (source === null) {
+		selectedSource = null;
+		return;
+	}
+	selectedSource = {
+		id: source.id,
+		name: source.name,
+		display_id: source.display_id ?? "",
+		thumbnail: null,
+		appIcon: null,
+	};
 }
🤖 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/ipc/handlers.ts` around lines 381 - 392, The setter
setSelectedDesktopSource currently only updates selectedDesktopSource and leaves
selectedSource stale when passed null; update the function so that when source
=== null you also clear/reset selectedSource (e.g., set selectedSource = null or
reset its fields: id/name/display_id to empty strings and thumbnail/appIcon to
null) so helper readers like get-selected-source, getSelectedDisplay, and
getSelectedSourceBounds reflect the cleared state. Target the
setSelectedDesktopSource function and the selectedDesktopSource/selectedSource
symbols when making this change.

Comment thread electron/main.ts
Comment on lines +25 to +28
const cliOpts = parseArgs(process.argv.slice(2));
if (cliOpts.headless) {
process.env.HEADLESS = "true";
}
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 | ⚡ Quick win

Honor HEADLESS=true in main boot too.

Right now every headless branch in this file keys off cliOpts.headless, so setting only the env var never takes the app down the headless path. The window module may see process.env.HEADLESS, but main.ts itself still behaves like GUI mode.

🤖 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 25 - 28, The main boot currently only checks
cliOpts.headless from parseArgs, so setting HEADLESS=true in the environment is
ignored; update main.ts to derive the effective headless flag from both sources
(e.g., const isHeadless = cliOpts.headless || process.env.HEADLESS === "true")
and use isHeadless for all headless branches instead of cliOpts.headless (or set
cliOpts.headless from process.env if you prefer), ensuring any code paths that
reference cliOpts.headless (created by parseArgs) are switched to the unified
isHeadless variable so environment-only HEADLESS=true is honored.

Comment thread electron/main.ts
Comment on lines +589 to +638
mainWindow = createHudOverlayWindow();

const { IpcSocketServer } = await import("./ipc-socket-server.js");
const { RecorderBridge } = await import("./recorder-bridge.js");
const { desktopCapturer } = await import("electron");

const bridge = new RecorderBridge({
webContents: mainWindow.webContents,
ipcOn: (channel, listener) => {
ipcMain.on(channel, (event, ...args) => listener(event, ...args));
},
ipcOnce: (channel, listener) => {
const wrapped = (event: Electron.IpcMainEvent, ...args: unknown[]) =>
listener(event, ...args);
ipcMain.once(channel, wrapped);
return () => ipcMain.removeListener(channel, wrapped);
},
recordingsDir: RECORDINGS_DIR,
});
bridgeRef = bridge;

// Auto-pick the first screen source before any cli-start-recording fires.
// Sets module-level state in handlers.ts directly so that
// setDisplayMediaRequestHandler resolves the correct source without
// going through executeJavaScript (which would inject an OS-controlled
// string into an eval, risking injection and fragility).
async function autoSelectFirstScreen(): Promise<void> {
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: 0, height: 0 },
});
const first = sources[0];
if (!first) throw new Error("no screen sources available");
setSelectedDesktopSource(first);
}

const ipc = new IpcSocketServer(cliOpts.ipcPath);
ipc.register("recorder.start", async (params) => {
await autoSelectFirstScreen();
return bridge.start((params as Parameters<typeof bridge.start>[0]) ?? {});
});
ipc.register("recorder.stop", (params) =>
bridge.stop((params as Parameters<typeof bridge.stop>[0]) ?? {}),
);
ipc.register("recorder.status", () => Promise.resolve(bridge.status()));
ipc.register("recorder.cleanup", (params) =>
bridge.cleanup(params as Parameters<typeof bridge.cleanup>[0]),
);
await ipc.listen();
console.log(`openscreen --headless: renderer loaded, ipc listening on ${cliOpts.ipcPath}`);
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 | ⚡ Quick win

Don’t open the socket until the renderer is actually ready.

RecorderBridge.start() depends on the HUD renderer having mounted and subscribed to onCliStartRecording, but we start listening immediately after creating the window. A client that connects as soon as the “ipc listening” log appears can race this and hit the 10s start timeout for no real reason.

🤖 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 589 - 638, The socket is opened before the HUD
renderer is guaranteed to be ready, allowing clients to call
RecorderBridge.start() before the renderer has mounted; delay calling
IpcSocketServer.listen() (and emitting the "ipc listening" log) until the HUD
renderer signals readiness—e.g. await mainWindow.webContents to finish loading
with await new Promise(resolve => mainWindow.webContents.once('did-finish-load',
resolve)) or wait for a renderer-side IPC like ipcMain.once('hud.ready',
...)—then call ipc.listen() and the console.log; keep all existing
ipc.register(...) calls as-is but move the final await ipc.listen() and log
after the renderer-ready await so RecorderBridge.start() (bridge.start) can't
race the renderer mount created by createHudOverlayWindow().

Comment thread electron/main.ts
Comment on lines +595 to +607
const bridge = new RecorderBridge({
webContents: mainWindow.webContents,
ipcOn: (channel, listener) => {
ipcMain.on(channel, (event, ...args) => listener(event, ...args));
},
ipcOnce: (channel, listener) => {
const wrapped = (event: Electron.IpcMainEvent, ...args: unknown[]) =>
listener(event, ...args);
ipcMain.once(channel, wrapped);
return () => ipcMain.removeListener(channel, wrapped);
},
recordingsDir: RECORDINGS_DIR,
});
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

--out-dir and --retention-hours are parsed, but never applied.

Headless recording is still hardwired to RECORDINGS_DIR on Line 606, and nothing in this boot path uses cliOpts.retentionHours at all. So the CLI surface advertised by this PR isn’t actually honored yet.

Also applies to: 625-638

🤖 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 595 - 607, The CLI flags --out-dir and
--retention-hours are parsed but not passed into the recorder; replace hardcoded
RECORDINGS_DIR usage when constructing RecorderBridge with the parsed CLI option
(e.g., cliOpts.outDir or outDir) and add a retentionHours (or retention_hours)
property set from cliOpts.retentionHours so the bridge knows where to store
recordings and how long to retain them; also update the other headless boot path
that still uses RECORDINGS_DIR (the second RecorderBridge/recording
initialization referenced around lines 625-638) to accept and forward the same
outDir and retentionHours values instead of the constant.

Comment on lines +164 to +247
async stop(_params: StopParams = {}): Promise<StopResult> {
if (this.state === "idle") {
if (this.latestStopResult) return this.latestStopResult;
throw Object.assign(new Error("no active recording"), { code: -32000 });
}
if (this.pendingStop) {
throw Object.assign(new Error("a stop is already in flight"), { code: -32000 });
}

this.state = "encoding";

const stopPromise = new Promise<StopRecordedPayload>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingStop = null;
this.state = "idle";
this.currentSessionId = null;
reject(new Error("timed out waiting for renderer to finalize recording"));
}, 30_000);
this.pendingStop = { resolve, reject, timer };
});

this.deps.webContents.send("stop-recording-from-tray");

const payload = await stopPromise;

const sessionId = this.currentSessionId ?? "unknown";
const screenVideoPath =
payload.screenVideoPath ??
path.join(this.deps.recordingsDir, `${RECORDING_FILE_PREFIX}${sessionId}${VIDEO_EXT}`);
const cursorLogPath =
payload.cursorTelemetryPath ?? `${screenVideoPath}${CURSOR_SIDECAR_SUFFIX}`;
const durationMs = payload.durationMs ?? (this.startedAt ? this.now() - this.startedAt : 0);

const result: StopResult = {
session_id: sessionId,
path: screenVideoPath,
duration_ms: durationMs,
cursor_log_path: cursorLogPath,
click_count: 0,
bytes: 0,
};

// Hydrate bytes from disk.
try {
const st = await stat(screenVideoPath);
result.bytes = st.size;
} catch {
/* ignore — file may not exist if discard path was taken */
}

// Hydrate click_count from cursor.json sidecar.
try {
const raw = await readFile(cursorLogPath, "utf8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
result.click_count = parsed.filter(
(s: unknown) =>
typeof s === "object" &&
s !== null &&
"type" in (s as Record<string, unknown>) &&
(s as Record<string, unknown>).type === "click",
).length;
} else if (parsed && typeof parsed === "object") {
const obj = parsed as { clicks?: unknown; samples?: unknown };
if (Array.isArray(obj.clicks)) {
result.click_count = obj.clicks.length;
} else if (Array.isArray(obj.samples)) {
result.click_count = (obj.samples as unknown[]).filter(
(s) =>
typeof s === "object" &&
s !== null &&
"type" in (s as Record<string, unknown>) &&
(s as Record<string, unknown>).type === "click",
).length;
}
}
} catch {
/* ignore — sidecar may not exist */
}

this.latestStopResult = result;
this.currentSessionId = null;
this.startedAt = null;
return result;
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 | ⚡ Quick win

Validate session_id here instead of ignoring it.

StopParams advertises session scoping, but this implementation never checks it. In practice that means a caller can stop whichever session happens to be active, or get back latestStopResult for a different session once idle. That's a pretty rough contract for programmatic control.

🤖 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/recorder-bridge.ts` around lines 164 - 247, The stop method
currently ignores StopParams.session_id; update stop(_params: StopParams = {})
to validate the optional session_id: if params.session_id is provided and
this.state === "idle" then only return this.latestStopResult when its session_id
equals params.session_id, otherwise throw (same RPC error shape); if
params.session_id is provided and this.state !== "idle" ensure it matches
this.currentSessionId before proceeding and throw if not; use the validated
session id when computing sessionId for the StopResult (fall back to
this.currentSessionId when params.session_id is undefined) and keep existing
behavior for clearing this.currentSessionId, pendingStop and setting
latestStopResult. References: stop, StopParams, _params.session_id,
latestStopResult, currentSessionId, pendingStop.

Comment on lines +719 to +727
cliCleanup = window.electronAPI.onCliStartRecording((payload) => {
const mode = payload?.cursorCaptureMode;
if (mode === "editable-overlay" || mode === "system" || mode === "none") {
setCursorCaptureMode(mode as CursorCaptureMode);
}
void (async () => {
try {
await startRecordingRef.current();
const id = recordingId.current;
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 | ⚡ Quick win

Use the CLI-provided cursor mode directly for this start.

setCursorCaptureMode(mode) won’t synchronously update the cursorCaptureMode captured by startRecording, so this immediate call can still start with the old mode. If IPC asks for "system" right after the UI was on "editable-overlay", we’ll likely record with the stale setting.

🤖 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 `@src/hooks/useScreenRecorder.ts` around lines 719 - 727, The CLI callback
should use the provided payload.cursorCaptureMode directly when starting to
avoid a stale cursorCaptureMode; instead of relying on setCursorCaptureMode to
synchronously update state before invoking startRecordingRef.current, pass the
mode into the start call (or call a variant like startRecordingWithMode) so
startRecording uses the CLI mode immediately; update the onCliStartRecording
handler in useScreenRecorder.ts (the cliCleanup callback that currently calls
setCursorCaptureMode and startRecordingRef.current) to pass the mode into
startRecordingRef.current (or adapt startRecordingRef/current implementation to
accept an explicit CursorCaptureMode) while still calling setCursorCaptureMode
for UI state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: can openscreen provide a CLI tool

1 participant