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