diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index a977056..3cdc32e 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -54,7 +54,7 @@ class BrowserCLI: if self._browser is not None: return [] targets = active_browser_targets() - if len(targets) <= 1: + if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets @@ -62,7 +62,10 @@ class BrowserCLI: results = [] for target in self._multi_browser_targets(): try: - data = send_command(command, args, profile=target.profile) + if target.remote: + data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token) + else: + data = send_command(command, args, profile=target.profile) except (BrowserNotConnected, RuntimeError): continue results.append((target, data)) diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 93426c4..3faa419 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -31,6 +31,7 @@ from browser_cli.client import ( REGISTRY_PATH, active_browser_targets, display_browser_name, + save_remote_token, ) from browser_cli.platform import install_base_dir, is_windows @@ -185,6 +186,8 @@ def main(ctx, browser, remote, token): ctx.obj["token"] = token if remote: os.environ["BROWSER_CLI_REMOTE"] = remote + if token: + save_remote_token(remote, token) if token: os.environ["BROWSER_CLI_TOKEN"] = token @@ -249,6 +252,17 @@ def clients_group(ctx): "extensionVersion": "disconnected", }) + for target in active_browser_targets(): + if target.remote is None: + continue + try: + result = send_command("clients.list", profile=target.profile, remote=target.remote, token=target.token) + for c in (result or []): + c["profile"] = target.display_name + all_clients.append(c) + except (BrowserNotConnected, RuntimeError): + continue + if not all_clients: console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]") sys.exit(1) diff --git a/browser_cli/client.py b/browser_cli/client.py index 3aef0a5..45fe403 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -21,6 +21,7 @@ from typing import Any from browser_cli.platform import endpoint_for_alias, is_windows, registry_path REGISTRY_PATH = registry_path() +REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json" class BrowserNotConnected(Exception): @@ -32,6 +33,8 @@ class BrowserTarget: profile: str display_name: str socket_path: str + remote: str | None = None + token: str | None = None def _active_endpoints(reg: dict) -> dict: @@ -47,17 +50,85 @@ def display_browser_name(profile_name: str, sock_path: str) -> str: return Path(sock_path).stem or profile_name -def active_browser_targets() -> list[BrowserTarget]: - if not REGISTRY_PATH.exists(): - return [] +def _load_remotes() -> dict[str, dict[str, str]]: + if not REMOTE_REGISTRY_PATH.exists(): + return {} try: - reg = json.loads(REGISTRY_PATH.read_text()) + data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8")) 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() - ] + return {} + if not isinstance(data, dict): + return {} + return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)} + + +def save_remote_token(endpoint: str, token: str | None) -> None: + """Persist the auth token for a remote endpoint used by this client.""" + if not endpoint or not token: + return + remotes = _load_remotes() + current = remotes.get(endpoint, {}) + current["token"] = token + remotes[endpoint] = current + REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True) + REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True), encoding="utf-8") + try: + REMOTE_REGISTRY_PATH.chmod(0o600) + except OSError: + pass + + +def token_for_remote(endpoint: str | None) -> str | None: + if not endpoint: + return None + cfg = _load_remotes().get(endpoint) or {} + token = cfg.get("token") + return str(token) if token else None + + +def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str: + host, sep, port = endpoint.rpartition(":") + remote_name = host if sep and port == "8765" else endpoint + return f"{remote_name}:{display_name or profile_name}" + + +def _remote_browser_targets() -> list[BrowserTarget]: + targets: list[BrowserTarget] = [] + for endpoint, cfg in _load_remotes().items(): + token = str(cfg.get("token") or "") or None + try: + remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token) + except (BrowserNotConnected, RuntimeError): + continue + for item in remote_targets or []: + profile = str(item.get("profile") or "default") + display = str(item.get("displayName") or profile) + targets.append( + BrowserTarget( + profile=profile, + display_name=_remote_display_name(endpoint, profile, display), + socket_path="", + remote=endpoint, + token=token, + ) + ) + return targets + + +def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]: + targets: list[BrowserTarget] = [] + if REGISTRY_PATH.exists(): + try: + reg = json.loads(REGISTRY_PATH.read_text()) + except Exception: + reg = {} + targets.extend( + BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path) + for profile, sock_path in _active_endpoints(reg).items() + ) + if include_remotes: + targets.extend(_remote_browser_targets()) + return targets def _resolve_socket(profile: str | None = None) -> str: @@ -76,7 +147,7 @@ def _resolve_socket(profile: str | None = None) -> str: # Auto-detect: error when multiple browser instances are active try: - active = active_browser_targets() + active = active_browser_targets(include_remotes=False) if len(active) > 1: aliases = [target.profile for target in active] examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases) @@ -101,7 +172,7 @@ def _resolve_socket(profile: str | None = None) -> str: def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None) -> Any: """Send a command to the browser and return the response data.""" remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE") - resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") + resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or token_for_remote(remote_endpoint) msg = { "id": str(uuid.uuid4()), "command": command, diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py index 88b57ad..89554d3 100644 --- a/browser_cli/commands/groups.py +++ b/browser_cli/commands/groups.py @@ -17,8 +17,10 @@ def _handle(command, args=None, profile=None): raise SystemExit(1) -def _handle_multi(command, args=None, profile=None): +def _handle_multi(command, args=None, profile=None, remote=None, token=None): try: + if remote: + return send_command(command, args or {}, profile=profile, remote=remote, token=token) return send_command(command, args or {}, profile=profile) except (BrowserNotConnected, RuntimeError): return None @@ -29,7 +31,7 @@ def _multi_browser_targets(): if root.obj.get("browser_explicit"): return [] targets = active_browser_targets() - if len(targets) <= 1: + if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets @@ -71,7 +73,7 @@ def group_list(): if targets: groups = [] for target in targets: - result = _handle_multi("group.list", profile=target.profile) + result = _handle_multi("group.list", profile=target.profile, remote=target.remote, token=target.token) if result is None: continue groups.extend({**group, "browser": target.display_name} for group in result) @@ -104,7 +106,7 @@ def group_count(): total = 0 rows = 0 for target in targets: - count = _handle_multi("group.count", profile=target.profile) + count = _handle_multi("group.count", profile=target.profile, remote=target.remote, token=target.token) if count is None: continue count = int(count or 0) diff --git a/browser_cli/commands/serve.py b/browser_cli/commands/serve.py index cbdef8e..d4d6266 100644 --- a/browser_cli/commands/serve.py +++ b/browser_cli/commands/serve.py @@ -56,6 +56,17 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv _log(addr, command, None, "DENIED", "bad token") return + if command == "browser-cli.targets": + from browser_cli.client import active_browser_targets + targets = [ + {"profile": target.profile, "displayName": target.display_name} + for target in active_browser_targets(include_remotes=False) + ] + data = json.dumps({"id": msg_id, "success": True, "data": targets}).encode() + client_sock.sendall(struct.pack("