mirror of
https://github.com/runablehq/mini-browser.git
synced 2026-09-20 13:45:46 +08:00
feat: add record command
This commit is contained in:
@@ -11,4 +11,15 @@ await build({
|
||||
external: ["puppeteer-core"],
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/recorder-daemon.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
format: "esm",
|
||||
outfile: "dist/recorder-daemon.js",
|
||||
external: ["puppeteer-core"],
|
||||
})
|
||||
|
||||
console.log("Built dist/mb.js")
|
||||
console.log("Built dist/recorder-daemon.js")
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Flags } from "../lib/flags"
|
||||
import {
|
||||
readState,
|
||||
writeState,
|
||||
clearState,
|
||||
isProcessRunning,
|
||||
} from "../lib/recorder-state"
|
||||
|
||||
const DEFAULT_FPS = 30
|
||||
const DEFAULT_SCALE = 1
|
||||
|
||||
const getDaemonPath = () => {
|
||||
// In compiled mode, both mb.js and recorder-daemon.js are in dist/
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
return resolve(dirname(currentFile), "recorder-daemon.js")
|
||||
}
|
||||
|
||||
interface StartInput {
|
||||
path: string
|
||||
tab: number
|
||||
fps: number
|
||||
scale: number
|
||||
}
|
||||
|
||||
const start = async ({ path, tab, fps, scale }: StartInput) => {
|
||||
const existing = readState()
|
||||
if (existing && isProcessRunning(existing.pid)) {
|
||||
throw new Error(
|
||||
`Already recording to ${existing.path}. Run 'mb record stop' first.`
|
||||
)
|
||||
}
|
||||
|
||||
// Clear stale state if process is dead
|
||||
if (existing) clearState()
|
||||
|
||||
const outputPath = resolve(path)
|
||||
const ext = outputPath.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !["webm", "mp4", "gif"].includes(ext)) {
|
||||
throw new Error("Output file must end in .webm, .mp4, or .gif")
|
||||
}
|
||||
|
||||
if (fps <= 0) throw new Error("--fps must be a positive number")
|
||||
if (scale <= 0) throw new Error("--scale must be a positive number")
|
||||
|
||||
const config = { path: outputPath, tab, fps, scale }
|
||||
const daemonPath = getDaemonPath()
|
||||
|
||||
const child = spawn(process.execPath, [daemonPath], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
MB_RECORDER_CONFIG: JSON.stringify(config),
|
||||
},
|
||||
})
|
||||
|
||||
child.unref()
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error("Failed to spawn recorder daemon")
|
||||
}
|
||||
|
||||
writeState({
|
||||
pid: child.pid,
|
||||
path: outputPath,
|
||||
tab,
|
||||
fps,
|
||||
scale,
|
||||
startedAt: Date.now(),
|
||||
})
|
||||
|
||||
// Give daemon time to start and catch errors
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
|
||||
// Verify it's still running
|
||||
if (!isProcessRunning(child.pid)) {
|
||||
clearState()
|
||||
throw new Error("Recorder daemon failed to start. Is Chrome running?")
|
||||
}
|
||||
|
||||
console.log(`Recording to ${outputPath}`)
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
const state = readState()
|
||||
if (!state) {
|
||||
throw new Error("No recording in progress")
|
||||
}
|
||||
|
||||
if (!isProcessRunning(state.pid)) {
|
||||
clearState()
|
||||
throw new Error("Recording process died unexpectedly")
|
||||
}
|
||||
|
||||
// Send SIGTERM to daemon
|
||||
process.kill(state.pid, "SIGTERM")
|
||||
|
||||
// Wait for process to exit
|
||||
const maxWait = 10000
|
||||
const startTime = Date.now()
|
||||
while (isProcessRunning(state.pid) && Date.now() - startTime < maxWait) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
|
||||
if (isProcessRunning(state.pid)) {
|
||||
// Force kill if still running
|
||||
process.kill(state.pid, "SIGKILL")
|
||||
clearState()
|
||||
throw new Error("Recorder did not stop gracefully, force killed")
|
||||
}
|
||||
|
||||
clearState()
|
||||
console.log(state.path)
|
||||
}
|
||||
|
||||
const status = () => {
|
||||
const state = readState()
|
||||
if (!state || !isProcessRunning(state.pid)) {
|
||||
if (state) clearState()
|
||||
console.log("Not recording")
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - state.startedAt) / 1000).toFixed(1)
|
||||
console.log(`Recording to ${state.path} (${elapsed}s elapsed)`)
|
||||
}
|
||||
|
||||
export const record = async (args: string[], flags: Flags) => {
|
||||
const [subcommand, ...rest] = args
|
||||
|
||||
switch (subcommand) {
|
||||
case "start": {
|
||||
const path = rest[0]
|
||||
if (!path) {
|
||||
throw new Error("Usage: mb record start <file.webm|mp4|gif> [--fps N] [--scale N]")
|
||||
}
|
||||
await start({
|
||||
path,
|
||||
tab: flags.tab,
|
||||
fps: flags.fps ?? DEFAULT_FPS,
|
||||
scale: flags.scale ?? DEFAULT_SCALE,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "stop":
|
||||
await stop()
|
||||
break
|
||||
case "status":
|
||||
status()
|
||||
break
|
||||
default:
|
||||
throw new Error(
|
||||
"Usage: mb record <start|stop|status>\n" +
|
||||
" start <file> Start recording\n" +
|
||||
" stop Stop recording and save\n" +
|
||||
" status Check recording status"
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -18,8 +18,9 @@ import { wait } from "./commands/wait"
|
||||
import { audit } from "./commands/audit"
|
||||
import { tab } from "./commands/tab"
|
||||
import { logs } from "./commands/logs"
|
||||
import { record } from "./commands/record"
|
||||
|
||||
const commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab, logs }
|
||||
const commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab, logs, record }
|
||||
|
||||
const help = `mb — Browser CLI for Agents
|
||||
|
||||
@@ -45,6 +46,11 @@ Interact:
|
||||
drag <x1> <y1> <x2> <y2> Drag
|
||||
scroll <dir> [px] Scroll up/down/left/right
|
||||
|
||||
Recording:
|
||||
record start <file> Start recording (.webm, .mp4, .gif)
|
||||
record stop Stop recording and save
|
||||
record status Check recording status
|
||||
|
||||
Other:
|
||||
js <code> Run JavaScript
|
||||
wait <target> Wait for ms/selector/networkidle/url:...
|
||||
@@ -60,7 +66,9 @@ Flags:
|
||||
--timeout <ms> Timeout (default: 30000)
|
||||
--tab <n> Tab index (default: 0)
|
||||
--json JSON output
|
||||
--right/--double Right/double click`
|
||||
--right/--double Right/double click
|
||||
--fps <n> Recording frame rate (default: 30)
|
||||
--scale <n> Recording scale factor (default: 1)`
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
|
||||
@@ -43,6 +43,8 @@ export const parse = (argv: string[]) => {
|
||||
right: { type: "boolean", default: false },
|
||||
double: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
fps: { type: "string" },
|
||||
scale: { type: "string" },
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -61,6 +63,8 @@ export const parse = (argv: string[]) => {
|
||||
right: values.right!,
|
||||
double: values.double!,
|
||||
help: values.help!,
|
||||
fps: values.fps ? parseNumericFlag({ name: "fps", value: values.fps }) : undefined,
|
||||
scale: values.scale ? parseNumericFlag({ name: "scale", value: values.scale }) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs"
|
||||
|
||||
const STATE_FILE = join(homedir(), ".mb-recorder.json")
|
||||
|
||||
export interface RecorderState {
|
||||
pid: number
|
||||
path: string
|
||||
tab: number
|
||||
fps: number
|
||||
scale: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export const getStatePath = () => STATE_FILE
|
||||
|
||||
export const readState = (): RecorderState | null => {
|
||||
if (!existsSync(STATE_FILE)) return null
|
||||
try {
|
||||
const content = readFileSync(STATE_FILE, "utf-8")
|
||||
return JSON.parse(content) as RecorderState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const writeState = (state: RecorderState) => {
|
||||
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
|
||||
}
|
||||
|
||||
export const clearState = () => {
|
||||
if (existsSync(STATE_FILE)) {
|
||||
unlinkSync(STATE_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
export const isProcessRunning = (pid: number) => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recorder daemon process - spawned by `mb record start`
|
||||
* Stays alive and records until SIGTERM is received
|
||||
*/
|
||||
import { connect } from "./lib/browser"
|
||||
import { clearState, writeState, readState } from "./lib/recorder-state"
|
||||
|
||||
interface DaemonConfig {
|
||||
path: string
|
||||
tab: number
|
||||
fps: number
|
||||
scale: number
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const configEnv = process.env.MB_RECORDER_CONFIG
|
||||
if (!configEnv) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config: DaemonConfig = JSON.parse(configEnv)
|
||||
const { page, close } = await connect(config.tab)
|
||||
|
||||
// Bring tab to front to ensure Chrome sends screencast frames
|
||||
await page.bringToFront()
|
||||
|
||||
// Warm up CDP screencast to trigger initial frame
|
||||
// (page.screencast waits for first frame which won't come without activity)
|
||||
const client = await page.createCDPSession()
|
||||
await client.send("Page.startScreencast", { format: "png" })
|
||||
await client.send("Page.stopScreencast")
|
||||
await client.detach()
|
||||
|
||||
const recorder = await page.screencast({
|
||||
path: config.path as `${string}.webm` | `${string}.mp4` | `${string}.gif`,
|
||||
fps: config.fps,
|
||||
scale: config.scale,
|
||||
})
|
||||
|
||||
// Update state to indicate recording has started
|
||||
const state = readState()
|
||||
if (state) {
|
||||
writeState({ ...state, startedAt: Date.now() })
|
||||
}
|
||||
|
||||
let stopping = false
|
||||
|
||||
const stop = async () => {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
try {
|
||||
await recorder.stop()
|
||||
await close()
|
||||
clearState()
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGTERM", stop)
|
||||
process.on("SIGINT", stop)
|
||||
|
||||
// Keep process alive
|
||||
setInterval(() => {}, 60000)
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
clearState()
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user