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    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
77/// Hint shown when Firefox fails the way the macOS 27 TCC issue makes it fail.
78const 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
84/// Workaround for Firefox < 158 on macOS 27: Gecko resolves the shared
85/// app-data root `~/Library/Application Support/Firefox` (profiles.ini /
86/// installs.ini bookkeeping) even when `-profile` is given. macOS 27 puts
87/// that folder under TCC, so a Firefox whose responsible process is a
88/// terminal or agent host without Full Disk Access exits with "Could not
89/// find profile folder." (headless) or hangs on a "Profile Missing" modal.
90///
91/// When this process is denied the same folder (it shares the responsible
92/// process with the child) and Firefox is older than 158, point
93/// CoreFoundation's notion of home at a browser-control-owned directory
94/// next to the profile. The profile and the download folder are explicit
95/// paths. Side effects inside that Firefox: file pickers start in the empty
96/// fake home and native-messaging hosts under the real
97/// `~/Library/Application Support/Mozilla` are not found. Returns `None`
98/// (launch unchanged) when the folder is readable or absent, on Firefox
99/// 158+, and off macOS.
100///
101/// Not covered: a machine where the folder does not exist yet but TCC
102/// would still deny creating it; such a launch fails with [`TCC_HINT`].
103fn 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
128/// Whether this process is denied the real Firefox app-data folder.
129fn 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
143/// The account's home directory from the user database, which is what
144/// Firefox resolves; `$HOME` is often rewritten by agent sandboxes.
145fn 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        // SAFETY: getpwuid_r writes into `pwd`/`buf`, both owned here and
153        // sized as passed; `out` is either null or points at `pwd`.
154        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            // SAFETY: pw_dir points into `buf`, NUL-terminated by libc.
165            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
175/// Wait for Firefox to print "WebDriver BiDi listening on ws://..." to its
176/// log file (we redirect stderr there because Firefox does not expose a
177/// `/json/version` HTTP endpoint).
178async 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            // A windowed Firefox hit by the TCC issue does not exit: it
216            // waits on a "Profile Missing" dialog until this timeout.
217            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    // Match: "WebDriver BiDi listening on ws://HOST:PORT" (path may be absent;
234    // Firefox typically logs without a trailing path. The actual WS endpoint
235    // for the browser is `<base>/session`).
236    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}