feat: Android companion app improvements & gateway URL camera payloads (#13541)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 9c179c9c3192ec76059f5caac1e8de8bdfb257ce
Co-authored-by: smartprogrammer93 <33181301+smartprogrammer93@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
Ahmad Bitar
2026-02-13 23:49:28 +08:00
committed by GitHub
parent 41f2f359a5
commit c179f71f42
38 changed files with 2158 additions and 748 deletions

View File

@@ -1,12 +1,13 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
writeBase64ToFile,
writeUrlToFile,
} from "./nodes-camera.js";
describe("nodes camera helpers", () => {
@@ -61,4 +62,45 @@ describe("nodes camera helpers", () => {
await expect(fs.readFile(out, "utf8")).resolves.toBe("hi");
await fs.rm(dir, { recursive: true, force: true });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("writes url payload to file", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("url-content", { status: 200 })),
);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
const out = path.join(dir, "x.bin");
try {
await writeUrlToFile(out, "https://example.com/clip.mp4");
await expect(fs.readFile(out, "utf8")).resolves.toBe("url-content");
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it("rejects non-https url payload", async () => {
await expect(writeUrlToFile("/tmp/ignored", "http://example.com/x.bin")).rejects.toThrow(
/only https/i,
);
});
it("rejects oversized content-length for url payload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response("tiny", {
status: 200,
headers: { "content-length": String(999_999_999) },
}),
),
);
await expect(writeUrlToFile("/tmp/ignored", "https://example.com/huge.bin")).rejects.toThrow(
/exceeds max/i,
);
});
});

View File

@@ -4,18 +4,22 @@ import * as os from "node:os";
import * as path from "node:path";
import { resolveCliName } from "./cli-name.js";
const MAX_CAMERA_URL_DOWNLOAD_BYTES = 250 * 1024 * 1024;
export type CameraFacing = "front" | "back";
export type CameraSnapPayload = {
format: string;
base64: string;
base64?: string;
url?: string;
width: number;
height: number;
};
export type CameraClipPayload = {
format: string;
base64: string;
base64?: string;
url?: string;
durationMs: number;
hasAudio: boolean;
};
@@ -40,24 +44,26 @@ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload {
const obj = asRecord(value);
const format = asString(obj.format);
const base64 = asString(obj.base64);
const url = asString(obj.url);
const width = asNumber(obj.width);
const height = asNumber(obj.height);
if (!format || !base64 || width === undefined || height === undefined) {
if (!format || (!base64 && !url) || width === undefined || height === undefined) {
throw new Error("invalid camera.snap payload");
}
return { format, base64, width, height };
return { format, ...(base64 ? { base64 } : {}), ...(url ? { url } : {}), width, height };
}
export function parseCameraClipPayload(value: unknown): CameraClipPayload {
const obj = asRecord(value);
const format = asString(obj.format);
const base64 = asString(obj.base64);
const url = asString(obj.url);
const durationMs = asNumber(obj.durationMs);
const hasAudio = asBoolean(obj.hasAudio);
if (!format || !base64 || durationMs === undefined || hasAudio === undefined) {
if (!format || (!base64 && !url) || durationMs === undefined || hasAudio === undefined) {
throw new Error("invalid camera.clip payload");
}
return { format, base64, durationMs, hasAudio };
return { format, ...(base64 ? { base64 } : {}), ...(url ? { url } : {}), durationMs, hasAudio };
}
export function cameraTempPath(opts: {
@@ -75,6 +81,69 @@ export function cameraTempPath(opts: {
return path.join(tmpDir, `${cliName}-camera-${opts.kind}${facingPart}-${id}${ext}`);
}
export async function writeUrlToFile(filePath: string, url: string) {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`);
}
const res = await fetch(url);
if (!res.ok) {
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
}
const contentLengthRaw = res.headers.get("content-length");
const contentLength = contentLengthRaw ? Number.parseInt(contentLengthRaw, 10) : undefined;
if (
typeof contentLength === "number" &&
Number.isFinite(contentLength) &&
contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES
) {
throw new Error(
`writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
const body = res.body;
if (!body) {
throw new Error(`failed to download ${url}: empty response body`);
}
const fileHandle = await fs.open(filePath, "w");
let bytes = 0;
let thrown: unknown;
try {
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
bytes += value.byteLength;
if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
throw new Error(
`writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
await fileHandle.write(value);
}
} catch (err) {
thrown = err;
} finally {
await fileHandle.close();
}
if (thrown) {
await fs.unlink(filePath).catch(() => {});
throw thrown;
}
return { path: filePath, bytes };
}
export async function writeBase64ToFile(filePath: string, base64: string) {
const buf = Buffer.from(base64, "base64");
await fs.writeFile(filePath, buf);

View File

@@ -10,6 +10,7 @@ import {
parseCameraClipPayload,
parseCameraSnapPayload,
writeBase64ToFile,
writeUrlToFile,
} from "../nodes-camera.js";
import { parseDurationMs } from "../parse-duration.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
@@ -155,7 +156,11 @@ export function registerNodesCameraCommands(nodes: Command) {
facing,
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
if (payload.url) {
await writeUrlToFile(filePath, payload.url);
} else if (payload.base64) {
await writeBase64ToFile(filePath, payload.base64);
}
results.push({
facing,
path: filePath,
@@ -223,7 +228,11 @@ export function registerNodesCameraCommands(nodes: Command) {
facing,
ext: payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
if (payload.url) {
await writeUrlToFile(filePath, payload.url);
} else if (payload.base64) {
await writeBase64ToFile(filePath, payload.base64);
}
if (opts.json) {
defaultRuntime.log(

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { parseCameraSnapPayload, parseCameraClipPayload } from "./nodes-camera.js";
const messageCommand = vi.fn();
const statusCommand = vi.fn();
@@ -461,4 +462,171 @@ describe("cli program (nodes media)", () => {
true,
);
});
describe("URL-based payloads", () => {
let originalFetch: typeof globalThis.fetch;
beforeAll(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () =>
new Response("url-content", {
status: 200,
headers: { "content-length": String("11") },
}),
) as unknown as typeof globalThis.fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
it("runs nodes camera snap with url payload", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: {
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
},
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"],
{ from: "user" },
);
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/openclaw-camera-snap-front-.*\.jpg$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-content");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
});
it("runs nodes camera clip with url payload", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 5000,
hasAudio: true,
},
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"],
{ from: "user" },
);
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/openclaw-camera-clip-front-.*\.mp4$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-content");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
});
});
describe("parseCameraSnapPayload with url", () => {
it("accepts url without base64", () => {
const result = parseCameraSnapPayload({
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
});
expect(result.url).toBe("https://example.com/photo.jpg");
expect(result.base64).toBeUndefined();
});
it("accepts both base64 and url", () => {
const result = parseCameraSnapPayload({
format: "jpg",
base64: "aGk=",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
});
expect(result.base64).toBe("aGk=");
expect(result.url).toBe("https://example.com/photo.jpg");
});
it("rejects payload with neither base64 nor url", () => {
expect(() => parseCameraSnapPayload({ format: "jpg", width: 640, height: 480 })).toThrow(
"invalid camera.snap payload",
);
});
});
describe("parseCameraClipPayload with url", () => {
it("accepts url without base64", () => {
const result = parseCameraClipPayload({
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 3000,
hasAudio: true,
});
expect(result.url).toBe("https://example.com/clip.mp4");
expect(result.base64).toBeUndefined();
});
it("rejects payload with neither base64 nor url", () => {
expect(() =>
parseCameraClipPayload({ format: "mp4", durationMs: 3000, hasAudio: true }),
).toThrow("invalid camera.clip payload");
});
});
});