From 7da0d6ec2896ea80c4501e0ddec3de67e41dee7f Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Mon, 29 Jun 2026 10:04:35 +0800 Subject: [PATCH] fix: constrain browser window to VNC framebuffer --- backend/browser_manager.py | 45 ++++++++++++++++++++++++ backend/tests/test_browser_manager.py | 50 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/backend/browser_manager.py b/backend/browser_manager.py index 327f91b5..af9d4443 100644 --- a/backend/browser_manager.py +++ b/backend/browser_manager.py @@ -233,6 +233,12 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: env={**os.environ, "DISPLAY": f":{display}"}, ) + await self._fit_window_to_vnc( + context, + width=profile.get("screen_width", 1920), + height=profile.get("screen_height", 1080), + ) + # Inject clipboard listener: captures copied text on every page # so the GET /clipboard endpoint can read it via page.evaluate() _clipboard_init_js = """ @@ -339,6 +345,45 @@ async def cleanup_stale(self): """Kill orphan processes from previous container runs.""" await self.vnc.cleanup_stale() + async def _fit_window_to_vnc(self, context: Any, width: int, height: int) -> None: + """Keep the native browser window inside the VNC framebuffer.""" + if not getattr(context, "pages", None): + logger.debug("Skipping VNC window fit: no pages available") + return + + try: + session = await context.new_cdp_session(context.pages[0]) + result = await session.send("Browser.getWindowForTarget") + bounds = result.get("bounds", {}) + left = int(bounds.get("left", 0)) + top = int(bounds.get("top", 0)) + current_width = int(bounds.get("width", width)) + current_height = int(bounds.get("height", height)) + + overflows = ( + left != 0 + or top != 0 + or left + current_width > width + or top + current_height > height + ) + if not overflows: + return + + await session.send( + "Browser.setWindowBounds", + { + "windowId": result["windowId"], + "bounds": {"left": 0, "top": 0, "width": width, "height": height}, + }, + ) + logger.info( + "Adjusted browser window to fit VNC framebuffer %dx%d", + width, + height, + ) + except Exception as exc: + logger.warning("Failed to adjust browser window bounds: %s", exc) + async def auto_launch_all(self): """Launch all profiles with auto_launch=True. Called on startup.""" from . import database as db diff --git a/backend/tests/test_browser_manager.py b/backend/tests/test_browser_manager.py index ba4665ef..ec7a8cc1 100644 --- a/backend/tests/test_browser_manager.py +++ b/backend/tests/test_browser_manager.py @@ -8,6 +8,7 @@ import pytest import socket +from unittest.mock import AsyncMock, MagicMock from backend.browser_manager import ( BASE_CDP_PORT, @@ -176,6 +177,55 @@ def test_launch_args_none_no_effect(): assert len(args) == base_count +# ── VNC browser window bounds ───────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_fit_window_to_vnc_moves_oversized_window_back_inside_framebuffer(): + mgr = BrowserManager() + page = MagicMock() + session = MagicMock() + session.send = AsyncMock( + side_effect=[ + { + "windowId": 123, + "bounds": { + "left": 10, + "top": 10, + "width": 1928, + "height": 1078, + "windowState": "normal", + }, + }, + { + "windowId": 123, + "bounds": { + "left": 0, + "top": 0, + "width": 1919, + "height": 1079, + "windowState": "normal", + }, + }, + ] + ) + context = MagicMock() + context.pages = [page] + context.new_cdp_session = AsyncMock(return_value=session) + + await mgr._fit_window_to_vnc(context, width=1920, height=1080) + + context.new_cdp_session.assert_awaited_once_with(page) + session.send.assert_any_await("Browser.getWindowForTarget") + session.send.assert_any_await( + "Browser.setWindowBounds", + { + "windowId": 123, + "bounds": {"left": 0, "top": 0, "width": 1920, "height": 1080}, + }, + ) + + # ── _allocate_cdp_port ───────────────────────────────────────────────────────