ferrous_browser/
browser.rs1use 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#[derive(Debug, Clone)]
39pub struct BrowserConfig {
40 pub headless: bool,
42 pub timeout: Duration,
44 pub viewport: (u32, u32),
46 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
61pub 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 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 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 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 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 pub async fn connect(ws_url: String) -> Result<Self> {
201 Self::connect_internal(ws_url, None).await
202 }
203
204 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 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 pub async fn new_page(&self) -> Result<Page> {
250 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 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(_) => {} 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 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}