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::{
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
67async 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 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}