Skip to main content

browser_control/launch/
firefox.rs

1//! Firefox launcher (WebDriver BiDi via `--remote-debugging-port`, Firefox 129+).
2
3use std::process::Stdio;
4use std::time::Duration;
5
6use anyhow::{bail, Context, Result};
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command;
9
10use crate::detect::{Engine, Installed};
11
12use super::{allocate_free_port, LaunchOpts, LaunchedHandle};
13
14pub async fn launch(installed: &Installed, opts: LaunchOpts) -> Result<LaunchedHandle> {
15    let port = allocate_free_port().context("allocating BiDi port")?;
16
17    if !opts.profile_dir.exists() {
18        std::fs::create_dir_all(&opts.profile_dir)
19            .with_context(|| format!("creating profile dir {}", opts.profile_dir.display()))?;
20    }
21
22    let mut cmd = Command::new(&installed.executable);
23    cmd.arg("-profile").arg(&opts.profile_dir).arg("-no-remote");
24    if opts.headless {
25        cmd.arg("-headless");
26    }
27    cmd.arg("--remote-debugging-port")
28        .arg(port.to_string())
29        .arg("about:blank");
30
31    cmd.stdin(Stdio::null())
32        .stdout(Stdio::piped())
33        .stderr(Stdio::piped())
34        .kill_on_drop(false);
35
36    let mut child = cmd
37        .spawn()
38        .with_context(|| format!("spawning {}", installed.executable.display()))?;
39    let pid = child.id().context("child has no pid")?;
40
41    let endpoint = wait_for_firefox_endpoint(port, &mut child).await?;
42
43    Ok(LaunchedHandle {
44        pid,
45        port,
46        endpoint,
47        engine: Engine::Bidi,
48        profile_dir: opts.profile_dir,
49        child: Some(child),
50    })
51}
52
53/// Wait for Firefox to print "WebDriver BiDi listening on ws://..." on stderr.
54///
55/// Firefox does not expose a `/json/version` HTTP endpoint, and its BiDi WS
56/// path is `/session`. We could synthesize `ws://127.0.0.1:<port>/session`
57/// directly, but reading the stderr line gives us the exact URL Firefox
58/// chose (and confirms readiness).
59async fn wait_for_firefox_endpoint(port: u16, child: &mut tokio::process::Child) -> Result<String> {
60    let stderr = child
61        .stderr
62        .take()
63        .context("firefox child has no captured stderr")?;
64    let mut reader = BufReader::new(stderr).lines();
65
66    let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
67
68    loop {
69        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
70        if remaining.is_zero() {
71            let _ = child.start_kill();
72            bail!("timed out waiting for Firefox WebDriver BiDi endpoint on port {port}");
73        }
74
75        tokio::select! {
76            line = tokio::time::timeout(remaining, reader.next_line()) => {
77                let line = line.map_err(|_| anyhow::anyhow!("timeout reading firefox stderr"))?;
78                match line {
79                    Ok(Some(l)) => {
80                        if let Some(url) = parse_bidi_url(&l) {
81                            return Ok(url);
82                        }
83                    }
84                    Ok(None) => {
85                        bail!("firefox stderr closed before BiDi endpoint was advertised");
86                    }
87                    Err(e) => bail!("reading firefox stderr: {e}"),
88                }
89            }
90            status = child.wait() => {
91                let status = status.context("waiting on firefox child")?;
92                bail!("firefox exited before BiDi endpoint was advertised (status: {status})");
93            }
94        }
95    }
96}
97
98fn parse_bidi_url(line: &str) -> Option<String> {
99    // Match: "WebDriver BiDi listening on ws://HOST:PORT" (path may be absent;
100    // Firefox typically logs without a trailing path. The actual WS endpoint
101    // for the browser is `<base>/session`).
102    let needle = "WebDriver BiDi listening on ";
103    let idx = line.find(needle)?;
104    let rest = &line[idx + needle.len()..];
105    let url = rest.split_whitespace().next()?.trim();
106    if !url.starts_with("ws://") && !url.starts_with("wss://") {
107        return None;
108    }
109    let trimmed = url.trim_end_matches('/');
110    Some(format!("{trimmed}/session"))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::parse_bidi_url;
116
117    #[test]
118    fn parses_bidi_listening_line() {
119        let l = "WebDriver BiDi listening on ws://127.0.0.1:9876";
120        assert_eq!(
121            parse_bidi_url(l).as_deref(),
122            Some("ws://127.0.0.1:9876/session")
123        );
124    }
125
126    #[test]
127    fn ignores_unrelated_lines() {
128        assert!(parse_bidi_url("*** You are running in headless mode.").is_none());
129        assert!(parse_bidi_url("[GFX1-]: noise").is_none());
130    }
131
132    #[test]
133    fn handles_trailing_slash() {
134        let l = "WebDriver BiDi listening on ws://127.0.0.1:1234/";
135        assert_eq!(
136            parse_bidi_url(l).as_deref(),
137            Some("ws://127.0.0.1:1234/session")
138        );
139    }
140}