diff --git a/README.md b/README.md
index 2bfba47..7d87a6e 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,10 @@ A universal browser automation library with a unified API across multiple browse
See [docs/feature-parity.md](docs/feature-parity.md) for the cross-language feature matrix and [docs/case-studies/issue-51/README.md](docs/case-studies/issue-51/README.md) for the implementation notes.
All three implementations can attach to a running Chrome-family browser over
-CDP. The JavaScript package also provides `launchAndConnectRealBrowser()` to
-find and start an installed Chrome, Edge, Brave, or Chromium with a safe,
-dedicated automation profile before attaching.
+CDP or find and start an installed Chrome, Edge, Brave, or Chromium with a
+safe, dedicated automation profile before attaching. Use
+`launchRealBrowser()` in JavaScript and `launch_real_browser()` in Python or
+Rust.
## Core Concept: Page State Machine
diff --git a/docs/feature-parity.md b/docs/feature-parity.md
index 40936ed..c8ff8ef 100644
--- a/docs/feature-parity.md
+++ b/docs/feature-parity.md
@@ -44,6 +44,17 @@ This matrix tracks the shared API surface across the maintained language impleme
| Rust API docs | `cd rust && cargo doc --no-deps --all-features` | Built by `.github/workflows/docs.yml` |
| Combined Pages artifact | Generated from both outputs | Uploaded on PRs and deployed from `main` |
+## Real-Browser Lifecycle Parity
+
+| Capability | JavaScript | Rust | Python |
+| --------------------------------------- | --------------------- | ------------------------------------ | ----------------------- |
+| Attach to an existing CDP endpoint | Playwright, Puppeteer | Chromiumoxide, Playwright, Puppeteer | Playwright, Selenium |
+| Discover an installed Chrome-family app | Linux, macOS, Windows | Linux, macOS, Windows | Linux, macOS, Windows |
+| Launch with a dedicated profile | `launchRealBrowser()` | `launch_real_browser()` | `launch_real_browser()` |
+| Loopback-only CDP readiness probe | Supported | Supported | Supported |
+| Seed cookies after connection | Supported | Supported | Supported |
+| Return browser and page handles | Raw engine handles | Shared `EngineAdapter` | Raw engine handles |
+
## Compatibility Notes
- Existing Rust aliases remain compatible: `chromiumoxide` and `cdp` parse as `EngineType::Chromiumoxide`; `fantoccini` and `webdriver` parse as `EngineType::Fantoccini`.
diff --git a/experiments/connect-real-browser-smoke.mjs b/experiments/connect-real-browser-smoke.mjs
index 52d8c48..7e8f69c 100644
--- a/experiments/connect-real-browser-smoke.mjs
+++ b/experiments/connect-real-browser-smoke.mjs
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import {
- launchAndConnectRealBrowser,
+ launchRealBrowser,
makeBrowserCommander,
} from "../js/src/index.js";
@@ -37,7 +37,7 @@ async function waitForExit(browserProcess) {
try {
for (const engine of ["playwright", "puppeteer"]) {
- const connection = await launchAndConnectRealBrowser({
+ const connection = await launchRealBrowser({
engine,
executablePath: browserExecutable,
userDataDir: path.join(temporaryDirectory, engine),
diff --git a/experiments/launch-real-browser-python-smoke.py b/experiments/launch-real-browser-python-smoke.py
new file mode 100644
index 0000000..833e290
--- /dev/null
+++ b/experiments/launch-real-browser-python-smoke.py
@@ -0,0 +1,63 @@
+"""Launch system Chrome through the Python real-browser lifecycle helper."""
+
+from __future__ import annotations
+
+import asyncio
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPOSITORY_ROOT / "python" / "src"))
+
+from browser_commander import ( # noqa: E402
+ RealBrowserOptions,
+ launch_real_browser,
+ make_browser_commander,
+)
+
+
+async def main() -> None:
+ executable = sys.argv[1] if len(sys.argv) > 1 else "/usr/bin/google-chrome"
+ profile = Path(tempfile.mkdtemp(prefix="browser-commander-python-real-"))
+ result = await launch_real_browser(
+ RealBrowserOptions(
+ engine="playwright",
+ executable_path=executable,
+ user_data_dir=str(profile),
+ headless=True,
+ args=["--no-sandbox", "--disable-dev-shm-usage"],
+ seed_cookies=[
+ {
+ "name": "attached",
+ "value": "python",
+ "url": "https://example.com",
+ }
+ ],
+ )
+ )
+
+ try:
+ await result.page.goto(
+ "data:text/html,Real browser connection works"
+ )
+ commander = make_browser_commander(
+ result.page,
+ enable_network_tracking=False,
+ enable_navigation_manager=False,
+ enable_dialog_manager=False,
+ )
+ assert await commander.count("#connected") == 1
+ await commander.destroy()
+ print("python real-browser launch-and-connect smoke test passed")
+ finally:
+ await result.browser.close()
+ if result.browser_process.returncode is None:
+ result.browser_process.terminate()
+ await result.browser_process.wait()
+ shutil.rmtree(profile, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/js/.changeset/real-browser-launch-parity.md b/js/.changeset/real-browser-launch-parity.md
new file mode 100644
index 0000000..0eea024
--- /dev/null
+++ b/js/.changeset/real-browser-launch-parity.md
@@ -0,0 +1,5 @@
+---
+'browser-commander': minor
+---
+
+Add the `launchRealBrowser()` API name for launching and attaching to a genuine installed Chrome-family browser.
diff --git a/js/README.md b/js/README.md
index de34134..1f05d6f 100644
--- a/js/README.md
+++ b/js/README.md
@@ -118,16 +118,16 @@ const { browser, page } = await connectBrowser({
const commander = makeBrowserCommander({ page });
```
-`launchAndConnectRealBrowser()` can find and start a genuine installed Chrome,
+`launchRealBrowser()` can find and start a genuine installed Chrome,
Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. It
always uses a dedicated profile; Chrome 136 and newer do not honor remote
debugging switches for the default profile. See the
[Chrome remote-debugging security change](https://developer.chrome.com/blog/remote-debugging-port).
```javascript
-import { launchAndConnectRealBrowser } from 'browser-commander';
+import { launchRealBrowser } from 'browser-commander';
-const connection = await launchAndConnectRealBrowser({
+const connection = await launchRealBrowser({
engine: 'puppeteer',
channel: 'chrome',
userDataDir: '/tmp/my-automation-profile',
@@ -140,6 +140,7 @@ await connection.browser.close();
Cookie seeding copies only cookies you explicitly provide; the helper does not
read, decrypt, or expose cookies from a browser's default profile.
+`launchAndConnectRealBrowser()` remains available as a descriptive alias.
Reuse a saved authenticated session by passing Playwright-compatible storage
state as a JSON file path or object. Cookies and localStorage are restored for
@@ -359,7 +360,7 @@ const { browser, page } = await connectBrowser({
Playwright accepts `timeout` and Puppeteer accepts `protocolTimeout`.
`storageState` can also seed Playwright-compatible cookies and localStorage.
-### launchAndConnectRealBrowser(options)
+### launchRealBrowser(options)
Start an installed browser and connect through `connectBrowser()`. Use
`channel` (`chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`,
@@ -369,6 +370,7 @@ a managed directory under `~/.browser-commander/real-browser/`, rejects known
default browser-profile paths, protects its remote-debugging arguments, and
returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path,
and profile path alongside `{ browser, page }`.
+`launchAndConnectRealBrowser()` is an alias with identical behavior.
### saveStorageState(page, filePath)
diff --git a/js/src/browser/real-browser.js b/js/src/browser/real-browser.js
index 240a838..01a4c14 100644
--- a/js/src/browser/real-browser.js
+++ b/js/src/browser/real-browser.js
@@ -362,6 +362,15 @@ export async function launchAndConnectRealBrowser(options = {}) {
return await launchAndConnectRealBrowserWithDependencies(options);
}
+/**
+ * Short Playwright-style name for {@link launchAndConnectRealBrowser}.
+ *
+ * Both names are the same function so existing callers can keep using the
+ * descriptive name while new code can use the API proposed for real-browser
+ * launch.
+ */
+export const launchRealBrowser = launchAndConnectRealBrowser;
+
/** Dependency-injected implementation used by the public helper and tests. */
export async function launchAndConnectRealBrowserWithDependencies(
options = {},
diff --git a/js/src/exports.js b/js/src/exports.js
index 93f891f..c0614e9 100644
--- a/js/src/exports.js
+++ b/js/src/exports.js
@@ -43,7 +43,10 @@ export {
// Re-export browser management
export { connectBrowser } from './browser/connector.js';
-export { launchAndConnectRealBrowser } from './browser/real-browser.js';
+export {
+ launchAndConnectRealBrowser,
+ launchRealBrowser,
+} from './browser/real-browser.js';
export { launchBrowser } from './browser/launcher.js';
export { saveStorageState } from './browser/storage-state.js';
export { emulateMedia } from './browser/media.js';
diff --git a/js/tests/unit/browser/real-browser.test.js b/js/tests/unit/browser/real-browser.test.js
index 6b07b38..763a0f1 100644
--- a/js/tests/unit/browser/real-browser.test.js
+++ b/js/tests/unit/browser/real-browser.test.js
@@ -9,8 +9,12 @@ import {
buildRealBrowserArgs,
launchAndConnectRealBrowser,
launchAndConnectRealBrowserWithDependencies,
+ launchRealBrowser,
} from '../../../src/browser/real-browser.js';
-import { launchAndConnectRealBrowser as publicHelper } from '../../../src/index.js';
+import {
+ launchAndConnectRealBrowser as publicHelper,
+ launchRealBrowser as publicShortHelper,
+} from '../../../src/index.js';
describe('launchAndConnectRealBrowser', () => {
let temporaryDirectory;
@@ -24,6 +28,8 @@ describe('launchAndConnectRealBrowser', () => {
it('is exported from the package API', () => {
assert.equal(publicHelper, launchAndConnectRealBrowser);
+ assert.equal(launchRealBrowser, launchAndConnectRealBrowser);
+ assert.equal(publicShortHelper, launchRealBrowser);
});
it('builds a loopback-only CDP command with a dedicated profile', () => {
diff --git a/python/README.md b/python/README.md
index 3ffeb35..fa60955 100644
--- a/python/README.md
+++ b/python/README.md
@@ -176,6 +176,36 @@ non-default `--user-data-dir`; remote debugging is intentionally disabled for
the default Chrome profile. Cookie seeding uses only values supplied by the
caller and does not read the default profile.
+### launch_real_browser(options)
+
+Discover and start a genuine installed Chrome, Edge, Brave, or Chromium with a
+dedicated profile, wait for its loopback CDP endpoint, and attach with
+Playwright or Selenium:
+
+```python
+from browser_commander import RealBrowserOptions, launch_real_browser
+
+result = await launch_real_browser(
+ RealBrowserOptions(
+ engine="playwright", # or "selenium"
+ channel="chrome", # chrome, msedge, brave, or chromium
+ user_data_dir="/tmp/browser-commander-profile",
+ seed_cookies=[
+ {"name": "session", "value": "saved", "url": "https://example.com"}
+ ],
+ )
+)
+
+browser, page = result.browser, result.page
+print(result.cdp_endpoint, result.executable_path)
+```
+
+The helper also supports beta/dev/canary channels and an explicit
+`executable_path`. It rejects known default browser profiles and prevents
+custom arguments from overriding its loopback address, debugging port, or
+profile. The returned `browser_process` can be terminated explicitly after
+closing the browser. `launch_and_connect_real_browser()` is an alias.
+
The `color_scheme` option emulates `prefers-color-scheme` at launch time:
```python
diff --git a/python/changelog.d/68.added.md b/python/changelog.d/68.added.md
new file mode 100644
index 0000000..3cc44b0
--- /dev/null
+++ b/python/changelog.d/68.added.md
@@ -0,0 +1,3 @@
+### Added
+
+- Added `launch_real_browser()` for discovering and starting an installed Chrome-family browser with a dedicated profile before attaching over CDP.
diff --git a/python/src/browser_commander/__init__.py b/python/src/browser_commander/__init__.py
index 547c77f..4ca7bf5 100644
--- a/python/src/browser_commander/__init__.py
+++ b/python/src/browser_commander/__init__.py
@@ -37,6 +37,8 @@
# Page trigger system
PageTriggerManager,
PlaywrightAdapter,
+ RealBrowserOptions,
+ RealBrowserResult,
ScrollResult,
ScrollVerificationResult,
SeleniumAdapter,
@@ -83,7 +85,9 @@
is_verbose_enabled,
# Element visibility
is_visible,
+ launch_and_connect_real_browser,
launch_browser,
+ launch_real_browser,
locator,
log_element_info,
make_url_condition,
@@ -149,6 +153,8 @@
# Page trigger system
"PageTriggerManager",
"PlaywrightAdapter",
+ "RealBrowserOptions",
+ "RealBrowserResult",
"ScrollResult",
"ScrollVerificationResult",
"SeleniumAdapter",
@@ -195,7 +201,9 @@
"is_verbose_enabled",
# Element visibility
"is_visible",
+ "launch_and_connect_real_browser",
"launch_browser",
+ "launch_real_browser",
"locator",
"log_element_info",
"make_browser_commander",
diff --git a/python/src/browser_commander/browser/__init__.py b/python/src/browser_commander/browser/__init__.py
index eeb1284..8902637 100644
--- a/python/src/browser_commander/browser/__init__.py
+++ b/python/src/browser_commander/browser/__init__.py
@@ -22,6 +22,12 @@
wait_for_url_stabilization,
)
from browser_commander.browser.pdf import pdf
+from browser_commander.browser.real_browser import (
+ RealBrowserOptions,
+ RealBrowserResult,
+ launch_and_connect_real_browser,
+ launch_real_browser,
+)
__all__ = [
"ConnectOptions",
@@ -29,12 +35,16 @@
"LaunchOptions",
"LaunchResult",
"NavigationVerificationResult",
+ "RealBrowserOptions",
+ "RealBrowserResult",
"WaitAfterActionResult",
"connect_browser",
"default_navigation_verification",
"emulate_media",
"goto",
+ "launch_and_connect_real_browser",
"launch_browser",
+ "launch_real_browser",
# PDF generation
"pdf",
"verify_navigation",
diff --git a/python/src/browser_commander/browser/real_browser.py b/python/src/browser_commander/browser/real_browser.py
new file mode 100644
index 0000000..1fbde36
--- /dev/null
+++ b/python/src/browser_commander/browser/real_browser.py
@@ -0,0 +1,511 @@
+"""Launch a genuine installed Chrome-family browser and attach over CDP."""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+import ntpath
+import os
+import posixpath
+import re
+import sys
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+from urllib.request import urlopen
+
+from browser_commander.browser.connector import ConnectOptions, connect_browser
+from browser_commander.browser.launcher import LaunchResult
+from browser_commander.core.engine_detection import EngineType
+
+_MANAGED_ARGUMENTS = (
+ "--remote-debugging-address",
+ "--remote-debugging-port",
+ "--user-data-dir",
+)
+
+_CHANNEL_EXECUTABLE_NAMES = {
+ "brave": ("brave-browser", "brave-browser-stable", "brave"),
+ "chrome": ("google-chrome", "google-chrome-stable", "chrome"),
+ "chrome-beta": ("google-chrome-beta",),
+ "chrome-canary": ("google-chrome-canary",),
+ "chrome-dev": ("google-chrome-unstable",),
+ "chromium": ("chromium", "chromium-browser"),
+ "msedge": ("microsoft-edge", "microsoft-edge-stable", "msedge"),
+ "msedge-beta": ("microsoft-edge-beta",),
+ "msedge-canary": ("microsoft-edge-canary",),
+ "msedge-dev": ("microsoft-edge-dev",),
+}
+
+
+@dataclass
+class RealBrowserOptions:
+ """Configuration for starting an installed browser and attaching over CDP."""
+
+ engine: EngineType = "playwright"
+ channel: str = "chrome"
+ executable_path: str | None = None
+ user_data_dir: str | None = None
+ remote_debugging_port: int = 0
+ headless: bool = False
+ args: list[str] = field(default_factory=list)
+ startup_timeout: int = 30_000
+ slow_mo: int | None = None
+ timeout: int | None = None
+ headers: dict[str, str] | None = None
+ seed_cookies: list[dict[str, Any]] = field(default_factory=list)
+ verbose: bool = False
+
+
+@dataclass
+class RealBrowserResult(LaunchResult):
+ """Connected browser handles plus spawned-process metadata."""
+
+ browser_process: Any
+ cdp_endpoint: str
+ executable_path: str
+ user_data_dir: str
+
+
+def _path_module(platform: str) -> Any:
+ return ntpath if platform == "win32" else posixpath
+
+
+def default_real_browser_user_data_dir(
+ channel: str,
+ *,
+ home_dir: str | os.PathLike[str] | None = None,
+ platform: str | None = None,
+) -> str:
+ """Return Browser Commander's managed profile path for a channel."""
+
+ selected_platform = platform or sys.platform
+ path_module = _path_module(selected_platform)
+ home = os.fspath(home_dir) if home_dir is not None else str(Path.home())
+ directory_name = re.sub(r"[^a-z0-9_.-]", "-", channel, flags=re.IGNORECASE)
+ return path_module.join(
+ home,
+ ".browser-commander",
+ "real-browser",
+ directory_name,
+ )
+
+
+def known_default_user_data_dirs(
+ *,
+ platform: str | None = None,
+ home_dir: str | os.PathLike[str] | None = None,
+ environment: Mapping[str, str] | None = None,
+) -> list[str]:
+ """Return known default Chrome-family profile roots for an OS."""
+
+ selected_platform = platform or sys.platform
+ path_module = _path_module(selected_platform)
+ home = os.fspath(home_dir) if home_dir is not None else str(Path.home())
+ selected_environment = os.environ if environment is None else environment
+
+ if selected_platform == "darwin":
+ support = path_module.join(home, "Library", "Application Support")
+ return [
+ path_module.join(support, "Google", name)
+ for name in ("Chrome", "Chrome Beta", "Chrome Canary", "Chrome Dev")
+ ] + [
+ path_module.join(support, *parts)
+ for parts in (
+ ("Chromium",),
+ ("BraveSoftware", "Brave-Browser"),
+ ("BraveSoftware", "Brave-Browser-Beta"),
+ ("BraveSoftware", "Brave-Browser-Nightly"),
+ ("Microsoft Edge",),
+ ("Microsoft Edge Beta",),
+ ("Microsoft Edge Canary",),
+ ("Microsoft Edge Dev",),
+ )
+ ]
+
+ if selected_platform == "win32":
+ local_app_data = selected_environment.get(
+ "LOCALAPPDATA",
+ path_module.join(home, "AppData", "Local"),
+ )
+ return [
+ path_module.join(local_app_data, *parts)
+ for parts in (
+ ("Google", "Chrome", "User Data"),
+ ("Google", "Chrome Beta", "User Data"),
+ ("Google", "Chrome Dev", "User Data"),
+ ("Google", "Chrome SxS", "User Data"),
+ ("Chromium", "User Data"),
+ ("BraveSoftware", "Brave-Browser", "User Data"),
+ ("BraveSoftware", "Brave-Browser-Beta", "User Data"),
+ ("BraveSoftware", "Brave-Browser-Nightly", "User Data"),
+ ("Microsoft", "Edge", "User Data"),
+ ("Microsoft", "Edge Beta", "User Data"),
+ ("Microsoft", "Edge Dev", "User Data"),
+ ("Microsoft", "Edge SxS", "User Data"),
+ )
+ ]
+
+ return [
+ path_module.join(home, ".config", *parts)
+ for parts in (
+ ("google-chrome",),
+ ("google-chrome-beta",),
+ ("google-chrome-unstable",),
+ ("chromium",),
+ ("BraveSoftware", "Brave-Browser"),
+ ("BraveSoftware", "Brave-Browser-Beta"),
+ ("BraveSoftware", "Brave-Browser-Nightly"),
+ ("microsoft-edge",),
+ ("microsoft-edge-beta",),
+ ("microsoft-edge-dev",),
+ )
+ ]
+
+
+def assert_dedicated_user_data_dir(
+ user_data_dir: str | os.PathLike[str],
+ *,
+ platform: str | None = None,
+ home_dir: str | os.PathLike[str] | None = None,
+ environment: Mapping[str, str] | None = None,
+) -> None:
+ """Reject known default browser profiles before enabling remote debugging."""
+
+ selected_platform = platform or sys.platform
+ path_module = _path_module(selected_platform)
+
+ def normalize(value: str | os.PathLike[str]) -> str:
+ normalized = path_module.normcase(path_module.abspath(os.fspath(value)))
+ return normalized.rstrip("\\/")
+
+ requested = normalize(user_data_dir)
+ defaults = known_default_user_data_dirs(
+ platform=selected_platform,
+ home_dir=home_dir,
+ environment=environment,
+ )
+ if any(normalize(directory) == requested for directory in defaults):
+ msg = (
+ "launch_real_browser requires a dedicated user_data_dir, "
+ "not a browser default profile"
+ )
+ raise ValueError(msg)
+
+
+def _browser_install_candidates(
+ channel: str,
+ *,
+ platform: str | None = None,
+ environment: Mapping[str, str] | None = None,
+ home_dir: str | os.PathLike[str] | None = None,
+) -> list[str]:
+ selected_platform = platform or sys.platform
+ selected_environment = os.environ if environment is None else environment
+ names = _CHANNEL_EXECUTABLE_NAMES.get(channel)
+ if names is None:
+ expected = ", ".join(_CHANNEL_EXECUTABLE_NAMES)
+ msg = f"Unknown browser channel: {channel}. Expected one of {expected}"
+ raise ValueError(msg)
+
+ candidates: list[str] = []
+ if selected_platform == "darwin":
+ applications = {
+ "brave": "Brave Browser.app/Contents/MacOS/Brave Browser",
+ "chrome": "Google Chrome.app/Contents/MacOS/Google Chrome",
+ "chrome-beta": "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
+ "chrome-canary": "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
+ "chrome-dev": "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
+ "chromium": "Chromium.app/Contents/MacOS/Chromium",
+ "msedge": "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+ "msedge-beta": "Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
+ "msedge-canary": "Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
+ "msedge-dev": "Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
+ }
+ relative = applications[channel]
+ candidates.append(posixpath.join("/Applications", relative))
+ home = os.fspath(home_dir) if home_dir is not None else str(Path.home())
+ candidates.append(posixpath.join(home, "Applications", relative))
+ elif selected_platform == "win32":
+ relative_paths = {
+ "brave": ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
+ "chrome": ("Google", "Chrome", "Application", "chrome.exe"),
+ "chrome-beta": ("Google", "Chrome Beta", "Application", "chrome.exe"),
+ "chrome-canary": ("Google", "Chrome SxS", "Application", "chrome.exe"),
+ "chrome-dev": ("Google", "Chrome Dev", "Application", "chrome.exe"),
+ "chromium": ("Chromium", "Application", "chrome.exe"),
+ "msedge": ("Microsoft", "Edge", "Application", "msedge.exe"),
+ "msedge-beta": ("Microsoft", "Edge Beta", "Application", "msedge.exe"),
+ "msedge-canary": ("Microsoft", "Edge SxS", "Application", "msedge.exe"),
+ "msedge-dev": ("Microsoft", "Edge Dev", "Application", "msedge.exe"),
+ }
+ roots = (
+ selected_environment.get("PROGRAMFILES"),
+ selected_environment.get("PROGRAMFILES(X86)"),
+ selected_environment.get("LOCALAPPDATA"),
+ )
+ candidates.extend(
+ ntpath.join(root, *relative_paths[channel]) for root in roots if root
+ )
+ else:
+ for name in names:
+ candidates.extend((f"/usr/bin/{name}", f"/usr/local/bin/{name}"))
+ if channel == "chrome":
+ candidates.append("/opt/google/chrome/google-chrome")
+
+ path_module = _path_module(selected_platform)
+ separator = ";" if selected_platform == "win32" else os.pathsep
+ for directory in selected_environment.get("PATH", "").split(separator):
+ if not directory:
+ continue
+ for name in names:
+ executable_name = f"{name}.exe" if selected_platform == "win32" else name
+ candidates.append(path_module.join(directory, executable_name))
+ return list(dict.fromkeys(candidates))
+
+
+def resolve_system_browser_executable(
+ *,
+ channel: str = "chrome",
+ executable_path: str | os.PathLike[str] | None = None,
+) -> str:
+ """Resolve a genuine installed Chrome-family browser executable."""
+
+ if executable_path is not None:
+ candidates = [str(Path(executable_path).expanduser().resolve())]
+ else:
+ candidates = _browser_install_candidates(channel)
+
+ for candidate in candidates:
+ if Path(candidate).is_file() and os.access(candidate, os.X_OK):
+ return candidate
+
+ if executable_path is not None:
+ msg = f"Browser executable is not accessible: {executable_path}"
+ else:
+ msg = f"Could not find an installed {channel} browser; provide executable_path"
+ raise FileNotFoundError(msg)
+
+
+def build_real_browser_args(
+ *,
+ user_data_dir: str | os.PathLike[str],
+ remote_debugging_port: int = 0,
+ headless: bool = False,
+ args: list[str] | None = None,
+) -> list[str]:
+ """Build the protected command line for the installed browser process."""
+
+ if (
+ isinstance(remote_debugging_port, bool)
+ or not isinstance(remote_debugging_port, int)
+ or not 0 <= remote_debugging_port <= 65_535
+ ):
+ msg = "remote_debugging_port must be an integer from 0 to 65535"
+ raise ValueError(msg)
+
+ extra_args = args or []
+ for argument in extra_args:
+ if any(
+ argument == managed or argument.startswith(f"{managed}=")
+ for managed in _MANAGED_ARGUMENTS
+ ):
+ msg = f"{argument} is managed by launch_real_browser"
+ raise ValueError(msg)
+
+ return [
+ "--remote-debugging-address=127.0.0.1",
+ f"--remote-debugging-port={remote_debugging_port}",
+ f"--user-data-dir={os.fspath(user_data_dir)}",
+ "--no-first-run",
+ "--no-default-browser-check",
+ *(["--headless=new"] if headless else []),
+ *extra_args,
+ ]
+
+
+def _fetch_cdp_version(endpoint: str, timeout_seconds: float) -> bool:
+ with urlopen(
+ f"{endpoint.rstrip('/')}/json/version",
+ timeout=timeout_seconds,
+ ) as response:
+ if response.status != 200:
+ return False
+ payload = json.load(response)
+ return bool(payload.get("webSocketDebuggerUrl"))
+
+
+async def wait_for_cdp_endpoint(
+ *,
+ remote_debugging_port: int,
+ user_data_dir: str | os.PathLike[str],
+ browser_process: Any,
+ startup_timeout: int = 30_000,
+) -> str:
+ """Wait until the spawned browser publishes a usable CDP endpoint."""
+
+ if startup_timeout <= 0:
+ msg = "startup_timeout must be greater than zero"
+ raise ValueError(msg)
+
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + startup_timeout / 1000
+ active_port_path = Path(user_data_dir) / "DevToolsActivePort"
+
+ while loop.time() < deadline:
+ if browser_process.returncode is not None:
+ msg = (
+ "Browser exited before its DevTools endpoint was ready "
+ f"(exit {browser_process.returncode})"
+ )
+ raise RuntimeError(msg)
+
+ port = remote_debugging_port
+ if port == 0:
+ try:
+ port = int(active_port_path.read_text(encoding="utf-8").splitlines()[0])
+ except (OSError, IndexError, ValueError):
+ port = 0
+
+ if port > 0:
+ endpoint = f"http://127.0.0.1:{port}"
+ remaining = max(0.001, deadline - loop.time())
+ try:
+ ready = await asyncio.to_thread(
+ _fetch_cdp_version,
+ endpoint,
+ min(remaining, 0.5),
+ )
+ if ready:
+ return endpoint
+ except (OSError, TimeoutError, ValueError, json.JSONDecodeError):
+ pass
+ await asyncio.sleep(0.1)
+
+ msg = f"Timed out after {startup_timeout}ms waiting for the DevTools endpoint"
+ raise TimeoutError(msg)
+
+
+async def _spawn_browser(
+ executable: str,
+ arguments: list[str],
+ *,
+ verbose: bool,
+) -> asyncio.subprocess.Process:
+ output = None if verbose else asyncio.subprocess.DEVNULL
+ return await asyncio.create_subprocess_exec(
+ executable,
+ *arguments,
+ stdin=asyncio.subprocess.DEVNULL,
+ stdout=output,
+ stderr=output,
+ )
+
+
+async def _resolve(value: Any) -> Any:
+ return await value if inspect.isawaitable(value) else value
+
+
+async def _terminate_process(process: Any) -> None:
+ if process.returncode is not None:
+ return
+ process.terminate()
+ wait = getattr(process, "wait", None)
+ if wait is None:
+ return
+ pending = wait()
+ if not inspect.isawaitable(pending):
+ return
+ try:
+ await asyncio.wait_for(pending, timeout=5)
+ except asyncio.TimeoutError:
+ kill = getattr(process, "kill", None)
+ if kill is not None:
+ kill()
+ await wait()
+
+
+async def launch_real_browser(
+ options: RealBrowserOptions | None = None,
+) -> RealBrowserResult:
+ """Start an installed browser with a dedicated profile and attach over CDP."""
+
+ return await launch_real_browser_with_dependencies(options or RealBrowserOptions())
+
+
+async def launch_real_browser_with_dependencies(
+ options: RealBrowserOptions,
+ *,
+ resolve_executable: Any = resolve_system_browser_executable,
+ spawn_browser: Any = _spawn_browser,
+ wait_for_endpoint: Any = wait_for_cdp_endpoint,
+ connect: Any = connect_browser,
+) -> RealBrowserResult:
+ """Dependency-injected implementation used by the public helper and tests."""
+
+ if options.engine not in ("playwright", "selenium"):
+ msg = f"Invalid engine: {options.engine}. Expected 'playwright' or 'selenium'"
+ raise ValueError(msg)
+
+ user_data_dir = options.user_data_dir or default_real_browser_user_data_dir(
+ options.channel
+ )
+ assert_dedicated_user_data_dir(user_data_dir)
+ Path(user_data_dir).mkdir(parents=True, exist_ok=True)
+
+ executable_path = await _resolve(
+ resolve_executable(
+ channel=options.channel,
+ executable_path=options.executable_path,
+ )
+ )
+ arguments = build_real_browser_args(
+ user_data_dir=user_data_dir,
+ remote_debugging_port=options.remote_debugging_port,
+ headless=options.headless,
+ args=options.args,
+ )
+ browser_process = await _resolve(
+ spawn_browser(executable_path, arguments, verbose=options.verbose)
+ )
+
+ try:
+ cdp_endpoint = await _resolve(
+ wait_for_endpoint(
+ remote_debugging_port=options.remote_debugging_port,
+ user_data_dir=user_data_dir,
+ browser_process=browser_process,
+ startup_timeout=options.startup_timeout,
+ )
+ )
+ connection = await _resolve(
+ connect(
+ ConnectOptions(
+ engine=options.engine,
+ cdp_endpoint=cdp_endpoint,
+ slow_mo=options.slow_mo,
+ timeout=options.timeout,
+ headers=options.headers,
+ seed_cookies=options.seed_cookies,
+ verbose=options.verbose,
+ )
+ )
+ )
+ except BaseException:
+ await _terminate_process(browser_process)
+ raise
+
+ return RealBrowserResult(
+ browser=connection.browser,
+ page=connection.page,
+ browser_process=browser_process,
+ cdp_endpoint=cdp_endpoint,
+ executable_path=str(executable_path),
+ user_data_dir=str(user_data_dir),
+ )
+
+
+# Retain the descriptive helper name introduced by the JavaScript API.
+launch_and_connect_real_browser = launch_real_browser
diff --git a/python/src/browser_commander/exports.py b/python/src/browser_commander/exports.py
index d5ca2b6..e1ba1ad 100644
--- a/python/src/browser_commander/exports.py
+++ b/python/src/browser_commander/exports.py
@@ -28,6 +28,12 @@
wait_for_url_stabilization,
)
from browser_commander.browser.pdf import pdf
+from browser_commander.browser.real_browser import (
+ RealBrowserOptions,
+ RealBrowserResult,
+ launch_and_connect_real_browser,
+ launch_real_browser,
+)
from browser_commander.core.constants import CHROME_ARGS, TIMING
# Re-export new core components
@@ -170,6 +176,8 @@
# Page trigger system
"PageTriggerManager",
"PlaywrightAdapter",
+ "RealBrowserOptions",
+ "RealBrowserResult",
"ScrollResult",
"ScrollVerificationResult",
"SeleniumAdapter",
@@ -219,7 +227,9 @@
"key_down",
"key_up",
# Browser management
+ "launch_and_connect_real_browser",
"launch_browser",
+ "launch_real_browser",
"locator",
"log_element_info",
"make_url_condition",
diff --git a/python/tests/unit/browser/test_real_browser.py b/python/tests/unit/browser/test_real_browser.py
new file mode 100644
index 0000000..01aa8b5
--- /dev/null
+++ b/python/tests/unit/browser/test_real_browser.py
@@ -0,0 +1,187 @@
+"""Tests for launching and attaching to genuine installed browsers."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from browser_commander import (
+ RealBrowserOptions,
+ launch_and_connect_real_browser,
+ launch_real_browser,
+)
+from browser_commander.browser.real_browser import (
+ _browser_install_candidates,
+ assert_dedicated_user_data_dir,
+ build_real_browser_args,
+ launch_real_browser_with_dependencies,
+)
+
+
+def test_public_api_exports_compatible_helper_names() -> None:
+ assert launch_and_connect_real_browser is launch_real_browser
+
+
+def test_builds_protected_loopback_command() -> None:
+ arguments = build_real_browser_args(
+ user_data_dir="/tmp/browser-commander-dedicated",
+ remote_debugging_port=9333,
+ headless=True,
+ args=["--lang=en-US"],
+ )
+
+ assert arguments == [
+ "--remote-debugging-address=127.0.0.1",
+ "--remote-debugging-port=9333",
+ "--user-data-dir=/tmp/browser-commander-dedicated",
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--headless=new",
+ "--lang=en-US",
+ ]
+
+
+def test_rejects_default_profiles_and_managed_arguments(tmp_path: Any) -> None:
+ # Keep the simulated Linux paths independent of the host running the test.
+ fake_home = "/home/tester"
+ with pytest.raises(ValueError, match="dedicated user_data_dir"):
+ assert_dedicated_user_data_dir(
+ "/home/tester/.config/google-chrome",
+ platform="linux",
+ home_dir=fake_home,
+ environment={},
+ )
+
+ with pytest.raises(ValueError, match="managed by launch_real_browser"):
+ build_real_browser_args(
+ user_data_dir=tmp_path / "dedicated",
+ args=["--remote-debugging-port=9222"],
+ )
+
+
+@pytest.mark.parametrize(
+ ("platform", "channel", "environment", "expected"),
+ [
+ (
+ "darwin",
+ "chrome",
+ {"PATH": ""},
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ ),
+ (
+ "win32",
+ "msedge",
+ {"PROGRAMFILES": r"C:\Program Files", "PATH": ""},
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
+ ),
+ (
+ "linux",
+ "brave",
+ {"PATH": "/custom/bin"},
+ "/custom/bin/brave-browser",
+ ),
+ ],
+)
+def test_discovers_standard_browser_locations_on_each_platform(
+ platform: str,
+ channel: str,
+ environment: dict[str, str],
+ expected: str,
+) -> None:
+ candidates = _browser_install_candidates(
+ channel,
+ platform=platform,
+ environment=environment,
+ home_dir="/Users/tester",
+ )
+
+ assert expected in candidates
+
+
+@pytest.mark.asyncio
+async def test_spawns_waits_connects_and_returns_process_metadata(
+ tmp_path: Any,
+) -> None:
+ calls: list[Any] = []
+
+ class FakeProcess:
+ returncode = None
+
+ def terminate(self) -> None:
+ calls.append(("terminate",))
+
+ process = FakeProcess()
+ browser = object()
+ page = object()
+
+ async def resolve_executable(**options: Any) -> str:
+ calls.append(("resolve", options))
+ return "/opt/google/chrome"
+
+ async def spawn_browser(
+ executable: str, arguments: list[str], **options: Any
+ ) -> Any:
+ calls.append(("spawn", executable, arguments, options))
+ return process
+
+ async def wait_for_endpoint(**options: Any) -> str:
+ calls.append(("wait", options))
+ return "http://127.0.0.1:9444"
+
+ async def connect(options: Any) -> Any:
+ calls.append(("connect", options))
+ from browser_commander.browser.launcher import LaunchResult
+
+ return LaunchResult(browser=browser, page=page)
+
+ result = await launch_real_browser_with_dependencies(
+ RealBrowserOptions(
+ engine="selenium",
+ channel="chrome",
+ user_data_dir=str(tmp_path / "profile"),
+ remote_debugging_port=0,
+ seed_cookies=[{"name": "SID", "value": "saved"}],
+ ),
+ resolve_executable=resolve_executable,
+ spawn_browser=spawn_browser,
+ wait_for_endpoint=wait_for_endpoint,
+ connect=connect,
+ )
+
+ assert result.browser is browser
+ assert result.page is page
+ assert result.browser_process is process
+ assert result.cdp_endpoint == "http://127.0.0.1:9444"
+ assert result.executable_path == "/opt/google/chrome"
+ assert result.user_data_dir == str(tmp_path / "profile")
+ connect_options = calls[-1][1]
+ assert connect_options.engine == "selenium"
+ assert connect_options.cdp_endpoint == "http://127.0.0.1:9444"
+ assert connect_options.seed_cookies == [{"name": "SID", "value": "saved"}]
+
+
+@pytest.mark.asyncio
+async def test_terminates_spawned_browser_when_connection_fails(tmp_path: Any) -> None:
+ terminated = False
+
+ class FakeProcess:
+ returncode = None
+
+ def terminate(self) -> None:
+ nonlocal terminated
+ terminated = True
+
+ async def connect(_options: Any) -> Any:
+ raise RuntimeError("connection failed")
+
+ with pytest.raises(RuntimeError, match="connection failed"):
+ await launch_real_browser_with_dependencies(
+ RealBrowserOptions(user_data_dir=str(tmp_path / "profile")),
+ resolve_executable=lambda **_options: "/opt/google/chrome",
+ spawn_browser=lambda *_args, **_options: FakeProcess(),
+ wait_for_endpoint=lambda **_options: "http://127.0.0.1:9222",
+ connect=connect,
+ )
+
+ assert terminated
diff --git a/rust/README.md b/rust/README.md
index c8a8bd4..1f2faa0 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -161,6 +161,38 @@ pass a non-default `--user-data-dir` together with the remote-debugging flag;
Chrome intentionally disables remote debugging for its default data directory.
Cookies can be supplied explicitly with `ConnectOptions::seed_cookies()`.
+### Launch and Connect to an Installed Browser
+
+`launch_real_browser()` discovers and starts genuine installed Chrome, Edge,
+Brave, or Chromium with a dedicated profile, waits for its loopback CDP
+endpoint, and attaches with Chromiumoxide or the Playwright/Puppeteer bridges:
+
+```rust
+use browser_commander::prelude::*;
+use serde_json::json;
+
+let result = launch_real_browser(
+ RealBrowserOptions::playwright()
+ .channel("chrome")
+ .user_data_dir("/tmp/browser-commander-profile")
+ .seed_cookies(vec![json!({
+ "name": "session",
+ "value": "saved",
+ "url": "https://example.com"
+ })])
+ .node_working_dir("./js"),
+).await?;
+
+result.page.goto("https://example.com").await?;
+println!("CDP endpoint: {}", result.cdp_endpoint);
+```
+
+An explicit `executable_path` can replace channel discovery. Known default
+profiles and custom arguments that override the loopback address, debugging
+port, or profile are rejected. `RealBrowserLaunchResult` owns a
+`browser_process` handle and terminates the spawned browser when dropped.
+`launch_and_connect_real_browser()` is an alias.
+
### Navigation
```rust
diff --git a/rust/changelog.d/68.real-browser-launch.md b/rust/changelog.d/68.real-browser-launch.md
new file mode 100644
index 0000000..5c65605
--- /dev/null
+++ b/rust/changelog.d/68.real-browser-launch.md
@@ -0,0 +1,7 @@
+---
+bump: minor
+---
+
+### Added
+
+- Added `launch_real_browser()` for discovering and starting an installed Chrome-family browser with a dedicated profile before attaching over CDP.
diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs
index e7bfd85..b18b520 100644
--- a/rust/src/browser/mod.rs
+++ b/rust/src/browser/mod.rs
@@ -10,6 +10,7 @@ pub mod launcher;
pub mod media;
pub mod navigation_ops;
pub mod node_bridge;
+pub mod real_browser;
pub use chromiumoxide_adapter::ChromiumoxidePage;
pub use connector::{connect_browser, ConnectOptions};
@@ -20,3 +21,8 @@ pub use navigation_ops::{
NavigationResult, NavigationVerificationResult, WaitUntil,
};
pub use node_bridge::NodeBridgePage;
+pub use real_browser::{
+ assert_dedicated_user_data_dir, build_real_browser_args, default_real_browser_user_data_dir,
+ launch_and_connect_real_browser, launch_real_browser, resolve_system_browser_executable,
+ BrowserProcess, RealBrowserLaunchResult, RealBrowserOptions,
+};
diff --git a/rust/src/browser/real_browser.rs b/rust/src/browser/real_browser.rs
new file mode 100644
index 0000000..6369d1d
--- /dev/null
+++ b/rust/src/browser/real_browser.rs
@@ -0,0 +1,830 @@
+//! Launch genuine installed Chrome-family browsers and attach over CDP.
+
+use std::collections::HashSet;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, ExitStatus, Stdio};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use serde_json::Value;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+
+use crate::browser::connector::{connect_browser, ConnectOptions};
+use crate::browser::launcher::{Browser, LaunchResult};
+use crate::core::engine::{EngineAdapter, EngineType};
+
+const MANAGED_ARGUMENTS: [&str; 3] = [
+ "--remote-debugging-address",
+ "--remote-debugging-port",
+ "--user-data-dir",
+];
+
+/// Options for launching an installed browser and attaching over CDP.
+#[derive(Debug, Clone)]
+pub struct RealBrowserOptions {
+ /// Browser Commander engine used after the browser starts.
+ pub engine: EngineType,
+ /// Installed Chrome-family channel to discover.
+ pub channel: String,
+ /// Explicit installed-browser executable, bypassing channel discovery.
+ pub executable_path: Option,
+ /// Dedicated, non-default browser profile.
+ pub user_data_dir: Option,
+ /// Loopback CDP port. Zero lets Chrome choose an available port.
+ pub remote_debugging_port: u16,
+ /// Run the installed browser headlessly.
+ pub headless: bool,
+ /// Additional browser arguments.
+ pub args: Vec,
+ /// Maximum time to wait for Chrome's `/json/version` endpoint.
+ pub startup_timeout: Duration,
+ /// Delay Playwright/Puppeteer operations by this many milliseconds.
+ pub slow_mo: u64,
+ /// Optional connection timeout.
+ pub timeout: Option,
+ /// Optional Puppeteer timeout for individual CDP calls.
+ pub protocol_timeout: Option,
+ /// Cookies to seed immediately after attaching.
+ pub seed_cookies: Vec,
+ /// Enable browser and connector logging.
+ pub verbose: bool,
+ /// Node.js executable for Playwright/Puppeteer bridge engines.
+ pub node_executable: Option,
+ /// Directory where Node resolves Playwright/Puppeteer.
+ pub node_working_dir: Option,
+}
+
+impl Default for RealBrowserOptions {
+ fn default() -> Self {
+ Self {
+ engine: EngineType::Chromiumoxide,
+ channel: "chrome".to_string(),
+ executable_path: None,
+ user_data_dir: None,
+ remote_debugging_port: 0,
+ headless: false,
+ args: Vec::new(),
+ startup_timeout: Duration::from_secs(30),
+ slow_mo: 0,
+ timeout: None,
+ protocol_timeout: None,
+ seed_cookies: Vec::new(),
+ verbose: false,
+ node_executable: None,
+ node_working_dir: None,
+ }
+ }
+}
+
+impl RealBrowserOptions {
+ /// Create native Chromiumoxide options.
+ pub fn chromiumoxide() -> Self {
+ Self::default()
+ }
+
+ /// Create Playwright bridge options.
+ pub fn playwright() -> Self {
+ Self {
+ engine: EngineType::Playwright,
+ slow_mo: 150,
+ ..Self::default()
+ }
+ }
+
+ /// Create Puppeteer bridge options.
+ pub fn puppeteer() -> Self {
+ Self {
+ engine: EngineType::Puppeteer,
+ ..Self::default()
+ }
+ }
+
+ /// Select an installed browser channel.
+ pub fn channel(mut self, channel: impl Into) -> Self {
+ self.channel = channel.into();
+ self
+ }
+
+ /// Select an explicit installed-browser executable.
+ pub fn executable_path(mut self, executable_path: impl Into) -> Self {
+ self.executable_path = Some(executable_path.into());
+ self
+ }
+
+ /// Select a dedicated browser profile.
+ pub fn user_data_dir(mut self, user_data_dir: impl Into) -> Self {
+ self.user_data_dir = Some(user_data_dir.into());
+ self
+ }
+
+ /// Select a loopback CDP port. Zero asks Chrome to allocate one.
+ pub fn remote_debugging_port(mut self, port: u16) -> Self {
+ self.remote_debugging_port = port;
+ self
+ }
+
+ /// Enable or disable headless mode.
+ pub fn headless(mut self, headless: bool) -> Self {
+ self.headless = headless;
+ self
+ }
+
+ /// Set additional browser arguments.
+ pub fn with_args(mut self, args: Vec) -> Self {
+ self.args = args;
+ self
+ }
+
+ /// Set the CDP readiness timeout.
+ pub fn startup_timeout(mut self, timeout: Duration) -> Self {
+ self.startup_timeout = timeout;
+ self
+ }
+
+ /// Set the engine operation delay.
+ pub fn slow_mo(mut self, milliseconds: u64) -> Self {
+ self.slow_mo = milliseconds;
+ self
+ }
+
+ /// Set the connection timeout.
+ pub fn timeout(mut self, timeout: Duration) -> Self {
+ self.timeout = Some(timeout);
+ self
+ }
+
+ /// Set Puppeteer's timeout for individual CDP calls.
+ pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
+ self.protocol_timeout = Some(timeout);
+ self
+ }
+
+ /// Seed cookies after attaching.
+ pub fn seed_cookies(mut self, cookies: Vec) -> Self {
+ self.seed_cookies = cookies;
+ self
+ }
+
+ /// Enable launch and connection logging.
+ pub fn verbose(mut self, verbose: bool) -> Self {
+ self.verbose = verbose;
+ self
+ }
+
+ /// Override the Node.js executable for bridge engines.
+ pub fn node_executable(mut self, executable: impl Into) -> Self {
+ self.node_executable = Some(executable.into());
+ self
+ }
+
+ /// Set the directory where Node resolves Playwright/Puppeteer.
+ pub fn node_working_dir(mut self, directory: impl Into) -> Self {
+ self.node_working_dir = Some(directory.into());
+ self
+ }
+
+ /// Resolve the configured or managed dedicated profile path.
+ pub fn get_user_data_dir(&self) -> PathBuf {
+ self.user_data_dir
+ .clone()
+ .unwrap_or_else(|| default_real_browser_user_data_dir(&self.channel))
+ }
+}
+
+/// Owned installed-browser process. Dropping it terminates the spawned browser.
+pub struct BrowserProcess {
+ child: Child,
+}
+
+impl BrowserProcess {
+ fn new(child: Child) -> Self {
+ Self { child }
+ }
+
+ /// Operating-system process identifier.
+ pub fn id(&self) -> u32 {
+ self.child.id()
+ }
+
+ /// Return the exit status if the browser has stopped.
+ pub fn try_wait(&mut self) -> io::Result