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 if let Some(dir) = &opts.download_dir {
26 if let Err(e) = super::download_prefs::apply_firefox(&opts.profile_dir, dir) {
27 tracing::warn!(error = %e, "writing Firefox download prefs failed");
28 }
29 }
30
31 let log_path = opts.profile_dir.join("browser.log");
32 let log_file =
33 File::create(&log_path).with_context(|| format!("creating {}", log_path.display()))?;
34 let log_clone = log_file
35 .try_clone()
36 .context("cloning log file handle for stderr")?;
37
38 let mut cmd = Command::new(&installed.executable);
39 if let Some(home) = macos_tcc_home_override(&opts.profile_dir, &installed.version) {
40 tracing::info!(home = %home.display(), "Firefox app-data is TCC-protected; launching with CFFIXED_USER_HOME");
41 cmd.env("CFFIXED_USER_HOME", home);
42 }
43 cmd.arg("-profile").arg(&opts.profile_dir).arg("-no-remote");
44 if opts.headless {
45 cmd.arg("-headless");
46 }
47 cmd.arg("--remote-debugging-port")
48 .arg(port.to_string())
49 .arg("about:blank");
50
51 cmd.stdin(Stdio::null())
52 .stdout(Stdio::from(log_file))
53 .stderr(Stdio::from(log_clone))
54 .kill_on_drop(false);
55 configure_session_detachment(&mut cmd);
56
57 let mut child = cmd
58 .spawn()
59 .with_context(|| format!("spawning {}", installed.executable.display()))?;
60 let pid = child.id().context("child has no pid")?;
61
62 let endpoint = wait_for_firefox_endpoint(port, &mut child, &log_path).await?;
63 if !opts.headless {
64 background_after_launch(pid);
65 }
66
67 Ok(LaunchedHandle {
68 pid,
69 port,
70 endpoint,
71 engine: Engine::Bidi,
72 profile_dir: opts.profile_dir,
73 child: Some(child),
74 })
75}
76
77const TCC_HINT: &str =
79 "\nhint: on macOS 27, Firefox before 158 reads ~/Library/Application Support/Firefox \
80 even with -profile, and macOS privacy protection (TCC) denies that folder to apps \
81 launched from a terminal or agent host. Update Firefox to 158 or later, or grant the \
82 host app Full Disk Access.";
83
84fn macos_tcc_home_override(
104 profile_dir: &std::path::Path,
105 version: &str,
106) -> Option<std::path::PathBuf> {
107 if !cfg!(target_os = "macos") || firefox_major(version).is_some_and(|m| m >= 158) {
108 return None;
109 }
110 if !firefox_app_data_denied() {
111 return None;
112 }
113 let name = profile_dir.file_name()?.to_string_lossy().to_string();
114 let fake = profile_dir.with_file_name(format!("{name}-home"));
115 std::fs::create_dir_all(&fake).ok()?;
116 Some(fake)
117}
118
119fn firefox_major(version: &str) -> Option<u32> {
120 version
121 .trim()
122 .split(|c: char| !c.is_ascii_digit())
123 .next()?
124 .parse()
125 .ok()
126}
127
128fn firefox_app_data_denied() -> bool {
130 if !cfg!(target_os = "macos") {
131 return false;
132 }
133 let Some(home) = account_home() else {
134 return false;
135 };
136 let app_data = home.join("Library/Application Support/Firefox");
137 matches!(
138 std::fs::read_dir(&app_data),
139 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied
140 )
141}
142
143fn account_home() -> Option<std::path::PathBuf> {
146 #[cfg(unix)]
147 {
148 use std::ffi::CStr;
149 let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
150 let mut buf = vec![0 as libc::c_char; 16 * 1024];
151 let mut out: *mut libc::passwd = std::ptr::null_mut();
152 let rc = unsafe {
155 libc::getpwuid_r(
156 libc::getuid(),
157 &mut pwd,
158 buf.as_mut_ptr(),
159 buf.len(),
160 &mut out,
161 )
162 };
163 if rc == 0 && !out.is_null() && !pwd.pw_dir.is_null() {
164 let dir = unsafe { CStr::from_ptr(pwd.pw_dir) };
166 let path = std::path::PathBuf::from(dir.to_string_lossy().into_owned());
167 if path.is_absolute() {
168 return Some(path);
169 }
170 }
171 }
172 std::env::var_os("HOME").map(std::path::PathBuf::from)
173}
174
175async fn wait_for_firefox_endpoint(
179 port: u16,
180 child: &mut tokio::process::Child,
181 log_path: &std::path::Path,
182) -> Result<String> {
183 let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
184 let mut seen = 0usize;
185
186 loop {
187 if let Some(status) = child.try_wait().context("polling child status")? {
188 let log = std::fs::read_to_string(log_path).unwrap_or_default();
189 let hint = if log.contains("Could not find profile folder") {
190 TCC_HINT
191 } else {
192 ""
193 };
194 bail!(
195 "firefox exited before BiDi endpoint was advertised (status: {status}); \
196 log ({}):\n{}{hint}",
197 log_path.display(),
198 log
199 );
200 }
201
202 if let Ok(s) = std::fs::read_to_string(log_path) {
203 if s.len() > seen {
204 for line in s[seen..].lines() {
205 if let Some(url) = parse_bidi_url(line) {
206 return Ok(url);
207 }
208 }
209 seen = s.len();
210 }
211 }
212
213 if tokio::time::Instant::now() >= deadline {
214 let _ = child.start_kill();
215 let hint = if firefox_app_data_denied() {
218 TCC_HINT
219 } else {
220 ""
221 };
222 bail!(
223 "timed out waiting for Firefox WebDriver BiDi endpoint on port {port}; \
224 see log at {}{hint}",
225 log_path.display()
226 );
227 }
228 tokio::time::sleep(Duration::from_millis(100)).await;
229 }
230}
231
232fn parse_bidi_url(line: &str) -> Option<String> {
233 let needle = "WebDriver BiDi listening on ";
237 let idx = line.find(needle)?;
238 let rest = &line[idx + needle.len()..];
239 let url = rest.split_whitespace().next()?.trim();
240 if !url.starts_with("ws://") && !url.starts_with("wss://") {
241 return None;
242 }
243 let trimmed = url.trim_end_matches('/');
244 Some(format!("{trimmed}/session"))
245}
246
247#[cfg(test)]
248mod tests {
249 use super::{firefox_major, parse_bidi_url};
250
251 #[test]
252 fn parses_firefox_major_version() {
253 assert_eq!(firefox_major("157.0"), Some(157));
254 assert_eq!(firefox_major("158.0.1"), Some(158));
255 assert_eq!(firefox_major("159.0b3"), Some(159));
256 assert_eq!(firefox_major("fake"), None);
257 }
258
259 #[test]
260 fn parses_bidi_listening_line() {
261 let l = "WebDriver BiDi listening on ws://127.0.0.1:9876";
262 assert_eq!(
263 parse_bidi_url(l).as_deref(),
264 Some("ws://127.0.0.1:9876/session")
265 );
266 }
267
268 #[test]
269 fn ignores_unrelated_lines() {
270 assert!(parse_bidi_url("*** You are running in headless mode.").is_none());
271 assert!(parse_bidi_url("[GFX1-]: noise").is_none());
272 }
273
274 #[test]
275 fn handles_trailing_slash() {
276 let l = "WebDriver BiDi listening on ws://127.0.0.1:1234/";
277 assert_eq!(
278 parse_bidi_url(l).as_deref(),
279 Some("ws://127.0.0.1:1234/session")
280 );
281 }
282}