ai-dev-browser 0.5.3__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.
- ai_dev_browser/__init__.py +110 -0
- ai_dev_browser/_cli.py +428 -0
- ai_dev_browser/_version.py +13 -0
- ai_dev_browser/cdp/__init__.py +6 -0
- ai_dev_browser/cdp/accessibility.py +668 -0
- ai_dev_browser/cdp/animation.py +494 -0
- ai_dev_browser/cdp/audits.py +1990 -0
- ai_dev_browser/cdp/autofill.py +292 -0
- ai_dev_browser/cdp/background_service.py +215 -0
- ai_dev_browser/cdp/bluetooth_emulation.py +626 -0
- ai_dev_browser/cdp/browser.py +821 -0
- ai_dev_browser/cdp/cache_storage.py +311 -0
- ai_dev_browser/cdp/cast.py +172 -0
- ai_dev_browser/cdp/console.py +107 -0
- ai_dev_browser/cdp/crash_report_context.py +55 -0
- ai_dev_browser/cdp/css.py +2710 -0
- ai_dev_browser/cdp/debugger.py +1405 -0
- ai_dev_browser/cdp/device_access.py +141 -0
- ai_dev_browser/cdp/device_orientation.py +45 -0
- ai_dev_browser/cdp/dom.py +2257 -0
- ai_dev_browser/cdp/dom_debugger.py +321 -0
- ai_dev_browser/cdp/dom_snapshot.py +876 -0
- ai_dev_browser/cdp/dom_storage.py +222 -0
- ai_dev_browser/cdp/emulation.py +1779 -0
- ai_dev_browser/cdp/event_breakpoints.py +56 -0
- ai_dev_browser/cdp/extensions.py +246 -0
- ai_dev_browser/cdp/fed_cm.py +283 -0
- ai_dev_browser/cdp/fetch.py +507 -0
- ai_dev_browser/cdp/file_system.py +115 -0
- ai_dev_browser/cdp/headless_experimental.py +115 -0
- ai_dev_browser/cdp/heap_profiler.py +401 -0
- ai_dev_browser/cdp/indexed_db.py +528 -0
- ai_dev_browser/cdp/input_.py +701 -0
- ai_dev_browser/cdp/inspector.py +95 -0
- ai_dev_browser/cdp/io.py +101 -0
- ai_dev_browser/cdp/layer_tree.py +464 -0
- ai_dev_browser/cdp/log.py +190 -0
- ai_dev_browser/cdp/media.py +313 -0
- ai_dev_browser/cdp/memory.py +305 -0
- ai_dev_browser/cdp/network.py +5317 -0
- ai_dev_browser/cdp/overlay.py +1468 -0
- ai_dev_browser/cdp/page.py +3970 -0
- ai_dev_browser/cdp/performance.py +124 -0
- ai_dev_browser/cdp/performance_timeline.py +200 -0
- ai_dev_browser/cdp/preload.py +575 -0
- ai_dev_browser/cdp/profiler.py +420 -0
- ai_dev_browser/cdp/pwa.py +278 -0
- ai_dev_browser/cdp/py.typed +0 -0
- ai_dev_browser/cdp/runtime.py +1589 -0
- ai_dev_browser/cdp/schema.py +50 -0
- ai_dev_browser/cdp/security.py +518 -0
- ai_dev_browser/cdp/service_worker.py +401 -0
- ai_dev_browser/cdp/smart_card_emulation.py +891 -0
- ai_dev_browser/cdp/storage.py +1573 -0
- ai_dev_browser/cdp/system_info.py +327 -0
- ai_dev_browser/cdp/target.py +822 -0
- ai_dev_browser/cdp/tethering.py +65 -0
- ai_dev_browser/cdp/tracing.py +377 -0
- ai_dev_browser/cdp/util.py +17 -0
- ai_dev_browser/cdp/web_audio.py +606 -0
- ai_dev_browser/cdp/web_authn.py +598 -0
- ai_dev_browser/cdp/web_mcp.py +219 -0
- ai_dev_browser/core/__init__.py +218 -0
- ai_dev_browser/core/_case.py +38 -0
- ai_dev_browser/core/_cf_template.py +84 -0
- ai_dev_browser/core/_element.py +431 -0
- ai_dev_browser/core/_tab.py +817 -0
- ai_dev_browser/core/_transport.py +362 -0
- ai_dev_browser/core/ax.py +605 -0
- ai_dev_browser/core/browser.py +323 -0
- ai_dev_browser/core/cdp.py +66 -0
- ai_dev_browser/core/chrome.py +253 -0
- ai_dev_browser/core/cloudflare.py +77 -0
- ai_dev_browser/core/config.py +95 -0
- ai_dev_browser/core/connection.py +403 -0
- ai_dev_browser/core/cookies.py +103 -0
- ai_dev_browser/core/dialog.py +165 -0
- ai_dev_browser/core/download.py +38 -0
- ai_dev_browser/core/elements.py +913 -0
- ai_dev_browser/core/human.py +612 -0
- ai_dev_browser/core/login.py +122 -0
- ai_dev_browser/core/mouse.py +140 -0
- ai_dev_browser/core/navigation.py +225 -0
- ai_dev_browser/core/overlays.py +148 -0
- ai_dev_browser/core/page.py +302 -0
- ai_dev_browser/core/port.py +465 -0
- ai_dev_browser/core/process.py +114 -0
- ai_dev_browser/core/snapshot.py +425 -0
- ai_dev_browser/core/storage.py +50 -0
- ai_dev_browser/core/tabs.py +125 -0
- ai_dev_browser/core/text_match.py +217 -0
- ai_dev_browser/core/window.py +84 -0
- ai_dev_browser/pool/__init__.py +34 -0
- ai_dev_browser/pool/job.py +140 -0
- ai_dev_browser/pool/persistence.py +154 -0
- ai_dev_browser/pool/pool.py +873 -0
- ai_dev_browser/pool/worker.py +171 -0
- ai_dev_browser/profile.py +107 -0
- ai_dev_browser/py.typed +0 -0
- ai_dev_browser/tools/__init__.py +9 -0
- ai_dev_browser/tools/_generate.py +209 -0
- ai_dev_browser/tools/browser_list.py +14 -0
- ai_dev_browser/tools/browser_start.py +14 -0
- ai_dev_browser/tools/browser_stop.py +14 -0
- ai_dev_browser/tools/cdp_send.py +14 -0
- ai_dev_browser/tools/click_by_html_id.py +14 -0
- ai_dev_browser/tools/click_by_ref.py +14 -0
- ai_dev_browser/tools/click_by_text.py +14 -0
- ai_dev_browser/tools/click_by_xpath.py +14 -0
- ai_dev_browser/tools/cloudflare_verify.py +14 -0
- ai_dev_browser/tools/cookies_list.py +14 -0
- ai_dev_browser/tools/cookies_load.py +14 -0
- ai_dev_browser/tools/cookies_save.py +14 -0
- ai_dev_browser/tools/dialog_respond.py +14 -0
- ai_dev_browser/tools/download.py +14 -0
- ai_dev_browser/tools/drag_by_ref.py +14 -0
- ai_dev_browser/tools/find_by_html_id.py +14 -0
- ai_dev_browser/tools/find_by_xpath.py +14 -0
- ai_dev_browser/tools/focus_by_ref.py +14 -0
- ai_dev_browser/tools/highlight_by_ref.py +14 -0
- ai_dev_browser/tools/hover_by_ref.py +14 -0
- ai_dev_browser/tools/html_by_ref.py +14 -0
- ai_dev_browser/tools/js_evaluate.py +14 -0
- ai_dev_browser/tools/login_interactive.py +14 -0
- ai_dev_browser/tools/mouse_click.py +14 -0
- ai_dev_browser/tools/mouse_drag.py +14 -0
- ai_dev_browser/tools/mouse_move.py +14 -0
- ai_dev_browser/tools/page_discover.py +14 -0
- ai_dev_browser/tools/page_emulate_focus.py +14 -0
- ai_dev_browser/tools/page_goto.py +14 -0
- ai_dev_browser/tools/page_html.py +14 -0
- ai_dev_browser/tools/page_info.py +14 -0
- ai_dev_browser/tools/page_reload.py +14 -0
- ai_dev_browser/tools/page_screenshot.py +14 -0
- ai_dev_browser/tools/page_scroll.py +14 -0
- ai_dev_browser/tools/page_wait_element.py +14 -0
- ai_dev_browser/tools/page_wait_ready.py +14 -0
- ai_dev_browser/tools/page_wait_url.py +14 -0
- ai_dev_browser/tools/screenshot_by_ref.py +14 -0
- ai_dev_browser/tools/select_by_ref.py +14 -0
- ai_dev_browser/tools/storage_get.py +14 -0
- ai_dev_browser/tools/storage_set.py +14 -0
- ai_dev_browser/tools/tab_close.py +14 -0
- ai_dev_browser/tools/tab_list.py +14 -0
- ai_dev_browser/tools/tab_new.py +14 -0
- ai_dev_browser/tools/tab_switch.py +14 -0
- ai_dev_browser/tools/type_by_ref.py +14 -0
- ai_dev_browser/tools/type_by_text.py +14 -0
- ai_dev_browser/tools/upload_by_ref.py +14 -0
- ai_dev_browser/tools/window_set.py +14 -0
- ai_dev_browser-0.5.3.dist-info/METADATA +177 -0
- ai_dev_browser-0.5.3.dist-info/RECORD +155 -0
- ai_dev_browser-0.5.3.dist-info/WHEEL +5 -0
- ai_dev_browser-0.5.3.dist-info/licenses/LICENSE +25 -0
- ai_dev_browser-0.5.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Browser lifecycle management operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .chrome import launch_chrome
|
|
9
|
+
from .config import (
|
|
10
|
+
DEFAULT_REUSE_STRATEGY,
|
|
11
|
+
ReuseStrategy,
|
|
12
|
+
get_workspace_profile_dir,
|
|
13
|
+
)
|
|
14
|
+
from .connection import graceful_close_browser
|
|
15
|
+
from .port import (
|
|
16
|
+
_cleanup_temp_profile,
|
|
17
|
+
_query_chrome_cmdline,
|
|
18
|
+
find_debug_chromes,
|
|
19
|
+
find_workspace_chromes,
|
|
20
|
+
get_available_port,
|
|
21
|
+
get_pid_on_port,
|
|
22
|
+
is_port_in_use,
|
|
23
|
+
)
|
|
24
|
+
from .process import _kill_process_tree
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_chrome_using_profile(profile_dir: Path) -> tuple[int, int | None] | None:
|
|
28
|
+
"""Find a debugging Chrome using the specified profile via CDP.
|
|
29
|
+
|
|
30
|
+
Scans debug port range and queries each Chrome's command line
|
|
31
|
+
to check if --user-data-dir matches the given profile directory.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
(port, pid) tuple if found, None otherwise.
|
|
35
|
+
"""
|
|
36
|
+
profile_str = str(profile_dir)
|
|
37
|
+
for port, pid, _ws in find_debug_chromes():
|
|
38
|
+
cmdline = _query_chrome_cmdline(port)
|
|
39
|
+
if cmdline and any(profile_str in arg for arg in cmdline):
|
|
40
|
+
return (port, pid)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _find_reusable_chrome(profile: str | None = None) -> int | None:
|
|
45
|
+
"""Find a debugging Chrome in the current workspace to reuse.
|
|
46
|
+
|
|
47
|
+
When `profile` is given, only reuse a Chrome already using that
|
|
48
|
+
profile's user-data-dir — otherwise two browser_start calls with
|
|
49
|
+
different profiles would silently share the same Chrome, defeating
|
|
50
|
+
per-profile isolation (e.g. a parallel worker pool).
|
|
51
|
+
|
|
52
|
+
When `profile` is None the caller has no profile preference, so any
|
|
53
|
+
idle debugging Chrome in this workspace is reused (legacy behaviour
|
|
54
|
+
for default invocations).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Port number if a suitable Chrome is found, None otherwise.
|
|
58
|
+
"""
|
|
59
|
+
if profile is not None:
|
|
60
|
+
user_data_dir = get_workspace_profile_dir(profile)
|
|
61
|
+
existing = _find_chrome_using_profile(user_data_dir)
|
|
62
|
+
return existing[0] if existing else None
|
|
63
|
+
|
|
64
|
+
for port, _pid in find_workspace_chromes():
|
|
65
|
+
return port
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def browser_start(
|
|
70
|
+
port: int | None = None,
|
|
71
|
+
headless: bool = os.environ.get("AI_DEV_BROWSER_HEADLESS", "").lower()
|
|
72
|
+
in ("1", "true"),
|
|
73
|
+
url: str | None = None,
|
|
74
|
+
profile: str | None = None,
|
|
75
|
+
temp: bool = False,
|
|
76
|
+
reuse: ReuseStrategy = DEFAULT_REUSE_STRATEGY,
|
|
77
|
+
) -> dict:
|
|
78
|
+
"""Start or reuse a browser instance.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
port: Debug port (auto-assigned if None)
|
|
82
|
+
headless: Run in headless mode
|
|
83
|
+
url: Initial URL to open (default: about:blank)
|
|
84
|
+
profile: Profile name (default: "default")
|
|
85
|
+
temp: Use temporary profile instead
|
|
86
|
+
reuse: "none" (always new) or "any" (reuse idle Chrome, default)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
dict with port, pid, headless, url, profile, reused, message
|
|
90
|
+
"""
|
|
91
|
+
# Try to reuse an existing Chrome. Profile-aware: if the caller asked
|
|
92
|
+
# for a specific profile we must not hand back a Chrome running a
|
|
93
|
+
# different profile (that would break parallel workers using distinct
|
|
94
|
+
# profiles for isolation). `temp=True` always wants a fresh session,
|
|
95
|
+
# so skip reuse entirely for that.
|
|
96
|
+
if reuse != "none" and not temp:
|
|
97
|
+
reused_port = _find_reusable_chrome(profile=profile)
|
|
98
|
+
if reused_port:
|
|
99
|
+
pid = get_pid_on_port(reused_port)
|
|
100
|
+
return {
|
|
101
|
+
"port": reused_port,
|
|
102
|
+
"pid": pid,
|
|
103
|
+
"profile": profile or "default",
|
|
104
|
+
"reused": True,
|
|
105
|
+
"message": f"Reusing existing Chrome on port {reused_port}",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# No reusable Chrome found above (or reuse was skipped for
|
|
109
|
+
# temp/profile/none). Top-level has already decided reuse semantics, so
|
|
110
|
+
# ask get_available_port for a fresh port only — otherwise it would
|
|
111
|
+
# silently hand back the same workspace Chrome we just declined to
|
|
112
|
+
# reuse.
|
|
113
|
+
if port is None:
|
|
114
|
+
port = get_available_port(reuse=False)
|
|
115
|
+
else:
|
|
116
|
+
# User specified a port - check if it's available
|
|
117
|
+
if is_port_in_use(port=port):
|
|
118
|
+
pid = get_pid_on_port(port)
|
|
119
|
+
return {
|
|
120
|
+
"error": f"Port {port} is already in use (PID: {pid}). "
|
|
121
|
+
f"Use a different port or stop the existing process."
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Determine user data directory
|
|
125
|
+
if temp:
|
|
126
|
+
user_data_dir = None
|
|
127
|
+
profile_name = "(temp)"
|
|
128
|
+
else:
|
|
129
|
+
profile_name = profile or "default"
|
|
130
|
+
user_data_dir = get_workspace_profile_dir(profile_name)
|
|
131
|
+
user_data_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
# Safety: if another Chrome is already using this profile, reuse it
|
|
134
|
+
# (launching two Chromes with the same user-data-dir causes crashes)
|
|
135
|
+
existing = _find_chrome_using_profile(user_data_dir)
|
|
136
|
+
if existing:
|
|
137
|
+
existing_port, existing_pid = existing
|
|
138
|
+
return {
|
|
139
|
+
"port": existing_port,
|
|
140
|
+
"pid": existing_pid,
|
|
141
|
+
"profile": profile_name,
|
|
142
|
+
"reused": True,
|
|
143
|
+
"message": f"Profile '{profile_name}' already in use. Reusing Chrome on port {existing_port}.",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Launch Chrome
|
|
147
|
+
start_url = url or "about:blank"
|
|
148
|
+
process = launch_chrome(
|
|
149
|
+
port=port,
|
|
150
|
+
headless=headless,
|
|
151
|
+
start_url=start_url,
|
|
152
|
+
user_data_dir=str(user_data_dir) if user_data_dir else None,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Wait for Chrome to start listening on the port
|
|
156
|
+
max_wait = 10.0
|
|
157
|
+
poll_interval = 0.2
|
|
158
|
+
elapsed = 0.0
|
|
159
|
+
while elapsed < max_wait:
|
|
160
|
+
if is_port_in_use(port=port):
|
|
161
|
+
break
|
|
162
|
+
if process.poll() is not None:
|
|
163
|
+
stderr = process.stderr.read() if process.stderr else ""
|
|
164
|
+
# Provide more helpful error message
|
|
165
|
+
if not stderr:
|
|
166
|
+
stderr = (
|
|
167
|
+
"Chrome exited silently. Possible causes:\n"
|
|
168
|
+
" - Another Chrome is using this profile\n"
|
|
169
|
+
" - Profile directory is corrupted\n"
|
|
170
|
+
" - Insufficient permissions"
|
|
171
|
+
)
|
|
172
|
+
return {"error": f"Chrome process exited unexpectedly: {stderr}"}
|
|
173
|
+
time.sleep(poll_interval)
|
|
174
|
+
elapsed += poll_interval
|
|
175
|
+
else:
|
|
176
|
+
return {
|
|
177
|
+
"error": f"Chrome started (PID {process.pid}) but port {port} not listening after {max_wait}s",
|
|
178
|
+
"pid": process.pid,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"port": port,
|
|
183
|
+
"pid": process.pid,
|
|
184
|
+
"headless": headless,
|
|
185
|
+
"url": start_url,
|
|
186
|
+
"profile": profile_name,
|
|
187
|
+
"reused": False,
|
|
188
|
+
"message": f"Browser started on port {port}",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _graceful_stop(port: int, pid: int, timeout: float = 5.0) -> dict:
|
|
193
|
+
"""Gracefully stop a Chrome instance via CDP Browser.close().
|
|
194
|
+
|
|
195
|
+
Sends Browser.close() which flushes cookies/profile data, then waits
|
|
196
|
+
for the process to exit. Falls back to force-kill if graceful fails.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
dict with port, pid, and method used ("graceful" or "force").
|
|
200
|
+
"""
|
|
201
|
+
# Try graceful shutdown via CDP. Detect whether we're already inside a
|
|
202
|
+
# running event loop BEFORE constructing the coroutine — otherwise
|
|
203
|
+
# calling graceful_close_browser(port=port) eagerly creates a coroutine
|
|
204
|
+
# object, and if asyncio.run rejects it (in-loop), the coroutine is
|
|
205
|
+
# never awaited and Python emits
|
|
206
|
+
# "RuntimeWarning: coroutine ... was never awaited".
|
|
207
|
+
try:
|
|
208
|
+
asyncio.get_running_loop()
|
|
209
|
+
in_loop = True
|
|
210
|
+
except RuntimeError:
|
|
211
|
+
in_loop = False
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
if in_loop:
|
|
215
|
+
# Can't asyncio.run here — offload to a thread with its own loop
|
|
216
|
+
import concurrent.futures
|
|
217
|
+
|
|
218
|
+
def _run_close():
|
|
219
|
+
return asyncio.run(graceful_close_browser(port=port))
|
|
220
|
+
|
|
221
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
222
|
+
sent = pool.submit(_run_close).result(timeout=timeout)
|
|
223
|
+
else:
|
|
224
|
+
sent = asyncio.run(graceful_close_browser(port=port))
|
|
225
|
+
except Exception:
|
|
226
|
+
sent = False
|
|
227
|
+
|
|
228
|
+
if sent:
|
|
229
|
+
# Wait for process to exit
|
|
230
|
+
elapsed = 0.0
|
|
231
|
+
poll_interval = 0.2
|
|
232
|
+
while elapsed < timeout:
|
|
233
|
+
if not is_port_in_use(port=port):
|
|
234
|
+
_cleanup_temp_profile(port)
|
|
235
|
+
return {"port": port, "pid": pid, "method": "graceful"}
|
|
236
|
+
time.sleep(poll_interval)
|
|
237
|
+
elapsed += poll_interval
|
|
238
|
+
|
|
239
|
+
# Graceful failed or timed out — force kill
|
|
240
|
+
_kill_process_tree(pid)
|
|
241
|
+
_cleanup_temp_profile(port)
|
|
242
|
+
return {"port": port, "pid": pid, "method": "force"}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def browser_stop(
|
|
246
|
+
port: int | None = None,
|
|
247
|
+
stop_all: bool = False,
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Stop browser instance(s).
|
|
250
|
+
|
|
251
|
+
Uses CDP Browser.close() for graceful shutdown (flushes cookies to
|
|
252
|
+
profile SQLite). Falls back to force-kill if graceful fails.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
port: Port of browser to stop
|
|
256
|
+
stop_all: Stop all debugging Chrome instances
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
dict with stopped status, count, browsers list
|
|
260
|
+
"""
|
|
261
|
+
if not port and not stop_all:
|
|
262
|
+
return {"error": "Please specify port or stop_all"}
|
|
263
|
+
|
|
264
|
+
stopped = []
|
|
265
|
+
|
|
266
|
+
if stop_all:
|
|
267
|
+
for p, pid, _ws in find_debug_chromes():
|
|
268
|
+
if pid is None:
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
result = _graceful_stop(p, pid)
|
|
272
|
+
stopped.append(result)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
else:
|
|
276
|
+
pid = get_pid_on_port(port)
|
|
277
|
+
if pid:
|
|
278
|
+
result = _graceful_stop(port, pid)
|
|
279
|
+
stopped.append(result)
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
"stopped": True,
|
|
283
|
+
"count": len(stopped),
|
|
284
|
+
"browsers": stopped,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def browser_list(all_workspaces: bool = False) -> dict:
|
|
289
|
+
"""List debugging Chrome instances.
|
|
290
|
+
|
|
291
|
+
By default, shows only Chromes belonging to the current workspace.
|
|
292
|
+
Use all_workspaces=True to see all debugging Chromes.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
all_workspaces: Show Chromes from all workspaces (default: current only)
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
dict with browsers list and count
|
|
299
|
+
"""
|
|
300
|
+
browsers = []
|
|
301
|
+
|
|
302
|
+
if all_workspaces:
|
|
303
|
+
for p, pid, workspace in find_debug_chromes():
|
|
304
|
+
info: dict = {
|
|
305
|
+
"port": p,
|
|
306
|
+
"pid": pid,
|
|
307
|
+
}
|
|
308
|
+
if workspace:
|
|
309
|
+
info["workspace"] = workspace
|
|
310
|
+
browsers.append(info)
|
|
311
|
+
else:
|
|
312
|
+
for p, pid in find_workspace_chromes():
|
|
313
|
+
browsers.append(
|
|
314
|
+
{
|
|
315
|
+
"port": p,
|
|
316
|
+
"pid": pid,
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"browsers": browsers,
|
|
322
|
+
"count": len(browsers),
|
|
323
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""CDP (Chrome DevTools Protocol) command operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from ai_dev_browser import cdp as cdp_module
|
|
6
|
+
|
|
7
|
+
from ._case import camel_to_snake
|
|
8
|
+
from ._tab import Tab
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_cdp_command(method: str, params: dict):
|
|
12
|
+
"""Dynamically create a CDP command generator.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
method: CDP method like "Browser.getVersion" or "DOM.getDocument"
|
|
16
|
+
params: Parameters dict
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
CDP command generator
|
|
20
|
+
"""
|
|
21
|
+
domain, cmd = method.split(".")
|
|
22
|
+
domain_snake = camel_to_snake(domain)
|
|
23
|
+
cmd_snake = camel_to_snake(cmd)
|
|
24
|
+
|
|
25
|
+
# Get the domain module (e.g., cdp.browser)
|
|
26
|
+
domain_mod = getattr(cdp_module, domain_snake)
|
|
27
|
+
|
|
28
|
+
# Get the command function (e.g., cdp.browser.get_version)
|
|
29
|
+
cmd_func = getattr(domain_mod, cmd_snake)
|
|
30
|
+
|
|
31
|
+
# Call with params
|
|
32
|
+
return cmd_func(**params) if params else cmd_func()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def cdp_send(
|
|
36
|
+
tab: Tab,
|
|
37
|
+
method: str,
|
|
38
|
+
params: str | None = None,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Send a CDP (Chrome DevTools Protocol) command.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
tab: Tab instance
|
|
44
|
+
method: CDP method name (e.g., "Browser.getVersion", "DOM.getDocument")
|
|
45
|
+
params: JSON string of parameters
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
dict with result or error
|
|
49
|
+
"""
|
|
50
|
+
# Parse params if provided
|
|
51
|
+
parsed_params = {}
|
|
52
|
+
if params:
|
|
53
|
+
parsed_params = json.loads(params)
|
|
54
|
+
|
|
55
|
+
# Create CDP command generator
|
|
56
|
+
cdp_cmd = _get_cdp_command(method, parsed_params)
|
|
57
|
+
|
|
58
|
+
# Send CDP command
|
|
59
|
+
result = await tab.send(cdp_cmd)
|
|
60
|
+
|
|
61
|
+
# Try to serialize result
|
|
62
|
+
try:
|
|
63
|
+
json.dumps(result)
|
|
64
|
+
return {"result": result}
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
return {"result": str(result)}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Chrome executable detection and launching.
|
|
2
|
+
|
|
3
|
+
This module provides cross-platform Chrome management:
|
|
4
|
+
- find_chrome: Detect Chrome installation path
|
|
5
|
+
- launch_chrome: Start Chrome with remote debugging
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
chrome_path = find_chrome()
|
|
9
|
+
if chrome_path:
|
|
10
|
+
process = launch_chrome()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .config import DEFAULT_DEBUG_PORT, DEFAULT_PROFILE_PREFIX
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_chrome() -> str | None:
|
|
28
|
+
"""
|
|
29
|
+
Find Chrome executable path based on platform.
|
|
30
|
+
|
|
31
|
+
Automatically detects Chrome installation on Windows, macOS, and Linux.
|
|
32
|
+
Checks common installation paths and falls back to PATH search on Unix.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Path to Chrome executable, or None if not found.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
chrome = find_chrome()
|
|
39
|
+
if chrome:
|
|
40
|
+
print(f"Chrome at: {chrome}")
|
|
41
|
+
else:
|
|
42
|
+
print("Chrome not found")
|
|
43
|
+
"""
|
|
44
|
+
system = platform.system()
|
|
45
|
+
|
|
46
|
+
if system == "Darwin": # macOS
|
|
47
|
+
candidates = [
|
|
48
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
49
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
50
|
+
str(
|
|
51
|
+
Path.home()
|
|
52
|
+
/ "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
elif system == "Windows":
|
|
56
|
+
candidates = [
|
|
57
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
58
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
59
|
+
str(Path.home() / r"AppData\Local\Google\Chrome\Application\chrome.exe"),
|
|
60
|
+
]
|
|
61
|
+
else: # Linux
|
|
62
|
+
candidates = [
|
|
63
|
+
"/usr/bin/google-chrome",
|
|
64
|
+
"/usr/bin/google-chrome-stable",
|
|
65
|
+
"/usr/bin/chromium",
|
|
66
|
+
"/usr/bin/chromium-browser",
|
|
67
|
+
"/snap/bin/chromium",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Check known paths
|
|
71
|
+
for candidate in candidates:
|
|
72
|
+
if Path(candidate).exists():
|
|
73
|
+
return candidate
|
|
74
|
+
|
|
75
|
+
# Try to find via 'which' on Unix-like systems
|
|
76
|
+
if system != "Windows":
|
|
77
|
+
for cmd in [
|
|
78
|
+
"google-chrome",
|
|
79
|
+
"google-chrome-stable",
|
|
80
|
+
"chromium",
|
|
81
|
+
"chromium-browser",
|
|
82
|
+
]:
|
|
83
|
+
result = shutil.which(cmd)
|
|
84
|
+
if result:
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ensure_no_session_restore(user_data_dir: Path) -> None:
|
|
91
|
+
"""Ensure Chrome won't restore previous session on startup.
|
|
92
|
+
|
|
93
|
+
Sets restore_on_startup=5 in Preferences file, which tells Chrome
|
|
94
|
+
to open a new tab page instead of restoring previous tabs.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
user_data_dir: Chrome user data directory path
|
|
98
|
+
"""
|
|
99
|
+
import json
|
|
100
|
+
|
|
101
|
+
default_dir = user_data_dir / "Default"
|
|
102
|
+
default_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
prefs_file = default_dir / "Preferences"
|
|
105
|
+
|
|
106
|
+
# Load existing preferences or start fresh
|
|
107
|
+
prefs = {}
|
|
108
|
+
if prefs_file.exists():
|
|
109
|
+
try:
|
|
110
|
+
prefs = json.loads(prefs_file.read_text(encoding="utf-8"))
|
|
111
|
+
except (OSError, json.JSONDecodeError):
|
|
112
|
+
prefs = {}
|
|
113
|
+
|
|
114
|
+
# Set restore_on_startup to 5 (open new tab page)
|
|
115
|
+
if "session" not in prefs:
|
|
116
|
+
prefs["session"] = {}
|
|
117
|
+
prefs["session"]["restore_on_startup"] = 5
|
|
118
|
+
|
|
119
|
+
# Write back
|
|
120
|
+
try:
|
|
121
|
+
prefs_file.write_text(json.dumps(prefs), encoding="utf-8")
|
|
122
|
+
logger.debug(f"Set restore_on_startup=5 in {prefs_file}")
|
|
123
|
+
except OSError as e:
|
|
124
|
+
logger.warning(f"Failed to write Preferences: {e}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def launch_chrome(
|
|
128
|
+
port: int = DEFAULT_DEBUG_PORT,
|
|
129
|
+
headless: bool = False,
|
|
130
|
+
user_data_dir: str | Path | None = None,
|
|
131
|
+
profile_prefix: str = DEFAULT_PROFILE_PREFIX,
|
|
132
|
+
extra_args: list[str] | None = None,
|
|
133
|
+
start_url: str = "about:blank",
|
|
134
|
+
disable_session_restore: bool = True,
|
|
135
|
+
disable_session_crashed_bubble: bool = True,
|
|
136
|
+
hide_crash_restore_bubble: bool = True,
|
|
137
|
+
) -> subprocess.Popen:
|
|
138
|
+
"""
|
|
139
|
+
Launch Chrome with remote debugging enabled.
|
|
140
|
+
|
|
141
|
+
Creates an isolated Chrome instance with its own profile directory,
|
|
142
|
+
suitable for browser automation without affecting user's Chrome.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
port: Remote debugging port (default: DEFAULT_DEBUG_PORT)
|
|
146
|
+
headless: Run in headless mode (default: False)
|
|
147
|
+
user_data_dir: Custom user data directory. If None, creates a temp directory.
|
|
148
|
+
profile_prefix: Prefix for temp profile directory name
|
|
149
|
+
extra_args: Additional Chrome command-line arguments
|
|
150
|
+
start_url: Initial URL to open (default: "about:blank" for clean state)
|
|
151
|
+
disable_session_restore: Prevent Chrome from restoring previous tabs (default: True).
|
|
152
|
+
Sets restore_on_startup=5 in Preferences file.
|
|
153
|
+
disable_session_crashed_bubble: Chrome flag --disable-session-crashed-bubble (default: True)
|
|
154
|
+
hide_crash_restore_bubble: Chrome flag --hide-crash-restore-bubble (default: True)
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Popen process handle for the Chrome instance.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
FileNotFoundError: If Chrome executable not found.
|
|
161
|
+
RuntimeError: If Chrome fails to start.
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
process = launch_chrome()
|
|
165
|
+
# ... connect via CDP ...
|
|
166
|
+
process.terminate()
|
|
167
|
+
"""
|
|
168
|
+
chrome_path = find_chrome()
|
|
169
|
+
if not chrome_path:
|
|
170
|
+
raise FileNotFoundError(
|
|
171
|
+
"Chrome executable not found. Please install Google Chrome or set the path manually."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Create isolated user data directory if not provided
|
|
175
|
+
# Use port number in name so browser_stop can find and clean it up
|
|
176
|
+
if user_data_dir is None:
|
|
177
|
+
temp_dir = Path(tempfile.gettempdir()) / f"{profile_prefix}{port}"
|
|
178
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
user_data_dir = str(temp_dir)
|
|
180
|
+
|
|
181
|
+
# Disable session restore by setting Preferences
|
|
182
|
+
# This prevents Chrome from restoring previous tabs on startup
|
|
183
|
+
if disable_session_restore:
|
|
184
|
+
_ensure_no_session_restore(Path(user_data_dir))
|
|
185
|
+
|
|
186
|
+
# Build Chrome arguments (cross-platform)
|
|
187
|
+
args = [
|
|
188
|
+
chrome_path,
|
|
189
|
+
f"--remote-debugging-port={port}",
|
|
190
|
+
f"--user-data-dir={user_data_dir}",
|
|
191
|
+
"--remote-allow-origins=*", # Allow CDP connections for in-use detection
|
|
192
|
+
"--enable-automation", # Required for CDP Browser.getBrowserCommandLine()
|
|
193
|
+
"--no-first-run",
|
|
194
|
+
"--no-default-browser-check",
|
|
195
|
+
"--disable-background-networking",
|
|
196
|
+
"--disable-client-side-phishing-detection",
|
|
197
|
+
"--disable-default-apps",
|
|
198
|
+
"--disable-extensions",
|
|
199
|
+
"--disable-hang-monitor",
|
|
200
|
+
"--disable-popup-blocking",
|
|
201
|
+
"--disable-prompt-on-repost",
|
|
202
|
+
"--disable-sync",
|
|
203
|
+
"--disable-translate",
|
|
204
|
+
"--metrics-recording-only",
|
|
205
|
+
"--safebrowsing-disable-auto-update",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
# Workspace tag: identifies which working directory owns this Chrome.
|
|
209
|
+
# Read back via CDP Browser.getBrowserCommandLine() in port.py.
|
|
210
|
+
args.append(f"--ai-dev-browser-workspace={os.getcwd()}")
|
|
211
|
+
|
|
212
|
+
# Session restore behavior (default: suppress for clean automation state)
|
|
213
|
+
if disable_session_crashed_bubble:
|
|
214
|
+
args.append("--disable-session-crashed-bubble")
|
|
215
|
+
if hide_crash_restore_bubble:
|
|
216
|
+
args.append("--hide-crash-restore-bubble")
|
|
217
|
+
|
|
218
|
+
if headless:
|
|
219
|
+
args.append("--headless=new")
|
|
220
|
+
|
|
221
|
+
if extra_args:
|
|
222
|
+
args.extend(extra_args)
|
|
223
|
+
|
|
224
|
+
# Start URL goes last - use start_url parameter to control initial page
|
|
225
|
+
if start_url:
|
|
226
|
+
args.append(start_url)
|
|
227
|
+
|
|
228
|
+
# Start Chrome process
|
|
229
|
+
try:
|
|
230
|
+
# Use subprocess.Popen on all platforms
|
|
231
|
+
# On Unix, use start_new_session to detach from parent
|
|
232
|
+
# On Windows, CREATE_NEW_PROCESS_GROUP for similar isolation
|
|
233
|
+
if platform.system() == "Windows":
|
|
234
|
+
popen_kwargs = {
|
|
235
|
+
"stdout": subprocess.DEVNULL,
|
|
236
|
+
"stderr": subprocess.PIPE,
|
|
237
|
+
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
238
|
+
}
|
|
239
|
+
else:
|
|
240
|
+
popen_kwargs = {
|
|
241
|
+
"stdout": subprocess.DEVNULL,
|
|
242
|
+
"stderr": subprocess.PIPE,
|
|
243
|
+
"start_new_session": True,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
logger.debug(f"Launching Chrome on port {port}")
|
|
247
|
+
process = subprocess.Popen(args, **popen_kwargs) # type: ignore[call-overload]
|
|
248
|
+
|
|
249
|
+
logger.debug(f"Chrome process created, PID: {process.pid}")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
raise RuntimeError(f"Failed to launch Chrome: {e}") from e
|
|
252
|
+
|
|
253
|
+
return process
|