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