skill
ffpup — Firefox (BiDi) bridge
Public Unreviewedby barrett-land
Firefox automation bridge for Adom Desktop — the WebDriver-BiDi sibling of the pup (Chrome) bridge.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
// ffpup — Firefox (WebDriver BiDi) bridge for Adom Desktop.
//
// The BiDi sibling of the puppeteer (Chrome) bridge. Drives the user's REAL
// Firefox over WebDriver BiDi: trusted input (isTrusted=true on clicks AND
// keystrokes — the thing a WebExtension content script can't fake), persistent
// logged-in profiles, and NO "controlled by automation" infobar (unlike Chrome's
// --remote-debugging-port). Uses the system Firefox — no browser download.
//
// Protocol (matches the pup bridge so AD routes it identically):
// GET /status → health JSON
// POST /command {command, args} → dispatch a verb, returns {success, output|error}
// Verbs are the ffbrowser_* surface mirroring pup's browser_*.
//
// Launched by Adom Desktop as: node server.js --port <os-assigned>
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// puppeteer-core is loaded LAZILY (on the first browser verb), and auto-installed
// into this bridge dir if missing. This keeps the published bridge zip tiny — the
// ~50MB node_modules is fetched once, on first use, on the user's machine. /status
// never touches it, so the bridge passes its health check instantly.
let _puppeteer = null;
function getPuppeteer() {
if (_puppeteer) return _puppeteer;
try { _puppeteer = require('puppeteer-core'); return _puppeteer; } catch { /* not installed */ }
try { _puppeteer = require('puppeteer'); return _puppeteer; } catch { /* dev fallback */ }
console.log('[ffpup] first run — installing puppeteer-core (one-time)…');
execSync('npm install puppeteer-core@^24 --no-audit --no-fund --loglevel=error',
{ cwd: __dirname, stdio: 'inherit' });
_puppeteer = require('puppeteer-core');
return _puppeteer;
}
const PORT = (() => {
const i = process.argv.indexOf('--port');
return i !== -1 && process.argv[i + 1] ? parseInt(process.argv[i + 1], 10) : 8861;
})();
// Headful by default (this drives the user's real browser). FFPUP_HEADLESS=1 for
// container/CI testing where there's no display.
const HEADLESS = process.env.FFPUP_HEADLESS === '1';
// Firefox profile location. Snap-packaged Firefox (the Ubuntu 24.04 default) is
// confined: it can ONLY read profiles under ~/snap/firefox/… or NON-hidden home
// dirs — NOT ~/.local, where AD's bridges-cache lives. A profile it can't read
// gives the user a "profile could not be loaded" dialog and Firefox exits. So when
// snap Firefox is present, put profiles inside its own accessible data dir.
function resolveStateDir() {
if (process.env.FFPUP_STATE_DIR) return process.env.FFPUP_STATE_DIR;
const home = process.env.HOME || require('os').homedir();
if (process.platform === 'linux') {
try {
const snapCommon = path.join(home, 'snap', 'firefox', 'common');
if (fs.existsSync(snapCommon)) return path.join(snapCommon, 'ffpup');
} catch { /* ignore */ }
}
return path.join(__dirname, '.ffpup');
}
const STATE_DIR = resolveStateDir();
const PROFILES_DIR = path.join(STATE_DIR, 'profiles');
fs.mkdirSync(PROFILES_DIR, { recursive: true });
// Resolve a Firefox executable: env override → system install → puppeteer-managed.
function resolveFirefox() {
if (process.env.FFPUP_FIREFOX_PATH && fs.existsSync(process.env.FFPUP_FIREFOX_PATH)) {
return process.env.FFPUP_FIREFOX_PATH;
}
const candidates = process.platform === 'win32'
? ['C:/Program Files/Mozilla Firefox/firefox.exe', 'C:/Program Files (x86)/Mozilla Firefox/firefox.exe']
: ['/usr/bin/firefox', '/usr/lib/firefox/firefox', '/snap/bin/firefox',
'/Applications/Firefox.app/Contents/MacOS/firefox'];
for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } }
return null; // null → puppeteer uses its own downloaded Firefox (dev only)
}
// Firefox prefs mirroring the Chrome launch-flag suppressions: no first-run, no
// default-browser nag, no telemetry, no session-restore (this surface is
// AI-driven — it knows what it wants open).
const FIREFOX_PREFS = {
'browser.shell.checkDefaultBrowser': false,
'browser.startup.page': 0,
'browser.startup.homepage_override.mstone': 'ignore',
'datareporting.policy.dataSubmissionEnabled': false,
'toolkit.telemetry.enabled': false,
'app.update.auto': false,
'app.update.enabled': false,
'browser.tabs.warnOnClose': false,
'browser.sessionstore.resume_from_crash': false,
'browser.aboutwelcome.enabled': false,
};
// ── State ────────────────────────────────────────────────────────────
// browsers: profileName → { browser, refCount } (one Firefox per profile)
// sessions: sessionId → { tabs:[{tabId,page,errors}], activeTabId, profileName, tabCounter }
const browsers = new Map();
const sessions = new Map();
let activeSessionId = null;
function createSession(profileName) {
return { tabs: [], activeTabId: null, profileName, tabCounter: 0 };
}
function nextTabId(s) { return `tab-${++s.tabCounter}`; }
function getTab(session, tabId) {
if (tabId) return session.tabs.find(t => t.tabId === tabId) || null;
return session.tabs.find(t => t.tabId === session.activeTabId) || session.tabs[session.tabs.length - 1] || null;
}
async function getOrLaunchBrowser(profileName, freshProfile) {
const puppeteer = getPuppeteer();
const existing = browsers.get(profileName);
if (existing && existing.browser.isConnected()) {
try {
await Promise.race([
existing.browser.pages(),
new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2500)),
]);
return existing.browser;
} catch {
try { await existing.browser.close(); } catch { /* ignore */ }
browsers.delete(profileName);
}
}
const launchOpts = {
browser: 'firefox', // WebDriver BiDi
headless: HEADLESS,
defaultViewport: null,
extraPrefsFirefox: FIREFOX_PREFS,
handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
};
if (!freshProfile) {
const userDataDir = path.join(PROFILES_DIR, profileName);
fs.mkdirSync(userDataDir, { recursive: true });
launchOpts.userDataDir = userDataDir;
}
const exe = resolveFirefox();
if (exe) launchOpts.executablePath = exe;
const browser = await puppeteer.launch(launchOpts);
browser.on('disconnected', () => { browsers.delete(profileName); });
browsers.set(profileName, { browser, refCount: 0 });
return browser;
}
function attachTab(session, page) {
const tabId = nextTabId(session);
const errors = [];
page.on('pageerror', e => errors.push({ type: 'page', text: String(e && e.message || e), ts: Date.now() }));
page.on('console', msg => { if (msg.type() === 'error') errors.push({ type: 'console', text: msg.text(), ts: Date.now() }); });
page.on('close', () => {
const i = session.tabs.findIndex(t => t.tabId === tabId);
if (i !== -1) {
session.tabs.splice(i, 1);
if (session.activeTabId === tabId) {
session.activeTabId = session.tabs.length ? session.tabs[session.tabs.length - 1].tabId : null;
}
}
});
session.tabs.push({ tabId, page, errors });
session.activeTabId = tabId;
return tabId;
}
async function sessionInfo(sessionId) {
const s = sessions.get(sessionId);
if (!s) return null;
const tabs = [];
for (const t of s.tabs) {
let url = null, title = null;
try { url = t.page.url(); } catch { /* closed */ }
try { title = await t.page.title(); } catch { /* ignore */ }
tabs.push({ tabId: t.tabId, url, title, active: t.tabId === s.activeTabId, errorCount: t.errors.length });
}
return { sessionId, profile: s.profileName, engine: 'firefox', activeTabId: s.activeTabId, tabs, tabCount: tabs.length };
}
// ── Verb handlers ────────────────────────────────────────────────────
const handlers = {
async ffbrowser_open_window(args) {
if (!args.sessionId) throw err('sessionId is required. Example: ffbrowser_open_window {"sessionId":"x","profile":"x","url":"https://…"}');
if (!args.profile) throw err('profile is required (use the same value as sessionId for a dedicated profile, or share another session\'s profile to share cookies/logins).');
const sessionId = args.sessionId;
const profileName = args.profile;
// Reuse a live session: just navigate.
const existing = sessions.get(sessionId);
if (existing) {
const tab = getTab(existing);
if (tab) {
try {
await tab.page.title();
if (args.url) { await tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 }); tab.errors.length = 0; }
activeSessionId = sessionId;
return ok(await sessionInfo(sessionId));
} catch { sessions.delete(sessionId); }
} else { sessions.delete(sessionId); }
}
const browser = await getOrLaunchBrowser(profileName, !!args.freshProfile);
// Reuse a blank startup tab if one is free, else open a new page.
const claimed = new Set();
for (const s of sessions.values()) for (const t of s.tabs) claimed.add(t.page);
const pages = await browser.pages();
let page = pages.find(p => { if (claimed.has(p)) return false; try { const u = p.url(); return !u || u === 'about:blank'; } catch { return false; } });
if (!page) page = await browser.newPage();
if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
const session = createSession(profileName);
attachTab(session, page);
sessions.set(sessionId, session);
activeSessionId = sessionId;
return ok(await sessionInfo(sessionId));
},
async ffbrowser_open_tab(args) {
const s = requireSession(args.sessionId);
const browser = browsers.get(s.profileName).browser;
const page = await browser.newPage();
if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
const tabId = attachTab(s, page);
return ok({ ...(await sessionInfo(args.sessionId)), openedTabId: tabId });
},
async ffbrowser_close_tab(args) {
const s = requireSession(args.sessionId);
const tab = getTab(s, args.tabId);
if (!tab) throw err(`tab not found: ${args.tabId || '(active)'}`);
try { await tab.page.close(); } catch { /* ignore */ }
return ok(await sessionInfo(args.sessionId));
},
async ffbrowser_switch_tab(args) {
const s = requireSession(args.sessionId);
const tab = getTab(s, args.tabId);
if (!tab) throw err(`tab not found: ${args.tabId}`);
s.activeTabId = tab.tabId;
try { await tab.page.bringToFront(); } catch { /* ignore */ }
return ok(await sessionInfo(args.sessionId));
},
async ffbrowser_list_tabs(args) {
requireSession(args.sessionId);
return ok(await sessionInfo(args.sessionId));
},
async ffbrowser_close_window(args) {
const s = requireSession(args.sessionId);
sessions.delete(args.sessionId);
if (activeSessionId === args.sessionId) activeSessionId = sessions.keys().next().value || null;
const entry = browsers.get(s.profileName);
// Close the browser only if no other session shares this profile.
const stillUsed = [...sessions.values()].some(o => o.profileName === s.profileName);
if (entry && !stillUsed) { try { await entry.browser.close(); } catch { /* ignore */ } browsers.delete(s.profileName); }
return ok({ closed: args.sessionId });
},
async ffbrowser_navigate(args) {
const tab = activeTabOf(args);
if (!args.url) throw err('url is required');
await tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
tab.errors.length = 0;
return ok({ url: tab.page.url(), title: await tab.page.title().catch(() => null) });
},
async ffbrowser_reload(args) {
const tab = activeTabOf(args);
await tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
tab.errors.length = 0;
return ok({ url: tab.page.url() });
},
async ffbrowser_evaluate(args) {
const tab = activeTabOf(args);
if (!args.expr) throw err('expr is required. Usage: ffbrowser_evaluate {"sessionId":"…","expr":"document.title"}');
const value = await tab.page.evaluate(expr => {
// eslint-disable-next-line no-eval
const r = eval(expr);
return r;
}, args.expr);
return ok({ value });
},
async ffbrowser_screenshot(args) {
const tab = activeTabOf(args);
const buf = await tab.page.screenshot({ fullPage: !!args.fullPage, type: 'png' });
const shotPath = path.join(STATE_DIR, `shot-${args.sessionId}-${Date.now()}.png`);
try { fs.writeFileSync(shotPath, buf); } catch { /* ignore */ }
return ok({ path: shotPath, base64: buf.toString('base64'), bytes: buf.length });
},
// Trusted input via puppeteer's BiDi-backed page.mouse / page.keyboard.
// type: click | move | type | key
async ffbrowser_input_dispatch(args) {
const tab = activeTabOf(args);
const page = tab.page;
const t = (args.type || '').toLowerCase();
if (t === 'click') {
if (args.selector) {
const elt = await page.$(args.selector);
if (!elt) throw err(`selector not found: ${args.selector}`);
await elt.click();
return ok({ clicked: args.selector, isTrusted: true });
}
if (typeof args.x === 'number' && typeof args.y === 'number') {
await page.mouse.click(args.x, args.y);
return ok({ clicked: { x: args.x, y: args.y }, isTrusted: true });
}
throw err('click: provide {selector} OR {x,y}');
}
if (t === 'move') {
if (typeof args.x !== 'number' || typeof args.y !== 'number') throw err('move: {x,y} required');
await page.mouse.move(args.x, args.y);
return ok({ moved: { x: args.x, y: args.y } });
}
if (t === 'type') {
if (args.selector) { const e = await page.$(args.selector); if (e) await e.focus(); }
if (typeof args.text !== 'string') throw err('type: {text} required');
await page.keyboard.type(args.text, { delay: args.delay || 0 });
return ok({ typed: args.text.length, isTrusted: true });
}
if (t === 'key') {
if (!args.key) throw err('key: {key} required (e.g. Enter, Escape, Tab, ArrowDown)');
await page.keyboard.press(args.key);
return ok({ pressed: args.key, isTrusted: true });
}
throw err(`unknown input type "${args.type}". Supported: click | move | type | key`);
},
async ffbrowser_errors(args) {
const tab = activeTabOf(args);
return ok({ errors: tab.errors, count: tab.errors.length });
},
async ffbrowser_status() {
const out = [];
for (const id of sessions.keys()) out.push(await sessionInfo(id));
return ok({ bridge: 'ffpup', engine: 'firefox', sessionCount: sessions.size, activeSession: activeSessionId, sessions: out });
},
};
// ── helpers for handlers ─────────────────────────────────────────────
function err(message) { const e = new Error(message); e._ffpup = true; return e; }
function ok(obj) { return { success: true, output: JSON.stringify({ ok: true, ...obj }) }; }
function requireSession(sessionId) {
if (!sessionId) throw err('sessionId is required');
const s = sessions.get(sessionId);
if (!s) throw err(`session "${sessionId}" not found. Open one first with ffbrowser_open_window. Available: [${[...sessions.keys()].join(', ')}]`);
return s;
}
function activeTabOf(args) {
const s = requireSession(args.sessionId);
const tab = getTab(s, args.tabId);
if (!tab) throw err(`no open tab in session "${args.sessionId}"`);
return tab;
}
// ── HTTP server ──────────────────────────────────────────────────────
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', c => { data += c; });
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
function sendJSON(res, obj, status = 200) {
const body = JSON.stringify(obj);
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(body);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const pathname = url.pathname;
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' });
return res.end();
}
// Health — accept both /status (manifest) and /health (pup-compat).
if ((pathname === '/status' || pathname === '/health') && req.method === 'GET') {
return sendJSON(res, {
status: 'ok', bridge: 'ffpup', engine: 'firefox', port: PORT,
browserCount: browsers.size, sessionCount: sessions.size, activeSession: activeSessionId,
firefox: resolveFirefox() || '(puppeteer-managed)', headless: HEADLESS,
});
}
if (pathname === '/command' && req.method === 'POST') {
let command;
try {
const body = JSON.parse(await readBody(req) || '{}');
command = body.command;
const args = body.args || {};
const handler = handlers[command];
if (!handler) return sendJSON(res, { success: false, error: `unknown command "${command}". Known: ${Object.keys(handlers).join(', ')}` });
const result = await handler(args);
return sendJSON(res, result);
} catch (e) {
return sendJSON(res, { success: false, error: e && e.message ? e.message : String(e), command });
}
}
sendJSON(res, { success: false, error: `not found: ${req.method} ${pathname}` }, 404);
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[ffpup] listening on 127.0.0.1:${PORT} — firefox=${resolveFirefox() || '(puppeteer-managed)'} headless=${HEADLESS}`);
});
process.on('SIGTERM', () => { server.close(); process.exit(0); });
process.on('SIGINT', () => { server.close(); process.exit(0); });