// Human-operable Chrome control for the on-screen (Go-Live) browser. // // node chrome-control.mjs '' // // Connects to the on-screen Chrome over CDP (so every action is visible on the // broadcast) and performs ONE command, printing a JSON result on stdout for the // Python `controlBrowser` tool to wrap. READ actions use the CDP/DOM API; // ACTION actions (navigate/click/type/scroll) drive real input via xdotool // (human.mjs) so the cursor visibly moves and text is typed one char at a time, // exactly as a person would — never synthetic DOM events. // // Commands (json): { "action": "", ...params } // status -> broadcast/browser readiness + tab list // listTabs -> [{index,url,title,active}] // navigate {url, human?} -> open url in the active tab // newTab {url?} -> open a new tab (optionally to url) // closeTab {index} -> close tab by index // activateTab{index} -> bring tab to front // closePopups -> close blank/popup tabs, keep the active one // click {selector, human?} -> click an element (human cursor by default) // type {text, selector?, human?}-> type text char-by-char (human by default) // scroll {dir, notches?} -> wheel scroll (dir: "down"|"up") // pressKey {key} -> press a single key (xdotool keysym) // screenshot {path} -> save a PNG of the active tab import { chromium } from 'playwright'; import { humanClick, humanType, pressKey, humanScroll, navigateOmnibox, } from './human.mjs'; const CDP = process.env.CDP_PORT || '9222'; const CDP_HOST = process.env.CDP_HOST || '127.0.0.1'; const out = (o) => process.stdout.write(JSON.stringify(o)); let cmd; try { cmd = JSON.parse(process.argv[2] || '{}'); } catch (e) { out({ ok: false, error: `bad command json: ${e?.message || e}` }); process.exit(1); } const action = String(cmd.action || '').trim(); if (!action) { out({ ok: false, error: 'no action' }); process.exit(1); } const tabInfo = async (pages, active) => { const list = []; for (let i = 0; i < pages.length; i++) { const p = pages[i]; list.push({ index: i, url: p.url(), title: await p.title().catch(() => ''), active: p === active, }); } return list; }; let b; try { b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`); const ctx = b.contexts()[0]; if (!ctx) throw new Error('no browser context (is Chrome running?)'); const pages = ctx.pages(); // The "active" tab: the most recently fronted page. Playwright has no direct // accessor, so we treat the first visible page as active and let activateTab // change focus explicitly. let page = pages.find((p) => p.url() && p.url() !== 'about:blank') || pages[0] || (await ctx.newPage()); page.setDefaultTimeout(20000); switch (action) { case 'status': { out({ ok: true, browserOpen: true, tabCount: pages.length, tabs: await tabInfo(pages, page) }); break; } case 'listTabs': { out({ ok: true, tabs: await tabInfo(pages, page) }); break; } case 'navigate': { const url = String(cmd.url || '').trim(); if (!url) throw new Error('navigate: no url'); const target = /^https?:\/\//i.test(url) ? url : `https://${url}`; await page.bringToFront().catch(() => {}); if (cmd.human === false) { await page.goto(target, { waitUntil: 'domcontentloaded' }); } else { // Type the URL into the omnibox char-by-char with a real cursor. await navigateOmnibox(target); await page.waitForLoadState('domcontentloaded').catch(() => {}); } out({ ok: true, url: page.url(), title: await page.title().catch(() => '') }); break; } case 'newTab': { const np = await ctx.newPage(); await np.bringToFront().catch(() => {}); if (cmd.url) { const target = /^https?:\/\//i.test(cmd.url) ? cmd.url : `https://${cmd.url}`; await np.goto(target, { waitUntil: 'domcontentloaded' }).catch(() => {}); } out({ ok: true, index: ctx.pages().length - 1, url: np.url() }); break; } case 'closeTab': { const idx = Number(cmd.index); if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) throw new Error('closeTab: bad index'); await pages[idx].close(); out({ ok: true, closed: idx, remaining: ctx.pages().length }); break; } case 'activateTab': { const idx = Number(cmd.index); if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) throw new Error('activateTab: bad index'); await pages[idx].bringToFront(); out({ ok: true, active: idx, url: pages[idx].url() }); break; } case 'closePopups': { // Close throwaway/popup tabs (about:blank or anything that isn't the // active content tab), keeping the active one. let closed = 0; for (const p of pages) { if (p === page) continue; const u = p.url(); if (!u || u === 'about:blank' || cmd.all) { await p.close().catch(() => {}); closed++; } } out({ ok: true, closed, remaining: ctx.pages().length }); break; } case 'click': { const selector = String(cmd.selector || '').trim(); if (!selector) throw new Error('click: no selector'); await page.bringToFront().catch(() => {}); const locator = page.locator(selector).first(); if (cmd.human === false) await locator.click(); else await humanClick(page, locator); out({ ok: true }); break; } case 'type': { const text = String(cmd.text ?? ''); await page.bringToFront().catch(() => {}); if (cmd.selector) { const locator = page.locator(String(cmd.selector)).first(); if (cmd.human === false) { await locator.fill(text); out({ ok: true }); break; } await humanClick(page, locator); // focus the field with a real click first } await humanType(text); out({ ok: true }); break; } case 'scroll': { const dir = String(cmd.dir || 'down').toLowerCase() === 'up' ? -1 : 1; await humanScroll(page, dir, Number(cmd.notches) || 5); out({ ok: true }); break; } case 'pressKey': { const key = String(cmd.key || '').trim(); if (!key) throw new Error('pressKey: no key'); await pressKey(key); out({ ok: true }); break; } case 'screenshot': { const path = String(cmd.path || '').trim(); if (!path) throw new Error('screenshot: no path'); await page.bringToFront().catch(() => {}); await page.screenshot({ path }); out({ ok: true, path }); break; } default: out({ ok: false, error: `unknown action: ${action}` }); await b.close(); process.exit(1); } await b.close(); } catch (e) { try { await b?.close(); } catch { /* ignore */ } out({ ok: false, error: String(e?.message || e) }); process.exit(1); }