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 extra_args: Vec<String>,
36 pub ignore_default_args: Vec<String>,
38 pub ignore_all_default_args: bool,
40 pub channel: Option<String>,
42 pub executable_path: Option<PathBuf>,
44 pub color_scheme: Option<ColorScheme>,
46 pub launch_timeout: Option<Duration>,
48 pub sandbox: bool,
55 pub node_executable: Option<PathBuf>,
57 pub node_working_dir: Option<PathBuf>,
59}
60
61impl Default for LaunchOptions {
62 fn default() -> Self {
63 Self {
64 engine: EngineType::Chromiumoxide,
65 user_data_dir: None,
66 headless: false,
67 slow_mo: 0,
68 verbose: false,
69 args: Vec::new(),
70 extra_args: Vec::new(),
71 ignore_default_args: Vec::new(),
72 ignore_all_default_args: false,
73 channel: None,
74 executable_path: None,
75 color_scheme: None,
76 launch_timeout: None,
77 sandbox: true,
78 node_executable: None,
79 node_working_dir: None,
80 }
81 }
82}
83
84impl LaunchOptions {
85 pub fn engine(mut self, engine: EngineType) -> Self {
87 self.engine = engine;
88 if engine == EngineType::Playwright && self.slow_mo == 0 {
89 self.slow_mo = 150;
90 }
91 self
92 }
93
94 pub fn chromiumoxide() -> Self {
96 Self {
97 engine: EngineType::Chromiumoxide,
98 ..Default::default()
99 }
100 }
101
102 pub fn fantoccini() -> Self {
104 Self {
105 engine: EngineType::Fantoccini,
106 ..Default::default()
107 }
108 }
109
110 pub fn playwright() -> Self {
112 Self {
113 engine: EngineType::Playwright,
114 slow_mo: 150,
115 ..Default::default()
116 }
117 }
118
119 pub fn puppeteer() -> Self {
121 Self {
122 engine: EngineType::Puppeteer,
123 ..Default::default()
124 }
125 }
126
127 pub fn headless(mut self, headless: bool) -> Self {
129 self.headless = headless;
130 self
131 }
132
133 pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
135 self.user_data_dir = Some(dir.into());
136 self
137 }
138
139 pub fn slow_mo(mut self, ms: u64) -> Self {
141 self.slow_mo = ms;
142 self
143 }
144
145 pub fn verbose(mut self, verbose: bool) -> Self {
147 self.verbose = verbose;
148 self
149 }
150
151 pub fn with_args(mut self, args: Vec<String>) -> Self {
153 self.args = args;
154 self
155 }
156
157 pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
159 self.extra_args = args;
160 self
161 }
162
163 pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
165 self.ignore_default_args = args;
166 self
167 }
168
169 pub fn ignore_all_default_args(mut self) -> Self {
171 self.ignore_all_default_args = true;
172 self
173 }
174
175 pub fn channel(mut self, channel: impl Into<String>) -> Self {
177 self.channel = Some(channel.into());
178 self
179 }
180
181 pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
183 self.executable_path = Some(executable_path.into());
184 self
185 }
186
187 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
189 self.color_scheme = Some(color_scheme);
190 self
191 }
192
193 pub fn launch_timeout(mut self, timeout: Duration) -> Self {
195 self.launch_timeout = Some(timeout);
196 self
197 }
198
199 pub fn sandbox(mut self, sandbox: bool) -> Self {
201 self.sandbox = sandbox;
202 self
203 }
204
205 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
207 self.node_executable = Some(executable.into());
208 self
209 }
210
211 pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
213 self.node_working_dir = Some(dir.into());
214 self
215 }
216
217 pub fn all_chrome_args(&self) -> Vec<String> {
219 let mut all_args: Vec<String> = if self.ignore_all_default_args {
220 Vec::new()
221 } else {
222 CHROME_ARGS
223 .iter()
224 .filter(|argument| {
225 !self
226 .ignore_default_args
227 .iter()
228 .any(|item| item == **argument)
229 })
230 .map(|argument| argument.to_string())
231 .collect()
232 };
233 all_args.extend(self.args.clone());
234 all_args.extend(self.extra_args.clone());
235 all_args
236 }
237
238 pub fn get_user_data_dir(&self) -> PathBuf {
240 if let Some(ref dir) = self.user_data_dir {
241 dir.clone()
242 } else {
243 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
244 home.join(".browser-commander")
245 .join(format!("{}-data", self.engine))
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
252pub struct Browser {
253 pub engine: EngineType,
255 pub user_data_dir: PathBuf,
257 pub headless: bool,
259}
260
261pub struct LaunchResult {
267 pub browser: Browser,
269 pub page: Arc<dyn EngineAdapter>,
275}
276
277impl std::fmt::Debug for LaunchResult {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.debug_struct("LaunchResult")
280 .field("browser", &self.browser)
281 .field("page", &"<dyn EngineAdapter>")
282 .finish()
283 }
284}
285
286pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
314 if options.verbose {
315 tracing::info!("Launching browser with {} engine...", options.engine);
316 }
317
318 let user_data_dir = options.get_user_data_dir();
319 std::fs::create_dir_all(&user_data_dir)?;
320
321 match options.engine {
322 EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
323 EngineType::Playwright | EngineType::Puppeteer => {
324 launch_node_bridge(options, user_data_dir).await
325 }
326 EngineType::Fantoccini => Err(anyhow::anyhow!(
327 "fantoccini engine launch is not yet implemented; \
328 connect to an existing WebDriver session or use EngineType::Chromiumoxide"
329 )),
330 }
331}
332
333async fn launch_node_bridge(
334 options: LaunchOptions,
335 user_data_dir: PathBuf,
336) -> Result<LaunchResult, anyhow::Error> {
337 let engine = options.engine;
338 let headless = options.headless;
339 let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
340
341 Ok(LaunchResult {
342 browser: Browser {
343 engine,
344 user_data_dir,
345 headless,
346 },
347 page: Arc::new(adapter),
348 })
349}
350
351async fn launch_chromiumoxide(
352 options: LaunchOptions,
353 user_data_dir: PathBuf,
354) -> Result<LaunchResult, anyhow::Error> {
355 let headless_mode = if options.headless {
356 HeadlessMode::New
357 } else {
358 HeadlessMode::False
359 };
360
361 let mut builder = BrowserConfig::builder()
362 .user_data_dir(&user_data_dir)
363 .headless_mode(headless_mode)
364 .args(options.all_chrome_args());
365
366 if options.ignore_all_default_args || !options.ignore_default_args.is_empty() {
370 builder = builder.disable_default_args();
371 }
372
373 if !options.sandbox {
374 builder = builder.no_sandbox();
375 }
376
377 if let Some(ref executable_path) = options.executable_path {
378 builder = builder.chrome_executable(executable_path);
379 }
380
381 if let Some(timeout) = options.launch_timeout {
382 builder = builder.launch_timeout(timeout);
383 }
384
385 let config = builder
386 .build()
387 .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
388
389 let (browser, mut handler) = CdpBrowser::launch(config)
390 .await
391 .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
392
393 let handler_task = tokio::spawn(async move {
398 while let Some(event) = handler.next().await {
399 if let Err(err) = event {
400 tracing::debug!(error = %err, "chromiumoxide handler event error");
401 }
402 }
403 });
404
405 let page = browser
406 .new_page("about:blank")
407 .await
408 .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
409
410 let engine = options.engine;
411 let headless = options.headless;
412 let color_scheme = options.color_scheme.clone();
413
414 let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
415
416 if let Some(ref cs) = color_scheme {
418 if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
419 if options.verbose {
420 tracing::warn!(error = %err, "could not set color scheme");
421 }
422 }
423 }
424
425 if !headless {
428 if let Err(err) = adapter.bring_to_front().await {
429 if options.verbose {
430 tracing::debug!(error = %err, "bring_to_front failed");
431 }
432 }
433 }
434
435 if options.verbose {
436 tracing::info!("Browser launched with {} engine", engine);
437 }
438
439 Ok(LaunchResult {
440 browser: Browser {
441 engine,
442 user_data_dir,
443 headless,
444 },
445 page: Arc::new(adapter),
446 })
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn launch_options_default() {
455 let options = LaunchOptions::default();
456 assert_eq!(options.engine, EngineType::Chromiumoxide);
457 assert!(!options.headless);
458 assert_eq!(options.slow_mo, 0);
459 assert!(!options.verbose);
460 assert!(options.args.is_empty());
461 assert!(options.extra_args.is_empty());
462 assert!(options.ignore_default_args.is_empty());
463 assert!(!options.ignore_all_default_args);
464 assert!(options.channel.is_none());
465 assert!(options.executable_path.is_none());
466 assert!(options.node_executable.is_none());
467 assert!(options.node_working_dir.is_none());
468 }
469
470 #[test]
471 fn launch_options_builder() {
472 let options = LaunchOptions::chromiumoxide()
473 .headless(true)
474 .slow_mo(100)
475 .verbose(true)
476 .with_args(vec!["--custom-arg".to_string()]);
477
478 assert_eq!(options.engine, EngineType::Chromiumoxide);
479 assert!(options.headless);
480 assert_eq!(options.slow_mo, 100);
481 assert!(options.verbose);
482 assert_eq!(options.args, vec!["--custom-arg"]);
483 }
484
485 #[test]
486 fn launch_options_fantoccini() {
487 let options = LaunchOptions::fantoccini();
488 assert_eq!(options.engine, EngineType::Fantoccini);
489 }
490
491 #[test]
492 fn launch_options_playwright() {
493 let options = LaunchOptions::playwright();
494 assert_eq!(options.engine, EngineType::Playwright);
495 assert_eq!(options.slow_mo, 150);
496 }
497
498 #[test]
499 fn launch_options_puppeteer() {
500 let options = LaunchOptions::puppeteer();
501 assert_eq!(options.engine, EngineType::Puppeteer);
502 }
503
504 #[test]
505 fn launch_options_node_bridge_configuration() {
506 let options = LaunchOptions::playwright()
507 .node_executable("/custom/node")
508 .node_working_dir("/project/js")
509 .channel("chrome-beta")
510 .executable_path("/opt/google/chrome-beta");
511
512 assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
513 assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
514 assert_eq!(options.channel.as_deref(), Some("chrome-beta"));
515 assert_eq!(
516 options.executable_path,
517 Some(PathBuf::from("/opt/google/chrome-beta"))
518 );
519 }
520
521 #[test]
522 fn all_chrome_args_includes_defaults() {
523 let options = LaunchOptions::default();
524 let args = options.all_chrome_args();
525
526 assert!(args.contains(&"--disable-infobars".to_string()));
527 assert!(args.contains(&"--password-store=basic".to_string()));
528 assert!(args.contains(&"--no-first-run".to_string()));
529 }
530
531 #[test]
532 fn all_chrome_args_appends_extra_args_and_ignores_selected_defaults() {
533 let options = LaunchOptions::default()
534 .with_args(vec!["--legacy-arg".to_string()])
535 .with_extra_args(vec!["--lang=en-US".to_string()])
536 .ignore_default_args(vec!["--no-default-browser-check".to_string()]);
537
538 let args = options.all_chrome_args();
539 assert!(args.contains(&"--password-store=basic".to_string()));
540 assert!(!args.contains(&"--no-default-browser-check".to_string()));
541 assert_eq!(
542 &args[args.len() - 2..],
543 ["--legacy-arg".to_string(), "--lang=en-US".to_string()]
544 );
545 }
546
547 #[test]
548 fn all_chrome_args_can_ignore_every_default() {
549 let options = LaunchOptions::default()
550 .ignore_all_default_args()
551 .with_extra_args(vec!["--lang=en-US".to_string()]);
552
553 assert_eq!(options.all_chrome_args(), ["--lang=en-US".to_string()]);
554 }
555
556 #[test]
557 fn all_chrome_args_can_ignore_password_store_default_specifically() {
558 let options = LaunchOptions::default()
559 .ignore_default_args(vec!["--password-store=basic".to_string()]);
560
561 let args = options.all_chrome_args();
562 assert!(!args.contains(&"--password-store=basic".to_string()));
563 assert!(args.contains(&"--no-first-run".to_string()));
564 }
565
566 #[test]
567 fn all_chrome_args_includes_custom() {
568 let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
569 let args = options.all_chrome_args();
570
571 assert!(args.contains(&"--custom".to_string()));
572 }
573
574 #[test]
575 fn get_user_data_dir_uses_custom() {
576 let options = LaunchOptions::default().user_data_dir("/custom/path");
577 assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
578 }
579
580 #[test]
581 fn get_user_data_dir_creates_default() {
582 let options = LaunchOptions::default();
583 let dir = options.get_user_data_dir();
584 assert!(dir.to_string_lossy().contains("browser-commander"));
585 assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
586 }
587
588 #[tokio::test]
589 async fn launch_fantoccini_is_unimplemented() {
590 let options = LaunchOptions::fantoccini();
591 let err = launch_browser(options).await.unwrap_err();
592 assert!(err.to_string().contains("fantoccini"));
593 }
594
595 #[tokio::test]
596 async fn launch_playwright_reports_missing_node_executable() {
597 let options = LaunchOptions::playwright()
598 .headless(true)
599 .node_executable("browser-commander-missing-node");
600 let err = launch_browser(options).await.unwrap_err();
601 assert!(err.to_string().contains("failed to start Node.js bridge"));
602 }
603}