browser-tools 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,22 @@
1
+ """Browser Tools automation package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .browser_state import ActiveAttachConfig, BrowserState, ProjectBrowserConfig
6
+ from .camoufox_session import CamoufoxSession
7
+ from .cdp_client import CDPClient, CDPError
8
+ from .frame_manager import FrameManager
9
+ from .persistent_browser import PersistentChromeController
10
+
11
+ __all__ = [
12
+ "ActiveAttachConfig",
13
+ "BrowserState",
14
+ "CDPClient",
15
+ "CDPError",
16
+ "CamoufoxSession",
17
+ "FrameManager",
18
+ "PersistentChromeController",
19
+ "ProjectBrowserConfig",
20
+ "__version__",
21
+ ]
22
+ __version__ = "0.1.0"
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # attach-chrome.sh — Launch Chrome with remote debugging for browser-tools attach
3
+ #
4
+ # Usage:
5
+ # ./attach-chrome.sh [--port PORT] [--profile PROFILE] [URL]
6
+ #
7
+ # Examples:
8
+ # ./attach-chrome.sh # Default port 9222
9
+ # ./attach-chrome.sh --port 9333 # Custom port
10
+ # ./attach-chrome.sh --profile dev # Named profile
11
+ # ./attach-chrome.sh https://myapp.localhost # Open specific URL
12
+
13
+ set -euo pipefail
14
+
15
+ PORT=9222
16
+ PROFILE=""
17
+ URL=""
18
+ CHROME_CANARY="/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
19
+ CHROME_STABLE="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
20
+
21
+ while [[ $# -gt 0 ]]; do
22
+ case "$1" in
23
+ --port)
24
+ PORT="$2"
25
+ shift 2
26
+ ;;
27
+ --profile)
28
+ PROFILE="$2"
29
+ shift 2
30
+ ;;
31
+ --help|-h)
32
+ echo "Usage: $0 [--port PORT] [--profile PROFILE] [URL]"
33
+ echo ""
34
+ echo "Launch Chrome with remote debugging enabled for browser-tools."
35
+ echo ""
36
+ echo "Options:"
37
+ echo " --port PORT Remote debugging port (default: 9222)"
38
+ echo " --profile NAME Named profile for persistent sessions"
39
+ echo " URL URL to open on launch"
40
+ echo ""
41
+ echo "Then in your agent, use:"
42
+ echo ' attach_browser(endpoint="http://127.0.0.1:PORT")'
43
+ exit 0
44
+ ;;
45
+ *)
46
+ URL="$1"
47
+ shift
48
+ ;;
49
+ esac
50
+ done
51
+
52
+ # Find Chrome executable
53
+ CHROME=""
54
+ if [[ -x "$CHROME_CANARY" ]]; then
55
+ CHROME="$CHROME_CANARY"
56
+ elif [[ -x "$CHROME_STABLE" ]]; then
57
+ CHROME="$CHROME_STABLE"
58
+ elif command -v google-chrome &>/dev/null; then
59
+ CHROME="google-chrome"
60
+ elif command -v chromium &>/dev/null; then
61
+ CHROME="chromium"
62
+ else
63
+ echo "Error: Chrome not found. Install Chrome or Chrome Canary." >&2
64
+ exit 1
65
+ fi
66
+
67
+ # Build user-data-dir path
68
+ CACHE_DIR="$HOME/.cache/tool-proxy/browser-tools"
69
+ if [[ -n "$PROFILE" ]]; then
70
+ USER_DATA_DIR="$CACHE_DIR/profiles/$PROFILE"
71
+ else
72
+ USER_DATA_DIR="$CACHE_DIR/profiles/attach-$PORT"
73
+ fi
74
+
75
+ mkdir -p "$USER_DATA_DIR"
76
+ chmod 700 "$USER_DATA_DIR"
77
+
78
+ echo "Launching Chrome with remote debugging on port $PORT..."
79
+ echo " Executable: $CHROME"
80
+ echo " Profile: $USER_DATA_DIR"
81
+
82
+ ARGS=(
83
+ "--remote-debugging-port=$PORT"
84
+ "--user-data-dir=$USER_DATA_DIR"
85
+ "--no-first-run"
86
+ "--no-default-browser-check"
87
+ "--disable-sync"
88
+ )
89
+
90
+ if [[ -n "$URL" ]]; then
91
+ ARGS+=("$URL")
92
+ fi
93
+
94
+ # Detach Chrome into a new session so it survives if our parent shell is
95
+ # killed (e.g. an agent's Bash tool call hits its timeout). nohup alone is
96
+ # not enough on macOS: when the shell's process group is signaled, the
97
+ # child dies with it. Python's start_new_session=True calls os.setsid()
98
+ # in the child before exec, putting Chrome in its own session/process group.
99
+ python3 - "$CHROME" "${ARGS[@]}" <<'PY'
100
+ import subprocess, sys
101
+ subprocess.Popen(
102
+ sys.argv[1:],
103
+ stdin=subprocess.DEVNULL,
104
+ stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.DEVNULL,
106
+ start_new_session=True,
107
+ )
108
+ PY
109
+
110
+ # Wait for the remote debugging endpoint to become reachable so the caller
111
+ # can attach immediately on return.
112
+ for _ in $(seq 1 60); do
113
+ if curl -fsS "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
114
+ echo ""
115
+ echo "Chrome ready on http://127.0.0.1:$PORT"
116
+ echo "Connect with: attach_browser(endpoint=\"http://127.0.0.1:$PORT\")"
117
+ exit 0
118
+ fi
119
+ sleep 0.5
120
+ done
121
+
122
+ echo "Error: Chrome did not become ready on port $PORT within 30s." >&2
123
+ echo "If another Chrome is already using $USER_DATA_DIR, kill it first or use --profile NAME." >&2
124
+ exit 1
@@ -0,0 +1,157 @@
1
+ """Persisted browser state dataclasses for browser-tools.
2
+
3
+ Extracted from persistent_browser.py to keep the module under 800 lines.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path # noqa: TC003
11
+
12
+
13
+ @dataclass
14
+ class BrowserState:
15
+ """Persisted browser state shared across wrapper invocations."""
16
+
17
+ browser_url: str
18
+ selected_page_id: int | None = None
19
+ selected_page_url: str | None = None
20
+ pid: int | None = None
21
+ user_data_dir: str | None = None
22
+ headless: bool = False
23
+ isolated: bool = True
24
+ channel: str = "canary"
25
+ viewport: str | None = None
26
+ last_used_at: float = 0.0
27
+ daemon_pid: int | None = None
28
+ daemon_socket: str | None = None
29
+
30
+ @classmethod
31
+ def from_path(cls, path: Path) -> BrowserState | None:
32
+ """Load browser state from disk if it exists and is valid.
33
+
34
+ Args:
35
+ path: JSON file containing persisted browser state.
36
+
37
+ Returns:
38
+ BrowserState when the file exists and parses successfully, otherwise None.
39
+ """
40
+ if not path.exists():
41
+ return None
42
+ try:
43
+ data = json.loads(path.read_text())
44
+ except (OSError, json.JSONDecodeError):
45
+ return None
46
+ try:
47
+ return cls(**data)
48
+ except TypeError:
49
+ return None
50
+
51
+ def save(self, path: Path) -> None:
52
+ """Persist browser state to disk.
53
+
54
+ Args:
55
+ path: JSON file to write.
56
+
57
+ Returns:
58
+ None.
59
+ """
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ path.write_text(json.dumps(asdict(self), indent=2, sort_keys=True))
62
+
63
+
64
+ @dataclass
65
+ class ActiveAttachConfig:
66
+ """Persisted configuration for the currently attached external Chrome.
67
+
68
+ Stored separately from BrowserState so future tool-proxy invocations can
69
+ recreate the correct controller after attach_browser has exited.
70
+ """
71
+
72
+ browser_url: str
73
+ profile: str | None = None
74
+ mode: str = "full"
75
+ stealth: bool = False
76
+ saved_at: float = 0.0
77
+
78
+ @classmethod
79
+ def from_path(cls, path: Path) -> ActiveAttachConfig | None:
80
+ """Load config from disk.
81
+
82
+ Args:
83
+ path: JSON file containing the saved attach config.
84
+
85
+ Returns:
86
+ Parsed config, or None when the file is missing/invalid.
87
+ """
88
+ if not path.exists():
89
+ return None
90
+ try:
91
+ data = json.loads(path.read_text())
92
+ except (OSError, json.JSONDecodeError):
93
+ return None
94
+ try:
95
+ return cls(**data)
96
+ except TypeError:
97
+ return None
98
+
99
+ def save(self, path: Path) -> None:
100
+ """Persist config to disk.
101
+
102
+ Args:
103
+ path: JSON file to write.
104
+
105
+ Returns:
106
+ None.
107
+ """
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ path.write_text(json.dumps(asdict(self), indent=2, sort_keys=True))
110
+
111
+
112
+ @dataclass
113
+ class ProjectBrowserConfig:
114
+ """Preferred browser session configuration loaded from a project file."""
115
+
116
+ mode: str = "headless"
117
+ profile: str | None = None
118
+ endpoint: str | None = None
119
+ browser_url: str | None = None
120
+ headless: bool | None = None
121
+ isolated: bool | None = None
122
+ channel: str = "canary"
123
+ viewport: str | None = None
124
+ stealth: bool = False
125
+ saved_at: float = 0.0
126
+
127
+ @classmethod
128
+ def from_path(cls, path: Path) -> ProjectBrowserConfig | None:
129
+ """Load a project browser preference file.
130
+
131
+ Args:
132
+ path: JSON project preference path.
133
+
134
+ Returns:
135
+ Parsed project browser config, or None when missing/invalid.
136
+ """
137
+ if not path.exists():
138
+ return None
139
+ try:
140
+ data = json.loads(path.read_text())
141
+ except (OSError, json.JSONDecodeError):
142
+ return None
143
+ if not isinstance(data, dict):
144
+ return None
145
+ if isinstance(data.get("preferred_session"), dict):
146
+ data = data["preferred_session"]
147
+ if isinstance(data.get("preferredSession"), dict):
148
+ data = data["preferredSession"]
149
+ allowed = {field.name for field in cls.__dataclass_fields__.values()}
150
+ return cls(**{key: value for key, value in data.items() if key in allowed})
151
+
152
+
153
+ __all__ = [
154
+ "ActiveAttachConfig",
155
+ "BrowserState",
156
+ "ProjectBrowserConfig",
157
+ ]