Skip to main content

earl_protocol_browser/
launcher.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4use chromiumoxide::browser::BrowserConfig;
5use chromiumoxide::handler::Handler;
6use chromiumoxide::{Browser, Page};
7use futures::StreamExt;
8
9use crate::error::BrowserError;
10
11/// Platform-ordered list of Chrome binary candidates.
12///
13/// If the `EARL_BROWSER_PATH` environment variable is set, it is returned as
14/// the sole candidate (no fallbacks are tried). Otherwise, a list of
15/// well-known installation paths for the current platform is returned,
16/// followed by any matches found on `PATH` via the `which` crate.
17pub fn chrome_binary_candidates() -> Vec<PathBuf> {
18    // EARL_BROWSER_PATH override takes priority and is the only result.
19    if let Ok(p) = std::env::var("EARL_BROWSER_PATH") {
20        return vec![PathBuf::from(p)];
21    }
22
23    let mut candidates = vec![];
24
25    #[cfg(target_os = "macos")]
26    {
27        candidates.push(PathBuf::from(
28            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
29        ));
30        candidates.push(PathBuf::from(
31            "/Applications/Chromium.app/Contents/MacOS/Chromium",
32        ));
33    }
34
35    #[cfg(target_os = "linux")]
36    {
37        candidates.push(PathBuf::from("/usr/bin/google-chrome"));
38        candidates.push(PathBuf::from("/usr/bin/google-chrome-stable"));
39        candidates.push(PathBuf::from("/usr/bin/chromium-browser"));
40        candidates.push(PathBuf::from("/usr/bin/chromium"));
41    }
42
43    #[cfg(target_os = "windows")]
44    {
45        candidates.push(PathBuf::from(
46            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
47        ));
48        candidates.push(PathBuf::from(
49            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
50        ));
51    }
52
53    // PATH fallbacks via `which` crate.
54    for name in &["chrome", "google-chrome", "chromium", "chromium-browser"] {
55        if let Ok(p) = which::which(name)
56            && !candidates.contains(&p)
57        {
58            candidates.push(p);
59        }
60    }
61
62    candidates
63}
64
65/// Find the Chrome binary, returning the first path that exists.
66///
67/// Returns `BrowserError::ChromeNotFound` if none of the candidates exist.
68pub fn find_chrome() -> Result<PathBuf> {
69    let candidates = chrome_binary_candidates();
70    for path in &candidates {
71        if path.exists() {
72            return Ok(path.clone());
73        }
74    }
75    let paths = candidates
76        .iter()
77        .map(|p| format!("  - {}", p.display()))
78        .collect::<Vec<_>>()
79        .join("\n");
80    Err(BrowserError::ChromeNotFound { paths }.into())
81}
82
83/// Spawn the chromiumoxide `Handler` on a Tokio task.
84///
85/// The handler **must** be polled continuously — if it stops, all CDP commands
86/// will deadlock. This helper spawns it as a background task that runs until
87/// the handler's stream is exhausted (i.e., the browser connection closes).
88fn spawn_handler(mut handler: Handler) {
89    tokio::spawn(async move { while handler.next().await.is_some() {} });
90}
91
92/// Launch a new Chrome/Chromium instance and return the connected `Browser`.
93///
94/// The handler task is spawned automatically; callers do not need to manage it.
95/// The second element of the returned tuple is the WebSocket debug URL for the
96/// launched instance — useful for reconnecting or recording the session.
97///
98/// # Arguments
99/// * `headless` — `true` runs in headless mode (default Chrome headless); `false` shows the window.
100pub async fn launch_chrome(headless: bool) -> Result<(Browser, String)> {
101    let chrome = find_chrome()?;
102
103    let mut config_builder = BrowserConfig::builder()
104        .chrome_executable(chrome)
105        // Disable chromiumoxide's own request timeout; Earl manages timeouts externally.
106        .request_timeout(std::time::Duration::from_secs(3600));
107
108    if !headless {
109        config_builder = config_builder.with_head();
110    }
111
112    let config = config_builder
113        .build()
114        .map_err(|e| anyhow::anyhow!("browser config error: {e}"))?;
115
116    let (browser, handler) = Browser::launch(config).await.context("launching Chrome")?;
117
118    spawn_handler(handler);
119
120    let ws_url = browser.websocket_address().clone();
121    Ok((browser, ws_url))
122}
123
124/// Connect to an existing Chrome instance by WebSocket (or HTTP debug) URL.
125///
126/// The handler task is spawned automatically.
127pub async fn connect_chrome(ws_url: &str) -> Result<Browser> {
128    let (browser, handler) = Browser::connect(ws_url)
129        .await
130        .context("connecting to Chrome CDP")?;
131
132    spawn_handler(handler);
133
134    Ok(browser)
135}
136
137/// Apply Earl's default page configuration after a page is created.
138///
139/// Currently this denies all downloads so that unexpected file saves surface
140/// as an error rather than silently writing to disk.
141pub async fn configure_page(page: &Page) -> Result<()> {
142    use chromiumoxide::cdp::browser_protocol::browser::{
143        SetDownloadBehaviorBehavior, SetDownloadBehaviorParams,
144    };
145
146    // Deny all downloads by default.
147    let params = SetDownloadBehaviorParams::new(SetDownloadBehaviorBehavior::Deny);
148
149    page.execute(params)
150        .await
151        .context("setting download behavior to deny")?;
152
153    Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn chrome_binary_candidates_non_empty() {
162        let candidates = chrome_binary_candidates();
163        assert!(!candidates.is_empty());
164    }
165
166    #[test]
167    fn find_chrome_returns_result() {
168        // Should either find Chrome or return a ChromeNotFound error.
169        // Either outcome is valid — we just check it doesn't panic.
170        let _ = find_chrome();
171    }
172}