main
py 3,148 lines 115 KB
Raw
1 from __future__ import annotations
2
3 import atexit
4 import asyncio
5 import base64
6 import contextlib
7 import contextvars
8 import os
9 import re
10 import shutil
11 import signal
12 import threading
13 import time
14 import uuid
15 from dataclasses import dataclass
16 from pathlib import Path
17 from typing import Any
18
19 from helpers import chat_media, files, kvp
20 from helpers.defer import DeferredTask
21 from helpers.errors import RepairableException
22 from helpers.print_style import PrintStyle
23
24 from plugins._browser.helpers.config import (
25 DEFAULT_BROWSER_TAB_SCOPE,
26 DEFAULT_HOMEPAGE_KEY,
27 DEFAULT_MAX_OPEN_TABS,
28 MAX_OPEN_TABS_KEY,
29 TAB_SCOPE_KEY,
30 build_browser_launch_config,
31 get_browser_config,
32 )
33 from plugins._browser.helpers.interactive_view import BrowserInteractiveView
34 from plugins._browser.helpers.url import normalize_url
35
36
37 PLUGIN_DIR = Path(__file__).resolve().parents[1]
38 DOM_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-dom-helper.js"
39 CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
40 RUNTIME_DATA_KEY = "_browser_runtime"
41 SHARED_RUNTIME_ID = "shared"
42 BROWSER_TABS_KEY = "browser_open_tabs"
43 BROWSER_TABS_VERSION = 1
44 DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
45 CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
46 SCREENCAST_MAX_WIDTH = 4096
47 SCREENCAST_MAX_HEIGHT = 4096
48 VIEWPORT_SIZE_TOLERANCE = 4
49 CLIPBOARD_BRIDGE_SCRIPT = r"""
50 (payload) => {
51 const action = String(payload?.action || "").trim().toLowerCase();
52 const text = String(payload?.text || "");
53 const result = {
54 action,
55 text: "",
56 changed: false,
57 default_prevented: false,
58 handled: false,
59 method: "dom",
60 };
61 const textInputTypes = new Set([
62 "",
63 "email",
64 "number",
65 "password",
66 "search",
67 "tel",
68 "text",
69 "url",
70 ]);
71
72 function deepestActiveElement() {
73 let active = document.activeElement || document.body || document.documentElement;
74 while (active?.shadowRoot?.activeElement) {
75 active = active.shadowRoot.activeElement;
76 }
77 return active || document.body || document.documentElement;
78 }
79
80 function editableTarget(element) {
81 if (!element) return null;
82 if (isTextControl(element) || element.isContentEditable) return element;
83 const closest = element.closest?.("input, textarea, [contenteditable]");
84 if (closest && (isTextControl(closest) || closest.isContentEditable)) return closest;
85 return element;
86 }
87
88 function isTextControl(element) {
89 if (!element) return false;
90 const tagName = String(element.tagName || "").toLowerCase();
91 if (tagName === "textarea") {
92 return !element.disabled && !element.readOnly;
93 }
94 if (tagName !== "input") return false;
95 const type = String(element.type || "text").toLowerCase();
96 return textInputTypes.has(type) && !element.disabled && !element.readOnly;
97 }
98
99 function selectedText(element) {
100 if (isTextControl(element)) {
101 try {
102 const start = Number(element.selectionStart);
103 const end = Number(element.selectionEnd);
104 if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
105 return String(element.value || "").slice(start, end);
106 }
107 } catch {}
108 return "";
109 }
110 const selection = globalThis.getSelection?.();
111 return selection ? String(selection.toString() || "") : "";
112 }
113
114 function makeClipboardData(seedText = "") {
115 let transfer = null;
116 try {
117 transfer = new DataTransfer();
118 } catch {}
119 if (transfer && seedText) {
120 transfer.setData("text/plain", seedText);
121 transfer.setData("text", seedText);
122 }
123 return transfer;
124 }
125
126 function clipboardDataText(transfer) {
127 if (!transfer) return "";
128 return String(transfer.getData("text/plain") || transfer.getData("text") || "");
129 }
130
131 function makeClipboardEvent(type, transfer) {
132 let event = null;
133 try {
134 event = new ClipboardEvent(type, {
135 bubbles: true,
136 cancelable: true,
137 clipboardData: transfer,
138 });
139 } catch {}
140 if (!event) {
141 event = new Event(type, { bubbles: true, cancelable: true });
142 }
143 if (transfer && !event.clipboardData) {
144 try {
145 Object.defineProperty(event, "clipboardData", { value: transfer });
146 } catch {}
147 }
148 return event;
149 }
150
151 function dispatchClipboardEvent(target, type, seedText = "") {
152 const transfer = makeClipboardData(seedText);
153 const event = makeClipboardEvent(type, transfer);
154 (target || document.body || document.documentElement).dispatchEvent(event);
155 return {
156 defaultPrevented: Boolean(event.defaultPrevented),
157 text: clipboardDataText(event.clipboardData || transfer),
158 };
159 }
160
161 function dispatchInputEvent(element, type, inputType, data = null) {
162 let event = null;
163 try {
164 event = new InputEvent(type, {
165 bubbles: true,
166 cancelable: type === "beforeinput",
167 inputType,
168 data,
169 });
170 } catch {}
171 if (!event) {
172 event = new Event(type, {
173 bubbles: true,
174 cancelable: type === "beforeinput",
175 });
176 }
177 return element.dispatchEvent(event);
178 }
179
180 function insertIntoTextControl(element, value) {
181 if (!isTextControl(element)) return false;
182 let start = 0;
183 let end = 0;
184 try {
185 start = Number(element.selectionStart);
186 end = Number(element.selectionEnd);
187 } catch {
188 return false;
189 }
190 if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
191 if (!dispatchInputEvent(element, "beforeinput", "insertFromPaste", value)) {
192 return false;
193 }
194 element.setRangeText(value, start, end, "end");
195 dispatchInputEvent(element, "input", "insertFromPaste", value);
196 return true;
197 }
198
199 function insertIntoContentEditable(element, value) {
200 if (!element?.isContentEditable) return false;
201 if (!dispatchInputEvent(element, "beforeinput", "insertFromPaste", value)) {
202 return false;
203 }
204 const selection = globalThis.getSelection?.();
205 if (!selection || selection.rangeCount === 0) return false;
206 try {
207 if (document.queryCommandSupported?.("insertText") && document.execCommand("insertText", false, value)) {
208 return true;
209 }
210 } catch {}
211 const range = selection.getRangeAt(0);
212 range.deleteContents();
213 const node = document.createTextNode(value);
214 range.insertNode(node);
215 range.setStartAfter(node);
216 range.collapse(true);
217 selection.removeAllRanges();
218 selection.addRange(range);
219 dispatchInputEvent(element, "input", "insertFromPaste", value);
220 return true;
221 }
222
223 function insertText(element, value) {
224 return insertIntoTextControl(element, value) || insertIntoContentEditable(element, value);
225 }
226
227 function removeSelectedText(element) {
228 if (isTextControl(element)) {
229 let start = 0;
230 let end = 0;
231 try {
232 start = Number(element.selectionStart);
233 end = Number(element.selectionEnd);
234 } catch {
235 return false;
236 }
237 if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return false;
238 if (!dispatchInputEvent(element, "beforeinput", "deleteByCut", null)) {
239 return false;
240 }
241 element.setRangeText("", start, end, "start");
242 dispatchInputEvent(element, "input", "deleteByCut", null);
243 return true;
244 }
245 if (!element?.isContentEditable) return false;
246 const selection = globalThis.getSelection?.();
247 if (!selection || selection.rangeCount === 0 || !String(selection.toString() || "")) {
248 return false;
249 }
250 if (!dispatchInputEvent(element, "beforeinput", "deleteByCut", null)) {
251 return false;
252 }
253 selection.deleteFromDocument();
254 dispatchInputEvent(element, "input", "deleteByCut", null);
255 return true;
256 }
257
258 const target = editableTarget(deepestActiveElement());
259 if (action === "paste") {
260 const event = dispatchClipboardEvent(target, "paste", text);
261 result.default_prevented = event.defaultPrevented;
262 result.handled = true;
263 result.text = text;
264 if (!event.defaultPrevented) {
265 result.changed = insertText(target, text);
266 }
267 return result;
268 }
269
270 if (action === "copy" || action === "cut") {
271 const selectionText = selectedText(target);
272 const event = dispatchClipboardEvent(target, action, selectionText);
273 result.default_prevented = event.defaultPrevented;
274 result.text = event.text || selectionText;
275 result.handled = Boolean(result.text || event.defaultPrevented);
276 if (action === "cut" && result.text && !event.defaultPrevented) {
277 result.changed = removeSelectedText(target);
278 }
279 return result;
280 }
281
282 result.error = `Unsupported clipboard action: ${action}`;
283 return result;
284 }
285 """
286
287 _SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
288
289
290 def _safe_context_id(context_id: str) -> str:
291 return _SAFE_CONTEXT_RE.sub("_", str(context_id or "default")).strip("._") or "default"
292
293
294 def _load_browser_tabs() -> tuple[bool, list[dict[str, Any]]]:
295 try:
296 payload = kvp.get_persistent(BROWSER_TABS_KEY, None)
297 except Exception as exc:
298 PrintStyle.warning(f"Browser tab recovery state could not be read: {exc}")
299 return False, []
300 if payload is None:
301 return False, []
302 if not isinstance(payload, dict) or not isinstance(payload.get("tabs"), list):
303 PrintStyle.warning("Browser tab recovery state is invalid; starting without it.")
304 return False, []
305
306 tabs: list[dict[str, Any]] = []
307 for entry in payload["tabs"]:
308 if not isinstance(entry, dict):
309 continue
310 context_id = str(entry.get("context_id") or "").strip()
311 url = str(entry.get("url") or "").strip()
312 if not context_id or not url:
313 continue
314 tabs.append(
315 {
316 "context_id": context_id,
317 "url": url,
318 "active": bool(entry.get("active")),
319 }
320 )
321 return True, tabs
322
323
324 def _save_browser_tabs(tabs: list[dict[str, Any]]) -> None:
325 kvp.set_persistent(
326 BROWSER_TABS_KEY,
327 {"version": BROWSER_TABS_VERSION, "tabs": tabs},
328 )
329
330
331 def _forget_browser_context(context_id: str) -> None:
332 exists, tabs = _load_browser_tabs()
333 if not exists:
334 return
335 remaining = [entry for entry in tabs if entry["context_id"] != context_id]
336 if len(remaining) != len(tabs):
337 try:
338 _save_browser_tabs(remaining)
339 except Exception as exc:
340 PrintStyle.warning(f"Browser tab recovery state could not be updated: {exc}")
341
342
343 def has_restorable_browser_tabs(context_id: str) -> bool:
344 exists, tabs = _load_browser_tabs()
345 if not exists:
346 for runtime_id in (SHARED_RUNTIME_ID, _safe_context_id(context_id)):
347 session_dir = Path(
348 files.get_abs_path(
349 "tmp",
350 "browser",
351 "sessions",
352 runtime_id,
353 "Default",
354 "Sessions",
355 )
356 )
357 try:
358 if any(session_dir.glob("Session_*")) or any(session_dir.glob("Tabs_*")):
359 return True
360 except OSError:
361 continue
362 return False
363 if not tabs:
364 return False
365 if str(
366 get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
367 or DEFAULT_BROWSER_TAB_SCOPE
368 ) == "shared":
369 return True
370 return any(entry["context_id"] == str(context_id) for entry in tabs)
371
372
373 @dataclass
374 class BrowserPage:
375 id: int
376 page: Any
377 context_id: str = ""
378
379
380 class _BrowserScreencast:
381 def __init__(
382 self,
383 *,
384 stream_id: str,
385 browser_id: int,
386 session: Any,
387 mime: str,
388 ):
389 self.id = stream_id
390 self.browser_id = browser_id
391 self.session = session
392 self.mime = mime
393 self.frame_consumer: Any | None = None
394 self.stop_callback: Any | None = None
395 self.queue = asyncio.Queue(maxsize=1)
396 self.stopped = False
397 self._closed = False
398 self._ack_tasks: set[asyncio.Task] = set()
399 self._expected_width = 0
400 self._expected_height = 0
401
402 async def start(
403 self,
404 *,
405 quality: int,
406 every_nth_frame: int,
407 viewport: dict[str, int],
408 capture_scale: float = 1.0,
409 ) -> None:
410 self.session.on("Page.screencastFrame", self._on_frame)
411 width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
412 height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
413 scale = max(1.0, min(2.0, float(capture_scale or 1.0)))
414 max_width = max(320, min(SCREENCAST_MAX_WIDTH, int(round(width * scale))))
415 max_height = max(200, min(SCREENCAST_MAX_HEIGHT, int(round(height * scale))))
416 self._expected_width = width
417 self._expected_height = height
418 with contextlib.suppress(Exception):
419 await self.session.send("Page.enable")
420 await self._apply_cdp_viewport({"width": width, "height": height})
421 await self.session.send(
422 "Page.startScreencast",
423 {
424 "format": "jpeg",
425 "quality": max(20, min(95, int(quality))),
426 "maxWidth": max_width,
427 "maxHeight": max_height,
428 "everyNthFrame": max(1, int(every_nth_frame)),
429 },
430 )
431
432 async def _apply_cdp_viewport(self, viewport: dict[str, int]) -> None:
433 width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
434 height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
435 await self.session.send(
436 "Emulation.setDeviceMetricsOverride",
437 {
438 "width": width,
439 "height": height,
440 "deviceScaleFactor": 1,
441 "mobile": False,
442 "dontSetVisibleSize": True,
443 },
444 )
445 with contextlib.suppress(Exception):
446 await self.session.send(
447 "Emulation.setVisibleSize",
448 {
449 "width": width,
450 "height": height,
451 },
452 )
453
454 async def next_frame(self, timeout: float = 1.0) -> dict[str, Any]:
455 frame = await asyncio.wait_for(self.queue.get(), timeout=max(0.1, float(timeout)))
456 if frame is None:
457 raise RuntimeError("Browser screencast stopped.")
458 return frame
459
460 async def pop_frame(self) -> dict[str, Any] | None:
461 try:
462 frame = self.queue.get_nowait()
463 except asyncio.QueueEmpty:
464 return None
465 if frame is None:
466 raise RuntimeError("Browser screencast stopped.")
467 return frame
468
469 async def attach_consumer(self, frame_consumer: Any, stop_callback: Any | None = None) -> None:
470 self.frame_consumer = frame_consumer
471 self.stop_callback = stop_callback
472 frame = await self.pop_frame()
473 if frame:
474 await self._deliver_frame(frame)
475
476 async def stop(self) -> None:
477 if self._closed:
478 return
479 was_stopped = self.stopped
480 self._closed = True
481 self.stopped = True
482 if not was_stopped:
483 self._notify_stopped()
484 self._drop_queued_frames()
485 with contextlib.suppress(asyncio.QueueFull):
486 self.queue.put_nowait(None)
487 with contextlib.suppress(Exception):
488 await self.session.send("Page.stopScreencast")
489 for task in list(self._ack_tasks):
490 task.cancel()
491 if self._ack_tasks:
492 await asyncio.gather(*self._ack_tasks, return_exceptions=True)
493 self._ack_tasks.clear()
494 with contextlib.suppress(Exception):
495 await self.session.detach()
496
497 def _on_frame(self, params: dict[str, Any]) -> None:
498 if self.stopped:
499 return
500 task = asyncio.create_task(self._handle_frame(params or {}))
501 self._ack_tasks.add(task)
502 task.add_done_callback(self._ack_tasks.discard)
503
504 async def _handle_frame(self, params: dict[str, Any]) -> None:
505 stop_after_ack = False
506 notify_stop = False
507 try:
508 data = params.get("data") or ""
509 if data:
510 metadata = dict(params.get("metadata") or {})
511 size = self._jpeg_size(data)
512 if size:
513 metadata["jpegWidth"], metadata["jpegHeight"] = size
514 metadata["expectedWidth"] = self._expected_width
515 metadata["expectedHeight"] = self._expected_height
516 await self._deliver_frame(
517 {
518 "browser_id": self.browser_id,
519 "mime": self.mime,
520 "image": data,
521 "metadata": metadata,
522 }
523 )
524 except asyncio.CancelledError:
525 stop_after_ack = True
526 except Exception:
527 if self.frame_consumer:
528 stop_after_ack = True
529 notify_stop = True
530 else:
531 raise
532 finally:
533 session_id = params.get("sessionId")
534 if session_id is not None and not self.stopped:
535 with contextlib.suppress(Exception):
536 await self.session.send(
537 "Page.screencastFrameAck",
538 {"sessionId": int(session_id)},
539 )
540 if stop_after_ack:
541 self.stopped = True
542 if notify_stop:
543 self._notify_stopped()
544
545 def _notify_stopped(self) -> None:
546 if not self.stop_callback:
547 return
548 with contextlib.suppress(Exception):
549 self.stop_callback()
550
551 async def _deliver_frame(self, frame: dict[str, Any]) -> None:
552 if not self.frame_consumer:
553 self._queue_latest(frame)
554 return
555 future = self.frame_consumer(frame)
556 if future is not None:
557 await asyncio.wrap_future(future)
558
559 def _queue_latest(self, frame: dict[str, Any]) -> None:
560 self._drop_queued_frames()
561 with contextlib.suppress(asyncio.QueueFull):
562 self.queue.put_nowait(frame)
563
564 @staticmethod
565 def _jpeg_size(data: str) -> tuple[int, int] | None:
566 try:
567 raw = base64.b64decode(data, validate=False)
568 except Exception:
569 return None
570 if len(raw) < 10 or raw[:2] != b"\xff\xd8":
571 return None
572 index = 2
573 standalone_markers = {0x01, *range(0xD0, 0xD8)}
574 size_markers = {
575 0xC0,
576 0xC1,
577 0xC2,
578 0xC3,
579 0xC5,
580 0xC6,
581 0xC7,
582 0xC9,
583 0xCA,
584 0xCB,
585 0xCD,
586 0xCE,
587 0xCF,
588 }
589 while index < len(raw) - 9:
590 if raw[index] != 0xFF:
591 index += 1
592 continue
593 while index < len(raw) and raw[index] == 0xFF:
594 index += 1
595 if index >= len(raw):
596 return None
597 marker = raw[index]
598 index += 1
599 if marker in standalone_markers:
600 continue
601 if index + 2 > len(raw):
602 return None
603 segment_length = int.from_bytes(raw[index : index + 2], "big")
604 if segment_length < 2 or index + segment_length > len(raw):
605 return None
606 if marker in size_markers and segment_length >= 7:
607 height = int.from_bytes(raw[index + 3 : index + 5], "big")
608 width = int.from_bytes(raw[index + 5 : index + 7], "big")
609 return width, height
610 index += segment_length
611 return None
612
613 def _drop_queued_frames(self) -> None:
614 while True:
615 try:
616 self.queue.get_nowait()
617 except asyncio.QueueEmpty:
618 return
619
620
621 class BrowserRuntime:
622 def __init__(self, context_id: str):
623 self.context_id = str(context_id)
624 self._core = _BrowserRuntimeCore(self.context_id)
625 self._worker = DeferredTask(thread_name=f"BrowserRuntime-{self.context_id}")
626 self._closed = False
627
628 async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
629 return await self.call_for(self.context_id, method, *args, **kwargs)
630
631 async def call_for(
632 self,
633 context_id: str,
634 method: str,
635 *args: Any,
636 **kwargs: Any,
637 ) -> Any:
638 if self._closed and method != "close":
639 raise RuntimeError("Browser runtime is closed.")
640
641 async def runner():
642 token = self._core.request_context_id.set(str(context_id or self.context_id))
643 try:
644 fn = getattr(self._core, method)
645 return await fn(*args, **kwargs)
646 finally:
647 self._core.request_context_id.reset(token)
648
649 return await self._worker.execute_inside(runner)
650
651 async def close(self, delete_profile: bool = False) -> None:
652 if self._closed:
653 return
654 try:
655 await self.call("close", delete_profile=delete_profile)
656 finally:
657 self._closed = True
658 try:
659 self._worker.kill(terminate_thread=True)
660 finally:
661 self._core.interactive_view.close()
662
663
664 class BrowserRuntimeSession:
665 def __init__(self, context_id: str, runtime: BrowserRuntime):
666 self.context_id = str(context_id)
667 self._runtime = runtime
668
669 async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
670 return await self._runtime.call_for(self.context_id, method, *args, **kwargs)
671
672
673 class _BrowserRuntimeCore:
674 _VALID_MODIFIERS = {"Control", "Shift", "Alt", "Meta"}
675 _KEY_ALIASES = {
676 "cmd": "Meta",
677 "command": "Meta",
678 "control": "Control",
679 "ctrl": "Control",
680 "escape": "Escape",
681 "esc": "Escape",
682 "meta": "Meta",
683 "option": "Alt",
684 "return": "Enter",
685 "space": "Space",
686 }
687 _POPUP_WAIT_SECONDS = 2.0
688
689 def __init__(self, context_id: str):
690 self.context_id = context_id
691 self.safe_context_id = _safe_context_id(context_id)
692 self.request_context_id: contextvars.ContextVar[str] = contextvars.ContextVar(
693 f"browser_context_{id(self)}",
694 default=context_id,
695 )
696 self.playwright = None
697 self.context = None
698 self.pages: dict[int, BrowserPage] = {}
699 self.screencasts: dict[str, _BrowserScreencast] = {}
700 self.next_browser_id = 1
701 self._last_interacted_browser_ids: dict[str, int] = {}
702 self._dom_helper_source: str | None = None
703 self._content_helper_source: str | None = None
704 self._start_lock: asyncio.Lock | None = None
705 self._registry_lock: asyncio.Lock | None = None
706 self._closing = False
707 self._pending_popups: list[asyncio.Future[int]] = []
708 self._pending_popup_contexts: dict[asyncio.Future[int], str] = {}
709 self._background_popup_pages: set[int] = set()
710 self._bootstrap_page: Any | None = None
711 self._restore_state_exists = False
712 self._restore_state_loaded = False
713 self._restore_entries: list[dict[str, Any]] = []
714 self._restored_context_ids: set[str] = set()
715 self._restored_all = False
716 self._restoring_tabs = False
717 self._browser_chrome_height: int | None = None
718 self._browser_window_page: Any | None = None
719 self._browser_window_session: Any | None = None
720 self._browser_window_id: int | None = None
721 self.interactive_view = BrowserInteractiveView(context_id)
722
723 @property
724 def current_context_id(self) -> str:
725 return str(self.request_context_id.get() or self.context_id)
726
727 @property
728 def last_interacted_browser_id(self) -> int | None:
729 return self._last_interacted_browser_ids.get(self.current_context_id)
730
731 @last_interacted_browser_id.setter
732 def last_interacted_browser_id(self, browser_id: int | None) -> None:
733 self._set_last_interacted(self.current_context_id, browser_id)
734
735 def _set_last_interacted(self, context_id: str, browser_id: int | None) -> None:
736 context_id = str(context_id or self.context_id)
737 if browser_id is None:
738 self._last_interacted_browser_ids.pop(context_id, None)
739 else:
740 self._last_interacted_browser_ids[context_id] = int(browser_id)
741
742 def _load_restore_state(self) -> None:
743 self._restore_state_exists, self._restore_entries = _load_browser_tabs()
744 self._restore_state_loaded = True
745 self._restored_context_ids.clear()
746 self._restored_all = False
747 self._restoring_tabs = False
748
749 def _tab_scope(self) -> str:
750 return str(
751 get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
752 or DEFAULT_BROWSER_TAB_SCOPE
753 )
754
755 def _persist_browser_tabs(self) -> None:
756 if not self._restore_state_loaded or self._restoring_tabs:
757 return
758
759 replaced_contexts = set(self._restored_context_ids)
760 live_tabs: list[dict[str, Any]] = []
761 for browser_id in sorted(self.pages):
762 browser_page = self.pages[browser_id]
763 context_id = self._page_context_id(browser_page)
764 replaced_contexts.add(context_id)
765 try:
766 url = str(browser_page.page.url or "about:blank").strip()
767 except Exception:
768 continue
769 if not url:
770 url = "about:blank"
771 live_tabs.append(
772 {
773 "context_id": context_id,
774 "url": url,
775 "active": (
776 self._last_interacted_browser_ids.get(context_id) == browser_id
777 ),
778 }
779 )
780
781 preserved_tabs = (
782 []
783 if self._restored_all
784 else [
785 entry
786 for entry in self._restore_entries
787 if entry["context_id"] not in replaced_contexts
788 ]
789 )
790 tabs = preserved_tabs + live_tabs
791 try:
792 _save_browser_tabs(tabs)
793 except Exception as exc:
794 PrintStyle.warning(f"Browser tab recovery state could not be saved: {exc}")
795 return
796 self._restore_state_exists = True
797 self._restore_entries = tabs
798
799 async def _restore_tabs_for_scope(self) -> None:
800 if not self._restore_state_loaded or self._restoring_tabs or not self.context:
801 return
802
803 tab_scope = self._tab_scope()
804 if tab_scope == "shared":
805 if self._restored_all:
806 return
807 entries = [
808 entry
809 for entry in self._restore_entries
810 if entry["context_id"] not in self._restored_context_ids
811 ]
812 else:
813 context_id = self.current_context_id
814 if self._restored_all or context_id in self._restored_context_ids:
815 return
816 entries = [
817 entry for entry in self._restore_entries if entry["context_id"] == context_id
818 ]
819
820 restored_ids: dict[str, list[int]] = {}
821 active_ids: dict[str, int] = {}
822 navigations: list[tuple[Any, str]] = []
823 self._restoring_tabs = True
824 try:
825 for entry in entries:
826 context_id = entry["context_id"]
827 if len(self._context_browser_ids(context_id)) >= self._max_open_tabs():
828 continue
829 page = self._bootstrap_page
830 self._bootstrap_page = None
831 if not page or page.is_closed():
832 page = await self.context.new_page()
833 browser_page = await self._register_page(page, context_id)
834 navigations.append((page, normalize_url(entry["url"])))
835 restored_ids.setdefault(context_id, []).append(browser_page.id)
836 if entry["active"]:
837 active_ids[context_id] = browser_page.id
838 await asyncio.gather(
839 *(
840 self._goto(page, url, wait_until="commit")
841 for page, url in navigations
842 )
843 )
844 finally:
845 self._restoring_tabs = False
846
847 for context_id, browser_ids in restored_ids.items():
848 self._set_last_interacted(
849 context_id,
850 active_ids.get(context_id, browser_ids[0]),
851 )
852 if tab_scope == "shared":
853 self._restored_all = True
854 self._restored_context_ids.update(entry["context_id"] for entry in entries)
855 else:
856 self._restored_context_ids.add(self.current_context_id)
857 self._persist_browser_tabs()
858
859 def _page_context_id(self, browser_page: BrowserPage) -> str:
860 return str(browser_page.context_id or self.context_id)
861
862 def _context_browser_ids(self, context_id: str | None = None) -> list[int]:
863 target = str(context_id or self.current_context_id)
864 return sorted(
865 browser_id
866 for browser_id, browser_page in self.pages.items()
867 if self._page_context_id(browser_page) == target
868 )
869
870 def _ensure_registry_lock(self) -> asyncio.Lock:
871 if self._registry_lock is None:
872 self._registry_lock = asyncio.Lock()
873 return self._registry_lock
874
875 def _maybe_promote(self, resolved_id: int) -> None:
876 # Promote only if the target IS the current active tab or no tab is
877 # active yet. Cross-tab work on a backgrounded tab does not steal
878 # viewer focus.
879 current = self.last_interacted_browser_id
880 if current is None or current == resolved_id:
881 self.last_interacted_browser_id = int(resolved_id)
882
883 def _background_focus_target(
884 self,
885 previous_focus: int | None,
886 fallback_id: int,
887 ) -> int | None:
888 browser_ids = self._context_browser_ids()
889 if previous_focus in browser_ids:
890 return int(previous_focus)
891 if fallback_id in browser_ids:
892 return int(fallback_id)
893 return next(iter(browser_ids), None)
894
895 def _normalize_modifiers(self, modifiers: list[str] | str | None) -> list[str] | None:
896 if modifiers is None:
897 return None
898 if isinstance(modifiers, str):
899 raw = [modifiers]
900 elif isinstance(modifiers, list):
901 raw = modifiers
902 else:
903 raise ValueError("modifiers must be a string or list")
904 normalized = [str(modifier).strip() for modifier in raw if str(modifier).strip()]
905 if not normalized:
906 return None
907 bad = set(normalized) - self._VALID_MODIFIERS
908 if bad:
909 raise ValueError(
910 f"unsupported modifiers: {sorted(bad)}; allowed: {sorted(self._VALID_MODIFIERS)}"
911 )
912 return normalized
913
914 @classmethod
915 def _normalize_keys(cls, keys: list[str] | str | None) -> list[str]:
916 if keys is None:
917 return []
918 if isinstance(keys, str):
919 raw = re.split(r"\s*\+\s*|\s*,\s*", keys.strip())
920 elif isinstance(keys, list):
921 raw = keys
922 else:
923 raw = [str(keys)]
924 normalized: list[str] = []
925 for key in raw:
926 value = str(key or "").strip()
927 if not value:
928 continue
929 normalized.append(
930 cls._KEY_ALIASES.get(
931 value.lower(),
932 value.upper() if len(value) == 1 and value.isalpha() else value,
933 )
934 )
935 return normalized
936
937 @staticmethod
938 def _has_reference(reference_id: int | str | None) -> bool:
939 return reference_id is not None and str(reference_id).strip() != ""
940
941 def _screenshot_output_path(self, browser_id: int, path: str = "") -> tuple[Path, str, str]:
942 raw_path = str(path or "").strip()
943 if raw_path:
944 output_path = Path(files.fix_dev_path(raw_path) if raw_path.startswith("/a0/") else raw_path)
945 if not output_path.is_absolute():
946 output_path = Path(files.get_abs_path(str(output_path)))
947 suffix = output_path.suffix.lower()
948 if suffix == ".png":
949 return output_path, "png", "image/png"
950 if suffix not in {".jpg", ".jpeg"}:
951 output_path = output_path.with_suffix(".jpg") if not suffix else output_path.with_name(f"{output_path.name}.jpg")
952 return output_path, "jpeg", "image/jpeg"
953
954 timestamp = time.strftime("%Y%m%d-%H%M%S")
955 millis = int((time.time() % 1) * 1000)
956 output_path = self.screenshots_dir / f"browser-{int(browser_id)}-{timestamp}-{millis:03d}.jpg"
957 return output_path, "jpeg", "image/jpeg"
958
959 @staticmethod
960 def _normalize_upload_paths(path: str = "", paths: list[str] | None = None) -> list[str]:
961 raw_paths: list[str] = []
962 if paths:
963 if not isinstance(paths, list):
964 raise ValueError("paths must be a list of file paths")
965 raw_paths.extend(str(item or "").strip() for item in paths)
966 if str(path or "").strip():
967 raw_paths.append(str(path or "").strip())
968
969 normalized_paths: list[str] = []
970 for raw_path in raw_paths:
971 if not raw_path:
972 continue
973 candidate = Path(files.fix_dev_path(raw_path) if raw_path.startswith("/a0/") else raw_path)
974 if not candidate.is_absolute():
975 candidate = Path(files.get_abs_path(str(candidate)))
976 candidate = candidate.expanduser().resolve()
977 if not candidate.is_file():
978 raise FileNotFoundError(f"Upload file does not exist: {candidate}")
979 normalized_paths.append(str(candidate))
980
981 if not normalized_paths:
982 raise ValueError("upload_file requires path or non-empty paths")
983 return normalized_paths
984
985 @staticmethod
986 def _multi_group_key(call: dict[str, Any]) -> Any:
987 value = call.get("browser_id")
988 if value is None or str(value).strip() == "":
989 return None
990 raw = str(value).strip()
991 if raw.startswith("browser-"):
992 raw = raw.split("-", 1)[1]
993 try:
994 return int(raw)
995 except ValueError:
996 return raw
997
998 @property
999 def profile_dir(self) -> Path:
1000 return Path(files.get_abs_path("tmp/browser/sessions", self.safe_context_id))
1001
1002 @property
1003 def downloads_dir(self) -> Path:
1004 return Path(files.get_abs_path("usr/downloads/browser"))
1005
1006 @property
1007 def screenshots_dir(self) -> Path:
1008 return Path(files.get_abs_path("tmp/browser/screenshots", self.safe_context_id))
1009
1010 async def ensure_started(self) -> None:
1011 if self._context_is_alive():
1012 await self._restore_tabs_for_scope()
1013 return
1014 if self.context:
1015 await self._discard_stale_context("Browser context is stale; restarting.")
1016
1017 if self._start_lock is None:
1018 self._start_lock = asyncio.Lock()
1019
1020 async with self._start_lock:
1021 if self._context_is_alive():
1022 await self._restore_tabs_for_scope()
1023 return
1024 if self.context:
1025 await self._discard_stale_context("Browser context is stale; restarting.")
1026 elif self.playwright and not self._closing:
1027 await self._stop_playwright("Browser context closed; restarting Playwright.")
1028 await self._start()
1029 await self._restore_tabs_for_scope()
1030
1031 def _context_is_alive(self) -> bool:
1032 if not self.context:
1033 return False
1034 try:
1035 pages = getattr(self.context, "pages")
1036 len(pages() if callable(pages) else pages)
1037 return True
1038 except AttributeError:
1039 # Lightweight test doubles may not model Playwright's pages property.
1040 return True
1041 except Exception:
1042 return False
1043
1044 async def _discard_stale_context(self, message: str) -> None:
1045 PrintStyle.warning(message)
1046 self._discard_context_state()
1047 await self._stop_playwright("Playwright stop after Browser context loss failed")
1048
1049 def _discard_context_state(self) -> None:
1050 for waiter in self._pending_popups:
1051 if not waiter.done():
1052 waiter.set_exception(RuntimeError("Browser context closed."))
1053 self._pending_popups.clear()
1054 self._pending_popup_contexts.clear()
1055 self._background_popup_pages.clear()
1056 self._bootstrap_page = None
1057 self._browser_chrome_height = None
1058 self._browser_window_page = None
1059 self._browser_window_session = None
1060 self._browser_window_id = None
1061 self.pages.clear()
1062 self._last_interacted_browser_ids.clear()
1063 for screencast in self.screencasts.values():
1064 screencast.stopped = True
1065 screencast._drop_queued_frames()
1066 with contextlib.suppress(asyncio.QueueFull):
1067 screencast.queue.put_nowait(None)
1068 for task in list(screencast._ack_tasks):
1069 task.cancel()
1070 screencast._ack_tasks.clear()
1071 self.screencasts.clear()
1072 self.context = None
1073
1074 async def _stop_playwright(self, warning: str) -> None:
1075 if not self.playwright:
1076 return
1077 try:
1078 await self.playwright.stop()
1079 except Exception as exc:
1080 PrintStyle.warning(f"{warning}: {exc}")
1081 finally:
1082 self.playwright = None
1083
1084 async def _start(self) -> None:
1085 from plugins._browser import hooks
1086
1087 self._load_restore_state()
1088 preparation = hooks.prepare_playwright_cache()
1089 if preparation.get("errors") or not preparation.get("binary"):
1090 problem = preparation.get("errors") or "missing binary"
1091 raise RuntimeError(f"Browser setup failed: {problem}")
1092 from patchright.async_api import async_playwright
1093
1094 self.profile_dir.parent.mkdir(parents=True, exist_ok=True)
1095 self._adopt_legacy_profile(self.current_context_id)
1096 self.profile_dir.mkdir(parents=True, exist_ok=True)
1097 self.downloads_dir.mkdir(parents=True, exist_ok=True)
1098 self._release_orphaned_profile_singleton()
1099 browser_config = get_browser_config()
1100 launch_config = build_browser_launch_config(browser_config)
1101 browser_binary = Path(preparation["binary"])
1102 browser_display = self.interactive_view.ensure_display()
1103 launch_args = list(launch_config["args"])
1104 if not self._restore_state_exists:
1105 launch_args.append("--restore-last-session")
1106 if browser_display:
1107 launch_args.extend(
1108 [
1109 "--window-position=0,0",
1110 f"--window-size={self.interactive_view.width},{self.interactive_view.height}",
1111 ]
1112 )
1113
1114 self.playwright = await async_playwright().start()
1115 launch_kwargs: dict[str, Any] = {
1116 "user_data_dir": str(self.profile_dir),
1117 "headless": not bool(browser_display),
1118 "accept_downloads": True,
1119 "downloads_path": str(self.downloads_dir),
1120 "args": launch_args,
1121 }
1122 if browser_display:
1123 launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display}
1124 launch_kwargs["no_viewport"] = True
1125 else:
1126 launch_kwargs.update(
1127 viewport=DEFAULT_VIEWPORT,
1128 screen=DEFAULT_VIEWPORT,
1129 no_viewport=False,
1130 )
1131 if launch_config["channel"]:
1132 launch_kwargs["channel"] = launch_config["channel"]
1133 else:
1134 launch_kwargs["executable_path"] = str(browser_binary)
1135 if launch_config["proxy"]:
1136 launch_kwargs["proxy"] = launch_config["proxy"]
1137 try:
1138 self.context = await self.playwright.chromium.launch_persistent_context(
1139 **launch_kwargs
1140 )
1141 except Exception:
1142 if self.playwright:
1143 try:
1144 await self.playwright.stop()
1145 except Exception:
1146 pass
1147 self.playwright = None
1148 raise
1149 self.context.set_default_timeout(30000)
1150 self.context.set_default_navigation_timeout(30000)
1151 self.context.on("close", self._on_context_closed)
1152 self.context.on("page", self._on_new_page_sync)
1153
1154 existing_pages = list(self.context.pages)
1155 if self._restore_state_exists:
1156 for page in existing_pages:
1157 if self._bootstrap_page is None:
1158 self._bootstrap_page = page
1159 await self._fit_browser_window(page)
1160 continue
1161 with contextlib.suppress(Exception):
1162 await page.close()
1163 return
1164
1165 for page in existing_pages:
1166 if page.url == "about:blank":
1167 if browser_display and self._bootstrap_page is None:
1168 self._bootstrap_page = page
1169 await self._fit_browser_window(page)
1170 continue
1171 try:
1172 await page.close()
1173 except Exception:
1174 pass
1175 continue
1176 await self._register_page(page)
1177
1178 def _adopt_legacy_profile(self, context_id: str) -> None:
1179 if self.safe_context_id != SHARED_RUNTIME_ID or self.profile_dir.exists():
1180 return
1181 legacy_profile = Path(
1182 files.get_abs_path("tmp/browser/sessions", _safe_context_id(context_id))
1183 )
1184 if legacy_profile == self.profile_dir or not legacy_profile.is_dir():
1185 return
1186 try:
1187 legacy_profile.rename(self.profile_dir)
1188 PrintStyle.info(
1189 f"Browser adopted the existing profile for context {context_id}."
1190 )
1191 except OSError as exc:
1192 PrintStyle.warning(f"Browser profile migration failed: {exc}")
1193
1194 def _release_orphaned_profile_singleton(self) -> None:
1195 lock_path = self.profile_dir / "SingletonLock"
1196 owner_pid = self._profile_singleton_owner_pid(lock_path)
1197 if owner_pid and self._process_owns_profile(owner_pid):
1198 PrintStyle.warning(
1199 f"Stopping orphaned Chromium process {owner_pid} for Browser profile {self.safe_context_id}."
1200 )
1201 self._terminate_process(owner_pid)
1202
1203 for name in CHROME_SINGLETON_FILES:
1204 singleton_path = self.profile_dir / name
1205 try:
1206 if singleton_path.exists() or singleton_path.is_symlink():
1207 singleton_path.unlink()
1208 except OSError as exc:
1209 PrintStyle.warning(f"Could not remove stale Browser profile lock {singleton_path}: {exc}")
1210
1211 @staticmethod
1212 def _profile_singleton_owner_pid(lock_path: Path) -> int | None:
1213 try:
1214 target = os.readlink(lock_path)
1215 except OSError:
1216 return None
1217 raw_pid = target.rsplit("-", 1)[-1]
1218 if not raw_pid.isdigit():
1219 return None
1220 return int(raw_pid)
1221
1222 def _process_owns_profile(self, pid: int) -> bool:
1223 cmdline_path = Path("/proc") / str(pid) / "cmdline"
1224 try:
1225 raw = cmdline_path.read_bytes()
1226 except OSError:
1227 return False
1228 cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore")
1229 return "chrome" in cmdline.lower() and str(self.profile_dir) in cmdline
1230
1231 @staticmethod
1232 def _terminate_process(pid: int) -> None:
1233 try:
1234 os.kill(pid, signal.SIGTERM)
1235 except ProcessLookupError:
1236 return
1237 except OSError as exc:
1238 PrintStyle.warning(f"Could not stop orphaned Chromium process {pid}: {exc}")
1239 return
1240
1241 deadline = time.monotonic() + 3
1242 while time.monotonic() < deadline:
1243 if not Path("/proc", str(pid)).exists():
1244 return
1245 time.sleep(0.1)
1246
1247 try:
1248 os.kill(pid, signal.SIGKILL)
1249 except ProcessLookupError:
1250 pass
1251 except OSError as exc:
1252 PrintStyle.warning(f"Could not force-stop orphaned Chromium process {pid}: {exc}")
1253
1254 async def open(self, url: str = "") -> dict[str, Any]:
1255 await self.ensure_started()
1256 self._ensure_can_open_page()
1257 context_id = self.current_context_id
1258 page = self._bootstrap_page
1259 self._bootstrap_page = None
1260 if not page or page.is_closed():
1261 page = await self.context.new_page()
1262 browser_page = await self._register_page(page, context_id)
1263 self.last_interacted_browser_id = browser_page.id
1264 target_url = self._initial_url(url)
1265 if target_url and target_url != "about:blank":
1266 await self._goto(page, normalize_url(target_url))
1267 else:
1268 await self._settle(page)
1269 self._persist_browser_tabs()
1270 return {"id": browser_page.id, "state": await self._state(browser_page.id)}
1271
1272 def _initial_url(self, url: str = "") -> str:
1273 raw_url = str(url or "").strip()
1274 if raw_url:
1275 return raw_url
1276 return str(get_browser_config().get(DEFAULT_HOMEPAGE_KEY) or "about:blank").strip() or "about:blank"
1277
1278 def _max_open_tabs(self) -> int:
1279 try:
1280 value = int(get_browser_config().get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS))
1281 except (TypeError, ValueError):
1282 value = DEFAULT_MAX_OPEN_TABS
1283 return max(1, value)
1284
1285 def _tab_limit_error(self, context_id: str | None = None) -> RepairableException:
1286 max_open_tabs = self._max_open_tabs()
1287 open_tabs = len(self._context_browser_ids(context_id))
1288 return RepairableException(
1289 f"Browser tab limit reached ({open_tabs}/{max_open_tabs}). "
1290 "Navigate an existing browser_id or close tabs with close/close_all before opening more."
1291 )
1292
1293 def _ensure_can_open_page(self) -> None:
1294 if len(self._context_browser_ids()) >= self._max_open_tabs():
1295 raise self._tab_limit_error()
1296
1297 async def list(self, include_content: bool = False) -> dict[str, Any]:
1298 await self.ensure_started()
1299 ids = self._context_browser_ids()
1300 if not ids:
1301 return {
1302 "browsers": [],
1303 "last_interacted_browser_id": self.last_interacted_browser_id,
1304 }
1305 states_task = asyncio.gather(*(self._state(bid) for bid in ids))
1306 if include_content:
1307 contents_task = asyncio.gather(
1308 *(self.content(bid) for bid in ids),
1309 return_exceptions=True,
1310 )
1311 states, contents = await asyncio.gather(states_task, contents_task)
1312 out: list[dict[str, Any]] = []
1313 for idx, bid in enumerate(ids):
1314 entry = states[idx]
1315 c = contents[idx]
1316 if isinstance(c, Exception):
1317 entry["content_error"] = str(c)
1318 else:
1319 entry["content"] = c
1320 out.append(entry)
1321 else:
1322 out = await states_task
1323 return {
1324 "browsers": out,
1325 "last_interacted_browser_id": self.last_interacted_browser_id,
1326 }
1327
1328 async def list_all(self) -> dict[str, Any]:
1329 await self.ensure_started()
1330 browser_ids = sorted(self.pages)
1331 return {
1332 "browsers": await asyncio.gather(
1333 *(self._state(browser_id) for browser_id in browser_ids)
1334 ),
1335 "last_interacted_browser_ids": dict(self._last_interacted_browser_ids),
1336 }
1337
1338 async def multi(self, calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
1339 if not isinstance(calls, list) or not calls:
1340 raise ValueError("multi requires a non-empty list of calls")
1341 groups: dict[Any, list[tuple[int, dict[str, Any]]]] = {}
1342 for idx, call in enumerate(calls):
1343 if not isinstance(call, dict):
1344 raise ValueError(f"calls[{idx}] is not an object")
1345 key = self._multi_group_key(call)
1346 groups.setdefault(key, []).append((idx, call))
1347
1348 results: list[dict[str, Any] | None] = [None] * len(calls)
1349
1350 async def run_group(group: list[tuple[int, dict[str, Any]]]) -> None:
1351 for idx, call in group:
1352 try:
1353 out = await self._dispatch_call(call)
1354 results[idx] = {"ok": True, "result": out}
1355 except Exception as exc:
1356 results[idx] = {"ok": False, "error": str(exc)}
1357
1358 await asyncio.gather(*(run_group(g) for g in groups.values()))
1359 return [r if r is not None else {"ok": False, "error": "missing"} for r in results]
1360
1361 async def _dispatch_call(self, call: dict[str, Any]) -> Any:
1362 action = str(call.get("action") or "").strip().lower().replace("-", "_")
1363 bid = call.get("browser_id")
1364 if action == "open":
1365 return await self.open(call.get("url") or "")
1366 if action == "screenshot":
1367 return await self.screenshot_file(
1368 bid,
1369 quality=int(call.get("quality") or 80),
1370 full_page=bool(call.get("full_page")),
1371 path=call.get("path") or "",
1372 )
1373 if action == "list":
1374 return await self.list(include_content=bool(call.get("include_content")))
1375 if action == "state":
1376 return await self.state(bid)
1377 if action in {"set_active", "setactive", "activate", "focus"}:
1378 return await self.set_active(bid)
1379 if action == "navigate":
1380 return await self.navigate(bid, call.get("url") or "")
1381 if action == "back":
1382 return await self.back(bid)
1383 if action == "forward":
1384 return await self.forward(bid)
1385 if action == "reload":
1386 return await self.reload(bid)
1387 if action == "content":
1388 payload = None
1389 sels = call.get("selectors")
1390 sel = call.get("selector")
1391 if sels:
1392 payload = {"selectors": sels}
1393 elif sel:
1394 payload = {"selector": sel}
1395 return await self.content(bid, payload)
1396 if action == "detail":
1397 ref = call.get("ref")
1398 if ref is None:
1399 raise ValueError("detail requires ref")
1400 return await self.detail(bid, ref)
1401 if action == "click":
1402 ref = call.get("ref")
1403 if ref is None and (call.get("x") or call.get("y")):
1404 return await self.mouse(
1405 bid,
1406 "click",
1407 float(call.get("x") or 0),
1408 float(call.get("y") or 0),
1409 button=call.get("button") or "left",
1410 modifiers=self._normalize_modifiers(call.get("modifiers")),
1411 )
1412 if ref is None:
1413 raise ValueError("click requires ref")
1414 return await self.click(
1415 bid, ref,
1416 modifiers=self._normalize_modifiers(call.get("modifiers")),
1417 focus_popup=call.get("focus_popup"),
1418 )
1419 if action == "type":
1420 ref = call.get("ref")
1421 if ref is None:
1422 return await self.keyboard(
1423 bid,
1424 key="",
1425 text=str(call.get("text") or ""),
1426 )
1427 return await self.type(bid, ref, call.get("text") or "")
1428 if action == "submit":
1429 ref = call.get("ref")
1430 if ref is None:
1431 raise ValueError("submit requires ref")
1432 return await self.submit(bid, ref)
1433 if action in {"type_submit", "typesubmit"}:
1434 ref = call.get("ref")
1435 if ref is None:
1436 raise ValueError("type_submit requires ref")
1437 return await self.type_submit(bid, ref, call.get("text") or "")
1438 if action == "scroll":
1439 ref = call.get("ref")
1440 if ref is None:
1441 raise ValueError("scroll requires ref")
1442 return await self.scroll(bid, ref)
1443 if action == "evaluate":
1444 return await self.evaluate(bid, call.get("script") or "")
1445 if action in {"key_chord", "keychord"}:
1446 keys = self._normalize_keys(call.get("keys"))
1447 if not keys:
1448 raise ValueError("key_chord requires non-empty keys")
1449 return await self.key_chord(bid, keys)
1450 if action == "mouse":
1451 return await self.mouse(
1452 bid, call.get("event_type") or "click",
1453 float(call.get("x") or 0), float(call.get("y") or 0),
1454 button=call.get("button") or "left",
1455 modifiers=self._normalize_modifiers(call.get("modifiers")),
1456 )
1457 if action == "hover":
1458 return await self.hover(
1459 bid,
1460 ref=call.get("ref"),
1461 x=float(call.get("x") or 0),
1462 y=float(call.get("y") or 0),
1463 offset_x=float(call.get("offset_x") or 0),
1464 offset_y=float(call.get("offset_y") or 0),
1465 )
1466 if action == "double_click":
1467 return await self.double_click(
1468 bid,
1469 ref=call.get("ref"),
1470 x=float(call.get("x") or 0),
1471 y=float(call.get("y") or 0),
1472 button=call.get("button") or "left",
1473 modifiers=self._normalize_modifiers(call.get("modifiers")),
1474 offset_x=float(call.get("offset_x") or 0),
1475 offset_y=float(call.get("offset_y") or 0),
1476 )
1477 if action == "right_click":
1478 return await self.right_click(
1479 bid,
1480 ref=call.get("ref"),
1481 x=float(call.get("x") or 0),
1482 y=float(call.get("y") or 0),
1483 modifiers=self._normalize_modifiers(call.get("modifiers")),
1484 offset_x=float(call.get("offset_x") or 0),
1485 offset_y=float(call.get("offset_y") or 0),
1486 )
1487 if action == "drag":
1488 return await self.drag(
1489 bid,
1490 ref=call.get("ref"),
1491 target_ref=call.get("target_ref"),
1492 x=float(call.get("x") or 0),
1493 y=float(call.get("y") or 0),
1494 to_x=float(call.get("to_x") or 0),
1495 to_y=float(call.get("to_y") or 0),
1496 offset_x=float(call.get("offset_x") or 0),
1497 offset_y=float(call.get("offset_y") or 0),
1498 target_offset_x=float(call.get("target_offset_x") or 0),
1499 target_offset_y=float(call.get("target_offset_y") or 0),
1500 )
1501 if action == "wheel":
1502 return await self.wheel(
1503 bid,
1504 float(call.get("x") or 0),
1505 float(call.get("y") or 0),
1506 float(call.get("delta_x") or 0),
1507 float(call.get("delta_y") or 0),
1508 )
1509 if action == "keyboard":
1510 return await self.keyboard(
1511 bid,
1512 key=str(call.get("key") or ""),
1513 text=str(call.get("text") or ""),
1514 )
1515 if action == "clipboard":
1516 clipboard_action = str(
1517 call.get("clipboard_action")
1518 or call.get("operation")
1519 or call.get("event_type")
1520 or ""
1521 ).strip().lower()
1522 return await self.clipboard(
1523 bid,
1524 action=clipboard_action,
1525 text=str(call.get("text") or ""),
1526 )
1527 if action in {"copy", "cut", "paste"}:
1528 return await self.clipboard(
1529 bid,
1530 action=action,
1531 text=str(call.get("text") or ""),
1532 )
1533 if action == "set_viewport":
1534 return await self.set_viewport(
1535 bid,
1536 int(call.get("width") or 0),
1537 int(call.get("height") or 0),
1538 )
1539 if action == "select_option":
1540 ref = call.get("ref")
1541 if ref is None:
1542 raise ValueError("select_option requires ref")
1543 return await self.select_option(
1544 bid,
1545 ref,
1546 value=str(call.get("value") or ""),
1547 values=call.get("values"),
1548 )
1549 if action == "set_checked":
1550 ref = call.get("ref")
1551 if ref is None:
1552 raise ValueError("set_checked requires ref")
1553 checked = call.get("checked")
1554 return await self.set_checked(
1555 bid,
1556 ref,
1557 checked=True if checked is None else bool(checked),
1558 )
1559 if action == "upload_file":
1560 ref = call.get("ref")
1561 if ref is None:
1562 raise ValueError("upload_file requires ref")
1563 return await self.upload_file(
1564 bid,
1565 ref,
1566 path=call.get("path") or "",
1567 paths=call.get("paths"),
1568 )
1569 if action == "close":
1570 return await self.close_browser(bid)
1571 if action == "close_all":
1572 return await self.close_all_browsers()
1573 raise ValueError(f"unknown action: {action}")
1574
1575 async def set_active(self, browser_id: int | str | None) -> dict[str, Any]:
1576 await self.ensure_started()
1577 resolved_id = self._resolve_browser_id(browser_id)
1578 page = self._page(resolved_id)
1579 # Explicit focus change — bypass _maybe_promote.
1580 self.last_interacted_browser_id = int(resolved_id)
1581 with contextlib.suppress(Exception):
1582 await page.bring_to_front()
1583 await self._fit_browser_window(page)
1584 self._persist_browser_tabs()
1585 return await self._state(resolved_id)
1586
1587 async def state(self, browser_id: int | str | None = None) -> dict[str, Any]:
1588 await self.ensure_started()
1589 return await self._state(self._resolve_browser_id(browser_id))
1590
1591 async def navigate(
1592 self,
1593 browser_id: int | str | None,
1594 url: str,
1595 *,
1596 wait_until: str = "domcontentloaded",
1597 ) -> dict[str, Any]:
1598 await self.ensure_started()
1599 resolved_id = self._resolve_browser_id(browser_id)
1600 page = self._page(resolved_id)
1601 await self._goto(page, normalize_url(url), wait_until=wait_until)
1602 self._maybe_promote(resolved_id)
1603 self._persist_browser_tabs()
1604 return await self._state(resolved_id)
1605
1606 async def back(
1607 self,
1608 browser_id: int | str | None = None,
1609 *,
1610 wait_until: str = "domcontentloaded",
1611 ) -> dict[str, Any]:
1612 await self.ensure_started()
1613 resolved_id = self._resolve_browser_id(browser_id)
1614 page = self._page(resolved_id)
1615 await page.go_back(wait_until=wait_until, timeout=10000)
1616 await self._settle(page, short=wait_until == "commit")
1617 self._maybe_promote(resolved_id)
1618 self._persist_browser_tabs()
1619 return await self._state(resolved_id)
1620
1621 async def forward(
1622 self,
1623 browser_id: int | str | None = None,
1624 *,
1625 wait_until: str = "domcontentloaded",
1626 ) -> dict[str, Any]:
1627 await self.ensure_started()
1628 resolved_id = self._resolve_browser_id(browser_id)
1629 page = self._page(resolved_id)
1630 await page.go_forward(wait_until=wait_until, timeout=10000)
1631 await self._settle(page, short=wait_until == "commit")
1632 self._maybe_promote(resolved_id)
1633 self._persist_browser_tabs()
1634 return await self._state(resolved_id)
1635
1636 async def reload(
1637 self,
1638 browser_id: int | str | None = None,
1639 *,
1640 wait_until: str = "domcontentloaded",
1641 ) -> dict[str, Any]:
1642 await self.ensure_started()
1643 resolved_id = self._resolve_browser_id(browser_id)
1644 page = self._page(resolved_id)
1645 await page.reload(wait_until=wait_until, timeout=15000)
1646 await self._settle(page, short=wait_until == "commit")
1647 self._maybe_promote(resolved_id)
1648 self._persist_browser_tabs()
1649 return await self._state(resolved_id)
1650
1651 async def content(
1652 self,
1653 browser_id: int | str | None = None,
1654 payload: dict[str, Any] | None = None,
1655 ) -> dict[str, Any]:
1656 await self.ensure_started()
1657 resolved_id = self._resolve_browser_id(browser_id)
1658 page = self._page(resolved_id)
1659 await self._ensure_content_helper(page)
1660 result = await page.evaluate(
1661 "(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
1662 payload or None,
1663 isolated_context=True,
1664 )
1665 self._maybe_promote(resolved_id)
1666 return result or {}
1667
1668 async def detail(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
1669 await self.ensure_started()
1670 resolved_id = self._resolve_browser_id(browser_id)
1671 page = self._page(resolved_id)
1672 await self._ensure_content_helper(page)
1673 result = await page.evaluate(
1674 "(ref) => globalThis.__spaceBrowserPageContent__.detail(ref)",
1675 reference_id,
1676 isolated_context=True,
1677 )
1678 self._maybe_promote(resolved_id)
1679 return result or {}
1680
1681 async def annotation_target(
1682 self,
1683 browser_id: int | str | None,
1684 payload: dict[str, Any] | None = None,
1685 ) -> dict[str, Any]:
1686 await self.ensure_started()
1687 resolved_id = self._resolve_browser_id(browser_id)
1688 page = self._page(resolved_id)
1689 await self._ensure_content_helper(page)
1690 result = await page.evaluate(
1691 "(payload) => globalThis.__spaceBrowserPageContent__.annotate(payload || null)",
1692 payload or None,
1693 isolated_context=True,
1694 )
1695 self._maybe_promote(resolved_id)
1696 return result or {}
1697
1698 async def evaluate(self, browser_id: int | str | None, script: str) -> dict[str, Any]:
1699 await self.ensure_started()
1700 resolved_id = self._resolve_browser_id(browser_id)
1701 page = self._page(resolved_id)
1702 result = await page.evaluate(str(script or "undefined"), isolated_context=False)
1703 self._maybe_promote(resolved_id)
1704 return {"result": result, "state": await self._state(resolved_id)}
1705
1706 async def click(
1707 self,
1708 browser_id: int | str | None,
1709 reference_id: int | str,
1710 modifiers: list[str] | str | None = None,
1711 focus_popup: bool | None = None,
1712 ) -> dict[str, Any]:
1713 modifiers = self._normalize_modifiers(modifiers)
1714 if modifiers:
1715 return await self._modifier_click(browser_id, reference_id, modifiers, focus_popup)
1716 return await self._reference_action("click", browser_id, reference_id)
1717
1718 async def _modifier_click(
1719 self,
1720 browser_id: int | str | None,
1721 reference_id: int | str,
1722 modifiers: list[str],
1723 focus_popup: bool | None,
1724 ) -> dict[str, Any]:
1725 await self.ensure_started()
1726 resolved_id = self._resolve_browser_id(browser_id)
1727 previous_focus = self.last_interacted_browser_id
1728 page = self._page(resolved_id)
1729 await self._ensure_content_helper(page)
1730
1731 box = await page.evaluate(
1732 "(ref) => globalThis.__spaceBrowserPageContent__.boundingBoxFor(ref)",
1733 reference_id,
1734 isolated_context=True,
1735 )
1736
1737 background = focus_popup is False or (
1738 focus_popup is None and bool({"Control", "Meta"} & set(modifiers))
1739 )
1740
1741 loop = asyncio.get_running_loop()
1742 waiter: asyncio.Future[int] = loop.create_future()
1743 self._pending_popups.append(waiter)
1744 self._pending_popup_contexts[waiter] = self.current_context_id
1745
1746 warning: str | None = None
1747 opened_id: int | None = None
1748 try:
1749 box_has_geometry = bool(box and box.get("width") and box.get("height"))
1750 box_selector = box.get("selector") if box else None
1751 if box_has_geometry:
1752 cx = box["x"] + box["width"] / 2
1753 cy = box["y"] + box["height"] / 2
1754 # Mouse.click does not accept modifiers; hold them via keyboard.
1755 pressed: list[str] = []
1756 try:
1757 for mod in modifiers:
1758 await page.keyboard.down(mod)
1759 pressed.append(mod)
1760 await page.mouse.click(cx, cy)
1761 finally:
1762 for mod in reversed(pressed):
1763 with contextlib.suppress(Exception):
1764 await page.keyboard.up(mod)
1765 await self._settle(page, short=False)
1766 elif box_selector:
1767 try:
1768 await page.locator(box_selector).click(
1769 modifiers=list(modifiers), force=True, timeout=5000
1770 )
1771 await self._settle(page, short=False)
1772 except Exception as exc:
1773 await self._reference_action("click", browser_id, reference_id)
1774 warning = f"modifiers ignored: locator click failed ({exc})"
1775 else:
1776 await self._reference_action("click", browser_id, reference_id)
1777 warning = "modifiers ignored: target geometry unavailable"
1778
1779 try:
1780 opened_id = await asyncio.wait_for(
1781 asyncio.shield(waiter), timeout=self._POPUP_WAIT_SECONDS
1782 )
1783 except asyncio.TimeoutError:
1784 opened_id = None
1785 finally:
1786 if waiter in self._pending_popups:
1787 self._pending_popups.remove(waiter)
1788 self._pending_popup_contexts.pop(waiter, None)
1789 if not waiter.done():
1790 waiter.cancel()
1791
1792 if opened_id is not None and background:
1793 if self.last_interacted_browser_id == opened_id:
1794 # Force focus back to the tab that was active before the
1795 # background click; the popup hook may have promoted.
1796 self.last_interacted_browser_id = self._background_focus_target(
1797 previous_focus,
1798 resolved_id,
1799 )
1800 finally:
1801 if waiter in self._pending_popups:
1802 self._pending_popups.remove(waiter)
1803 self._pending_popup_contexts.pop(waiter, None)
1804
1805 if background:
1806 # Background-mode click: preserve the pre-click focus even when
1807 # the clicked tab itself was not active.
1808 self.last_interacted_browser_id = self._background_focus_target(
1809 previous_focus,
1810 resolved_id,
1811 )
1812 return {
1813 "action": {
1814 "ref": reference_id,
1815 "modifiers": list(modifiers),
1816 "opened_browser_ids": [opened_id] if opened_id is not None else [],
1817 **({"warning": warning} if warning else {}),
1818 },
1819 "state": await self._state(resolved_id),
1820 }
1821
1822 async def key_chord(
1823 self,
1824 browser_id: int | str | None,
1825 keys: list[str],
1826 ) -> dict[str, Any]:
1827 if not keys:
1828 raise ValueError("key_chord requires at least one key")
1829 await self.ensure_started()
1830 resolved_id = self._resolve_browser_id(browser_id)
1831 page = self._page(resolved_id)
1832 pressed: list[str] = []
1833 try:
1834 for k in keys:
1835 await page.keyboard.down(k)
1836 pressed.append(k)
1837 finally:
1838 for k in reversed(pressed):
1839 with contextlib.suppress(Exception):
1840 await page.keyboard.up(k)
1841 await self._settle(page, short=True)
1842 self._maybe_promote(resolved_id)
1843 return await self._state(resolved_id)
1844
1845 async def submit(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
1846 return await self._reference_action("submit", browser_id, reference_id)
1847
1848 async def scroll(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
1849 return await self._reference_action("scroll", browser_id, reference_id)
1850
1851 async def type(
1852 self,
1853 browser_id: int | str | None,
1854 reference_id: int | str,
1855 text: str,
1856 ) -> dict[str, Any]:
1857 return await self._reference_action("type", browser_id, reference_id, text)
1858
1859 async def type_submit(
1860 self,
1861 browser_id: int | str | None,
1862 reference_id: int | str,
1863 text: str,
1864 ) -> dict[str, Any]:
1865 return await self._reference_action("typeSubmit", browser_id, reference_id, text)
1866
1867 async def clipboard(
1868 self,
1869 browser_id: int | str | None,
1870 *,
1871 action: str,
1872 text: str = "",
1873 ) -> dict[str, Any]:
1874 await self.ensure_started()
1875 resolved_id = self._resolve_browser_id(browser_id)
1876 page = self._page(resolved_id)
1877 normalized_action = str(action or "").strip().lower()
1878 if normalized_action not in {"copy", "cut", "paste"}:
1879 raise ValueError(f"Unsupported clipboard action: {normalized_action}")
1880
1881 clipboard_result: dict[str, Any]
1882 try:
1883 clipboard_result = await page.evaluate(
1884 CLIPBOARD_BRIDGE_SCRIPT,
1885 {
1886 "action": normalized_action,
1887 "text": str(text or ""),
1888 },
1889 isolated_context=False,
1890 ) or {}
1891 except Exception as exc:
1892 clipboard_result = {
1893 "action": normalized_action,
1894 "text": "",
1895 "changed": False,
1896 "default_prevented": False,
1897 "handled": False,
1898 "error": str(exc),
1899 }
1900
1901 if (
1902 normalized_action == "paste"
1903 and text
1904 and not clipboard_result.get("changed")
1905 and not clipboard_result.get("default_prevented")
1906 ):
1907 if await self._insert_clipboard_text(page, str(text)):
1908 clipboard_result["changed"] = True
1909 clipboard_result["method"] = "keyboard.insert_text"
1910 elif normalized_action in {"copy", "cut"} and not clipboard_result.get("text"):
1911 with contextlib.suppress(Exception):
1912 shortcut = "Control+C" if normalized_action == "copy" else "Control+X"
1913 await page.keyboard.press(shortcut)
1914 clipboard_result["keyboard_shortcut"] = True
1915
1916 await self._settle(page, short=True)
1917 self._maybe_promote(resolved_id)
1918 return {
1919 "state": await self._state(resolved_id),
1920 "clipboard": clipboard_result,
1921 }
1922
1923 async def close_browser(self, browser_id: int | str | None = None) -> dict[str, Any]:
1924 await self.ensure_started()
1925 resolved_id = self._resolve_browser_id(browser_id)
1926 await self._stop_screencasts_for_browser(resolved_id)
1927 page = self._page(resolved_id)
1928 await page.close()
1929 self.pages.pop(resolved_id, None)
1930 if self.last_interacted_browser_id == resolved_id:
1931 self.last_interacted_browser_id = next(iter(self._context_browser_ids()), None)
1932 self._persist_browser_tabs()
1933 return await self.list()
1934
1935 async def close_all_browsers(self) -> dict[str, Any]:
1936 await self.ensure_started()
1937 await self.close_context()
1938 return {"browsers": [], "last_interacted_browser_id": None}
1939
1940 async def close_context(self) -> None:
1941 for browser_id in self._context_browser_ids():
1942 await self._stop_screencasts_for_browser(browser_id)
1943 try:
1944 await self.pages[browser_id].page.close()
1945 except Exception:
1946 pass
1947 self.pages.pop(browser_id, None)
1948 self.last_interacted_browser_id = None
1949 self._restored_context_ids.add(self.current_context_id)
1950 self._persist_browser_tabs()
1951
1952 async def screenshot(
1953 self,
1954 browser_id: int | str | None = None,
1955 *,
1956 quality: int = 70,
1957 ) -> dict[str, Any]:
1958 await self.ensure_started()
1959 resolved_id = self._resolve_browser_id(browser_id)
1960 page = self._page(resolved_id)
1961 image = await page.screenshot(type="jpeg", quality=max(20, min(95, int(quality))))
1962 return {
1963 "browser_id": resolved_id,
1964 "mime": "image/jpeg",
1965 "image": base64.b64encode(image).decode("ascii"),
1966 "state": await self._state(resolved_id),
1967 }
1968
1969 async def screenshot_file(
1970 self,
1971 browser_id: int | str | None = None,
1972 *,
1973 quality: int = 80,
1974 full_page: bool = False,
1975 path: str = "",
1976 ) -> dict[str, Any]:
1977 await self.ensure_started()
1978 resolved_id = self._resolve_browser_id(browser_id)
1979 page = self._page(resolved_id)
1980 page_context_id = self._page_context_id(self.pages[resolved_id])
1981 raw_path = str(path or "").strip()
1982 if not raw_path:
1983 image = await page.screenshot(
1984 type="jpeg",
1985 quality=max(20, min(95, int(quality))),
1986 full_page=bool(full_page),
1987 )
1988 saved = chat_media.save_image_bytes(
1989 context_id=page_context_id,
1990 payload=image,
1991 mime_type="image/jpeg",
1992 category="screenshots",
1993 source="browser",
1994 preferred_name=f"browser-{resolved_id}.jpg",
1995 )
1996 return {
1997 "browser_id": resolved_id,
1998 "context_id": page_context_id,
1999 "path": saved.path,
2000 "a0_path": saved.a0_path,
2001 "mime": "image/jpeg",
2002 "ephemeral": False,
2003 "chat_scoped": True,
2004 "state": await self._state(resolved_id),
2005 "vision_load": {
2006 "tool_name": "vision_load",
2007 "tool_args": {
2008 "paths": [saved.a0_path],
2009 },
2010 },
2011 }
2012
2013 output_path, image_type, mime = self._screenshot_output_path(resolved_id, path)
2014 output_path.parent.mkdir(parents=True, exist_ok=True)
2015 clamped_quality = max(20, min(95, int(quality)))
2016 screenshot_kwargs: dict[str, Any] = {
2017 "path": str(output_path),
2018 "type": image_type,
2019 "full_page": bool(full_page),
2020 }
2021 if image_type == "jpeg":
2022 screenshot_kwargs["quality"] = clamped_quality
2023 await page.screenshot(**screenshot_kwargs)
2024 local_path = str(output_path)
2025 return {
2026 "browser_id": resolved_id,
2027 "context_id": page_context_id,
2028 "path": local_path,
2029 "a0_path": files.normalize_a0_path(local_path),
2030 "mime": mime,
2031 "state": await self._state(resolved_id),
2032 "vision_load": {
2033 "tool_name": "vision_load",
2034 "tool_args": {
2035 "paths": [local_path],
2036 },
2037 },
2038 }
2039
2040 async def start_screencast(
2041 self,
2042 browser_id: int | str | None = None,
2043 *,
2044 quality: int = 78,
2045 every_nth_frame: int = 1,
2046 capture_scale: float = 1.0,
2047 ) -> dict[str, Any]:
2048 await self.ensure_started()
2049 resolved_id = self._resolve_browser_id(browser_id)
2050 page = self._page(resolved_id)
2051 stream_id = uuid.uuid4().hex
2052 session = await self.context.new_cdp_session(page)
2053 screencast = _BrowserScreencast(
2054 stream_id=stream_id,
2055 browser_id=resolved_id,
2056 session=session,
2057 mime="image/jpeg",
2058 )
2059 self.screencasts[stream_id] = screencast
2060 try:
2061 await screencast.start(
2062 quality=quality,
2063 every_nth_frame=every_nth_frame,
2064 viewport=await self._page_viewport(page),
2065 capture_scale=capture_scale,
2066 )
2067 except Exception:
2068 self.screencasts.pop(stream_id, None)
2069 await screencast.stop()
2070 raise
2071 self._maybe_promote(resolved_id)
2072 return {
2073 "stream_id": stream_id,
2074 "browser_id": resolved_id,
2075 "state": await self._state(resolved_id),
2076 }
2077
2078 @staticmethod
2079 async def _page_viewport(page: Any) -> dict[str, int]:
2080 viewport = getattr(page, "viewport_size", None)
2081 if viewport:
2082 return {
2083 "width": int(viewport.get("width") or DEFAULT_VIEWPORT["width"]),
2084 "height": int(viewport.get("height") or DEFAULT_VIEWPORT["height"]),
2085 }
2086 try:
2087 measured = await page.evaluate(
2088 "() => ({ width: globalThis.innerWidth, height: globalThis.innerHeight })",
2089 isolated_context=False,
2090 )
2091 return {
2092 "width": int(measured.get("width") or DEFAULT_VIEWPORT["width"]),
2093 "height": int(measured.get("height") or DEFAULT_VIEWPORT["height"]),
2094 }
2095 except Exception:
2096 return dict(DEFAULT_VIEWPORT)
2097
2098 async def interactive_viewer(
2099 self,
2100 browser_id: int | str | None = None,
2101 *,
2102 width: int = 0,
2103 height: int = 0,
2104 ) -> dict[str, Any]:
2105 await self.ensure_started()
2106 resolved_id = self._resolve_browser_id(browser_id)
2107 page = self._page(resolved_id)
2108 current_viewport = await self._page_viewport(page)
2109 viewer = self.interactive_view.ensure_viewer(
2110 width or int(current_viewport.get("width") or DEFAULT_VIEWPORT["width"]),
2111 height or int(current_viewport.get("height") or DEFAULT_VIEWPORT["height"]),
2112 )
2113 if not viewer.get("available"):
2114 return viewer
2115
2116 await self._stop_screencasts_for_browser(resolved_id)
2117 with contextlib.suppress(Exception):
2118 await page.bring_to_front()
2119 viewport_result = await self.set_viewport(
2120 resolved_id,
2121 int(viewer.get("width") or width or DEFAULT_VIEWPORT["width"]),
2122 int(viewer.get("height") or height or DEFAULT_VIEWPORT["height"]),
2123 resize_interactive=True,
2124 )
2125 self.last_interacted_browser_id = int(resolved_id)
2126 return {
2127 **viewer,
2128 "browser_id": resolved_id,
2129 "state": viewport_result["state"],
2130 "viewport": viewport_result["viewport"],
2131 }
2132
2133 async def read_screencast_frame(
2134 self,
2135 stream_id: str,
2136 *,
2137 timeout: float = 1.0,
2138 ) -> dict[str, Any]:
2139 screencast = self.screencasts.get(str(stream_id or ""))
2140 if not screencast:
2141 raise KeyError("Browser screencast is not active.")
2142 return await screencast.next_frame(timeout=timeout)
2143
2144 async def pop_screencast_frame(self, stream_id: str) -> dict[str, Any] | None:
2145 screencast = self.screencasts.get(str(stream_id or ""))
2146 if not screencast:
2147 raise KeyError("Browser screencast is not active.")
2148 return await screencast.pop_frame()
2149
2150 async def attach_screencast_consumer(
2151 self,
2152 stream_id: str,
2153 frame_consumer: Any,
2154 stop_callback: Any | None = None,
2155 ) -> None:
2156 screencast = self.screencasts.get(str(stream_id or ""))
2157 if not screencast:
2158 raise KeyError("Browser screencast is not active.")
2159 await screencast.attach_consumer(frame_consumer, stop_callback)
2160
2161 async def stop_screencast(self, stream_id: str) -> None:
2162 screencast = self.screencasts.pop(str(stream_id or ""), None)
2163 if screencast:
2164 await screencast.stop()
2165
2166 async def set_viewport(
2167 self,
2168 browser_id: int | str | None,
2169 width: int,
2170 height: int,
2171 restart_screencast: bool = False,
2172 resize_interactive: bool = False,
2173 include_state: bool = True,
2174 ) -> dict[str, Any]:
2175 await self.ensure_started()
2176 resolved_id = self._resolve_browser_id(browser_id)
2177 page = self._page(resolved_id)
2178 if resize_interactive:
2179 resized = self.interactive_view.resize(width, height)
2180 viewport = {
2181 "width": int(resized.get("width") or self.interactive_view.width),
2182 "height": int(resized.get("height") or self.interactive_view.height),
2183 }
2184 await self._fit_browser_window(page)
2185 self._maybe_promote(resolved_id)
2186 return {
2187 "state": await self._state(resolved_id) if include_state else None,
2188 "viewport": viewport,
2189 }
2190
2191 viewport = {
2192 "width": max(320, min(4096, int(width or DEFAULT_VIEWPORT["width"]))),
2193 "height": max(200, min(4096, int(height or DEFAULT_VIEWPORT["height"]))),
2194 }
2195 current_viewport = await self._page_viewport(page)
2196 changed = (
2197 abs(int(current_viewport.get("width") or 0) - viewport["width"])
2198 > VIEWPORT_SIZE_TOLERANCE
2199 or abs(int(current_viewport.get("height") or 0) - viewport["height"])
2200 > VIEWPORT_SIZE_TOLERANCE
2201 )
2202 should_restart_screencast = changed or restart_screencast
2203 if should_restart_screencast:
2204 await self._stop_screencasts_for_browser(resolved_id)
2205 if changed:
2206 await page.set_viewport_size(viewport)
2207 if should_restart_screencast:
2208 await self._settle(page, short=True)
2209 self._maybe_promote(resolved_id)
2210 return {"state": await self._state(resolved_id), "viewport": viewport}
2211
2212 async def _point_for(
2213 self,
2214 page: Any,
2215 reference_id: int | str,
2216 *,
2217 offset_x: float = 0,
2218 offset_y: float = 0,
2219 ) -> dict[str, Any]:
2220 await self._ensure_content_helper(page)
2221 point = await page.evaluate(
2222 "(args) => globalThis.__spaceBrowserPageContent__.pointFor(args.ref, args.offsets)",
2223 {
2224 "ref": reference_id,
2225 "offsets": {
2226 "offset_x": float(offset_x),
2227 "offset_y": float(offset_y),
2228 "useOffsets": bool(offset_x or offset_y),
2229 },
2230 },
2231 isolated_context=True,
2232 )
2233 if not point or not isinstance(point, dict):
2234 raise ValueError(f"Could not resolve Browser ref {reference_id!r} to a viewport point")
2235 return point
2236
2237 async def _input_point(
2238 self,
2239 page: Any,
2240 reference_id: int | str | None,
2241 *,
2242 x: float = 0,
2243 y: float = 0,
2244 offset_x: float = 0,
2245 offset_y: float = 0,
2246 ) -> dict[str, Any]:
2247 if self._has_reference(reference_id):
2248 return await self._point_for(
2249 page,
2250 reference_id,
2251 offset_x=offset_x,
2252 offset_y=offset_y,
2253 )
2254 return {
2255 "x": float(x),
2256 "y": float(y),
2257 "rect": None,
2258 "selector": None,
2259 }
2260
2261 async def hover(
2262 self,
2263 browser_id: int | str | None,
2264 ref: int | str | None = None,
2265 x: float = 0,
2266 y: float = 0,
2267 offset_x: float = 0,
2268 offset_y: float = 0,
2269 ) -> dict[str, Any]:
2270 await self.ensure_started()
2271 resolved_id = self._resolve_browser_id(browser_id)
2272 page = self._page(resolved_id)
2273 point = await self._input_point(
2274 page,
2275 ref,
2276 x=x,
2277 y=y,
2278 offset_x=offset_x,
2279 offset_y=offset_y,
2280 )
2281 await page.mouse.move(float(point["x"]), float(point["y"]))
2282 self._maybe_promote(resolved_id)
2283 return {
2284 "action": {
2285 "point": point,
2286 "ref": ref if self._has_reference(ref) else None,
2287 },
2288 "state": await self._state(resolved_id),
2289 }
2290
2291 async def double_click(
2292 self,
2293 browser_id: int | str | None,
2294 ref: int | str | None = None,
2295 x: float = 0,
2296 y: float = 0,
2297 button: str = "left",
2298 modifiers: list[str] | str | None = None,
2299 offset_x: float = 0,
2300 offset_y: float = 0,
2301 ) -> dict[str, Any]:
2302 modifiers = self._normalize_modifiers(modifiers)
2303 await self.ensure_started()
2304 resolved_id = self._resolve_browser_id(browser_id)
2305 page = self._page(resolved_id)
2306 point = await self._input_point(
2307 page,
2308 ref,
2309 x=x,
2310 y=y,
2311 offset_x=offset_x,
2312 offset_y=offset_y,
2313 )
2314 pressed: list[str] = []
2315 try:
2316 if modifiers:
2317 for mod in modifiers:
2318 await page.keyboard.down(mod)
2319 pressed.append(mod)
2320 await page.mouse.dblclick(float(point["x"]), float(point["y"]), button=button or "left")
2321 finally:
2322 for mod in reversed(pressed):
2323 with contextlib.suppress(Exception):
2324 await page.keyboard.up(mod)
2325 await self._settle(page, short=True)
2326 self._maybe_promote(resolved_id)
2327 return {
2328 "action": {
2329 "button": button or "left",
2330 "modifiers": modifiers or [],
2331 "point": point,
2332 "ref": ref if self._has_reference(ref) else None,
2333 },
2334 "state": await self._state(resolved_id),
2335 }
2336
2337 async def right_click(
2338 self,
2339 browser_id: int | str | None,
2340 ref: int | str | None = None,
2341 x: float = 0,
2342 y: float = 0,
2343 modifiers: list[str] | str | None = None,
2344 offset_x: float = 0,
2345 offset_y: float = 0,
2346 ) -> dict[str, Any]:
2347 modifiers = self._normalize_modifiers(modifiers)
2348 await self.ensure_started()
2349 resolved_id = self._resolve_browser_id(browser_id)
2350 page = self._page(resolved_id)
2351 point = await self._input_point(
2352 page,
2353 ref,
2354 x=x,
2355 y=y,
2356 offset_x=offset_x,
2357 offset_y=offset_y,
2358 )
2359 pressed: list[str] = []
2360 try:
2361 if modifiers:
2362 for mod in modifiers:
2363 await page.keyboard.down(mod)
2364 pressed.append(mod)
2365 await page.mouse.click(float(point["x"]), float(point["y"]), button="right")
2366 finally:
2367 for mod in reversed(pressed):
2368 with contextlib.suppress(Exception):
2369 await page.keyboard.up(mod)
2370 await self._settle(page, short=True)
2371 self._maybe_promote(resolved_id)
2372 return {
2373 "action": {
2374 "button": "right",
2375 "modifiers": modifiers or [],
2376 "point": point,
2377 "ref": ref if self._has_reference(ref) else None,
2378 },
2379 "state": await self._state(resolved_id),
2380 }
2381
2382 async def drag(
2383 self,
2384 browser_id: int | str | None,
2385 ref: int | str | None = None,
2386 target_ref: int | str | None = None,
2387 x: float = 0,
2388 y: float = 0,
2389 to_x: float = 0,
2390 to_y: float = 0,
2391 offset_x: float = 0,
2392 offset_y: float = 0,
2393 target_offset_x: float = 0,
2394 target_offset_y: float = 0,
2395 ) -> dict[str, Any]:
2396 await self.ensure_started()
2397 resolved_id = self._resolve_browser_id(browser_id)
2398 page = self._page(resolved_id)
2399 start_point = await self._input_point(
2400 page,
2401 ref,
2402 x=x,
2403 y=y,
2404 offset_x=offset_x,
2405 offset_y=offset_y,
2406 )
2407 end_point = await self._input_point(
2408 page,
2409 target_ref,
2410 x=to_x,
2411 y=to_y,
2412 offset_x=target_offset_x,
2413 offset_y=target_offset_y,
2414 )
2415 await page.mouse.move(float(start_point["x"]), float(start_point["y"]))
2416 await page.mouse.down()
2417 await page.mouse.move(float(end_point["x"]), float(end_point["y"]), steps=12)
2418 await page.mouse.up()
2419 await self._settle(page, short=True)
2420 self._maybe_promote(resolved_id)
2421 return {
2422 "action": {
2423 "from": start_point,
2424 "ref": ref if self._has_reference(ref) else None,
2425 "target_ref": target_ref if self._has_reference(target_ref) else None,
2426 "to": end_point,
2427 },
2428 "state": await self._state(resolved_id),
2429 }
2430
2431 async def select_option(
2432 self,
2433 browser_id: int | str | None,
2434 ref: int | str,
2435 value: str = "",
2436 values: list[str] | None = None,
2437 ) -> dict[str, Any]:
2438 await self.ensure_started()
2439 resolved_id = self._resolve_browser_id(browser_id)
2440 page = self._page(resolved_id)
2441 await self._ensure_content_helper(page)
2442 action = await page.evaluate(
2443 "(args) => globalThis.__spaceBrowserPageContent__.select(args.ref, args.values)",
2444 {
2445 "ref": ref,
2446 "values": values if values is not None else value,
2447 },
2448 isolated_context=True,
2449 )
2450 await self._settle(page, short=True)
2451 self._maybe_promote(resolved_id)
2452 return {"action": action or {}, "state": await self._state(resolved_id)}
2453
2454 async def set_checked(
2455 self,
2456 browser_id: int | str | None,
2457 ref: int | str,
2458 checked: bool = True,
2459 ) -> dict[str, Any]:
2460 await self.ensure_started()
2461 resolved_id = self._resolve_browser_id(browser_id)
2462 page = self._page(resolved_id)
2463 await self._ensure_content_helper(page)
2464 action = await page.evaluate(
2465 "(args) => globalThis.__spaceBrowserPageContent__.setChecked(args.ref, args.checked)",
2466 {
2467 "ref": ref,
2468 "checked": bool(checked),
2469 },
2470 isolated_context=True,
2471 )
2472 await self._settle(page, short=True)
2473 self._maybe_promote(resolved_id)
2474 return {"action": action or {}, "state": await self._state(resolved_id)}
2475
2476 async def upload_file(
2477 self,
2478 browser_id: int | str | None,
2479 ref: int | str,
2480 path: str = "",
2481 paths: list[str] | None = None,
2482 ) -> dict[str, Any]:
2483 upload_paths = self._normalize_upload_paths(path=path, paths=paths)
2484 await self.ensure_started()
2485 resolved_id = self._resolve_browser_id(browser_id)
2486 page = self._page(resolved_id)
2487 await self._ensure_content_helper(page)
2488 metadata = await page.evaluate(
2489 "(ref) => globalThis.__spaceBrowserPageContent__.fileInputFor(ref)",
2490 ref,
2491 isolated_context=True,
2492 )
2493 handle = None
2494 try:
2495 handle = await page.evaluate_handle(
2496 "(ref) => globalThis.__spaceBrowserPageContent__.fileInputElementFor(ref)",
2497 ref,
2498 isolated_context=True,
2499 )
2500 element = handle.as_element() if handle else None
2501 if element:
2502 await element.set_input_files(upload_paths)
2503 elif metadata and metadata.get("selector"):
2504 await page.set_input_files(metadata["selector"], upload_paths)
2505 else:
2506 raise ValueError(f"Browser ref {ref!r} does not resolve to a file input")
2507 finally:
2508 if handle:
2509 with contextlib.suppress(Exception):
2510 await handle.dispose()
2511 await self._settle(page, short=True)
2512 self._maybe_promote(resolved_id)
2513 return {
2514 "action": {
2515 "files": upload_paths,
2516 "input": metadata or {},
2517 "ref": ref,
2518 },
2519 "state": await self._state(resolved_id),
2520 }
2521
2522 async def mouse(
2523 self,
2524 browser_id: int | str | None,
2525 event_type: str,
2526 x: float,
2527 y: float,
2528 button: str = "left",
2529 modifiers: list[str] | str | None = None,
2530 ) -> dict[str, Any]:
2531 event_type_lower = str(event_type or "click").lower()
2532 modifiers = self._normalize_modifiers(modifiers)
2533 if modifiers:
2534 if event_type_lower != "click":
2535 raise ValueError("modifiers are only valid for event_type='click'")
2536 await self.ensure_started()
2537 resolved_id = self._resolve_browser_id(browser_id)
2538 page = self._page(resolved_id)
2539 if event_type_lower == "move":
2540 await page.mouse.move(float(x), float(y))
2541 elif event_type_lower == "down":
2542 await page.mouse.down(button=button)
2543 elif event_type_lower == "up":
2544 await page.mouse.up(button=button)
2545 else:
2546 pressed: list[str] = []
2547 try:
2548 if modifiers:
2549 for mod in modifiers:
2550 await page.keyboard.down(mod)
2551 pressed.append(mod)
2552 await page.mouse.click(float(x), float(y), button=button)
2553 finally:
2554 for mod in reversed(pressed):
2555 with contextlib.suppress(Exception):
2556 await page.keyboard.up(mod)
2557 await self._settle(page, short=True)
2558 self._maybe_promote(resolved_id)
2559 return await self._state(resolved_id)
2560
2561 async def wheel(
2562 self,
2563 browser_id: int | str | None,
2564 x: float,
2565 y: float,
2566 delta_x: float = 0,
2567 delta_y: float = 0,
2568 ) -> dict[str, Any]:
2569 await self.ensure_started()
2570 resolved_id = self._resolve_browser_id(browser_id)
2571 page = self._page(resolved_id)
2572 await page.mouse.move(float(x), float(y))
2573 await page.mouse.wheel(float(delta_x), float(delta_y))
2574 self._maybe_promote(resolved_id)
2575 return await self._state(resolved_id)
2576
2577 async def keyboard(
2578 self,
2579 browser_id: int | str | None,
2580 *,
2581 key: str = "",
2582 text: str = "",
2583 ) -> dict[str, Any]:
2584 await self.ensure_started()
2585 resolved_id = self._resolve_browser_id(browser_id)
2586 page = self._page(resolved_id)
2587 if text:
2588 await page.keyboard.type(str(text))
2589 elif key:
2590 await page.keyboard.press(str(key))
2591 await self._settle(page, short=True)
2592 self._maybe_promote(resolved_id)
2593 return await self._state(resolved_id)
2594
2595 async def _insert_clipboard_text(self, page: Any, text: str) -> bool:
2596 if not text:
2597 return False
2598 insert_text = getattr(page.keyboard, "insert_text", None)
2599 if callable(insert_text):
2600 await insert_text(str(text))
2601 else:
2602 await page.keyboard.type(str(text))
2603 return True
2604
2605 async def close(self, delete_profile: bool = False) -> None:
2606 if delete_profile:
2607 with contextlib.suppress(Exception):
2608 kvp.remove_persistent(BROWSER_TABS_KEY)
2609 self._restore_entries.clear()
2610 self._restore_state_exists = False
2611 else:
2612 self._persist_browser_tabs()
2613 self._closing = True
2614 for waiter in self._pending_popups:
2615 if not waiter.done():
2616 waiter.set_exception(RuntimeError("Browser runtime is closing."))
2617 self._pending_popups.clear()
2618 self._pending_popup_contexts.clear()
2619 self._background_popup_pages.clear()
2620 await self._stop_all_screencasts()
2621 await self._reset_browser_window_session()
2622 for browser_id in list(self.pages):
2623 try:
2624 await self.pages[browser_id].page.close()
2625 except Exception:
2626 pass
2627 self.pages.clear()
2628 if self.context:
2629 try:
2630 await self.context.close()
2631 except Exception as exc:
2632 PrintStyle.warning(f"Browser context close failed: {exc}")
2633 self.context = None
2634 if self.playwright:
2635 try:
2636 await self.playwright.stop()
2637 except Exception as exc:
2638 PrintStyle.warning(f"Playwright stop failed: {exc}")
2639 self.playwright = None
2640 self._last_interacted_browser_ids.clear()
2641 if delete_profile:
2642 shutil.rmtree(self.profile_dir, ignore_errors=True)
2643
2644 def _on_context_closed(self) -> None:
2645 if self._closing or self.context is None:
2646 return
2647 PrintStyle.warning("Browser context closed unexpectedly; will restart on next use.")
2648 self._discard_context_state()
2649
2650 async def _reference_action(
2651 self,
2652 helper_method: str,
2653 browser_id: int | str | None,
2654 reference_id: int | str,
2655 text: str | None = None,
2656 ) -> dict[str, Any]:
2657 resolved_id = self._resolve_browser_id(browser_id)
2658 page = self._page(resolved_id)
2659 await self._ensure_content_helper(page)
2660 if text is None:
2661 action = await page.evaluate(
2662 "(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref)",
2663 {"method": helper_method, "ref": reference_id},
2664 isolated_context=True,
2665 )
2666 else:
2667 action = await page.evaluate(
2668 "(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref, args.text)",
2669 {"method": helper_method, "ref": reference_id, "text": text},
2670 isolated_context=True,
2671 )
2672 await self._settle(page, short=False)
2673 self._maybe_promote(resolved_id)
2674 return {"action": action or {}, "state": await self._state(resolved_id)}
2675
2676 async def _goto(
2677 self,
2678 page: Any,
2679 url: str,
2680 *,
2681 wait_until: str = "domcontentloaded",
2682 ) -> None:
2683 from patchright.async_api import Error as PlaywrightError
2684 from patchright.async_api import TimeoutError as PlaywrightTimeoutError
2685
2686 try:
2687 await page.goto(url, wait_until=wait_until, timeout=30000)
2688 except PlaywrightTimeoutError:
2689 PrintStyle.warning(f"Browser navigation timed out waiting for {wait_until}: {url}")
2690 except PlaywrightError as exc:
2691 PrintStyle.warning(f"Browser navigation showed a native error page for {url}: {exc}")
2692 await self._settle(page, short=wait_until == "commit")
2693
2694 async def _settle(self, page: Any, short: bool = False) -> None:
2695 from patchright.async_api import Error as PlaywrightError
2696 from patchright.async_api import TimeoutError as PlaywrightTimeoutError
2697
2698 try:
2699 await page.wait_for_load_state(
2700 "domcontentloaded",
2701 timeout=1000 if short else 5000,
2702 )
2703 except (PlaywrightError, PlaywrightTimeoutError):
2704 pass
2705 await asyncio.sleep(0.1 if short else 0.35)
2706
2707 async def _state(self, browser_id: int) -> dict[str, Any]:
2708 browser_page = self.pages.get(int(browser_id))
2709 if not browser_page:
2710 raise KeyError(f"Browser {browser_id} is not open.")
2711 page = browser_page.page
2712 try:
2713 title = await page.title()
2714 except Exception:
2715 title = ""
2716 try:
2717 history_length = await page.evaluate(
2718 "() => globalThis.history?.length || 0",
2719 isolated_context=False,
2720 )
2721 except Exception:
2722 history_length = 0
2723 return {
2724 "id": browser_page.id,
2725 "context_id": self._page_context_id(browser_page),
2726 "currentUrl": page.url,
2727 "title": title,
2728 "canGoBack": bool(history_length and int(history_length) > 1),
2729 "canGoForward": False,
2730 "loading": False,
2731 }
2732
2733 def _register_page_locked(
2734 self,
2735 page: Any,
2736 context_id: str | None = None,
2737 ) -> BrowserPage:
2738 requested_context_id = str(context_id or self.current_context_id)
2739 existing = self._browser_id_for_page(page)
2740 if existing is not None:
2741 browser_page = self.pages[existing]
2742 if (
2743 context_id is not None
2744 and self._page_context_id(browser_page) != requested_context_id
2745 ):
2746 previous_context_id = self._page_context_id(browser_page)
2747 browser_page.context_id = requested_context_id
2748 if self._last_interacted_browser_ids.get(previous_context_id) == existing:
2749 self._set_last_interacted(previous_context_id, None)
2750 return browser_page
2751 browser_id = self.next_browser_id
2752 self.next_browser_id += 1
2753 browser_page = BrowserPage(
2754 id=browser_id,
2755 page=page,
2756 context_id=requested_context_id,
2757 )
2758 self.pages[browser_id] = browser_page
2759
2760 def on_close() -> None:
2761 try:
2762 asyncio.create_task(self._unregister_page_async(browser_id))
2763 except RuntimeError:
2764 # No running loop (e.g., during shutdown). Best-effort sync pop.
2765 self.pages.pop(browser_id, None)
2766
2767 page.on("close", on_close)
2768
2769 def on_navigated(frame: Any) -> None:
2770 main_frame = getattr(page, "main_frame", None)
2771 if main_frame is not None and frame is not main_frame:
2772 return
2773 try:
2774 asyncio.create_task(self._persist_page_change_async(browser_id))
2775 except RuntimeError:
2776 return
2777
2778 page.on("framenavigated", on_navigated)
2779 return browser_page
2780
2781 async def _register_page(
2782 self,
2783 page: Any,
2784 context_id: str | None = None,
2785 ) -> BrowserPage:
2786 lock = self._ensure_registry_lock()
2787 async with lock:
2788 browser_page = self._register_page_locked(page, context_id)
2789 await self._fit_browser_window(page)
2790 return browser_page
2791
2792 async def _fit_browser_window(self, page: Any) -> None:
2793 if getattr(self.interactive_view, "display", None) is None or not self.context:
2794 return
2795 try:
2796 if self._browser_window_session is None or self._browser_window_page is not page:
2797 await self._reset_browser_window_session()
2798 self._browser_window_page = page
2799 self._browser_window_session = await self.context.new_cdp_session(page)
2800 target = await self._browser_window_session.send("Browser.getWindowForTarget")
2801 self._browser_window_id = target.get("windowId")
2802 if self._browser_window_id is None:
2803 await self._reset_browser_window_session()
2804 return
2805 current = await self._browser_window_session.send(
2806 "Browser.getWindowBounds",
2807 {"windowId": self._browser_window_id},
2808 )
2809 if current.get("bounds", {}).get("windowState") != "normal":
2810 await self._browser_window_session.send(
2811 "Browser.setWindowBounds",
2812 {
2813 "windowId": self._browser_window_id,
2814 "bounds": {"windowState": "normal"},
2815 },
2816 )
2817 if self._browser_chrome_height is None:
2818 chrome_height = await page.evaluate(
2819 "() => Math.max(0, globalThis.outerHeight - globalThis.innerHeight)",
2820 isolated_context=False,
2821 )
2822 self._browser_chrome_height = max(0, min(256, int(chrome_height or 0)))
2823 chrome_height = self._browser_chrome_height
2824 await self._browser_window_session.send(
2825 "Browser.setWindowBounds",
2826 {
2827 "windowId": self._browser_window_id,
2828 "bounds": {
2829 "windowState": "normal",
2830 "left": 0,
2831 "top": -chrome_height,
2832 "width": self.interactive_view.width,
2833 "height": self.interactive_view.height + chrome_height,
2834 },
2835 },
2836 )
2837 except Exception as exc:
2838 await self._reset_browser_window_session()
2839 PrintStyle.warning(f"Interactive Browser window fit failed: {exc}")
2840
2841 async def _reset_browser_window_session(self) -> None:
2842 session = self._browser_window_session
2843 self._browser_window_page = None
2844 self._browser_window_session = None
2845 self._browser_window_id = None
2846 if session:
2847 with contextlib.suppress(Exception):
2848 await session.detach()
2849
2850 async def _unregister_page_async(self, browser_id: int) -> None:
2851 try:
2852 lock = self._ensure_registry_lock()
2853 async with lock:
2854 browser_page = self.pages.pop(browser_id, None)
2855 if browser_page:
2856 context_id = self._page_context_id(browser_page)
2857 if self._last_interacted_browser_ids.get(context_id) == browser_id:
2858 remaining = self._context_browser_ids(context_id)
2859 self._set_last_interacted(
2860 context_id,
2861 next(iter(remaining), None),
2862 )
2863 self._background_popup_pages.discard(browser_id)
2864 except Exception as exc:
2865 PrintStyle.warning(f"Page unregister failed: {exc}")
2866
2867 async def _persist_page_change_async(self, browser_id: int) -> None:
2868 await asyncio.sleep(0)
2869 if self._closing or self._restoring_tabs or browser_id not in self.pages:
2870 return
2871 self._persist_browser_tabs()
2872
2873 def _on_new_page_sync(self, page: Any) -> None:
2874 if self._closing or self.context is None:
2875 return
2876 try:
2877 asyncio.create_task(self._on_new_page_async(page))
2878 except RuntimeError:
2879 return
2880
2881 async def _on_new_page_async(self, page: Any) -> None:
2882 try:
2883 with contextlib.suppress(Exception):
2884 await page.wait_for_load_state("domcontentloaded", timeout=2000)
2885 if self._closing or page.is_closed():
2886 return
2887 lock = self._ensure_registry_lock()
2888 close_over_limit = False
2889 context_id = await self._new_page_context_id(page)
2890 async with lock:
2891 if self._closing:
2892 return
2893 if self._browser_id_for_page(page) is not None:
2894 return
2895 if len(self._context_browser_ids(context_id)) >= self._max_open_tabs():
2896 limit_error = self._tab_limit_error(context_id)
2897 waiter = self._pop_pending_popup(context_id)
2898 if waiter:
2899 waiter.set_exception(limit_error)
2900 close_over_limit = True
2901 else:
2902 browser_page = self._register_page_locked(page, context_id)
2903 new_id = browser_page.id
2904 waiter = self._pop_pending_popup(context_id)
2905 if waiter:
2906 waiter.set_result(new_id)
2907 if new_id not in self._background_popup_pages:
2908 self._set_last_interacted(context_id, new_id)
2909 else:
2910 self._background_popup_pages.discard(new_id)
2911 if close_over_limit:
2912 with contextlib.suppress(Exception):
2913 await page.close()
2914 else:
2915 await self._fit_browser_window(page)
2916 self._persist_browser_tabs()
2917 except Exception as exc:
2918 PrintStyle.warning(f"Popup registration failed: {exc}")
2919
2920 async def _new_page_context_id(self, page: Any) -> str:
2921 opener_fn = getattr(page, "opener", None)
2922 if callable(opener_fn):
2923 with contextlib.suppress(Exception):
2924 opener = await opener_fn()
2925 opener_id = self._browser_id_for_page(opener)
2926 if opener_id is not None:
2927 return self._page_context_id(self.pages[opener_id])
2928 for waiter in self._pending_popups:
2929 context_id = self._pending_popup_contexts.get(waiter)
2930 if context_id and not waiter.done():
2931 return context_id
2932 return self.current_context_id
2933
2934 def _pop_pending_popup(self, context_id: str) -> asyncio.Future[int] | None:
2935 for waiter in list(self._pending_popups):
2936 if waiter.done():
2937 self._pending_popups.remove(waiter)
2938 self._pending_popup_contexts.pop(waiter, None)
2939 continue
2940 if self._pending_popup_contexts.get(waiter) != context_id:
2941 continue
2942 self._pending_popups.remove(waiter)
2943 self._pending_popup_contexts.pop(waiter, None)
2944 return waiter
2945 return None
2946
2947 def _browser_id_for_page(self, page: Any) -> int | None:
2948 for browser_id, browser_page in self.pages.items():
2949 if browser_page.page == page:
2950 return browser_id
2951 return None
2952
2953 def _resolve_browser_id(self, browser_id: int | str | None = None) -> int:
2954 browser_ids = self._context_browser_ids()
2955 if browser_id is None or str(browser_id).strip() == "":
2956 if self.last_interacted_browser_id in browser_ids:
2957 return int(self.last_interacted_browser_id)
2958 if browser_ids:
2959 return browser_ids[0]
2960 raise KeyError("No browser is open. Use action=open first.")
2961 value = str(browser_id).strip()
2962 if value.startswith("browser-"):
2963 value = value.split("-", 1)[1]
2964 resolved = int(value)
2965 if resolved not in browser_ids:
2966 raise KeyError(f"Browser {resolved} is not open.")
2967 return resolved
2968
2969 def _page(self, browser_id: int) -> Any:
2970 return self.pages[int(browser_id)].page
2971
2972 async def _stop_screencasts_for_browser(self, browser_id: int) -> None:
2973 stream_ids = [
2974 stream_id
2975 for stream_id, screencast in self.screencasts.items()
2976 if screencast.browser_id == int(browser_id)
2977 ]
2978 for stream_id in stream_ids:
2979 await self.stop_screencast(stream_id)
2980
2981 async def _stop_all_screencasts(self) -> None:
2982 for stream_id in list(self.screencasts):
2983 await self.stop_screencast(stream_id)
2984
2985 async def _ensure_content_helper(self, page: Any) -> None:
2986 await self._ensure_dom_helper(page)
2987 has_helper = await page.evaluate(
2988 "() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())",
2989 isolated_context=True,
2990 )
2991 if has_helper:
2992 return
2993 if self._content_helper_source is None:
2994 self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
2995 await page.evaluate(self._content_helper_source, isolated_context=True)
2996
2997 async def _ensure_dom_helper(self, page: Any) -> None:
2998 if self._dom_helper_source is None:
2999 self._dom_helper_source = DOM_HELPER_PATH.read_text(encoding="utf-8")
3000 await self._ensure_helper_source(
3001 page,
3002 self._dom_helper_source,
3003 "() => Boolean(globalThis.__spaceBrowserDomHelper__?.captureDocument)",
3004 )
3005
3006 async def _ensure_helper_source(self, page: Any, source: str, ready_script: str) -> None:
3007 targets = [page]
3008 frames = getattr(page, "frames", None)
3009 if isinstance(frames, list) and frames:
3010 targets = frames
3011 for target in targets:
3012 try:
3013 has_helper = await target.evaluate(ready_script, isolated_context=True)
3014 except Exception:
3015 continue
3016 if has_helper:
3017 continue
3018 with contextlib.suppress(Exception):
3019 await target.evaluate(source, isolated_context=True)
3020
3021 _runtimes: dict[str, BrowserRuntimeSession] = {}
3022 _shared_runtime: BrowserRuntime | None = None
3023 _runtime_lock = threading.RLock()
3024
3025
3026 async def get_runtime(
3027 context_id: str,
3028 *,
3029 create: bool = True,
3030 ) -> BrowserRuntimeSession | None:
3031 global _shared_runtime
3032 context_id = str(context_id or "").strip()
3033 if not context_id:
3034 raise ValueError("context_id is required")
3035 with _runtime_lock:
3036 runtime = _runtimes.get(context_id)
3037 if runtime is None and _shared_runtime is not None:
3038 runtime = BrowserRuntimeSession(context_id, _shared_runtime)
3039 if create:
3040 _runtimes[context_id] = runtime
3041 elif runtime is None and create:
3042 if _shared_runtime is None:
3043 _shared_runtime = BrowserRuntime(SHARED_RUNTIME_ID)
3044 runtime = BrowserRuntimeSession(context_id, _shared_runtime)
3045 _runtimes[context_id] = runtime
3046 return runtime
3047
3048
3049 async def close_runtime(context_id: str, *, delete_profile: bool = True) -> None:
3050 context_id = str(context_id or "").strip()
3051 if not context_id:
3052 return
3053 _forget_browser_context(context_id)
3054 with _runtime_lock:
3055 runtime = _runtimes.pop(context_id, None)
3056 shared_runtime = _shared_runtime
3057 if runtime:
3058 await runtime.call("close_context")
3059 elif shared_runtime:
3060 await shared_runtime.call_for(context_id, "close_context")
3061
3062
3063 def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None:
3064 task = DeferredTask(thread_name="BrowserCleanup")
3065 task.start_task(close_runtime, context_id, delete_profile=delete_profile)
3066 try:
3067 task.result_sync(timeout=30)
3068 finally:
3069 task.kill(terminate_thread=True)
3070
3071
3072 async def close_all_runtimes(*, delete_profiles: bool = False) -> None:
3073 global _shared_runtime
3074 with _runtime_lock:
3075 _runtimes.clear()
3076 runtime = _shared_runtime
3077 _shared_runtime = None
3078 if delete_profiles:
3079 with contextlib.suppress(Exception):
3080 kvp.remove_persistent(BROWSER_TABS_KEY)
3081 if runtime:
3082 try:
3083 await runtime.close(delete_profile=delete_profiles)
3084 except Exception as exc:
3085 PrintStyle.warning(f"Browser runtime cleanup failed: {exc}")
3086
3087
3088 def close_all_runtimes_sync() -> None:
3089 task = DeferredTask(thread_name="BrowserCleanupAll")
3090 task.start_task(close_all_runtimes, delete_profiles=False)
3091 try:
3092 task.result_sync(timeout=30)
3093 finally:
3094 task.kill(terminate_thread=True)
3095
3096
3097 def known_context_ids() -> list[str]:
3098 with _runtime_lock:
3099 return sorted(_runtimes)
3100
3101
3102 async def list_runtime_sessions() -> list[dict[str, Any]]:
3103 with _runtime_lock:
3104 runtimes = list(_runtimes.items())
3105 shared_runtime = _shared_runtime
3106
3107 if shared_runtime and str(
3108 get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
3109 or DEFAULT_BROWSER_TAB_SCOPE
3110 ) == "shared":
3111 request_context_id = runtimes[0][0] if runtimes else SHARED_RUNTIME_ID
3112 try:
3113 listing = await shared_runtime.call_for(request_context_id, "list_all")
3114 except Exception as exc:
3115 PrintStyle.warning(f"Shared Browser runtime list failed: {exc}")
3116 return []
3117 grouped: dict[str, list[dict[str, Any]]] = {}
3118 for browser in listing.get("browsers") or []:
3119 context_id = str(browser.get("context_id") or SHARED_RUNTIME_ID)
3120 grouped.setdefault(context_id, []).append(browser)
3121 active_ids = listing.get("last_interacted_browser_ids") or {}
3122 return [
3123 {
3124 "context_id": context_id,
3125 "browsers": browsers,
3126 "last_interacted_browser_id": active_ids.get(context_id),
3127 }
3128 for context_id, browsers in grouped.items()
3129 ]
3130
3131 sessions: list[dict[str, Any]] = []
3132 for context_id, runtime in runtimes:
3133 try:
3134 listing = await runtime.call("list")
3135 except Exception as exc:
3136 PrintStyle.warning(f"Browser runtime list failed for context {context_id}: {exc}")
3137 continue
3138 sessions.append(
3139 {
3140 "context_id": context_id,
3141 "browsers": listing.get("browsers") or [],
3142 "last_interacted_browser_id": listing.get("last_interacted_browser_id"),
3143 }
3144 )
3145 return sessions
3146
3147
3148 atexit.register(close_all_runtimes_sync)