stygian_browser/
browser.rs1use std::time::{Duration, Instant};
27
28use chromiumoxide::Browser;
29use futures::StreamExt;
30use tokio::time::timeout;
31use tracing::{debug, info, warn};
32
33use crate::{
34 BrowserConfig,
35 error::{BrowserError, Result},
36};
37
38pub struct BrowserInstance {
45 browser: Browser,
46 config: BrowserConfig,
47 launched_at: Instant,
48 healthy: bool,
50 id: String,
52}
53
54impl BrowserInstance {
55 pub async fn launch(config: BrowserConfig) -> Result<Self> {
77 let id = ulid::Ulid::new().to_string();
78 let launch_timeout = config.launch_timeout;
79
80 info!(browser_id = %id, "Launching browser");
81
82 let args = config.effective_args();
83 debug!(browser_id = %id, ?args, "Chrome launch arguments");
84
85 let mut builder = chromiumoxide::BrowserConfig::builder();
86
87 if !config.headless {
89 builder = builder.with_head();
90 }
91
92 if let Some(path) = &config.chrome_path {
93 builder = builder.chrome_executable(path);
94 }
95
96 if let Some(dir) = &config.user_data_dir {
97 builder = builder.user_data_dir(dir);
98 }
99
100 for arg in &args {
101 builder = builder.arg(arg.as_str());
102 }
103
104 if let Some((w, h)) = config.window_size {
105 builder = builder.window_size(w, h);
106 }
107
108 let cdp_cfg = builder
109 .build()
110 .map_err(|e| BrowserError::LaunchFailed { reason: e })?;
111
112 let (browser, mut handler) = timeout(launch_timeout, Browser::launch(cdp_cfg))
113 .await
114 .map_err(|_| BrowserError::Timeout {
115 operation: "browser.launch".to_string(),
116 duration_ms: u64::try_from(launch_timeout.as_millis()).unwrap_or(u64::MAX),
117 })?
118 .map_err(|e| BrowserError::LaunchFailed {
119 reason: e.to_string(),
120 })?;
121
122 tokio::spawn(async move { while handler.next().await.is_some() {} });
125
126 info!(browser_id = %id, "Browser launched successfully");
127
128 Ok(Self {
129 browser,
130 config,
131 launched_at: Instant::now(),
132 healthy: true,
133 id,
134 })
135 }
136
137 pub const fn is_healthy_cached(&self) -> bool {
143 self.healthy
144 }
145
146 pub async fn is_healthy(&mut self) -> bool {
163 match self.health_check().await {
164 Ok(()) => true,
165 Err(e) => {
166 warn!(browser_id = %self.id, error = %e, "Health check failed");
167 false
168 }
169 }
170 }
171
172 pub async fn health_check(&mut self) -> Result<()> {
176 let op_timeout = self.config.cdp_timeout;
177
178 timeout(op_timeout, self.browser.version())
179 .await
180 .map_err(|_| {
181 self.healthy = false;
182 BrowserError::Timeout {
183 operation: "Browser.getVersion".to_string(),
184 duration_ms: u64::try_from(op_timeout.as_millis()).unwrap_or(u64::MAX),
185 }
186 })?
187 .map_err(|e| {
188 self.healthy = false;
189 BrowserError::CdpError {
190 operation: "Browser.getVersion".to_string(),
191 message: e.to_string(),
192 }
193 })?;
194
195 self.healthy = true;
196 Ok(())
197 }
198
199 pub const fn browser(&self) -> &Browser {
203 &self.browser
204 }
205
206 pub const fn browser_mut(&mut self) -> &mut Browser {
208 &mut self.browser
209 }
210
211 pub fn id(&self) -> &str {
213 &self.id
214 }
215
216 pub fn uptime(&self) -> Duration {
218 self.launched_at.elapsed()
219 }
220
221 pub const fn config(&self) -> &BrowserConfig {
223 &self.config
224 }
225
226 pub async fn shutdown(mut self) -> Result<()> {
245 info!(browser_id = %self.id, "Shutting down browser");
246
247 let op_timeout = self.config.cdp_timeout;
248
249 if let Err(e) = timeout(op_timeout, self.browser.close()).await {
250 warn!(
252 browser_id = %self.id,
253 "Browser.close timed out after {}ms: {e}",
254 op_timeout.as_millis()
255 );
256 }
257
258 self.healthy = false;
259 info!(browser_id = %self.id, "Browser shut down");
260 Ok(())
261 }
262
263 pub async fn new_page(&self) -> crate::error::Result<crate::page::PageHandle> {
285 use tokio::time::timeout;
286
287 let cdp_timeout = self.config.cdp_timeout;
288
289 let page = timeout(cdp_timeout, self.browser.new_page("about:blank"))
290 .await
291 .map_err(|_| crate::error::BrowserError::Timeout {
292 operation: "Browser.newPage".to_string(),
293 duration_ms: u64::try_from(cdp_timeout.as_millis()).unwrap_or(u64::MAX),
294 })?
295 .map_err(|e| crate::error::BrowserError::CdpError {
296 operation: "Browser.newPage".to_string(),
297 message: e.to_string(),
298 })?;
299
300 #[cfg(feature = "stealth")]
302 crate::stealth::apply_stealth_to_page(&page, &self.config).await?;
303
304 Ok(crate::page::PageHandle::new(page, cdp_timeout))
305 }
306}
307
308#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
318 fn effective_args_contain_automation_flag() {
319 let config = BrowserConfig::default();
320 let args = config.effective_args();
321 assert!(
322 args.iter().any(|a| a.contains("AutomationControlled")),
323 "Expected --disable-blink-features=AutomationControlled in args: {args:?}"
324 );
325 }
326
327 #[test]
328 fn proxy_arg_injected_when_set() {
329 let config = BrowserConfig::builder()
330 .proxy("http://proxy.example.com:8080".to_string())
331 .build();
332 let args = config.effective_args();
333 assert!(
334 args.iter().any(|a| a.contains("proxy.example.com")),
335 "Expected proxy arg in {args:?}"
336 );
337 }
338
339 #[test]
340 fn window_size_arg_injected() {
341 let config = BrowserConfig::builder().window_size(1280, 720).build();
342 let args = config.effective_args();
343 assert!(
344 args.iter().any(|a| a.contains("1280")),
345 "Expected window-size arg in {args:?}"
346 );
347 }
348
349 #[test]
350 fn browser_instance_is_send_sync() {
351 fn assert_send<T: Send>() {}
352 fn assert_sync<T: Sync>() {}
353 assert_send::<BrowserInstance>();
354 assert_sync::<BrowserInstance>();
355 }
356
357 #[test]
358 fn effective_args_include_no_sandbox() {
359 let cfg = BrowserConfig::default();
360 let args = cfg.effective_args();
361 assert!(args.iter().any(|a| a == "--no-sandbox"));
362 }
363
364 #[test]
365 fn effective_args_include_disable_dev_shm() {
366 let cfg = BrowserConfig::default();
367 let args = cfg.effective_args();
368 assert!(args.iter().any(|a| a.contains("disable-dev-shm-usage")));
369 }
370
371 #[test]
372 fn no_window_size_arg_when_none() {
373 let cfg = BrowserConfig {
374 window_size: None,
375 ..BrowserConfig::default()
376 };
377 let args = cfg.effective_args();
378 assert!(!args.iter().any(|a| a.contains("--window-size")));
379 }
380
381 #[test]
382 fn custom_arg_appended() {
383 let cfg = BrowserConfig::builder()
384 .arg("--user-agent=MyCustomBot/1.0".to_string())
385 .build();
386 let args = cfg.effective_args();
387 assert!(args.iter().any(|a| a.contains("MyCustomBot")));
388 }
389
390 #[test]
391 fn proxy_bypass_list_arg_injected() {
392 let cfg = BrowserConfig::builder()
393 .proxy("http://proxy:8080".to_string())
394 .proxy_bypass_list("<local>,localhost".to_string())
395 .build();
396 let args = cfg.effective_args();
397 assert!(args.iter().any(|a| a.contains("proxy-bypass-list")));
398 }
399
400 #[test]
401 fn headless_mode_preserved_in_config() {
402 let cfg = BrowserConfig::builder().headless(false).build();
403 assert!(!cfg.headless);
404 let cfg2 = BrowserConfig::builder().headless(true).build();
405 assert!(cfg2.headless);
406 }
407
408 #[test]
409 fn launch_timeout_default_is_non_zero() {
410 let cfg = BrowserConfig::default();
411 assert!(!cfg.launch_timeout.is_zero());
412 }
413
414 #[test]
415 fn cdp_timeout_default_is_non_zero() {
416 let cfg = BrowserConfig::default();
417 assert!(!cfg.cdp_timeout.is_zero());
418 }
419}