mirror of
https://github.com/browser-use/browser-use
synced 2026-04-22 17:45:09 +02:00
326 lines
9.3 KiB
Python
326 lines
9.3 KiB
Python
"""Platform utilities for CLI and daemon."""
|
|
|
|
import json as _json
|
|
import os
|
|
import platform
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.request
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
|
|
def is_process_alive(pid: int) -> bool:
|
|
"""Check if a process is still running.
|
|
|
|
On Windows, os.kill(pid, 0) calls TerminateProcess — so we use
|
|
OpenProcess via ctypes instead.
|
|
"""
|
|
if sys.platform == 'win32':
|
|
import ctypes
|
|
|
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
if handle:
|
|
ctypes.windll.kernel32.CloseHandle(handle)
|
|
return True
|
|
return False
|
|
else:
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (OSError, ProcessLookupError):
|
|
return False
|
|
|
|
|
|
def validate_session_name(session: str) -> None:
|
|
"""Validate session name — reject path traversal and special characters.
|
|
|
|
Raises ValueError on invalid name.
|
|
"""
|
|
if not re.match(r'^[a-zA-Z0-9_-]+$', session):
|
|
raise ValueError(f'Invalid session name {session!r}: only letters, digits, hyphens, and underscores allowed')
|
|
|
|
|
|
def get_home_dir() -> Path:
|
|
"""Get the browser-use home directory (~/.browser-use/).
|
|
|
|
All CLI-managed files live here: config, sockets, PIDs, binaries, tunnels.
|
|
Override with BROWSER_USE_HOME env var.
|
|
"""
|
|
env = os.environ.get('BROWSER_USE_HOME')
|
|
if env:
|
|
d = Path(env).expanduser()
|
|
else:
|
|
d = Path.home() / '.browser-use'
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def get_socket_path(session: str = 'default') -> str:
|
|
"""Get daemon socket path for a session.
|
|
|
|
On Windows, returns a TCP address (tcp://127.0.0.1:PORT).
|
|
On Unix, returns a Unix socket path.
|
|
"""
|
|
if sys.platform == 'win32':
|
|
port = 49152 + zlib.adler32(session.encode()) % 16383
|
|
return f'tcp://127.0.0.1:{port}'
|
|
return str(get_home_dir() / f'{session}.sock')
|
|
|
|
|
|
def get_pid_path(session: str = 'default') -> Path:
|
|
"""Get PID file path for a session."""
|
|
return get_home_dir() / f'{session}.pid'
|
|
|
|
|
|
def get_auth_token_path(session: str = 'default') -> Path:
|
|
"""Get auth token file path for a session."""
|
|
return get_home_dir() / f'{session}.token'
|
|
|
|
|
|
def find_chrome_executable() -> str | None:
|
|
"""Find Chrome/Chromium executable on the system."""
|
|
system = platform.system()
|
|
|
|
if system == 'Darwin':
|
|
# macOS
|
|
paths = [
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
]
|
|
for path in paths:
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
elif system == 'Linux':
|
|
# Linux: try common commands
|
|
for cmd in ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']:
|
|
try:
|
|
result = subprocess.run(['which', cmd], capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
elif system == 'Windows':
|
|
# Windows: check common paths
|
|
paths = [
|
|
os.path.expandvars(r'%ProgramFiles%\Google\Chrome\Application\chrome.exe'),
|
|
os.path.expandvars(r'%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe'),
|
|
os.path.expandvars(r'%LocalAppData%\Google\Chrome\Application\chrome.exe'),
|
|
]
|
|
for path in paths:
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
def get_chrome_profile_path(profile: str | None) -> str | None:
|
|
"""Get Chrome user data directory for a profile.
|
|
|
|
If profile is None, returns the default Chrome user data directory.
|
|
"""
|
|
if profile is None:
|
|
# Use default Chrome profile location
|
|
system = platform.system()
|
|
if system == 'Darwin':
|
|
return str(Path.home() / 'Library' / 'Application Support' / 'Google' / 'Chrome')
|
|
elif system == 'Linux':
|
|
base = Path.home() / '.config'
|
|
for name in ('google-chrome', 'chromium'):
|
|
if (base / name).is_dir():
|
|
return str(base / name)
|
|
return str(base / 'google-chrome')
|
|
elif system == 'Windows':
|
|
return os.path.expandvars(r'%LocalAppData%\Google\Chrome\User Data')
|
|
else:
|
|
# Return the profile name - Chrome will use it as a subdirectory
|
|
# The actual path will be user_data_dir/profile
|
|
return profile
|
|
|
|
return None
|
|
|
|
|
|
def get_chrome_user_data_dirs() -> list[Path]:
|
|
"""Return candidate Chrome/Chromium user-data directories for the current OS.
|
|
|
|
Covers Google Chrome, Chrome Canary, Chromium, and Brave on macOS/Linux/Windows.
|
|
"""
|
|
system = platform.system()
|
|
home = Path.home()
|
|
candidates: list[Path] = []
|
|
|
|
if system == 'Darwin':
|
|
base = home / 'Library' / 'Application Support'
|
|
for name in ('Google/Chrome', 'Google/Chrome Canary', 'Chromium', 'BraveSoftware/Brave-Browser'):
|
|
candidates.append(base / name)
|
|
elif system == 'Linux':
|
|
base = home / '.config'
|
|
for name in ('google-chrome', 'google-chrome-unstable', 'chromium', 'BraveSoftware/Brave-Browser'):
|
|
candidates.append(base / name)
|
|
elif system == 'Windows':
|
|
local_app_data = os.environ.get('LOCALAPPDATA', str(home / 'AppData' / 'Local'))
|
|
base = Path(local_app_data)
|
|
for name in (
|
|
'Google\\Chrome\\User Data',
|
|
'Google\\Chrome SxS\\User Data',
|
|
'Chromium\\User Data',
|
|
'BraveSoftware\\Brave-Browser\\User Data',
|
|
):
|
|
candidates.append(base / name)
|
|
|
|
return [d for d in candidates if d.is_dir()]
|
|
|
|
|
|
def discover_chrome_cdp_url() -> str:
|
|
"""Auto-discover a running Chrome instance's CDP WebSocket URL.
|
|
|
|
Strategy:
|
|
1. Read ``DevToolsActivePort`` from known Chrome data dirs.
|
|
2. Probe ``/json/version`` via HTTP to get ``webSocketDebuggerUrl``.
|
|
3. If HTTP fails, construct ``ws://`` URL directly from the port file.
|
|
4. Fallback: probe well-known port 9222.
|
|
|
|
Raises ``RuntimeError`` if no running Chrome with remote debugging is found.
|
|
"""
|
|
|
|
def _probe_http(port: int) -> str | None:
|
|
"""Try GET http://127.0.0.1:{port}/json/version and return webSocketDebuggerUrl."""
|
|
try:
|
|
req = urllib.request.Request(f'http://127.0.0.1:{port}/json/version')
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
data = _json.loads(resp.read())
|
|
url = data.get('webSocketDebuggerUrl')
|
|
if url and isinstance(url, str):
|
|
return url
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _port_is_open(port: int) -> bool:
|
|
"""Check if something is listening on 127.0.0.1:{port}."""
|
|
import socket
|
|
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
try:
|
|
s.settimeout(1)
|
|
s.connect(('127.0.0.1', port))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
s.close()
|
|
|
|
# --- Phase 1: DevToolsActivePort files ---
|
|
for data_dir in get_chrome_user_data_dirs():
|
|
port_file = data_dir / 'DevToolsActivePort'
|
|
if not port_file.is_file():
|
|
continue
|
|
try:
|
|
lines = port_file.read_text().strip().splitlines()
|
|
if not lines:
|
|
continue
|
|
port = int(lines[0].strip())
|
|
ws_path = lines[1].strip() if len(lines) > 1 else '/devtools/browser'
|
|
except (ValueError, OSError):
|
|
continue
|
|
|
|
# Try HTTP probe first (gives us the full canonical URL)
|
|
ws_url = _probe_http(port)
|
|
if ws_url:
|
|
return ws_url
|
|
|
|
# HTTP may not respond (Chrome M144+), but if the port is open, trust the file
|
|
if _port_is_open(port):
|
|
return f'ws://127.0.0.1:{port}{ws_path}'
|
|
|
|
# --- Phase 2: well-known fallback ports ---
|
|
for port in (9222,):
|
|
ws_url = _probe_http(port)
|
|
if ws_url:
|
|
return ws_url
|
|
|
|
raise RuntimeError(
|
|
'Could not discover a running Chrome instance with remote debugging enabled.\n'
|
|
'Enable remote debugging in Chrome (chrome://inspect/#remote-debugging, or launch with --remote-debugging-port=9222) and try again.'
|
|
)
|
|
|
|
|
|
def list_chrome_profiles() -> list[dict[str, str]]:
|
|
"""List available Chrome profiles with their names.
|
|
|
|
Returns:
|
|
List of dicts with 'directory' and 'name' keys, ex:
|
|
[{'directory': 'Default', 'name': 'Person 1'}, {'directory': 'Profile 1', 'name': 'Work'}]
|
|
"""
|
|
import json
|
|
|
|
user_data_dir = get_chrome_profile_path(None)
|
|
if user_data_dir is None:
|
|
return []
|
|
|
|
local_state_path = Path(user_data_dir) / 'Local State'
|
|
if not local_state_path.exists():
|
|
return []
|
|
|
|
try:
|
|
with open(local_state_path, encoding='utf-8') as f:
|
|
local_state = json.load(f)
|
|
|
|
info_cache = local_state.get('profile', {}).get('info_cache', {})
|
|
profiles = []
|
|
for directory, info in info_cache.items():
|
|
profiles.append(
|
|
{
|
|
'directory': directory,
|
|
'name': info.get('name', directory),
|
|
}
|
|
)
|
|
return sorted(profiles, key=lambda p: p['directory'])
|
|
except (json.JSONDecodeError, KeyError, OSError):
|
|
return []
|
|
|
|
|
|
def get_config_path() -> Path:
|
|
"""Get browser-use config file path."""
|
|
return get_home_dir() / 'config.json'
|
|
|
|
|
|
def get_bin_dir() -> Path:
|
|
"""Get directory for CLI-managed binaries."""
|
|
d = get_home_dir() / 'bin'
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def get_tunnel_dir() -> Path:
|
|
"""Get directory for tunnel metadata and logs."""
|
|
return get_home_dir() / 'tunnels'
|
|
|
|
|
|
def migrate_legacy_paths() -> None:
|
|
"""One-time migration of config from old XDG location to ~/.browser-use/.
|
|
|
|
Copies (not moves) config.json if old location exists and new location does not.
|
|
"""
|
|
new_config = get_home_dir() / 'config.json'
|
|
if new_config.exists():
|
|
return
|
|
|
|
# Check old XDG location
|
|
if sys.platform == 'win32':
|
|
old_base = Path(os.environ.get('APPDATA', Path.home()))
|
|
else:
|
|
old_base = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config'))
|
|
old_config = old_base / 'browser-use' / 'config.json'
|
|
|
|
if old_config.exists():
|
|
import shutil
|
|
|
|
shutil.copy2(str(old_config), str(new_config))
|
|
print(f'Migrated config from {old_config} to {new_config}', file=sys.stderr)
|