Skip to main content

ferrous_browser/
browser.rs

1use crate::cdp::CDPClient;
2use crate::connection::Connection;
3use crate::error::{BrowserError, Result};
4use crate::page::Page;
5use nix::unistd::Pid;
6use serde_json::json;
7use std::net::TcpListener;
8use std::process::Command;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::RwLock;
12
13// ── P4: BrowserConfig ────────────────────────────────────────────────────────
14
15/// Configuration options for launching a Chrome/Chromium instance.
16///
17/// Use [`BrowserConfig::default()`] to get sensible defaults, then
18/// customise the fields you need.
19///
20/// # Example
21///
22/// ```no_run
23/// use ferrous_browser::{Browser, BrowserConfig};
24/// use std::time::Duration;
25///
26/// # #[tokio::main]
27/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
28/// let config = BrowserConfig {
29///     headless: true,
30///     timeout: Duration::from_secs(60),
31///     viewport: (1920, 1080),
32///     args: vec!["--disable-extensions".to_string()],
33/// };
34/// let browser = Browser::launch_chrome(Some(config)).await?;
35/// # Ok(())
36/// # }
37/// ```
38#[derive(Debug, Clone)]
39pub struct BrowserConfig {
40    /// Run Chrome in headless mode (default: `true`).
41    pub headless: bool,
42    /// Maximum time to wait for Chrome to start (default: 30 s).
43    pub timeout: Duration,
44    /// Viewport size as `(width, height)` in logical pixels (default: `1280 x 720`).
45    pub viewport: (u32, u32),
46    /// Additional Chrome command-line arguments appended after the built-in flags.
47    pub args: Vec<String>,
48}
49
50impl Default for BrowserConfig {
51    fn default() -> Self {
52        Self {
53            headless: true,
54            timeout: Duration::from_secs(30),
55            viewport: (1280, 720),
56            args: Vec::new(),
57        }
58    }
59}
60
61// ── Browser ──────────────────────────────────────────────────────────────────
62
63/// A handle to a Chrome/Chromium browser instance.
64///
65/// # Example
66///
67/// ```no_run
68/// use ferrous_browser::{Browser, WaitUntil};
69///
70/// #[tokio::main]
71/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
72///     let browser = Browser::launch_chrome(None).await?;
73///     let page = browser.new_page().await?;
74///     page.goto("https://example.com", WaitUntil::Load).await?;
75///     Ok(())
76/// }
77/// ```
78pub struct Browser {
79    cdp: Arc<CDPClient>,
80    pages: Arc<RwLock<Vec<Page>>>,
81    _child_pid: Option<Pid>,
82}
83
84impl Browser {
85    fn find_chrome() -> Option<String> {
86        let candidates = [
87            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
88            "/Applications/Chromium.app/Contents/MacOS/Chromium",
89            "google-chrome",
90            "chromium-browser",
91            "chromium",
92            "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
93            "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
94        ];
95        for candidate in candidates {
96            if std::path::Path::new(candidate).exists() || which::which(candidate).is_ok() {
97                return Some(candidate.to_string());
98            }
99        }
100        None
101    }
102
103    /// Pick a free TCP port on localhost.
104    fn free_port() -> Result<u16> {
105        TcpListener::bind("127.0.0.1:0")
106            .map(|l| l.local_addr().unwrap().port())
107            .map_err(|e| {
108                BrowserError::BrowserNotLaunched(format!("Could not find a free port: {e}"))
109            })
110    }
111
112    /// Launch Chrome/Chromium and connect to it automatically.
113    ///
114    /// Pass `None` to use [`BrowserConfig::default`].
115    ///
116    /// # Example
117    ///
118    /// ```no_run
119    /// use ferrous_browser::{Browser, BrowserConfig};
120    ///
121    /// # #[tokio::main]
122    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
123    /// let browser = Browser::launch_chrome(None).await?;
124    ///
125    /// let config = BrowserConfig { headless: false, ..Default::default() };
126    /// let browser = Browser::launch_chrome(Some(config)).await?;
127    /// # Ok(())
128    /// # }
129    /// ```
130    pub async fn launch_chrome(config: Option<BrowserConfig>) -> Result<Self> {
131        let config = config.unwrap_or_default();
132
133        let chrome_path = Self::find_chrome().ok_or_else(|| {
134            BrowserError::BrowserNotLaunched(
135                "Chrome/Chromium not found. Install Google Chrome or set a custom path via BrowserConfig::args.".to_string(),
136            )
137        })?;
138
139        // Use a dynamically-assigned free port so multiple instances never conflict
140        let port = Self::free_port()?;
141
142        let mut chrome_args: Vec<String> = vec![
143            format!("--remote-debugging-port={port}"),
144            "--no-sandbox".to_string(),
145            "--disable-gpu".to_string(),
146            "--disable-dev-shm-usage".to_string(),
147            format!("--window-size={},{}", config.viewport.0, config.viewport.1),
148        ];
149        if config.headless {
150            chrome_args.push("--headless=new".to_string());
151        }
152        chrome_args.extend(config.args.iter().cloned());
153
154        let child = Command::new(&chrome_path)
155            .args(&chrome_args)
156            .spawn()
157            .map_err(|e| {
158                BrowserError::BrowserNotLaunched(format!(
159                    "Failed to spawn Chrome at '{chrome_path}': {e}"
160                ))
161            })?;
162
163        let pid = Pid::from_raw(child.id() as i32);
164
165        // Poll until Chrome's HTTP endpoint is ready and fetch the WebSocket URL
166        let deadline = tokio::time::Instant::now() + config.timeout;
167        let ws_url = loop {
168            if let Ok(resp) = reqwest::get(format!("http://localhost:{port}/json/version")).await {
169                if let Ok(json) = resp.json::<serde_json::Value>().await {
170                    if let Some(url) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
171                        break url.to_string();
172                    }
173                }
174            }
175            if tokio::time::Instant::now() >= deadline {
176                return Err(BrowserError::BrowserNotLaunched(format!(
177                    "Chrome did not start within {}s",
178                    config.timeout.as_secs()
179                )));
180            }
181            tokio::time::sleep(Duration::from_millis(200)).await;
182        };
183
184        Self::connect_internal(ws_url, Some(pid)).await
185    }
186
187    /// Connect to a CDP WebSocket URL directly.
188    ///
189    /// # Example
190    ///
191    /// ```no_run
192    /// use ferrous_browser::Browser;
193    ///
194    /// # #[tokio::main]
195    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
196    /// let browser = Browser::connect("ws://localhost:9222".to_string()).await?;
197    /// # Ok(())
198    /// # }
199    /// ```
200    pub async fn connect(ws_url: String) -> Result<Self> {
201        Self::connect_internal(ws_url, None).await
202    }
203
204    /// Connect to a Chrome instance already running on `localhost:9222`.
205    ///
206    /// # Example
207    ///
208    /// ```no_run
209    /// use ferrous_browser::Browser;
210    ///
211    /// # #[tokio::main]
212    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
213    /// let browser = Browser::launch().await?;
214    /// # Ok(())
215    /// # }
216    /// ```
217    pub async fn launch() -> Result<Self> {
218        Self::connect("ws://localhost:9222".to_string()).await
219    }
220
221    async fn connect_internal(ws_url: String, pid: Option<Pid>) -> Result<Self> {
222        use futures_util::StreamExt;
223        let cdp = Arc::new(CDPClient::new(ws_url));
224        let ws_stream = cdp.connect().await?;
225        let (sink, stream) = ws_stream.split();
226        cdp.set_sink(sink).await;
227        let conn = Connection::new(cdp.clone(), stream);
228        tokio::spawn(conn.run());
229
230        // Enable auto-attach so new targets connect instantly without round-trip
231        cdp.send_command(
232            "Target.setAutoAttach".to_string(),
233            Some(json!({
234                "autoAttach": true,
235                "waitForDebuggerOnStart": false,
236                "flatten": true
237            })),
238        )
239        .await?;
240
241        Ok(Browser {
242            cdp,
243            pages: Arc::new(RwLock::new(Vec::new())),
244            _child_pid: pid,
245        })
246    }
247
248    /// Create a new page/tab in the browser.
249    pub async fn new_page(&self) -> Result<Page> {
250        // Subscribe to events BEFORE creating target so we don't miss attachedToTarget
251        let mut event_rx = self.cdp.subscribe_events();
252
253        let target_response = self
254            .cdp
255            .send_command(
256                "Target.createTarget".to_string(),
257                Some(json!({ "url": "about:blank" })),
258            )
259            .await?;
260
261        let target_id = target_response
262            .get("targetId")
263            .and_then(|v| v.as_str())
264            .ok_or_else(|| {
265                BrowserError::invalid_response(
266                    "new_page()",
267                    "missing targetId in Target.createTarget response",
268                )
269            })?
270            .to_string();
271
272        // Wait for the automatic Target.attachedToTarget event for this targetId
273        let session_id = loop {
274            match event_rx.recv().await {
275                Ok(msg) if msg.method.as_deref() == Some("Target.attachedToTarget") => {
276                    if let Some(params) = msg.params {
277                        let msg_target_id = params
278                            .get("targetInfo")
279                            .and_then(|t| t.get("targetId"))
280                            .and_then(|t| t.as_str());
281                        if msg_target_id == Some(&target_id) {
282                            if let Some(sess_id) = params.get("sessionId").and_then(|s| s.as_str())
283                            {
284                                break sess_id.to_string();
285                            }
286                        }
287                    }
288                }
289                Ok(_) => {} // ignore other events
290                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
291                Err(_) => {
292                    return Err(BrowserError::invalid_response(
293                        "new_page()",
294                        "event channel closed before Target.attachedToTarget",
295                    ));
296                }
297            }
298        };
299
300        let page = Page::new(target_id, session_id, self.cdp.clone());
301        self.pages.write().await.push(page.clone());
302        Ok(page)
303    }
304
305    /// Get the number of open pages/tabs.
306    pub async fn page_count(&self) -> usize {
307        self.pages.read().await.len()
308    }
309}
310
311impl Drop for Browser {
312    fn drop(&mut self) {
313        if let Some(pid) = self._child_pid {
314            let _ = nix::sys::signal::kill(pid, nix::sys::signal::SIGTERM);
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_browser_config_defaults() {
325        let cfg = BrowserConfig::default();
326        assert!(cfg.headless);
327        assert_eq!(cfg.viewport, (1280, 720));
328        assert_eq!(cfg.timeout, Duration::from_secs(30));
329        assert!(cfg.args.is_empty());
330    }
331
332    #[test]
333    fn test_browser_config_custom() {
334        let cfg = BrowserConfig {
335            headless: false,
336            timeout: Duration::from_secs(60),
337            viewport: (1920, 1080),
338            args: vec!["--disable-extensions".to_string()],
339        };
340        assert!(!cfg.headless);
341        assert_eq!(cfg.viewport, (1920, 1080));
342        assert_eq!(cfg.timeout, Duration::from_secs(60));
343        assert_eq!(cfg.args, vec!["--disable-extensions"]);
344    }
345
346    #[test]
347    fn test_free_port() {
348        let port = Browser::free_port().unwrap();
349        assert!(port > 1024);
350    }
351}