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| BrowserError::BrowserNotLaunched(
108                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| BrowserError::BrowserNotLaunched(
158                format!("Failed to spawn Chrome at '{chrome_path}': {e}")
159            ))?;
160
161        let pid = Pid::from_raw(child.id() as i32);
162
163        // Poll until Chrome's HTTP endpoint is ready and fetch the WebSocket URL
164        let deadline = tokio::time::Instant::now() + config.timeout;
165        let ws_url = loop {
166            match reqwest::get(format!("http://localhost:{port}/json/version")).await {
167                Ok(resp) => {
168                    if let Ok(json) = resp.json::<serde_json::Value>().await {
169                        if let Some(url) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
170                            break url.to_string();
171                        }
172                    }
173                }
174                Err(_) => {}
175            }
176            if tokio::time::Instant::now() >= deadline {
177                return Err(BrowserError::BrowserNotLaunched(format!(
178                    "Chrome did not start within {}s",
179                    config.timeout.as_secs()
180                )));
181            }
182            tokio::time::sleep(Duration::from_millis(200)).await;
183        };
184
185        Self::connect_internal(ws_url, Some(pid)).await
186    }
187
188    /// Connect to a CDP WebSocket URL directly.
189    ///
190    /// # Example
191    ///
192    /// ```no_run
193    /// use ferrous_browser::Browser;
194    ///
195    /// # #[tokio::main]
196    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
197    /// let browser = Browser::connect("ws://localhost:9222".to_string()).await?;
198    /// # Ok(())
199    /// # }
200    /// ```
201    pub async fn connect(ws_url: String) -> Result<Self> {
202        Self::connect_internal(ws_url, None).await
203    }
204
205    /// Connect to a Chrome instance already running on `localhost:9222`.
206    ///
207    /// # Example
208    ///
209    /// ```no_run
210    /// use ferrous_browser::Browser;
211    ///
212    /// # #[tokio::main]
213    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
214    /// let browser = Browser::launch().await?;
215    /// # Ok(())
216    /// # }
217    /// ```
218    pub async fn launch() -> Result<Self> {
219        Self::connect("ws://localhost:9222".to_string()).await
220    }
221
222    async fn connect_internal(ws_url: String, pid: Option<Pid>) -> Result<Self> {
223        use futures_util::StreamExt;
224        let cdp = Arc::new(CDPClient::new(ws_url));
225        let ws_stream = cdp.connect().await?;
226        let (sink, stream) = ws_stream.split();
227        cdp.set_sink(sink).await;
228        let conn = Connection::new(cdp.clone(), stream);
229        tokio::spawn(conn.run());
230
231        // Enable auto-attach so new targets connect instantly without round-trip
232        cdp.send_command(
233            "Target.setAutoAttach".to_string(),
234            Some(json!({
235                "autoAttach": true,
236                "waitForDebuggerOnStart": false,
237                "flatten": true
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(|| BrowserError::invalid_response(
265                "new_page()", "missing targetId in Target.createTarget response"
266            ))?
267            .to_string();
268
269        // Wait for the automatic Target.attachedToTarget event for this targetId
270        let session_id = loop {
271            match event_rx.recv().await {
272                Ok(msg) if msg.method.as_deref() == Some("Target.attachedToTarget") => {
273                    if let Some(params) = msg.params {
274                        let msg_target_id = params
275                            .get("targetInfo")
276                            .and_then(|t| t.get("targetId"))
277                            .and_then(|t| t.as_str());
278                        if msg_target_id == Some(&target_id) {
279                            if let Some(sess_id) = params.get("sessionId").and_then(|s| s.as_str()) {
280                                break sess_id.to_string();
281                            }
282                        }
283                    }
284                }
285                Ok(_) => {} // ignore other events
286                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
287                Err(_) => {
288                    return Err(BrowserError::invalid_response(
289                        "new_page()", "event channel closed before Target.attachedToTarget"
290                    ));
291                }
292            }
293        };
294
295        let page = Page::new(target_id, session_id, self.cdp.clone());
296        self.pages.write().await.push(page.clone());
297        Ok(page)
298    }
299
300    /// Get the number of open pages/tabs.
301    pub async fn page_count(&self) -> usize {
302        self.pages.read().await.len()
303    }
304}
305
306impl Drop for Browser {
307    fn drop(&mut self) {
308        if let Some(pid) = self._child_pid {
309            let _ = nix::sys::signal::kill(pid, nix::sys::signal::SIGTERM);
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_browser_config_defaults() {
320        let cfg = BrowserConfig::default();
321        assert!(cfg.headless);
322        assert_eq!(cfg.viewport, (1280, 720));
323        assert_eq!(cfg.timeout, Duration::from_secs(30));
324        assert!(cfg.args.is_empty());
325    }
326
327    #[test]
328    fn test_browser_config_custom() {
329        let cfg = BrowserConfig {
330            headless: false,
331            timeout: Duration::from_secs(60),
332            viewport: (1920, 1080),
333            args: vec!["--disable-extensions".to_string()],
334        };
335        assert!(!cfg.headless);
336        assert_eq!(cfg.viewport, (1920, 1080));
337        assert_eq!(cfg.timeout, Duration::from_secs(60));
338        assert_eq!(cfg.args, vec!["--disable-extensions"]);
339    }
340
341    #[test]
342    fn test_free_port() {
343        let port = Browser::free_port().unwrap();
344        assert!(port > 1024);
345    }
346}