#!/usr/bin/env python """ ff.py - drive Firefox over Playwright, the Firefox sibling of cdp.py. Firefox dropped most of its CDP support, so the stateless "new connection per command" trick cdp.py uses against Chrome's debug port doesn't port cleanly. Instead `launch` spawns a small background daemon that holds ONE Playwright Firefox page (on a persistent profile, so logins survive); every other subcommand is a thin HTTP client to that daemon. The page persists between calls (nav now, shot later) and the daemon accumulates console + network logs for retrieval -- the "collect the logs" use case. Usage: py ff.py launch [url] [--headless] # start the background Firefox daemon py ff.py status # daemon health + current url/title py ff.py nav # navigate the page py ff.py shot # screenshot the page to a PNG file py ff.py click # left-click at viewport coords py ff.py type # insert text into the focused element py ff.py key # press a key (Enter/Tab/Escape/...) py ff.py eval # page.evaluate(js), prints JSON result py ff.py console [--clear] # dump collected console messages (JSON) py ff.py network [--clear] # dump collected network requests (JSON) py ff.py stop # shut the daemon down Env: FF_PORT (control port, default 9333) FF_PROFILE (default %USERPROFILE%\\.claude\\ff-profile) """ import sys, os, json, time, subprocess, urllib.request, urllib.error PORT = int(os.environ.get("FF_PORT", "9333")) BASE = f"http://localhost:{PORT}" PROFILE = os.environ.get("FF_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "ff-profile")) # --------------------------------------------------------------------------- # # client side (the CLI you actually type) # --------------------------------------------------------------------------- # def _req(path, method="GET", body=None, timeout=30): data = json.dumps(body).encode() if body is not None else None r = urllib.request.Request(BASE + path, data=data, method=method, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(r, timeout=timeout) as resp: raw = resp.read().decode() return json.loads(raw) if raw else {} def _alive(): try: _req("/status", timeout=2) return True except Exception: return False def cmd_launch(args): headless = "--headless" in args url = next((a for a in args if not a.startswith("--")), None) if _alive(): print(f"[ff] daemon already running on {BASE}") if url: _req("/nav", "POST", {"url": _fix(url)}) print(f"[ff] navigated -> {_fix(url)}") return os.makedirs(PROFILE, exist_ok=True) flags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008 # DETACHED_PROCESS env = dict(os.environ, FF_DAEMON="1", FF_HEADLESS="1" if headless else "0", FF_START_URL=_fix(url) if url else "about:blank") # Redirect the detached child's stdio to a logfile -- otherwise it inherits # the parent's stdout pipe (caller never gets control back) and any startup # crash is invisible. log = open(os.path.join(os.path.dirname(PROFILE), "ff-daemon.log"), "w") subprocess.Popen([sys.executable, os.path.abspath(__file__), "_serve"], env=env, creationflags=flags, close_fds=True, stdin=subprocess.DEVNULL, stdout=log, stderr=log) for _ in range(60): if _alive(): print(f"[ff] daemon up on {BASE} (headless={headless}) profile={PROFILE}") if url: print(f"[ff] start url -> {_fix(url)}") return time.sleep(0.5) raise SystemExit("[ff] daemon failed to start (check that 'py -m playwright install firefox' ran)") def _fix(url): if url and "://" not in url and url != "about:blank": return "https://" + url return url def _need(args, n, what): if len(args) < n: raise SystemExit(f"[ff] {what}") def cmd_status(a): print(json.dumps(_req("/status"), indent=2)) def cmd_nav(a): _need(a, 1, "usage: ff.py nav ") _req("/nav", "POST", {"url": _fix(a[0])}) print(f"[ff] navigated -> {_fix(a[0])}") def cmd_shot(a): _need(a, 1, "usage: ff.py shot ") out = os.path.abspath(a[0]) _req("/shot", "POST", {"path": out}) print(f"[ff] screenshot -> {out} ({os.path.getsize(out)} bytes)") def cmd_click(a): _need(a, 2, "usage: ff.py click ") _req("/click", "POST", {"x": float(a[0]), "y": float(a[1])}) print(f"[ff] click ({a[0]},{a[1]})") def cmd_type(a): _need(a, 1, "usage: ff.py type ") _req("/type", "POST", {"text": a[0]}) print(f"[ff] typed {len(a[0])} chars") def cmd_key(a): _need(a, 1, "usage: ff.py key ") _req("/key", "POST", {"key": a[0]}) print(f"[ff] key {a[0]}") def cmd_eval(a): _need(a, 1, "usage: ff.py eval ") print(json.dumps(_req("/eval", "POST", {"js": a[0]}).get("value"), indent=2, default=str)) def cmd_console(a): res = _req("/console" + ("?clear=1" if "--clear" in a else "")) print(json.dumps(res.get("messages", []), indent=2, default=str)) def cmd_network(a): res = _req("/network" + ("?clear=1" if "--clear" in a else "")) print(json.dumps(res.get("requests", []), indent=2, default=str)) def cmd_stop(a): if not _alive(): print("[ff] daemon not running") return try: _req("/stop", "POST", {}, timeout=5) except Exception: pass print("[ff] daemon stopped") # --------------------------------------------------------------------------- # # daemon side (py ff.py _serve) -- holds the live Firefox page # --------------------------------------------------------------------------- # def serve(): from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs import threading from playwright.sync_api import sync_playwright headless = os.environ.get("FF_HEADLESS") == "1" start_url = os.environ.get("FF_START_URL", "about:blank") pw = sync_playwright().start() ctx = pw.firefox.launch_persistent_context(PROFILE, headless=headless, viewport={"width": 1280, "height": 800}) page = ctx.pages[0] if ctx.pages else ctx.new_page() console_log, network_log = [], [] page.on("console", lambda m: console_log.append( {"type": m.type, "text": m.text, "location": m.location})) page.on("response", lambda r: network_log.append( {"status": r.status, "method": r.request.method, "url": r.url, "type": r.request.resource_type})) page.on("pageerror", lambda e: console_log.append( {"type": "pageerror", "text": str(e), "location": {}})) if start_url and start_url != "about:blank": try: page.goto(start_url, wait_until="load", timeout=30000) except Exception: pass class H(BaseHTTPRequestHandler): def log_message(self, *a): # silence pass def _reply(self, obj, code=200): b = json.dumps(obj, default=str).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(b))) self.end_headers() self.wfile.write(b) def _body(self): n = int(self.headers.get("Content-Length", 0)) return json.loads(self.rfile.read(n)) if n else {} def do_GET(self): u = urlparse(self.path) q = parse_qs(u.query) try: if u.path == "/status": self._reply({"ok": True, "url": page.url, "title": page.title(), "headless": headless, "console": len(console_log), "network": len(network_log)}) elif u.path == "/console": msgs = list(console_log) if q.get("clear"): console_log.clear() self._reply({"messages": msgs}) elif u.path == "/network": reqs = list(network_log) if q.get("clear"): network_log.clear() self._reply({"requests": reqs}) else: self._reply({"error": "not found"}, 404) except Exception as e: self._reply({"error": str(e)}, 500) def do_POST(self): u = urlparse(self.path) try: b = self._body() if u.path == "/nav": page.goto(b["url"], wait_until="load", timeout=30000) self._reply({"ok": True, "url": page.url}) elif u.path == "/shot": page.screenshot(path=b["path"], full_page=b.get("full", False)) self._reply({"ok": True}) elif u.path == "/click": page.mouse.click(b["x"], b["y"]) self._reply({"ok": True}) elif u.path == "/type": page.keyboard.insert_text(b["text"]) self._reply({"ok": True}) elif u.path == "/key": page.keyboard.press(b["key"]) self._reply({"ok": True}) elif u.path == "/eval": self._reply({"value": page.evaluate(b["js"])}) elif u.path == "/stop": self._reply({"ok": True}) threading.Thread(target=httpd.shutdown, daemon=True).start() else: self._reply({"error": "not found"}, 404) except Exception as e: self._reply({"error": str(e)}, 500) httpd = HTTPServer(("127.0.0.1", PORT), H) try: httpd.serve_forever() finally: try: ctx.close() except Exception: pass pw.stop() CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot, "click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval, "console": cmd_console, "network": cmd_network, "stop": cmd_stop} if __name__ == "__main__": if len(sys.argv) >= 2 and sys.argv[1] == "_serve": serve() elif len(sys.argv) < 2 or sys.argv[1] not in CMDS: print(__doc__) raise SystemExit(1) else: CMDS[sys.argv[1]](sys.argv[2:])