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 +1 -0
- dotbrave/__main__.py +4 -0
- dotbrave/_base/__init__.py +0 -0
- dotbrave/_base/cdp.py +286 -0
- dotbrave/_base/live_apply.py +98 -0
- dotbrave/_base/orchestrator.py +700 -0
- dotbrave/_base/process.py +344 -0
- dotbrave/_base/pwa.py +530 -0
- dotbrave/_base/settings.py +431 -0
- dotbrave/_base/utils.py +67 -0
- dotbrave/browser.py +355 -0
- dotbrave/cli.py +65 -0
- dotbrave/command_ids.py +549 -0
- dotbrave/live.py +273 -0
- dotbrave/pwa.py +131 -0
- dotbrave/settings.py +42 -0
- dotbrave/shortcuts.py +289 -0
- dotbrave/utils.py +103 -0
- dotbrave-0.1.0.dist-info/METADATA +277 -0
- dotbrave-0.1.0.dist-info/RECORD +23 -0
- dotbrave-0.1.0.dist-info/WHEEL +4 -0
- dotbrave-0.1.0.dist-info/entry_points.txt +2 -0
- dotbrave-0.1.0.dist-info/licenses/LICENSE +21 -0
dotbrave/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
dotbrave/__main__.py
ADDED
|
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)
|