persistent-browser-bridge 0.1.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,139 @@
1
+ """Compact, safe DOM snapshots for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from playwright.async_api import Page
10
+
11
+ SNAPSHOT_SCRIPT = r"""
12
+ () => {
13
+ const visible = (el) => {
14
+ const s = getComputedStyle(el), r = el.getBoundingClientRect();
15
+ return s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0' && r.width > 0 && r.height > 0;
16
+ };
17
+ const exposed = (el) => {
18
+ if (!visible(el)) return false;
19
+ const r = el.getBoundingClientRect();
20
+ if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth) return false;
21
+ const x = Math.max(0, Math.min(innerWidth - 1, r.left + r.width / 2));
22
+ const y = Math.max(0, Math.min(innerHeight - 1, r.top + r.height / 2));
23
+ const top = document.elementFromPoint(x, y);
24
+ return !!top && (el.contains(top) || top.contains(el));
25
+ };
26
+ const clean = (v) => (v || '').replace(/\s+/g, ' ').trim();
27
+ const selector = (el) => {
28
+ if (el.id && CSS.escape) return '#' + CSS.escape(el.id);
29
+ const parts = [];
30
+ let node = el;
31
+ while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.body) {
32
+ let part = node.tagName.toLowerCase();
33
+ const parent = node.parentElement;
34
+ if (parent) {
35
+ const siblings = [...parent.children].filter(x => x.tagName === node.tagName);
36
+ if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
37
+ }
38
+ parts.unshift(part); node = parent;
39
+ if (parts.length >= 6) break;
40
+ }
41
+ return parts.join(' > ');
42
+ };
43
+ const roleOf = (el) => {
44
+ if (el.getAttribute('role')) return el.getAttribute('role');
45
+ const tag = el.tagName.toLowerCase(), type = (el.getAttribute('type') || '').toLowerCase();
46
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
47
+ if (tag === 'button' || type === 'button' || type === 'submit' || type === 'reset') return 'button';
48
+ if (tag === 'textarea' || (tag === 'input' && !['button','submit','reset','checkbox','radio','file','hidden'].includes(type))) return 'textbox';
49
+ if (type === 'checkbox') return 'checkbox';
50
+ if (type === 'radio') return 'radio';
51
+ if (tag === 'select') return 'combobox';
52
+ return tag;
53
+ };
54
+ const nameOf = (el) => {
55
+ const aria = el.getAttribute('aria-label'); if (aria) return clean(aria);
56
+ const labelled = el.getAttribute('aria-labelledby');
57
+ if (labelled) return clean(labelled.split(/\s+/).map(id => document.getElementById(id)?.innerText || '').join(' '));
58
+ if (el.labels?.length) return clean([...el.labels].map(x => x.innerText).join(' '));
59
+ return clean(el.innerText || el.getAttribute('alt') || el.getAttribute('title') || el.getAttribute('name') || el.getAttribute('placeholder'));
60
+ };
61
+ const candidates = [...document.querySelectorAll('a[href],button,input:not([type=hidden]),textarea,select,[role],[contenteditable=true]')]
62
+ .filter(visible).slice(0, 500);
63
+ const elements = candidates.map((el, index) => {
64
+ const type = (el.getAttribute('type') || '').toLowerCase();
65
+ const item = {id:index + 1, role:roleOf(el), name:nameOf(el), selector:selector(el)};
66
+ for (const [key, value] of [['placeholder',el.getAttribute('placeholder')],['href',el.getAttribute('href')],['type',type]]) {
67
+ if (value) item[key] = clean(value).slice(0, 500);
68
+ }
69
+ if (type !== 'password' && ['checkbox','radio'].includes(type)) item.checked = !!el.checked;
70
+ if (el.disabled) item.disabled = true;
71
+ return item;
72
+ });
73
+ const headings = [...document.querySelectorAll('h1,h2,h3')].filter(visible).slice(0, 30)
74
+ .map(el => ({level:Number(el.tagName[1]), text:clean(el.innerText).slice(0, 500)})).filter(x => x.text);
75
+ const root = document.querySelector('main') || document.body;
76
+ const blocks = [...root.querySelectorAll('p,li,dt,dd,blockquote,pre,article,[role=article],[role=status],[role=alert]')]
77
+ .filter(exposed).map(el => clean(el.innerText)).filter(Boolean);
78
+ const text = clean(blocks.length ? blocks.join(' ') : root.innerText).slice(0, 20000);
79
+ return {elements, headings, text};
80
+ }
81
+ """
82
+
83
+
84
+ @dataclass
85
+ class SnapshotStore:
86
+ snapshot_id: str = ""
87
+ page_url: str = ""
88
+ elements: dict[int, dict[str, Any]] | None = None
89
+
90
+ def update(self, url: str, elements: list[dict[str, Any]]) -> None:
91
+ digest = hashlib.sha256((url + repr(elements)).encode()).hexdigest()[:10]
92
+ self.snapshot_id = f"s_{digest}"
93
+ self.page_url = url
94
+ self.elements = {int(item["id"]): item for item in elements}
95
+
96
+ def get(self, element_id: int) -> dict[str, Any] | None:
97
+ return (self.elements or {}).get(element_id)
98
+
99
+
100
+ async def capture_snapshot(page: Page, store: SnapshotStore, max_chars: int) -> dict[str, Any]:
101
+ raw = await page.evaluate(SNAPSHOT_SCRIPT)
102
+ elements = raw["elements"]
103
+ store.update(page.url, elements)
104
+ public_keys = {"id", "role", "name", "placeholder", "href", "type", "checked", "disabled"}
105
+ public_elements = [{k: v for k, v in item.items() if k in public_keys} for item in elements]
106
+ return {
107
+ "success": True,
108
+ "snapshot_id": store.snapshot_id,
109
+ "url": page.url,
110
+ "title": await page.title(),
111
+ "elements": public_elements,
112
+ "headings": raw["headings"],
113
+ "text": raw["text"][:max_chars],
114
+ "truncated": len(raw["text"]) > max_chars,
115
+ }
116
+
117
+
118
+ def format_snapshot(data: dict[str, Any]) -> str:
119
+ lines = [
120
+ "PAGE",
121
+ f"Title: {data['title']}",
122
+ f"URL: {data['url']}",
123
+ f"Snapshot: {data['snapshot_id']}",
124
+ "",
125
+ "INTERACTIVE ELEMENTS",
126
+ ]
127
+ for item in data["elements"]:
128
+ lines.append(f"[{item['id']}] {item['role']}")
129
+ for key in ("name", "placeholder", "href", "type", "checked", "disabled"):
130
+ if key in item and item[key] not in {"", None}:
131
+ lines.append(f"{key}: {item[key]}")
132
+ lines.append("")
133
+ if data["headings"]:
134
+ lines.append("HEADINGS")
135
+ lines.extend(f"[H{x['level']}] {x['text']}" for x in data["headings"])
136
+ lines.append("")
137
+ if data["text"]:
138
+ lines.extend(("TEXT", data["text"]))
139
+ return "\n".join(lines).rstrip()
pbb/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ """Agent-friendly command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from pbb.browser.browser_detection import detect_browsers
19
+ from pbb.browser.locks import ProfileLock, ProfileLockedError
20
+ from pbb.browser.profile import create_profile, delete_profile, list_profiles, profile_path
21
+ from pbb.browser.snapshot import format_snapshot
22
+ from pbb.config import config_path, load_settings
23
+ from pbb.daemon.client import DaemonClient, DaemonUnavailable
24
+ from pbb.utils.paths import logs_dir, profiles_dir
25
+
26
+ app = typer.Typer(no_args_is_help=True, help="Persistent, DOM-first browser control for AI agents.")
27
+ profile_app = typer.Typer(help="Manage dedicated PBB browser profiles.")
28
+ app.add_typer(profile_app, name="profile")
29
+ console = Console(stderr=False)
30
+ error_console = Console(stderr=True)
31
+
32
+
33
+ def emit(data: dict[str, Any], as_json: bool, title: str | None = None) -> None:
34
+ if as_json:
35
+ # Keep agent output valid even when a Windows console uses a legacy code page.
36
+ sys.stdout.write(json.dumps(data, ensure_ascii=True, default=str) + "\n")
37
+ sys.stdout.flush()
38
+ if not data.get("success", False):
39
+ raise typer.Exit(1)
40
+ return
41
+ if not data.get("success", False):
42
+ error_console.print(f"[red]{data.get('error', 'error')}:[/red] {data.get('message', '')}")
43
+ if data.get("details"):
44
+ error_console.print_json(json.dumps(data["details"], default=str))
45
+ raise typer.Exit(1)
46
+ if title:
47
+ console.print(f"[bold]{title}[/bold]")
48
+ for key, value in data.items():
49
+ if key not in {"success", "details"} and value is not None:
50
+ console.print(f"{key.replace('_', ' ').title()}: {value}")
51
+
52
+
53
+ def call(path: str, payload: dict[str, Any] | None, as_json: bool, title: str) -> dict[str, Any]:
54
+ try:
55
+ data = DaemonClient(load_settings()).post(path, payload)
56
+ except DaemonUnavailable as exc:
57
+ data = {"success": False, "error": "daemon_not_running", "message": str(exc), "details": {}}
58
+ emit(data, as_json, title)
59
+ return data
60
+
61
+
62
+ def _port_available(host: str, port: int) -> bool:
63
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
64
+ try:
65
+ sock.bind((host, port))
66
+ return True
67
+ except OSError:
68
+ return False
69
+
70
+
71
+ @app.command()
72
+ def start(
73
+ profile: str = typer.Option(None, help="Dedicated PBB profile name."),
74
+ browser: str = typer.Option(None, help="edge, chrome, chromium, or auto."),
75
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
76
+ ) -> None:
77
+ """Start the local daemon and persistent browser."""
78
+ settings = load_settings()
79
+ selected_profile = profile or settings.default_profile
80
+ client = DaemonClient(settings)
81
+ try:
82
+ current = client.status()
83
+ current["message"] = "PBB already running"
84
+ emit(current, json_output, "PBB already running")
85
+ return
86
+ except DaemonUnavailable:
87
+ pass
88
+ if not _port_available(settings.daemon_host, settings.daemon_port):
89
+ emit(
90
+ {
91
+ "success": False,
92
+ "error": "port_in_use",
93
+ "message": f"Port {settings.daemon_port} is already in use",
94
+ "details": {},
95
+ },
96
+ json_output,
97
+ )
98
+ create_profile(selected_profile)
99
+ log_path = logs_dir() / "daemon.log"
100
+ log_handle = log_path.open("a", encoding="utf-8")
101
+ command = [sys.executable, "-m", "pbb.daemon.server", "--profile", selected_profile]
102
+ if browser:
103
+ command.extend(("--browser", browser))
104
+ creationflags = 0
105
+ if os.name == "nt":
106
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
107
+ process = subprocess.Popen(
108
+ command,
109
+ stdin=subprocess.DEVNULL,
110
+ stdout=log_handle,
111
+ stderr=log_handle,
112
+ close_fds=True,
113
+ creationflags=creationflags,
114
+ )
115
+ log_handle.close()
116
+ deadline = time.monotonic() + 30
117
+ last_error = "daemon startup timed out"
118
+ while time.monotonic() < deadline:
119
+ time.sleep(0.25)
120
+ try:
121
+ data = client.status()
122
+ emit(data, json_output, "PBB started")
123
+ return
124
+ except DaemonUnavailable as exc:
125
+ last_error = str(exc)
126
+ if process.poll() is not None:
127
+ last_error = f"daemon exited with code {process.returncode}"
128
+ break
129
+ emit(
130
+ {
131
+ "success": False,
132
+ "error": "daemon_start_failed",
133
+ "message": last_error,
134
+ "details": {"log": str(log_path)},
135
+ },
136
+ json_output,
137
+ )
138
+
139
+
140
+ @app.command()
141
+ def status(json_output: bool = typer.Option(False, "--json")) -> None:
142
+ """Show daemon, browser, profile, and current page status."""
143
+ try:
144
+ data = DaemonClient(load_settings()).status()
145
+ except DaemonUnavailable as exc:
146
+ data = {"success": False, "error": "daemon_not_running", "message": str(exc), "details": {}}
147
+ emit(data, json_output, "PBB Status")
148
+
149
+
150
+ @app.command("open")
151
+ def open_url(url: str, json_output: bool = typer.Option(False, "--json")) -> None:
152
+ """Navigate the active page to URL."""
153
+ call("/navigate", {"url": url}, json_output, "Page opened")
154
+
155
+
156
+ @app.command()
157
+ def snapshot(json_output: bool = typer.Option(False, "--json")) -> None:
158
+ """Capture a compact, safe DOM snapshot."""
159
+ try:
160
+ data = DaemonClient(load_settings()).post("/snapshot")
161
+ except DaemonUnavailable as exc:
162
+ data = {"success": False, "error": "daemon_not_running", "message": str(exc), "details": {}}
163
+ if data.get("success") and not json_output:
164
+ console.print(format_snapshot(data))
165
+ return
166
+ emit(data, json_output)
167
+
168
+
169
+ @app.command()
170
+ def click(target: str, json_output: bool = typer.Option(False, "--json")) -> None:
171
+ """Click a snapshot reference, selector, or semantic target."""
172
+ call("/click", {"target": target}, json_output, "Click complete")
173
+
174
+
175
+ @app.command()
176
+ def fill(target: str, value: str, json_output: bool = typer.Option(False, "--json")) -> None:
177
+ """Fill an input without echoing its value in output."""
178
+ call("/fill", {"target": target, "value": value}, json_output, "Fill complete")
179
+
180
+
181
+ @app.command()
182
+ def text(target: str, json_output: bool = typer.Option(False, "--json")) -> None:
183
+ """Read visible text from a target."""
184
+ call("/text", {"target": target}, json_output, "Text")
185
+
186
+
187
+ @app.command()
188
+ def download(
189
+ target: str,
190
+ output: Path = typer.Option(None, help="Destination directory or file."),
191
+ json_output: bool = typer.Option(False, "--json"),
192
+ ) -> None:
193
+ """Click a target and save the resulting Playwright download."""
194
+ call(
195
+ "/download",
196
+ {"target": target, "output": str(output) if output else None},
197
+ json_output,
198
+ "Download complete",
199
+ )
200
+
201
+
202
+ @app.command()
203
+ def screenshot(
204
+ output: Path = typer.Argument(Path("pbb-screenshot.png")),
205
+ full_page: bool = typer.Option(False, "--full-page"),
206
+ json_output: bool = typer.Option(False, "--json"),
207
+ ) -> None:
208
+ """Save a screenshot as a secondary inspection aid."""
209
+ call(
210
+ "/screenshot",
211
+ {"output": str(output), "full_page": full_page},
212
+ json_output,
213
+ "Screenshot saved",
214
+ )
215
+
216
+
217
+ @app.command()
218
+ def close(json_output: bool = typer.Option(False, "--json")) -> None:
219
+ """Close the browser and stop the daemon."""
220
+ call("/shutdown", {}, json_output, "PBB closed")
221
+
222
+
223
+ @app.command()
224
+ def shutdown(json_output: bool = typer.Option(False, "--json")) -> None:
225
+ """Alias for close."""
226
+ close(json_output)
227
+
228
+
229
+ @app.command()
230
+ def recover(json_output: bool = typer.Option(False, "--json")) -> None:
231
+ """Remove stale PBB-owned profile locks only."""
232
+ cleared: list[str] = []
233
+ for name in list_profiles():
234
+ lock = ProfileLock(profile_path(name), name)
235
+ if lock.clear_stale():
236
+ cleared.append(name)
237
+ emit({"success": True, "cleared_profiles": cleared}, json_output, "Recovery complete")
238
+
239
+
240
+ @app.command()
241
+ def doctor(json_output: bool = typer.Option(False, "--json")) -> None:
242
+ """Check Python, browsers, directories, and daemon port."""
243
+ settings = load_settings()
244
+ browsers = [item.as_dict() for item in detect_browsers()]
245
+ result = {
246
+ "success": True,
247
+ "python": sys.version.split()[0],
248
+ "playwright": "installed",
249
+ "browsers": browsers,
250
+ "profiles_directory": str(profiles_dir()),
251
+ "config": str(config_path()),
252
+ "daemon_port": settings.daemon_port,
253
+ "port_available": _port_available(settings.daemon_host, settings.daemon_port),
254
+ }
255
+ emit(result, json_output, "Persistent Browser Bridge Doctor")
256
+
257
+
258
+ @profile_app.command("create")
259
+ def profile_create(name: str, json_output: bool = typer.Option(False, "--json")) -> None:
260
+ path = create_profile(name)
261
+ emit({"success": True, "profile": name, "path": str(path)}, json_output, "Profile ready")
262
+
263
+
264
+ @profile_app.command("list")
265
+ def profile_list(json_output: bool = typer.Option(False, "--json")) -> None:
266
+ names = list_profiles()
267
+ if json_output:
268
+ emit({"success": True, "profiles": names}, True)
269
+ return
270
+ table = Table(title="PBB Profiles")
271
+ table.add_column("Name")
272
+ table.add_column("Path")
273
+ for name in names:
274
+ table.add_row(name, str(profile_path(name)))
275
+ console.print(table)
276
+
277
+
278
+ @profile_app.command("path")
279
+ def profile_show_path(name: str) -> None:
280
+ console.print(str(profile_path(name)))
281
+
282
+
283
+ @profile_app.command("delete")
284
+ def profile_delete(
285
+ name: str,
286
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm deletion."),
287
+ json_output: bool = typer.Option(False, "--json"),
288
+ ) -> None:
289
+ if not yes and not typer.confirm(f"Delete PBB profile '{name}' and its browser data?"):
290
+ raise typer.Abort()
291
+ try:
292
+ lock = ProfileLock(profile_path(name), name)
293
+ if lock.path.exists() and not lock.is_stale():
294
+ raise ProfileLockedError(f"Profile '{name}' is currently in use")
295
+ delete_profile(name)
296
+ emit({"success": True, "deleted": name}, json_output, "Profile deleted")
297
+ except (FileNotFoundError, ProfileLockedError) as exc:
298
+ emit(
299
+ {"success": False, "error": "profile_locked", "message": str(exc), "details": {}},
300
+ json_output,
301
+ )
302
+
303
+
304
+ if __name__ == "__main__":
305
+ app()
pbb/config.py ADDED
@@ -0,0 +1,66 @@
1
+ """Configuration loading with TOML and environment overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field, field_validator
11
+
12
+ from pbb.constants import DEFAULT_HOST, DEFAULT_PORT
13
+ from pbb.utils.paths import config_dir, default_download_dir, ensure_app_dirs
14
+
15
+
16
+ class Settings(BaseModel):
17
+ browser: str = "auto"
18
+ default_profile: str = "default"
19
+ daemon_host: str = DEFAULT_HOST
20
+ daemon_port: int = Field(DEFAULT_PORT, ge=1024, le=65535)
21
+ download_dir: Path = Field(default_factory=default_download_dir)
22
+ timeout: int = Field(15_000, ge=1000, le=300_000)
23
+ headless: bool = False
24
+ snapshot_max_chars: int = Field(5000, ge=500, le=100_000)
25
+
26
+ @field_validator("daemon_host")
27
+ @classmethod
28
+ def localhost_only(cls, value: str) -> str:
29
+ if value not in {"127.0.0.1", "localhost", "::1"}:
30
+ raise ValueError("v0.1 only permits a loopback daemon host")
31
+ return value
32
+
33
+ @field_validator("browser")
34
+ @classmethod
35
+ def known_browser(cls, value: str) -> str:
36
+ normalized = value.lower()
37
+ if normalized not in {"edge", "chrome", "chromium", "auto"}:
38
+ raise ValueError("browser must be edge, chrome, chromium, or auto")
39
+ return normalized
40
+
41
+
42
+ ENV_MAP = {
43
+ "PBB_BROWSER": ("browser", str),
44
+ "PBB_PROFILE": ("default_profile", str),
45
+ "PBB_PORT": ("daemon_port", int),
46
+ "PBB_DOWNLOAD_DIR": ("download_dir", Path),
47
+ "PBB_TIMEOUT": ("timeout", int),
48
+ }
49
+
50
+
51
+ def config_path() -> Path:
52
+ return config_dir() / "config.toml"
53
+
54
+
55
+ def load_settings() -> Settings:
56
+ ensure_app_dirs()
57
+ values: dict[str, Any] = {}
58
+ path = config_path()
59
+ if path.exists():
60
+ with path.open("rb") as handle:
61
+ values.update(tomllib.load(handle))
62
+ for env_name, (field_name, converter) in ENV_MAP.items():
63
+ raw = os.getenv(env_name)
64
+ if raw is not None:
65
+ values[field_name] = converter(raw)
66
+ return Settings.model_validate(values)
pbb/constants.py ADDED
@@ -0,0 +1,7 @@
1
+ """Project-wide constants."""
2
+
3
+ APP_NAME = "PersistentBrowserBridge"
4
+ APP_AUTHOR = "PersistentBrowserBridge"
5
+ DEFAULT_HOST = "127.0.0.1"
6
+ DEFAULT_PORT = 8765
7
+ __version__ = "0.1.0"
pbb/daemon/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Local-only browser daemon."""
pbb/daemon/app.py ADDED
@@ -0,0 +1,137 @@
1
+ """FastAPI application served only on loopback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import JSONResponse
13
+ from playwright.async_api import Error as PlaywrightError
14
+ from pydantic import BaseModel
15
+
16
+ from pbb.browser.locator import LocatorResolutionError
17
+ from pbb.browser.manager import BrowserManager
18
+ from pbb.config import Settings
19
+
20
+
21
+ class NavigateRequest(BaseModel):
22
+ url: str
23
+
24
+
25
+ class TargetRequest(BaseModel):
26
+ target: str
27
+
28
+
29
+ class FillRequest(TargetRequest):
30
+ value: str
31
+
32
+
33
+ class DownloadRequest(TargetRequest):
34
+ output: str | None = None
35
+
36
+
37
+ class ScreenshotRequest(BaseModel):
38
+ output: str = "pbb-screenshot.png"
39
+ full_page: bool = False
40
+
41
+
42
+ def error(code: str, message: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
43
+ return {"success": False, "error": code, "message": message, "details": details or {}}
44
+
45
+
46
+ def create_app(settings: Settings, profile: str, browser: str | None = None) -> FastAPI:
47
+ manager = BrowserManager(settings, profile, browser)
48
+
49
+ @asynccontextmanager
50
+ async def lifespan(_app: FastAPI):
51
+ await manager.start()
52
+ try:
53
+ yield
54
+ finally:
55
+ await manager.close()
56
+
57
+ app = FastAPI(
58
+ title="Persistent Browser Bridge",
59
+ version="0.1.0",
60
+ docs_url=None,
61
+ redoc_url=None,
62
+ lifespan=lifespan,
63
+ )
64
+ app.state.manager = manager
65
+ app.state.server = None
66
+
67
+ @app.exception_handler(LocatorResolutionError)
68
+ async def locator_error(_request: Request, exc: LocatorResolutionError) -> JSONResponse:
69
+ return JSONResponse(error(exc.code, str(exc), exc.details), status_code=400)
70
+
71
+ @app.exception_handler(PlaywrightError)
72
+ async def playwright_error(_request: Request, exc: PlaywrightError) -> JSONResponse:
73
+ message = str(exc)
74
+ lowered = message.lower()
75
+ code = "page_closed" if "closed" in lowered else "browser_disconnected"
76
+ return JSONResponse(error(code, message), status_code=409)
77
+
78
+ @app.exception_handler(Exception)
79
+ async def internal_error(_request: Request, exc: Exception) -> JSONResponse:
80
+ return JSONResponse(error("internal_error", str(exc)), status_code=500)
81
+
82
+ @app.get("/status")
83
+ async def status() -> dict[str, Any]:
84
+ result = await manager.status()
85
+ result.update({"daemon_running": True, "pid": os.getpid()})
86
+ return result
87
+
88
+ @app.post("/start")
89
+ async def start() -> dict[str, Any]:
90
+ await manager.start()
91
+ return await status()
92
+
93
+ @app.post("/navigate")
94
+ async def navigate(request: NavigateRequest) -> dict[str, Any]:
95
+ return await manager.navigate(request.url)
96
+
97
+ @app.post("/snapshot")
98
+ async def snapshot() -> dict[str, Any]:
99
+ return await manager.snapshot()
100
+
101
+ @app.post("/click")
102
+ async def click(request: TargetRequest) -> dict[str, Any]:
103
+ return await manager.click(request.target)
104
+
105
+ @app.post("/fill")
106
+ async def fill(request: FillRequest) -> dict[str, Any]:
107
+ return await manager.fill(request.target, request.value)
108
+
109
+ @app.post("/text")
110
+ async def text(request: TargetRequest) -> dict[str, Any]:
111
+ return await manager.text(request.target)
112
+
113
+ @app.post("/download")
114
+ async def download(request: DownloadRequest) -> dict[str, Any]:
115
+ output = Path(request.output) if request.output else None
116
+ return await manager.download(request.target, output)
117
+
118
+ @app.post("/screenshot")
119
+ async def screenshot(request: ScreenshotRequest) -> dict[str, Any]:
120
+ return await manager.screenshot(Path(request.output), request.full_page)
121
+
122
+ @app.post("/close")
123
+ async def close() -> dict[str, Any]:
124
+ await manager.close()
125
+ return {"success": True, "browser_running": False}
126
+
127
+ @app.post("/shutdown")
128
+ async def stop() -> dict[str, Any]:
129
+ async def request_exit() -> None:
130
+ await asyncio.sleep(0.1)
131
+ if app.state.server is not None:
132
+ app.state.server.should_exit = True
133
+
134
+ asyncio.create_task(request_exit())
135
+ return {"success": True, "message": "PBB daemon is shutting down"}
136
+
137
+ return app