337
338
339
class _BrowserRuntimeCore:
340
+ _VALID_MODIFIERS = {"Control", "Shift", "Alt", "Meta"}
341
+ _POPUP_WAIT_SECONDS = 2.0
342
+
343
def __init__(self, context_id: str):
344
self.context_id = context_id
345
self.safe_context_id = _safe_context_id(context_id)
351
self.last_interacted_browser_id: int | None = None
352
self._content_helper_source: str | None = None
353
self._start_lock: asyncio.Lock | None = None
354
+ self._registry_lock: asyncio.Lock | None = None
355
+ self._closing = False
356
+ self._pending_popups: list[asyncio.Future[int]] = []
357
+ self._background_popup_pages: set[int] = set()
358
+
359
+ def _ensure_registry_lock(self) -> asyncio.Lock:
360
+ if self._registry_lock is None:
361
+ self._registry_lock = asyncio.Lock()
362
+ return self._registry_lock
363
+
364
+ def _maybe_promote(self, resolved_id: int) -> None:
365
+ # Promote only if the target IS the current active tab or no tab is
366
+ # active yet. Cross-tab work on a backgrounded tab does not steal
367
+ # viewer focus.
368
+ current = self.last_interacted_browser_id
369
+ if current is None or current == resolved_id:
370
+ self.last_interacted_browser_id = int(resolved_id)
371
372
@property
373
def profile_dir(self) -> Path:
431
raise
432
self.context.set_default_timeout(30000)
433
self.context.set_default_navigation_timeout(30000)
434
+ self.context.on("page", self._on_new_page_sync)
435
await self.context.add_init_script(self._shadow_dom_script())
436
await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
437
442
except Exception:
443
pass
444
continue
424
- self._register_page(page)
445
+ await self._register_page(page)
446
447
def _release_orphaned_profile_singleton(self) -> None:
448
lock_path = self.profile_dir / "SingletonLock"
507
async def open(self, url: str = "") -> dict[str, Any]:
508
await self.ensure_started()
509
page = await self.context.new_page()
489
- browser_page = self._register_page(page)
510
+ browser_page = await self._register_page(page)
511
self.last_interacted_browser_id = browser_page.id
512
target_url = self._initial_url(url)
513
if target_url and target_url != "about:blank":
522
return raw_url
523
return str(get_browser_config().get(DEFAULT_HOMEPAGE_KEY) or "about:blank").strip() or "about:blank"
524
504
- async def list(self) -> dict[str, Any]:
525
+ async def list(self, include_content: bool = False) -> dict[str, Any]:
526
await self.ensure_started()
527
+ ids = sorted(self.pages)
528
+ if not ids:
529
+ return {
530
+ "browsers": [],
531
+ "last_interacted_browser_id": self.last_interacted_browser_id,
532
+ }
533
+ states_task = asyncio.gather(*(self._state(bid) for bid in ids))
534
+ if include_content:
535
+ contents_task = asyncio.gather(
536
+ *(self.content(bid) for bid in ids),
537
+ return_exceptions=True,
538
+ )
539
+ states, contents = await asyncio.gather(states_task, contents_task)
540
+ out: list[dict[str, Any]] = []
541
+ for idx, bid in enumerate(ids):
542
+ entry = states[idx]
543
+ c = contents[idx]
544
+ if isinstance(c, Exception):
545
+ entry["content_error"] = str(c)
546
+ else:
547
+ entry["content"] = c
548
+ out.append(entry)
549
+ else:
550
+ out = await states_task
551
return {
507
- "browsers": [await self._state(browser_id) for browser_id in sorted(self.pages)],
552
+ "browsers": out,
553
"last_interacted_browser_id": self.last_interacted_browser_id,
554
}
555
556
+ async def multi(self, calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
557
+ if not isinstance(calls, list) or not calls:
558
+ raise ValueError("multi requires a non-empty list of calls")
559
+ groups: dict[Any, list[tuple[int, dict[str, Any]]]] = {}
560
+ for idx, call in enumerate(calls):
561
+ if not isinstance(call, dict):
562
+ raise ValueError(f"calls[{idx}] is not an object")
563
+ key = call.get("browser_id")
564
+ groups.setdefault(key, []).append((idx, call))
565
+
566
+ results: list[dict[str, Any] | None] = [None] * len(calls)
567
+
568
+ async def run_group(group: list[tuple[int, dict[str, Any]]]) -> None:
569
+ for idx, call in group:
570
+ try:
571
+ out = await self._dispatch_call(call)
572
+ results[idx] = {"ok": True, "result": out}
573
+ except Exception as exc:
574
+ results[idx] = {"ok": False, "error": str(exc)}
575
+
576
+ await asyncio.gather(*(run_group(g) for g in groups.values()))
577
+ return [r if r is not None else {"ok": False, "error": "missing"} for r in results]
578
+
579
+ async def _dispatch_call(self, call: dict[str, Any]) -> Any:
580
+ action = str(call.get("action") or "").strip().lower().replace("-", "_")
581
+ bid = call.get("browser_id")
582
+ if action == "open":
583
+ return await self.open(call.get("url") or "")
584
+ if action == "list":
585
+ return await self.list(include_content=bool(call.get("include_content")))
586
+ if action == "state":
587
+ return await self.state(bid)
588
+ if action in {"set_active", "setactive", "activate", "focus"}:
589
+ return await self.set_active(bid)
590
+ if action == "navigate":
591
+ return await self.navigate(bid, call.get("url") or "")
592
+ if action == "back":
593
+ return await self.back(bid)
594
+ if action == "forward":
595
+ return await self.forward(bid)
596
+ if action == "reload":
597
+ return await self.reload(bid)
598
+ if action == "content":
599
+ payload = None
600
+ sels = call.get("selectors")
601
+ sel = call.get("selector")
602
+ if sels:
603
+ payload = {"selectors": sels}
604
+ elif sel:
605
+ payload = {"selector": sel}
606
+ return await self.content(bid, payload)
607
+ if action == "detail":
608
+ ref = call.get("ref")
609
+ if ref is None:
610
+ raise ValueError("detail requires ref")
611
+ return await self.detail(bid, ref)
612
+ if action == "click":
613
+ ref = call.get("ref")
614
+ if ref is None:
615
+ raise ValueError("click requires ref")
616
+ return await self.click(
617
+ bid, ref,
618
+ modifiers=call.get("modifiers"),
619
+ focus_popup=call.get("focus_popup"),
620
+ )
621
+ if action == "type":
622
+ ref = call.get("ref")
623
+ if ref is None:
624
+ raise ValueError("type requires ref")
625
+ return await self.type(bid, ref, call.get("text") or "")
626
+ if action == "submit":
627
+ ref = call.get("ref")
628
+ if ref is None:
629
+ raise ValueError("submit requires ref")
630
+ return await self.submit(bid, ref)
631
+ if action in {"type_submit", "typesubmit"}:
632
+ ref = call.get("ref")
633
+ if ref is None:
634
+ raise ValueError("type_submit requires ref")
635
+ return await self.type_submit(bid, ref, call.get("text") or "")
636
+ if action == "scroll":
637
+ ref = call.get("ref")
638
+ if ref is None:
639
+ raise ValueError("scroll requires ref")
640
+ return await self.scroll(bid, ref)
641
+ if action == "evaluate":
642
+ return await self.evaluate(bid, call.get("script") or "")
643
+ if action in {"key_chord", "keychord"}:
644
+ keys = call.get("keys") or []
645
+ if not keys:
646
+ raise ValueError("key_chord requires non-empty keys")
647
+ return await self.key_chord(bid, list(keys))
648
+ if action == "mouse":
649
+ return await self.mouse(
650
+ bid, call.get("event_type") or "click",
651
+ float(call.get("x") or 0), float(call.get("y") or 0),
652
+ button=call.get("button") or "left",
653
+ modifiers=call.get("modifiers"),
654
+ )
655
+ if action == "close":
656
+ return await self.close_browser(bid)
657
+ if action == "close_all":
658
+ return await self.close_all_browsers()
659
+ raise ValueError(f"unknown action: {action}")
660
+
661
+ async def set_active(self, browser_id: int | str | None) -> dict[str, Any]:
662
+ await self.ensure_started()
663
+ resolved_id = self._resolve_browser_id(browser_id)
664
+ # Explicit focus change — bypass _maybe_promote.
665
+ self.last_interacted_browser_id = int(resolved_id)
666
+ return await self._state(resolved_id)
667
+
668
async def state(self, browser_id: int | str | None = None) -> dict[str, Any]:
669
await self.ensure_started()
670
return await self._state(self._resolve_browser_id(browser_id))
674
resolved_id = self._resolve_browser_id(browser_id)
675
page = self._page(resolved_id)
676
await self._goto(page, normalize_url(url))
520
- self.last_interacted_browser_id = resolved_id
677
+ self._maybe_promote(resolved_id)
678
return await self._state(resolved_id)
679
680
async def back(self, browser_id: int | str | None = None) -> dict[str, Any]:
683
page = self._page(resolved_id)
684
await page.go_back(wait_until="domcontentloaded", timeout=10000)
685
await self._settle(page)
529
- self.last_interacted_browser_id = resolved_id
686
+ self._maybe_promote(resolved_id)
687
return await self._state(resolved_id)
688
689
async def forward(self, browser_id: int | str | None = None) -> dict[str, Any]:
692
page = self._page(resolved_id)
693
await page.go_forward(wait_until="domcontentloaded", timeout=10000)
694
await self._settle(page)
538
- self.last_interacted_browser_id = resolved_id
695
+ self._maybe_promote(resolved_id)
696
return await self._state(resolved_id)
697
698
async def reload(self, browser_id: int | str | None = None) -> dict[str, Any]:
701
page = self._page(resolved_id)
702
await page.reload(wait_until="domcontentloaded", timeout=15000)
703
await self._settle(page)
547
- self.last_interacted_browser_id = resolved_id
704
+ self._maybe_promote(resolved_id)
705
return await self._state(resolved_id)
706
707
async def content(
717
"(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
718
payload or None,
719
)
563
- self.last_interacted_browser_id = resolved_id
720
+ self._maybe_promote(resolved_id)
721
return result or {}
722
723
async def detail(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
729
"(ref) => globalThis.__spaceBrowserPageContent__.detail(ref)",
730
reference_id,
731
)
575
- self.last_interacted_browser_id = resolved_id
732
+ self._maybe_promote(resolved_id)
733
return result or {}
734
735
async def annotation_target(
745
"(payload) => globalThis.__spaceBrowserPageContent__.annotate(payload || null)",
746
payload or None,
747
)
591
- self.last_interacted_browser_id = resolved_id
748
+ self._maybe_promote(resolved_id)
749
return result or {}
750
751
async def evaluate(self, browser_id: int | str | None, script: str) -> dict[str, Any]:
753
resolved_id = self._resolve_browser_id(browser_id)
754
page = self._page(resolved_id)
755
result = await page.evaluate(str(script or "undefined"))
599
- self.last_interacted_browser_id = resolved_id
756
+ self._maybe_promote(resolved_id)
757
return {"result": result, "state": await self._state(resolved_id)}
758
602
- async def click(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
759
+ async def click(
760
+ self,
761
+ browser_id: int | str | None,
762
+ reference_id: int | str,
763
+ modifiers: list[str] | None = None,
764
+ focus_popup: bool | None = None,
765
+ ) -> dict[str, Any]:
766
+ if modifiers:
767
+ return await self._modifier_click(browser_id, reference_id, modifiers, focus_popup)
768
return await self._reference_action("click", browser_id, reference_id)
769
770
+ async def _modifier_click(
771
+ self,
772
+ browser_id: int | str | None,
773
+ reference_id: int | str,
774
+ modifiers: list[str],
775
+ focus_popup: bool | None,
776
+ ) -> dict[str, Any]:
777
+ bad = set(modifiers) - self._VALID_MODIFIERS
778
+ if bad:
779
+ raise ValueError(
780
+ f"unsupported modifiers: {sorted(bad)}; allowed: {sorted(self._VALID_MODIFIERS)}"
781
+ )
782
+ await self.ensure_started()
783
+ resolved_id = self._resolve_browser_id(browser_id)
784
+ page = self._page(resolved_id)
785
+ await self._ensure_content_helper(page)
786
+
787
+ box = await page.evaluate(
788
+ "(ref) => globalThis.__spaceBrowserPageContent__.boundingBoxFor(ref)",
789
+ reference_id,
790
+ )
791
+
792
+ background = focus_popup is False or (
793
+ focus_popup is None and bool({"Control", "Meta"} & set(modifiers))
794
+ )
795
+
796
+ loop = asyncio.get_running_loop()
797
+ waiter: asyncio.Future[int] = loop.create_future()
798
+ self._pending_popups.append(waiter)
799
+
800
+ warning: str | None = None
801
+ opened_id: int | None = None
802
+ try:
803
+ box_has_geometry = bool(box and box.get("width") and box.get("height"))
804
+ box_selector = box.get("selector") if box else None
805
+ if box_has_geometry:
806
+ cx = box["x"] + box["width"] / 2
807
+ cy = box["y"] + box["height"] / 2
808
+ # Mouse.click does not accept modifiers; hold them via keyboard.
809
+ pressed: list[str] = []
810
+ try:
811
+ for mod in modifiers:
812
+ await page.keyboard.down(mod)
813
+ pressed.append(mod)
814
+ await page.mouse.click(cx, cy)
815
+ finally:
816
+ for mod in reversed(pressed):
817
+ with contextlib.suppress(Exception):
818
+ await page.keyboard.up(mod)
819
+ await self._settle(page, short=False)
820
+ elif box_selector:
821
+ try:
822
+ await page.locator(box_selector).click(
823
+ modifiers=list(modifiers), force=True, timeout=5000
824
+ )
825
+ await self._settle(page, short=False)
826
+ except Exception as exc:
827
+ await self._reference_action("click", browser_id, reference_id)
828
+ warning = f"modifiers ignored: locator click failed ({exc})"
829
+ else:
830
+ await self._reference_action("click", browser_id, reference_id)
831
+ warning = "modifiers ignored: target geometry unavailable"
832
+
833
+ try:
834
+ opened_id = await asyncio.wait_for(
835
+ asyncio.shield(waiter), timeout=self._POPUP_WAIT_SECONDS
836
+ )
837
+ except asyncio.TimeoutError:
838
+ opened_id = None
839
+ finally:
840
+ if waiter in self._pending_popups:
841
+ self._pending_popups.remove(waiter)
842
+ if not waiter.done():
843
+ waiter.cancel()
844
+
845
+ if opened_id is not None and background:
846
+ self._background_popup_pages.add(opened_id)
847
+ if self.last_interacted_browser_id == opened_id:
848
+ # Force focus back to origin tab — popup hook had promoted.
849
+ self.last_interacted_browser_id = int(resolved_id)
850
+ finally:
851
+ if waiter in self._pending_popups:
852
+ self._pending_popups.remove(waiter)
853
+
854
+ if background:
855
+ # Background-mode click: explicitly keep focus on origin.
856
+ self.last_interacted_browser_id = int(resolved_id)
857
+ return {
858
+ "action": {
859
+ "ref": reference_id,
860
+ "modifiers": list(modifiers),
861
+ "opened_browser_ids": [opened_id] if opened_id is not None else [],
862
+ **({"warning": warning} if warning else {}),
863
+ },
864
+ "state": await self._state(resolved_id),
865
+ }
866
+
867
+ async def key_chord(
868
+ self,
869
+ browser_id: int | str | None,
870
+ keys: list[str],
871
+ ) -> dict[str, Any]:
872
+ if not keys:
873
+ raise ValueError("key_chord requires at least one key")
874
+ await self.ensure_started()
875
+ resolved_id = self._resolve_browser_id(browser_id)
876
+ page = self._page(resolved_id)
877
+ pressed: list[str] = []
878
+ try:
879
+ for k in keys:
880
+ await page.keyboard.down(k)
881
+ pressed.append(k)
882
+ finally:
883
+ for k in reversed(pressed):
884
+ with contextlib.suppress(Exception):
885
+ await page.keyboard.up(k)
886
+ await self._settle(page, short=True)
887
+ self._maybe_promote(resolved_id)
888
+ return await self._state(resolved_id)
889
+
890
async def submit(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
891
return await self._reference_action("submit", browser_id, reference_id)
892
978
self.screencasts.pop(stream_id, None)
979
await screencast.stop()
980
raise
696
- self.last_interacted_browser_id = resolved_id
981
+ self._maybe_promote(resolved_id)
982
return {
983
"stream_id": stream_id,
984
"browser_id": resolved_id,
1037
await self._remount_viewport(page, viewport)
1038
if should_remount_viewport:
1039
await self._settle(page, short=True)
755
- self.last_interacted_browser_id = resolved_id
1040
+ self._maybe_promote(resolved_id)
1041
return {"state": await self._state(resolved_id), "viewport": viewport}
1042
1043
async def _apply_viewport_with_remount(self, page: Any, viewport: dict[str, int]) -> None:
1062
x: float,
1063
y: float,
1064
button: str = "left",
1065
+ modifiers: list[str] | None = None,
1066
) -> dict[str, Any]:
1067
+ event_type_lower = str(event_type or "click").lower()
1068
+ if modifiers:
1069
+ if event_type_lower != "click":
1070
+ raise ValueError("modifiers are only valid for event_type='click'")
1071
+ bad = set(modifiers) - self._VALID_MODIFIERS
1072
+ if bad:
1073
+ raise ValueError(
1074
+ f"unsupported modifiers: {sorted(bad)}; allowed: {sorted(self._VALID_MODIFIERS)}"
1075
+ )
1076
await self.ensure_started()
1077
resolved_id = self._resolve_browser_id(browser_id)
1078
page = self._page(resolved_id)
784
- event_type = str(event_type or "click").lower()
785
- if event_type == "move":
1079
+ if event_type_lower == "move":
1080
await page.mouse.move(float(x), float(y))
787
- elif event_type == "down":
1081
+ elif event_type_lower == "down":
1082
await page.mouse.down(button=button)
789
- elif event_type == "up":
1083
+ elif event_type_lower == "up":
1084
await page.mouse.up(button=button)
1085
else:
792
- await page.mouse.click(float(x), float(y), button=button)
1086
+ pressed: list[str] = []
1087
+ try:
1088
+ if modifiers:
1089
+ for mod in modifiers:
1090
+ await page.keyboard.down(mod)
1091
+ pressed.append(mod)
1092
+ await page.mouse.click(float(x), float(y), button=button)
1093
+ finally:
1094
+ for mod in reversed(pressed):
1095
+ with contextlib.suppress(Exception):
1096
+ await page.keyboard.up(mod)
1097
await self._settle(page, short=True)
794
- self.last_interacted_browser_id = resolved_id
1098
+ self._maybe_promote(resolved_id)
1099
return await self._state(resolved_id)
1100
1101
async def wheel(
1111
page = self._page(resolved_id)
1112
await page.mouse.move(float(x), float(y))
1113
await page.mouse.wheel(float(delta_x), float(delta_y))
810
- self.last_interacted_browser_id = resolved_id
1114
+ self._maybe_promote(resolved_id)
1115
return await self._state(resolved_id)
1116
1117
async def keyboard(
1129
elif key:
1130
await page.keyboard.press(str(key))
1131
await self._settle(page, short=True)
828
- self.last_interacted_browser_id = resolved_id
1132
+ self._maybe_promote(resolved_id)
1133
return await self._state(resolved_id)
1134
1135
async def close(self, delete_profile: bool = False) -> None:
1136
+ self._closing = True
1137
+ for waiter in self._pending_popups:
1138
+ if not waiter.done():
1139
+ waiter.set_exception(RuntimeError("Browser runtime is closing."))
1140
+ self._pending_popups.clear()
1141
+ self._background_popup_pages.clear()
1142
await self._stop_all_screencasts()
1143
for browser_id in list(self.pages):
1144
try:
1183
{"method": helper_method, "ref": reference_id, "text": text},
1184
)
1185
await self._settle(page, short=False)
876
- self.last_interacted_browser_id = resolved_id
1186
+ self._maybe_promote(resolved_id)
1187
return {"action": action or {}, "state": await self._state(resolved_id)}
1188
1189
async def _goto(self, page: Any, url: str) -> None:
1234
"loading": False,
1235
}
1236
927
- def _register_page(self, page: Any) -> BrowserPage:
1237
+ def _register_page_locked(self, page: Any) -> BrowserPage:
1238
existing = self._browser_id_for_page(page)
1239
if existing is not None:
1240
return self.pages[existing]
1244
self.pages[browser_id] = browser_page
1245
1246
def on_close() -> None:
937
- self.pages.pop(browser_id, None)
1247
+ try:
1248
+ asyncio.create_task(self._unregister_page_async(browser_id))
1249
+ except RuntimeError:
1250
+ # No running loop (e.g., during shutdown). Best-effort sync pop.
1251
+ self.pages.pop(browser_id, None)
1252
1253
page.on("close", on_close)
1254
return browser_page
1255
1256
+ async def _register_page(self, page: Any) -> BrowserPage:
1257
+ lock = self._ensure_registry_lock()
1258
+ async with lock:
1259
+ return self._register_page_locked(page)
1260
+
1261
+ async def _unregister_page_async(self, browser_id: int) -> None:
1262
+ try:
1263
+ lock = self._ensure_registry_lock()
1264
+ async with lock:
1265
+ self.pages.pop(browser_id, None)
1266
+ if self.last_interacted_browser_id == browser_id:
1267
+ self.last_interacted_browser_id = next(iter(sorted(self.pages)), None)
1268
+ self._background_popup_pages.discard(browser_id)
1269
+ except Exception as exc:
1270
+ PrintStyle.warning(f"Page unregister failed: {exc}")
1271
+
1272
+ def _on_new_page_sync(self, page: Any) -> None:
1273
+ if self._closing or self.context is None:
1274
+ return
1275
+ try:
1276
+ asyncio.create_task(self._on_new_page_async(page))
1277
+ except RuntimeError:
1278
+ return
1279
+
1280
+ async def _on_new_page_async(self, page: Any) -> None:
1281
+ try:
1282
+ with contextlib.suppress(Exception):
1283
+ await page.wait_for_load_state("domcontentloaded", timeout=2000)
1284
+ if self._closing or page.is_closed():
1285
+ return
1286
+ lock = self._ensure_registry_lock()
1287
+ async with lock:
1288
+ if self._closing:
1289
+ return
1290
+ if self._browser_id_for_page(page) is not None:
1291
+ return
1292
+ browser_page = self._register_page_locked(page)
1293
+ new_id = browser_page.id
1294
+ while self._pending_popups:
1295
+ waiter = self._pending_popups.pop(0)
1296
+ if not waiter.done():
1297
+ waiter.set_result(new_id)
1298
+ break
1299
+ if new_id not in self._background_popup_pages:
1300
+ self.last_interacted_browser_id = new_id
1301
+ else:
1302
+ self._background_popup_pages.discard(new_id)
1303
+ except Exception as exc:
1304
+ PrintStyle.warning(f"Popup registration failed: {exc}")
1305
+
1306
def _browser_id_for_page(self, page: Any) -> int | None:
1307
for browser_id, browser_page in self.pages.items():
1308
if browser_page.page == page: