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| BrowserError::BrowserNotLaunched(
108 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| 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 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 pub async fn connect(ws_url: String) -> Result<Self> {
202 Self::connect_internal(ws_url, None).await
203 }
204
205 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 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 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(|| BrowserError::invalid_response(
265 "new_page()", "missing targetId in Target.createTarget response"
266 ))?
267 .to_string();
268
269 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(_) => {} 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 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}