mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-23 07:07:10 +08:00
fix(browser): drive system Chrome via spawn+CDP
This commit is contained in:
149
agent/tools/browser/chrome_launcher.py
Normal file
149
agent/tools/browser/chrome_launcher.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Spawn a system Chrome/Edge with a DevTools debugging port for CDP control.
|
||||
|
||||
Why this exists: driving a system browser via Playwright's
|
||||
``chromium.launch(channel="chrome")`` makes the app *take over* another app's
|
||||
process, which on macOS triggers a TCC "Automation" permission prompt and a
|
||||
multi-second (sometimes 100s+) stall on first use. Launching Chrome ourselves
|
||||
with ``--remote-debugging-port`` and attaching via ``connect_over_cdp`` avoids
|
||||
that entirely — from the OS's view it's just a process listening on a local
|
||||
port — and matches how Codex / Claude Code drive the user's real browser.
|
||||
|
||||
The launched process uses an isolated ``--user-data-dir`` so it never fights
|
||||
the user's day-to-day browser profile, while still persisting login state
|
||||
across sessions inside that dir.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from typing import Optional, List
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class ChromeLauncher:
|
||||
"""Own the lifecycle of a debugging-enabled Chrome/Edge child process."""
|
||||
|
||||
def __init__(self, executable: str, user_data_dir: str,
|
||||
extra_args: Optional[List[str]] = None,
|
||||
headless: bool = False):
|
||||
self._executable = executable
|
||||
self._user_data_dir = user_data_dir
|
||||
self._extra_args = extra_args or []
|
||||
self._headless = headless
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._port: Optional[int] = None
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""CDP HTTP endpoint (only valid after a successful launch())."""
|
||||
return f"http://127.0.0.1:{self._port}" if self._port else ""
|
||||
|
||||
@staticmethod
|
||||
def _free_port() -> int:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def launch(self, ready_timeout: float = 25.0) -> str:
|
||||
"""Spawn Chrome and block until its CDP endpoint answers.
|
||||
|
||||
Returns the CDP endpoint URL. Raises RuntimeError if the endpoint never
|
||||
comes up (the child process is killed in that case).
|
||||
"""
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
self._port = self._free_port()
|
||||
|
||||
args = [
|
||||
self._executable,
|
||||
f"--remote-debugging-port={self._port}",
|
||||
f"--user-data-dir={self._user_data_dir}",
|
||||
# Trim first-run overhead and background chatter for faster starts.
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,OptimizationHints",
|
||||
# A blank first tab keeps startup cheap and predictable.
|
||||
"about:blank",
|
||||
]
|
||||
if self._headless:
|
||||
args.insert(1, "--headless=new")
|
||||
args[1:1] = self._extra_args
|
||||
|
||||
popen_kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
# Detach from any console and never flash a window on Windows.
|
||||
popen_kwargs["creationflags"] = (
|
||||
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
)
|
||||
else:
|
||||
# New session so the child isn't tied to the parent's controlling
|
||||
# terminal / process group (clean teardown, no signal bleed).
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
logger.info(f"[Browser] Spawning {os.path.basename(self._executable)} "
|
||||
f"on CDP port {self._port} (profile={self._user_data_dir})")
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**popen_kwargs,
|
||||
)
|
||||
|
||||
if not self._wait_ready(ready_timeout):
|
||||
self.close()
|
||||
raise RuntimeError(
|
||||
f"Chrome did not expose a CDP endpoint on port {self._port} "
|
||||
f"within {ready_timeout:.0f}s"
|
||||
)
|
||||
return self.endpoint
|
||||
|
||||
def _wait_ready(self, timeout: float) -> bool:
|
||||
"""Poll DevTools /json/version until Chrome is listening (or times out)."""
|
||||
deadline = time.time() + timeout
|
||||
url = f"http://127.0.0.1:{self._port}/json/version"
|
||||
while time.time() < deadline:
|
||||
# Bail out early if the process died on startup.
|
||||
if self._proc and self._proc.poll() is not None:
|
||||
logger.error(
|
||||
f"[Browser] Chrome exited early (code={self._proc.returncode}) "
|
||||
"before opening the CDP port"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def close(self):
|
||||
"""Terminate the spawned Chrome process (idempotent)."""
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
self._port = None
|
||||
if proc is None:
|
||||
return
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] error terminating Chrome process: {e}")
|
||||
Reference in New Issue
Block a user