reasonkit_web/browser/
controller.rs

1//! Browser lifecycle management
2//!
3//! This module handles browser launch, shutdown, and page management.
4
5use crate::error::{BrowserError, Error, Result};
6use chromiumoxide::browser::{Browser, BrowserConfig as CdpBrowserConfig};
7use chromiumoxide::Page;
8use futures::StreamExt;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::RwLock;
12use tokio::task::JoinHandle;
13use tracing::{debug, info, instrument, warn};
14
15/// Configuration for browser launch
16#[derive(Debug, Clone)]
17pub struct BrowserConfig {
18    /// Run in headless mode (default: true)
19    pub headless: bool,
20    /// Browser window width (default: 1920)
21    pub width: u32,
22    /// Browser window height (default: 1080)
23    pub height: u32,
24    /// Enable sandbox (default: true for production)
25    pub sandbox: bool,
26    /// User agent string (None = use default)
27    pub user_agent: Option<String>,
28    /// Navigation timeout in milliseconds (default: 30000)
29    pub timeout_ms: u64,
30    /// Path to Chrome/Chromium executable (None = auto-detect)
31    pub chrome_path: Option<String>,
32    /// Enable stealth mode (default: true)
33    pub stealth: bool,
34    /// Additional Chrome arguments
35    pub extra_args: Vec<String>,
36}
37
38impl Default for BrowserConfig {
39    fn default() -> Self {
40        Self {
41            headless: true,
42            width: 1920,
43            height: 1080,
44            sandbox: true,
45            user_agent: None,
46            timeout_ms: 30000,
47            chrome_path: None,
48            stealth: true,
49            extra_args: Vec::new(),
50        }
51    }
52}
53
54impl BrowserConfig {
55    /// Create a new config builder
56    pub fn builder() -> BrowserConfigBuilder {
57        BrowserConfigBuilder::default()
58    }
59}
60
61/// Builder for BrowserConfig
62#[derive(Default)]
63pub struct BrowserConfigBuilder {
64    config: BrowserConfig,
65}
66
67impl BrowserConfigBuilder {
68    /// Set headless mode
69    pub fn headless(mut self, headless: bool) -> Self {
70        self.config.headless = headless;
71        self
72    }
73
74    /// Set viewport dimensions
75    pub fn viewport(mut self, width: u32, height: u32) -> Self {
76        self.config.width = width;
77        self.config.height = height;
78        self
79    }
80
81    /// Enable/disable sandbox
82    pub fn sandbox(mut self, sandbox: bool) -> Self {
83        self.config.sandbox = sandbox;
84        self
85    }
86
87    /// Set user agent
88    pub fn user_agent<S: Into<String>>(mut self, ua: S) -> Self {
89        self.config.user_agent = Some(ua.into());
90        self
91    }
92
93    /// Set navigation timeout
94    pub fn timeout_ms(mut self, ms: u64) -> Self {
95        self.config.timeout_ms = ms;
96        self
97    }
98
99    /// Set Chrome path
100    pub fn chrome_path<S: Into<String>>(mut self, path: S) -> Self {
101        self.config.chrome_path = Some(path.into());
102        self
103    }
104
105    /// Enable/disable stealth mode
106    pub fn stealth(mut self, stealth: bool) -> Self {
107        self.config.stealth = stealth;
108        self
109    }
110
111    /// Add extra Chrome argument
112    pub fn arg<S: Into<String>>(mut self, arg: S) -> Self {
113        self.config.extra_args.push(arg.into());
114        self
115    }
116
117    /// Build the config
118    pub fn build(self) -> BrowserConfig {
119        self.config
120    }
121}
122
123/// Handle to an open browser page
124#[derive(Clone)]
125pub struct PageHandle {
126    pub(crate) page: Page,
127    pub(crate) url: Arc<RwLock<String>>,
128}
129
130impl PageHandle {
131    /// Get the underlying chromiumoxide Page
132    pub fn inner(&self) -> &Page {
133        &self.page
134    }
135
136    /// Get the current URL
137    pub async fn url(&self) -> String {
138        self.url.read().await.clone()
139    }
140
141    /// Set the current URL (internal use)
142    pub(crate) async fn set_url(&self, url: String) {
143        *self.url.write().await = url;
144    }
145}
146
147/// High-level browser controller
148pub struct BrowserController {
149    browser: Browser,
150    handler: JoinHandle<()>,
151    config: BrowserConfig,
152    pages: Arc<RwLock<Vec<PageHandle>>>,
153}
154
155impl BrowserController {
156    /// Create a new browser controller with default config
157    #[instrument]
158    pub async fn new() -> Result<Self> {
159        Self::with_config(BrowserConfig::default()).await
160    }
161
162    /// Create a new browser controller with custom config
163    #[instrument(skip(config))]
164    pub async fn with_config(config: BrowserConfig) -> Result<Self> {
165        info!(
166            "Launching browser with config: headless={}",
167            config.headless
168        );
169
170        let mut builder = CdpBrowserConfig::builder();
171
172        // Set viewport
173        builder = builder.viewport(chromiumoxide::handler::viewport::Viewport {
174            width: config.width,
175            height: config.height,
176            device_scale_factor: None,
177            emulating_mobile: false,
178            is_landscape: true,
179            has_touch: false,
180        });
181
182        // Headless mode
183        if !config.headless {
184            builder = builder.with_head();
185        }
186
187        // Sandbox
188        if !config.sandbox {
189            builder = builder.arg("--no-sandbox");
190        }
191
192        // Chrome path
193        if let Some(ref path) = config.chrome_path {
194            builder = builder.chrome_executable(path);
195        }
196
197        // Extra args
198        for arg in &config.extra_args {
199            builder = builder.arg(arg);
200        }
201
202        // Ensure WebGL support in headless mode
203        if config.headless {
204            builder = builder.arg("--use-gl=swiftshader");
205            builder = builder.arg("--enable-webgl");
206            builder = builder.arg("--ignore-gpu-blocklist");
207        }
208
209        let cdp_config = builder
210            .build()
211            .map_err(|e| BrowserError::ConfigError(e.to_string()))?;
212
213        let (browser, mut handler) = Browser::launch(cdp_config)
214            .await
215            .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?;
216
217        // Spawn handler task
218        let handler_task = tokio::spawn(async move {
219            while let Some(event) = handler.next().await {
220                if event.is_err() {
221                    warn!("Browser handler event error");
222                    break;
223                }
224            }
225            debug!("Browser handler finished");
226        });
227
228        info!("Browser launched successfully");
229
230        Ok(Self {
231            browser,
232            handler: handler_task,
233            config,
234            pages: Arc::new(RwLock::new(Vec::new())),
235        })
236    }
237
238    /// Create a new page/tab
239    #[instrument(skip(self))]
240    pub async fn new_page(&self) -> Result<PageHandle> {
241        let page = self
242            .browser
243            .new_page("about:blank")
244            .await
245            .map_err(|e| BrowserError::PageCreationFailed(e.to_string()))?;
246
247        // Apply stealth mode if enabled
248        if self.config.stealth {
249            super::stealth::StealthMode::apply(&page).await?;
250        }
251
252        let handle = PageHandle {
253            page,
254            url: Arc::new(RwLock::new("about:blank".to_string())),
255        };
256
257        self.pages.write().await.push(handle.clone());
258        debug!("Created new page");
259
260        Ok(handle)
261    }
262
263    /// Navigate to URL and return page handle
264    #[instrument(skip(self))]
265    pub async fn navigate(&self, url: &str) -> Result<PageHandle> {
266        let page_handle = self.new_page().await?;
267        super::navigation::PageNavigator::goto(&page_handle, url, None).await?;
268        Ok(page_handle)
269    }
270
271    /// Get the browser configuration
272    pub fn config(&self) -> &BrowserConfig {
273        &self.config
274    }
275
276    /// Get the number of open pages
277    pub async fn page_count(&self) -> usize {
278        self.pages.read().await.len()
279    }
280
281    /// Close the browser
282    #[instrument(skip(self))]
283    pub async fn close(mut self) -> Result<()> {
284        info!("Closing browser");
285
286        // Clear pages (browser close will close all pages)
287        self.pages.write().await.clear();
288
289        // Close browser
290        self.browser
291            .close()
292            .await
293            .map_err(|e| Error::cdp(e.to_string()))?;
294
295        // Wait for handler to finish
296        let _ = tokio::time::timeout(Duration::from_secs(5), self.handler).await;
297
298        info!("Browser closed");
299        Ok(())
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_browser_config_default() {
309        let config = BrowserConfig::default();
310        assert!(config.headless);
311        assert_eq!(config.width, 1920);
312        assert_eq!(config.height, 1080);
313        assert!(config.sandbox);
314        assert!(config.stealth);
315        assert_eq!(config.timeout_ms, 30000);
316    }
317
318    #[test]
319    fn test_browser_config_builder() {
320        let config = BrowserConfig::builder()
321            .headless(false)
322            .viewport(1280, 720)
323            .sandbox(false)
324            .user_agent("TestBot/1.0")
325            .timeout_ms(60000)
326            .stealth(false)
327            .arg("--disable-gpu")
328            .build();
329
330        assert!(!config.headless);
331        assert_eq!(config.width, 1280);
332        assert_eq!(config.height, 720);
333        assert!(!config.sandbox);
334        assert_eq!(config.user_agent, Some("TestBot/1.0".to_string()));
335        assert_eq!(config.timeout_ms, 60000);
336        assert!(!config.stealth);
337        assert_eq!(config.extra_args, vec!["--disable-gpu"]);
338    }
339}