/ 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