utils/flutter_web_drive/browser.py
Krzysztof kuhy Rudnicki f93a24e283 flutter_web_drive: headless CDP driver + MCP server for Flutter web apps
Drives a Flutter web build in a persistent headless chromium (systemd-run
unit, own profile, CDP port) through the semantics tree: tree, tap by label,
text, key, scroll, wait, shot, console. Never touches the live display;
attaches only to HeadlessChrome. The MCP server wraps the same Driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ4sULGfyav7EEwQ6vhbfg
2026-10-24 22:37:49 +02:00

82 lines
2.4 KiB
Python

"""The headless chromium unit: argv, systemd-run wrapper, size parsing."""
from __future__ import annotations
import re
import subprocess
from collections.abc import Callable
from pathlib import Path
from flutter_web_drive.errors import EXIT_USAGE, DriveError
# The real binary: /usr/local/bin/chromium is a hosts-installer wrapper.
CHROMIUM = "/usr/bin/chromium"
Runner = Callable[[list[str]], int]
def run(cmd: list[str]) -> int:
"""Run a short command quietly and return its exit code."""
return subprocess.run(cmd, check=False, capture_output=True).returncode
def parse_size(size: str) -> tuple[int, int]:
"""``1400x1000`` -> (1400, 1000)."""
m = re.fullmatch(r"(\d+)x(\d+)", size)
if not m:
raise DriveError(
"start", f"--size must look like 1400x1000, got {size!r}", EXIT_USAGE
)
return int(m[1]), int(m[2])
def chromium_argv(
url: str, profile: Path, cdp_port: int, size: tuple[int, int]
) -> list[str]:
"""Headless chromium with CDP on loopback and a persistent profile.
``--disable-extensions`` keeps the Claude extension (loaded via
chromium-flags.conf) out of this CDP session; ``--password-store=basic``
stops chromium asking the desktop keyring, which could prompt on :0.
"""
width, height = size
return [
CHROMIUM,
"--headless=new",
f"--remote-debugging-port={cdp_port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={profile}",
"--disable-extensions",
"--no-first-run",
"--no-default-browser-check",
"--password-store=basic",
"--mute-audio",
"--hide-scrollbars",
"--enable-unsafe-swiftshader",
f"--window-size={width},{height}",
url,
]
def unit_argv(unit: str, argv: list[str]) -> list[str]:
"""``systemd-run --user`` so the browser outlives the calling shell.
DISPLAY/WAYLAND_DISPLAY are unset in the unit: even a mis-flagged
chromium then has no live display to open a window on.
"""
return [
"systemd-run",
"--user",
f"--unit={unit}",
"--collect",
"--quiet",
"-p",
"UnsetEnvironment=DISPLAY WAYLAND_DISPLAY",
*argv,
]
def unit_active(unit: str, runner: Runner) -> bool:
"""Is the systemd user unit running?"""
return runner(["systemctl", "--user", "is-active", "--quiet", unit]) == 0