Skip to main content

browser_control/launch/
firefox.rs

1//! Firefox launcher (WebDriver BiDi via `--remote-debugging-port`, Firefox 129+).
2
3use std::fs::File;
4use std::process::Stdio;
5use std::time::Duration;
6
7use anyhow::{bail, Context, Result};
8use tokio::process::Command;
9
10use crate::detect::{Engine, Installed};
11
12use super::{allocate_free_port, configure_session_detachment, 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 log_path = opts.profile_dir.join("browser.log");
23    let log_file =
24        File::create(&log_path).with_context(|| format!("creating {}", log_path.display()))?;
25    let log_clone = log_file
26        .try_clone()
27        .context("cloning log file handle for stderr")?;
28
29    let mut cmd = Command::new(&installed.executable);
30    cmd.arg("-profile").arg(&opts.profile_dir).arg("-no-remote");
31    if opts.headless {
32        cmd.arg("-headless");
33    }
34    cmd.arg("--remote-debugging-port")
35        .arg(port.to_string())
36        .arg("about:blank");
37
38    cmd.stdin(Stdio::null())
39        .stdout(Stdio::from(log_file))
40        .stderr(Stdio::from(log_clone))
41        .kill_on_drop(false);
42    configure_session_detachment(&mut cmd);
43
44    let mut child = cmd
45        .spawn()
46        .with_context(|| format!("spawning {}", installed.executable.display()))?;
47    let pid = child.id().context("child has no pid")?;
48
49    let endpoint = wait_for_firefox_endpoint(port, &mut child, &log_path).await?;
50
51    Ok(LaunchedHandle {
52        pid,
53        port,
54        endpoint,
55        engine: Engine::Bidi,
56        profile_dir: opts.profile_dir,
57        child: Some(child),
58    })
59}
60
61/// Wait for Firefox to print "WebDriver BiDi listening on ws://..." to its
62/// log file (we redirect stderr there because Firefox does not expose a
63/// `/json/version` HTTP endpoint).
64async fn wait_for_firefox_endpoint(
65    port: u16,
66    child: &mut tokio::process::Child,
67    log_path: &std::path::Path,
68) -> Result<String> {
69    let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
70    let mut seen = 0usize;
71
72    loop {
73        if let Some(status) = child.try_wait().context("polling child status")? {
74            let log = std::fs::read_to_string(log_path).unwrap_or_default();
75            bail!(
76                "firefox exited before BiDi endpoint was advertised (status: {status}); \
77                 log ({}):\n{}",
78                log_path.display(),
79                log
80            );
81        }
82
83        if let Ok(s) = std::fs::read_to_string(log_path) {
84            if s.len() > seen {
85                for line in s[seen..].lines() {
86                    if let Some(url) = parse_bidi_url(line) {
87                        return Ok(url);
88                    }
89                }
90                seen = s.len();
91            }
92        }
93
94        if tokio::time::Instant::now() >= deadline {
95            let _ = child.start_kill();
96            bail!(
97                "timed out waiting for Firefox WebDriver BiDi endpoint on port {port}; \
98                 see log at {}",
99                log_path.display()
100            );
101        }
102        tokio::time::sleep(Duration::from_millis(100)).await;
103    }
104}
105
106fn parse_bidi_url(line: &str) -> Option<String> {
107    // Match: "WebDriver BiDi listening on ws://HOST:PORT" (path may be absent;
108    // Firefox typically logs without a trailing path. The actual WS endpoint
109    // for the browser is `<base>/session`).
110    let needle = "WebDriver BiDi listening on ";
111    let idx = line.find(needle)?;
112    let rest = &line[idx + needle.len()..];
113    let url = rest.split_whitespace().next()?.trim();
114    if !url.starts_with("ws://") && !url.starts_with("wss://") {
115        return None;
116    }
117    let trimmed = url.trim_end_matches('/');
118    Some(format!("{trimmed}/session"))
119}
120
121#[cfg(test)]
122mod tests {
123    use super::parse_bidi_url;
124
125    #[test]
126    fn parses_bidi_listening_line() {
127        let l = "WebDriver BiDi listening on ws://127.0.0.1:9876";
128        assert_eq!(
129            parse_bidi_url(l).as_deref(),
130            Some("ws://127.0.0.1:9876/session")
131        );
132    }
133
134    #[test]
135    fn ignores_unrelated_lines() {
136        assert!(parse_bidi_url("*** You are running in headless mode.").is_none());
137        assert!(parse_bidi_url("[GFX1-]: noise").is_none());
138    }
139
140    #[test]
141    fn handles_trailing_slash() {
142        let l = "WebDriver BiDi listening on ws://127.0.0.1:1234/";
143        assert_eq!(
144            parse_bidi_url(l).as_deref(),
145            Some("ws://127.0.0.1:1234/session")
146        );
147    }
148}