mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 22:27:13 +08:00
fix(browser): drive system Chrome via spawn+CDP
This commit is contained in:
@@ -321,6 +321,14 @@ class BrowserService:
|
|||||||
self._context = None
|
self._context = None
|
||||||
self._page = None
|
self._page = None
|
||||||
|
|
||||||
|
# When we drive a system Chrome/Edge, we spawn it ourselves with a
|
||||||
|
# debugging port and attach over CDP (see chrome_launcher). This avoids
|
||||||
|
# the macOS Automation prompt + multi-second stall that
|
||||||
|
# chromium.launch(channel=...) incurs. Holds the child process owner.
|
||||||
|
self._chrome_launcher = None
|
||||||
|
# Path to the system browser executable when using system-chrome mode.
|
||||||
|
self._system_exe: Optional[str] = None
|
||||||
|
|
||||||
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
||||||
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
||||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||||
@@ -352,13 +360,29 @@ class BrowserService:
|
|||||||
|
|
||||||
# Resolve which browser engine to drive (system Chrome vs downloaded
|
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||||
# Chromium). Deferred detection failures are surfaced at launch time.
|
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||||
|
#
|
||||||
|
# For a system Chrome/Edge we DON'T use chromium.launch(channel=...):
|
||||||
|
# that "takes over" another app and triggers the macOS Automation
|
||||||
|
# prompt + a long stall. Instead we spawn the browser ourselves with a
|
||||||
|
# debugging port and attach over CDP (self._launch_mode = "system-cdp").
|
||||||
|
# `self._system_exe` is the browser executable; the persistent
|
||||||
|
# user_data_dir keeps login state across sessions.
|
||||||
if self._launch_mode != "cdp":
|
if self._launch_mode != "cdp":
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_env import resolve_engine
|
from agent.tools.browser.browser_env import resolve_engine
|
||||||
engine = resolve_engine(self._config)
|
engine = resolve_engine(self._config)
|
||||||
if engine["mode"] == "system-chrome":
|
if engine["mode"] == "system-chrome":
|
||||||
self._channel = engine["channel"]
|
self._channel = engine["channel"]
|
||||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
self._system_exe = engine.get("path")
|
||||||
|
# Only switch to spawn+CDP when we actually know the exe
|
||||||
|
# path (macOS/Windows/Linux detection returns it). Persist
|
||||||
|
# login state in a dedicated profile dir.
|
||||||
|
if self._system_exe:
|
||||||
|
self._launch_mode = "system-cdp"
|
||||||
|
if not self._user_data_dir:
|
||||||
|
self._user_data_dir = expand_path(_DEFAULT_USER_DATA_DIR)
|
||||||
|
logger.info(f"[Browser] Engine resolved: {engine['reason']} "
|
||||||
|
f"(spawn+CDP={bool(self._system_exe)})")
|
||||||
elif engine["mode"] == "playwright-chromium":
|
elif engine["mode"] == "playwright-chromium":
|
||||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||||
else:
|
else:
|
||||||
@@ -509,6 +533,8 @@ class BrowserService:
|
|||||||
|
|
||||||
if self._launch_mode == "cdp":
|
if self._launch_mode == "cdp":
|
||||||
self._connect_cdp(viewport)
|
self._connect_cdp(viewport)
|
||||||
|
elif self._launch_mode == "system-cdp":
|
||||||
|
self._launch_system_cdp(launch_args, viewport)
|
||||||
elif self._launch_mode == "persistent":
|
elif self._launch_mode == "persistent":
|
||||||
self._launch_persistent(launch_args, viewport, user_agent)
|
self._launch_persistent(launch_args, viewport, user_agent)
|
||||||
else:
|
else:
|
||||||
@@ -577,6 +603,41 @@ class BrowserService:
|
|||||||
self._page = pages[0] if pages else self._context.new_page()
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
self._wire_close_listeners()
|
self._wire_close_listeners()
|
||||||
|
|
||||||
|
def _launch_system_cdp(self, launch_args: List[str], viewport: Dict[str, int]):
|
||||||
|
"""Spawn the user's system Chrome/Edge with a debugging port, attach via CDP.
|
||||||
|
|
||||||
|
This is the default for system browsers. Unlike launch(channel=...), it
|
||||||
|
does not "take over" the browser app, so it avoids the macOS Automation
|
||||||
|
prompt / long stall. Login state persists in the isolated user_data_dir.
|
||||||
|
"""
|
||||||
|
from agent.tools.browser.chrome_launcher import ChromeLauncher
|
||||||
|
|
||||||
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
logger.info(
|
||||||
|
f"[Browser] Launching system:{self._channel} via spawn+CDP "
|
||||||
|
f"(headless={self._headless}, profile={self._user_data_dir})"
|
||||||
|
)
|
||||||
|
self._chrome_launcher = ChromeLauncher(
|
||||||
|
executable=self._system_exe,
|
||||||
|
user_data_dir=self._user_data_dir,
|
||||||
|
extra_args=launch_args,
|
||||||
|
headless=self._headless,
|
||||||
|
)
|
||||||
|
endpoint = self._chrome_launcher.launch()
|
||||||
|
|
||||||
|
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
|
||||||
|
# The spawned Chrome opens its own default context (backed by
|
||||||
|
# user_data_dir); reuse it so cookies / logins persist.
|
||||||
|
contexts = self._browser.contexts
|
||||||
|
self._context = contexts[0] if contexts else self._browser.new_context(viewport=viewport)
|
||||||
|
pages = self._context.pages
|
||||||
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
|
try:
|
||||||
|
self._page.set_viewport_size(viewport)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._wire_close_listeners()
|
||||||
|
|
||||||
def _connect_cdp(self, viewport: Dict[str, int]):
|
def _connect_cdp(self, viewport: Dict[str, int]):
|
||||||
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
||||||
endpoint = self._cdp_endpoint
|
endpoint = self._cdp_endpoint
|
||||||
@@ -631,13 +692,27 @@ class BrowserService:
|
|||||||
self._cancel_idle_timer()
|
self._cancel_idle_timer()
|
||||||
|
|
||||||
if self._launch_mode == "cdp":
|
if self._launch_mode == "cdp":
|
||||||
# For CDP, browser.close() only detaches the Playwright client;
|
# For external CDP, browser.close() only detaches the Playwright
|
||||||
# the user's Chrome process and its tabs stay alive.
|
# client; the user's Chrome process and its tabs stay alive.
|
||||||
try:
|
try:
|
||||||
if self._browser:
|
if self._browser:
|
||||||
self._browser.close()
|
self._browser.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
||||||
|
elif self._launch_mode == "system-cdp":
|
||||||
|
# We own the spawned Chrome: detach the CDP client, then kill the
|
||||||
|
# process we started so it doesn't linger.
|
||||||
|
try:
|
||||||
|
if self._browser:
|
||||||
|
self._browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] system-cdp disconnect error: {e}")
|
||||||
|
try:
|
||||||
|
if self._chrome_launcher:
|
||||||
|
self._chrome_launcher.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] chrome launcher close error: {e}")
|
||||||
|
self._chrome_launcher = None
|
||||||
else:
|
else:
|
||||||
for obj, label in [
|
for obj, label in [
|
||||||
(self._context, "context"),
|
(self._context, "context"),
|
||||||
|
|||||||
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