Add interactive internal Browser viewport

Render the existing Patchright page through an isolated, authenticated Xpra session with CDP screencast and snapshot fallbacks. Keep Chromium out of fullscreen, synchronize native viewport resizing across canvas and modal handoffs, and remove Xpra decoration and shadow-cursor artifacts. Move loading feedback into each Browser tab while reserving the footer for actual errors.

Alessandro committed Aug 16, 2026 at 12:58 UTC 005b366b5147f35c5fe66c44c864f74e1ec4633d
12 files changed +1163 -211
helpers/virtual_desktop.py
+33 -22
@@ -115,29 +115,38 @@ def get_registry() -> VirtualDesktopRegistry:
115 return _registry
116
117
118 -def session_url(token: str, *, title: str = "Desktop") -> str:
118 +def session_url(
119 + token: str,
120 + *,
121 + title: str = "Desktop",
122 + encoding: str = "jpeg",
123 + quality: int = 85,
124 + speed: int = 80,
125 + file_transfer: bool = True,
126 + printing: bool = True,
127 +) -> str:
128 quoted_token = quote(str(token), safe="")
129 base_path = f"{SESSION_PATH}/{quoted_token}/"
121 - query = urlencode(
122 - {
123 - "path": base_path,
124 - "title": title,
125 - "encoding": "jpeg",
126 - "quality": "85",
127 - "speed": "80",
128 - "sharing": "true",
129 - "clipboard": "true",
130 - "clipboard_direction": "both",
131 - "clipboard_poll": "true",
132 - "clipboard_preferred_format": "text/plain",
133 - "printing": "true",
134 - "file_transfer": "true",
135 - "sound": "false",
136 - "offscreen": "true",
137 - "floating_menu": "false",
138 - "xpramenu": "false",
139 - },
140 - )
130 + options = {
131 + "path": base_path,
132 + "title": title,
133 + "quality": str(max(0, min(100, int(quality)))),
134 + "speed": str(max(0, min(100, int(speed)))),
135 + "sharing": "true",
136 + "clipboard": "true",
137 + "clipboard_direction": "both",
138 + "clipboard_poll": "true",
139 + "clipboard_preferred_format": "text/plain",
140 + "printing": str(bool(printing)).lower(),
141 + "file_transfer": str(bool(file_transfer)).lower(),
142 + "sound": "false",
143 + "offscreen": "true",
144 + "floating_menu": "false",
145 + "xpramenu": "false",
146 + }
147 + if encoding:
148 + options["encoding"] = str(encoding)
149 + query = urlencode(options)
150 return f"{base_path}index.html?{query}"
151
152
@@ -264,6 +273,7 @@ def resize_display(
273 keys: tuple[str, ...] = (),
274 xauthority: str = "",
275 home: str = "",
276 + settle_seconds: float = 0.15,
277 ) -> dict[str, Any]:
278 target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height)
279 xrandr = shutil.which("xrandr")
@@ -296,7 +306,8 @@ def resize_display(
306 timeout=4,
307 env=env,
308 )
299 - time.sleep(0.15)
309 + if settle_seconds > 0:
310 + time.sleep(settle_seconds)
311 current = current_display_size(display, xauthority=xauthority, home=home)
312 ok = current == (target_width, target_height)
313 if ok:
helpers/virtual_desktop.py.dox.md
+4 -2
@@ -23,13 +23,13 @@
23 - `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None`
24 - `resize_session(token: str, width: int, height: int) -> dict[str, Any]`
25 - `get_registry() -> VirtualDesktopRegistry`
26 -- `session_url(token: str, title: str=...) -> str`
26 +- `session_url(token: str, title: str=..., encoding: str=..., quality: int=..., speed: int=..., file_transfer: bool=..., printing: bool=...) -> str`
27 - `collect_status() -> dict[str, Any]`
28 - `find_xpra_html_root() -> Path | None`
29 - `_package_installed(package: str) -> bool`
30 - `normalize_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=...) -> tuple[int, int]`
31 - `normalize_desktop_display_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=..., min_aspect_ratio: float=...) -> tuple[int, int]`
32 -- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=...) -> dict[str, Any]`
32 +- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=..., settle_seconds: float=...) -> dict[str, Any]`
33 - `_ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None`
34 - `_select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]`
35 - `_xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]`
@@ -54,6 +54,8 @@
54
55 - Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `get_registry.register`, `get_registry.unregister`, `get_registry.proxy_for_token`, `get_registry.resize`, `quote`, `urlencode`, `find_xpra_html_root`, `subprocess.run`, `normalize_size`, `shutil.which`, `_display_env`, `current_display_size`, `_ensure_xrandr_mode`, `_select_xrandr_mode`, `time.sleep`, `strip`, `_xrandr_output_modes`, `result.stdout.splitlines`.
56 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
57 +- Session URLs keep Desktop's JPEG, printing, and file-transfer defaults while allowing restricted viewers such as Browser to negotiate encoding and disable unrelated capabilities.
58 +- Display resizing keeps the Desktop settle delay by default; latency-sensitive callers may skip it when they immediately verify the XRandR size.
59
60 ## Work Guidance
61
plugins/_browser/AGENTS.md
+13 -5
@@ -2,23 +2,30 @@
2
3 ## Purpose
4
5 -- Own the built-in Playwright browser tool and WebUI browser viewer.
5 +- Own the built-in Patchright browser tool and WebUI browser viewer.
6 - Bridge browser automation, page inspection helpers, and browser panel UI.
7
8 ## Ownership
9
10 - `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults.
11 - `tools/browser.py` owns the agent-facing browser tool.
12 -- `helpers/` owns Playwright runtime, selectors, URL helpers, extension management, and connector runtime logic.
12 +- `helpers/` owns the Patchright runtime, private interactive display, selectors, URL helpers, extension management, and connector runtime logic.
13 - `api/` owns status, extension, and browser WebSocket handlers.
14 - `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI.
15
16 ## Local Contracts
17
18 - Keep browser actions safe around external pages, credentials, and user data.
19 -- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
19 +- Preserve Patchright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
20 - Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
21 -- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
21 +- Default the visible WebUI Browser to the authenticated Xpra HTML5 viewer for its existing Patchright page. Keep live CDP screencast and lightweight snapshots as automatic fallbacks.
22 +- Keep headful Chromium in a normal window with its own toolbar clipped above the private display; do not use browser fullscreen, which shows Chromium's exit warning.
23 +- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops.
24 +- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
25 +- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
26 +- Present the Xpra shadow window as the raw browser canvas: remove its HTML decoration and shadow pointer while preserving exact viewport geometry.
27 +- Give every internal Browser runtime its own Xvfb display and unguessable Xpra gateway token; never expose another chat context's display through a shared viewer.
28 +- Bind Browser Xpra endpoints to loopback, route them through the authenticated virtual-desktop gateway, and keep file transfer, URL opening, printing, and audio disabled.
29 - Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `<img>`/data URL path for snapshots and fallback rendering.
30 - Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
31 - Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
@@ -35,6 +42,7 @@
42 - Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
43 - Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
44 - `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path.
45 +- Browser startup must install the shared virtual-desktop route hook itself; do not make Browser depend on the Desktop plugin being enabled.
46
47 ## Work Guidance
48
@@ -47,7 +55,7 @@
55 ## Verification
56
57 - Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes.
50 -- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path.
58 +- For viewer render-path changes, verify direct iframe interaction reaches the same page controlled by Patchright, separate contexts use separate displays, and an unavailable Xpra runtime falls back to CDP screencast/snapshot rendering.
59 - Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills.
60
61 ## Child DOX Index
plugins/_browser/api/status.py
+2
@@ -1,5 +1,6 @@
1 from helpers.api import ApiHandler, Request
2 from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
3 +from plugins._browser.helpers.interactive_view import collect_status as collect_interactive_status
4 from plugins._browser.helpers.playwright import (
5 get_playwright_binary,
6 get_playwright_cache_dir,
@@ -37,5 +38,6 @@ class Status(ApiHandler):
38 "requires_full_browser": launch_config["requires_full_browser"],
39 },
40 "host_browser": host_browser,
41 + "interactive_view": collect_interactive_status(),
42 "contexts": known_context_ids(),
43 }
plugins/_browser/api/ws_browser.py
+83 -8
@@ -25,7 +25,12 @@ SCREENCAST_STREAM_QUALITY = 80
25 SCREENSHOT_QUALITY = 92
26 VIEWER_TRANSPORT_SCREENCAST = "screencast"
27 VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
28 -VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
28 +VIEWER_TRANSPORT_INTERACTIVE = "interactive"
29 +VIEWER_TRANSPORTS = {
30 + VIEWER_TRANSPORT_INTERACTIVE,
31 + VIEWER_TRANSPORT_SCREENCAST,
32 + VIEWER_TRANSPORT_SNAPSHOT,
33 +}
34
35
36 class WsBrowser(WsHandler):
@@ -87,8 +92,19 @@ class WsBrowser(WsHandler):
92 if opened.get("id"):
93 listing["last_interacted_browser_id"] = opened.get("id")
94 active_id = self._active_browser_id(listing, data.get("browser_id"))
95 + requested_transport = self._viewer_transport(data)
96 + viewer_transport, interactive_view = await self._effective_viewer(
97 + runtime,
98 + active_id,
99 + data,
100 + )
101 initial_viewport = self._viewport_from_data(data)
91 - if runtime and active_id and initial_viewport:
102 + if (
103 + runtime
104 + and active_id
105 + and initial_viewport
106 + and viewer_transport != VIEWER_TRANSPORT_INTERACTIVE
107 + ):
108 await runtime.call(
109 "set_viewport",
110 active_id,
@@ -103,7 +119,6 @@ class WsBrowser(WsHandler):
119 if existing:
120 existing.cancel()
121 viewer_id = str(data.get("viewer_id") or "")
106 - viewer_transport = self._viewer_transport(data)
122 binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames")))
123 slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames)))
124 capture_scale = self._capture_scale_from_data(data)
@@ -120,7 +135,13 @@ class WsBrowser(WsHandler):
135 capture_scale=capture_scale,
136 )
137 else:
123 - stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
138 + stream_task = self._stream_state(
139 + sid,
140 + context_id,
141 + active_id,
142 + viewer_id,
143 + viewer_transport=viewer_transport,
144 + )
145 self._streams[stream_key] = asyncio.create_task(stream_task)
146 snapshot = await self._snapshot_for_browser(runtime, active_id)
147
@@ -136,6 +157,14 @@ class WsBrowser(WsHandler):
157 "tab_scope": tab_scope,
158 "viewer_id": viewer_id,
159 "viewer_transport": viewer_transport,
160 + "interactive_view": interactive_view,
161 + "viewer_fallback_reason": (
162 + str(interactive_view.get("error") or "")
163 + if requested_transport == VIEWER_TRANSPORT_INTERACTIVE
164 + and interactive_view
165 + and not interactive_view.get("available")
166 + else ""
167 + ),
168 "binary_frames": binary_frames,
169 "slim_frames": slim_frames,
170 }
@@ -253,6 +282,15 @@ class WsBrowser(WsHandler):
282
283 listing = await runtime.call("list")
284 last_interacted_browser_id = listing.get("last_interacted_browser_id")
285 + active_id = self._active_browser_id(
286 + listing,
287 + self._result_browser_id(result) or browser_id,
288 + )
289 + viewer_transport, interactive_view = await self._effective_viewer(
290 + runtime,
291 + active_id,
292 + data,
293 + )
294 snapshot = await self._snapshot_for_result(runtime, result)
295 browsers, all_browsers, tab_scope = await self._tabs_for_scope(
296 context_id,
@@ -273,7 +311,8 @@ class WsBrowser(WsHandler):
311 "all_browsers": all_browsers,
312 "tab_scope": tab_scope,
313 "last_interacted_browser_id": last_interacted_browser_id,
276 - "viewer_transport": self._viewer_transport(data),
314 + "viewer_transport": viewer_transport,
315 + "interactive_view": interactive_view,
316 },
317 correlation_id=data.get("correlationId"),
318 )
@@ -288,7 +327,8 @@ class WsBrowser(WsHandler):
327 "command": command,
328 "browser_id": browser_id,
329 "viewer_id": viewer_id,
291 - "viewer_transport": self._viewer_transport(data),
330 + "viewer_transport": viewer_transport,
331 + "interactive_view": interactive_view,
332 }
333
334 async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -326,12 +366,15 @@ class WsBrowser(WsHandler):
366 text=str(data.get("text") or ""),
367 )
368 elif input_type == "viewport":
369 + viewer_transport = self._viewer_transport(data)
370 result = await runtime.call(
371 "set_viewport",
372 browser_id,
373 int(data.get("width") or 0),
374 int(data.get("height") or 0),
375 restart_screencast=bool(data.get("restart_stream")),
376 + resize_interactive=viewer_transport == VIEWER_TRANSPORT_INTERACTIVE,
377 + include_state=viewer_transport != VIEWER_TRANSPORT_INTERACTIVE,
378 )
379 elif input_type == "wheel":
380 result = await runtime.call(
@@ -582,6 +625,8 @@ class WsBrowser(WsHandler):
625 context_id: str,
626 browser_id: int | str | None,
627 viewer_id: str = "",
628 + *,
629 + viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
630 ) -> None:
631 last_signature = None
632 while True:
@@ -594,7 +639,7 @@ class WsBrowser(WsHandler):
639 sid,
640 context_id,
641 viewer_id=viewer_id,
597 - frame_source=VIEWER_TRANSPORT_SNAPSHOT,
642 + frame_source=viewer_transport,
643 )
644 last_signature = signature
645 await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
@@ -625,7 +670,7 @@ class WsBrowser(WsHandler):
670 browsers=browsers,
671 viewer_id=viewer_id,
672 state=state,
628 - viewer_transport=VIEWER_TRANSPORT_SNAPSHOT,
673 + viewer_transport=viewer_transport,
674 )
675 last_signature = signature
676 await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
@@ -653,6 +698,36 @@ class WsBrowser(WsHandler):
698 active_id = browsers[0].get("id")
699 return active_id
700
701 + async def _effective_viewer(
702 + self,
703 + runtime: Any,
704 + browser_id: int | str | None,
705 + data: dict[str, Any],
706 + ) -> tuple[str, dict[str, Any] | None]:
707 + requested = self._viewer_transport(data)
708 + if requested != VIEWER_TRANSPORT_INTERACTIVE or not runtime or not browser_id:
709 + return requested, None
710 + viewport = self._viewport_from_data(data) or {}
711 + try:
712 + viewer = await runtime.call(
713 + "interactive_viewer",
714 + browser_id,
715 + width=int(viewport.get("width") or 0),
716 + height=int(viewport.get("height") or 0),
717 + )
718 + except Exception as exc:
719 + viewer = {"available": False, "error": str(exc)}
720 + if viewer.get("available"):
721 + return VIEWER_TRANSPORT_INTERACTIVE, viewer
722 + return VIEWER_TRANSPORT_SCREENCAST, viewer
723 +
724 + @staticmethod
725 + def _result_browser_id(result: Any) -> int | str | None:
726 + if not isinstance(result, dict):
727 + return None
728 + state = result.get("state") if isinstance(result.get("state"), dict) else result
729 + return state.get("id") if isinstance(state, dict) else None
730 +
731 @staticmethod
732 def _state_for_browser(
733 browsers: list[dict[str, Any]],
plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py
+2
@@ -3,6 +3,7 @@ from __future__ import annotations
3 import threading
4 from typing import Any
5
6 +from helpers import virtual_desktop_routes
7 from helpers.extension import Extension
8 from helpers.print_style import PrintStyle
9 from plugins._browser import hooks
@@ -13,6 +14,7 @@ _startup_migration_thread: threading.Thread | None = None
14
15 class BrowserPlaywrightCacheMigration(Extension):
16 def execute(self, **kwargs):
17 + virtual_desktop_routes.install_route_hooks()
18 _start_background_cache_migration()
19
20
plugins/_browser/helpers/interactive_view.py new
+293
@@ -0,0 +1,293 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import select
5 +import shutil
6 +import socket
7 +import subprocess
8 +import threading
9 +import time
10 +import uuid
11 +from pathlib import Path
12 +from typing import Any
13 +
14 +from helpers import files, virtual_desktop
15 +from helpers.print_style import PrintStyle
16 +
17 +
18 +DEFAULT_WIDTH = 1024
19 +DEFAULT_HEIGHT = 768
20 +START_TIMEOUT_SECONDS = 15.0
21 +
22 +
23 +def collect_status() -> dict[str, Any]:
24 + binaries = {
25 + name: shutil.which(name) or ""
26 + for name in ("Xvfb", "xpra", "xrandr")
27 + }
28 + html_root = virtual_desktop.find_xpra_html_root()
29 + missing = [name for name, path in binaries.items() if not path]
30 + if not html_root:
31 + missing.append("xpra-html5")
32 + return {
33 + "available": not missing,
34 + "missing": missing,
35 + "binaries": binaries,
36 + "xpra_html_root": str(html_root or ""),
37 + }
38 +
39 +
40 +class BrowserInteractiveView:
41 + """Own one private X display and its optional Xpra viewer."""
42 +
43 + def __init__(self, context_id: str) -> None:
44 + self.context_id = str(context_id)
45 + self.token = f"browser-{uuid.uuid4().hex}"
46 + self.state_dir = Path(files.get_abs_path("tmp", "browser", "displays", self.token))
47 + self.display: int | None = None
48 + self.port = 0
49 + self.width = DEFAULT_WIDTH
50 + self.height = DEFAULT_HEIGHT
51 + self._xvfb: subprocess.Popen[Any] | None = None
52 + self._xpra: subprocess.Popen[Any] | None = None
53 + self._lock = threading.RLock()
54 +
55 + @property
56 + def display_name(self) -> str:
57 + return f":{self.display}" if self.display is not None else ""
58 +
59 + def ensure_display(self) -> str:
60 + with self._lock:
61 + if self._running(self._xvfb) and self.display is not None:
62 + return self.display_name
63 +
64 + self._stop_locked()
65 + xvfb = shutil.which("Xvfb")
66 + if not xvfb:
67 + return ""
68 +
69 + self.state_dir.mkdir(parents=True, exist_ok=True)
70 + self.state_dir.chmod(0o700)
71 + read_fd, write_fd = os.pipe()
72 + try:
73 + process = subprocess.Popen(
74 + [
75 + xvfb,
76 + "-displayfd",
77 + str(write_fd),
78 + "-screen",
79 + "0",
80 + f"{virtual_desktop.MAX_WIDTH}x{virtual_desktop.MAX_HEIGHT}x24",
81 + "+extension",
82 + "GLX",
83 + "+extension",
84 + "RANDR",
85 + "+extension",
86 + "RENDER",
87 + "+extension",
88 + "Composite",
89 + "-nolisten",
90 + "tcp",
91 + "-noreset",
92 + "-ac",
93 + ],
94 + stdin=subprocess.DEVNULL,
95 + stdout=subprocess.DEVNULL,
96 + stderr=subprocess.DEVNULL,
97 + pass_fds=(write_fd,),
98 + )
99 + except OSError:
100 + os.close(read_fd)
101 + return ""
102 + finally:
103 + os.close(write_fd)
104 +
105 + try:
106 + ready, _, _ = select.select([read_fd], [], [], 5)
107 + display_number = os.read(read_fd, 32).decode().strip() if ready else ""
108 + finally:
109 + os.close(read_fd)
110 +
111 + if not display_number.isdigit() or process.poll() is not None:
112 + self._terminate(process)
113 + return ""
114 +
115 + self._xvfb = process
116 + self.display = int(display_number)
117 + self.resize(self.width, self.height)
118 + return self.display_name
119 +
120 + def ensure_viewer(self, width: int = 0, height: int = 0) -> dict[str, Any]:
121 + with self._lock:
122 + display_name = self.ensure_display()
123 + if not display_name:
124 + return self._unavailable("Xvfb is unavailable.")
125 +
126 + status = collect_status()
127 + if not status["available"]:
128 + return self._unavailable(
129 + f"Interactive Browser runtime needs: {', '.join(status['missing'])}."
130 + )
131 +
132 + self.resize(width or self.width, height or self.height)
133 + if not self._running(self._xpra):
134 + try:
135 + self._start_xpra(str(status["binaries"]["xpra"]))
136 + except Exception as exc:
137 + PrintStyle.warning(f"Interactive Browser viewer failed to start: {exc}")
138 + self._terminate(self._xpra)
139 + self._xpra = None
140 + self.port = 0
141 + virtual_desktop.unregister_session(self.token)
142 + return self._unavailable(str(exc))
143 +
144 + virtual_desktop.register_session(
145 + token=self.token,
146 + host="127.0.0.1",
147 + port=self.port,
148 + owner="browser",
149 + title="Browser",
150 + resize=self.resize,
151 + )
152 + return {
153 + "available": True,
154 + "token": self.token,
155 + "url": virtual_desktop.session_url(
156 + self.token,
157 + title="Browser",
158 + encoding="",
159 + quality=90,
160 + speed=90,
161 + file_transfer=False,
162 + printing=False,
163 + ),
164 + "width": self.width,
165 + "height": self.height,
166 + }
167 +
168 + def resize(self, width: int, height: int) -> dict[str, Any]:
169 + with self._lock:
170 + target_width, target_height = virtual_desktop.normalize_size(width, height)
171 + self.width = target_width
172 + self.height = target_height
173 + if self.display is None or not self._running(self._xvfb):
174 + return {
175 + "ok": False,
176 + "error": "Browser display is unavailable.",
177 + "width": target_width,
178 + "height": target_height,
179 + }
180 + result = virtual_desktop.resize_display(
181 + display=self.display,
182 + width=target_width,
183 + height=target_height,
184 + settle_seconds=0,
185 + )
186 + return result
187 +
188 + def close(self) -> None:
189 + with self._lock:
190 + self._stop_locked()
191 + shutil.rmtree(self.state_dir, ignore_errors=True)
192 +
193 + def _start_xpra(self, xpra: str) -> None:
194 + self.port = self._free_port()
195 + runtime_dir = self.state_dir / "runtime"
196 + socket_dir = self.state_dir / "sockets"
197 + runtime_dir.mkdir(parents=True, exist_ok=True)
198 + socket_dir.mkdir(parents=True, exist_ok=True)
199 + runtime_dir.chmod(0o700)
200 + env = {
201 + **os.environ,
202 + "DISPLAY": self.display_name,
203 + "XDG_RUNTIME_DIR": str(runtime_dir),
204 + }
205 + self._xpra = subprocess.Popen(
206 + [
207 + xpra,
208 + "shadow",
209 + self.display_name,
210 + "--daemon=no",
211 + "--mdns=no",
212 + "--html=on",
213 + "--tray=no",
214 + "--system-tray=no",
215 + "--notifications=no",
216 + "--clipboard=yes",
217 + "--clipboard-direction=both",
218 + "--file-transfer=no",
219 + "--open-files=no",
220 + "--open-url=no",
221 + "--printing=no",
222 + "--audio=no",
223 + "--speaker=off",
224 + "--microphone=off",
225 + "--sharing=yes",
226 + "--resize-display=yes",
227 + "--encoding=auto",
228 + "--quality=90",
229 + "--speed=90",
230 + f"--bind-tcp=127.0.0.1:{self.port}",
231 + f"--socket-dir={socket_dir}",
232 + f"--log-dir={self.state_dir}",
233 + "--log-file=xpra.log",
234 + ],
235 + stdin=subprocess.DEVNULL,
236 + stdout=subprocess.DEVNULL,
237 + stderr=subprocess.DEVNULL,
238 + env=env,
239 + )
240 + self._wait_for_port(self._xpra, self.port)
241 +
242 + def _stop_locked(self) -> None:
243 + virtual_desktop.unregister_session(self.token)
244 + self._terminate(self._xpra)
245 + self._terminate(self._xvfb)
246 + self._xpra = None
247 + self._xvfb = None
248 + self.port = 0
249 + self.display = None
250 +
251 + def _unavailable(self, error: str) -> dict[str, Any]:
252 + return {
253 + "available": False,
254 + "error": str(error or "Interactive Browser viewer is unavailable."),
255 + }
256 +
257 + @staticmethod
258 + def _running(process: subprocess.Popen[Any] | None) -> bool:
259 + return bool(process and process.poll() is None)
260 +
261 + @staticmethod
262 + def _terminate(process: subprocess.Popen[Any] | None) -> None:
263 + if not process or process.poll() is not None:
264 + return
265 + process.terminate()
266 + try:
267 + process.wait(timeout=2)
268 + except subprocess.TimeoutExpired:
269 + process.kill()
270 + process.wait(timeout=2)
271 +
272 + @staticmethod
273 + def _free_port() -> int:
274 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
275 + probe.bind(("127.0.0.1", 0))
276 + return int(probe.getsockname()[1])
277 +
278 + @staticmethod
279 + def _wait_for_port(
280 + process: subprocess.Popen[Any],
281 + port: int,
282 + timeout: float = START_TIMEOUT_SECONDS,
283 + ) -> None:
284 + deadline = time.monotonic() + timeout
285 + while time.monotonic() < deadline:
286 + if process.poll() is not None:
287 + raise RuntimeError("Xpra exited before its Browser endpoint was ready.")
288 + try:
289 + with socket.create_connection(("127.0.0.1", port), timeout=0.2):
290 + return
291 + except OSError:
292 + time.sleep(0.1)
293 + raise TimeoutError("Timed out waiting for the interactive Browser endpoint.")
plugins/_browser/helpers/playwright.py
-85
@@ -1,9 +1,6 @@
1 -import atexit
1 import json
2 import os
3 import re
5 -import select
6 -import shutil
4 import subprocess
5 import sys
6 import threading
@@ -23,9 +20,6 @@ RETIRED_PLAYWRIGHT_CACHE_DIRS = (
20 ("usr", "browser", "playwright"),
21 )
22 _INSTALL_LOCK = threading.Lock()
26 -_DISPLAY_LOCK = threading.Lock()
27 -_DISPLAY_PROCESS: subprocess.Popen | None = None
28 -_DISPLAY_NAME = ""
23
24
25 def _primary_cache_dir() -> Path:
@@ -64,82 +58,6 @@ def configure_playwright_env() -> str:
58 return cache_dir
59
60
67 -def ensure_browser_display() -> str:
68 - global _DISPLAY_NAME, _DISPLAY_PROCESS
69 -
70 - with _DISPLAY_LOCK:
71 - if _DISPLAY_PROCESS and _DISPLAY_PROCESS.poll() is None:
72 - return _DISPLAY_NAME
73 -
74 - xvfb = shutil.which("Xvfb")
75 - if not xvfb:
76 - return ""
77 -
78 - read_fd, write_fd = os.pipe()
79 - try:
80 - process = subprocess.Popen(
81 - [
82 - xvfb,
83 - "-displayfd",
84 - str(write_fd),
85 - "-screen",
86 - "0",
87 - "1365x768x24",
88 - "+extension",
89 - "GLX",
90 - "-nolisten",
91 - "tcp",
92 - "-noreset",
93 - "-ac",
94 - ],
95 - stdin=subprocess.DEVNULL,
96 - stdout=subprocess.DEVNULL,
97 - stderr=subprocess.DEVNULL,
98 - pass_fds=(write_fd,),
99 - )
100 - except OSError:
101 - os.close(read_fd)
102 - return ""
103 - finally:
104 - os.close(write_fd)
105 -
106 - try:
107 - ready, _, _ = select.select([read_fd], [], [], 5)
108 - display_number = os.read(read_fd, 32).decode().strip() if ready else ""
109 - finally:
110 - os.close(read_fd)
111 -
112 - if not display_number.isdigit() or process.poll() is not None:
113 - _terminate_browser_display(process)
114 - return ""
115 -
116 - _DISPLAY_PROCESS = process
117 - _DISPLAY_NAME = f":{display_number}"
118 - return _DISPLAY_NAME
119 -
120 -
121 -def close_browser_display() -> None:
122 - global _DISPLAY_NAME, _DISPLAY_PROCESS
123 -
124 - with _DISPLAY_LOCK:
125 - process = _DISPLAY_PROCESS
126 - _DISPLAY_PROCESS = None
127 - _DISPLAY_NAME = ""
128 - if process:
129 - _terminate_browser_display(process)
130 -
131 -
132 -def _terminate_browser_display(process: subprocess.Popen) -> None:
133 - if process.poll() is not None:
134 - return
135 - process.terminate()
136 - try:
137 - process.wait(timeout=2)
138 - except subprocess.TimeoutExpired:
139 - process.kill()
140 - process.wait(timeout=2)
141 -
142 -
61 def find_playwright_binary(cache_dir: Path, revision: str = "") -> Path | None:
62 prefix = f"chromium-{revision}" if revision.isdigit() else "chromium-*"
63 binaries = [
@@ -197,6 +115,3 @@ def ensure_playwright_binary() -> Path:
115 if not binary:
116 raise RuntimeError("Patchright Chromium binary not found after installation")
117 return binary
200 -
201 -
202 -atexit.register(close_browser_display)
plugins/_browser/helpers/runtime.py
+172 -12
@@ -27,9 +27,7 @@ from plugins._browser.helpers.config import (
27 build_browser_launch_config,
28 get_browser_config,
29 )
30 -from plugins._browser.helpers.playwright import (
31 - ensure_browser_display,
32 -)
30 +from plugins._browser.helpers.interactive_view import BrowserInteractiveView
31 from plugins._browser.helpers.url import normalize_url
32
33
@@ -558,7 +556,10 @@ class BrowserRuntime:
556 await self.call("close", delete_profile=delete_profile)
557 finally:
558 self._closed = True
561 - self._worker.kill(terminate_thread=True)
559 + try:
560 + self._worker.kill(terminate_thread=True)
561 + finally:
562 + self._core.interactive_view.close()
563
564
565 class _BrowserRuntimeCore:
@@ -594,6 +595,11 @@ class _BrowserRuntimeCore:
595 self._pending_popups: list[asyncio.Future[int]] = []
596 self._background_popup_pages: set[int] = set()
597 self._bootstrap_page: Any | None = None
598 + self._browser_chrome_height: int | None = None
599 + self._browser_window_page: Any | None = None
600 + self._browser_window_session: Any | None = None
601 + self._browser_window_id: int | None = None
602 + self.interactive_view = BrowserInteractiveView(context_id)
603
604 def _ensure_registry_lock(self) -> asyncio.Lock:
605 if self._registry_lock is None:
@@ -777,6 +783,10 @@ class _BrowserRuntimeCore:
783 self._pending_popups.clear()
784 self._background_popup_pages.clear()
785 self._bootstrap_page = None
786 + self._browser_chrome_height = None
787 + self._browser_window_page = None
788 + self._browser_window_session = None
789 + self._browser_window_id = None
790 self.pages.clear()
791 self.last_interacted_browser_id = None
792 for screencast in self.screencasts.values():
@@ -815,7 +825,15 @@ class _BrowserRuntimeCore:
825 browser_config = get_browser_config()
826 launch_config = build_browser_launch_config(browser_config)
827 browser_binary = Path(preparation["binary"])
818 - browser_display = ensure_browser_display()
828 + browser_display = self.interactive_view.ensure_display()
829 + launch_args = list(launch_config["args"])
830 + if browser_display:
831 + launch_args.extend(
832 + [
833 + "--window-position=0,0",
834 + f"--window-size={self.interactive_view.width},{self.interactive_view.height}",
835 + ]
836 + )
837
838 self.playwright = await async_playwright().start()
839 launch_kwargs: dict[str, Any] = {
@@ -823,13 +841,17 @@ class _BrowserRuntimeCore:
841 "headless": not bool(browser_display),
842 "accept_downloads": True,
843 "downloads_path": str(self.downloads_dir),
826 - "viewport": DEFAULT_VIEWPORT,
827 - "screen": DEFAULT_VIEWPORT,
828 - "no_viewport": False,
829 - "args": launch_config["args"],
844 + "args": launch_args,
845 }
846 if browser_display:
847 launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display}
848 + launch_kwargs["no_viewport"] = True
849 + else:
850 + launch_kwargs.update(
851 + viewport=DEFAULT_VIEWPORT,
852 + screen=DEFAULT_VIEWPORT,
853 + no_viewport=False,
854 + )
855 if launch_config["channel"]:
856 launch_kwargs["channel"] = launch_config["channel"]
857 else:
@@ -857,6 +879,7 @@ class _BrowserRuntimeCore:
879 if page.url == "about:blank":
880 if browser_display and self._bootstrap_page is None:
881 self._bootstrap_page = page
882 + await self._fit_browser_window(page)
883 continue
884 try:
885 await page.close()
@@ -1236,8 +1259,12 @@ class _BrowserRuntimeCore:
1259 async def set_active(self, browser_id: int | str | None) -> dict[str, Any]:
1260 await self.ensure_started()
1261 resolved_id = self._resolve_browser_id(browser_id)
1262 + page = self._page(resolved_id)
1263 # Explicit focus change — bypass _maybe_promote.
1264 self.last_interacted_browser_id = int(resolved_id)
1265 + with contextlib.suppress(Exception):
1266 + await page.bring_to_front()
1267 + await self._fit_browser_window(page)
1268 return await self._state(resolved_id)
1269
1270 async def state(self, browser_id: int | str | None = None) -> dict[str, Any]:
@@ -1703,7 +1730,7 @@ class _BrowserRuntimeCore:
1730 await screencast.start(
1731 quality=quality,
1732 every_nth_frame=every_nth_frame,
1706 - viewport=page.viewport_size or DEFAULT_VIEWPORT,
1733 + viewport=await self._page_viewport(page),
1734 capture_scale=capture_scale,
1735 )
1736 except Exception:
@@ -1717,6 +1744,61 @@ class _BrowserRuntimeCore:
1744 "state": await self._state(resolved_id),
1745 }
1746
1747 + @staticmethod
1748 + async def _page_viewport(page: Any) -> dict[str, int]:
1749 + viewport = getattr(page, "viewport_size", None)
1750 + if viewport:
1751 + return {
1752 + "width": int(viewport.get("width") or DEFAULT_VIEWPORT["width"]),
1753 + "height": int(viewport.get("height") or DEFAULT_VIEWPORT["height"]),
1754 + }
1755 + try:
1756 + measured = await page.evaluate(
1757 + "() => ({ width: globalThis.innerWidth, height: globalThis.innerHeight })",
1758 + isolated_context=False,
1759 + )
1760 + return {
1761 + "width": int(measured.get("width") or DEFAULT_VIEWPORT["width"]),
1762 + "height": int(measured.get("height") or DEFAULT_VIEWPORT["height"]),
1763 + }
1764 + except Exception:
1765 + return dict(DEFAULT_VIEWPORT)
1766 +
1767 + async def interactive_viewer(
1768 + self,
1769 + browser_id: int | str | None = None,
1770 + *,
1771 + width: int = 0,
1772 + height: int = 0,
1773 + ) -> dict[str, Any]:
1774 + await self.ensure_started()
1775 + resolved_id = self._resolve_browser_id(browser_id)
1776 + page = self._page(resolved_id)
1777 + current_viewport = await self._page_viewport(page)
1778 + viewer = self.interactive_view.ensure_viewer(
1779 + width or int(current_viewport.get("width") or DEFAULT_VIEWPORT["width"]),
1780 + height or int(current_viewport.get("height") or DEFAULT_VIEWPORT["height"]),
1781 + )
1782 + if not viewer.get("available"):
1783 + return viewer
1784 +
1785 + await self._stop_screencasts_for_browser(resolved_id)
1786 + with contextlib.suppress(Exception):
1787 + await page.bring_to_front()
1788 + viewport_result = await self.set_viewport(
1789 + resolved_id,
1790 + int(viewer.get("width") or width or DEFAULT_VIEWPORT["width"]),
1791 + int(viewer.get("height") or height or DEFAULT_VIEWPORT["height"]),
1792 + resize_interactive=True,
1793 + )
1794 + self.last_interacted_browser_id = int(resolved_id)
1795 + return {
1796 + **viewer,
1797 + "browser_id": resolved_id,
1798 + "state": viewport_result["state"],
1799 + "viewport": viewport_result["viewport"],
1800 + }
1801 +
1802 async def read_screencast_frame(
1803 self,
1804 stream_id: str,
@@ -1756,15 +1838,30 @@ class _BrowserRuntimeCore:
1838 width: int,
1839 height: int,
1840 restart_screencast: bool = False,
1841 + resize_interactive: bool = False,
1842 + include_state: bool = True,
1843 ) -> dict[str, Any]:
1844 await self.ensure_started()
1845 resolved_id = self._resolve_browser_id(browser_id)
1846 page = self._page(resolved_id)
1847 + if resize_interactive:
1848 + resized = self.interactive_view.resize(width, height)
1849 + viewport = {
1850 + "width": int(resized.get("width") or self.interactive_view.width),
1851 + "height": int(resized.get("height") or self.interactive_view.height),
1852 + }
1853 + await self._fit_browser_window(page)
1854 + self._maybe_promote(resolved_id)
1855 + return {
1856 + "state": await self._state(resolved_id) if include_state else None,
1857 + "viewport": viewport,
1858 + }
1859 +
1860 viewport = {
1861 "width": max(320, min(4096, int(width or DEFAULT_VIEWPORT["width"]))),
1862 "height": max(200, min(4096, int(height or DEFAULT_VIEWPORT["height"]))),
1863 }
1767 - current_viewport = page.viewport_size or {}
1864 + current_viewport = await self._page_viewport(page)
1865 changed = (
1866 abs(int(current_viewport.get("width") or 0) - viewport["width"])
1867 > VIEWPORT_SIZE_TOLERANCE
@@ -2182,6 +2279,7 @@ class _BrowserRuntimeCore:
2279 self._pending_popups.clear()
2280 self._background_popup_pages.clear()
2281 await self._stop_all_screencasts()
2282 + await self._reset_browser_window_session()
2283 for browser_id in list(self.pages):
2284 try:
2285 await self.pages[browser_id].page.close()
@@ -2315,7 +2413,67 @@ class _BrowserRuntimeCore:
2413 async def _register_page(self, page: Any) -> BrowserPage:
2414 lock = self._ensure_registry_lock()
2415 async with lock:
2318 - return self._register_page_locked(page)
2416 + browser_page = self._register_page_locked(page)
2417 + await self._fit_browser_window(page)
2418 + return browser_page
2419 +
2420 + async def _fit_browser_window(self, page: Any) -> None:
2421 + if getattr(self.interactive_view, "display", None) is None or not self.context:
2422 + return
2423 + try:
2424 + if self._browser_window_session is None or self._browser_window_page is not page:
2425 + await self._reset_browser_window_session()
2426 + self._browser_window_page = page
2427 + self._browser_window_session = await self.context.new_cdp_session(page)
2428 + target = await self._browser_window_session.send("Browser.getWindowForTarget")
2429 + self._browser_window_id = target.get("windowId")
2430 + if self._browser_window_id is None:
2431 + await self._reset_browser_window_session()
2432 + return
2433 + current = await self._browser_window_session.send(
2434 + "Browser.getWindowBounds",
2435 + {"windowId": self._browser_window_id},
2436 + )
2437 + if current.get("bounds", {}).get("windowState") != "normal":
2438 + await self._browser_window_session.send(
2439 + "Browser.setWindowBounds",
2440 + {
2441 + "windowId": self._browser_window_id,
2442 + "bounds": {"windowState": "normal"},
2443 + },
2444 + )
2445 + if self._browser_chrome_height is None:
2446 + chrome_height = await page.evaluate(
2447 + "() => Math.max(0, globalThis.outerHeight - globalThis.innerHeight)",
2448 + isolated_context=False,
2449 + )
2450 + self._browser_chrome_height = max(0, min(256, int(chrome_height or 0)))
2451 + chrome_height = self._browser_chrome_height
2452 + await self._browser_window_session.send(
2453 + "Browser.setWindowBounds",
2454 + {
2455 + "windowId": self._browser_window_id,
2456 + "bounds": {
2457 + "windowState": "normal",
2458 + "left": 0,
2459 + "top": -chrome_height,
2460 + "width": self.interactive_view.width,
2461 + "height": self.interactive_view.height + chrome_height,
2462 + },
2463 + },
2464 + )
2465 + except Exception as exc:
2466 + await self._reset_browser_window_session()
2467 + PrintStyle.warning(f"Interactive Browser window fit failed: {exc}")
2468 +
2469 + async def _reset_browser_window_session(self) -> None:
2470 + session = self._browser_window_session
2471 + self._browser_window_page = None
2472 + self._browser_window_session = None
2473 + self._browser_window_id = None
2474 + if session:
2475 + with contextlib.suppress(Exception):
2476 + await session.detach()
2477
2478 async def _unregister_page_async(self, browser_id: int) -> None:
2479 try:
@@ -2372,6 +2530,8 @@ class _BrowserRuntimeCore:
2530 if close_over_limit:
2531 with contextlib.suppress(Exception):
2532 await page.close()
2533 + else:
2534 + await self._fit_browser_window(page)
2535 except Exception as exc:
2536 PrintStyle.warning(f"Popup registration failed: {exc}")
2537
plugins/_browser/webui/browser-panel.html
+55 -39
@@ -20,10 +20,13 @@
20 <div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
21 <button type="button" class="browser-tab" role="tab"
22 :aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
23 + :aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"
24 :title="$store.browserPage.browserTabTooltip(browser)"
25 @click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
26 <x-icon class="browser-tab-icon" aria-hidden="true" name="language"></x-icon>
27 <span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
28 + <x-icon class="browser-tab-loading spinning" aria-hidden="true"
29 + x-show="$store.browserPage.isBrowserLoading(browser)" name="progress_activity"></x-icon>
30 </button>
31 <button type="button" class="browser-tab-close"
32 :title="'Close ' + $store.browserPage.browserTabLabel(browser)"
@@ -185,15 +188,22 @@
188 </form>
189 </div>
190
188 - <div class="browser-stage" tabindex="0" @click="$el.focus()"
189 - :class="{ 'is-annotating': $store.browserPage.annotating }"
190 - @wheel.prevent="$store.browserPage.handleStageWheel($event)">
191 - <canvas class="browser-frame browser-frame-canvas"
192 - x-show="$store.browserPage.frameCanvasReady"
193 - x-init="$store.browserPage.attachFrameCanvas($el)"
194 - @click="$store.browserPage.sendMouse('click', $event)"
195 - @mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas>
196 - <template x-if="$store.browserPage.frameSrc">
191 + <div class="browser-stage" tabindex="0" @click="$el.focus()"
192 + :class="{ 'is-annotating': $store.browserPage.annotating }"
193 + @wheel.prevent="$store.browserPage.handleStageWheel($event)">
194 + <template x-if="$store.browserPage.isInteractiveSurface($el.parentElement)">
195 + <iframe class="browser-interactive-frame"
196 + :src="$store.browserPage.interactiveViewUrl"
197 + aria-label="Browser viewport"
198 + allow="clipboard-read; clipboard-write"
199 + @load="$store.browserPage.onInteractiveViewLoad()"></iframe>
200 + </template>
201 + <canvas class="browser-frame browser-frame-canvas"
202 + x-show="!$store.browserPage.usesInteractiveTransport() && $store.browserPage.frameCanvasReady"
203 + x-init="$store.browserPage.attachFrameCanvas($el)"
204 + @click="$store.browserPage.sendMouse('click', $event)"
205 + @mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas>
206 + <template x-if="!$store.browserPage.usesInteractiveTransport() && $store.browserPage.frameSrc">
207 <img class="browser-frame browser-frame-image" :src="$store.browserPage.frameSrc"
208 @click="$store.browserPage.sendMouse('click', $event)"
209 @mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
@@ -282,18 +292,11 @@
292 </template>
293 </div>
294
285 - <div class="browser-bottom-status"
286 - :class="{ 'is-active': $store.browserPage.isBusy() || $store.browserPage.error, 'has-error': $store.browserPage.error && !$store.browserPage.isBusy() }">
287 - <template x-if="$store.browserPage.isBusy()">
288 - <div class="browser-status" :title="$store.browserPage.loadingMessage()">
289 - <x-icon class="spinning" name="progress_activity"></x-icon>
290 - <span x-text="$store.browserPage.loadingMessage()">Loading</span>
291 - </div>
292 - </template>
293 - <template x-if="!$store.browserPage.isBusy() && $store.browserPage.error">
295 + <template x-if="$store.browserPage.error">
296 + <div class="browser-bottom-status has-error">
297 <div class="browser-error" x-text="$store.browserPage.error" :title="$store.browserPage.error"></div>
295 - </template>
296 - </div>
298 + </div>
299 + </template>
300 </div>
301 </template>
302 </div>
@@ -603,6 +606,13 @@
606 font-weight: 600;
607 }
608
609 + .browser-tab-loading {
610 + flex: 0 0 auto;
611 + color: color-mix(in srgb, var(--color-text) 66%, var(--color-primary) 34%);
612 + font-size: 0.96rem;
613 + line-height: 1;
614 + }
615 +
616 .browser-tab-close {
617 display: inline-flex;
618 align-items: center;
@@ -997,19 +1007,31 @@
1007 cursor: crosshair;
1008 }
1009
1000 - .browser-frame {
1001 - display: block;
1002 - position: absolute;
1003 - inset: 0;
1004 - width: 100%;
1005 - height: 100%;
1006 - min-width: 0;
1007 - min-height: 0;
1008 - object-fit: contain;
1009 - image-rendering: auto;
1010 - user-select: none;
1011 - background: #fff;
1012 - }
1010 + .browser-frame {
1011 + display: block;
1012 + position: absolute;
1013 + inset: 0;
1014 + width: 100%;
1015 + height: 100%;
1016 + min-width: 0;
1017 + min-height: 0;
1018 + object-fit: contain;
1019 + image-rendering: auto;
1020 + user-select: none;
1021 + background: #fff;
1022 + }
1023 +
1024 + .browser-interactive-frame {
1025 + display: block;
1026 + position: absolute;
1027 + inset: 0;
1028 + z-index: 1;
1029 + box-sizing: border-box;
1030 + width: 100%;
1031 + height: 100%;
1032 + border: 0;
1033 + background: #fff;
1034 + }
1035
1036 .browser-annotation-layer {
1037 position: absolute;
@@ -1239,7 +1261,6 @@
1261 background: color-mix(in srgb, #7f1d1d 22%, var(--color-background));
1262 }
1263
1242 - .browser-status,
1264 .browser-error {
1265 display: inline-flex;
1266 align-items: center;
@@ -1250,17 +1271,12 @@
1271 white-space: nowrap;
1272 }
1273
1253 - .browser-status span:not(.material-symbols-outlined),
1274 .browser-error {
1275 min-width: 0;
1276 overflow: hidden;
1277 text-overflow: ellipsis;
1278 }
1279
1260 - .browser-status .material-symbols-outlined {
1261 - font-size: 15px;
1262 - }
1263 -
1280 .browser-modal .spinning,
1281 .browser-panel .spinning {
1282 display: inline-block;
plugins/_browser/webui/browser-store.js
+155 -32
@@ -21,11 +21,13 @@ const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
21 const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
22 const BROWSER_COMMAND_TIMEOUT_MS = 45000;
23 const BROWSER_CONFIG_REFRESH_MS = 15000;
24 +const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";
25 const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";
26 const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";
26 -const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
27 +const VIEWPORT_SYNC_INTERVAL_MS = 50;
28 const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
29 const CANVAS_VIEWPORT_SETTLE_MS = 520;
30 +const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;
31 const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
32 const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;
33 const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
@@ -168,9 +170,10 @@ const model = {
170 frameSrc: "",
171 frameCanvasReady: false,
172 frameState: null,
171 - viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
173 + viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
174 + interactiveViewUrl: "",
175 + viewerFallbackReason: "",
176 tabScope: "per_context",
173 - liveScreencastEnabled: true,
177 annotating: false,
178 annotationComments: [],
179 annotationDraft: null,
@@ -216,6 +219,7 @@ const model = {
219 _openSignature: "",
220 _connectSequence: 0,
221 _viewerToken: "",
222 + _subscribedViewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
223 _contextCreatePromise: null,
224 _lastSelectedContextId: "",
225 _sessionRefreshPromise: null,
@@ -879,6 +883,7 @@ const model = {
883
884 resetRenderedFrame() {
885 this.cancelFrameRender();
886 + this.interactiveViewUrl = "";
887 this.clearFrameSrc();
888 this.clearFrameCanvas();
889 this._lastFrameDimensions = null;
@@ -932,6 +937,7 @@ const model = {
937
938 async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) {
939 if (!this.connected || !this.activeBrowserId) return;
940 + const surfaceMode = this._mode;
941 await this.waitForSurfaceViewport({ sequence });
942 if (!this.isCurrentSurfaceOpen(sequence)) {
943 return;
@@ -939,19 +945,24 @@ const model = {
945 await this.syncViewport(true, {
946 restartStream: this._mode === "canvas" && this.usesScreencastTransport(),
947 });
942 - if (this._mode !== "canvas") return;
943 - this.scheduleViewportSyncForSurface(sequence, 240);
944 - this.scheduleViewportSyncForSurface(sequence, 520);
948 + if (surfaceMode === "modal" && this.usesInteractiveTransport()) {
949 + this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);
950 + return;
951 + }
952 + if (surfaceMode !== "canvas") return;
953 + this.scheduleViewportSyncForSurface(sequence, 240, surfaceMode);
954 + this.scheduleViewportSyncForSurface(sequence, 520, surfaceMode);
955 },
956
957 requestedViewerTransport() {
948 - return this.liveScreencastEnabled
949 - ? BROWSER_VIEWER_TRANSPORT_SCREENCAST
950 - : BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
958 + return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
959 },
960
961 normalizeViewerTransport(value = "") {
962 const normalized = String(value || "").trim().toLowerCase().replace("-", "_");
963 + if (normalized === BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
964 + return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
965 + }
966 if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) {
967 return BROWSER_VIEWER_TRANSPORT_SCREENCAST;
968 }
@@ -974,6 +985,101 @@ const model = {
985 return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
986 },
987
988 + usesInteractiveTransport() {
989 + return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_INTERACTIVE
990 + && Boolean(this.interactiveViewUrl);
991 + },
992 +
993 + isInteractiveSurface(stage = null) {
994 + return this.usesInteractiveTransport() && stage === this._stageElement;
995 + },
996 +
997 + prepareInteractiveViewFrame(frame = null) {
998 + const target = frame || this._stageElement?.querySelector?.(".browser-interactive-frame");
999 + const remoteWindow = target?.contentWindow;
1000 + if (!remoteWindow) return false;
1001 + try {
1002 + const remoteDocument = target.contentDocument || remoteWindow.document;
1003 + if (!remoteDocument) return false;
1004 + if (!remoteDocument.getElementById("a0-xpra-browser-frame-css")) {
1005 + const style = remoteDocument.createElement("style");
1006 + style.id = "a0-xpra-browser-frame-css";
1007 + style.textContent = `
1008 + #shadow_pointer {
1009 + display: none !important;
1010 + visibility: hidden !important;
1011 + opacity: 0 !important;
1012 + }
1013 + .window canvas,
1014 + .undecorated canvas {
1015 + display: block !important;
1016 + margin: 0 !important;
1017 + }
1018 + `;
1019 + remoteDocument.head?.appendChild(style);
1020 + }
1021 +
1022 + const normalizeWindows = () => {
1023 + const windows = Object.values(remoteWindow.client?.id_to_window || {});
1024 + for (const xpraWindow of windows) {
1025 + xpraWindow.resizable = false;
1026 + xpraWindow.decorations = false;
1027 + xpraWindow.decorated = false;
1028 + xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1029 + xpraWindow._set_decorated?.(false);
1030 + xpraWindow.configure_border_class?.();
1031 + xpraWindow.leftoffset = 0;
1032 + xpraWindow.rightoffset = 0;
1033 + xpraWindow.topoffset = 0;
1034 + xpraWindow.bottomoffset = 0;
1035 + xpraWindow.updateCSSGeometry?.();
1036 + }
1037 + return windows.length > 0;
1038 + };
1039 +
1040 + const screen = remoteDocument.querySelector?.("#screen");
1041 + if (screen && !remoteWindow.__a0BrowserFrameObserver && remoteWindow.MutationObserver) {
1042 + const observer = new remoteWindow.MutationObserver(normalizeWindows);
1043 + observer.observe(screen, { childList: true });
1044 + remoteWindow.__a0BrowserFrameObserver = observer;
1045 + }
1046 + return normalizeWindows();
1047 + } catch {
1048 + return false;
1049 + }
1050 + },
1051 +
1052 + syncInteractiveViewSize() {
1053 + if (!this.usesInteractiveTransport()) return;
1054 + const frame = this._stageElement?.querySelector?.(".browser-interactive-frame");
1055 + try {
1056 + this.prepareInteractiveViewFrame(frame);
1057 + frame?.contentWindow?.client?._screen_resized?.();
1058 + } catch {}
1059 + },
1060 +
1061 + applyViewer(data = {}) {
1062 + if (data?.viewer_transport) {
1063 + this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1064 + }
1065 + if (Object.prototype.hasOwnProperty.call(data || {}, "interactive_view")) {
1066 + const viewer = data.interactive_view;
1067 + this.interactiveViewUrl = viewer?.available && viewer?.url ? String(viewer.url) : "";
1068 + this.viewerFallbackReason = String(data.viewer_fallback_reason || viewer?.error || "");
1069 + }
1070 + if (this.viewerTransport !== BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
1071 + this.interactiveViewUrl = "";
1072 + }
1073 + },
1074 +
1075 + onInteractiveViewLoad() {
1076 + if (!this.usesInteractiveTransport()) return;
1077 + this.prepareInteractiveViewFrame();
1078 + this.switchingBrowserId = null;
1079 + this._surfaceSwitching = false;
1080 + this.queueViewportSync(true);
1081 + },
1082 +
1083 supportsBinaryFrames() {
1084 return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED;
1085 },
@@ -1003,9 +1109,9 @@ const model = {
1109 return { width, height };
1110 },
1111
1006 - scheduleViewportSyncForSurface(sequence, delayMs = 0) {
1112 + scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode) {
1113 globalThis.setTimeout?.(() => {
1008 - if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== "canvas") {
1114 + if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== mode) {
1115 return;
1116 }
1117 this.queueViewportSync(true);
@@ -1093,7 +1199,8 @@ const model = {
1199 replaceAll: Boolean(data.all_browsers),
1200 replaceContext: !data.all_browsers,
1201 });
1096 - this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1202 + this.applyViewer(data);
1203 + this._subscribedViewerTransport = this.viewerTransport;
1204 this.setActiveBrowserId(
1205 data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
1206 data.active_browser_context_id || contextId,
@@ -1108,9 +1215,7 @@ const model = {
1215 const frameHandler = ({ data }) => {
1216 if (data?.context_id !== this.contextId) return;
1217 if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
1111 - if (data?.viewer_transport) {
1112 - this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1113 - }
1218 + this.applyViewer(data);
1219 this.applyTabScope(data);
1220 const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
1221 const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
@@ -1180,9 +1285,7 @@ const model = {
1285 const stateHandler = ({ data }) => {
1286 if (data?.context_id !== this.contextId) return;
1287 if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
1183 - if (data?.viewer_transport) {
1184 - this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1185 - }
1288 + this.applyViewer(data);
1289 this.applyTabScope(data);
1290 const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
1291 if (Array.isArray(data.browsers)) {
@@ -1384,7 +1487,7 @@ const model = {
1487 },
1488
1489 hasFrame() {
1387 - return Boolean(this.frameSrc || this.frameCanvasReady);
1490 + return Boolean(this.interactiveViewUrl || this.frameSrc || this.frameCanvasReady);
1491 },
1492
1493 paintFrameBitmap(bitmap) {
@@ -1419,6 +1522,10 @@ const model = {
1522 },
1523
1524 frameElement() {
1525 + if (this.usesInteractiveTransport()) {
1526 + const iframe = this._stageElement?.querySelector?.(".browser-interactive-frame");
1527 + if (iframe) return iframe;
1528 + }
1529 if (this.frameCanvasReady) {
1530 const canvas = this.currentFrameCanvas();
1531 if (canvas) return canvas;
@@ -1467,7 +1574,7 @@ const model = {
1574 replaceAll: Boolean(data.all_browsers),
1575 replaceContext: !data.all_browsers,
1576 });
1470 - this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1577 + this.applyViewer(data);
1578 const result = data.result || {};
1579 const resultContextId = this.normalizeContextId(
1580 result.context_id
@@ -1510,7 +1617,12 @@ const model = {
1617 previousActiveBrowserId,
1618 previousActiveContextId,
1619 );
1513 - if ((commandName === "open" || commandName === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
1620 + const viewerTransportChanged = this._subscribedViewerTransport !== this.viewerTransport;
1621 + if (
1622 + (commandName === "open" || commandName === "close" || activeChanged || viewerTransportChanged)
1623 + && this.contextId
1624 + && this.activeBrowserId
1625 + ) {
1626 await this.connectViewer({
1627 browserId: this.activeBrowserId,
1628 contextId: this.activeBrowserContextId,
@@ -1670,6 +1782,10 @@ const model = {
1782 return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId);
1783 },
1784
1785 + isBrowserLoading(browser) {
1786 + return Boolean(browser?.loading || (this.isActiveBrowser(browser) && this.isBusy()));
1787 + },
1788 +
1789 browserTabTitle(browser) {
1790 const title = String(browser?.title || "").trim();
1791 const url = String(browser?.currentUrl || "").trim();
@@ -1831,6 +1947,7 @@ const model = {
1947 if (snapshot.state) {
1948 this.applyActiveFrameState(snapshot.state);
1949 }
1950 + if (this.usesInteractiveTransport()) return;
1951 const frameBrowserId = snapshotId || this.activeBrowserId;
1952 this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
1953 browserId: frameBrowserId,
@@ -2521,13 +2638,21 @@ const model = {
2638
2639 queueViewportSync(force = false) {
2640 this.clearRenderedFrameIfViewportChanged();
2641 + if (force) {
2642 + if (this._viewportSyncTimer) {
2643 + globalThis.clearTimeout(this._viewportSyncTimer);
2644 + this._viewportSyncTimer = null;
2645 + }
2646 + void this.syncViewport(true);
2647 + return;
2648 + }
2649 if (this._viewportSyncTimer) {
2525 - globalThis.clearTimeout(this._viewportSyncTimer);
2650 + return;
2651 }
2652 this._viewportSyncTimer = globalThis.setTimeout(() => {
2653 this._viewportSyncTimer = null;
2529 - void this.syncViewport(force);
2530 - }, force ? 0 : VIEWPORT_SYNC_DEBOUNCE_MS);
2654 + void this.syncViewport(false);
2655 + }, VIEWPORT_SYNC_INTERVAL_MS);
2656 },
2657
2658 async syncViewport(force = false, options = {}) {
@@ -2542,7 +2667,7 @@ const model = {
2667 }
2668 const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
2669 if (
2545 - (!restartStream && this._lastViewportKey === key)
2670 + (!force && !restartStream && this._lastViewportKey === key)
2671 || (
2672 !force
2673 && !restartStream
@@ -2555,11 +2680,13 @@ const model = {
2680 return;
2681 }
2682 try {
2683 + this.syncInteractiveViewSize();
2684 await websocket.emit("browser_viewer_input", {
2685 context_id: contextId,
2686 browser_id: this.activeBrowserId,
2687 viewer_id: this._viewerToken,
2688 input_type: "viewport",
2689 + viewer_transport: this.viewerTransport,
2690 width: viewport.width,
2691 height: viewport.height,
2692 restart_stream: restartStream && this.usesScreencastTransport(),
@@ -2739,6 +2866,9 @@ const model = {
2866 this._viewerToken = "";
2867 this.switchingBrowserId = null;
2868 this.viewerTransport = this.requestedViewerTransport();
2869 + this._subscribedViewerTransport = this.viewerTransport;
2870 + this.interactiveViewUrl = "";
2871 + this.viewerFallbackReason = "";
2872 this.tabScope = "per_context";
2873 this._surfaceMounted = false;
2874 this._surfaceSwitching = false;
@@ -3010,13 +3140,6 @@ const model = {
3140 return this.frameState?.currentUrl || this.address || "about:blank";
3141 },
3142
3013 - loadingMessage() {
3014 - if (this.browserInstallExpected) {
3015 - const cacheDir = this.status?.playwright?.cache_dir || "/a0/tmp/playwright";
3016 - return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`;
3017 - }
3018 - return "Loading";
3019 - },
3143 };
3144
3145 export const store = createStore("browserPage", model);
tests/test_browser_agent_regressions.py
+351 -6
@@ -82,7 +82,7 @@ sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_conf
82 def anyio_backend():
83 return "asyncio"
84
85 -from helpers import ephemeral_images
85 +from helpers import ephemeral_images, virtual_desktop
86 from helpers.errors import RepairableException
87 from plugins._browser.helpers.config import (
88 build_browser_launch_config,
@@ -109,6 +109,8 @@ from plugins._browser.helpers.runtime import (
109 normalize_url,
110 )
111 import plugins._browser.helpers.runtime as browser_runtime_module
112 +from plugins._browser.helpers.interactive_view import BrowserInteractiveView
113 +import plugins._browser.helpers.interactive_view as browser_interactive_view_module
114 from plugins._browser.helpers.playwright import (
115 ensure_playwright_binary,
116 get_playwright_binary,
@@ -833,7 +835,8 @@ def test_browser_viewer_allows_slow_extension_startup():
835 assert "const BROWSER_COMMAND_TIMEOUT_MS = 45000;" in js
836 assert "? BROWSER_FIRST_INSTALL_TIMEOUT_MS" in js
837 assert ": BROWSER_SUBSCRIBE_TIMEOUT_MS" in js
836 - assert "Installing Chromium for the first Browser run" in js
838 + assert "browserInstallExpected" in js
839 + assert "Installing Chromium for the first Browser run" not in js
840
841
842 def test_browser_viewer_creates_chat_when_no_context_is_selected():
@@ -880,6 +883,20 @@ def test_browser_canvas_startup_waits_for_raw_viewport_settle():
883 assert "this.resetRenderedFrame();" in js
884
885
886 +def test_browser_interactive_modal_handoff_reconciles_after_xpra_resize():
887 + js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
888 + encoding="utf-8"
889 + )
890 +
891 + assert "const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;" in js
892 + assert "const surfaceMode = this._mode;" in js
893 + assert 'surfaceMode === "modal" && this.usesInteractiveTransport()' in js
894 + assert "this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);" in js
895 + assert "scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode)" in js
896 + assert "this._mode !== mode" in js
897 + assert "(!force && !restartStream && this._lastViewportKey === key)" in js
898 +
899 +
900 def test_browser_surface_handoffs_keep_existing_frame_until_replacement_arrives():
901 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
902 encoding="utf-8"
@@ -1393,6 +1410,12 @@ def test_browser_viewer_uses_tabs_for_session_switching():
1410 assert ':key="$store.browserPage.browserTabKey(browser)"' in main_html
1411 assert "browser.context_id" in main_html
1412 assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
1413 + assert ':aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"' in main_html
1414 + assert 'class="browser-tab-loading spinning"' in main_html
1415 + assert 'x-show="$store.browserPage.isBrowserLoading(browser)"' in main_html
1416 + assert "<span x-text=\"$store.browserPage.loadingMessage()\">Loading</span>" not in main_html
1417 + assert "isBrowserLoading(browser)" in browser_store
1418 + assert "loadingMessage()" not in browser_store
1419 assert "browser-tab-context" not in main_html
1420 assert 'handleSelectedContextChange($store.chats?.selected)' in main_html
1421 assert "activeBrowserContextId" in browser_store
@@ -1445,7 +1468,7 @@ def test_browser_tabs_close_without_confirmation_or_busy_lock():
1468 assert "_commandInFlightCount" in browser_store
1469
1470
1448 -def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1471 +def test_browser_viewer_defaults_to_interactive_with_screencast_and_snapshot_fallbacks():
1472 ws_browser = (PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py").read_text(
1473 encoding="utf-8"
1474 )
@@ -1465,6 +1488,9 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1488 assert 'runtime.call("screenshot"' in ws_browser
1489 assert 'VIEWER_TRANSPORT_SNAPSHOT = "snapshot"' in ws_browser
1490 assert 'VIEWER_TRANSPORT_SCREENCAST = "screencast"' in ws_browser
1491 + assert 'VIEWER_TRANSPORT_INTERACTIVE = "interactive"' in ws_browser
1492 + assert "async def _effective_viewer(" in ws_browser
1493 + assert "return VIEWER_TRANSPORT_SCREENCAST, viewer" in ws_browser
1494 assert "def _viewer_transport(data: dict[str, Any])" in ws_browser
1495 assert "return VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
1496 assert "self._stream_state" in ws_browser
@@ -1510,8 +1536,20 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1536 assert "return await globalThis.createImageBitmap(blob);" in browser_store
1537 assert 'const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";' in browser_store
1538 assert 'const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";' in browser_store
1513 - assert "viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST" in browser_store
1514 - assert "liveScreencastEnabled: true" in browser_store
1539 + assert 'const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";' in browser_store
1540 + assert "const VIEWPORT_SYNC_INTERVAL_MS = 50;" in browser_store
1541 + assert "viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE" in browser_store
1542 + assert "return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;" in browser_store
1543 + assert "usesInteractiveTransport()" in browser_store
1544 + assert "isInteractiveSurface(stage = null)" in browser_store
1545 + assert "prepareInteractiveViewFrame(frame = null)" in browser_store
1546 + assert 'style.id = "a0-xpra-browser-frame-css";' in browser_store
1547 + assert "#shadow_pointer" in browser_store
1548 + assert "xpraWindow._set_decorated?.(false);" in browser_store
1549 + assert "xpraWindow.updateCSSGeometry?.();" in browser_store
1550 + assert "syncInteractiveViewSize()" in browser_store
1551 + assert "frame?.contentWindow?.client?._screen_resized?.();" in browser_store
1552 + assert "applyViewer(data = {})" in browser_store
1553 assert "requestedViewerTransport()" in browser_store
1554 assert "normalizeViewerTransport(value = \"\")" in browser_store
1555 assert "usesScreencastTransport()" in browser_store
@@ -1583,6 +1621,11 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1621 assert "canvas_wheel_screenshot" not in ws_browser
1622 assert "surface_mode: this._mode" not in browser_store
1623 assert '<canvas class="browser-frame browser-frame-canvas"' in main_html
1624 + assert '<iframe class="browser-interactive-frame"' in main_html
1625 + assert 'x-if="$store.browserPage.isInteractiveSurface($el.parentElement)"' in main_html
1626 + assert 'aria-label="Browser viewport"' in main_html
1627 + assert 'title="Interactive Browser viewport"' not in main_html
1628 + assert 'allow="clipboard-read; clipboard-write"' in main_html
1629 assert 'x-init="$store.browserPage.attachFrameCanvas($el)"' in main_html
1630 assert '<img class="browser-frame browser-frame-image"' in main_html
1631 assert "$store.browserPage.hasFrame()" in main_html
@@ -2207,7 +2250,12 @@ def test_browser_docker_installs_full_chromium_to_tmp_cache():
2250 ).read_text(encoding="utf-8")
2251 assert '"headless": not bool(browser_display)' in runtime
2252 assert 'launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display}' in runtime
2253 + assert 'launch_kwargs["no_viewport"] = True' in runtime
2254 + assert '"windowState": "fullscreen"' not in runtime
2255 + assert "self.interactive_view.ensure_display()" in runtime
2256 assert " xvfb \\" in install_additional
2257 + assert "XPRA_PACKAGES=(xpra xpra-x11 xpra-html5)" in install_additional
2258 + assert " xdotool \\" in install_additional
2259
2260
2261 def test_browser_startup_migration_prepares_current_playwright_binary():
@@ -2222,10 +2270,248 @@ def test_browser_startup_migration_prepares_current_playwright_binary():
2270 ).read_text(encoding="utf-8")
2271
2272 assert "class BrowserPlaywrightCacheMigration(Extension)" in extension
2273 + assert "virtual_desktop_routes.install_route_hooks()" in extension
2274 assert "hooks.prepare_playwright_cache()" in extension
2275 assert "PrintStyle.warning" in extension
2276
2277
2278 +def test_browser_interactive_views_use_isolated_loopback_sessions(monkeypatch, tmp_path):
2279 + class FakeProcess:
2280 + def __init__(self):
2281 + self.returncode = None
2282 +
2283 + def poll(self):
2284 + return self.returncode
2285 +
2286 + def terminate(self):
2287 + self.returncode = 0
2288 +
2289 + def wait(self, timeout=None):
2290 + return self.returncode
2291 +
2292 + next_display = iter((71, 72))
2293 + next_port = iter((44001, 44002))
2294 +
2295 + def fake_ensure_display(view):
2296 + view.display = next(next_display)
2297 + view._xvfb = FakeProcess()
2298 + return view.display_name
2299 +
2300 + def fake_resize(view, width, height):
2301 + view.width, view.height = virtual_desktop.normalize_size(width, height)
2302 + return {"ok": True, "width": view.width, "height": view.height}
2303 +
2304 + def fake_start_xpra(view, xpra):
2305 + assert xpra == "/usr/bin/xpra"
2306 + view.port = next(next_port)
2307 + view._xpra = FakeProcess()
2308 +
2309 + monkeypatch.setattr(
2310 + browser_interactive_view_module.files,
2311 + "get_abs_path",
2312 + lambda *parts: str(tmp_path.joinpath(*parts)),
2313 + )
2314 + monkeypatch.setattr(BrowserInteractiveView, "ensure_display", fake_ensure_display)
2315 + monkeypatch.setattr(BrowserInteractiveView, "resize", fake_resize)
2316 + monkeypatch.setattr(BrowserInteractiveView, "_start_xpra", fake_start_xpra)
2317 + monkeypatch.setattr(
2318 + browser_interactive_view_module,
2319 + "collect_status",
2320 + lambda: {
2321 + "available": True,
2322 + "missing": [],
2323 + "binaries": {"xpra": "/usr/bin/xpra"},
2324 + "xpra_html_root": "/usr/share/xpra/www",
2325 + },
2326 + )
2327 +
2328 + views = [BrowserInteractiveView("ctx-a"), BrowserInteractiveView("ctx-b")]
2329 + try:
2330 + viewers = [view.ensure_viewer(1200, 700) for view in views]
2331 +
2332 + assert views[0].token != views[1].token
2333 + assert views[0].display != views[1].display
2334 + assert all(viewer["available"] for viewer in viewers)
2335 + for view, viewer in zip(views, viewers, strict=True):
2336 + endpoint = virtual_desktop.proxy_for_token(view.token)
2337 + assert endpoint is not None
2338 + assert endpoint.host == "127.0.0.1"
2339 + assert endpoint.owner == "browser"
2340 + assert endpoint.port == view.port
2341 + assert viewer["token"] == view.token
2342 + assert "file_transfer=false" in viewer["url"]
2343 + assert "printing=false" in viewer["url"]
2344 + finally:
2345 + for view in views:
2346 + view.close()
2347 +
2348 + assert all(virtual_desktop.proxy_for_token(view.token) is None for view in views)
2349 +
2350 +
2351 +@pytest.mark.anyio
2352 +async def test_browser_interactive_viewer_reuses_the_automated_page():
2353 + class FakePage:
2354 + viewport_size = {"width": 1024, "height": 768}
2355 +
2356 + class FakeInteractiveView:
2357 + def __init__(self):
2358 + self.ensure_calls = []
2359 +
2360 + def ensure_viewer(self, width, height):
2361 + self.ensure_calls.append((width, height))
2362 + return {
2363 + "available": True,
2364 + "token": "browser-token",
2365 + "url": "/desktop/session/browser-token/",
2366 + "width": width,
2367 + "height": height,
2368 + }
2369 +
2370 + page = FakePage()
2371 + interactive_view = FakeInteractiveView()
2372 + viewport_calls = []
2373 + stopped = []
2374 + core = _BrowserRuntimeCore("ctx")
2375 + core.context = object()
2376 + core.pages[7] = BrowserPage(id=7, page=page)
2377 + core.interactive_view = interactive_view
2378 +
2379 + async def ensure_started():
2380 + return None
2381 +
2382 + async def stop_screencasts(browser_id):
2383 + stopped.append(browser_id)
2384 +
2385 + async def set_viewport(browser_id, width, height, **kwargs):
2386 + viewport_calls.append((browser_id, width, height, kwargs))
2387 + return {"state": {"id": browser_id}, "viewport": {"width": width, "height": height}}
2388 +
2389 + core.ensure_started = ensure_started
2390 + core._stop_screencasts_for_browser = stop_screencasts
2391 + core.set_viewport = set_viewport
2392 +
2393 + result = await core.interactive_viewer(7, width=1200, height=700)
2394 +
2395 + assert core.pages[7].page is page
2396 + assert interactive_view.ensure_calls == [(1200, 700)]
2397 + assert stopped == [7]
2398 + assert viewport_calls == [(7, 1200, 700, {"resize_interactive": True})]
2399 + assert core.last_interacted_browser_id == 7
2400 + assert result["url"] == "/desktop/session/browser-token/"
2401 +
2402 +
2403 +@pytest.mark.anyio
2404 +async def test_browser_interactive_window_clips_chrome_without_fullscreen():
2405 + commands = []
2406 +
2407 + class FakeSession:
2408 + async def send(self, method, params=None):
2409 + commands.append((method, params))
2410 + if method == "Browser.getWindowForTarget":
2411 + return {"windowId": 7}
2412 + if method == "Browser.getWindowBounds":
2413 + return {
2414 + "bounds": {
2415 + "windowState": "normal",
2416 + "left": 0,
2417 + "top": 0,
2418 + "width": 1024,
2419 + "height": 768,
2420 + }
2421 + }
2422 + return {}
2423 +
2424 + async def detach(self):
2425 + commands.append(("detach", None))
2426 +
2427 + class FakeContext:
2428 + async def new_cdp_session(self, page):
2429 + assert page is fake_page
2430 + return FakeSession()
2431 +
2432 + class FakeInteractiveView:
2433 + display = 0
2434 + width = 1200
2435 + height = 700
2436 +
2437 + class FakePage:
2438 + async def evaluate(self, script, **kwargs):
2439 + commands.append(("evaluate", script))
2440 + return 87
2441 +
2442 + fake_page = FakePage()
2443 + core = _BrowserRuntimeCore("ctx")
2444 + core.context = FakeContext()
2445 + core.interactive_view = FakeInteractiveView()
2446 +
2447 + await core._fit_browser_window(fake_page)
2448 + core.interactive_view.width = 1280
2449 + core.interactive_view.height = 720
2450 + await core._fit_browser_window(fake_page)
2451 + await core._reset_browser_window_session()
2452 +
2453 + assert commands == [
2454 + ("Browser.getWindowForTarget", None),
2455 + ("Browser.getWindowBounds", {"windowId": 7}),
2456 + (
2457 + "evaluate",
2458 + "() => Math.max(0, globalThis.outerHeight - globalThis.innerHeight)",
2459 + ),
2460 + (
2461 + "Browser.setWindowBounds",
2462 + {
2463 + "windowId": 7,
2464 + "bounds": {
2465 + "windowState": "normal",
2466 + "left": 0,
2467 + "top": -87,
2468 + "width": 1200,
2469 + "height": 787,
2470 + },
2471 + },
2472 + ),
2473 + (
2474 + "Browser.setWindowBounds",
2475 + {
2476 + "windowId": 7,
2477 + "bounds": {
2478 + "windowState": "normal",
2479 + "left": 0,
2480 + "top": -87,
2481 + "width": 1280,
2482 + "height": 807,
2483 + },
2484 + },
2485 + ),
2486 + ("detach", None),
2487 + ]
2488 +
2489 +
2490 +@pytest.mark.anyio
2491 +async def test_browser_viewer_falls_back_when_interactive_runtime_is_unavailable():
2492 + calls = []
2493 +
2494 + class FakeRuntime:
2495 + async def call(self, method, *args, **kwargs):
2496 + calls.append((method, args, kwargs))
2497 + return {"available": False, "error": "xpra unavailable"}
2498 +
2499 + handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
2500 + transport, viewer = await handler._effective_viewer(
2501 + FakeRuntime(),
2502 + 7,
2503 + {
2504 + "viewer_transport": "interactive",
2505 + "viewport_width": 1200,
2506 + "viewport_height": 700,
2507 + },
2508 + )
2509 +
2510 + assert transport == ws_browser_module.VIEWER_TRANSPORT_SCREENCAST
2511 + assert viewer == {"available": False, "error": "xpra unavailable"}
2512 + assert calls == [("interactive_viewer", (7,), {"width": 1200, "height": 700})]
2513 +
2514 +
2515 def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path):
2516 monkeypatch.setattr(
2517 browser_runtime_module.files,
@@ -3245,6 +3531,7 @@ async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
3531 "width": 1280,
3532 "height": 720,
3533 "restart_stream": True,
3534 + "viewer_transport": "interactive",
3535 },
3536 "sid-1",
3537 )
@@ -3254,10 +3541,68 @@ async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
3541 "snapshot": None,
3542 }
3543 assert calls == [
3257 - ("set_viewport", (7, 1280, 720), {"restart_screencast": True})
3544 + (
3545 + "set_viewport",
3546 + (7, 1280, 720),
3547 + {
3548 + "restart_screencast": True,
3549 + "resize_interactive": True,
3550 + "include_state": False,
3551 + },
3552 + )
3553 ]
3554
3555
3556 +@pytest.mark.anyio
3557 +async def test_browser_interactive_resize_uses_native_window_viewport():
3558 + fitted = []
3559 +
3560 + class FakePage:
3561 + viewport_size = None
3562 +
3563 + async def set_viewport_size(self, viewport):
3564 + raise AssertionError("Interactive native viewport must not enable emulation")
3565 +
3566 + class FakeInteractiveView:
3567 + display = 0
3568 + width = 1024
3569 + height = 768
3570 +
3571 + def resize(self, width, height):
3572 + self.width = width
3573 + self.height = height
3574 + return {"ok": True, "width": width, "height": height}
3575 +
3576 + page = FakePage()
3577 + core = _BrowserRuntimeCore("ctx")
3578 + core.context = object()
3579 + core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=page)
3580 + core.interactive_view = FakeInteractiveView()
3581 +
3582 + async def fake_fit(fitted_page):
3583 + fitted.append(fitted_page)
3584 +
3585 + async def fake_state(browser_id):
3586 + return {"id": browser_id}
3587 +
3588 + core._fit_browser_window = fake_fit
3589 + core._state = fake_state
3590 +
3591 + result = await core.set_viewport(
3592 + 7,
3593 + 1280,
3594 + 720,
3595 + resize_interactive=True,
3596 + include_state=False,
3597 + )
3598 +
3599 + assert result == {
3600 + "state": None,
3601 + "viewport": {"width": 1280, "height": 720},
3602 + }
3603 + assert fitted == [page]
3604 +
3605 +
3606 @pytest.mark.anyio
3607 async def test_browser_runtime_restarts_screencast_without_resizing_same_viewport():
3608 viewport_calls = []