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.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. 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()
@@ -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'