browser_commander/browser/
launcher.rs1use std::path::PathBuf;
7use std::sync::Arc;
8use std::time::Duration;
9
10use chromiumoxide::browser::{Browser as CdpBrowser, BrowserConfig, HeadlessMode};
11use futures::StreamExt;
12
13use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
14use crate::browser::media::ColorScheme;
15use crate::browser::node_bridge::NodeBridgePage;
16use crate::core::constants::CHROME_ARGS;
17use crate::core::engine::{EngineAdapter, EngineType};
18
19#[derive(Debug, Clone)]
21pub struct LaunchOptions {
22 pub engine: EngineType,
24 pub user_data_dir: Option<PathBuf>,
26 pub headless: bool,
28 pub slow_mo: u64,
30 pub verbose: bool,
32 pub args: Vec<String>,
34 pub color_scheme: Option<ColorScheme>,
36 pub launch_timeout: Option<Duration>,
38 pub sandbox: bool,
45 pub node_executable: Option<PathBuf>,
47 pub node_working_dir: Option<PathBuf>,
49}
50
51impl Default for LaunchOptions {
52 fn default() -> Self {
53 Self {
54 engine: EngineType::Chromiumoxide,
55 user_data_dir: None,
56 headless: false,
57 slow_mo: 0,
58 verbose: false,
59 args: Vec::new(),
60 color_scheme: None,
61 launch_timeout: None,
62 sandbox: true,
63 node_executable: None,
64 node_working_dir: None,
65 }
66 }
67}
68
69impl LaunchOptions {
70 pub fn engine(mut self, engine: EngineType) -> Self {
72 self.engine = engine;
73 if engine == EngineType::Playwright && self.slow_mo == 0 {
74 self.slow_mo = 150;
75 }
76 self
77 }
78
79 pub fn chromiumoxide() -> Self {
81 Self {
82 engine: EngineType::Chromiumoxide,
83 ..Default::default()
84 }
85 }
86
87 pub fn fantoccini() -> Self {
89 Self {
90 engine: EngineType::Fantoccini,
91 ..Default::default()
92 }
93 }
94
95 pub fn playwright() -> Self {
97 Self {
98 engine: EngineType::Playwright,
99 slow_mo: 150,
100 ..Default::default()
101 }
102 }
103
104 pub fn puppeteer() -> Self {
106 Self {
107 engine: EngineType::Puppeteer,
108 ..Default::default()
109 }
110 }
111
112 pub fn headless(mut self, headless: bool) -> Self {
114 self.headless = headless;
115 self
116 }
117
118 pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
120 self.user_data_dir = Some(dir.into());
121 self
122 }
123
124 pub fn slow_mo(mut self, ms: u64) -> Self {
126 self.slow_mo = ms;
127 self
128 }
129
130 pub fn verbose(mut self, verbose: bool) -> Self {
132 self.verbose = verbose;
133 self
134 }
135
136 pub fn with_args(mut self, args: Vec<String>) -> Self {
138 self.args = args;
139 self
140 }
141
142 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
144 self.color_scheme = Some(color_scheme);
145 self
146 }
147
148 pub fn launch_timeout(mut self, timeout: Duration) -> Self {
150 self.launch_timeout = Some(timeout);
151 self
152 }
153
154 pub fn sandbox(mut self, sandbox: bool) -> Self {
156 self.sandbox = sandbox;
157 self
158 }
159
160 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
162 self.node_executable = Some(executable.into());
163 self
164 }
165
166 pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
168 self.node_working_dir = Some(dir.into());
169 self
170 }
171
172 pub fn all_chrome_args(&self) -> Vec<String> {
174 let mut all_args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
175 all_args.extend(self.args.clone());
176 all_args
177 }
178
179 pub fn get_user_data_dir(&self) -> PathBuf {
181 if let Some(ref dir) = self.user_data_dir {
182 dir.clone()
183 } else {
184 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
185 home.join(".browser-commander")
186 .join(format!("{}-data", self.engine))
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct Browser {
194 pub engine: EngineType,
196 pub user_data_dir: PathBuf,
198 pub headless: bool,
200}
201
202pub struct LaunchResult {
208 pub browser: Browser,
210 pub page: Arc<dyn EngineAdapter>,
216}
217
218impl std::fmt::Debug for LaunchResult {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("LaunchResult")
221 .field("browser", &self.browser)
222 .field("page", &"<dyn EngineAdapter>")
223 .finish()
224 }
225}
226
227pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
255 if options.verbose {
256 tracing::info!("Launching browser with {} engine...", options.engine);
257 }
258
259 let user_data_dir = options.get_user_data_dir();
260 std::fs::create_dir_all(&user_data_dir)?;
261
262 match options.engine {
263 EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
264 EngineType::Playwright | EngineType::Puppeteer => {
265 launch_node_bridge(options, user_data_dir).await
266 }
267 EngineType::Fantoccini => Err(anyhow::anyhow!(
268 "fantoccini engine launch is not yet implemented; \
269 connect to an existing WebDriver session or use EngineType::Chromiumoxide"
270 )),
271 }
272}
273
274async fn launch_node_bridge(
275 options: LaunchOptions,
276 user_data_dir: PathBuf,
277) -> Result<LaunchResult, anyhow::Error> {
278 let engine = options.engine;
279 let headless = options.headless;
280 let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
281
282 Ok(LaunchResult {
283 browser: Browser {
284 engine,
285 user_data_dir,
286 headless,
287 },
288 page: Arc::new(adapter),
289 })
290}
291
292async fn launch_chromiumoxide(
293 options: LaunchOptions,
294 user_data_dir: PathBuf,
295) -> Result<LaunchResult, anyhow::Error> {
296 let headless_mode = if options.headless {
297 HeadlessMode::New
298 } else {
299 HeadlessMode::False
300 };
301
302 let mut builder = BrowserConfig::builder()
303 .user_data_dir(&user_data_dir)
304 .headless_mode(headless_mode)
305 .args(options.all_chrome_args());
306
307 if !options.sandbox {
308 builder = builder.no_sandbox();
309 }
310
311 if let Some(timeout) = options.launch_timeout {
312 builder = builder.launch_timeout(timeout);
313 }
314
315 let config = builder
316 .build()
317 .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
318
319 let (browser, mut handler) = CdpBrowser::launch(config)
320 .await
321 .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
322
323 let handler_task = tokio::spawn(async move {
328 while let Some(event) = handler.next().await {
329 if let Err(err) = event {
330 tracing::debug!(error = %err, "chromiumoxide handler event error");
331 }
332 }
333 });
334
335 let page = browser
336 .new_page("about:blank")
337 .await
338 .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
339
340 let engine = options.engine;
341 let headless = options.headless;
342 let color_scheme = options.color_scheme.clone();
343
344 let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
345
346 if let Some(ref cs) = color_scheme {
348 if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
349 if options.verbose {
350 tracing::warn!(error = %err, "could not set color scheme");
351 }
352 }
353 }
354
355 if !headless {
358 if let Err(err) = adapter.bring_to_front().await {
359 if options.verbose {
360 tracing::debug!(error = %err, "bring_to_front failed");
361 }
362 }
363 }
364
365 if options.verbose {
366 tracing::info!("Browser launched with {} engine", engine);
367 }
368
369 Ok(LaunchResult {
370 browser: Browser {
371 engine,
372 user_data_dir,
373 headless,
374 },
375 page: Arc::new(adapter),
376 })
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn launch_options_default() {
385 let options = LaunchOptions::default();
386 assert_eq!(options.engine, EngineType::Chromiumoxide);
387 assert!(!options.headless);
388 assert_eq!(options.slow_mo, 0);
389 assert!(!options.verbose);
390 assert!(options.args.is_empty());
391 assert!(options.node_executable.is_none());
392 assert!(options.node_working_dir.is_none());
393 }
394
395 #[test]
396 fn launch_options_builder() {
397 let options = LaunchOptions::chromiumoxide()
398 .headless(true)
399 .slow_mo(100)
400 .verbose(true)
401 .with_args(vec!["--custom-arg".to_string()]);
402
403 assert_eq!(options.engine, EngineType::Chromiumoxide);
404 assert!(options.headless);
405 assert_eq!(options.slow_mo, 100);
406 assert!(options.verbose);
407 assert_eq!(options.args, vec!["--custom-arg"]);
408 }
409
410 #[test]
411 fn launch_options_fantoccini() {
412 let options = LaunchOptions::fantoccini();
413 assert_eq!(options.engine, EngineType::Fantoccini);
414 }
415
416 #[test]
417 fn launch_options_playwright() {
418 let options = LaunchOptions::playwright();
419 assert_eq!(options.engine, EngineType::Playwright);
420 assert_eq!(options.slow_mo, 150);
421 }
422
423 #[test]
424 fn launch_options_puppeteer() {
425 let options = LaunchOptions::puppeteer();
426 assert_eq!(options.engine, EngineType::Puppeteer);
427 }
428
429 #[test]
430 fn launch_options_node_bridge_configuration() {
431 let options = LaunchOptions::playwright()
432 .node_executable("/custom/node")
433 .node_working_dir("/project/js");
434
435 assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
436 assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
437 }
438
439 #[test]
440 fn all_chrome_args_includes_defaults() {
441 let options = LaunchOptions::default();
442 let args = options.all_chrome_args();
443
444 assert!(args.contains(&"--disable-infobars".to_string()));
445 assert!(args.contains(&"--no-first-run".to_string()));
446 }
447
448 #[test]
449 fn all_chrome_args_includes_custom() {
450 let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
451 let args = options.all_chrome_args();
452
453 assert!(args.contains(&"--custom".to_string()));
454 }
455
456 #[test]
457 fn get_user_data_dir_uses_custom() {
458 let options = LaunchOptions::default().user_data_dir("/custom/path");
459 assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
460 }
461
462 #[test]
463 fn get_user_data_dir_creates_default() {
464 let options = LaunchOptions::default();
465 let dir = options.get_user_data_dir();
466 assert!(dir.to_string_lossy().contains("browser-commander"));
467 assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
468 }
469
470 #[tokio::test]
471 async fn launch_fantoccini_is_unimplemented() {
472 let options = LaunchOptions::fantoccini();
473 let err = launch_browser(options).await.unwrap_err();
474 assert!(err.to_string().contains("fantoccini"));
475 }
476
477 #[tokio::test]
478 async fn launch_playwright_reports_missing_node_executable() {
479 let options = LaunchOptions::playwright()
480 .headless(true)
481 .node_executable("browser-commander-missing-node");
482 let err = launch_browser(options).await.unwrap_err();
483 assert!(err.to_string().contains("failed to start Node.js bridge"));
484 }
485}