-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Add local automation API for scripted recording control #817
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| vi.mock("electron", () => ({ | ||
| app: { getName: () => "Recordly" }, | ||
| BrowserWindow: class { | ||
| static getAllWindows() { | ||
| return []; | ||
| } | ||
| }, | ||
| desktopCapturer: { getSources: vi.fn() }, | ||
| })); | ||
|
|
||
| describe("pickSource", () => { | ||
| const screen = { id: "screen:0:0", name: "Entire screen", sourceType: "screen" as const }; | ||
| const chromeWindow = { | ||
| id: "window:1:0", | ||
| name: "Chrome - New Tab", | ||
| sourceType: "window" as const, | ||
| }; | ||
| const codeWindow = { id: "window:2:0", name: "VS Code", sourceType: "window" as const }; | ||
| const sources = [screen, chromeWindow, codeWindow]; | ||
|
|
||
| it("matches an exact sourceId first", async () => { | ||
| const { pickSource } = await import("./automationServer"); | ||
| expect(pickSource(sources, { sourceId: "window:2:0" })).toBe(codeWindow); | ||
| }); | ||
|
|
||
| it("matches sourceName as a case-insensitive substring", async () => { | ||
| const { pickSource } = await import("./automationServer"); | ||
| expect(pickSource(sources, { sourceName: "chrome" })).toBe(chromeWindow); | ||
| }); | ||
|
|
||
| it("falls back to the first screen source when nothing matches", async () => { | ||
| const { pickSource } = await import("./automationServer"); | ||
| expect(pickSource(sources, { sourceName: "does-not-exist" })).toBe(screen); | ||
| }); | ||
|
|
||
| it("returns undefined when there is no screen fallback either", async () => { | ||
| const { pickSource } = await import("./automationServer"); | ||
| expect(pickSource([chromeWindow, codeWindow], {})).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,213 @@ | ||||||||||||||||||||||||||||||||||||||
| import http from "node:http"; | ||||||||||||||||||||||||||||||||||||||
| import path from "node:path"; | ||||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from "node:url"; | ||||||||||||||||||||||||||||||||||||||
| import { app, BrowserWindow, desktopCapturer } from "electron"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||
| windowsCaptureTargetPath, | ||||||||||||||||||||||||||||||||||||||
| windowsNativeCaptureActive, | ||||||||||||||||||||||||||||||||||||||
| windowsPendingVideoPath, | ||||||||||||||||||||||||||||||||||||||
| } from "./ipc/state"; | ||||||||||||||||||||||||||||||||||||||
| import type { NativeMacRecordingOptions, SelectedSource } from "./ipc/types"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const PORT = Number(process.env.RECORDLY_AUTOMATION_PORT ?? 17373); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Local-only control API for external tools (CI, AI coding agents, custom | ||||||||||||||||||||||||||||||||||||||
| // scripts) that need to start/stop a recording without driving the UI. | ||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||
| // Binds to 127.0.0.1 only. Recording is driven through a hidden window that | ||||||||||||||||||||||||||||||||||||||
| // runs the app's own preload, so every command goes through the exact same | ||||||||||||||||||||||||||||||||||||||
| // IPC pipeline the UI uses (source selection -> native capture -> cursor | ||||||||||||||||||||||||||||||||||||||
| // telemetry). No recording logic is duplicated here. | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export type AutomationSource = { | ||||||||||||||||||||||||||||||||||||||
| id: string; | ||||||||||||||||||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||||||||||||||||||
| sourceType: "screen" | "window"; | ||||||||||||||||||||||||||||||||||||||
| display_id?: string; | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function pickSource( | ||||||||||||||||||||||||||||||||||||||
| sources: AutomationSource[], | ||||||||||||||||||||||||||||||||||||||
| body: { sourceId?: unknown; sourceName?: unknown }, | ||||||||||||||||||||||||||||||||||||||
| ): AutomationSource | undefined { | ||||||||||||||||||||||||||||||||||||||
| if (typeof body.sourceId === "string") { | ||||||||||||||||||||||||||||||||||||||
| const byId = sources.find((source) => source.id === body.sourceId); | ||||||||||||||||||||||||||||||||||||||
| if (byId) return byId; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (typeof body.sourceName === "string") { | ||||||||||||||||||||||||||||||||||||||
| const wanted = body.sourceName.toLowerCase(); | ||||||||||||||||||||||||||||||||||||||
| const byName = sources.find((source) => source.name.toLowerCase().includes(wanted)); | ||||||||||||||||||||||||||||||||||||||
| if (byName) return byName; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return sources.find((source) => source.sourceType === "screen"); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| let clientWindow: BrowserWindow | null = null; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function getClientWindow(): Promise<BrowserWindow> { | ||||||||||||||||||||||||||||||||||||||
| if (clientWindow && !clientWindow.isDestroyed()) { | ||||||||||||||||||||||||||||||||||||||
| return clientWindow; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const electronDir = path.dirname(fileURLToPath(import.meta.url)); | ||||||||||||||||||||||||||||||||||||||
| clientWindow = new BrowserWindow({ | ||||||||||||||||||||||||||||||||||||||
| show: false, | ||||||||||||||||||||||||||||||||||||||
| webPreferences: { | ||||||||||||||||||||||||||||||||||||||
| preload: path.join(electronDir, "preload.mjs"), | ||||||||||||||||||||||||||||||||||||||
| nodeIntegration: false, | ||||||||||||||||||||||||||||||||||||||
| contextIsolation: true, | ||||||||||||||||||||||||||||||||||||||
| webSecurity: false, | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| await clientWindow.loadURL("data:text/html,<html><body></body></html>"); | ||||||||||||||||||||||||||||||||||||||
| return clientWindow; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function invokeClient<T>(expression: string): Promise<T> { | ||||||||||||||||||||||||||||||||||||||
| const win = await getClientWindow(); | ||||||||||||||||||||||||||||||||||||||
| return (await win.webContents.executeJavaScript(`window.electronAPI.${expression}`)) as T; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function listSources(): Promise<AutomationSource[]> { | ||||||||||||||||||||||||||||||||||||||
| const sources = await desktopCapturer.getSources({ | ||||||||||||||||||||||||||||||||||||||
| types: ["screen", "window"], | ||||||||||||||||||||||||||||||||||||||
| thumbnailSize: { width: 0, height: 0 }, | ||||||||||||||||||||||||||||||||||||||
| fetchWindowIcons: false, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| const ownNames = new Set( | ||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||
| app.getName(), | ||||||||||||||||||||||||||||||||||||||
| ...BrowserWindow.getAllWindows().flatMap((win) => { | ||||||||||||||||||||||||||||||||||||||
| const title = win.getTitle().trim(); | ||||||||||||||||||||||||||||||||||||||
| return title ? [title] : []; | ||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||
| ].filter(Boolean), | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return sources.flatMap((source): AutomationSource[] => { | ||||||||||||||||||||||||||||||||||||||
| const isScreen = source.id.startsWith("screen:"); | ||||||||||||||||||||||||||||||||||||||
| if (!isScreen && ownNames.has(source.name)) { | ||||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return [ | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| id: source.id, | ||||||||||||||||||||||||||||||||||||||
| name: source.name, | ||||||||||||||||||||||||||||||||||||||
| sourceType: isScreen ? "screen" : "window", | ||||||||||||||||||||||||||||||||||||||
| display_id: source.display_id, | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function handleStart(body: Record<string, unknown>) { | ||||||||||||||||||||||||||||||||||||||
| const sources = await listSources(); | ||||||||||||||||||||||||||||||||||||||
| const picked = pickSource(sources, body); | ||||||||||||||||||||||||||||||||||||||
| if (!picked) { | ||||||||||||||||||||||||||||||||||||||
| return { success: false, message: "No recordable source found" }; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const selected: SelectedSource = picked; | ||||||||||||||||||||||||||||||||||||||
| await invokeClient(`selectSource(${JSON.stringify(selected)})`); | ||||||||||||||||||||||||||||||||||||||
| const options: NativeMacRecordingOptions = { | ||||||||||||||||||||||||||||||||||||||
| capturesSystemAudio: Boolean(body.systemAudio), | ||||||||||||||||||||||||||||||||||||||
| capturesMicrophone: Boolean(body.microphone), | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
| const result = await invokeClient<{ success: boolean; message?: string }>( | ||||||||||||||||||||||||||||||||||||||
| `startNativeScreenRecording(${JSON.stringify(selected)}, ${JSON.stringify(options)})`, | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| if (result.success) { | ||||||||||||||||||||||||||||||||||||||
| // Starts cursor telemetry capture, which powers the editor's auto-zoom suggestions. | ||||||||||||||||||||||||||||||||||||||
| await invokeClient("setRecordingState(true)"); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return { ...result, source: { id: picked.id, name: picked.name } }; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function handleStop() { | ||||||||||||||||||||||||||||||||||||||
| const result = await invokeClient<{ | ||||||||||||||||||||||||||||||||||||||
| success: boolean; | ||||||||||||||||||||||||||||||||||||||
| path?: string; | ||||||||||||||||||||||||||||||||||||||
| message?: string; | ||||||||||||||||||||||||||||||||||||||
| }>("stopNativeScreenRecording()"); | ||||||||||||||||||||||||||||||||||||||
| await invokeClient("setRecordingState(false)"); | ||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+128
to
+135
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Update telemetry only after a successful native stop. If Proposed fix }>("stopNativeScreenRecording()");
- await invokeClient("setRecordingState(false)");
+ if (result.success) {
+ await invokeClient("setRecordingState(false)");
+ }
return result;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function sendJson(res: http.ServerResponse, status: number, payload: unknown) { | ||||||||||||||||||||||||||||||||||||||
| res.writeHead(status, { "content-type": "application/json" }); | ||||||||||||||||||||||||||||||||||||||
| res.end(JSON.stringify(payload)); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> { | ||||||||||||||||||||||||||||||||||||||
| return new Promise((resolve, reject) => { | ||||||||||||||||||||||||||||||||||||||
| let raw = ""; | ||||||||||||||||||||||||||||||||||||||
| req.on("data", (chunk) => { | ||||||||||||||||||||||||||||||||||||||
| raw += chunk; | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+143
to
+148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Use a bounded, byte-safe request-body parser. Line 147 converts each 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| req.on("end", () => { | ||||||||||||||||||||||||||||||||||||||
| if (!raw) { | ||||||||||||||||||||||||||||||||||||||
| resolve({}); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| resolve(JSON.parse(raw)); | ||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||
| reject(error); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+154
to
+157
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return HTTP 400 for invalid JSON bodies. A 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| req.on("error", reject); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| if (req.method === "GET" && req.url === "/health") { | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 200, { ok: true, app: app.getName() }); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (req.method === "GET" && req.url === "/sources") { | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 200, { success: true, sources: await listSources() }); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (req.method === "GET" && req.url === "/recording/status") { | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 200, { | ||||||||||||||||||||||||||||||||||||||
| success: true, | ||||||||||||||||||||||||||||||||||||||
| recording: windowsNativeCaptureActive, | ||||||||||||||||||||||||||||||||||||||
| targetPath: windowsCaptureTargetPath, | ||||||||||||||||||||||||||||||||||||||
| pendingPath: windowsPendingVideoPath, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (req.method === "POST" && req.url === "/recording/start") { | ||||||||||||||||||||||||||||||||||||||
| const body = await readJsonBody(req); | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 200, await handleStart(body)); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (req.method === "POST" && req.url === "/recording/stop") { | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 200, await handleStop()); | ||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 404, { success: false, message: "Not found" }); | ||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||
| sendJson(res, 500, { | ||||||||||||||||||||||||||||||||||||||
| success: false, | ||||||||||||||||||||||||||||||||||||||
| message: error instanceof Error ? error.message : String(error), | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function startAutomationServer(): void { | ||||||||||||||||||||||||||||||||||||||
| const server = http.createServer((req, res) => { | ||||||||||||||||||||||||||||||||||||||
| void handleRequest(req, res); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| server.on("error", (error) => { | ||||||||||||||||||||||||||||||||||||||
| console.warn(`[automation] server error: ${error.message}`); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| server.listen(PORT, "127.0.0.1", () => { | ||||||||||||||||||||||||||||||||||||||
| console.log( | ||||||||||||||||||||||||||||||||||||||
| `[automation] Recordly automation API on http://127.0.0.1:${PORT} (health/sources/recording/start|stop|status)`, | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+201
to
+210
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Require an automation credential for capture-control endpoints. Binding to loopback does not identify the caller. Any local process that can connect can list window titles, start capture through Recordly’s recording permission, stop capture, and obtain output paths. Require a per-launch or explicitly configured bearer token before routing 🧰 Tools🪛 ast-grep (0.45.1)[warning] 201-203: Use (https-protocol-missing-typescript) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Electron 43 BrowserWindow webSecurity false behavior and whether preload scripts require it when the window loads a data URL.💡 Result:
In Electron, setting webSecurity to false in BrowserWindow webPreferences disables the renderer process's same-origin policy [1][2][3]. This change also automatically sets allowRunningInsecureContent to true, unless it has been explicitly configured otherwise by the user [1][4][2]. Because disabling webSecurity effectively removes critical security boundaries—allowing the execution of insecure code and potentially enabling cross-origin attacks—it is strongly recommended not to use this setting in production applications [2][3][5]. Regarding preload scripts, they do not require webSecurity to be set to false to function, even when the window loads a data URL [6]. Preload scripts operate in a privileged context that is injected before the web page content loads [6]. They retain their functionality and ability to access Node.js modules or Electron APIs (subject to your sandboxing and context isolation configuration) regardless of the webSecurity setting [5][6][7]. Loading a data URL is treated as a page load event, and the preload script will execute according to its configuration without needing to disable web security [6].
Citations:
🏁 Script executed:
Repository: webadderallorg/Recordly
Length of output: 50382
🏁 Script executed:
Repository: webadderallorg/Recordly
Length of output: 8045
Keep Chromium web security enabled.
This hidden window loads a static
data:document and uses fixed preload APIs. The preload does not requirewebSecurity: false. Remove this override.Proposed fix
webPreferences: { preload: path.join(electronDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, - webSecurity: false, },📝 Committable suggestion
🤖 Prompt for AI Agents