real-browser-cli 0.14.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_cli/__init__.py +164 -0
- browser_cli/async_sdk.py +237 -0
- browser_cli/auth.py +263 -0
- browser_cli/cli.py +151 -0
- browser_cli/client/__init__.py +47 -0
- browser_cli/client/auth.py +63 -0
- browser_cli/client/core.py +200 -0
- browser_cli/client/messages.py +45 -0
- browser_cli/client/targets.py +95 -0
- browser_cli/command_security.py +119 -0
- browser_cli/commands/__init__.py +81 -0
- browser_cli/commands/auth.py +157 -0
- browser_cli/commands/clients.py +173 -0
- browser_cli/commands/completion.py +56 -0
- browser_cli/commands/doctor.py +90 -0
- browser_cli/commands/dom.py +191 -0
- browser_cli/commands/events.py +52 -0
- browser_cli/commands/extension.py +42 -0
- browser_cli/commands/extract.py +70 -0
- browser_cli/commands/groups.py +108 -0
- browser_cli/commands/install.py +121 -0
- browser_cli/commands/navigate.py +96 -0
- browser_cli/commands/page.py +26 -0
- browser_cli/commands/perf.py +47 -0
- browser_cli/commands/raw.py +23 -0
- browser_cli/commands/remote.py +68 -0
- browser_cli/commands/script.py +68 -0
- browser_cli/commands/search.py +79 -0
- browser_cli/commands/serve.py +117 -0
- browser_cli/commands/serve_http.py +115 -0
- browser_cli/commands/session.py +163 -0
- browser_cli/commands/storage.py +36 -0
- browser_cli/commands/tabs.py +252 -0
- browser_cli/commands/watch.py +60 -0
- browser_cli/commands/windows.py +87 -0
- browser_cli/commands/workspace.py +91 -0
- browser_cli/compat/__init__.py +4 -0
- browser_cli/compat/auth.py +44 -0
- browser_cli/compat/commands.py +43 -0
- browser_cli/constants.py +95 -0
- browser_cli/endpoints.py +55 -0
- browser_cli/errors.py +9 -0
- browser_cli/framing.py +83 -0
- browser_cli/local_transport.py +64 -0
- browser_cli/markdown/__init__.py +8 -0
- browser_cli/markdown/html.py +259 -0
- browser_cli/markdown/render.py +188 -0
- browser_cli/models.py +182 -0
- browser_cli/native/__init__.py +1 -0
- browser_cli/native/host.py +211 -0
- browser_cli/native/local_server.py +111 -0
- browser_cli/native/protocol.py +30 -0
- browser_cli/platform.py +34 -0
- browser_cli/registry.py +99 -0
- browser_cli/remote/__init__.py +1 -0
- browser_cli/remote/registry.py +53 -0
- browser_cli/remote/transport.py +230 -0
- browser_cli/sdk/__init__.py +48 -0
- browser_cli/sdk/base.py +116 -0
- browser_cli/sdk/browser_data.py +37 -0
- browser_cli/sdk/decorators.py +107 -0
- browser_cli/sdk/dom.py +169 -0
- browser_cli/sdk/extension.py +24 -0
- browser_cli/sdk/factories.py +103 -0
- browser_cli/sdk/groups.py +51 -0
- browser_cli/sdk/navigation.py +122 -0
- browser_cli/sdk/perf.py +23 -0
- browser_cli/sdk/routing.py +149 -0
- browser_cli/sdk/session.py +72 -0
- browser_cli/sdk/tabs.py +213 -0
- browser_cli/sdk/windows.py +26 -0
- browser_cli/sdk/workflow_decorators.py +200 -0
- browser_cli/serve/__init__.py +0 -0
- browser_cli/serve/auth.py +107 -0
- browser_cli/serve/control.py +59 -0
- browser_cli/serve/logging.py +16 -0
- browser_cli/serve/proxy.py +79 -0
- browser_cli/serve/runtime.py +196 -0
- browser_cli/transport.py +214 -0
- browser_cli/version_manager.py +17 -0
- real_browser_cli-0.14.2.dist-info/METADATA +87 -0
- real_browser_cli-0.14.2.dist-info/RECORD +85 -0
- real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
- real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
- real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Shared logging helpers for ``browser-cli serve``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
def log_request(addr: tuple, command: str, profile: str | None, status: str, error: str | None = None) -> None:
|
|
10
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
11
|
+
addr_str = f"{addr[0]}:{addr[1]}"
|
|
12
|
+
profile_str = f"[dim]{profile}[/dim] " if profile else ""
|
|
13
|
+
if error:
|
|
14
|
+
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
|
15
|
+
else:
|
|
16
|
+
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Proxying from TCP clients to the local browser native-host socket."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from browser_cli import transport
|
|
8
|
+
from browser_cli.compat import adapt_request, adapt_response
|
|
9
|
+
from browser_cli.framing import async_recv_frame, async_send_frame
|
|
10
|
+
from browser_cli.serve.logging import log_request
|
|
11
|
+
|
|
12
|
+
_STRIP_PROTOCOL_FIELDS = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
|
|
13
|
+
|
|
14
|
+
class ServeProxyMixin:
|
|
15
|
+
addr: tuple
|
|
16
|
+
profile: str | None
|
|
17
|
+
client_ver: str
|
|
18
|
+
command: str
|
|
19
|
+
compress: bool
|
|
20
|
+
accept_encoding: dict | None
|
|
21
|
+
|
|
22
|
+
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
|
23
|
+
async def send_payload(self, data: bytes) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def forward_to_browser(self, msg: dict) -> None:
|
|
26
|
+
from browser_cli.client import BrowserNotConnected
|
|
27
|
+
from browser_cli.client.targets import resolve_socket
|
|
28
|
+
from browser_cli.platform import is_windows
|
|
29
|
+
|
|
30
|
+
resolved_profile = msg.get("_route") or self.profile
|
|
31
|
+
clean_msg = {k: v for k, v in msg.items() if k not in _STRIP_PROTOCOL_FIELDS}
|
|
32
|
+
clean_payload = json.dumps(adapt_request(clean_msg, self.client_ver)).encode()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
sock_path = resolve_socket(resolved_profile)
|
|
36
|
+
except BrowserNotConnected as e:
|
|
37
|
+
await self.send_error(str(e))
|
|
38
|
+
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
if is_windows():
|
|
43
|
+
resp_payload = await self._windows_roundtrip(sock_path, clean_payload)
|
|
44
|
+
else:
|
|
45
|
+
resp_payload = await self._unix_roundtrip(sock_path, clean_payload)
|
|
46
|
+
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
|
|
47
|
+
except (OSError, json.JSONDecodeError, ConnectionError) as e:
|
|
48
|
+
await self.send_error(str(e))
|
|
49
|
+
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
|
|
50
|
+
|
|
51
|
+
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
|
|
52
|
+
from multiprocessing.connection import Client as PipeClient
|
|
53
|
+
|
|
54
|
+
def _pipe_roundtrip():
|
|
55
|
+
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
|
56
|
+
pipe.send_bytes(payload)
|
|
57
|
+
return pipe.recv_bytes()
|
|
58
|
+
|
|
59
|
+
return await asyncio.to_thread(_pipe_roundtrip)
|
|
60
|
+
|
|
61
|
+
async def _unix_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
|
|
62
|
+
local_reader, local_writer = await asyncio.open_unix_connection(sock_path)
|
|
63
|
+
try:
|
|
64
|
+
await async_send_frame(local_writer, payload)
|
|
65
|
+
return await async_recv_frame(local_reader) or b""
|
|
66
|
+
finally:
|
|
67
|
+
local_writer.close()
|
|
68
|
+
await local_writer.wait_closed()
|
|
69
|
+
|
|
70
|
+
async def send_browser_response(self, resp_payload: bytes, resolved_profile: str | None) -> None:
|
|
71
|
+
resp_data = json.loads(resp_payload)
|
|
72
|
+
if self.compress:
|
|
73
|
+
await self.send_payload(transport.encode_response(resp_data, self.accept_encoding, self.command))
|
|
74
|
+
else:
|
|
75
|
+
await self.send_payload(resp_payload)
|
|
76
|
+
if resp_data.get("success", True):
|
|
77
|
+
log_request(self.addr, self.command, resolved_profile, "OK")
|
|
78
|
+
else:
|
|
79
|
+
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Runtime implementation for ``browser-cli serve``.
|
|
2
|
+
|
|
3
|
+
The Click command lives in ``browser_cli.commands.serve``. This module owns the
|
|
4
|
+
connection lifecycle; auth, control commands and browser proxying live in small
|
|
5
|
+
mixins so each piece can be tested/refactored independently.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import socket
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from browser_cli import transport
|
|
17
|
+
from browser_cli.compat import adapt_auth
|
|
18
|
+
from browser_cli.framing import async_recv_frame, async_send_frame
|
|
19
|
+
from browser_cli.serve.auth import ServeAuthMixin
|
|
20
|
+
from browser_cli.serve.control import ServeControlMixin
|
|
21
|
+
from browser_cli.serve.logging import console, log_request
|
|
22
|
+
from browser_cli.serve.proxy import ServeProxyMixin
|
|
23
|
+
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
|
|
24
|
+
|
|
25
|
+
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
|
26
|
+
await async_send_frame(writer, data)
|
|
27
|
+
|
|
28
|
+
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
|
29
|
+
return await async_recv_frame(reader) or b""
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
|
|
33
|
+
reader: asyncio.StreamReader
|
|
34
|
+
writer: asyncio.StreamWriter
|
|
35
|
+
addr: tuple
|
|
36
|
+
profile: str | None
|
|
37
|
+
auth_keys: list[str] | None
|
|
38
|
+
auth_keys_path: Path | None
|
|
39
|
+
nonce: str
|
|
40
|
+
pq_private_key: object | None = None
|
|
41
|
+
compress: bool = True
|
|
42
|
+
|
|
43
|
+
response_secret: bytes | None = None
|
|
44
|
+
accept_encoding: dict | None = None
|
|
45
|
+
client_ver: str = "0"
|
|
46
|
+
msg_id: object = None
|
|
47
|
+
command: str = "?"
|
|
48
|
+
|
|
49
|
+
async def send_payload(self, data: bytes) -> None:
|
|
50
|
+
if self.response_secret is not None:
|
|
51
|
+
from browser_cli.auth import pq_encrypt
|
|
52
|
+
data = json.dumps({"encrypted": pq_encrypt(self.response_secret, "response", data)}).encode()
|
|
53
|
+
await _async_framed_send(self.writer, data)
|
|
54
|
+
|
|
55
|
+
async def send_error(self, msg: str, msg_id=None) -> None:
|
|
56
|
+
err = json.dumps({"id": self.msg_id if msg_id is None else msg_id, "success": False, "error": msg}).encode()
|
|
57
|
+
try:
|
|
58
|
+
await self.send_payload(err)
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
async def send_ok(self, payload, command: str | None = None) -> None:
|
|
63
|
+
obj = {"id": self.msg_id, "success": True, "data": payload}
|
|
64
|
+
try:
|
|
65
|
+
await self.send_payload(transport.encode_response(obj, self.accept_encoding if self.compress else None, command))
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def read_message(self) -> dict | None:
|
|
70
|
+
try:
|
|
71
|
+
payload = await _async_recv_all(self.reader)
|
|
72
|
+
except (ConnectionError, OSError) as exc:
|
|
73
|
+
if "too large" in str(exc):
|
|
74
|
+
await self.send_error(str(exc), msg_id=None)
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
msg = json.loads(payload)
|
|
78
|
+
except (json.JSONDecodeError, ValueError):
|
|
79
|
+
await self.send_error("invalid JSON", msg_id=None)
|
|
80
|
+
log_request(self.addr, "?", None, "ERROR", "invalid JSON")
|
|
81
|
+
return None
|
|
82
|
+
return msg if isinstance(msg, dict) else None
|
|
83
|
+
|
|
84
|
+
async def run(self) -> None:
|
|
85
|
+
msg = await self.read_message()
|
|
86
|
+
if msg is None or not await self.validate_client(msg):
|
|
87
|
+
return
|
|
88
|
+
msg = adapt_auth(msg, self.client_ver)
|
|
89
|
+
self.command = msg.get("command", "?")
|
|
90
|
+
msg = await self.authenticate(msg)
|
|
91
|
+
if msg is None:
|
|
92
|
+
return
|
|
93
|
+
self.accept_encoding = msg.get("accept_encoding")
|
|
94
|
+
if await self.handle_control_command(msg):
|
|
95
|
+
return
|
|
96
|
+
await self.forward_to_browser(msg)
|
|
97
|
+
|
|
98
|
+
async def _async_proxy_request(
|
|
99
|
+
reader: asyncio.StreamReader,
|
|
100
|
+
writer: asyncio.StreamWriter,
|
|
101
|
+
addr: tuple,
|
|
102
|
+
profile: str | None,
|
|
103
|
+
auth_keys: list[str] | None,
|
|
104
|
+
auth_keys_path: Path | None,
|
|
105
|
+
nonce: str,
|
|
106
|
+
pq_private_key=None,
|
|
107
|
+
compress: bool = True,
|
|
108
|
+
) -> None:
|
|
109
|
+
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
|
|
110
|
+
|
|
111
|
+
async def _async_handle_client(
|
|
112
|
+
reader: asyncio.StreamReader,
|
|
113
|
+
writer: asyncio.StreamWriter,
|
|
114
|
+
addr: tuple,
|
|
115
|
+
profile: str | None,
|
|
116
|
+
auth_keys_path: Path | None,
|
|
117
|
+
compress: bool = True,
|
|
118
|
+
conn_limit: asyncio.Semaphore | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
if conn_limit is None:
|
|
121
|
+
conn_limit = asyncio.Semaphore(64)
|
|
122
|
+
if conn_limit.locked():
|
|
123
|
+
writer.close()
|
|
124
|
+
await writer.wait_closed()
|
|
125
|
+
return
|
|
126
|
+
await conn_limit.acquire()
|
|
127
|
+
try:
|
|
128
|
+
auth_keys = await _load_auth_keys(auth_keys_path)
|
|
129
|
+
nonce, pq_private_key, challenge_msg = await _build_challenge(auth_keys_path)
|
|
130
|
+
try:
|
|
131
|
+
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
|
|
132
|
+
except OSError:
|
|
133
|
+
return
|
|
134
|
+
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
|
|
135
|
+
finally:
|
|
136
|
+
conn_limit.release()
|
|
137
|
+
writer.close()
|
|
138
|
+
try:
|
|
139
|
+
await writer.wait_closed()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
|
|
144
|
+
if auth_keys_path is None:
|
|
145
|
+
return None
|
|
146
|
+
from browser_cli.auth import load_authorized_keys
|
|
147
|
+
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
|
|
148
|
+
|
|
149
|
+
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
|
|
150
|
+
nonce = secrets.token_hex(32)
|
|
151
|
+
pq_private_key = None
|
|
152
|
+
challenge_msg = {
|
|
153
|
+
"type": "challenge",
|
|
154
|
+
"nonce": nonce,
|
|
155
|
+
"server_version": get_installed_version(),
|
|
156
|
+
"min_client_version": PROTOCOL_MIN_CLIENT,
|
|
157
|
+
}
|
|
158
|
+
if auth_keys_path is not None:
|
|
159
|
+
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
|
|
160
|
+
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
|
|
161
|
+
if pq_keypair is not None:
|
|
162
|
+
pq_private_key, pq_public_key = pq_keypair
|
|
163
|
+
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
|
164
|
+
return nonce, pq_private_key, challenge_msg
|
|
165
|
+
|
|
166
|
+
def _handle_client(
|
|
167
|
+
client_sock: socket.socket,
|
|
168
|
+
addr: tuple,
|
|
169
|
+
profile: str | None,
|
|
170
|
+
auth_keys_path: Path | None,
|
|
171
|
+
compress: bool = True,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Run one accepted socket through the async serve pipeline."""
|
|
174
|
+
|
|
175
|
+
async def _run() -> None:
|
|
176
|
+
reader, writer = await asyncio.open_connection(sock=client_sock)
|
|
177
|
+
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
asyncio.run(_run())
|
|
181
|
+
except OSError:
|
|
182
|
+
try:
|
|
183
|
+
client_sock.close()
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
|
188
|
+
conn_limit = asyncio.Semaphore(64)
|
|
189
|
+
|
|
190
|
+
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
191
|
+
peer = writer.get_extra_info("peername") or ("?", 0)
|
|
192
|
+
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
|
|
193
|
+
|
|
194
|
+
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
|
|
195
|
+
async with server:
|
|
196
|
+
await server.serve_forever()
|
browser_cli/transport.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Response payload encoding for the TCP serve <-> client leg.
|
|
2
|
+
|
|
3
|
+
The wire frame stays ``4-byte LE length + payload``. The payload is made
|
|
4
|
+
self-describing so old peers keep working unchanged:
|
|
5
|
+
|
|
6
|
+
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
|
|
7
|
+
format). Old clients and old servers only ever produce/consume this.
|
|
8
|
+
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
|
|
9
|
+
The tag's high nibble selects serialization, the low nibble compression::
|
|
10
|
+
|
|
11
|
+
tag = (serialization << 4) | compression
|
|
12
|
+
|
|
13
|
+
This is only ever emitted toward a peer that advertised support for it, so it
|
|
14
|
+
is fully backward compatible: clients announce what they can decode via the
|
|
15
|
+
``accept_encoding`` field in their request, and the server encodes the
|
|
16
|
+
response accordingly. Requests themselves stay plain JSON (they are tiny).
|
|
17
|
+
|
|
18
|
+
Compression is the big win — response payloads (``extract.html``,
|
|
19
|
+
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
|
|
20
|
+
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
|
|
21
|
+
image as raw bytes instead of a base64 data URL (~33% smaller before
|
|
22
|
+
compression); the client transparently rebuilds the data URL so the SDK/CLI
|
|
23
|
+
API is unchanged.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import gzip
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import zlib
|
|
32
|
+
|
|
33
|
+
from browser_cli.constants import (
|
|
34
|
+
COMP_GZIP,
|
|
35
|
+
COMP_NONE,
|
|
36
|
+
COMP_ZLIB,
|
|
37
|
+
COMP_ZSTD,
|
|
38
|
+
DEFAULT_TRANSPORT_THRESHOLD,
|
|
39
|
+
SER_JSON,
|
|
40
|
+
SER_MSGPACK,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try: # optional: better ratio + speed than zlib/gzip
|
|
44
|
+
import zstandard as _zstd
|
|
45
|
+
except Exception: # pragma: no cover - depends on optional extra
|
|
46
|
+
_zstd = None
|
|
47
|
+
|
|
48
|
+
try: # optional: alternate serialization + raw binary for screenshots
|
|
49
|
+
import msgpack as _msgpack
|
|
50
|
+
except Exception: # pragma: no cover - depends on optional extra
|
|
51
|
+
_msgpack = None
|
|
52
|
+
|
|
53
|
+
# ── codec ids ────────────────────────────────────────────────────────────────
|
|
54
|
+
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
|
|
55
|
+
_SER_ID = {v: k for k, v in _SER_NAME.items()}
|
|
56
|
+
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
|
|
57
|
+
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
|
|
58
|
+
|
|
59
|
+
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
|
|
60
|
+
|
|
61
|
+
# JSON top-level values always start with one of these bytes; a tag byte never does.
|
|
62
|
+
_JSON_FIRST_BYTES = frozenset(b"{[")
|
|
63
|
+
|
|
64
|
+
def msgpack_available() -> bool:
|
|
65
|
+
return _msgpack is not None
|
|
66
|
+
|
|
67
|
+
def zstd_available() -> bool:
|
|
68
|
+
return _zstd is not None
|
|
69
|
+
|
|
70
|
+
def supported_serialization() -> list[str]:
|
|
71
|
+
"""Serializations this build can produce/consume, best first."""
|
|
72
|
+
return (["msgpack"] if _msgpack is not None else []) + ["json"]
|
|
73
|
+
|
|
74
|
+
def supported_compression() -> list[str]:
|
|
75
|
+
"""Compression codecs this build can produce/consume, best first."""
|
|
76
|
+
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
|
|
77
|
+
|
|
78
|
+
def client_accept_encoding() -> dict:
|
|
79
|
+
"""What the local client advertises it can decode (sent with each request)."""
|
|
80
|
+
return {"ser": supported_serialization(), "comp": supported_compression()}
|
|
81
|
+
|
|
82
|
+
# ── compression primitives ────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def _compress(comp_id: int, data: bytes) -> bytes:
|
|
85
|
+
if comp_id == COMP_NONE:
|
|
86
|
+
return data
|
|
87
|
+
if comp_id == COMP_ZLIB:
|
|
88
|
+
return zlib.compress(data, 6)
|
|
89
|
+
if comp_id == COMP_GZIP:
|
|
90
|
+
return gzip.compress(data, compresslevel=6)
|
|
91
|
+
if comp_id == COMP_ZSTD:
|
|
92
|
+
if _zstd is None:
|
|
93
|
+
raise ValueError("zstd compression requested but zstandard is not installed")
|
|
94
|
+
return _zstd.ZstdCompressor(level=10).compress(data)
|
|
95
|
+
raise ValueError(f"unknown compression id {comp_id}")
|
|
96
|
+
|
|
97
|
+
def _decompress(comp_id: int, data: bytes) -> bytes:
|
|
98
|
+
if comp_id == COMP_NONE:
|
|
99
|
+
return data
|
|
100
|
+
if comp_id == COMP_ZLIB:
|
|
101
|
+
return zlib.decompress(data)
|
|
102
|
+
if comp_id == COMP_GZIP:
|
|
103
|
+
return gzip.decompress(data)
|
|
104
|
+
if comp_id == COMP_ZSTD:
|
|
105
|
+
if _zstd is None:
|
|
106
|
+
raise ValueError("zstd payload received but zstandard is not installed")
|
|
107
|
+
return _zstd.ZstdDecompressor().decompress(data)
|
|
108
|
+
raise ValueError(f"unknown compression id {comp_id}")
|
|
109
|
+
|
|
110
|
+
# ── codec negotiation ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _choose(accept: dict | None) -> tuple[int, int]:
|
|
113
|
+
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
|
|
114
|
+
accept = accept if isinstance(accept, dict) else {}
|
|
115
|
+
accept_ser = accept.get("ser") or ["json"]
|
|
116
|
+
accept_comp = accept.get("comp") or []
|
|
117
|
+
|
|
118
|
+
ser = SER_JSON
|
|
119
|
+
if _msgpack is not None and "msgpack" in accept_ser:
|
|
120
|
+
ser = SER_MSGPACK
|
|
121
|
+
|
|
122
|
+
comp = COMP_NONE
|
|
123
|
+
for name in supported_compression(): # server preference: zstd > gzip > zlib
|
|
124
|
+
if name in accept_comp:
|
|
125
|
+
comp = _COMP_ID[name]
|
|
126
|
+
break
|
|
127
|
+
return ser, comp
|
|
128
|
+
|
|
129
|
+
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
|
|
132
|
+
_B64_MARKER = "__b64__"
|
|
133
|
+
|
|
134
|
+
def _hoist_screenshot(obj, command: str | None):
|
|
135
|
+
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
|
|
136
|
+
|
|
137
|
+
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
|
|
138
|
+
"""
|
|
139
|
+
if command != "tabs.screenshot" or not isinstance(obj, dict):
|
|
140
|
+
return obj
|
|
141
|
+
data = obj.get("data")
|
|
142
|
+
if not isinstance(data, dict):
|
|
143
|
+
return obj
|
|
144
|
+
url = data.get("dataUrl")
|
|
145
|
+
if not isinstance(url, str):
|
|
146
|
+
return obj
|
|
147
|
+
m = _DATA_URL_RE.match(url)
|
|
148
|
+
if not m:
|
|
149
|
+
return obj
|
|
150
|
+
try:
|
|
151
|
+
raw = base64.b64decode(m.group(2))
|
|
152
|
+
except Exception:
|
|
153
|
+
return obj
|
|
154
|
+
new_data = dict(data)
|
|
155
|
+
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
|
|
156
|
+
return {**obj, "data": new_data}
|
|
157
|
+
|
|
158
|
+
def _unhoist_binary(obj):
|
|
159
|
+
"""Rebuild any hoisted data URL so callers see the original string again."""
|
|
160
|
+
if isinstance(obj, dict):
|
|
161
|
+
raw = obj.get("raw")
|
|
162
|
+
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
|
|
163
|
+
mime = obj.get("mime") or "application/octet-stream"
|
|
164
|
+
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
|
|
165
|
+
return {k: _unhoist_binary(v) for k, v in obj.items()}
|
|
166
|
+
if isinstance(obj, list):
|
|
167
|
+
return [_unhoist_binary(v) for v in obj]
|
|
168
|
+
return obj
|
|
169
|
+
|
|
170
|
+
# ── encode / decode ─────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def encode_response(obj, accept: dict | None = None, command: str | None = None,
|
|
173
|
+
threshold: int = DEFAULT_TRANSPORT_THRESHOLD) -> bytes:
|
|
174
|
+
"""Encode a response object for the chosen/accepted codec.
|
|
175
|
+
|
|
176
|
+
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
|
|
177
|
+
what an old server would have sent.
|
|
178
|
+
"""
|
|
179
|
+
ser, comp = _choose(accept)
|
|
180
|
+
|
|
181
|
+
if ser == SER_MSGPACK:
|
|
182
|
+
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
|
|
183
|
+
else:
|
|
184
|
+
body = json.dumps(obj).encode("utf-8")
|
|
185
|
+
|
|
186
|
+
if comp != COMP_NONE and len(body) >= threshold:
|
|
187
|
+
body = _compress(comp, body)
|
|
188
|
+
else:
|
|
189
|
+
comp = COMP_NONE
|
|
190
|
+
|
|
191
|
+
if ser == SER_JSON and comp == COMP_NONE:
|
|
192
|
+
return body # plain JSON — historical wire format, no tag byte
|
|
193
|
+
|
|
194
|
+
return bytes([(ser << 4) | comp]) + body
|
|
195
|
+
|
|
196
|
+
def decode_response(raw: bytes | None):
|
|
197
|
+
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
|
|
198
|
+
if raw is None:
|
|
199
|
+
return None
|
|
200
|
+
if not raw:
|
|
201
|
+
raise ValueError("empty response payload")
|
|
202
|
+
if raw[0] in _JSON_FIRST_BYTES:
|
|
203
|
+
return json.loads(raw)
|
|
204
|
+
|
|
205
|
+
tag = raw[0]
|
|
206
|
+
ser, comp = tag >> 4, tag & 0x0F
|
|
207
|
+
body = _decompress(comp, raw[1:])
|
|
208
|
+
if ser == SER_MSGPACK:
|
|
209
|
+
if _msgpack is None:
|
|
210
|
+
raise ValueError("msgpack payload received but msgpack is not installed")
|
|
211
|
+
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
|
|
212
|
+
if ser == SER_JSON:
|
|
213
|
+
return json.loads(body)
|
|
214
|
+
raise ValueError(f"unknown serialization id {ser}")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from importlib.metadata import version as _pkg_version
|
|
2
|
+
|
|
3
|
+
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT
|
|
4
|
+
|
|
5
|
+
def parse_version(v: str) -> tuple[int, ...]:
|
|
6
|
+
try:
|
|
7
|
+
return tuple(int(x) for x in v.lstrip("v").split("."))
|
|
8
|
+
except ValueError:
|
|
9
|
+
return (0,)
|
|
10
|
+
|
|
11
|
+
def get_installed_version() -> str:
|
|
12
|
+
try:
|
|
13
|
+
return _pkg_version("browser-cli")
|
|
14
|
+
except Exception:
|
|
15
|
+
return "0.0.0"
|
|
16
|
+
|
|
17
|
+
USER_AGENT = f"browser-cli/{get_installed_version()}"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: real-browser-cli
|
|
3
|
+
Version: 0.14.2
|
|
4
|
+
Summary: Control your real running browser from the terminal or Python SDK
|
|
5
|
+
License: # PolyForm Noncommercial License 1.0.0
|
|
6
|
+
|
|
7
|
+
Required Notice: Copyright (c) 2026 Daniel Dolezal
|
|
8
|
+
|
|
9
|
+
## Acceptance
|
|
10
|
+
|
|
11
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
12
|
+
|
|
13
|
+
## Copyright License
|
|
14
|
+
|
|
15
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
16
|
+
|
|
17
|
+
## Distribution License
|
|
18
|
+
|
|
19
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
20
|
+
|
|
21
|
+
## Notices
|
|
22
|
+
|
|
23
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
24
|
+
|
|
25
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
26
|
+
|
|
27
|
+
## Changes and New Works License
|
|
28
|
+
|
|
29
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
30
|
+
|
|
31
|
+
## Patent License
|
|
32
|
+
|
|
33
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
34
|
+
|
|
35
|
+
## Noncommercial Purposes
|
|
36
|
+
|
|
37
|
+
Any noncommercial purpose is a permitted purpose.
|
|
38
|
+
|
|
39
|
+
## Personal Uses
|
|
40
|
+
|
|
41
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
42
|
+
|
|
43
|
+
## Noncommercial Organizations
|
|
44
|
+
|
|
45
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
46
|
+
|
|
47
|
+
## Fair Use
|
|
48
|
+
|
|
49
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
54
|
+
|
|
55
|
+
## Patent Defense
|
|
56
|
+
|
|
57
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
58
|
+
|
|
59
|
+
## Violations
|
|
60
|
+
|
|
61
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
62
|
+
|
|
63
|
+
## No Liability
|
|
64
|
+
|
|
65
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
66
|
+
|
|
67
|
+
## Definitions
|
|
68
|
+
|
|
69
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
70
|
+
|
|
71
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
72
|
+
|
|
73
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization.
|
|
74
|
+
|
|
75
|
+
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
76
|
+
|
|
77
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
78
|
+
|
|
79
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
80
|
+
License-File: LICENSE
|
|
81
|
+
Requires-Python: >=3.10
|
|
82
|
+
Requires-Dist: click>=8
|
|
83
|
+
Requires-Dist: cryptography>=48
|
|
84
|
+
Requires-Dist: msgpack>=1
|
|
85
|
+
Requires-Dist: rich>=13
|
|
86
|
+
Provides-Extra: fast
|
|
87
|
+
Requires-Dist: zstandard>=0.22; extra == 'fast'
|