browser_control/launch/
firefox.rs1use 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
53async 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 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}