use crate::error::{BrowserError, Error, Result};
use chromiumoxide::browser::{Browser, BrowserConfig as CdpBrowserConfig};
use chromiumoxide::Page;
use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tracing::{debug, info, instrument, warn};
#[derive(Debug, Clone)]
pub struct BrowserConfig {
pub headless: bool,
pub width: u32,
pub height: u32,
pub sandbox: bool,
pub user_agent: Option<String>,
pub timeout_ms: u64,
pub chrome_path: Option<String>,
pub stealth: bool,
pub extra_args: Vec<String>,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
headless: true,
width: 1920,
height: 1080,
sandbox: true,
user_agent: None,
timeout_ms: 30000,
chrome_path: None,
stealth: true,
extra_args: Vec::new(),
}
}
}
impl BrowserConfig {
pub fn builder() -> BrowserConfigBuilder {
BrowserConfigBuilder::default()
}
}
#[derive(Default)]
pub struct BrowserConfigBuilder {
config: BrowserConfig,
}
impl BrowserConfigBuilder {
pub fn headless(mut self, headless: bool) -> Self {
self.config.headless = headless;
self
}
pub fn viewport(mut self, width: u32, height: u32) -> Self {
self.config.width = width;
self.config.height = height;
self
}
pub fn sandbox(mut self, sandbox: bool) -> Self {
self.config.sandbox = sandbox;
self
}
pub fn user_agent<S: Into<String>>(mut self, ua: S) -> Self {
self.config.user_agent = Some(ua.into());
self
}
pub fn timeout_ms(mut self, ms: u64) -> Self {
self.config.timeout_ms = ms;
self
}
pub fn chrome_path<S: Into<String>>(mut self, path: S) -> Self {
self.config.chrome_path = Some(path.into());
self
}
pub fn stealth(mut self, stealth: bool) -> Self {
self.config.stealth = stealth;
self
}
pub fn arg<S: Into<String>>(mut self, arg: S) -> Self {
self.config.extra_args.push(arg.into());
self
}
pub fn build(self) -> BrowserConfig {
self.config
}
}
#[derive(Clone)]
pub struct PageHandle {
pub(crate) page: Page,
pub(crate) url: Arc<RwLock<String>>,
}
impl PageHandle {
pub fn inner(&self) -> &Page {
&self.page
}
pub async fn url(&self) -> String {
self.url.read().await.clone()
}
pub(crate) async fn set_url(&self, url: String) {
*self.url.write().await = url;
}
}
pub struct BrowserController {
browser: Browser,
handler: JoinHandle<()>,
config: BrowserConfig,
pages: Arc<RwLock<Vec<PageHandle>>>,
}
impl BrowserController {
#[instrument]
pub async fn new() -> Result<Self> {
Self::with_config(BrowserConfig::default()).await
}
#[instrument(skip(config))]
pub async fn with_config(config: BrowserConfig) -> Result<Self> {
info!(
"Launching browser with config: headless={}",
config.headless
);
let mut builder = CdpBrowserConfig::builder();
builder = builder.viewport(chromiumoxide::handler::viewport::Viewport {
width: config.width,
height: config.height,
device_scale_factor: None,
emulating_mobile: false,
is_landscape: true,
has_touch: false,
});
if !config.headless {
builder = builder.with_head();
}
if !config.sandbox {
builder = builder.arg("--no-sandbox");
}
if let Some(ref path) = config.chrome_path {
builder = builder.chrome_executable(path);
}
for arg in &config.extra_args {
builder = builder.arg(arg);
}
if config.headless {
builder = builder.arg("--use-gl=swiftshader");
builder = builder.arg("--enable-webgl");
builder = builder.arg("--ignore-gpu-blocklist");
}
let cdp_config = builder
.build()
.map_err(|e| BrowserError::ConfigError(e.to_string()))?;
let (browser, mut handler) = Browser::launch(cdp_config)
.await
.map_err(|e| BrowserError::LaunchFailed(e.to_string()))?;
let handler_task = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if event.is_err() {
warn!("Browser handler event error");
break;
}
}
debug!("Browser handler finished");
});
info!("Browser launched successfully");
Ok(Self {
browser,
handler: handler_task,
config,
pages: Arc::new(RwLock::new(Vec::new())),
})
}
#[instrument(skip(self))]
pub async fn new_page(&self) -> Result<PageHandle> {
let page = self
.browser
.new_page("about:blank")
.await
.map_err(|e| BrowserError::PageCreationFailed(e.to_string()))?;
if self.config.stealth {
super::stealth::StealthMode::apply(&page).await?;
}
let handle = PageHandle {
page,
url: Arc::new(RwLock::new("about:blank".to_string())),
};
self.pages.write().await.push(handle.clone());
debug!("Created new page");
Ok(handle)
}
#[instrument(skip(self))]
pub async fn navigate(&self, url: &str) -> Result<PageHandle> {
let page_handle = self.new_page().await?;
super::navigation::PageNavigator::goto(&page_handle, url, None).await?;
Ok(page_handle)
}
pub fn config(&self) -> &BrowserConfig {
&self.config
}
pub async fn page_count(&self) -> usize {
self.pages.read().await.len()
}
#[instrument(skip(self))]
pub async fn close(mut self) -> Result<()> {
info!("Closing browser");
self.pages.write().await.clear();
self.browser
.close()
.await
.map_err(|e| Error::cdp(e.to_string()))?;
let _ = tokio::time::timeout(Duration::from_secs(5), self.handler).await;
info!("Browser closed");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_browser_config_default() {
let config = BrowserConfig::default();
assert!(config.headless);
assert_eq!(config.width, 1920);
assert_eq!(config.height, 1080);
assert!(config.sandbox);
assert!(config.stealth);
assert_eq!(config.timeout_ms, 30000);
}
#[test]
fn test_browser_config_builder() {
let config = BrowserConfig::builder()
.headless(false)
.viewport(1280, 720)
.sandbox(false)
.user_agent("TestBot/1.0")
.timeout_ms(60000)
.stealth(false)
.arg("--disable-gpu")
.build();
assert!(!config.headless);
assert_eq!(config.width, 1280);
assert_eq!(config.height, 720);
assert!(!config.sandbox);
assert_eq!(config.user_agent, Some("TestBot/1.0".to_string()));
assert_eq!(config.timeout_ms, 60000);
assert!(!config.stealth);
assert_eq!(config.extra_args, vec!["--disable-gpu"]);
}
}