lazyBoy/scripts/capture-hero.mjs

315 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Capture README hero screenshots from a running LazyBoy instance.
//
// The banner must show the real app, so this drives a real browser against a
// real stack instead of painting a mock-up. No npm dependencies: it speaks
// Chrome DevTools Protocol over the browser WebSocket (Node 22+).
//
// node scripts/capture-hero.mjs --out docs/hero/agent.png
// node scripts/capture-hero.mjs --out docs/hero/room.png --pick 測試聊天
// node scripts/capture-hero.mjs --out docs/hero/desktop.png --selector .side-card
//
// A capture that goes into the repository must not photograph whatever the
// agent's browser happens to have open. `--hide` blanks a region (its layout
// box stays, so nothing reflows) and `--anchor` prints where that region sits
// inside the shot, which is what docs/hero.html uses to overlay a clean panel.
//
// node scripts/capture-hero.mjs --pick 測試聊天 --hide .side-card --anchor .side-card \
// --out docs/hero/room.png
// node scripts/capture-hero.mjs --pick 阿狗 --selector .side-card --out docs/hero/desktop.png
//
// Environment:
// LB_URL stack to photograph (default http://127.0.0.1:3101)
// LB_TOKEN login token (default: LAZYBOY_APP_TOKEN from .env)
// CHROME browser binary (default: discovered, see findChrome)
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const option = (name, fallback) => {
const index = process.argv.indexOf(`--${name}`);
return index > -1 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
};
const config = {
base: (process.env.LB_URL || 'http://127.0.0.1:3101').replace(/\/$/, ''),
out: option('out', 'docs/hero/app.png'),
pick: option('pick', ''),
selector: option('selector', '.app-shell'),
hide: option('hide', ''),
anchor: option('anchor', ''),
width: Number(option('width', 1480)),
// 1480x650 keeps the 1184x520 image box in docs/hero.html free of cropping.
height: Number(option('height', 650)),
scale: Number(option('scale', 2)),
settle: Number(option('settle', 6000)),
};
function findChrome() {
const candidates = [
process.env.CHROME,
...['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'brave', 'microsoft-edge']
.map((name) => process.env.PATH.split(path.delimiter).map((dir) => path.join(dir, name)).find((bin) => fs.existsSync(bin))),
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
const playwright = path.join(os.homedir(), '.cache/ms-playwright');
if (fs.existsSync(playwright)) {
for (const build of fs.readdirSync(playwright).sort().reverse()) {
for (const dir of ['chrome-linux64', 'chrome-mac']) {
const bin = path.join(playwright, build, dir, 'chrome');
if (fs.existsSync(bin)) return bin;
}
}
}
throw new Error('no Chromium found; set CHROME=/path/to/chrome');
}
function readToken() {
if (process.env.LB_TOKEN) return process.env.LB_TOKEN;
const file = path.join(root, '.env');
const match = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').match(/^LAZYBOY_APP_TOKEN=(.+)$/m) : null;
if (!match) throw new Error('no login token; set LB_TOKEN or create .env with make env');
return match[1].trim();
}
async function waitForVersion(port) {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
if (response.ok) return await response.json();
} catch {
// Chrome is still coming up.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error('Chrome did not expose a DevTools endpoint');
}
// Minimal flat CDP client: one WebSocket, promise-per-id, plus event waiting.
class Connection {
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
this.listeners = new Map();
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.id) {
const waiter = this.pending.get(message.id);
if (!waiter) return;
this.pending.delete(message.id);
if (message.error) waiter.reject(new Error(`${message.method}: ${message.error.message}`));
else waiter.resolve(message.result);
return;
}
const handlers = this.listeners.get(message.method) || [];
this.listeners.set(message.method, handlers.filter((handler) => !handler.once));
handlers.forEach((handler) => handler.resolve(message.params));
});
}
send(method, params = {}, sessionId) {
const id = this.nextId;
this.nextId += 1;
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
}
waitFor(method, sessionId) {
return new Promise((resolve) => {
const handlers = this.listeners.get(method) || [];
handlers.push({ resolve, once: true });
this.listeners.set(method, handlers);
});
}
}
// Retry an evaluate call until it returns something other than null. The app
// loads its lists over the API, so a single shot races the first render.
const retry = async (attempt) => {
for (let pass = 0; pass < 40; pass += 1) {
const value = await attempt();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 250));
}
return null;
};
const launch = async () => {
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'lazyboy-hero-'));
const port = 9400 + Math.floor(Math.random() * 200);
const args = [
'--headless=new',
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--hide-scrollbars',
'--force-color-profile=srgb',
`--window-size=${config.width},${config.height}`,
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
'about:blank',
];
const chrome = spawn(findChrome(), args, { stdio: 'ignore' });
chrome.on('exit', (code) => {
if (code && code !== 0) console.error(`chrome exited early with code ${code}`);
});
const cleanup = () => {
chrome.kill('SIGKILL');
fs.rmSync(profile, { recursive: true, force: true });
};
const version = await waitForVersion(port);
return { cleanup, version };
};
const { cleanup, version } = await launch();
process.on('exit', cleanup);
try {
const socket = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true });
socket.addEventListener('error', () => reject(new Error('DevTools WebSocket failed')), { once: true });
});
const connection = new Connection(socket);
const { targetId } = await connection.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await connection.send('Target.attachToTarget', { targetId, flatten: true });
await connection.send('Page.enable', {}, sessionId);
await connection.send(
'Emulation.setDeviceMetricsOverride',
{ width: config.width, height: config.height, deviceScaleFactor: config.scale, mobile: false },
sessionId,
);
const goto = async (url) => {
const loaded = connection.waitFor('Page.loadEventFired', sessionId);
await connection.send('Page.navigate', { url }, sessionId);
await loaded;
};
await goto(`${config.base}/`);
const status = await connection.send(
'Runtime.evaluate',
{
expression: `fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(readToken())}})}).then(r=>r.status)`,
awaitPromise: true,
returnByValue: true,
},
sessionId,
).then((result) => result.result?.value);
if (status !== 200) throw new Error(`login failed with HTTP ${status}`);
await goto(`${config.base}/`);
if (config.pick) {
// The sidebar fills in after the first API responses, so retry until the
// entry is really there before clicking.
const clicked = await retry(async () => connection.send(
'Runtime.evaluate',
{
expression: `(() => {
const label = ${JSON.stringify(config.pick)};
const hits = [...document.querySelectorAll('button,[role="button"],li,div')].filter((el) => {
const text = (el.innerText || '').trim();
return text.startsWith(label) && text.length < 90 && el.offsetParent !== null
&& el.getBoundingClientRect().width < 420;
});
if (!hits.length) return null;
hits.sort((a, b) => a.innerText.length - b.innerText.length)[0].click();
return 'clicked';
})()`,
returnByValue: true,
},
sessionId,
).then((result) => result.result?.value));
if (clicked !== 'clicked') throw new Error(`sidebar entry "${config.pick}" not found`);
}
// The noVNC panel is a live stream; give it time to paint a real desktop.
await new Promise((resolve) => setTimeout(resolve, config.settle));
if (config.hide) {
const hidden = await connection.send(
'Runtime.evaluate',
{
expression: `document.querySelectorAll(${JSON.stringify(config.hide)}).length`,
returnByValue: true,
},
sessionId,
).then((result) => result.result?.value);
// A typo in --hide would silently ship a photograph of the live desktop.
if (!hidden) throw new Error(`--hide ${config.hide} matched nothing; refusing to capture`);
// A stylesheet survives React re-renders; an inline style does not.
await connection.send(
'Runtime.evaluate',
{
expression: `(() => {
const style = document.createElement('style');
const rules = ${JSON.stringify(config.hide)}.split(',')
.map((s) => s.trim()).filter(Boolean)
.map((s) => s + ' { visibility: hidden !important }').join('\\n');
style.textContent = rules;
document.head.appendChild(style);
return document.querySelectorAll(${JSON.stringify(config.hide)}).length;
})()`,
returnByValue: true,
},
sessionId,
);
}
const rect = await connection.send(
'Runtime.evaluate',
{
expression: `(() => {
const el = document.querySelector(${JSON.stringify(config.selector)});
if (!el) return null;
const box = el.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
})()`,
returnByValue: true,
},
sessionId,
).then((result) => result.result?.value);
if (!rect) throw new Error(`selector ${config.selector} not found`);
let placed = '';
if (config.anchor) {
const box = await connection.send(
'Runtime.evaluate',
{
expression: `(() => {
const host = document.querySelector(${JSON.stringify(config.selector)});
const el = document.querySelector(${JSON.stringify(config.anchor)});
if (!host || !el) return null;
const a = el.getBoundingClientRect();
const b = host.getBoundingClientRect();
return { x: a.x - b.x, y: a.y - b.y, width: a.width, height: a.height };
})()`,
returnByValue: true,
},
sessionId,
).then((result) => result.result?.value);
if (!box) throw new Error(`anchor ${config.anchor} not found`);
placed = ` anchor ${box.x.toFixed(1)},${box.y.toFixed(1)} ${box.width.toFixed(1)}x${box.height.toFixed(1)}`;
}
const shot = await connection.send(
'Page.captureScreenshot',
{ format: 'png', clip: { ...rect, scale: 1 }, captureBeyondViewport: true },
sessionId,
);
const file = path.resolve(root, config.out);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, Buffer.from(shot.data, 'base64'));
const bytes = fs.statSync(file).size;
console.log(`wrote ${path.relative(root, file)} ${Math.round(rect.width * config.scale)}x${Math.round(rect.height * config.scale)} (${bytes} bytes)${placed}`);
} finally {
cleanup();
}