seedcode-cli 6.1.5__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 (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,567 @@
1
+ """DOM inspection over the Chrome DevTools Protocol — the browser's own eyes.
2
+
3
+ This is the **DOM tier** of the detection ladder: when the user's default
4
+ browser is Chromium-based and exposes a DevTools endpoint, Seed Code can ask
5
+ the page directly ("is there a cookie banner?", "where is the first video
6
+ link?") instead of guessing from pixels. That is exact, theme-independent, and
7
+ needs no OCR.
8
+
9
+ Two deliberate design choices:
10
+
11
+ * **Opportunistic, never required.** Attaching needs the browser to have been
12
+ started with ``--remote-debugging-port``. When it wasn't — the common case
13
+ for an already-running browser — every function here returns ``None`` or
14
+ ``False`` and the caller falls through to the next ladder tier. Nothing in
15
+ Seed Code *depends* on CDP being live; the URL-first workflows in
16
+ :mod:`.browser_engine` reach their goal without touching the DOM at all.
17
+ * **No new dependencies.** Target discovery is plain HTTP over ``urllib`` and
18
+ the DevTools socket is a ~100-line RFC 6455 client over a stdlib socket.
19
+ Adding ``websockets``/``selenium`` to the install for this would violate the
20
+ "works offline, nothing extra to install" contract.
21
+
22
+ The module holds no AI code and never sees a model.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import json
29
+ import os
30
+ import socket
31
+ import struct
32
+ import threading
33
+ import time
34
+ import urllib.error
35
+ import urllib.request
36
+ from dataclasses import dataclass
37
+ from typing import Any
38
+
39
+ # The DevTools port Seed Code asks the browser to expose. Fixed so a browser
40
+ # launched by an earlier session is still reachable by a later one.
41
+ DEFAULT_PORT = 9222
42
+
43
+ # Everything here is best-effort against a live browser: keep timeouts short so
44
+ # a missing/hung endpoint costs the workflow a moment, not a stall.
45
+ _HTTP_TIMEOUT_S = 1.5
46
+ _WS_TIMEOUT_S = 4.0
47
+
48
+ # How long an availability verdict stays good. A hit is re-checked often enough
49
+ # to notice the browser closing; a miss is cached longer because the expensive
50
+ # case — no DevTools at all — is also the case that must stay cheap.
51
+ _AVAIL_TTL_S = 2.0
52
+ _AVAIL_MISS_TTL_S = 10.0
53
+
54
+ # port -> (available, expires_at)
55
+ _avail_cache: dict[int, tuple[bool, float]] = {}
56
+
57
+ # Guards the shared connection: skills may run from different threads and a
58
+ # DevTools socket cannot interleave request/response pairs safely.
59
+ _lock = threading.Lock()
60
+ _conn: "_Connection | None" = None
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class Tab:
65
+ """One open browser tab, as DevTools reports it."""
66
+
67
+ target_id: str
68
+ title: str
69
+ url: str
70
+ ws_url: str
71
+
72
+ def describe(self) -> str:
73
+ return f'"{self.title or "(untitled)"}" — {self.url}'
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class DomBox:
78
+ """A DOM element's position, already converted to screen coordinates."""
79
+
80
+ x: int # center, screen space
81
+ y: int # center, screen space
82
+ width: int
83
+ height: int
84
+ text: str
85
+
86
+ def as_tuple(self) -> tuple[int, int, int, int]:
87
+ """(left, top, width, height) — the resolver's box convention."""
88
+ return (self.x - self.width // 2, self.y - self.height // 2, self.width, self.height)
89
+
90
+
91
+ # --- target discovery (plain HTTP, no socket needed) -------------------------
92
+
93
+ def _endpoint(port: int, path: str) -> str:
94
+ return f"http://127.0.0.1:{port}/json{path}"
95
+
96
+
97
+ def _http(port: int, path: str, timeout: float = _HTTP_TIMEOUT_S) -> Any:
98
+ """GET a DevTools HTTP endpoint, returning parsed JSON or None."""
99
+ try:
100
+ with urllib.request.urlopen(_endpoint(port, path), timeout=timeout) as resp:
101
+ body = resp.read().decode("utf-8", "replace")
102
+ except (urllib.error.URLError, OSError, ValueError):
103
+ return None
104
+ if not body.strip():
105
+ return None
106
+ try:
107
+ return json.loads(body)
108
+ except json.JSONDecodeError:
109
+ return body
110
+
111
+
112
+ def is_available(port: int = DEFAULT_PORT) -> bool:
113
+ """Whether a DevTools endpoint is answering on ``port``.
114
+
115
+ The verdict is cached briefly. Callers like the popup manager ask this
116
+ (directly or via ``exists``) dozens of times per sweep, and without a cache
117
+ every miss costs a full connection timeout — turning an "is the page
118
+ clean?" check into a multi-second stall on the overwhelmingly common
119
+ no-DevTools machine.
120
+ """
121
+ global _avail_cache
122
+ now = time.monotonic()
123
+ cached = _avail_cache.get(port)
124
+ if cached is not None and now < cached[1]:
125
+ return cached[0]
126
+ ok = _http(port, "/version") is not None
127
+ ttl = _AVAIL_TTL_S if ok else _AVAIL_MISS_TTL_S
128
+ _avail_cache[port] = (ok, now + ttl)
129
+ return ok
130
+
131
+
132
+ def invalidate_availability() -> None:
133
+ """Forget the cached availability verdict (browser started/stopped)."""
134
+ _avail_cache.clear()
135
+
136
+
137
+ def list_tabs(port: int = DEFAULT_PORT) -> list[Tab]:
138
+ """Every open page tab (DevTools also lists workers/extensions; skipped)."""
139
+ data = _http(port, "/list")
140
+ if not isinstance(data, list):
141
+ return []
142
+ tabs = []
143
+ for entry in data:
144
+ if not isinstance(entry, dict) or entry.get("type") != "page":
145
+ continue
146
+ tabs.append(
147
+ Tab(
148
+ target_id=str(entry.get("id", "")),
149
+ title=str(entry.get("title", "")),
150
+ url=str(entry.get("url", "")),
151
+ ws_url=str(entry.get("webSocketDebuggerUrl", "")),
152
+ )
153
+ )
154
+ return tabs
155
+
156
+
157
+ def active_tab(port: int = DEFAULT_PORT) -> Tab | None:
158
+ """The tab DevTools lists first — the one most recently in the foreground."""
159
+ tabs = list_tabs(port)
160
+ return tabs[0] if tabs else None
161
+
162
+
163
+ def new_tab(url: str = "about:blank", port: int = DEFAULT_PORT) -> Tab | None:
164
+ """Open a tab via DevTools. Returns None when the endpoint is absent."""
165
+ data = _http(port, f"/new?{url}")
166
+ if not isinstance(data, dict):
167
+ return None
168
+ _reset_connection()
169
+ return Tab(
170
+ target_id=str(data.get("id", "")),
171
+ title=str(data.get("title", "")),
172
+ url=str(data.get("url", "")),
173
+ ws_url=str(data.get("webSocketDebuggerUrl", "")),
174
+ )
175
+
176
+
177
+ def activate_tab(target_id: str, port: int = DEFAULT_PORT) -> bool:
178
+ """Bring a tab to the foreground."""
179
+ ok = _http(port, f"/activate/{target_id}") is not None
180
+ if ok:
181
+ _reset_connection()
182
+ return ok
183
+
184
+
185
+ def close_tab(target_id: str, port: int = DEFAULT_PORT) -> bool:
186
+ """Close a tab."""
187
+ ok = _http(port, f"/close/{target_id}") is not None
188
+ if ok:
189
+ _reset_connection()
190
+ return ok
191
+
192
+
193
+ # --- JavaScript evaluation (the workhorse) -----------------------------------
194
+
195
+ def evaluate(expression: str, port: int = DEFAULT_PORT) -> Any:
196
+ """Evaluate JS in the active tab and return the JSON-decoded result.
197
+
198
+ Returns ``None`` when DevTools is unreachable or the expression threw —
199
+ callers treat that as "the DOM tier has nothing to say" and fall through.
200
+ """
201
+ # Short-circuit on the cached verdict so a machine with no DevTools pays
202
+ # one probe per sweep rather than one per query.
203
+ if not is_available(port):
204
+ return None
205
+ with _lock:
206
+ conn = _get_connection(port)
207
+ if conn is None:
208
+ return None
209
+ try:
210
+ reply = conn.call(
211
+ "Runtime.evaluate",
212
+ {
213
+ "expression": expression,
214
+ "returnByValue": True,
215
+ "awaitPromise": True,
216
+ # Consent banners and player controls are only clickable
217
+ # when the page believes a real user gesture occurred.
218
+ "userGesture": True,
219
+ },
220
+ )
221
+ except Exception:
222
+ _reset_connection_locked()
223
+ return None
224
+ if not isinstance(reply, dict):
225
+ return None
226
+ if reply.get("exceptionDetails"):
227
+ return None
228
+ return reply.get("result", {}).get("value")
229
+
230
+
231
+ def current_url(port: int = DEFAULT_PORT) -> str | None:
232
+ """The live address of the active tab, straight from the browser."""
233
+ tab = active_tab(port)
234
+ if tab is not None and tab.url:
235
+ return tab.url
236
+ value = evaluate("location.href", port)
237
+ return str(value) if isinstance(value, str) else None
238
+
239
+
240
+ def current_title(port: int = DEFAULT_PORT) -> str | None:
241
+ """The live title of the active tab."""
242
+ tab = active_tab(port)
243
+ if tab is not None and tab.title:
244
+ return tab.title
245
+ value = evaluate("document.title", port)
246
+ return str(value) if isinstance(value, str) else None
247
+
248
+
249
+ def navigate(url: str, port: int = DEFAULT_PORT) -> bool:
250
+ """Drive the active tab to ``url`` through DevTools."""
251
+ return bool(evaluate(f"(location.assign({json.dumps(url)}), true)", port))
252
+
253
+
254
+ def wait_for_load(timeout_s: float = 10.0, port: int = DEFAULT_PORT) -> bool:
255
+ """Block until the active tab finishes loading (or the timeout elapses)."""
256
+ import time
257
+
258
+ deadline = time.monotonic() + timeout_s
259
+ while time.monotonic() < deadline:
260
+ if evaluate("document.readyState === 'complete'", port) is True:
261
+ return True
262
+ time.sleep(0.25)
263
+ return False
264
+
265
+
266
+ # --- DOM querying (the resolver's DOM tier) ----------------------------------
267
+
268
+ # Built in JS so the *page* does the matching: one round trip, and the result
269
+ # is already in screen space. Chromium exposes the window's screen origin and
270
+ # the chrome height (outerHeight - innerHeight), which together convert a
271
+ # viewport rect into a coordinate the mouse driver can click.
272
+ _BOX_JS = """
273
+ (() => {
274
+ const el = %(finder)s;
275
+ if (!el) return null;
276
+ const r = el.getBoundingClientRect();
277
+ if (!r || r.width <= 0 || r.height <= 0) return null;
278
+ const chrome = window.outerHeight - window.innerHeight;
279
+ return {
280
+ x: Math.round(window.screenX + r.left + r.width / 2),
281
+ y: Math.round(window.screenY + chrome + r.top + r.height / 2),
282
+ width: Math.round(r.width),
283
+ height: Math.round(r.height),
284
+ text: (el.innerText || el.textContent || el.value || '').trim().slice(0, 120)
285
+ };
286
+ })()
287
+ """
288
+
289
+ # Find a visible element whose text/label/aria-label contains a phrase. Used by
290
+ # both the resolver's DOM tier and the popup manager's dismiss buttons.
291
+ _BY_TEXT_JS = """
292
+ (() => {
293
+ const want = %(needle)s.toLowerCase();
294
+ const tags = %(tags)s;
295
+ const nodes = document.querySelectorAll(tags.join(','));
296
+ for (const el of nodes) {
297
+ const r = el.getBoundingClientRect();
298
+ if (!r || r.width <= 0 || r.height <= 0) continue;
299
+ const style = window.getComputedStyle(el);
300
+ if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
301
+ const label = ((el.innerText || el.textContent || '') + ' ' +
302
+ (el.getAttribute('aria-label') || '') + ' ' +
303
+ (el.getAttribute('title') || '') + ' ' +
304
+ (el.value || '')).toLowerCase();
305
+ if (label.includes(want)) return el;
306
+ }
307
+ return null;
308
+ })()
309
+ """
310
+
311
+ # Element kinds a user can actually act on. Kept narrow so a phrase does not
312
+ # match a giant wrapper <div> that happens to contain the text.
313
+ _CLICKABLE_TAGS = [
314
+ "button", "a", "input[type=submit]", "input[type=button]",
315
+ "[role=button]", "[role=link]", "[role=menuitem]", "[role=tab]",
316
+ "[onclick]", "label",
317
+ ]
318
+
319
+
320
+ def _finder_by_text(needle: str, tags: list[str] | None = None) -> str:
321
+ return _BY_TEXT_JS % {
322
+ "needle": json.dumps(needle),
323
+ "tags": json.dumps(tags or _CLICKABLE_TAGS),
324
+ }
325
+
326
+
327
+ def _box_from(result: Any) -> DomBox | None:
328
+ if not isinstance(result, dict):
329
+ return None
330
+ try:
331
+ return DomBox(
332
+ x=int(result["x"]), y=int(result["y"]),
333
+ width=int(result["width"]), height=int(result["height"]),
334
+ text=str(result.get("text", "")),
335
+ )
336
+ except (KeyError, TypeError, ValueError):
337
+ return None
338
+
339
+
340
+ def locate_text(phrase: str, port: int = DEFAULT_PORT) -> DomBox | None:
341
+ """Find a clickable element matching ``phrase``; screen-space box or None."""
342
+ phrase = (phrase or "").strip()
343
+ if not phrase:
344
+ return None
345
+ js = _BOX_JS % {"finder": _finder_by_text(phrase)}
346
+ return _box_from(evaluate(js, port))
347
+
348
+
349
+ def locate_selector(selector: str, port: int = DEFAULT_PORT) -> DomBox | None:
350
+ """Find an element by CSS selector; screen-space box or None."""
351
+ selector = (selector or "").strip()
352
+ if not selector:
353
+ return None
354
+ js = _BOX_JS % {"finder": f"document.querySelector({json.dumps(selector)})"}
355
+ return _box_from(evaluate(js, port))
356
+
357
+
358
+ def click_text(phrase: str, port: int = DEFAULT_PORT) -> bool:
359
+ """Click an element by its visible text, inside the page.
360
+
361
+ A real in-page ``.click()`` — no mouse movement, so it cannot miss and is
362
+ unaffected by window position, scroll, or z-order.
363
+ """
364
+ phrase = (phrase or "").strip()
365
+ if not phrase:
366
+ return False
367
+ js = f"(() => {{ const el = {_finder_by_text(phrase)}; if (!el) return false; el.click(); return true; }})()"
368
+ return evaluate(js, port) is True
369
+
370
+
371
+ def click_selector(selector: str, port: int = DEFAULT_PORT) -> bool:
372
+ """Click the first element matching a CSS selector, inside the page."""
373
+ selector = (selector or "").strip()
374
+ if not selector:
375
+ return False
376
+ js = (
377
+ f"(() => {{ const el = document.querySelector({json.dumps(selector)}); "
378
+ "if (!el) return false; el.click(); return true; })()"
379
+ )
380
+ return evaluate(js, port) is True
381
+
382
+
383
+ def exists(selector: str, port: int = DEFAULT_PORT) -> bool:
384
+ """Whether any visible element matches a CSS selector."""
385
+ js = (
386
+ f"(() => {{ const el = document.querySelector({json.dumps(selector)}); "
387
+ "if (!el) return false; const r = el.getBoundingClientRect(); "
388
+ "return r.width > 0 && r.height > 0; })()"
389
+ )
390
+ return evaluate(js, port) is True
391
+
392
+
393
+ # --- connection management ---------------------------------------------------
394
+
395
+ def _get_connection(port: int) -> "_Connection | None":
396
+ """The live DevTools socket for the active tab, opening one if needed."""
397
+ global _conn
398
+ if _conn is not None and _conn.alive:
399
+ return _conn
400
+ tab = active_tab(port)
401
+ if tab is None or not tab.ws_url:
402
+ return None
403
+ try:
404
+ _conn = _Connection(tab.ws_url)
405
+ except Exception:
406
+ _conn = None
407
+ return _conn
408
+
409
+
410
+ def _reset_connection() -> None:
411
+ with _lock:
412
+ _reset_connection_locked()
413
+
414
+
415
+ def _reset_connection_locked() -> None:
416
+ """Drop the cached socket. Caller must already hold ``_lock``."""
417
+ global _conn
418
+ if _conn is not None:
419
+ try:
420
+ _conn.close()
421
+ except Exception:
422
+ pass
423
+ _conn = None
424
+
425
+
426
+ def reset() -> None:
427
+ """Forget any attached tab — called when the browser is closed/restarted."""
428
+ _reset_connection()
429
+
430
+
431
+ class _Connection:
432
+ """A minimal DevTools WebSocket client (RFC 6455, client role).
433
+
434
+ Only what CDP needs: a text-frame request/response pair with an incrementing
435
+ message id. Server frames are never masked; client frames always are.
436
+ """
437
+
438
+ def __init__(self, ws_url: str, timeout: float = _WS_TIMEOUT_S) -> None:
439
+ host, port, path = _split_ws_url(ws_url)
440
+ self._sock = socket.create_connection((host, port), timeout=timeout)
441
+ self._sock.settimeout(timeout)
442
+ self._handshake(host, port, path)
443
+ self._next_id = 0
444
+ self.alive = True
445
+
446
+ def _handshake(self, host: str, port: int, path: str) -> None:
447
+ key = base64.b64encode(os.urandom(16)).decode()
448
+ request = (
449
+ f"GET {path} HTTP/1.1\r\n"
450
+ f"Host: {host}:{port}\r\n"
451
+ "Upgrade: websocket\r\n"
452
+ "Connection: Upgrade\r\n"
453
+ f"Sec-WebSocket-Key: {key}\r\n"
454
+ "Sec-WebSocket-Version: 13\r\n"
455
+ # Chromium >= 111 rejects DevTools sockets from unknown origins
456
+ # unless the browser was started with --remote-allow-origins.
457
+ # Sending no Origin header at all keeps us in the allowed case.
458
+ "\r\n"
459
+ )
460
+ self._sock.sendall(request.encode())
461
+ header = b""
462
+ while b"\r\n\r\n" not in header:
463
+ chunk = self._sock.recv(4096)
464
+ if not chunk:
465
+ raise ConnectionError("DevTools closed the connection during handshake")
466
+ header += chunk
467
+ if len(header) > 65536:
468
+ raise ConnectionError("DevTools handshake response was implausibly large")
469
+ if b" 101 " not in header.split(b"\r\n", 1)[0]:
470
+ raise ConnectionError("DevTools refused the WebSocket upgrade")
471
+ # Anything after the header belongs to the frame stream.
472
+ self._buffer = header.split(b"\r\n\r\n", 1)[1]
473
+
474
+ def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
475
+ """Send a CDP command and return its ``result`` payload."""
476
+ self._next_id += 1
477
+ message_id = self._next_id
478
+ self._send(json.dumps({"id": message_id, "method": method, "params": params or {}}))
479
+ # CDP interleaves unsolicited events with replies; skip to ours.
480
+ for _ in range(50):
481
+ reply = json.loads(self._recv())
482
+ if reply.get("id") == message_id:
483
+ if "error" in reply:
484
+ raise RuntimeError(str(reply["error"]))
485
+ return reply.get("result")
486
+ raise TimeoutError(f"no DevTools reply for {method}")
487
+
488
+ def _send(self, text: str) -> None:
489
+ payload = text.encode("utf-8")
490
+ header = bytearray([0x81]) # FIN + text opcode
491
+ length = len(payload)
492
+ if length < 126:
493
+ header.append(0x80 | length)
494
+ elif length < (1 << 16):
495
+ header.append(0x80 | 126)
496
+ header += struct.pack(">H", length)
497
+ else:
498
+ header.append(0x80 | 127)
499
+ header += struct.pack(">Q", length)
500
+ mask = os.urandom(4)
501
+ header += mask
502
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
503
+ self._sock.sendall(bytes(header) + masked)
504
+
505
+ def _recv(self) -> str:
506
+ """Read one complete (possibly fragmented) text message."""
507
+ chunks: list[bytes] = []
508
+ while True:
509
+ fin, opcode, payload = self._read_frame()
510
+ if opcode == 0x8: # close
511
+ self.alive = False
512
+ raise ConnectionError("DevTools closed the connection")
513
+ if opcode == 0x9: # ping -> pong, then keep reading
514
+ self._pong(payload)
515
+ continue
516
+ if opcode == 0xA: # pong, ignore
517
+ continue
518
+ chunks.append(payload)
519
+ if fin:
520
+ return b"".join(chunks).decode("utf-8", "replace")
521
+
522
+ def _read_frame(self) -> tuple[bool, int, bytes]:
523
+ first, second = self._read_exactly(2)
524
+ fin = bool(first & 0x80)
525
+ opcode = first & 0x0F
526
+ length = second & 0x7F
527
+ if length == 126:
528
+ length = struct.unpack(">H", self._read_exactly(2))[0]
529
+ elif length == 127:
530
+ length = struct.unpack(">Q", self._read_exactly(8))[0]
531
+ # Server-to-client frames are unmasked; if a mask bit is set, honour it.
532
+ mask = self._read_exactly(4) if second & 0x80 else b""
533
+ payload = self._read_exactly(length) if length else b""
534
+ if mask:
535
+ payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
536
+ return fin, opcode, payload
537
+
538
+ def _read_exactly(self, count: int) -> bytes:
539
+ while len(self._buffer) < count:
540
+ chunk = self._sock.recv(max(4096, count - len(self._buffer)))
541
+ if not chunk:
542
+ self.alive = False
543
+ raise ConnectionError("DevTools stream ended")
544
+ self._buffer += chunk
545
+ out, self._buffer = self._buffer[:count], self._buffer[count:]
546
+ return out
547
+
548
+ def _pong(self, payload: bytes) -> None:
549
+ mask = os.urandom(4)
550
+ header = bytearray([0x8A, 0x80 | len(payload)]) + mask
551
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
552
+ self._sock.sendall(bytes(header) + masked)
553
+
554
+ def close(self) -> None:
555
+ self.alive = False
556
+ try:
557
+ self._sock.close()
558
+ except OSError:
559
+ pass
560
+
561
+
562
+ def _split_ws_url(ws_url: str) -> tuple[str, int, str]:
563
+ """Split ``ws://host:port/path`` into its parts."""
564
+ rest = ws_url.split("://", 1)[-1]
565
+ netloc, _, path = rest.partition("/")
566
+ host, _, port_text = netloc.partition(":")
567
+ return host or "127.0.0.1", int(port_text or 80), "/" + path