479a0f1964
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
- Allow remote host aliases passed via --browser to fan out for read-only multi-browser SDK paths while preserving strict routing for mutating commands. - Add remote host grouping and scoped profile labels to tabs tree output so global views avoid repeated host prefixes. - Carry browser family metadata through remote targets, tabs, and groups and style tree browser labels by family. - Split CLI rendering helpers into a typed rendering package with dedicated common, label, tabs-tree, and windows-tree modules. - Bump browser-cli and extension versions to 0.15.5. - Cover the new routing and rendering behavior with unit and CLI tests.
216 lines
8.6 KiB
Python
216 lines
8.6 KiB
Python
"""
|
|
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 asyncio
|
|
from typing import Any
|
|
|
|
from browser_cli import local_transport
|
|
from browser_cli.client import auth, messages, targets as target_discovery
|
|
from browser_cli.client.targets import BrowserTarget
|
|
from browser_cli.remote import registry as remote_registry
|
|
|
|
from browser_cli.errors import BrowserNotConnected
|
|
from browser_cli.endpoints import _remote_display_name
|
|
from browser_cli.remote.transport import _send_remote, _send_remote_async
|
|
|
|
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
|
|
targets: list[BrowserTarget] = []
|
|
for item in items or []:
|
|
profile = str(item.get("profile") or "default")
|
|
display = str(item.get("displayName") or profile)
|
|
display_name = _remote_display_name(endpoint, profile, display)
|
|
browser_name = item.get("browserName") or item.get("name")
|
|
targets.append(
|
|
BrowserTarget(
|
|
profile=profile,
|
|
display_name=display_name,
|
|
socket_path="",
|
|
remote=endpoint,
|
|
browser_name=str(browser_name) if browser_name else None,
|
|
display_group=display_name.rsplit(":", 1)[0],
|
|
)
|
|
)
|
|
return targets
|
|
|
|
def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
|
"""Return browser targets advertised by a single remote endpoint."""
|
|
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
|
return _remote_target_items(
|
|
endpoint,
|
|
send_command("browser-cli.targets", remote=endpoint, key=key, **kwargs),
|
|
)
|
|
|
|
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
|
targets: list[BrowserTarget] = []
|
|
for endpoint in remote_registry.load_remotes():
|
|
try:
|
|
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
|
|
except (BrowserNotConnected, RuntimeError):
|
|
continue
|
|
return targets
|
|
|
|
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
|
"""Return remote targets matching a user-facing alias.
|
|
|
|
Exact browser aliases such as ``host:profile`` return one target. Endpoint
|
|
aliases such as ``host`` or ``host:8765`` may return multiple targets, which
|
|
lets read/list SDK commands fan out while command dispatch can still reject
|
|
the ambiguous target.
|
|
"""
|
|
if not alias:
|
|
return []
|
|
targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets()
|
|
for target in targets:
|
|
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
|
if alias in {target.display_name, endpoint_profile}:
|
|
return [target]
|
|
|
|
endpoint_matches = []
|
|
for target in targets:
|
|
if not target.remote:
|
|
continue
|
|
remote_host, sep, _remote_port = target.remote.rpartition(":")
|
|
if alias == target.remote or (sep and alias == remote_host):
|
|
endpoint_matches.append(target)
|
|
return endpoint_matches
|
|
|
|
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
|
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
|
matches = remote_targets_for_alias(alias)
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if len(matches) > 1:
|
|
aliases = [target.profile for target in matches]
|
|
endpoint = matches[0].remote or alias or "remote"
|
|
examples = "\n".join(
|
|
f" browser-cli --remote {endpoint} --browser {a} ..."
|
|
for a in aliases
|
|
)
|
|
display_aliases = [target.display_name for target in matches]
|
|
shorthand_examples = "\n".join(
|
|
f" browser-cli --browser {a} ..."
|
|
for a in display_aliases
|
|
)
|
|
raise BrowserNotConnected(
|
|
f"Multiple remote browser instances are active on {alias}: {', '.join(aliases)}\n"
|
|
f"Use --browser <alias> with --remote to select one:\n{examples}\n"
|
|
f"Or use the full remote browser alias:\n{shorthand_examples}"
|
|
)
|
|
return None
|
|
|
|
def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
|
targets = target_discovery.active_local_browser_targets()
|
|
if include_remotes:
|
|
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
|
|
return targets
|
|
|
|
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
|
targets = remote_browser_targets(endpoint, key=key)
|
|
if len(targets) == 1:
|
|
return targets[0].profile
|
|
if len(targets) > 1:
|
|
aliases = [target.profile for target in targets]
|
|
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
|
|
raise BrowserNotConnected(
|
|
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
|
|
f"Use --browser <alias> to select one:\n{examples}"
|
|
)
|
|
return None
|
|
|
|
def send_command(
|
|
command: str,
|
|
args: dict | None = None,
|
|
profile: str | None = None,
|
|
remote: str | None = None,
|
|
key: "Path | None" = None,
|
|
*,
|
|
suppress_pq_warning: bool = False,
|
|
) -> Any:
|
|
"""Send a command to the browser and return the response data."""
|
|
requested_profile, remote_endpoint = messages.requested_target(profile, remote)
|
|
if not remote_endpoint and requested_profile and not target_discovery.is_active_local_profile(requested_profile):
|
|
if remote_alias_target := remote_target_for_alias(requested_profile):
|
|
remote_endpoint = remote_alias_target.remote
|
|
requested_profile = remote_alias_target.profile
|
|
|
|
msg = messages.base_message(command, args)
|
|
private_key = None
|
|
if remote_endpoint:
|
|
if suppress_pq_warning:
|
|
msg["_suppress_pq_warning"] = True
|
|
private_key = auth.add_remote_auth_fields(msg, command, requested_profile, remote_endpoint, key, _auto_route_remote)
|
|
|
|
try:
|
|
payload = messages.encode_payload(msg)
|
|
response = (
|
|
_send_remote(remote_endpoint, msg, private_key)
|
|
if remote_endpoint
|
|
else local_transport.send_local_sync(requested_profile, payload, target_discovery.resolve_socket)
|
|
)
|
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
|
|
|
|
return messages.decode_response(response)
|
|
|
|
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
|
|
"""Async variant of :func:`remote_browser_targets`."""
|
|
return _remote_target_items(
|
|
endpoint,
|
|
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
|
|
)
|
|
|
|
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
|
|
targets = await remote_browser_targets_async(endpoint, key=key)
|
|
if len(targets) == 1:
|
|
return targets[0].profile
|
|
if len(targets) > 1:
|
|
aliases = [target.profile for target in targets]
|
|
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
|
|
raise BrowserNotConnected(
|
|
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
|
|
f"Use --browser <alias> to select one:\n{examples}"
|
|
)
|
|
return None
|
|
|
|
async def send_command_async(
|
|
command: str,
|
|
args: dict | None = None,
|
|
profile: str | None = None,
|
|
remote: str | None = None,
|
|
key: "Path | None" = None,
|
|
*,
|
|
suppress_pq_warning: bool = False,
|
|
) -> Any:
|
|
"""Async variant of :func:`send_command` with native async socket/TCP paths."""
|
|
requested_profile, remote_endpoint = messages.requested_target(profile, remote)
|
|
if not remote_endpoint and requested_profile and not await asyncio.to_thread(target_discovery.is_active_local_profile, requested_profile):
|
|
if remote_alias_target := await asyncio.to_thread(remote_target_for_alias, requested_profile):
|
|
remote_endpoint = remote_alias_target.remote
|
|
requested_profile = remote_alias_target.profile
|
|
|
|
msg = messages.base_message(command, args)
|
|
private_key = None
|
|
if remote_endpoint:
|
|
if suppress_pq_warning:
|
|
msg["_suppress_pq_warning"] = True
|
|
private_key = await auth.add_remote_auth_fields_async(msg, command, requested_profile, remote_endpoint, key, _auto_route_remote_async)
|
|
|
|
try:
|
|
payload = messages.encode_payload(msg)
|
|
response = (
|
|
await _send_remote_async(remote_endpoint, msg, private_key)
|
|
if remote_endpoint
|
|
else await local_transport.send_local_async(requested_profile, payload, target_discovery.resolve_socket)
|
|
)
|
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
|
|
|
|
return messages.decode_response(response)
|