iflow-mcp_janspoerer-mcp_browser_use 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.
Files changed (50) hide show
  1. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/METADATA +26 -0
  2. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/RECORD +50 -0
  3. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/top_level.txt +1 -0
  7. mcp_browser_use/__init__.py +2 -0
  8. mcp_browser_use/__main__.py +1347 -0
  9. mcp_browser_use/actions/__init__.py +1 -0
  10. mcp_browser_use/actions/elements.py +173 -0
  11. mcp_browser_use/actions/extraction.py +864 -0
  12. mcp_browser_use/actions/keyboard.py +43 -0
  13. mcp_browser_use/actions/navigation.py +73 -0
  14. mcp_browser_use/actions/screenshots.py +85 -0
  15. mcp_browser_use/browser/__init__.py +1 -0
  16. mcp_browser_use/browser/chrome.py +150 -0
  17. mcp_browser_use/browser/chrome_executable.py +204 -0
  18. mcp_browser_use/browser/chrome_launcher.py +330 -0
  19. mcp_browser_use/browser/chrome_process.py +104 -0
  20. mcp_browser_use/browser/devtools.py +230 -0
  21. mcp_browser_use/browser/driver.py +322 -0
  22. mcp_browser_use/browser/process.py +133 -0
  23. mcp_browser_use/cleaners.py +530 -0
  24. mcp_browser_use/config/__init__.py +30 -0
  25. mcp_browser_use/config/environment.py +155 -0
  26. mcp_browser_use/config/paths.py +97 -0
  27. mcp_browser_use/constants.py +68 -0
  28. mcp_browser_use/context.py +150 -0
  29. mcp_browser_use/context_pack.py +85 -0
  30. mcp_browser_use/decorators/__init__.py +13 -0
  31. mcp_browser_use/decorators/ensure.py +84 -0
  32. mcp_browser_use/decorators/envelope.py +83 -0
  33. mcp_browser_use/decorators/locking.py +172 -0
  34. mcp_browser_use/helpers.py +173 -0
  35. mcp_browser_use/helpers_context.py +261 -0
  36. mcp_browser_use/locking/__init__.py +1 -0
  37. mcp_browser_use/locking/action_lock.py +190 -0
  38. mcp_browser_use/locking/file_mutex.py +139 -0
  39. mcp_browser_use/locking/window_registry.py +178 -0
  40. mcp_browser_use/tools/__init__.py +59 -0
  41. mcp_browser_use/tools/browser_management.py +260 -0
  42. mcp_browser_use/tools/debugging.py +195 -0
  43. mcp_browser_use/tools/extraction.py +58 -0
  44. mcp_browser_use/tools/interaction.py +323 -0
  45. mcp_browser_use/tools/navigation.py +84 -0
  46. mcp_browser_use/tools/screenshots.py +116 -0
  47. mcp_browser_use/utils/__init__.py +1 -0
  48. mcp_browser_use/utils/diagnostics.py +85 -0
  49. mcp_browser_use/utils/html_utils.py +118 -0
  50. mcp_browser_use/utils/retry.py +57 -0
@@ -0,0 +1,330 @@
1
+ """Chrome launch orchestration and command building."""
2
+
3
+ import os
4
+ import time
5
+ import platform
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Tuple, Optional
10
+
11
+ from .chrome_executable import get_chrome_binary_for_platform
12
+ from .chrome_process import find_chrome_by_port, is_chrome_running_with_userdata
13
+
14
+ # Import from sibling modules
15
+ from .devtools import devtools_active_port_from_file, is_debugger_listening
16
+ from .process import write_rendezvous, get_free_port
17
+
18
+ import logging
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def build_chrome_command(
23
+ binary: str,
24
+ port: int,
25
+ user_data_dir: str,
26
+ profile_name: str,
27
+ ) -> list[str]:
28
+ """
29
+ Build Chrome command-line arguments for debugging.
30
+
31
+ Consolidates duplicated command building from 2 locations in start_or_attach_chrome_from_env.
32
+
33
+ Args:
34
+ binary: Path to Chrome executable
35
+ port: Remote debugging port
36
+ user_data_dir: Chrome user data directory
37
+ profile_name: Chrome profile name
38
+
39
+ Returns:
40
+ list[str]: Command-line arguments for Chrome
41
+ """
42
+ headless = os.getenv("MCP_HEADLESS", "0").strip()
43
+ is_headless = headless in ("1", "true", "True", "yes", "Yes")
44
+
45
+ cmd = [
46
+ binary,
47
+ f"--remote-debugging-port={port}",
48
+ f"--user-data-dir={user_data_dir}",
49
+ f"--profile-directory={profile_name}",
50
+ "--no-first-run",
51
+ "--no-default-browser-check",
52
+ "--new-window",
53
+ "--disable-features=ProcessPerSite",
54
+ "--disable-gpu",
55
+ "--disable-dev-shm-usage",
56
+ "--disable-software-rasterizer",
57
+ "about:blank",
58
+ ]
59
+
60
+ if is_headless:
61
+ cmd.append("--headless=new")
62
+
63
+ # Load unpacked extensions from an external Extensions folder
64
+ # This prevents Chrome from auto-deleting extensions it doesn't recognize
65
+ enable_extensions = os.getenv("MCP_ENABLE_EXTENSIONS", "0").strip()
66
+
67
+ # Debug logging to file
68
+ debug_log_dir = Path(tempfile.gettempdir()) / "mcp_browser_logs"
69
+ debug_log_dir.mkdir(exist_ok=True)
70
+ debug_log_file = debug_log_dir / "extension_loading_debug.log"
71
+
72
+ with open(debug_log_file, "a") as f:
73
+ f.write(f"\n=== Extension Loading Debug {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
74
+ f.write(f"MCP_ENABLE_EXTENSIONS env var: {enable_extensions}\n")
75
+ f.write(f"user_data_dir: {user_data_dir}\n")
76
+ f.write(f"profile_name: {profile_name}\n")
77
+
78
+ if enable_extensions in ("1", "true", "True", "yes", "Yes"):
79
+ # Load extensions from dedicated MCPExtensions folder
80
+ # This avoids conflicts with Chrome's internal extension management
81
+ if platform.system() == "Windows":
82
+ extensions_base_path = Path(os.path.expandvars(r"%USERPROFILE%\MCPExtensions"))
83
+ else:
84
+ extensions_base_path = Path(os.path.expanduser("~/MCPExtensions"))
85
+
86
+ with open(debug_log_file, "a") as f:
87
+ f.write(f"extensions_base_path: {extensions_base_path}\n")
88
+ f.write(f"extensions_base_path.exists(): {extensions_base_path.exists()}\n")
89
+
90
+ extension_paths = []
91
+ if extensions_base_path.exists():
92
+ # Scan all subdirectories in MCPExtensions for valid extensions
93
+ for extension_dir in extensions_base_path.iterdir():
94
+ if extension_dir.is_dir() and (extension_dir / "manifest.json").exists():
95
+ extension_paths.append(str(extension_dir))
96
+
97
+ with open(debug_log_file, "a") as f:
98
+ f.write(f"Found {len(extension_paths)} extension(s)\n")
99
+ for path in extension_paths:
100
+ f.write(f" - {path}\n")
101
+
102
+ if extension_paths:
103
+ # Join all extension paths with comma
104
+ load_ext_arg = f"--load-extension={','.join(extension_paths)}"
105
+ cmd.append(load_ext_arg)
106
+ logger.info(f"Loading {len(extension_paths)} extension(s): {extension_paths}")
107
+
108
+ with open(debug_log_file, "a") as f:
109
+ f.write(f"Added to command: {load_ext_arg}\n")
110
+ f.write(f"Full command: {' '.join(cmd)}\n")
111
+
112
+ return cmd
113
+
114
+
115
+ def launch_chrome_process(
116
+ cmd: list[str],
117
+ port: int,
118
+ ) -> subprocess.Popen:
119
+ """
120
+ Launch Chrome process and verify it started.
121
+
122
+ Consolidates duplicated launch logic from 2 locations in start_or_attach_chrome_from_env.
123
+
124
+ Args:
125
+ cmd: Command-line arguments for Chrome
126
+ port: Remote debugging port (used for logging)
127
+
128
+ Returns:
129
+ subprocess.Popen: Chrome process
130
+
131
+ Note:
132
+ Does not raise if process exits immediately; caller should check proc.poll()
133
+ """
134
+ # Create error log file
135
+ log_dir = Path(tempfile.gettempdir()) / "mcp_browser_logs"
136
+ log_dir.mkdir(exist_ok=True)
137
+ error_log = log_dir / f"chrome_debug_{port}.log"
138
+
139
+ # Launch process
140
+ if platform.system() == "Windows":
141
+ proc = subprocess.Popen(
142
+ cmd,
143
+ creationflags=subprocess.CREATE_NO_WINDOW,
144
+ stdin=subprocess.DEVNULL,
145
+ stderr=subprocess.DEVNULL,
146
+ stdout=subprocess.DEVNULL
147
+ )
148
+ else:
149
+ proc = subprocess.Popen(
150
+ cmd,
151
+ stdin=subprocess.DEVNULL,
152
+ stderr=subprocess.DEVNULL,
153
+ stdout=subprocess.DEVNULL
154
+ )
155
+
156
+ # Verify Chrome started
157
+ time.sleep(2.0)
158
+ if proc.poll() is not None:
159
+ try:
160
+ with open(error_log, "r") as log:
161
+ error_content = log.read()
162
+ logger.error(f"Chrome failed to start. Exit code: {proc.returncode}. Log: {error_content}")
163
+ except Exception:
164
+ pass
165
+
166
+ return proc
167
+
168
+
169
+ def wait_for_devtools_ready(
170
+ host: str,
171
+ port: int,
172
+ user_data_dir: str,
173
+ timeout_iterations: int = 100,
174
+ ) -> bool:
175
+ """
176
+ Wait for DevTools endpoint to become available.
177
+
178
+ Consolidates duplicated waiting logic from 2 locations in start_or_attach_chrome_from_env.
179
+
180
+ Args:
181
+ host: Debugger host (typically "127.0.0.1")
182
+ port: Remote debugging port
183
+ user_data_dir: Chrome user data directory (for error messages)
184
+ timeout_iterations: Number of 0.1s iterations to wait
185
+
186
+ Returns:
187
+ bool: True if endpoint is ready
188
+
189
+ Raises:
190
+ RuntimeError: If endpoint never appears with helpful diagnostic message
191
+ """
192
+ for _ in range(timeout_iterations):
193
+ if is_debugger_listening(host, port):
194
+ return True
195
+ time.sleep(0.1)
196
+
197
+ # If endpoint never appeared, provide helpful error
198
+ if is_chrome_running_with_userdata(user_data_dir):
199
+ raise RuntimeError(
200
+ "DevTools endpoint did not appear. Likely causes:\n"
201
+ " - Chrome refused remote debugging because the user-data-dir is a default channel directory.\n"
202
+ " - Another Chrome instance (started without --remote-debugging-port) is holding this user-data-dir.\n"
203
+ "Actions:\n"
204
+ f" - Use a separate automation dir (e.g., '{Path(user_data_dir).parent}/Chrome Beta MCP'), "
205
+ "optionally seeded from your profile, and try again.\n"
206
+ " - Or ensure all Chrome/Chrome Beta processes are fully quit before starting."
207
+ )
208
+
209
+ raise RuntimeError(f"Failed to start Chrome with remote debugging on {port}.")
210
+
211
+
212
+ def try_attach_existing_chrome(
213
+ config: dict,
214
+ host: str,
215
+ ) -> Optional[Tuple[str, int, None]]:
216
+ """
217
+ Try to attach to existing Chrome instance via DevToolsActivePort.
218
+
219
+ Args:
220
+ config: Configuration dict with user_data_dir
221
+ host: Debugger host (typically "127.0.0.1")
222
+
223
+ Returns:
224
+ Optional[Tuple[str, int, None]]: (host, port, None) if successful, None otherwise
225
+ """
226
+ user_data_dir = config["user_data_dir"]
227
+
228
+ existing_port = devtools_active_port_from_file(user_data_dir)
229
+ if existing_port and is_debugger_listening(host, existing_port):
230
+ chrome_proc = find_chrome_by_port(existing_port)
231
+ write_rendezvous(config, existing_port, chrome_proc.pid if chrome_proc else os.getpid())
232
+ return host, existing_port, None
233
+
234
+ return None
235
+
236
+
237
+ def launch_on_fixed_port(
238
+ config: dict,
239
+ host: str,
240
+ port: int,
241
+ ) -> Tuple[str, int, Optional[subprocess.Popen]]:
242
+ """
243
+ Launch Chrome on a fixed port or attach to existing instance.
244
+
245
+ Args:
246
+ config: Configuration dict with user_data_dir, profile_name
247
+ host: Debugger host (typically "127.0.0.1")
248
+ port: Fixed port to use
249
+
250
+ Returns:
251
+ Tuple[str, int, Optional[subprocess.Popen]]: (host, port, proc) where proc is None if attached to existing
252
+
253
+ Raises:
254
+ RuntimeError: If Chrome fails to start or DevTools endpoint doesn't appear
255
+ """
256
+ user_data_dir = config["user_data_dir"]
257
+ profile_name = config["profile_name"]
258
+
259
+ # Check if profile is already debuggable on a different port
260
+ existing_port = devtools_active_port_from_file(user_data_dir)
261
+ if existing_port and existing_port != port and is_debugger_listening(host, existing_port):
262
+ chrome_proc = find_chrome_by_port(existing_port)
263
+ write_rendezvous(config, existing_port, chrome_proc.pid if chrome_proc else os.getpid())
264
+ return host, existing_port, None
265
+
266
+ # Check if already listening on target port
267
+ if is_debugger_listening(host, port):
268
+ chrome_proc = find_chrome_by_port(port)
269
+ write_rendezvous(config, port, chrome_proc.pid if chrome_proc else os.getpid())
270
+ return host, port, None
271
+
272
+ # Launch Chrome on fixed port
273
+ binary = get_chrome_binary_for_platform(config)
274
+ cmd = build_chrome_command(binary, port, user_data_dir, profile_name)
275
+ proc = launch_chrome_process(cmd, port)
276
+
277
+ # Wait for DevTools
278
+ if wait_for_devtools_ready(host, port, user_data_dir):
279
+ chrome_proc = find_chrome_by_port(port)
280
+ write_rendezvous(config, port, chrome_proc.pid if chrome_proc else proc.pid)
281
+ return host, port, proc
282
+
283
+ raise RuntimeError(f"Failed to start Chrome with remote debugging on {port}.")
284
+
285
+
286
+ def launch_on_dynamic_port(
287
+ config: dict,
288
+ host: str,
289
+ ) -> Tuple[str, int, subprocess.Popen]:
290
+ """
291
+ Launch Chrome on a dynamically assigned port.
292
+
293
+ Args:
294
+ config: Configuration dict with user_data_dir, profile_name
295
+ host: Debugger host (typically "127.0.0.1")
296
+
297
+ Returns:
298
+ Tuple[str, int, subprocess.Popen]: (host, port, proc)
299
+
300
+ Raises:
301
+ RuntimeError: If Chrome fails to start or DevTools endpoint doesn't appear
302
+ """
303
+ user_data_dir = config["user_data_dir"]
304
+ profile_name = config["profile_name"]
305
+
306
+ port = get_free_port()
307
+ binary = get_chrome_binary_for_platform(config)
308
+ cmd = build_chrome_command(binary, port, user_data_dir, profile_name)
309
+ proc = launch_chrome_process(cmd, port)
310
+
311
+ # Wait for DevTools
312
+ if wait_for_devtools_ready(host, port, user_data_dir):
313
+ chrome_proc = find_chrome_by_port(port)
314
+ if chrome_proc:
315
+ write_rendezvous(config, port, chrome_proc.pid)
316
+ else:
317
+ write_rendezvous(config, port, proc.pid)
318
+ return host, port, proc
319
+
320
+ raise RuntimeError("Failed to start Chrome with remote debugging; endpoint never came up.")
321
+
322
+
323
+ __all__ = [
324
+ 'build_chrome_command',
325
+ 'launch_chrome_process',
326
+ 'wait_for_devtools_ready',
327
+ 'try_attach_existing_chrome',
328
+ 'launch_on_fixed_port',
329
+ 'launch_on_dynamic_port',
330
+ ]
@@ -0,0 +1,104 @@
1
+ """Chrome process discovery and management."""
2
+
3
+ import time
4
+ import subprocess
5
+ from typing import Optional
6
+ import psutil
7
+
8
+ import logging
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def is_chrome_running_with_userdata(user_data_dir: str) -> bool:
13
+ """
14
+ Check if any Chrome process is running with the specified user-data-dir.
15
+
16
+ Args:
17
+ user_data_dir: Path to Chrome user data directory
18
+
19
+ Returns:
20
+ bool: True if a Chrome process is found with this user-data-dir
21
+ """
22
+ for p in psutil.process_iter(["name", "cmdline"]):
23
+ try:
24
+ if not p.info["name"] or "chrome" not in p.info["name"].lower():
25
+ continue
26
+ cmd = p.info.get("cmdline") or []
27
+ if any((arg or "").startswith("--user-data-dir=") and (arg.split("=", 1)[1].strip('"') == user_data_dir)
28
+ for arg in cmd):
29
+ return True
30
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
31
+ continue
32
+ return False
33
+
34
+
35
+ def find_chrome_by_port(port: int) -> Optional[psutil.Process]:
36
+ """
37
+ Find Chrome process listening on the specified debug port.
38
+
39
+ Args:
40
+ port: Remote debugging port number
41
+
42
+ Returns:
43
+ Optional[psutil.Process]: Chrome process if found, None otherwise
44
+ """
45
+ target = f"--remote-debugging-port={port}"
46
+ for p in psutil.process_iter(["name", "cmdline", "exe"]):
47
+ try:
48
+ if not p.info["name"]:
49
+ continue
50
+ if "chrome" not in p.info["name"].lower():
51
+ continue
52
+ cmd = p.info.get("cmdline") or []
53
+ if any(target in (arg or "") for arg in cmd):
54
+ return p
55
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
56
+ continue
57
+ return None
58
+
59
+
60
+ def find_chrome_by_userdata(user_data_dir: str) -> Optional[psutil.Process]:
61
+ """
62
+ Find the first Chrome process using the specified user-data-dir.
63
+
64
+ Args:
65
+ user_data_dir: Path to Chrome user data directory
66
+
67
+ Returns:
68
+ Optional[psutil.Process]: Chrome process if found, None otherwise
69
+ """
70
+ for p in psutil.process_iter(["name", "cmdline"]):
71
+ try:
72
+ if not p.info["name"] or "chrome" not in p.info["name"].lower():
73
+ continue
74
+ cmd = p.info.get("cmdline") or []
75
+ if any((arg or "").startswith("--user-data-dir=") and
76
+ (arg.split("=", 1)[1].strip('"') == user_data_dir)
77
+ for arg in cmd):
78
+ return p
79
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
80
+ continue
81
+ return None
82
+
83
+
84
+ def wait_for_process_stable(proc: subprocess.Popen, timeout: float = 2.0) -> bool:
85
+ """
86
+ Wait for process to stabilize and check if it's still running.
87
+
88
+ Args:
89
+ proc: Subprocess to check
90
+ timeout: Time to wait in seconds
91
+
92
+ Returns:
93
+ bool: True if process is running, False if it exited
94
+ """
95
+ time.sleep(timeout)
96
+ return proc.poll() is None
97
+
98
+
99
+ __all__ = [
100
+ 'is_chrome_running_with_userdata',
101
+ 'find_chrome_by_port',
102
+ 'find_chrome_by_userdata',
103
+ 'wait_for_process_stable',
104
+ ]
@@ -0,0 +1,230 @@
1
+ """DevTools protocol operations."""
2
+
3
+ import os
4
+ import json
5
+ import time
6
+ import urllib.request
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import logging
11
+ logger = logging.getLogger(__name__)
12
+
13
+ from ..context import get_context
14
+ from ..constants import ALLOW_ATTACH_ANY
15
+
16
+
17
+ def _read_devtools_active_port(user_data_dir: str) -> int | None:
18
+ """Read debug port from DevToolsActivePort file."""
19
+ p = Path(user_data_dir) / "DevToolsActivePort"
20
+ if not p.exists():
21
+ return None
22
+ try:
23
+ first = p.read_text().splitlines()[0].strip()
24
+ return int(first)
25
+ except Exception:
26
+ return None
27
+
28
+
29
+ def _same_dir(a: str, b: str) -> bool:
30
+ """Check if two paths refer to the same directory."""
31
+ if not a or not b:
32
+ return False
33
+ try:
34
+ return Path(a).resolve() == Path(b).resolve()
35
+ except Exception:
36
+ return False
37
+
38
+
39
+ def _devtools_user_data_dir(host: str, port: int, timeout: float = 1.5) -> str | None:
40
+ """Get user data directory from DevTools protocol."""
41
+ try:
42
+ with urllib.request.urlopen(f"http://{host}:{port}/json/version", timeout=timeout) as resp:
43
+ meta = json.load(resp)
44
+ return meta.get("userDataDir")
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def _verify_port_matches_profile(host: str, port: int, expected_dir: str) -> bool:
50
+ """Verify that a debug port belongs to the expected profile."""
51
+ actual = _devtools_user_data_dir(host, port)
52
+ return _same_dir(actual, expected_dir)
53
+
54
+
55
+ def is_debugger_listening(host: str, port: int, timeout: float = 3.0) -> bool:
56
+ """Check if Chrome DevTools debugger is listening on a port."""
57
+ try:
58
+ with urllib.request.urlopen(f"http://{host}:{port}/json/version", timeout=timeout) as resp:
59
+ return resp.status == 200
60
+ except Exception:
61
+ return False
62
+
63
+
64
+ def devtools_active_port_from_file(user_data_dir: str) -> Optional[int]:
65
+ """
66
+ If Chrome is running this profile with remote debugging enabled,
67
+ it writes 'DevToolsActivePort' in the user-data-dir. Return that port if valid.
68
+ """
69
+ try:
70
+ p = Path(user_data_dir) / "DevToolsActivePort"
71
+ if not p.exists():
72
+ return None
73
+ lines = p.read_text().splitlines()
74
+ if not lines:
75
+ return None
76
+ first = lines[0].strip()
77
+ return int(first) if first.isdigit() else None
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ def _ensure_debugger_ready(cfg: dict, max_wait_secs: float | None = None) -> None:
83
+ """
84
+ Ensure a debuggable Chrome is running for the configured user-data-dir,
85
+ set debugger host/port in context.
86
+ """
87
+ from .process import _is_port_open
88
+ from .chrome import start_or_attach_chrome_from_env, _launch_chrome_with_debug
89
+
90
+ ctx = get_context()
91
+
92
+ try:
93
+ host, port, _ = start_or_attach_chrome_from_env(cfg)
94
+ if not (ALLOW_ATTACH_ANY or _verify_port_matches_profile(host, port, cfg["user_data_dir"])):
95
+ raise RuntimeError("DevTools port does not belong to the configured profile")
96
+ ctx.debugger_host = host
97
+ ctx.debugger_port = port
98
+ return
99
+ except Exception:
100
+ ctx.debugger_host = None
101
+ ctx.debugger_port = None
102
+
103
+ # Allow override by env; default to 10 seconds
104
+ try:
105
+ max_wait_secs = float(os.getenv("MCP_DEVTOOLS_MAX_WAIT_SECS", "10")) if max_wait_secs is None else float(max_wait_secs)
106
+ except Exception:
107
+ max_wait_secs = 10.0
108
+
109
+ udir = cfg["user_data_dir"]
110
+ env_port = os.getenv("CHROME_REMOTE_DEBUG_PORT")
111
+ try:
112
+ env_port = int(env_port) if env_port else None
113
+ except Exception:
114
+ env_port = None
115
+
116
+ # 1) If the profile already wrote DevToolsActivePort, try to attach to that.
117
+ file_port = _read_devtools_active_port(udir)
118
+ if file_port and _is_port_open("127.0.0.1", file_port):
119
+ ctx.debugger_host = "127.0.0.1"
120
+ ctx.debugger_port = file_port
121
+ return
122
+
123
+ # 2) If allowed, attach to a known open port
124
+ if ALLOW_ATTACH_ANY:
125
+ for p in filter(None, [env_port, 9223]):
126
+ if _is_port_open("127.0.0.1", p):
127
+ ctx.debugger_host = "127.0.0.1"
128
+ ctx.debugger_port = p
129
+ return
130
+
131
+ # 3) Launch our own debuggable Chrome
132
+ port = env_port or 9225
133
+ _launch_chrome_with_debug(cfg, port)
134
+
135
+ # Wait until Chrome writes the file OR the TCP port answers
136
+ t0 = time.time()
137
+ while time.time() - t0 < max_wait_secs:
138
+ p = _read_devtools_active_port(udir)
139
+ if (p and _is_port_open("127.0.0.1", p)) or _is_port_open("127.0.0.1", port):
140
+ ctx.debugger_host = "127.0.0.1"
141
+ ctx.debugger_port = p or port
142
+ return
143
+ time.sleep(0.1)
144
+
145
+ ctx.debugger_host = None
146
+ ctx.debugger_port = None
147
+
148
+
149
+ def _handle_for_target(driver, target_id: Optional[str]) -> Optional[str]:
150
+ """Get window handle for a Chrome DevTools target ID."""
151
+ import time
152
+
153
+ if not target_id:
154
+ return None
155
+
156
+ # Fast path: Selenium handle suffix matches CDP targetId
157
+ for h in driver.window_handles:
158
+ try:
159
+ if h.endswith(target_id):
160
+ return h
161
+ except Exception:
162
+ pass
163
+
164
+ # Nudge Chrome to foreground that target, then retry
165
+ try:
166
+ driver.execute_cdp_cmd("Target.activateTarget", {"targetId": target_id})
167
+ except Exception:
168
+ pass
169
+
170
+ for _ in range(20): # ~1s total
171
+ for h in driver.window_handles:
172
+ try:
173
+ if h.endswith(target_id):
174
+ return h
175
+ except Exception:
176
+ continue
177
+ time.sleep(0.05)
178
+
179
+ # Robust path: probe handles via CDP
180
+ current = driver.current_window_handle if driver.window_handles else None
181
+ try:
182
+ for h in driver.window_handles:
183
+ try:
184
+ driver.switch_to.window(h)
185
+ info = driver.execute_cdp_cmd("Target.getTargetInfo", {}) or {}
186
+ tid = (info.get("targetInfo") or {}).get("targetId") or info.get("targetId")
187
+ if tid == target_id:
188
+ return h
189
+ except Exception:
190
+ continue
191
+
192
+ # Last resort: enumerate all targets
193
+ try:
194
+ targets = driver.execute_cdp_cmd("Target.getTargets", {}) or {}
195
+ for ti in (targets.get("targetInfos") or []):
196
+ if ti.get("targetId") == target_id:
197
+ try:
198
+ driver.execute_cdp_cmd("Target.activateTarget", {"targetId": target_id})
199
+ except Exception:
200
+ pass
201
+ # One last quick scan
202
+ for h in driver.window_handles:
203
+ try:
204
+ if h.endswith(target_id):
205
+ return h
206
+ except Exception:
207
+ continue
208
+ break
209
+ except Exception:
210
+ pass
211
+ finally:
212
+ if current and current in getattr(driver, "window_handles", []):
213
+ try:
214
+ driver.switch_to.window(current)
215
+ except Exception:
216
+ pass
217
+
218
+ return None
219
+
220
+
221
+ __all__ = [
222
+ '_read_devtools_active_port',
223
+ 'devtools_active_port_from_file',
224
+ '_devtools_user_data_dir',
225
+ '_verify_port_matches_profile',
226
+ '_same_dir',
227
+ 'is_debugger_listening',
228
+ '_ensure_debugger_ready',
229
+ '_handle_for_target',
230
+ ]