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.
Files changed (155) hide show
  1. ai_dev_browser/__init__.py +110 -0
  2. ai_dev_browser/_cli.py +428 -0
  3. ai_dev_browser/_version.py +13 -0
  4. ai_dev_browser/cdp/__init__.py +6 -0
  5. ai_dev_browser/cdp/accessibility.py +668 -0
  6. ai_dev_browser/cdp/animation.py +494 -0
  7. ai_dev_browser/cdp/audits.py +1990 -0
  8. ai_dev_browser/cdp/autofill.py +292 -0
  9. ai_dev_browser/cdp/background_service.py +215 -0
  10. ai_dev_browser/cdp/bluetooth_emulation.py +626 -0
  11. ai_dev_browser/cdp/browser.py +821 -0
  12. ai_dev_browser/cdp/cache_storage.py +311 -0
  13. ai_dev_browser/cdp/cast.py +172 -0
  14. ai_dev_browser/cdp/console.py +107 -0
  15. ai_dev_browser/cdp/crash_report_context.py +55 -0
  16. ai_dev_browser/cdp/css.py +2710 -0
  17. ai_dev_browser/cdp/debugger.py +1405 -0
  18. ai_dev_browser/cdp/device_access.py +141 -0
  19. ai_dev_browser/cdp/device_orientation.py +45 -0
  20. ai_dev_browser/cdp/dom.py +2257 -0
  21. ai_dev_browser/cdp/dom_debugger.py +321 -0
  22. ai_dev_browser/cdp/dom_snapshot.py +876 -0
  23. ai_dev_browser/cdp/dom_storage.py +222 -0
  24. ai_dev_browser/cdp/emulation.py +1779 -0
  25. ai_dev_browser/cdp/event_breakpoints.py +56 -0
  26. ai_dev_browser/cdp/extensions.py +246 -0
  27. ai_dev_browser/cdp/fed_cm.py +283 -0
  28. ai_dev_browser/cdp/fetch.py +507 -0
  29. ai_dev_browser/cdp/file_system.py +115 -0
  30. ai_dev_browser/cdp/headless_experimental.py +115 -0
  31. ai_dev_browser/cdp/heap_profiler.py +401 -0
  32. ai_dev_browser/cdp/indexed_db.py +528 -0
  33. ai_dev_browser/cdp/input_.py +701 -0
  34. ai_dev_browser/cdp/inspector.py +95 -0
  35. ai_dev_browser/cdp/io.py +101 -0
  36. ai_dev_browser/cdp/layer_tree.py +464 -0
  37. ai_dev_browser/cdp/log.py +190 -0
  38. ai_dev_browser/cdp/media.py +313 -0
  39. ai_dev_browser/cdp/memory.py +305 -0
  40. ai_dev_browser/cdp/network.py +5317 -0
  41. ai_dev_browser/cdp/overlay.py +1468 -0
  42. ai_dev_browser/cdp/page.py +3970 -0
  43. ai_dev_browser/cdp/performance.py +124 -0
  44. ai_dev_browser/cdp/performance_timeline.py +200 -0
  45. ai_dev_browser/cdp/preload.py +575 -0
  46. ai_dev_browser/cdp/profiler.py +420 -0
  47. ai_dev_browser/cdp/pwa.py +278 -0
  48. ai_dev_browser/cdp/py.typed +0 -0
  49. ai_dev_browser/cdp/runtime.py +1589 -0
  50. ai_dev_browser/cdp/schema.py +50 -0
  51. ai_dev_browser/cdp/security.py +518 -0
  52. ai_dev_browser/cdp/service_worker.py +401 -0
  53. ai_dev_browser/cdp/smart_card_emulation.py +891 -0
  54. ai_dev_browser/cdp/storage.py +1573 -0
  55. ai_dev_browser/cdp/system_info.py +327 -0
  56. ai_dev_browser/cdp/target.py +822 -0
  57. ai_dev_browser/cdp/tethering.py +65 -0
  58. ai_dev_browser/cdp/tracing.py +377 -0
  59. ai_dev_browser/cdp/util.py +17 -0
  60. ai_dev_browser/cdp/web_audio.py +606 -0
  61. ai_dev_browser/cdp/web_authn.py +598 -0
  62. ai_dev_browser/cdp/web_mcp.py +219 -0
  63. ai_dev_browser/core/__init__.py +218 -0
  64. ai_dev_browser/core/_case.py +38 -0
  65. ai_dev_browser/core/_cf_template.py +84 -0
  66. ai_dev_browser/core/_element.py +431 -0
  67. ai_dev_browser/core/_tab.py +817 -0
  68. ai_dev_browser/core/_transport.py +362 -0
  69. ai_dev_browser/core/ax.py +605 -0
  70. ai_dev_browser/core/browser.py +323 -0
  71. ai_dev_browser/core/cdp.py +66 -0
  72. ai_dev_browser/core/chrome.py +253 -0
  73. ai_dev_browser/core/cloudflare.py +77 -0
  74. ai_dev_browser/core/config.py +95 -0
  75. ai_dev_browser/core/connection.py +403 -0
  76. ai_dev_browser/core/cookies.py +103 -0
  77. ai_dev_browser/core/dialog.py +165 -0
  78. ai_dev_browser/core/download.py +38 -0
  79. ai_dev_browser/core/elements.py +913 -0
  80. ai_dev_browser/core/human.py +612 -0
  81. ai_dev_browser/core/login.py +122 -0
  82. ai_dev_browser/core/mouse.py +140 -0
  83. ai_dev_browser/core/navigation.py +225 -0
  84. ai_dev_browser/core/overlays.py +148 -0
  85. ai_dev_browser/core/page.py +302 -0
  86. ai_dev_browser/core/port.py +465 -0
  87. ai_dev_browser/core/process.py +114 -0
  88. ai_dev_browser/core/snapshot.py +425 -0
  89. ai_dev_browser/core/storage.py +50 -0
  90. ai_dev_browser/core/tabs.py +125 -0
  91. ai_dev_browser/core/text_match.py +217 -0
  92. ai_dev_browser/core/window.py +84 -0
  93. ai_dev_browser/pool/__init__.py +34 -0
  94. ai_dev_browser/pool/job.py +140 -0
  95. ai_dev_browser/pool/persistence.py +154 -0
  96. ai_dev_browser/pool/pool.py +873 -0
  97. ai_dev_browser/pool/worker.py +171 -0
  98. ai_dev_browser/profile.py +107 -0
  99. ai_dev_browser/py.typed +0 -0
  100. ai_dev_browser/tools/__init__.py +9 -0
  101. ai_dev_browser/tools/_generate.py +209 -0
  102. ai_dev_browser/tools/browser_list.py +14 -0
  103. ai_dev_browser/tools/browser_start.py +14 -0
  104. ai_dev_browser/tools/browser_stop.py +14 -0
  105. ai_dev_browser/tools/cdp_send.py +14 -0
  106. ai_dev_browser/tools/click_by_html_id.py +14 -0
  107. ai_dev_browser/tools/click_by_ref.py +14 -0
  108. ai_dev_browser/tools/click_by_text.py +14 -0
  109. ai_dev_browser/tools/click_by_xpath.py +14 -0
  110. ai_dev_browser/tools/cloudflare_verify.py +14 -0
  111. ai_dev_browser/tools/cookies_list.py +14 -0
  112. ai_dev_browser/tools/cookies_load.py +14 -0
  113. ai_dev_browser/tools/cookies_save.py +14 -0
  114. ai_dev_browser/tools/dialog_respond.py +14 -0
  115. ai_dev_browser/tools/download.py +14 -0
  116. ai_dev_browser/tools/drag_by_ref.py +14 -0
  117. ai_dev_browser/tools/find_by_html_id.py +14 -0
  118. ai_dev_browser/tools/find_by_xpath.py +14 -0
  119. ai_dev_browser/tools/focus_by_ref.py +14 -0
  120. ai_dev_browser/tools/highlight_by_ref.py +14 -0
  121. ai_dev_browser/tools/hover_by_ref.py +14 -0
  122. ai_dev_browser/tools/html_by_ref.py +14 -0
  123. ai_dev_browser/tools/js_evaluate.py +14 -0
  124. ai_dev_browser/tools/login_interactive.py +14 -0
  125. ai_dev_browser/tools/mouse_click.py +14 -0
  126. ai_dev_browser/tools/mouse_drag.py +14 -0
  127. ai_dev_browser/tools/mouse_move.py +14 -0
  128. ai_dev_browser/tools/page_discover.py +14 -0
  129. ai_dev_browser/tools/page_emulate_focus.py +14 -0
  130. ai_dev_browser/tools/page_goto.py +14 -0
  131. ai_dev_browser/tools/page_html.py +14 -0
  132. ai_dev_browser/tools/page_info.py +14 -0
  133. ai_dev_browser/tools/page_reload.py +14 -0
  134. ai_dev_browser/tools/page_screenshot.py +14 -0
  135. ai_dev_browser/tools/page_scroll.py +14 -0
  136. ai_dev_browser/tools/page_wait_element.py +14 -0
  137. ai_dev_browser/tools/page_wait_ready.py +14 -0
  138. ai_dev_browser/tools/page_wait_url.py +14 -0
  139. ai_dev_browser/tools/screenshot_by_ref.py +14 -0
  140. ai_dev_browser/tools/select_by_ref.py +14 -0
  141. ai_dev_browser/tools/storage_get.py +14 -0
  142. ai_dev_browser/tools/storage_set.py +14 -0
  143. ai_dev_browser/tools/tab_close.py +14 -0
  144. ai_dev_browser/tools/tab_list.py +14 -0
  145. ai_dev_browser/tools/tab_new.py +14 -0
  146. ai_dev_browser/tools/tab_switch.py +14 -0
  147. ai_dev_browser/tools/type_by_ref.py +14 -0
  148. ai_dev_browser/tools/type_by_text.py +14 -0
  149. ai_dev_browser/tools/upload_by_ref.py +14 -0
  150. ai_dev_browser/tools/window_set.py +14 -0
  151. ai_dev_browser-0.5.3.dist-info/METADATA +177 -0
  152. ai_dev_browser-0.5.3.dist-info/RECORD +155 -0
  153. ai_dev_browser-0.5.3.dist-info/WHEEL +5 -0
  154. ai_dev_browser-0.5.3.dist-info/licenses/LICENSE +25 -0
  155. 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