browser-agent-server 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ """Local HTTP daemon (127.0.0.1:8765) for system-wide Browser Agent access.
2
+
3
+ The daemon owns a single real-Chrome session (``BrowserCoordinator``),
4
+ guards it with a global mutex and hibernates Chrome + Xvfb after an idle
5
+ timeout so a small VPS can keep the listener resident at ~12 MB RAM.
6
+
7
+ Public HTTP contract — API version 1 (see docs/api.md):
8
+ GET /status (aliases: /, /health)
9
+ POST /fetch
10
+ POST /action
11
+ POST /agent
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import logging
18
+ import os
19
+ import threading
20
+ import time
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from importlib.metadata import PackageNotFoundError
23
+ from importlib.metadata import version as _pkg_version
24
+
25
+ from .chrome import RealChromeSession, is_warp_proxy_ready
26
+ from .vision_agent import run_vision_agent
27
+
28
+ log = logging.getLogger("browser_agent.server")
29
+
30
+ try:
31
+ __version__ = _pkg_version("browser-agent-server")
32
+ except PackageNotFoundError: # running from a source tree without install
33
+ __version__ = "0.0.0+local"
34
+
35
+ #: Version of the public HTTP contract exposed by this daemon.
36
+ API_VERSION = 1
37
+
38
+ DEFAULT_HOST = "127.0.0.1"
39
+ DEFAULT_PORT = int(os.environ.get("BROWSER_AGENT_PORT", "8765"))
40
+ DEFAULT_IDLE_TIMEOUT = int(os.environ.get("BROWSER_AGENT_IDLE_SEC", "60"))
41
+
42
+
43
+ class BrowserCoordinator:
44
+ """Thread-safe single-tab Chrome + X11 manager with automatic idle hibernation."""
45
+
46
+ def __init__(self, idle_timeout_sec: int = DEFAULT_IDLE_TIMEOUT):
47
+ self.lock = threading.RLock()
48
+ self.session = RealChromeSession()
49
+ self.idle_timeout_sec = max(15, int(idle_timeout_sec))
50
+ self._stop_reaper = threading.Event()
51
+ self._reaper = threading.Thread(target=self._reap_loop, daemon=True)
52
+ self._reaper.start()
53
+
54
+ def _reap_loop(self) -> None:
55
+ while not self._stop_reaper.wait(5.0):
56
+ if self.lock.acquire(blocking=False):
57
+ try:
58
+ if (
59
+ (self.session.is_running() or self.session.x11.is_running())
60
+ and time.time() - self.session.last_used > self.idle_timeout_sec
61
+ ):
62
+ log.info(
63
+ "Idle timeout (%ds) reached — hibernating Chrome & Xvfb to free RAM",
64
+ self.idle_timeout_sec,
65
+ )
66
+ self.session.stop()
67
+ finally:
68
+ self.lock.release()
69
+
70
+ def status(self) -> dict:
71
+ return {
72
+ "ok": True,
73
+ "api_version": API_VERSION,
74
+ "version": __version__,
75
+ "chrome_running": self.session.is_running(),
76
+ "x11_running": self.session.x11.is_running(),
77
+ "display": self.session.x11.display,
78
+ "resolution": f"{self.session.x11.width}x{self.session.x11.height}",
79
+ "mouse_pos": list(self.session.x11.mouse_pos),
80
+ "warp_proxy_ready": is_warp_proxy_ready(),
81
+ "idle_seconds": round(time.time() - self.session.last_used, 1),
82
+ "idle_timeout_sec": self.idle_timeout_sec,
83
+ }
84
+
85
+ def fetch(self, payload: dict) -> dict:
86
+ with self.lock:
87
+ url = str(payload.get("url") or "").strip()
88
+ if not url:
89
+ raise ValueError("Missing 'url' parameter")
90
+ return self.session.fetch_page(
91
+ url=url,
92
+ wait_sec=float(payload.get("wait_sec", 3.5)),
93
+ solve_cloudflare=bool(payload.get("solve_cloudflare", True)),
94
+ use_proxy=payload.get("use_proxy"),
95
+ include_screenshot=bool(payload.get("include_screenshot", False)),
96
+ )
97
+
98
+ def action(self, payload: dict) -> dict:
99
+ with self.lock:
100
+ act = str(payload.get("action") or "").strip().lower()
101
+ self.session.last_used = time.time()
102
+
103
+ if act == "stop":
104
+ self.session.stop()
105
+ return {"ok": True, "stopped": True}
106
+ if act == "vnc_start":
107
+ ok = self.session.x11.start_vnc(int(payload.get("port", 5900)))
108
+ return {"ok": ok, "vnc_port": int(payload.get("port", 5900))}
109
+ if act == "vnc_stop":
110
+ self.session.x11.stop_vnc()
111
+ return {"ok": True}
112
+
113
+ self.session.start(use_proxy=payload.get("use_proxy"))
114
+
115
+ if act == "goto":
116
+ url = str(payload.get("url") or "").strip()
117
+ cdp = self.session._connect_page_cdp()
118
+ try:
119
+ cdp.call("Page.navigate", {"url": url})
120
+ finally:
121
+ cdp.close()
122
+ time.sleep(float(payload.get("wait_sec", 2.5)))
123
+ if payload.get("solve_cloudflare", True):
124
+ self.session.solve_cloudflare_if_present()
125
+ cur_url, title, _ = self.session.get_html_and_title()
126
+ return {"ok": True, "url": cur_url, "title": title}
127
+
128
+ if act == "move":
129
+ pos = self.session.x11.move_mouse(int(payload["x"]), int(payload["y"]))
130
+ return {"ok": True, "mouse_pos": list(pos)}
131
+
132
+ if act == "click":
133
+ pos = self.session.x11.click(
134
+ x=int(payload["x"]) if payload.get("x") is not None else None,
135
+ y=int(payload["y"]) if payload.get("y") is not None else None,
136
+ button=int(payload.get("button", 1)),
137
+ double=bool(payload.get("double", False)),
138
+ )
139
+ return {"ok": True, "mouse_pos": list(pos)}
140
+
141
+ if act == "type":
142
+ self.session.x11.type_text(str(payload.get("text") or ""), submit=bool(payload.get("submit", False)))
143
+ return {"ok": True}
144
+
145
+ if act == "key":
146
+ self.session.x11.press_key(str(payload.get("key") or "Return"))
147
+ return {"ok": True}
148
+
149
+ if act == "scroll":
150
+ self.session.x11.scroll(
151
+ clicks=int(payload.get("clicks", 4)),
152
+ direction=str(payload.get("direction", "down")),
153
+ x=int(payload["x"]) if payload.get("x") is not None else None,
154
+ y=int(payload["y"]) if payload.get("y") is not None else None,
155
+ )
156
+ return {"ok": True}
157
+
158
+ if act == "screenshot":
159
+ raw = self.session.x11.screenshot_bytes()
160
+ return {"ok": True, "screenshot_b64": base64.b64encode(raw).decode("ascii")}
161
+
162
+ if act == "html":
163
+ cur_url, title, html = self.session.get_html_and_title()
164
+ return {"ok": True, "url": cur_url, "title": title, "html": html}
165
+
166
+ raise ValueError(f"Unknown action: {act!r}")
167
+
168
+ def agent(self, payload: dict) -> dict:
169
+ with self.lock:
170
+ url = str(payload.get("url") or "").strip()
171
+ goal = str(payload.get("goal") or "").strip()
172
+ if not goal:
173
+ raise ValueError("Missing 'goal' parameter")
174
+ return run_vision_agent(
175
+ session=self.session,
176
+ url=url,
177
+ goal=goal,
178
+ max_steps=int(payload.get("max_steps", 8)),
179
+ use_proxy=payload.get("use_proxy"),
180
+ )
181
+
182
+ def shutdown(self) -> None:
183
+ self._stop_reaper.set()
184
+ with self.lock:
185
+ self.session.stop()
186
+
187
+
188
+ def make_handler(coord: BrowserCoordinator):
189
+ class Handler(BaseHTTPRequestHandler):
190
+ def log_message(self, fmt: str, *args) -> None:
191
+ log.info("%s - %s", self.address_string(), fmt % args)
192
+
193
+ def _send_json(self, code: int, data: dict) -> None:
194
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
195
+ self.send_response(code)
196
+ self.send_header("Content-Type", "application/json; charset=utf-8")
197
+ self.send_header("Content-Length", str(len(body)))
198
+ self.end_headers()
199
+ self.wfile.write(body)
200
+
201
+ def _read_json(self) -> dict:
202
+ length = int(self.headers.get("Content-Length") or 0)
203
+ if length <= 0:
204
+ return {}
205
+ return json.loads(self.rfile.read(length).decode("utf-8"))
206
+
207
+ def do_GET(self) -> None: # noqa: N802
208
+ if self.path.rstrip("/") in ("", "/health", "/status"):
209
+ self._send_json(200, coord.status())
210
+ else:
211
+ self._send_json(404, {"ok": False, "error": "Not found"})
212
+
213
+ def do_POST(self) -> None: # noqa: N802
214
+ try:
215
+ payload = self._read_json()
216
+ path = self.path.rstrip("/")
217
+ if path == "/fetch":
218
+ self._send_json(200, coord.fetch(payload))
219
+ elif path == "/action":
220
+ self._send_json(200, coord.action(payload))
221
+ elif path == "/agent":
222
+ self._send_json(200, coord.agent(payload))
223
+ else:
224
+ self._send_json(404, {"ok": False, "error": f"Unknown endpoint {path}"})
225
+ except Exception as e: # noqa: BLE001
226
+ log.warning("Request %s failed: %s", self.path, e)
227
+ self._send_json(500, {"ok": False, "error": str(e)})
228
+
229
+ return Handler
230
+
231
+
232
+ def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> int:
233
+ """Run the localhost HTTP daemon in the foreground. Returns process exit code."""
234
+ coord = BrowserCoordinator(idle_timeout_sec=idle_timeout)
235
+ srv = ThreadingHTTPServer((host, port), make_handler(coord))
236
+ log.info("Browser Agent daemon listening on http://%s:%d (idle_timeout=%ds)", host, port, idle_timeout)
237
+ try:
238
+ srv.serve_forever()
239
+ except KeyboardInterrupt:
240
+ pass
241
+ finally:
242
+ coord.shutdown()
243
+ return 0
244
+
245
+
246
+ if __name__ == "__main__":
247
+ # Compatibility with `python -m browser_agent.server ...`
248
+ from .cli import main
249
+
250
+ raise SystemExit(main())
@@ -0,0 +1,165 @@
1
+ """Vision-guided Computer Use loop powered by Gemini Flash and hardware-level X11 mouse/keyboard."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import time
10
+ import urllib.request
11
+
12
+ from .chrome import RealChromeSession, _extract_text_from_html
13
+
14
+ log = logging.getLogger("browser_agent.vision")
15
+
16
+ VISION_MODELS = (
17
+ "gemini-2.5-flash",
18
+ "gemini-2.5-flash-lite",
19
+ "gemini-3-flash-preview",
20
+ )
21
+
22
+ SYSTEM_PROMPT = """You are a Linux X11 desktop browser agent controlling a real Chrome window (resolution 1280x720).
23
+ Given the user's goal, current URL, action history, and current 1280x720 screenshot, return ONLY a JSON object with the next action:
24
+ {
25
+ "thought": "Brief reasoning about what is visible on screen and what to do next",
26
+ "action": "click" | "type" | "scroll" | "key" | "wait" | "done",
27
+ "x": 640, // integer X coordinate (0..1280) for click/scroll
28
+ "y": 360, // integer Y coordinate (0..720) for click/scroll
29
+ "text": "...", // text to type when action == "type"
30
+ "submit": true, // press Enter after typing when action == "type"
31
+ "direction": "down", // "down" or "up" when action == "scroll"
32
+ "key": "Return", // X11 key name when action == "key" (e.g. "Return", "Escape", "Tab", "Page_Down")
33
+ "result": "..." // final extracted answer or summary when action == "done"
34
+ }"""
35
+
36
+
37
+ def _collect_api_keys() -> list[str]:
38
+ keys: list[str] = []
39
+ for var in ("GEMINI_API_KEY", "LLM_API_KEY", "GEMINI_API_KEYS", *[f"GEMINI_API_KEY_{i}" for i in range(1, 11)]):
40
+ val = os.environ.get(var, "").strip()
41
+ if val:
42
+ for part in re.split(r"[\s,;]+", val):
43
+ if part and part not in keys:
44
+ keys.append(part)
45
+ return keys
46
+
47
+
48
+ def _call_gemini_vision(screenshot_png: bytes, prompt_text: str, keys: list[str]) -> dict:
49
+ if not keys:
50
+ raise RuntimeError(
51
+ "No Gemini API key found in environment (set GEMINI_API_KEY or GEMINI_API_KEYS in .env)."
52
+ )
53
+ b64_img = base64.b64encode(screenshot_png).decode("ascii")
54
+ payload = json.dumps({
55
+ "contents": [{
56
+ "parts": [
57
+ {"text": f"{SYSTEM_PROMPT}\n\n{prompt_text}"},
58
+ {"inline_data": {"mime_type": "image/png", "data": b64_img}},
59
+ ],
60
+ }],
61
+ "generationConfig": {
62
+ "temperature": 0.2,
63
+ "maxOutputTokens": 1024,
64
+ "responseMimeType": "application/json",
65
+ },
66
+ }).encode("utf-8")
67
+
68
+ last_err = ""
69
+ for key in keys:
70
+ for model in VISION_MODELS:
71
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
72
+ req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=25) as resp:
75
+ body = json.loads(resp.read().decode("utf-8"))
76
+ parts = (body.get("candidates") or [])[0]["content"]["parts"]
77
+ raw_text = "".join(p.get("text", "") for p in parts).strip()
78
+ cleaned = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", raw_text)
79
+ obj, _ = json.JSONDecoder().raw_decode(cleaned.lstrip())
80
+ if isinstance(obj, dict):
81
+ return obj
82
+ except Exception as e: # noqa: BLE001
83
+ last_err = f"{model}: {e}"
84
+ continue
85
+ raise RuntimeError(f"All Gemini Vision calls failed ({last_err})")
86
+
87
+
88
+ def run_vision_agent(
89
+ session: RealChromeSession,
90
+ url: str,
91
+ goal: str,
92
+ max_steps: int = 8,
93
+ use_proxy: bool | None = None,
94
+ ) -> dict:
95
+ """Navigate to `url` and execute a Vision + X11 mouse/keyboard loop until `goal` is achieved."""
96
+ keys = _collect_api_keys()
97
+ session.start(use_proxy=use_proxy)
98
+
99
+ if url:
100
+ cdp = session._connect_page_cdp()
101
+ try:
102
+ cdp.call("Page.navigate", {"url": url})
103
+ finally:
104
+ cdp.close()
105
+ time.sleep(3.0)
106
+ session.solve_cloudflare_if_present()
107
+
108
+ history: list[dict] = []
109
+ final_result = ""
110
+
111
+ for step in range(1, max(1, min(20, int(max_steps))) + 1):
112
+ session.last_used = time.time()
113
+ cur_url, title, _ = session.get_html_and_title()
114
+ screenshot = session.x11.screenshot_bytes()
115
+
116
+ prompt = (
117
+ f"Goal: {goal}\n"
118
+ f"Current URL: {cur_url}\n"
119
+ f"Page Title: {title}\n"
120
+ f"Step: {step}/{max_steps}\n"
121
+ f"Previous actions: {json.dumps(history[-5:], ensure_ascii=False)}"
122
+ )
123
+ decision = _call_gemini_vision(screenshot, prompt, keys)
124
+ action = str(decision.get("action") or "done").lower()
125
+ thought = str(decision.get("thought") or "")
126
+ log.info("[Vision step %d/%d] action=%s thought=%s", step, max_steps, action, thought)
127
+ history.append({"step": step, **decision})
128
+
129
+ if action == "done":
130
+ final_result = str(decision.get("result") or thought)
131
+ break
132
+ if action == "click":
133
+ x = int(decision.get("x") or session.x11.width // 2)
134
+ y = int(decision.get("y") or session.x11.height // 2)
135
+ session.x11.click(x, y)
136
+ time.sleep(2.0)
137
+ elif action == "type":
138
+ if decision.get("x") is not None and decision.get("y") is not None:
139
+ session.x11.click(int(decision["x"]), int(decision["y"]))
140
+ time.sleep(0.3)
141
+ session.x11.type_text(str(decision.get("text") or ""), submit=bool(decision.get("submit", True)))
142
+ time.sleep(2.0)
143
+ elif action == "scroll":
144
+ session.x11.scroll(
145
+ clicks=int(decision.get("clicks") or 5),
146
+ direction=str(decision.get("direction") or "down"),
147
+ x=int(decision.get("x") or session.x11.width // 2),
148
+ y=int(decision.get("y") or session.x11.height // 2),
149
+ )
150
+ time.sleep(1.2)
151
+ elif action == "key":
152
+ session.x11.press_key(str(decision.get("key") or "Return"))
153
+ time.sleep(1.2)
154
+ elif action == "wait":
155
+ time.sleep(min(5.0, float(decision.get("seconds") or 2.0)))
156
+
157
+ cur_url, title, html = session.get_html_and_title()
158
+ return {
159
+ "ok": True,
160
+ "url": cur_url,
161
+ "title": title,
162
+ "result": final_result or (history[-1].get("thought", "") if history else ""),
163
+ "steps": history,
164
+ "text": _extract_text_from_html(html, cur_url)[:8000],
165
+ }
@@ -0,0 +1,273 @@
1
+ """Hardware-level X11 display, window manager, screenshot, and human-like mouse/keyboard control."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import math
6
+ import os
7
+ import random
8
+ import shutil
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+
13
+ log = logging.getLogger("browser_agent.x11")
14
+
15
+
16
+ def bezier_curve(
17
+ start: tuple[int, int],
18
+ end: tuple[int, int],
19
+ steps: int | None = None,
20
+ spread: float = 0.18,
21
+ ) -> list[tuple[int, int]]:
22
+ """Generate a human-like cubic Bezier mouse path with ease-in/out pacing and micro-jitter."""
23
+ x0, y0 = start
24
+ x3, y3 = end
25
+ dist = math.hypot(x3 - x0, y3 - y0)
26
+ if dist < 2:
27
+ return [(x3, y3)]
28
+
29
+ if steps is None:
30
+ steps = max(8, min(45, int(dist / 18)))
31
+
32
+ dx, dy = x3 - x0, y3 - y0
33
+ perp_x, perp_y = -dy, dx
34
+ offset1 = random.uniform(-spread, spread)
35
+ offset2 = random.uniform(-spread, spread)
36
+
37
+ x1 = x0 + dx * random.uniform(0.2, 0.35) + perp_x * offset1
38
+ y1 = y0 + dy * random.uniform(0.2, 0.35) + perp_y * offset1
39
+ x2 = x0 + dx * random.uniform(0.65, 0.85) + perp_x * offset2
40
+ y2 = y0 + dy * random.uniform(0.65, 0.85) + perp_y * offset2
41
+
42
+ points: list[tuple[int, int]] = []
43
+ for i in range(1, steps + 1):
44
+ raw_t = i / steps
45
+ # Smoothstep ease-in / ease-out velocity profile
46
+ t = raw_t * raw_t * (3.0 - 2.0 * raw_t)
47
+ u = 1.0 - t
48
+ bx = (u**3) * x0 + 3 * (u**2) * t * x1 + 3 * u * (t**2) * x2 + (t**3) * x3
49
+ by = (u**3) * y0 + 3 * (u**2) * t * y1 + 3 * u * (t**2) * y2 + (t**3) * y3
50
+ if i < steps:
51
+ bx += random.gauss(0, 0.6)
52
+ by += random.gauss(0, 0.6)
53
+ pt = (max(0, int(round(bx))), max(0, int(round(by))))
54
+ if not points or points[-1] != pt:
55
+ points.append(pt)
56
+
57
+ if points[-1] != (x3, y3):
58
+ points.append((x3, y3))
59
+ return points
60
+
61
+
62
+ class X11Display:
63
+ """Manages an on-demand Xvfb virtual screen, openbox window manager, and xdotool input."""
64
+
65
+ def __init__(self, display: str = ":99", width: int = 1280, height: int = 720, depth: int = 24):
66
+ self.display = display
67
+ self.width = width
68
+ self.height = height
69
+ self.depth = depth
70
+ self._xvfb_proc: subprocess.Popen | None = None
71
+ self._wm_proc: subprocess.Popen | None = None
72
+ self._vnc_proc: subprocess.Popen | None = None
73
+ self.mouse_pos: tuple[int, int] = (width // 2, height // 2)
74
+
75
+ @property
76
+ def env(self) -> dict[str, str]:
77
+ env = os.environ.copy()
78
+ env["DISPLAY"] = self.display
79
+ return env
80
+
81
+ def is_running(self) -> bool:
82
+ return self._xvfb_proc is not None and self._xvfb_proc.poll() is None
83
+
84
+ def start(self) -> None:
85
+ if self.is_running():
86
+ return
87
+ display_num = self.display.lstrip(":")
88
+ lock_file = Path(f"/tmp/.X{display_num}-lock")
89
+ socket_file = Path(f"/tmp/.X11-unix/X{display_num}")
90
+ if lock_file.exists():
91
+ try:
92
+ lock_file.unlink()
93
+ except OSError:
94
+ pass
95
+ if socket_file.exists():
96
+ try:
97
+ socket_file.unlink()
98
+ except OSError:
99
+ pass
100
+
101
+ self._xvfb_proc = subprocess.Popen(
102
+ [
103
+ "Xvfb",
104
+ self.display,
105
+ "-screen",
106
+ "0",
107
+ f"{self.width}x{self.height}x{self.depth}",
108
+ "-ac",
109
+ "-nolisten",
110
+ "tcp",
111
+ "-dpi",
112
+ "96",
113
+ ],
114
+ stdout=subprocess.DEVNULL,
115
+ stderr=subprocess.PIPE,
116
+ )
117
+ time.sleep(0.4)
118
+ if self._xvfb_proc.poll() is not None:
119
+ err = (self._xvfb_proc.stderr.read().decode("utf-8", errors="replace") if self._xvfb_proc.stderr else "").strip()
120
+ raise RuntimeError(f"Xvfb failed to start on {self.display} (rc={self._xvfb_proc.returncode}): {err[:300]}")
121
+ if shutil.which("openbox"):
122
+ self._wm_proc = subprocess.Popen(
123
+ ["openbox"],
124
+ env=self.env,
125
+ stdout=subprocess.DEVNULL,
126
+ stderr=subprocess.DEVNULL,
127
+ )
128
+ time.sleep(0.2)
129
+ log.info("X11 display %s started (%dx%d)", self.display, self.width, self.height)
130
+
131
+ def stop(self) -> None:
132
+ self.stop_vnc()
133
+ for proc in (self._wm_proc, self._xvfb_proc):
134
+ if proc and proc.poll() is None:
135
+ proc.terminate()
136
+ try:
137
+ proc.wait(timeout=3)
138
+ except subprocess.TimeoutExpired:
139
+ proc.kill()
140
+ self._wm_proc = None
141
+ self._xvfb_proc = None
142
+
143
+ def start_vnc(self, port: int = 5900) -> bool:
144
+ """Start localhost-only x11vnc for live debugging over SSH tunnel."""
145
+ self.start()
146
+ if self._vnc_proc and self._vnc_proc.poll() is None:
147
+ return True
148
+ if not shutil.which("x11vnc"):
149
+ return False
150
+ self._vnc_proc = subprocess.Popen(
151
+ [
152
+ "x11vnc",
153
+ "-display",
154
+ self.display,
155
+ "-localhost",
156
+ "-nopw",
157
+ "-forever",
158
+ "-shared",
159
+ "-noxdamage",
160
+ "-rfbport",
161
+ str(port),
162
+ "-quiet",
163
+ ],
164
+ stdout=subprocess.DEVNULL,
165
+ stderr=subprocess.DEVNULL,
166
+ )
167
+ return True
168
+
169
+ def stop_vnc(self) -> None:
170
+ if self._vnc_proc and self._vnc_proc.poll() is None:
171
+ self._vnc_proc.terminate()
172
+ try:
173
+ self._vnc_proc.wait(timeout=2)
174
+ except subprocess.TimeoutExpired:
175
+ self._vnc_proc.kill()
176
+ self._vnc_proc = None
177
+
178
+ def _xdotool(self, *args: str, timeout: float = 10.0) -> str:
179
+ self.start()
180
+ res = subprocess.run(
181
+ ["xdotool", *args],
182
+ env=self.env,
183
+ capture_output=True,
184
+ text=True,
185
+ timeout=timeout,
186
+ check=False,
187
+ )
188
+ return res.stdout.strip()
189
+
190
+ def move_mouse(self, x: int, y: int, duration: float | None = None) -> tuple[int, int]:
191
+ """Move the real X11 cursor along a human-like Bezier trajectory to (x, y)."""
192
+ x = max(1, min(self.width - 2, int(x)))
193
+ y = max(1, min(self.height - 2, int(y)))
194
+ dist = math.hypot(x - self.mouse_pos[0], y - self.mouse_pos[1])
195
+ if dist < 2:
196
+ self._xdotool("mousemove", str(x), str(y))
197
+ self.mouse_pos = (x, y)
198
+ return self.mouse_pos
199
+
200
+ path = bezier_curve(self.mouse_pos, (x, y))
201
+ if duration is None:
202
+ duration = max(0.08, min(0.45, dist / 1600.0 + random.uniform(0.03, 0.10)))
203
+
204
+ step_delay = duration / max(1, len(path))
205
+ for px, py in path:
206
+ self._xdotool("mousemove", str(px), str(py))
207
+ time.sleep(max(0.003, step_delay * random.uniform(0.75, 1.25)))
208
+
209
+ self.mouse_pos = (x, y)
210
+ return self.mouse_pos
211
+
212
+ def click(
213
+ self,
214
+ x: int | None = None,
215
+ y: int | None = None,
216
+ button: int = 1,
217
+ double: bool = False,
218
+ jitter: int = 2,
219
+ ) -> tuple[int, int]:
220
+ """Move humanly to (x, y) if given and perform a hardware-level X11 mouse click."""
221
+ if x is not None and y is not None:
222
+ tx = int(x) + (random.randint(-jitter, jitter) if jitter > 0 else 0)
223
+ ty = int(y) + (random.randint(-jitter, jitter) if jitter > 0 else 0)
224
+ self.move_mouse(tx, ty)
225
+
226
+ time.sleep(random.uniform(0.03, 0.08))
227
+ clicks = 2 if double else 1
228
+ for i in range(clicks):
229
+ self._xdotool("mousedown", str(button))
230
+ time.sleep(random.uniform(0.055, 0.125))
231
+ self._xdotool("mouseup", str(button))
232
+ if i + 1 < clicks:
233
+ time.sleep(random.uniform(0.06, 0.11))
234
+ return self.mouse_pos
235
+
236
+ def scroll(self, clicks: int = 4, direction: str = "down", x: int | None = None, y: int | None = None) -> None:
237
+ if x is not None and y is not None:
238
+ self.move_mouse(x, y)
239
+ btn = "5" if direction.lower() in ("down", "d") else "4"
240
+ for _ in range(max(1, abs(int(clicks)))):
241
+ self._xdotool("click", btn)
242
+ time.sleep(random.uniform(0.04, 0.09))
243
+
244
+ def type_text(self, text: str, submit: bool = False) -> None:
245
+ delay_ms = random.randint(35, 75)
246
+ self._xdotool("type", "--clearmodifiers", "--delay", str(delay_ms), "--", text, timeout=30.0)
247
+ if submit:
248
+ time.sleep(random.uniform(0.08, 0.18))
249
+ self.press_key("Return")
250
+
251
+ def press_key(self, key: str) -> None:
252
+ self._xdotool("key", "--clearmodifiers", key)
253
+
254
+ def screenshot_bytes(self) -> bytes:
255
+ """Capture X11 screen via scrot into /tmp and immediately delete the temporary file."""
256
+ self.start()
257
+ tmp_path = Path(f"/tmp/ba_screen_{os.getpid()}_{int(time.time() * 1000)}.png")
258
+ try:
259
+ subprocess.run(
260
+ ["scrot", "--overwrite", str(tmp_path)],
261
+ env=self.env,
262
+ stdout=subprocess.DEVNULL,
263
+ stderr=subprocess.DEVNULL,
264
+ timeout=10,
265
+ check=True,
266
+ )
267
+ return tmp_path.read_bytes()
268
+ finally:
269
+ if tmp_path.exists():
270
+ try:
271
+ tmp_path.unlink()
272
+ except OSError:
273
+ pass