""" Local IPC client — sends commands to native host relay endpoint. Used by both CLI and public Python API. Profile selection order: 1. Explicit `profile` argument to send_command() 2. BROWSER_CLI_PROFILE environment variable 3. First entry in runtime registry 4. Otherwise, no browser can be resolved automatically """ import json import os import socket import struct import uuid from dataclasses import dataclass from multiprocessing.connection import Client as PipeClient from pathlib import Path from typing import Any from browser_cli.platform import endpoint_for_alias, is_windows, registry_path REGISTRY_PATH = registry_path() class BrowserNotConnected(Exception): """Raised when the native host socket is not available.""" @dataclass(frozen=True) class BrowserTarget: profile: str display_name: str socket_path: str def _active_endpoints(reg: dict) -> dict: """Return only entries whose endpoint appears reachable.""" if is_windows(): return dict(reg) return {k: v for k, v in reg.items() if Path(v).exists()} def display_browser_name(profile_name: str, sock_path: str) -> str: if profile_name != "default": return profile_name return Path(sock_path).stem or profile_name def active_browser_targets() -> list[BrowserTarget]: if not REGISTRY_PATH.exists(): return [] try: reg = json.loads(REGISTRY_PATH.read_text()) except Exception: return [] return [ BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path) for profile, sock_path in _active_endpoints(reg).items() ] def _resolve_socket(profile: str | None = None) -> str: """Return the socket path for the given profile (or auto-detect).""" target = profile or os.environ.get("BROWSER_CLI_PROFILE") if target: if REGISTRY_PATH.exists(): try: reg = json.loads(REGISTRY_PATH.read_text()) if target in reg: return reg[target] except Exception: pass return endpoint_for_alias(target) # Auto-detect: error when multiple browser instances are active try: active = active_browser_targets() if len(active) > 1: aliases = [target.profile for target in active] examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases) raise BrowserNotConnected( f"Multiple browser instances are active: {', '.join(aliases)}\n" f"Use --browser to select one:\n{examples}" ) if active: return active[0].socket_path except BrowserNotConnected: raise except Exception: pass raise BrowserNotConnected( "Cannot resolve a browser socket automatically.\n" "Make sure the browser is running with the browser-cli extension enabled,\n" "or pass --browser / set BROWSER_CLI_PROFILE to a known alias." ) def send_command(command: str, args: dict | None = None, profile: str | None = None) -> Any: """Send a command to the browser and return the response data.""" sock_path = _resolve_socket(profile) msg = { "id": str(uuid.uuid4()), "command": command, "args": args or {}, } payload = json.dumps(msg).encode("utf-8") framed = struct.pack(" to select a specific profile" ) result = json.loads(response) if not result.get("success", True): raise RuntimeError(result.get("error", "unknown error from browser")) return result.get("data") def _recv_all(sock: socket.socket) -> bytes: raw_len = _recv_exact(sock, 4) msg_len = struct.unpack(" bytes: buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("Socket closed before full message received") buf += chunk return buf