reasonkit_web/browser/
controller.rs1use 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#[derive(Debug, Clone)]
17pub struct BrowserConfig {
18 pub headless: bool,
20 pub width: u32,
22 pub height: u32,
24 pub sandbox: bool,
26 pub user_agent: Option<String>,
28 pub timeout_ms: u64,
30 pub chrome_path: Option<String>,
32 pub stealth: bool,
34 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 pub fn builder() -> BrowserConfigBuilder {
57 BrowserConfigBuilder::default()
58 }
59}
60
61#[derive(Default)]
63pub struct BrowserConfigBuilder {
64 config: BrowserConfig,
65}
66
67impl BrowserConfigBuilder {
68 pub fn headless(mut self, headless: bool) -> Self {
70 self.config.headless = headless;
71 self
72 }
73
74 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 pub fn sandbox(mut self, sandbox: bool) -> Self {
83 self.config.sandbox = sandbox;
84 self
85 }
86
87 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 pub fn timeout_ms(mut self, ms: u64) -> Self {
95 self.config.timeout_ms = ms;
96 self
97 }
98
99 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 pub fn stealth(mut self, stealth: bool) -> Self {
107 self.config.stealth = stealth;
108 self
109 }
110
111 pub fn arg<S: Into<String>>(mut self, arg: S) -> Self {
113 self.config.extra_args.push(arg.into());
114 self
115 }
116
117 pub fn build(self) -> BrowserConfig {
119 self.config
120 }
121}
122
123#[derive(Clone)]
125pub struct PageHandle {
126 pub(crate) page: Page,
127 pub(crate) url: Arc<RwLock<String>>,
128}
129
130impl PageHandle {
131 pub fn inner(&self) -> &Page {
133 &self.page
134 }
135
136 pub async fn url(&self) -> String {
138 self.url.read().await.clone()
139 }
140
141 pub(crate) async fn set_url(&self, url: String) {
143 *self.url.write().await = url;
144 }
145}
146
147pub struct BrowserController {
149 browser: Browser,
150 handler: JoinHandle<()>,
151 config: BrowserConfig,
152 pages: Arc<RwLock<Vec<PageHandle>>>,
153}
154
155impl BrowserController {
156 #[instrument]
158 pub async fn new() -> Result<Self> {
159 Self::with_config(BrowserConfig::default()).await
160 }
161
162 #[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 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 if !config.headless {
184 builder = builder.with_head();
185 }
186
187 if !config.sandbox {
189 builder = builder.arg("--no-sandbox");
190 }
191
192 if let Some(ref path) = config.chrome_path {
194 builder = builder.chrome_executable(path);
195 }
196
197 for arg in &config.extra_args {
199 builder = builder.arg(arg);
200 }
201
202 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 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 #[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 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 #[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 pub fn config(&self) -> &BrowserConfig {
273 &self.config
274 }
275
276 pub async fn page_count(&self) -> usize {
278 self.pages.read().await.len()
279 }
280
281 #[instrument(skip(self))]
283 pub async fn close(mut self) -> Result<()> {
284 info!("Closing browser");
285
286 self.pages.write().await.clear();
288
289 self.browser
291 .close()
292 .await
293 .map_err(|e| Error::cdp(e.to_string()))?;
294
295 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}