feat: add mac node screen recording and ssh tunnel

This commit is contained in:
Peter Steinberger
2025-12-19 02:33:43 +01:00
parent 1fbd84da39
commit 95ea67de28
8 changed files with 311 additions and 1 deletions

View File

@@ -12,6 +12,11 @@ import {
canvasSnapshotTempPath,
parseCanvasSnapshotPayload,
} from "./nodes-canvas.js";
import {
parseScreenRecordPayload,
screenRecordTempPath,
writeScreenRecordToFile,
} from "./nodes-screen.js";
import { parseDurationMs } from "./parse-duration.js";
type NodesRpcOpts = {
@@ -29,6 +34,8 @@ type NodesRpcOpts = {
maxWidth?: string;
quality?: string;
duration?: string;
screen?: string;
fps?: string;
audio?: boolean;
};
@@ -760,4 +767,97 @@ export function registerNodesCli(program: Command) {
}),
{ timeoutMs: 90_000 },
);
const screen = nodes
.command("screen")
.description("Capture screen recordings from a paired node");
nodesCallOpts(
screen
.command("record")
.description(
"Capture a short screen recording from a node (prints MEDIA:<path>)",
)
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--screen <index>", "Screen index (0 = primary)", "0")
.option("--duration <ms|10s>", "Clip duration (ms or 10s)", "10000")
.option("--fps <fps>", "Frames per second", "10")
.option("--out <path>", "Output path")
.option(
"--invoke-timeout <ms>",
"Node invoke timeout in ms (default 120000)",
"120000",
)
.action(async (opts: NodesRpcOpts & { out?: string }) => {
try {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const durationMs = parseDurationMs(opts.duration ?? "");
const screenIndex = Number.parseInt(String(opts.screen ?? "0"), 10);
const fps = Number.parseFloat(String(opts.fps ?? "10"));
const timeoutMs = opts.invokeTimeout
? Number.parseInt(String(opts.invokeTimeout), 10)
: undefined;
const invokeParams: Record<string, unknown> = {
nodeId,
command: "screen.record",
params: {
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
screenIndex: Number.isFinite(screenIndex)
? screenIndex
: undefined,
fps: Number.isFinite(fps) ? fps : undefined,
format: "mp4",
},
idempotencyKey: randomIdempotencyKey(),
};
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
invokeParams.timeoutMs = timeoutMs;
}
const raw = (await callGatewayCli(
"node.invoke",
opts,
invokeParams,
)) as unknown;
const res =
typeof raw === "object" && raw !== null
? (raw as { payload?: unknown })
: {};
const parsed = parseScreenRecordPayload(res.payload);
const filePath =
opts.out ??
screenRecordTempPath({
ext: parsed.format || "mp4",
});
const written = await writeScreenRecordToFile(
filePath,
parsed.base64,
);
if (opts.json) {
defaultRuntime.log(
JSON.stringify(
{
file: {
path: written.path,
durationMs: parsed.durationMs,
fps: parsed.fps,
screenIndex: parsed.screenIndex,
},
},
null,
2,
),
);
return;
}
defaultRuntime.log(`MEDIA:${written.path}`);
} catch (err) {
defaultRuntime.error(`nodes screen record failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
{ timeoutMs: 180_000 },
);
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
parseScreenRecordPayload,
screenRecordTempPath,
} from "./nodes-screen.js";
describe("nodes screen helpers", () => {
it("parses screen.record payload", () => {
const payload = parseScreenRecordPayload({
format: "mp4",
base64: "Zm9v",
durationMs: 1000,
fps: 12,
screenIndex: 0,
});
expect(payload.format).toBe("mp4");
expect(payload.base64).toBe("Zm9v");
expect(payload.durationMs).toBe(1000);
expect(payload.fps).toBe(12);
expect(payload.screenIndex).toBe(0);
});
it("rejects invalid screen.record payload", () => {
expect(() => parseScreenRecordPayload({ format: "mp4" })).toThrow(
/invalid screen\.record payload/i,
);
});
it("builds screen record temp path", () => {
const p = screenRecordTempPath({
ext: "mp4",
tmpDir: "/tmp",
id: "id1",
});
expect(p).toBe("/tmp/clawdis-screen-record-id1.mp4");
});
});

58
src/cli/nodes-screen.ts Normal file
View File

@@ -0,0 +1,58 @@
import { randomUUID } from "node:crypto";
import * as os from "node:os";
import * as path from "node:path";
import { writeBase64ToFile } from "./nodes-camera.js";
export type ScreenRecordPayload = {
format: string;
base64: string;
durationMs?: number;
fps?: number;
screenIndex?: number;
};
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function parseScreenRecordPayload(value: unknown): ScreenRecordPayload {
const obj = asRecord(value);
const format = asString(obj.format);
const base64 = asString(obj.base64);
if (!format || !base64) {
throw new Error("invalid screen.record payload");
}
return {
format,
base64,
durationMs: typeof obj.durationMs === "number" ? obj.durationMs : undefined,
fps: typeof obj.fps === "number" ? obj.fps : undefined,
screenIndex:
typeof obj.screenIndex === "number" ? obj.screenIndex : undefined,
};
}
export function screenRecordTempPath(opts: {
ext: string;
tmpDir?: string;
id?: string;
}) {
const tmpDir = opts.tmpDir ?? os.tmpdir();
const id = opts.id ?? randomUUID();
const ext = opts.ext.startsWith(".") ? opts.ext : `.${opts.ext}`;
return path.join(tmpDir, `clawdis-screen-record-${id}${ext}`);
}
export async function writeScreenRecordToFile(
filePath: string,
base64: string,
) {
return writeBase64ToFile(filePath, base64);
}