dotbrave 0.1.0__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.
dotbrave/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
dotbrave/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from dotbrave.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
dotbrave/_base/cdp.py ADDED
@@ -0,0 +1,286 @@
1
+ """Tiny Chrome DevTools Protocol client used by live apply.
2
+
3
+ This intentionally avoids a runtime dependency on websocket-client. It
4
+ implements the small WebSocket subset needed for one request/response
5
+ CDP commands against a local browser endpoint.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import socket
14
+ import struct
15
+ import sys
16
+ from pathlib import Path
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from typing import Any
21
+
22
+ _LIVE_PORT_SIDECAR = ".dotbrave.live.json"
23
+
24
+
25
+ class _WebSocket:
26
+ def __init__(self, url: str, timeout: float = 5.0):
27
+ parsed = urllib.parse.urlparse(url)
28
+ if parsed.scheme != "ws":
29
+ sys.exit(f"error: unsupported DevTools websocket URL: {url}")
30
+ self.host = parsed.hostname or "127.0.0.1"
31
+ self.port = parsed.port or 80
32
+ self.path = parsed.path or "/"
33
+ if parsed.query:
34
+ self.path += "?" + parsed.query
35
+ self.sock = socket.create_connection((self.host, self.port), timeout=timeout)
36
+ self._handshake()
37
+
38
+ def _handshake(self) -> None:
39
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
40
+ req = (
41
+ f"GET {self.path} HTTP/1.1\r\n"
42
+ f"Host: {self.host}:{self.port}\r\n"
43
+ "Upgrade: websocket\r\n"
44
+ "Connection: Upgrade\r\n"
45
+ f"Sec-WebSocket-Key: {key}\r\n"
46
+ "Sec-WebSocket-Version: 13\r\n"
47
+ "\r\n"
48
+ )
49
+ self.sock.sendall(req.encode("ascii"))
50
+ response = b""
51
+ while b"\r\n\r\n" not in response:
52
+ chunk = self.sock.recv(4096)
53
+ if not chunk:
54
+ break
55
+ response += chunk
56
+ header = response.decode("iso-8859-1", "replace")
57
+ if " 101 " not in header.split("\r\n", 1)[0]:
58
+ sys.exit("error: DevTools websocket handshake failed")
59
+ expected = base64.b64encode(
60
+ hashlib.sha1(
61
+ (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")
62
+ ).digest()
63
+ ).decode("ascii")
64
+ headers = {}
65
+ for line in header.split("\r\n")[1:]:
66
+ if ":" not in line:
67
+ continue
68
+ name, value = line.split(":", 1)
69
+ headers[name.strip().lower()] = value.strip()
70
+ if headers.get("sec-websocket-accept") != expected:
71
+ sys.exit("error: DevTools websocket accept header mismatch")
72
+
73
+ def close(self) -> None:
74
+ try:
75
+ self.sock.close()
76
+ except OSError:
77
+ pass
78
+
79
+ def send_text(self, text: str) -> None:
80
+ payload = text.encode("utf-8")
81
+ header = bytearray([0x81])
82
+ length = len(payload)
83
+ if length < 126:
84
+ header.append(0x80 | length)
85
+ elif length <= 0xFFFF:
86
+ header.append(0x80 | 126)
87
+ header.extend(struct.pack("!H", length))
88
+ else:
89
+ header.append(0x80 | 127)
90
+ header.extend(struct.pack("!Q", length))
91
+ mask = os.urandom(4)
92
+ header.extend(mask)
93
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
94
+ self.sock.sendall(bytes(header) + masked)
95
+
96
+ def recv_text(self) -> str:
97
+ while True:
98
+ first = self._read_exact(2)
99
+ opcode = first[0] & 0x0F
100
+ length = first[1] & 0x7F
101
+ masked = bool(first[1] & 0x80)
102
+ if length == 126:
103
+ length = struct.unpack("!H", self._read_exact(2))[0]
104
+ elif length == 127:
105
+ length = struct.unpack("!Q", self._read_exact(8))[0]
106
+ mask = self._read_exact(4) if masked else b""
107
+ payload = self._read_exact(length)
108
+ if masked:
109
+ payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
110
+ if opcode == 0x8:
111
+ sys.exit("error: DevTools websocket closed")
112
+ if opcode == 0x9:
113
+ self._send_pong(payload)
114
+ continue
115
+ if opcode == 0x1:
116
+ return payload.decode("utf-8")
117
+
118
+ def _send_pong(self, payload: bytes) -> None:
119
+ self.sock.sendall(bytes([0x8A, len(payload)]) + payload)
120
+
121
+ def _read_exact(self, n: int) -> bytes:
122
+ chunks = bytearray()
123
+ while len(chunks) < n:
124
+ chunk = self.sock.recv(n - len(chunks))
125
+ if not chunk:
126
+ sys.exit("error: DevTools websocket ended unexpectedly")
127
+ chunks.extend(chunk)
128
+ return bytes(chunks)
129
+
130
+
131
+ class CdpClient:
132
+ def __init__(self, port: int, *, host: str = "127.0.0.1"):
133
+ self.port = port
134
+ self.host = host
135
+
136
+ def _json(self, path: str) -> Any:
137
+ url = f"http://{self.host}:{self.port}{path}"
138
+ try:
139
+ with urllib.request.urlopen(url, timeout=5) as resp:
140
+ return json.loads(resp.read().decode("utf-8"))
141
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as e:
142
+ sys.exit(
143
+ f"error: could not reach DevTools endpoint at "
144
+ f"{self.host}:{self.port}: {e}"
145
+ )
146
+
147
+ def list_targets(self) -> list[dict]:
148
+ targets = self._json("/json/list")
149
+ return targets if isinstance(targets, list) else []
150
+
151
+ def navigate(self, target: dict, url: str) -> None:
152
+ self._command(target, "Page.navigate", {"url": url})
153
+
154
+ def reload(self, target: dict) -> None:
155
+ self._command(target, "Page.reload", {"ignoreCache": True})
156
+
157
+ def evaluate(self, target: dict, expression: str) -> Any:
158
+ msg = self._command(
159
+ target,
160
+ "Runtime.evaluate",
161
+ {
162
+ "expression": expression,
163
+ "returnByValue": True,
164
+ "awaitPromise": True,
165
+ },
166
+ )
167
+ result = msg.get("result", {})
168
+ if "exceptionDetails" in result:
169
+ detail = result["exceptionDetails"]
170
+ desc = detail.get("exception", {}).get("description") or detail.get("text")
171
+ sys.exit(f"error: DevTools evaluation failed: {desc}")
172
+ value = result.get("result", {})
173
+ return value.get("value")
174
+
175
+ def _command(self, target: dict, method: str, params: dict | None = None) -> dict:
176
+ ws_url = target.get("webSocketDebuggerUrl")
177
+ if not isinstance(ws_url, str) or not ws_url:
178
+ sys.exit("error: DevTools target has no websocket URL")
179
+ ws = _WebSocket(ws_url)
180
+ try:
181
+ payload = {"id": 1, "method": method}
182
+ if params is not None:
183
+ payload["params"] = params
184
+ ws.send_text(json.dumps(payload, separators=(",", ":")))
185
+ while True:
186
+ msg = json.loads(ws.recv_text())
187
+ if msg.get("id") == 1:
188
+ if "error" in msg:
189
+ sys.exit(f"error: DevTools command failed: {msg['error']}")
190
+ return msg
191
+ finally:
192
+ ws.close()
193
+
194
+
195
+ def devtools_endpoint_alive(port: int) -> bool:
196
+ try:
197
+ with urllib.request.urlopen(
198
+ f"http://127.0.0.1:{port}/json/list", timeout=0.5
199
+ ) as resp:
200
+ targets = json.loads(resp.read().decode("utf-8"))
201
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
202
+ return False
203
+ return isinstance(targets, list)
204
+
205
+
206
+ def remember_devtools_port(profile_root, profile: str, port: int) -> None:
207
+ root = Path(profile_root)
208
+ root.mkdir(parents=True, exist_ok=True)
209
+ path = root / _LIVE_PORT_SIDECAR
210
+ path.write_text(
211
+ json.dumps({"profile": profile, "port": int(port)}, indent=2),
212
+ encoding="utf-8",
213
+ )
214
+
215
+
216
+ def _read_dotbrave_live_port(profile_root: Path, profile: str | None) -> int | None:
217
+ path = profile_root / _LIVE_PORT_SIDECAR
218
+ try:
219
+ data = json.loads(path.read_text(encoding="utf-8"))
220
+ except (OSError, json.JSONDecodeError):
221
+ return None
222
+ if not isinstance(data, dict):
223
+ return None
224
+ stored_profile = data.get("profile")
225
+ if profile is not None and isinstance(stored_profile, str):
226
+ if stored_profile != profile:
227
+ return None
228
+ try:
229
+ port = int(data["port"])
230
+ except (KeyError, TypeError, ValueError):
231
+ return None
232
+ return port if devtools_endpoint_alive(port) else None
233
+
234
+
235
+ def _read_devtools_active_port(profile_root: Path) -> int | None:
236
+ path = profile_root / "DevToolsActivePort"
237
+ try:
238
+ first = path.read_text(encoding="utf-8").splitlines()[0].strip()
239
+ except (OSError, IndexError):
240
+ return None
241
+ try:
242
+ return int(first)
243
+ except ValueError:
244
+ return None
245
+
246
+
247
+ def find_devtools_port(profile_root, profile: str | None = None) -> int | None:
248
+ root = Path(profile_root)
249
+ sidecar_port = _read_dotbrave_live_port(root, profile)
250
+ if sidecar_port is not None:
251
+ return sidecar_port
252
+ active_port = _read_devtools_active_port(root)
253
+ if active_port is None:
254
+ return None
255
+ return active_port if devtools_endpoint_alive(active_port) else None
256
+
257
+
258
+ def pick_unused_port() -> int:
259
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
260
+ sock.bind(("127.0.0.1", 0))
261
+ return int(sock.getsockname()[1])
262
+
263
+
264
+ def wait_for_devtools_endpoint(
265
+ port: int,
266
+ display_name: str,
267
+ *,
268
+ timeout: float = 15.0,
269
+ ) -> None:
270
+ import time
271
+
272
+ deadline = time.monotonic() + timeout
273
+ last_error: str | None = None
274
+ while time.monotonic() < deadline:
275
+ try:
276
+ targets = CdpClient(port).list_targets()
277
+ if targets:
278
+ return
279
+ except SystemExit as e:
280
+ last_error = str(e)
281
+ time.sleep(0.25)
282
+ sys.exit(
283
+ f"error: {display_name} did not expose a DevTools endpoint on "
284
+ f"127.0.0.1:{port} within {timeout:.0f}s"
285
+ + (f"\nlast error: {last_error}" if last_error else "")
286
+ )
@@ -0,0 +1,98 @@
1
+ """Shared helpers for applying plans through a running browser."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import json
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from dotbrave._base.utils import Plan
12
+
13
+
14
+ MISSING = object()
15
+
16
+
17
+ class LiveApplyUnsupported(Exception):
18
+ """A live adapter cannot apply one or more settings without restarting."""
19
+
20
+ def __init__(self, browser_name: str, keys: list[str]) -> None:
21
+ super().__init__(browser_name, keys)
22
+ self.browser_name = browser_name
23
+ self.keys = keys
24
+
25
+
26
+ def compute_target_prefs(prefs: dict, plans: list[Plan]) -> dict:
27
+ """Return the Preferences dict that normal offline apply would write."""
28
+ target = copy.deepcopy(prefs)
29
+ for plan in plans:
30
+ if not plan.empty:
31
+ plan.apply_fn(target)
32
+ return target
33
+
34
+
35
+ def backup_preferences(prefs_path: Path) -> Path:
36
+ backup = prefs_path.with_suffix(
37
+ prefs_path.suffix + f".bak.{datetime.now():%Y%m%d-%H%M%S}"
38
+ )
39
+ shutil.copy2(prefs_path, backup)
40
+ print(f"backup: {backup}")
41
+ return backup
42
+
43
+
44
+ def apply_external_plans(plans: list[Plan]) -> None:
45
+ for plan in plans:
46
+ if plan.external_apply_fn is not None and not plan.empty:
47
+ plan.external_apply_fn()
48
+
49
+
50
+ def write_state_files(plans: list[Plan]) -> None:
51
+ for plan in plans:
52
+ if plan.state_path is not None:
53
+ plan.state_path.write_text(
54
+ json.dumps(plan.state_payload, indent=2), encoding="utf-8",
55
+ )
56
+
57
+
58
+ def get_path(data: dict, parts: tuple[str, ...]) -> Any:
59
+ cur: Any = data
60
+ for part in parts:
61
+ if not isinstance(cur, dict) or part not in cur:
62
+ return MISSING
63
+ cur = cur[part]
64
+ return cur
65
+
66
+
67
+ def changed_leaf_paths(
68
+ before: Any, after: Any, prefix: tuple[str, ...] = (),
69
+ ) -> list[tuple[tuple[str, ...], Any]]:
70
+ """Return changed leaves in ``after`` compared to ``before``.
71
+
72
+ Deleted leaves are reported with ``MISSING`` as the value so browser
73
+ adapters can refuse live resets where the underlying API has no
74
+ single-pref reset operation.
75
+ """
76
+ if isinstance(after, dict) and not isinstance(before, dict):
77
+ before = {}
78
+ if isinstance(before, dict) and after is MISSING:
79
+ after = {}
80
+ if isinstance(before, dict) and isinstance(after, dict):
81
+ out: list[tuple[tuple[str, ...], Any]] = []
82
+ for key in sorted(set(before) | set(after)):
83
+ b = before.get(key, MISSING)
84
+ a = after.get(key, MISSING)
85
+ out.extend(changed_leaf_paths(b, a, prefix + (str(key),)))
86
+ return out
87
+ if before != after:
88
+ return [(prefix, after)]
89
+ return []
90
+
91
+
92
+ def refuse_live_removals(
93
+ browser_name: str,
94
+ changes: list[tuple[tuple[str, ...], Any]],
95
+ ) -> None:
96
+ removals = [".".join(parts) for parts, value in changes if value is MISSING]
97
+ if removals:
98
+ raise LiveApplyUnsupported(browser_name, removals)