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.
pbb/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Persistent Browser Bridge public package."""
2
+
3
+ from pbb.constants import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """Browser management primitives."""
@@ -0,0 +1,117 @@
1
+ """Detect installed Edge, Chrome, or Chromium browsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class BrowserInstall:
14
+ name: str
15
+ channel: str | None
16
+ executable: str | None
17
+
18
+ def as_dict(self) -> dict[str, str | None]:
19
+ return asdict(self)
20
+
21
+
22
+ def _windows_candidates() -> list[BrowserInstall]:
23
+ local = Path(os.environ.get("LOCALAPPDATA", ""))
24
+ return [
25
+ BrowserInstall(
26
+ "Microsoft Edge",
27
+ "msedge",
28
+ str(
29
+ Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)"))
30
+ / "Microsoft/Edge/Application/msedge.exe"
31
+ ),
32
+ ),
33
+ BrowserInstall(
34
+ "Microsoft Edge",
35
+ "msedge",
36
+ str(
37
+ Path(os.environ.get("PROGRAMFILES", "C:/Program Files"))
38
+ / "Microsoft/Edge/Application/msedge.exe"
39
+ ),
40
+ ),
41
+ BrowserInstall(
42
+ "Microsoft Edge", "msedge", str(local / "Microsoft/Edge/Application/msedge.exe")
43
+ ),
44
+ BrowserInstall(
45
+ "Google Chrome",
46
+ "chrome",
47
+ str(
48
+ Path(os.environ.get("PROGRAMFILES", "C:/Program Files"))
49
+ / "Google/Chrome/Application/chrome.exe"
50
+ ),
51
+ ),
52
+ BrowserInstall(
53
+ "Google Chrome",
54
+ "chrome",
55
+ str(
56
+ Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)"))
57
+ / "Google/Chrome/Application/chrome.exe"
58
+ ),
59
+ ),
60
+ BrowserInstall(
61
+ "Google Chrome", "chrome", str(local / "Google/Chrome/Application/chrome.exe")
62
+ ),
63
+ ]
64
+
65
+
66
+ def detect_browsers() -> list[BrowserInstall]:
67
+ candidates: list[BrowserInstall] = []
68
+ if sys.platform == "win32":
69
+ candidates.extend(_windows_candidates())
70
+ elif sys.platform == "darwin":
71
+ candidates.extend(
72
+ [
73
+ BrowserInstall(
74
+ "Microsoft Edge",
75
+ "msedge",
76
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
77
+ ),
78
+ BrowserInstall(
79
+ "Google Chrome",
80
+ "chrome",
81
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
82
+ ),
83
+ ]
84
+ )
85
+ else:
86
+ for name, channel, command in (
87
+ ("Microsoft Edge", "msedge", "microsoft-edge"),
88
+ ("Google Chrome", "chrome", "google-chrome"),
89
+ ("Chromium", None, "chromium"),
90
+ ("Chromium", None, "chromium-browser"),
91
+ ):
92
+ candidates.append(BrowserInstall(name, channel, shutil.which(command)))
93
+ seen: set[tuple[str, str]] = set()
94
+ found: list[BrowserInstall] = []
95
+ for item in candidates:
96
+ if item.executable and Path(item.executable).is_file():
97
+ key = (item.name, str(Path(item.executable).resolve()).lower())
98
+ if key not in seen:
99
+ seen.add(key)
100
+ found.append(item)
101
+ return found
102
+
103
+
104
+ def select_browser(preference: str) -> BrowserInstall:
105
+ installed = detect_browsers()
106
+ aliases = {"edge": "Microsoft Edge", "chrome": "Google Chrome", "chromium": "Chromium"}
107
+ if preference != "auto":
108
+ wanted = aliases[preference]
109
+ for browser in installed:
110
+ if browser.name == wanted:
111
+ return browser
112
+ if preference == "chromium":
113
+ return BrowserInstall("Playwright Chromium", None, None)
114
+ raise FileNotFoundError(f"Requested browser '{preference}' was not found")
115
+ if installed:
116
+ return installed[0]
117
+ return BrowserInstall("Playwright Chromium", None, None)
pbb/browser/locator.py ADDED
@@ -0,0 +1,103 @@
1
+ """Conservative target resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from playwright.async_api import Error as PlaywrightError
10
+ from playwright.async_api import Locator, Page
11
+
12
+ from pbb.browser.snapshot import SnapshotStore
13
+
14
+
15
+ class LocatorResolutionError(RuntimeError):
16
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
17
+ super().__init__(message)
18
+ self.code = code
19
+ self.details = details or {}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ResolvedLocator:
24
+ locator: Locator
25
+ metadata: dict[str, Any]
26
+
27
+
28
+ async def _unique(locator: Locator, label: str) -> ResolvedLocator | None:
29
+ count = await locator.count()
30
+ if count == 1:
31
+ return ResolvedLocator(locator.first, {"strategy": label})
32
+ if count > 1:
33
+ matches: list[dict[str, str]] = []
34
+ for index in range(min(count, 10)):
35
+ item = locator.nth(index)
36
+ matches.append(
37
+ {
38
+ "tag": await item.evaluate("el => el.tagName.toLowerCase()"),
39
+ "text": (await item.inner_text())[:200],
40
+ }
41
+ )
42
+ raise LocatorResolutionError(
43
+ "ambiguous_target",
44
+ f"Target matched {count} elements using {label}",
45
+ {"matches": matches},
46
+ )
47
+ return None
48
+
49
+
50
+ async def resolve_locator(page: Page, store: SnapshotStore, target: str) -> ResolvedLocator:
51
+ match = re.fullmatch(r"(?:(s_[0-9a-f]+):)?@(\d+)", target)
52
+ if match:
53
+ explicit_snapshot, raw_id = match.groups()
54
+ if explicit_snapshot and explicit_snapshot != store.snapshot_id:
55
+ raise LocatorResolutionError(
56
+ "stale_snapshot", "Snapshot reference is no longer current"
57
+ )
58
+ item = store.get(int(raw_id))
59
+ if item is None:
60
+ raise LocatorResolutionError(
61
+ "element_not_found", f"Snapshot element {target} was not found"
62
+ )
63
+ if page.url != store.page_url:
64
+ raise LocatorResolutionError("stale_snapshot", "Page URL changed after the snapshot")
65
+ locator = page.locator(item["selector"])
66
+ if await locator.count() != 1:
67
+ raise LocatorResolutionError(
68
+ "stale_snapshot", "Snapshot element changed or disappeared"
69
+ )
70
+ current = await locator.first.evaluate(
71
+ "el => ({name:(el.getAttribute('aria-label') || el.innerText || el.getAttribute('name') || el.getAttribute('placeholder') || '').replace(/\\s+/g,' ').trim(), role:el.getAttribute('role') || ''})"
72
+ )
73
+ expected_name = item.get("name", "")
74
+ if expected_name and current["name"] and expected_name != current["name"]:
75
+ raise LocatorResolutionError("stale_snapshot", "Snapshot element identity changed")
76
+ return ResolvedLocator(
77
+ locator.first, {"strategy": "snapshot", "snapshot_id": store.snapshot_id}
78
+ )
79
+
80
+ selector_error: PlaywrightError | None = None
81
+ try:
82
+ direct = await _unique(page.locator(target), "selector")
83
+ if direct:
84
+ return direct
85
+ except PlaywrightError as exc:
86
+ selector_error = exc
87
+ if selector_error and target.startswith(("#", ".", "[", "//", "css=", "xpath=")):
88
+ raise LocatorResolutionError(
89
+ "invalid_selector", f"Invalid selector: {target}"
90
+ ) from selector_error
91
+
92
+ strategies = [
93
+ (page.get_by_role("button", name=target, exact=True), "button role"),
94
+ (page.get_by_role("link", name=target, exact=True), "link role"),
95
+ (page.get_by_label(target, exact=True), "label"),
96
+ (page.get_by_placeholder(target, exact=True), "placeholder"),
97
+ (page.get_by_text(target, exact=True), "exact text"),
98
+ ]
99
+ for locator, label in strategies:
100
+ result = await _unique(locator, label)
101
+ if result:
102
+ return result
103
+ raise LocatorResolutionError("element_not_found", f"No element matched target: {target}")
pbb/browser/locks.py ADDED
@@ -0,0 +1,77 @@
1
+ """PBB-owned profile lock files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+
10
+ import psutil
11
+
12
+
13
+ class ProfileLockedError(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class LockInfo:
19
+ pid: int
20
+ start_time: float
21
+ profile: str
22
+
23
+
24
+ class ProfileLock:
25
+ def __init__(self, profile_path: Path, profile: str) -> None:
26
+ self.path = profile_path / "profile.lock"
27
+ self.profile = profile
28
+ self.acquired = False
29
+
30
+ def read(self) -> LockInfo | None:
31
+ try:
32
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
33
+ return LockInfo(**raw)
34
+ except (OSError, ValueError, TypeError):
35
+ return None
36
+
37
+ def is_stale(self) -> bool:
38
+ info = self.read()
39
+ if info is None:
40
+ return self.path.exists()
41
+ if not psutil.pid_exists(info.pid):
42
+ return True
43
+ try:
44
+ return abs(psutil.Process(info.pid).create_time() - info.start_time) > 2
45
+ except (psutil.Error, OSError):
46
+ return True
47
+
48
+ def acquire(self) -> None:
49
+ self.path.parent.mkdir(parents=True, exist_ok=True)
50
+ if self.path.exists():
51
+ info = self.read()
52
+ if not self.is_stale():
53
+ owner = info.pid if info else "unknown"
54
+ raise ProfileLockedError(
55
+ f"Profile '{self.profile}' is already in use by PID {owner}."
56
+ )
57
+ self.path.unlink(missing_ok=True)
58
+ info = LockInfo(os.getpid(), psutil.Process().create_time(), self.profile)
59
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
60
+ try:
61
+ descriptor = os.open(self.path, flags)
62
+ except FileExistsError as exc:
63
+ raise ProfileLockedError(f"Profile '{self.profile}' is already in use.") from exc
64
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
65
+ json.dump(asdict(info), handle)
66
+ self.acquired = True
67
+
68
+ def release(self) -> None:
69
+ if self.acquired:
70
+ self.path.unlink(missing_ok=True)
71
+ self.acquired = False
72
+
73
+ def clear_stale(self) -> bool:
74
+ if self.path.exists() and self.is_stale():
75
+ self.path.unlink()
76
+ return True
77
+ return False
pbb/browser/manager.py ADDED
@@ -0,0 +1,218 @@
1
+ """Own the Playwright persistent context for the daemon lifetime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from playwright.async_api import (
11
+ BrowserContext,
12
+ Error,
13
+ Page,
14
+ Playwright,
15
+ TimeoutError,
16
+ async_playwright,
17
+ )
18
+
19
+ from pbb.browser.browser_detection import BrowserInstall, select_browser
20
+ from pbb.browser.locator import LocatorResolutionError, resolve_locator
21
+ from pbb.browser.locks import ProfileLock
22
+ from pbb.browser.profile import create_profile
23
+ from pbb.browser.snapshot import SnapshotStore, capture_snapshot
24
+ from pbb.config import Settings
25
+
26
+
27
+ class BrowserManager:
28
+ def __init__(self, settings: Settings, profile: str, browser: str | None = None) -> None:
29
+ self.settings = settings
30
+ self.profile = profile
31
+ self.preference = browser or settings.browser
32
+ self.install: BrowserInstall | None = None
33
+ self.playwright: Playwright | None = None
34
+ self.context: BrowserContext | None = None
35
+ self.active_page: Page | None = None
36
+ self.store = SnapshotStore()
37
+ self.lock = ProfileLock(create_profile(profile), profile)
38
+ self.restarts = 0
39
+
40
+ async def start(self) -> None:
41
+ if self.context:
42
+ return
43
+ self.lock.acquire()
44
+ try:
45
+ self.install = select_browser(self.preference)
46
+ self.playwright = await async_playwright().start()
47
+ options: dict[str, Any] = {
48
+ "user_data_dir": str(create_profile(self.profile)),
49
+ "headless": self.settings.headless,
50
+ "accept_downloads": True,
51
+ "downloads_path": str(self.settings.download_dir),
52
+ "timeout": self.settings.timeout,
53
+ }
54
+ if self.install.channel:
55
+ options["channel"] = self.install.channel
56
+ elif self.install.executable:
57
+ options["executable_path"] = self.install.executable
58
+ self.context = await self.playwright.chromium.launch_persistent_context(**options)
59
+ self.context.set_default_timeout(self.settings.timeout)
60
+ self.active_page = (
61
+ self.context.pages[-1] if self.context.pages else await self.context.new_page()
62
+ )
63
+ except Exception:
64
+ self.lock.release()
65
+ if self.playwright:
66
+ await self.playwright.stop()
67
+ self.playwright = None
68
+ raise
69
+
70
+ async def close(self) -> None:
71
+ try:
72
+ if self.context:
73
+ await self.context.close()
74
+ finally:
75
+ self.context = None
76
+ self.active_page = None
77
+ if self.playwright:
78
+ await self.playwright.stop()
79
+ self.playwright = None
80
+ self.lock.release()
81
+
82
+ async def page(self) -> tuple[Page, bool]:
83
+ if not self.context:
84
+ await self.start()
85
+ assert self.active_page is not None
86
+ return self.active_page, True
87
+ assert self.context is not None
88
+ try:
89
+ if not self.active_page or self.active_page.is_closed():
90
+ pages = [page for page in self.context.pages if not page.is_closed()]
91
+ self.active_page = pages[-1] if pages else await self.context.new_page()
92
+ return self.active_page, True
93
+ return self.active_page, False
94
+ except Error:
95
+ await self.close()
96
+ self.restarts += 1
97
+ await self.start()
98
+ assert self.active_page is not None
99
+ return self.active_page, True
100
+
101
+ async def status(self) -> dict[str, Any]:
102
+ page = self.active_page
103
+ running = self.context is not None
104
+ restarted = False
105
+ if running and (page is None or page.is_closed()):
106
+ page, restarted = await self.page()
107
+ return {
108
+ "success": True,
109
+ "browser_running": running,
110
+ "browser": self.install.name if self.install else None,
111
+ "profile": self.profile,
112
+ "url": page.url if page and not page.is_closed() else None,
113
+ "title": await page.title() if page and not page.is_closed() else None,
114
+ "tabs": len(self.context.pages) if self.context else 0,
115
+ "browser_restarted": restarted,
116
+ }
117
+
118
+ async def navigate(self, url: str) -> dict[str, Any]:
119
+ page, restarted = await self.page()
120
+ started = time.perf_counter()
121
+ try:
122
+ await page.goto(url, wait_until="domcontentloaded", timeout=self.settings.timeout)
123
+ except TimeoutError as exc:
124
+ raise LocatorResolutionError(
125
+ "navigation_timeout", f"Navigation timed out: {url}"
126
+ ) from exc
127
+ return {
128
+ "success": True,
129
+ "url": page.url,
130
+ "title": await page.title(),
131
+ "duration_ms": round((time.perf_counter() - started) * 1000),
132
+ "browser_restarted": restarted,
133
+ }
134
+
135
+ async def snapshot(self) -> dict[str, Any]:
136
+ page, restarted = await self.page()
137
+ result = await capture_snapshot(page, self.store, self.settings.snapshot_max_chars)
138
+ result["browser_restarted"] = restarted
139
+ return result
140
+
141
+ async def click(self, target: str) -> dict[str, Any]:
142
+ page, restarted = await self.page()
143
+ resolved = await resolve_locator(page, self.store, target)
144
+ started = time.perf_counter()
145
+ await resolved.locator.click()
146
+ await asyncio.sleep(0.15)
147
+ return {
148
+ "success": True,
149
+ "action": "click",
150
+ "target": target,
151
+ "url": page.url,
152
+ "duration_ms": round((time.perf_counter() - started) * 1000),
153
+ "browser_restarted": restarted,
154
+ }
155
+
156
+ async def fill(self, target: str, value: str) -> dict[str, Any]:
157
+ page, restarted = await self.page()
158
+ resolved = await resolve_locator(page, self.store, target)
159
+ started = time.perf_counter()
160
+ await resolved.locator.fill(value)
161
+ input_type = await resolved.locator.get_attribute("type")
162
+ return {
163
+ "success": True,
164
+ "action": "fill",
165
+ "target": target,
166
+ "value_redacted": input_type == "password",
167
+ "url": page.url,
168
+ "duration_ms": round((time.perf_counter() - started) * 1000),
169
+ "browser_restarted": restarted,
170
+ }
171
+
172
+ async def text(self, target: str) -> dict[str, Any]:
173
+ page, restarted = await self.page()
174
+ resolved = await resolve_locator(page, self.store, target)
175
+ return {
176
+ "success": True,
177
+ "text": await resolved.locator.inner_text(),
178
+ "browser_restarted": restarted,
179
+ }
180
+
181
+ async def download(self, target: str, output: Path | None = None) -> dict[str, Any]:
182
+ page, restarted = await self.page()
183
+ resolved = await resolve_locator(page, self.store, target)
184
+ try:
185
+ async with page.expect_download(timeout=self.settings.timeout) as info:
186
+ await resolved.locator.click()
187
+ download = await info.value
188
+ except TimeoutError as exc:
189
+ raise LocatorResolutionError(
190
+ "download_not_triggered", "The target did not trigger a download before timeout"
191
+ ) from exc
192
+ destination = output or self.settings.download_dir
193
+ destination = destination.expanduser().resolve()
194
+ if destination.suffix and not destination.is_dir():
195
+ destination.parent.mkdir(parents=True, exist_ok=True)
196
+ saved_path = destination
197
+ else:
198
+ destination.mkdir(parents=True, exist_ok=True)
199
+ saved_path = destination / download.suggested_filename
200
+ await download.save_as(saved_path)
201
+ return {
202
+ "success": True,
203
+ "suggested_filename": download.suggested_filename,
204
+ "saved_path": str(saved_path),
205
+ "size": saved_path.stat().st_size,
206
+ "browser_restarted": restarted,
207
+ }
208
+
209
+ async def screenshot(self, output: Path, full_page: bool) -> dict[str, Any]:
210
+ page, restarted = await self.page()
211
+ path = output.expanduser().resolve()
212
+ path.parent.mkdir(parents=True, exist_ok=True)
213
+ await page.screenshot(path=path, full_page=full_page)
214
+ return {
215
+ "success": True,
216
+ "saved_path": str(path),
217
+ "browser_restarted": restarted,
218
+ }
pbb/browser/profile.py ADDED
@@ -0,0 +1,56 @@
1
+ """Dedicated persistent-profile management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from pbb.utils.paths import profiles_dir
10
+
11
+ PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
12
+
13
+
14
+ class InvalidProfileName(ValueError):
15
+ pass
16
+
17
+
18
+ def validate_profile_name(name: str) -> str:
19
+ if not PROFILE_RE.fullmatch(name) or name in {".", ".."}:
20
+ raise InvalidProfileName(
21
+ "profile names must be 1-64 characters using letters, numbers, '.', '_' or '-'"
22
+ )
23
+ return name
24
+
25
+
26
+ def profile_path(name: str) -> Path:
27
+ validate_profile_name(name)
28
+ root = profiles_dir().resolve()
29
+ path = (root / name).resolve()
30
+ if path.parent != root:
31
+ raise InvalidProfileName("profile path escapes the PBB profiles directory")
32
+ return path
33
+
34
+
35
+ def create_profile(name: str) -> Path:
36
+ path = profile_path(name)
37
+ path.mkdir(parents=True, exist_ok=True)
38
+ marker = path / ".pbb-profile"
39
+ marker.touch(exist_ok=True)
40
+ return path
41
+
42
+
43
+ def list_profiles() -> list[str]:
44
+ root = profiles_dir()
45
+ if not root.exists():
46
+ return []
47
+ return sorted(
48
+ item.name for item in root.iterdir() if item.is_dir() and (item / ".pbb-profile").exists()
49
+ )
50
+
51
+
52
+ def delete_profile(name: str) -> None:
53
+ path = profile_path(name)
54
+ if not (path / ".pbb-profile").exists():
55
+ raise FileNotFoundError(f"PBB profile '{name}' does not exist")
56
+ shutil.rmtree(path)