browser_commander/browser/
launcher.rs1use std::path::PathBuf;
7use std::sync::Arc;
8use std::time::Duration;
9
10use chromiumoxide::browser::{Browser as CdpBrowser, BrowserConfig};
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};
18use crate::fingerprint::apply::{apply_fingerprint, ApplyOptions};
19use crate::fingerprint::automation_parity::{
20 apply_automation_parity_args, parity_ignored_default_args,
21};
22use crate::fingerprint::profile::FingerprintProfile;
23
24#[derive(Debug, Clone)]
26pub struct LaunchOptions {
27 pub engine: EngineType,
29 pub user_data_dir: Option<PathBuf>,
31 pub headless: bool,
33 pub slow_mo: u64,
35 pub verbose: bool,
37 pub args: Vec<String>,
39 pub extra_args: Vec<String>,
41 pub ignore_default_args: Vec<String>,
43 pub ignore_all_default_args: bool,
45 pub channel: Option<String>,
47 pub executable_path: Option<PathBuf>,
49 pub color_scheme: Option<ColorScheme>,
51 pub launch_timeout: Option<Duration>,
53 pub sandbox: bool,
60 pub node_executable: Option<PathBuf>,
62 pub node_working_dir: Option<PathBuf>,
64 pub automation_parity: bool,
70 pub fingerprint: Option<FingerprintProfile>,
78}
79
80impl Default for LaunchOptions {
81 fn default() -> Self {
82 Self {
83 engine: EngineType::Chromiumoxide,
84 user_data_dir: None,
85 headless: false,
86 slow_mo: 0,
87 verbose: false,
88 args: Vec::new(),
89 extra_args: Vec::new(),
90 ignore_default_args: Vec::new(),
91 ignore_all_default_args: false,
92 channel: None,
93 executable_path: None,
94 color_scheme: None,
95 launch_timeout: None,
96 sandbox: true,
97 node_executable: None,
98 node_working_dir: None,
99 automation_parity: true,
100 fingerprint: None,
101 }
102 }
103}
104
105impl LaunchOptions {
106 pub fn engine(mut self, engine: EngineType) -> Self {
108 self.engine = engine;
109 if engine == EngineType::Playwright && self.slow_mo == 0 {
110 self.slow_mo = 150;
111 }
112 self
113 }
114
115 pub fn chromiumoxide() -> Self {
117 Self {
118 engine: EngineType::Chromiumoxide,
119 ..Default::default()
120 }
121 }
122
123 pub fn fantoccini() -> Self {
125 Self {
126 engine: EngineType::Fantoccini,
127 ..Default::default()
128 }
129 }
130
131 pub fn playwright() -> Self {
133 Self {
134 engine: EngineType::Playwright,
135 slow_mo: 150,
136 ..Default::default()
137 }
138 }
139
140 pub fn puppeteer() -> Self {
142 Self {
143 engine: EngineType::Puppeteer,
144 ..Default::default()
145 }
146 }
147
148 pub fn headless(mut self, headless: bool) -> Self {
150 self.headless = headless;
151 self
152 }
153
154 pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
156 self.user_data_dir = Some(dir.into());
157 self
158 }
159
160 pub fn slow_mo(mut self, ms: u64) -> Self {
162 self.slow_mo = ms;
163 self
164 }
165
166 pub fn verbose(mut self, verbose: bool) -> Self {
168 self.verbose = verbose;
169 self
170 }
171
172 pub fn with_args(mut self, args: Vec<String>) -> Self {
174 self.args = args;
175 self
176 }
177
178 pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
180 self.extra_args = args;
181 self
182 }
183
184 pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
186 self.ignore_default_args = args;
187 self
188 }
189
190 pub fn ignore_all_default_args(mut self) -> Self {
192 self.ignore_all_default_args = true;
193 self
194 }
195
196 pub fn channel(mut self, channel: impl Into<String>) -> Self {
198 self.channel = Some(channel.into());
199 self
200 }
201
202 pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
204 self.executable_path = Some(executable_path.into());
205 self
206 }
207
208 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
210 self.color_scheme = Some(color_scheme);
211 self
212 }
213
214 pub fn launch_timeout(mut self, timeout: Duration) -> Self {
216 self.launch_timeout = Some(timeout);
217 self
218 }
219
220 pub fn sandbox(mut self, sandbox: bool) -> Self {
222 self.sandbox = sandbox;
223 self
224 }
225
226 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
228 self.node_executable = Some(executable.into());
229 self
230 }
231
232 pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
234 self.node_working_dir = Some(dir.into());
235 self
236 }
237
238 pub fn automation_parity(mut self, automation_parity: bool) -> Self {
240 self.automation_parity = automation_parity;
241 self
242 }
243
244 pub fn fingerprint(mut self, fingerprint: FingerprintProfile) -> Self {
246 self.fingerprint = Some(fingerprint);
247 self
248 }
249
250 pub fn all_chrome_args(&self) -> Vec<String> {
252 let mut all_args: Vec<String> = if self.ignore_all_default_args {
253 Vec::new()
254 } else {
255 CHROME_ARGS
256 .iter()
257 .filter(|argument| {
258 !self
259 .ignore_default_args
260 .iter()
261 .any(|item| item == **argument)
262 })
263 .map(|argument| argument.to_string())
264 .collect()
265 };
266 all_args.extend(self.args.clone());
267 all_args.extend(self.extra_args.clone());
268 if self.automation_parity {
269 all_args = apply_automation_parity_args(&all_args);
270 }
271 all_args
272 }
273
274 pub fn all_ignored_default_args(&self) -> Vec<String> {
281 let mut ignored = if self.automation_parity {
282 parity_ignored_default_args(self.engine, self.headless)
283 } else {
284 Vec::new()
285 };
286 for argument in &self.ignore_default_args {
287 if !ignored.contains(argument) {
288 ignored.push(argument.clone());
289 }
290 }
291 ignored
292 }
293
294 pub fn get_user_data_dir(&self) -> PathBuf {
296 if let Some(ref dir) = self.user_data_dir {
297 dir.clone()
298 } else {
299 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
300 home.join(".browser-commander")
301 .join(format!("{}-data", self.engine))
302 }
303 }
304}
305
306#[derive(Debug, Clone)]
308pub struct Browser {
309 pub engine: EngineType,
311 pub user_data_dir: PathBuf,
313 pub headless: bool,
315}
316
317pub struct LaunchResult {
323 pub browser: Browser,
325 pub page: Arc<dyn EngineAdapter>,
331}
332
333impl std::fmt::Debug for LaunchResult {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 f.debug_struct("LaunchResult")
336 .field("browser", &self.browser)
337 .field("page", &"<dyn EngineAdapter>")
338 .finish()
339 }
340}
341
342pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
370 if options.verbose {
371 tracing::info!("Launching browser with {} engine...", options.engine);
372 }
373
374 let user_data_dir = options.get_user_data_dir();
375 std::fs::create_dir_all(&user_data_dir)?;
376
377 match options.engine {
378 EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
379 EngineType::Playwright | EngineType::Puppeteer => {
380 launch_node_bridge(options, user_data_dir).await
381 }
382 EngineType::Fantoccini => Err(anyhow::anyhow!(
383 "fantoccini engine launch is not yet implemented; \
384 connect to an existing WebDriver session or use EngineType::Chromiumoxide"
385 )),
386 }
387}
388
389async fn launch_node_bridge(
390 options: LaunchOptions,
391 user_data_dir: PathBuf,
392) -> Result<LaunchResult, anyhow::Error> {
393 let engine = options.engine;
394 let headless = options.headless;
395 if options.fingerprint.is_some() {
396 return Err(anyhow::anyhow!(
400 "the {engine} engine cannot apply a fingerprint profile yet; \
401 use EngineType::Chromiumoxide, or apply the profile from the \
402 JavaScript package, which drives Playwright and Puppeteer directly"
403 ));
404 }
405 let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
406
407 Ok(LaunchResult {
408 browser: Browser {
409 engine,
410 user_data_dir,
411 headless,
412 },
413 page: Arc::new(adapter),
414 })
415}
416
417async fn launch_chromiumoxide(
418 options: LaunchOptions,
419 user_data_dir: PathBuf,
420) -> Result<LaunchResult, anyhow::Error> {
421 let builder = BrowserConfig::builder();
424 let builder = if options.headless {
425 builder.new_headless_mode()
426 } else {
427 builder.with_head()
428 };
429 let mut builder = builder
430 .user_data_dir(&user_data_dir)
431 .args(options.all_chrome_args());
432
433 if options.ignore_all_default_args || !options.all_ignored_default_args().is_empty() {
437 builder = builder.disable_default_args();
438 }
439
440 if !options.sandbox {
441 builder = builder.no_sandbox();
442 }
443
444 if let Some(ref executable_path) = options.executable_path {
445 builder = builder.chrome_executable(executable_path);
446 }
447
448 if let Some(timeout) = options.launch_timeout {
449 builder = builder.launch_timeout(timeout);
450 }
451
452 let config = builder
453 .build()
454 .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
455
456 let (browser, mut handler) = CdpBrowser::launch(config)
457 .await
458 .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
459
460 let handler_task = tokio::spawn(async move {
465 while let Some(event) = handler.next().await {
466 if let Err(err) = event {
467 tracing::debug!(error = %err, "chromiumoxide handler event error");
468 }
469 }
470 });
471
472 let page = browser
473 .new_page("about:blank")
474 .await
475 .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
476
477 let engine = options.engine;
478 let headless = options.headless;
479 let color_scheme = options.color_scheme.clone();
480
481 let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
482
483 if let Some(ref profile) = options.fingerprint {
488 apply_fingerprint(&adapter, profile, ApplyOptions::default()).await?;
489 if options.verbose {
490 tracing::info!("Fingerprint profile applied");
491 }
492 }
493
494 if let Some(ref cs) = color_scheme {
496 if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
497 if options.verbose {
498 tracing::warn!(error = %err, "could not set color scheme");
499 }
500 }
501 }
502
503 if !headless {
506 if let Err(err) = adapter.bring_to_front().await {
507 if options.verbose {
508 tracing::debug!(error = %err, "bring_to_front failed");
509 }
510 }
511 }
512
513 if options.verbose {
514 tracing::info!("Browser launched with {} engine", engine);
515 }
516
517 Ok(LaunchResult {
518 browser: Browser {
519 engine,
520 user_data_dir,
521 headless,
522 },
523 page: Arc::new(adapter),
524 })
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use crate::fingerprint::automation_parity::{
531 AUTOMATION_CONTROLLED_OFF_ARG, PLAYWRIGHT_HEADLESS_POINTER_ARG,
532 PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
533 };
534 use crate::fingerprint::presets::create_default_fingerprint_preset;
535
536 #[test]
537 fn launch_options_default() {
538 let options = LaunchOptions::default();
539 assert_eq!(options.engine, EngineType::Chromiumoxide);
540 assert!(!options.headless);
541 assert_eq!(options.slow_mo, 0);
542 assert!(!options.verbose);
543 assert!(options.args.is_empty());
544 assert!(options.extra_args.is_empty());
545 assert!(options.ignore_default_args.is_empty());
546 assert!(!options.ignore_all_default_args);
547 assert!(options.automation_parity);
548 assert!(options.channel.is_none());
549 assert!(options.executable_path.is_none());
550 assert!(options.node_executable.is_none());
551 assert!(options.node_working_dir.is_none());
552 assert!(options.fingerprint.is_none());
555 }
556
557 #[test]
558 fn launch_options_builder() {
559 let options = LaunchOptions::chromiumoxide()
560 .headless(true)
561 .slow_mo(100)
562 .verbose(true)
563 .with_args(vec!["--custom-arg".to_string()]);
564
565 assert_eq!(options.engine, EngineType::Chromiumoxide);
566 assert!(options.headless);
567 assert_eq!(options.slow_mo, 100);
568 assert!(options.verbose);
569 assert_eq!(options.args, vec!["--custom-arg"]);
570 }
571
572 #[test]
573 fn launch_options_fantoccini() {
574 let options = LaunchOptions::fantoccini();
575 assert_eq!(options.engine, EngineType::Fantoccini);
576 }
577
578 #[test]
579 fn launch_options_playwright() {
580 let options = LaunchOptions::playwright();
581 assert_eq!(options.engine, EngineType::Playwright);
582 assert_eq!(options.slow_mo, 150);
583 }
584
585 #[test]
586 fn launch_options_puppeteer() {
587 let options = LaunchOptions::puppeteer();
588 assert_eq!(options.engine, EngineType::Puppeteer);
589 }
590
591 #[test]
592 fn launch_options_node_bridge_configuration() {
593 let options = LaunchOptions::playwright()
594 .node_executable("/custom/node")
595 .node_working_dir("/project/js")
596 .channel("chrome-beta")
597 .executable_path("/opt/google/chrome-beta");
598
599 assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
600 assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
601 assert_eq!(options.channel.as_deref(), Some("chrome-beta"));
602 assert_eq!(
603 options.executable_path,
604 Some(PathBuf::from("/opt/google/chrome-beta"))
605 );
606 }
607
608 #[test]
609 fn all_chrome_args_includes_defaults() {
610 let options = LaunchOptions::default();
611 let args = options.all_chrome_args();
612
613 assert!(args.contains(&"--disable-infobars".to_string()));
614 assert!(args.contains(&"--password-store=basic".to_string()));
615 assert!(args.contains(&"--no-first-run".to_string()));
616 }
617
618 #[test]
619 fn all_chrome_args_appends_extra_args_and_ignores_selected_defaults() {
620 let options = LaunchOptions::default()
621 .with_args(vec!["--legacy-arg".to_string()])
622 .with_extra_args(vec!["--lang=en-US".to_string()])
623 .ignore_default_args(vec!["--no-default-browser-check".to_string()]);
624
625 let args = options.all_chrome_args();
626 assert!(args.contains(&"--password-store=basic".to_string()));
627 assert!(!args.contains(&"--no-default-browser-check".to_string()));
628 assert_eq!(
629 &args[args.len() - 3..],
630 [
631 "--legacy-arg".to_string(),
632 "--lang=en-US".to_string(),
633 AUTOMATION_CONTROLLED_OFF_ARG.to_string()
634 ]
635 );
636 }
637
638 #[test]
639 fn all_chrome_args_can_ignore_every_default() {
640 let options = LaunchOptions::default()
641 .ignore_all_default_args()
642 .with_extra_args(vec!["--lang=en-US".to_string()]);
643
644 assert_eq!(
645 options.all_chrome_args(),
646 [
647 "--lang=en-US".to_string(),
648 AUTOMATION_CONTROLLED_OFF_ARG.to_string()
649 ]
650 );
651 }
652
653 #[test]
654 fn all_chrome_args_can_ignore_password_store_default_specifically() {
655 let options = LaunchOptions::default()
656 .ignore_default_args(vec!["--password-store=basic".to_string()]);
657
658 let args = options.all_chrome_args();
659 assert!(!args.contains(&"--password-store=basic".to_string()));
660 assert!(args.contains(&"--no-first-run".to_string()));
661 }
662
663 #[test]
664 fn all_chrome_args_includes_custom() {
665 let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
666 let args = options.all_chrome_args();
667
668 assert!(args.contains(&"--custom".to_string()));
669 }
670
671 #[test]
672 fn all_chrome_args_disables_the_automation_controlled_feature() {
673 let args = LaunchOptions::default().all_chrome_args();
674 assert_eq!(
675 args.last().map(String::as_str),
676 Some(AUTOMATION_CONTROLLED_OFF_ARG)
677 );
678 }
679
680 #[test]
681 fn all_chrome_args_leaves_the_command_line_alone_when_parity_is_off() {
682 let args = LaunchOptions::default()
683 .automation_parity(false)
684 .all_chrome_args();
685 assert!(!args
686 .iter()
687 .any(|argument| argument == AUTOMATION_CONTROLLED_OFF_ARG));
688 }
689
690 #[test]
691 fn all_ignored_default_args_merges_parity_with_the_caller_list() {
692 let options = LaunchOptions::playwright()
693 .headless(true)
694 .ignore_default_args(vec!["--no-first-run".to_string()]);
695
696 assert_eq!(
697 options.all_ignored_default_args(),
698 [
699 "--enable-automation".to_string(),
700 PLAYWRIGHT_SOFTWARE_WEBGL_ARG.to_string(),
701 PLAYWRIGHT_HEADLESS_POINTER_ARG.to_string(),
702 "--no-first-run".to_string()
703 ]
704 );
705 }
706
707 #[test]
708 fn all_ignored_default_args_does_not_repeat_a_switch_the_caller_already_listed() {
709 let options = LaunchOptions::playwright()
710 .ignore_default_args(vec!["--enable-automation".to_string()]);
711
712 assert_eq!(
713 options.all_ignored_default_args(),
714 [
715 "--enable-automation".to_string(),
716 PLAYWRIGHT_SOFTWARE_WEBGL_ARG.to_string()
717 ]
718 );
719 }
720
721 #[test]
722 fn all_ignored_default_args_keeps_only_the_caller_list_when_parity_is_off() {
723 let options = LaunchOptions::playwright()
724 .headless(true)
725 .automation_parity(false)
726 .ignore_default_args(vec!["--no-first-run".to_string()]);
727
728 assert_eq!(
729 options.all_ignored_default_args(),
730 ["--no-first-run".to_string()]
731 );
732 }
733
734 #[test]
735 fn chromiumoxide_excludes_nothing_by_default() {
736 assert!(LaunchOptions::chromiumoxide()
737 .all_ignored_default_args()
738 .is_empty());
739 }
740
741 #[test]
742 fn get_user_data_dir_uses_custom() {
743 let options = LaunchOptions::default().user_data_dir("/custom/path");
744 assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
745 }
746
747 #[test]
748 fn get_user_data_dir_creates_default() {
749 let options = LaunchOptions::default();
750 let dir = options.get_user_data_dir();
751 assert!(dir.to_string_lossy().contains("browser-commander"));
752 assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
753 }
754
755 #[tokio::test]
756 async fn launch_fantoccini_is_unimplemented() {
757 let options = LaunchOptions::fantoccini();
758 let err = launch_browser(options).await.unwrap_err();
759 assert!(err.to_string().contains("fantoccini"));
760 }
761
762 #[test]
763 fn launch_options_carry_a_fingerprint_profile() {
764 let profile = create_default_fingerprint_preset("windows-chrome").expect("preset");
765 let options = LaunchOptions::default().fingerprint(profile.clone());
766
767 assert_eq!(options.fingerprint, Some(profile));
768 }
769
770 #[tokio::test]
771 async fn launch_playwright_refuses_a_fingerprint_it_cannot_apply() {
772 let options = LaunchOptions::playwright()
775 .headless(true)
776 .fingerprint(create_default_fingerprint_preset("windows-chrome").expect("preset"));
777
778 let err = launch_browser(options).await.unwrap_err();
779
780 assert!(err
781 .to_string()
782 .contains("cannot apply a fingerprint profile"));
783 }
784
785 #[tokio::test]
786 async fn launch_playwright_reports_missing_node_executable() {
787 let options = LaunchOptions::playwright()
788 .headless(true)
789 .node_executable("browser-commander-missing-node");
790 let err = launch_browser(options).await.unwrap_err();
791 assert!(err.to_string().contains("failed to start Node.js bridge"));
792 }
793}