Add .claude/scripts/ff.py, a Firefox browser driver built on Playwright and the Firefox sibling of the existing cdp.py Chrome driver. It runs a small background daemon holding one Playwright Firefox page on a persistent profile, controlled over localhost:9333, with subcommands launch/status/nav/shot/click/ type/eval/console/network/stop. Verified end-to-end (real screenshot, network and console capture). This is now the preferred browser-automation path because Mike dislikes Chrome and the claude-in-chrome extension (that connector was disabled in ~/.claude.json this session - not a repo change). Add memory reference_ff_firefox_driver.md documenting the driver and an index line in MEMORY.md. The MEMORY.md change also unavoidably includes a pre-existing adjacent index line for reference_antigravity_agy_not_headless.md, so that memory file is bundled in to keep the index consistent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
#!/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 <url> # navigate the page
|
|
py ff.py shot <out.png> # screenshot the page to a PNG file
|
|
py ff.py click <x> <y> # left-click at viewport coords
|
|
py ff.py type <text> # insert text into the focused element
|
|
py ff.py key <Key> # press a key (Enter/Tab/Escape/...)
|
|
py ff.py eval <js> # 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 <url>")
|
|
_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.png>")
|
|
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 <x> <y>")
|
|
_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 <text>")
|
|
_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 <Key>")
|
|
_req("/key", "POST", {"key": a[0]})
|
|
print(f"[ff] key {a[0]}")
|
|
|
|
|
|
def cmd_eval(a):
|
|
_need(a, 1, "usage: ff.py eval <js>")
|
|
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:])
|