// Minimal CDP driver — real-time (no virtual-time budget), so // requestAnimationFrame keeps ticking like in a normal browser session. // (Headless Chrome with --virtual-time-budget stops rAF after a couple of // frames — the old `chrome --headless --screenshot` path is broken for this // game; this is not.) // // CLI: node cdpshot.mjs [waitMs] [w] [h] // Module: import { CDP, launch } from './cdpshot.mjs' import { spawn } from 'node:child_process'; import fs from 'node:fs'; const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * Launch an isolated headless Chrome with CDP enabled. * Returns { url, kill() } — url = the webSocketDebuggerUrl of a new tab at * `targetUrl` (blank if omitted). kill() terminates Chrome and cleans up * its profile dir. */ export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() * 100), width = 1280, height = 720, waitMs = 4000) { const userDataDir = `C:/Users/BRIAN~1.FER/AppData/Local/Temp/cdp-prof-${process.pid}-${port}`; fs.rmSync(userDataDir, { recursive: true, force: true }); const chrome = spawn(CHROME, [ '--headless', `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--enable-unsafe-swiftshader', '--hide-scrollbars', `--window-size=${width},${height}`, 'about:blank', ], { stdio: 'ignore' }); for (let i = 0; i < waitMs / 100; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) break; } catch { /* not up yet */ } await sleep(100); } // A fresh tab at the target URL (PUT = newer Chrome, POST = older). const newUrl = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(targetUrl || 'about:blank')}`; let target; try { target = await (await fetch(newUrl, { method: 'PUT' })).json(); } catch { target = await (await fetch(newUrl, { method: 'POST' })).json(); } const kill = () => { chrome.kill(); // Chrome may still hold profile files for a moment after kill; retry politely. (async () => { for (let i = 0; i < 12; i++) { try { fs.rmSync(userDataDir, { recursive: true, force: true }); return; } catch { await sleep(250); } } })(); }; return { url: target.webSocketDebuggerUrl, kill }; } /** Connect a CDP session to an already-running page. */ export class CDP { constructor(wsUrl) { this.ws = new WebSocket(wsUrl); this.id = 0; this.pending = new Map(); this.listeners = new Map(); this.ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); } else if (msg.method && this.listeners.has(msg.method)) { for (const fn of this.listeners.get(msg.method)) fn(msg.params); } }; } open() { return new Promise((r, j) => { this.ws.onopen = r; this.ws.onerror = j; }); } send(method, params = {}) { return new Promise((resolve, reject) => { const mid = ++this.id; this.pending.set(mid, { resolve, reject }); this.ws.send(JSON.stringify({ id: mid, method, params })); }); } on(method, fn) { if (!this.listeners.has(method)) this.listeners.set(method, []); this.listeners.get(method).push(fn); } close() { try { this.ws.close(); } catch { /* already closed */ } } } // ---- CLI entry ------------------------------------------------------ if (process.argv[1] && /cdpshot\.mjs$/.test(process.argv[1])) { const [url, out, waitMs = '7000', W = '1280', H = '720'] = process.argv.slice(2); const { url: wsUrl, kill } = await launch(url, undefined, Number(W), Number(H)); const cdp = new CDP(wsUrl); try { await cdp.open(); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); await cdp.send('Page.navigate', { url }); await sleep(Number(waitMs)); const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(out, Buffer.from(shot.data, 'base64')); console.log('saved', out); } finally { cdp.close(); kill(); } }