/ hermes_cli / browser_connect.py
browser_connect.py
  1  """Shared helpers for attaching Hermes to a local Chrome CDP port."""
  2  
  3  from __future__ import annotations
  4  
  5  import os
  6  import platform
  7  import shlex
  8  import shutil
  9  import subprocess
 10  
 11  from hermes_constants import get_hermes_home
 12  
 13  
 14  DEFAULT_BROWSER_CDP_PORT = 9222
 15  DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}"
 16  
 17  _DARWIN_APPS = (
 18      "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
 19      "/Applications/Chromium.app/Contents/MacOS/Chromium",
 20      "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
 21      "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
 22  )
 23  
 24  _WINDOWS_INSTALL_PARTS = (
 25      ("Google", "Chrome", "Application", "chrome.exe"),
 26      ("Chromium", "Application", "chrome.exe"),
 27      ("Chromium", "Application", "chromium.exe"),
 28      ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
 29      ("Microsoft", "Edge", "Application", "msedge.exe"),
 30  )
 31  
 32  _LINUX_BIN_NAMES = (
 33      "google-chrome", "google-chrome-stable", "chromium-browser",
 34      "chromium", "brave-browser", "microsoft-edge",
 35  )
 36  
 37  _WINDOWS_BIN_NAMES = (
 38      "chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
 39      "chrome", "msedge", "brave", "chromium",
 40  )
 41  
 42  
 43  def get_chrome_debug_candidates(system: str) -> list[str]:
 44      candidates: list[str] = []
 45      seen: set[str] = set()
 46  
 47      def add(path: str | None) -> None:
 48          if not path:
 49              return
 50          normalized = os.path.normcase(os.path.normpath(path))
 51          if normalized in seen or not os.path.isfile(path):
 52              return
 53          candidates.append(path)
 54          seen.add(normalized)
 55  
 56      def add_install_paths(bases: tuple[str | None, ...]) -> None:
 57          for base in filter(None, bases):
 58              for parts in _WINDOWS_INSTALL_PARTS:
 59                  add(os.path.join(base, *parts))
 60  
 61      if system == "Darwin":
 62          for app in _DARWIN_APPS:
 63              add(app)
 64          return candidates
 65  
 66      if system == "Windows":
 67          for name in _WINDOWS_BIN_NAMES:
 68              add(shutil.which(name))
 69          add_install_paths((
 70              os.environ.get("ProgramFiles"),
 71              os.environ.get("ProgramFiles(x86)"),
 72              os.environ.get("LOCALAPPDATA"),
 73          ))
 74          return candidates
 75  
 76      for name in _LINUX_BIN_NAMES:
 77          add(shutil.which(name))
 78      add_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"))
 79      return candidates
 80  
 81  
 82  def chrome_debug_data_dir() -> str:
 83      return str(get_hermes_home() / "chrome-debug")
 84  
 85  
 86  def _chrome_debug_args(port: int) -> list[str]:
 87      return [
 88          f"--remote-debugging-port={port}",
 89          f"--user-data-dir={chrome_debug_data_dir()}",
 90          "--no-first-run",
 91          "--no-default-browser-check",
 92      ]
 93  
 94  
 95  def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
 96      system = system or platform.system()
 97      candidates = get_chrome_debug_candidates(system)
 98  
 99      if candidates:
100          argv = [candidates[0], *_chrome_debug_args(port)]
101          return subprocess.list2cmdline(argv) if system == "Windows" else shlex.join(argv)
102  
103      if system == "Darwin":
104          data_dir = chrome_debug_data_dir()
105          return (
106              f'open -a "Google Chrome" --args --remote-debugging-port={port} '
107              f'--user-data-dir="{data_dir}" --no-first-run --no-default-browser-check'
108          )
109  
110      return None
111  
112  
113  def _detach_kwargs(system: str) -> dict:
114      if system != "Windows":
115          return {"start_new_session": True}
116      flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
117          subprocess, "CREATE_NEW_PROCESS_GROUP", 0
118      )
119      return {"creationflags": flags} if flags else {}
120  
121  
122  def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool:
123      system = system or platform.system()
124      candidates = get_chrome_debug_candidates(system)
125      if not candidates:
126          return False
127  
128      os.makedirs(chrome_debug_data_dir(), exist_ok=True)
129      try:
130          subprocess.Popen(
131              [candidates[0], *_chrome_debug_args(port)],
132              stdout=subprocess.DEVNULL,
133              stderr=subprocess.DEVNULL,
134              **_detach_kwargs(system),
135          )
136          return True
137      except Exception:
138          return False