1use 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::downloads::{attach_downloads, DownloadManager, DownloadSetting};
19use crate::fingerprint::apply::{apply_fingerprint, ApplyOptions};
20use crate::fingerprint::automation_parity::{
21 apply_automation_parity_args, parity_ignored_default_args,
22};
23use crate::fingerprint::profile::FingerprintProfile;
24
25#[derive(Debug, Clone)]
27pub struct LaunchOptions {
28 pub engine: EngineType,
30 pub user_data_dir: Option<PathBuf>,
32 pub headless: bool,
34 pub slow_mo: u64,
36 pub verbose: bool,
38 pub args: Vec<String>,
40 pub extra_args: Vec<String>,
42 pub ignore_default_args: Vec<String>,
44 pub ignore_all_default_args: bool,
46 pub channel: Option<String>,
48 pub executable_path: Option<PathBuf>,
50 pub color_scheme: Option<ColorScheme>,
52 pub launch_timeout: Option<Duration>,
54 pub sandbox: bool,
61 pub node_executable: Option<PathBuf>,
63 pub node_working_dir: Option<PathBuf>,
65 pub automation_parity: bool,
71 pub fingerprint: Option<FingerprintProfile>,
79 pub downloads: DownloadSetting,
86}
87
88impl Default for LaunchOptions {
89 fn default() -> Self {
90 Self {
91 engine: EngineType::Chromiumoxide,
92 user_data_dir: None,
93 headless: false,
94 slow_mo: 0,
95 verbose: false,
96 args: Vec::new(),
97 extra_args: Vec::new(),
98 ignore_default_args: Vec::new(),
99 ignore_all_default_args: false,
100 channel: None,
101 executable_path: None,
102 color_scheme: None,
103 launch_timeout: None,
104 sandbox: true,
105 node_executable: None,
106 node_working_dir: None,
107 automation_parity: true,
108 fingerprint: None,
109 downloads: DownloadSetting::Off,
110 }
111 }
112}
113
114impl LaunchOptions {
115 pub fn engine(mut self, engine: EngineType) -> Self {
117 self.engine = engine;
118 if engine == EngineType::Playwright && self.slow_mo == 0 {
119 self.slow_mo = 150;
120 }
121 self
122 }
123
124 pub fn chromiumoxide() -> Self {
126 Self {
127 engine: EngineType::Chromiumoxide,
128 ..Default::default()
129 }
130 }
131
132 pub fn fantoccini() -> Self {
134 Self {
135 engine: EngineType::Fantoccini,
136 ..Default::default()
137 }
138 }
139
140 pub fn playwright() -> Self {
142 Self {
143 engine: EngineType::Playwright,
144 slow_mo: 150,
145 ..Default::default()
146 }
147 }
148
149 pub fn puppeteer() -> Self {
151 Self {
152 engine: EngineType::Puppeteer,
153 ..Default::default()
154 }
155 }
156
157 pub fn headless(mut self, headless: bool) -> Self {
159 self.headless = headless;
160 self
161 }
162
163 pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
165 self.user_data_dir = Some(dir.into());
166 self
167 }
168
169 pub fn slow_mo(mut self, ms: u64) -> Self {
171 self.slow_mo = ms;
172 self
173 }
174
175 pub fn verbose(mut self, verbose: bool) -> Self {
177 self.verbose = verbose;
178 self
179 }
180
181 pub fn with_args(mut self, args: Vec<String>) -> Self {
183 self.args = args;
184 self
185 }
186
187 pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
189 self.extra_args = args;
190 self
191 }
192
193 pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
195 self.ignore_default_args = args;
196 self
197 }
198
199 pub fn ignore_all_default_args(mut self) -> Self {
201 self.ignore_all_default_args = true;
202 self
203 }
204
205 pub fn channel(mut self, channel: impl Into<String>) -> Self {
207 self.channel = Some(channel.into());
208 self
209 }
210
211 pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
213 self.executable_path = Some(executable_path.into());
214 self
215 }
216
217 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
219 self.color_scheme = Some(color_scheme);
220 self
221 }
222
223 pub fn launch_timeout(mut self, timeout: Duration) -> Self {
225 self.launch_timeout = Some(timeout);
226 self
227 }
228
229 pub fn sandbox(mut self, sandbox: bool) -> Self {
231 self.sandbox = sandbox;
232 self
233 }
234
235 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
237 self.node_executable = Some(executable.into());
238 self
239 }
240
241 pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
243 self.node_working_dir = Some(dir.into());
244 self
245 }
246
247 pub fn automation_parity(mut self, automation_parity: bool) -> Self {
249 self.automation_parity = automation_parity;
250 self
251 }
252
253 pub fn fingerprint(mut self, fingerprint: FingerprintProfile) -> Self {
255 self.fingerprint = Some(fingerprint);
256 self
257 }
258
259 pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
266 self.downloads = downloads.into();
267 self
268 }
269
270 pub fn all_chrome_args(&self) -> Vec<String> {
272 let mut all_args: Vec<String> = if self.ignore_all_default_args {
273 Vec::new()
274 } else {
275 CHROME_ARGS
276 .iter()
277 .filter(|argument| {
278 !self
279 .ignore_default_args
280 .iter()
281 .any(|item| item == **argument)
282 })
283 .map(|argument| argument.to_string())
284 .collect()
285 };
286 all_args.extend(self.args.clone());
287 all_args.extend(self.extra_args.clone());
288 if self.automation_parity {
289 all_args = apply_automation_parity_args(&all_args);
290 }
291 all_args
292 }
293
294 pub fn all_ignored_default_args(&self) -> Vec<String> {
301 let mut ignored = if self.automation_parity {
302 parity_ignored_default_args(self.engine, self.headless)
303 } else {
304 Vec::new()
305 };
306 for argument in &self.ignore_default_args {
307 if !ignored.contains(argument) {
308 ignored.push(argument.clone());
309 }
310 }
311 ignored
312 }
313
314 pub fn get_user_data_dir(&self) -> PathBuf {
316 if let Some(ref dir) = self.user_data_dir {
317 dir.clone()
318 } else {
319 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
320 home.join(".browser-commander")
321 .join(format!("{}-data", self.engine))
322 }
323 }
324}
325
326#[derive(Debug, Clone)]
328pub struct Browser {
329 pub engine: EngineType,
331 pub user_data_dir: PathBuf,
333 pub headless: bool,
335}
336
337pub struct LaunchResult {
343 pub browser: Browser,
345 pub page: Arc<dyn EngineAdapter>,
351 pub downloads: Option<Arc<DownloadManager>>,
357}
358
359impl std::fmt::Debug for LaunchResult {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 f.debug_struct("LaunchResult")
362 .field("browser", &self.browser)
363 .field("page", &"<dyn EngineAdapter>")
364 .field("downloads", &self.downloads)
365 .finish()
366 }
367}
368
369pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
397 if options.verbose {
398 tracing::info!("Launching browser with {} engine...", options.engine);
399 }
400
401 let user_data_dir = options.get_user_data_dir();
402 std::fs::create_dir_all(&user_data_dir)?;
403
404 match options.engine {
405 EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
406 EngineType::Playwright | EngineType::Puppeteer => {
407 launch_node_bridge(options, user_data_dir).await
408 }
409 EngineType::Fantoccini => Err(anyhow::anyhow!(
410 "fantoccini engine launch is not yet implemented; \
411 connect to an existing WebDriver session or use EngineType::Chromiumoxide"
412 )),
413 }
414}
415
416struct UnavailableTransport;
422
423#[async_trait::async_trait]
424impl crate::fingerprint::CdpTransport for UnavailableTransport {
425 async fn send(
426 &self,
427 method: &str,
428 _params: serde_json::Value,
429 ) -> anyhow::Result<serde_json::Value> {
430 Err(anyhow::anyhow!(
431 "this engine has no CDP transport, so {method} cannot be sent"
432 ))
433 }
434}
435
436async fn launch_node_bridge(
437 options: LaunchOptions,
438 user_data_dir: PathBuf,
439) -> Result<LaunchResult, anyhow::Error> {
440 let engine = options.engine;
441 let headless = options.headless;
442 if options.fingerprint.is_some() {
443 return Err(anyhow::anyhow!(
447 "the {engine} engine cannot apply a fingerprint profile yet; \
448 use EngineType::Chromiumoxide, or apply the profile from the \
449 JavaScript package, which drives Playwright and Puppeteer directly"
450 ));
451 }
452 attach_downloads(engine, &UnavailableTransport, options.downloads.clone())
457 .await
458 .map_err(|error| anyhow::anyhow!("{error}"))?;
459 let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
460
461 Ok(LaunchResult {
462 browser: Browser {
463 engine,
464 user_data_dir,
465 headless,
466 },
467 page: Arc::new(adapter),
468 downloads: None,
469 })
470}
471
472async fn launch_chromiumoxide(
473 options: LaunchOptions,
474 user_data_dir: PathBuf,
475) -> Result<LaunchResult, anyhow::Error> {
476 let builder = BrowserConfig::builder();
479 let builder = if options.headless {
480 builder.new_headless_mode()
481 } else {
482 builder.with_head()
483 };
484 let mut builder = builder
485 .user_data_dir(&user_data_dir)
486 .args(options.all_chrome_args());
487
488 if options.ignore_all_default_args || !options.all_ignored_default_args().is_empty() {
492 builder = builder.disable_default_args();
493 }
494
495 if !options.sandbox {
496 builder = builder.no_sandbox();
497 }
498
499 if let Some(ref executable_path) = options.executable_path {
500 builder = builder.chrome_executable(executable_path);
501 }
502
503 if let Some(timeout) = options.launch_timeout {
504 builder = builder.launch_timeout(timeout);
505 }
506
507 let config = builder
508 .build()
509 .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
510
511 let (browser, mut handler) = CdpBrowser::launch(config)
512 .await
513 .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
514
515 let handler_task = tokio::spawn(async move {
520 while let Some(event) = handler.next().await {
521 if let Err(err) = event {
522 tracing::debug!(error = %err, "chromiumoxide handler event error");
523 }
524 }
525 });
526
527 let page = browser
528 .new_page("about:blank")
529 .await
530 .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
531
532 let engine = options.engine;
533 let headless = options.headless;
534 let color_scheme = options.color_scheme.clone();
535
536 let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
537
538 if let Some(ref profile) = options.fingerprint {
543 apply_fingerprint(&adapter, profile, ApplyOptions::default()).await?;
544 if options.verbose {
545 tracing::info!("Fingerprint profile applied");
546 }
547 }
548
549 let downloads = attach_downloads(engine, &adapter, options.downloads.clone())
553 .await
554 .map_err(|error| anyhow::anyhow!("{error}"))?;
555
556 if let Some(ref cs) = color_scheme {
558 if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
559 if options.verbose {
560 tracing::warn!(error = %err, "could not set color scheme");
561 }
562 }
563 }
564
565 if !headless {
568 if let Err(err) = adapter.bring_to_front().await {
569 if options.verbose {
570 tracing::debug!(error = %err, "bring_to_front failed");
571 }
572 }
573 }
574
575 if options.verbose {
576 tracing::info!("Browser launched with {} engine", engine);
577 }
578
579 Ok(LaunchResult {
580 browser: Browser {
581 engine,
582 user_data_dir,
583 headless,
584 },
585 page: Arc::new(adapter),
586 downloads,
587 })
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::fingerprint::automation_parity::{
594 AUTOMATION_CONTROLLED_OFF_ARG, PLAYWRIGHT_HEADLESS_POINTER_ARG,
595 PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
596 };
597 use crate::fingerprint::presets::create_default_fingerprint_preset;
598
599 #[test]
600 fn launch_options_default() {
601 let options = LaunchOptions::default();
602 assert_eq!(options.engine, EngineType::Chromiumoxide);
603 assert!(!options.headless);
604 assert_eq!(options.slow_mo, 0);
605 assert!(!options.verbose);
606 assert!(options.args.is_empty());
607 assert!(options.extra_args.is_empty());
608 assert!(options.ignore_default_args.is_empty());
609 assert!(!options.ignore_all_default_args);
610 assert!(options.automation_parity);
611 assert!(options.channel.is_none());
612 assert!(options.executable_path.is_none());
613 assert!(options.node_executable.is_none());
614 assert!(options.node_working_dir.is_none());
615 assert!(options.fingerprint.is_none());
618 }
619
620 #[test]
621 fn launch_options_builder() {
622 let options = LaunchOptions::chromiumoxide()
623 .headless(true)
624 .slow_mo(100)
625 .verbose(true)
626 .with_args(vec!["--custom-arg".to_string()]);
627
628 assert_eq!(options.engine, EngineType::Chromiumoxide);
629 assert!(options.headless);
630 assert_eq!(options.slow_mo, 100);
631 assert!(options.verbose);
632 assert_eq!(options.args, vec!["--custom-arg"]);
633 }
634
635 #[test]
636 fn launch_options_fantoccini() {
637 let options = LaunchOptions::fantoccini();
638 assert_eq!(options.engine, EngineType::Fantoccini);
639 }
640
641 #[test]
642 fn launch_options_playwright() {
643 let options = LaunchOptions::playwright();
644 assert_eq!(options.engine, EngineType::Playwright);
645 assert_eq!(options.slow_mo, 150);
646 }
647
648 #[test]
649 fn launch_options_puppeteer() {
650 let options = LaunchOptions::puppeteer();
651 assert_eq!(options.engine, EngineType::Puppeteer);
652 }
653
654 #[test]
655 fn launch_options_node_bridge_configuration() {
656 let options = LaunchOptions::playwright()
657 .node_executable("/custom/node")
658 .node_working_dir("/project/js")
659 .channel("chrome-beta")
660 .executable_path("/opt/google/chrome-beta");
661
662 assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
663 assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
664 assert_eq!(options.channel.as_deref(), Some("chrome-beta"));
665 assert_eq!(
666 options.executable_path,
667 Some(PathBuf::from("/opt/google/chrome-beta"))
668 );
669 }
670
671 #[test]
672 fn all_chrome_args_includes_defaults() {
673 let options = LaunchOptions::default();
674 let args = options.all_chrome_args();
675
676 assert!(args.contains(&"--disable-infobars".to_string()));
677 assert!(args.contains(&"--password-store=basic".to_string()));
678 assert!(args.contains(&"--no-first-run".to_string()));
679 }
680
681 #[test]
682 fn all_chrome_args_appends_extra_args_and_ignores_selected_defaults() {
683 let options = LaunchOptions::default()
684 .with_args(vec!["--legacy-arg".to_string()])
685 .with_extra_args(vec!["--lang=en-US".to_string()])
686 .ignore_default_args(vec!["--no-default-browser-check".to_string()]);
687
688 let args = options.all_chrome_args();
689 assert!(args.contains(&"--password-store=basic".to_string()));
690 assert!(!args.contains(&"--no-default-browser-check".to_string()));
691 assert_eq!(
692 &args[args.len() - 3..],
693 [
694 "--legacy-arg".to_string(),
695 "--lang=en-US".to_string(),
696 AUTOMATION_CONTROLLED_OFF_ARG.to_string()
697 ]
698 );
699 }
700
701 #[test]
702 fn all_chrome_args_can_ignore_every_default() {
703 let options = LaunchOptions::default()
704 .ignore_all_default_args()
705 .with_extra_args(vec!["--lang=en-US".to_string()]);
706
707 assert_eq!(
708 options.all_chrome_args(),
709 [
710 "--lang=en-US".to_string(),
711 AUTOMATION_CONTROLLED_OFF_ARG.to_string()
712 ]
713 );
714 }
715
716 #[test]
717 fn all_chrome_args_can_ignore_password_store_default_specifically() {
718 let options = LaunchOptions::default()
719 .ignore_default_args(vec!["--password-store=basic".to_string()]);
720
721 let args = options.all_chrome_args();
722 assert!(!args.contains(&"--password-store=basic".to_string()));
723 assert!(args.contains(&"--no-first-run".to_string()));
724 }
725
726 #[test]
727 fn all_chrome_args_includes_custom() {
728 let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
729 let args = options.all_chrome_args();
730
731 assert!(args.contains(&"--custom".to_string()));
732 }
733
734 #[test]
735 fn all_chrome_args_disables_the_automation_controlled_feature() {
736 let args = LaunchOptions::default().all_chrome_args();
737 assert_eq!(
738 args.last().map(String::as_str),
739 Some(AUTOMATION_CONTROLLED_OFF_ARG)
740 );
741 }
742
743 #[test]
744 fn all_chrome_args_leaves_the_command_line_alone_when_parity_is_off() {
745 let args = LaunchOptions::default()
746 .automation_parity(false)
747 .all_chrome_args();
748 assert!(!args
749 .iter()
750 .any(|argument| argument == AUTOMATION_CONTROLLED_OFF_ARG));
751 }
752
753 #[test]
754 fn all_ignored_default_args_merges_parity_with_the_caller_list() {
755 let options = LaunchOptions::playwright()
756 .headless(true)
757 .ignore_default_args(vec!["--no-first-run".to_string()]);
758
759 assert_eq!(
760 options.all_ignored_default_args(),
761 [
762 "--enable-automation".to_string(),
763 PLAYWRIGHT_SOFTWARE_WEBGL_ARG.to_string(),
764 PLAYWRIGHT_HEADLESS_POINTER_ARG.to_string(),
765 "--no-first-run".to_string()
766 ]
767 );
768 }
769
770 #[test]
771 fn all_ignored_default_args_does_not_repeat_a_switch_the_caller_already_listed() {
772 let options = LaunchOptions::playwright()
773 .ignore_default_args(vec!["--enable-automation".to_string()]);
774
775 assert_eq!(
776 options.all_ignored_default_args(),
777 [
778 "--enable-automation".to_string(),
779 PLAYWRIGHT_SOFTWARE_WEBGL_ARG.to_string()
780 ]
781 );
782 }
783
784 #[test]
785 fn all_ignored_default_args_keeps_only_the_caller_list_when_parity_is_off() {
786 let options = LaunchOptions::playwright()
787 .headless(true)
788 .automation_parity(false)
789 .ignore_default_args(vec!["--no-first-run".to_string()]);
790
791 assert_eq!(
792 options.all_ignored_default_args(),
793 ["--no-first-run".to_string()]
794 );
795 }
796
797 #[test]
798 fn chromiumoxide_excludes_nothing_by_default() {
799 assert!(LaunchOptions::chromiumoxide()
800 .all_ignored_default_args()
801 .is_empty());
802 }
803
804 #[test]
805 fn get_user_data_dir_uses_custom() {
806 let options = LaunchOptions::default().user_data_dir("/custom/path");
807 assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
808 }
809
810 #[test]
811 fn get_user_data_dir_creates_default() {
812 let options = LaunchOptions::default();
813 let dir = options.get_user_data_dir();
814 assert!(dir.to_string_lossy().contains("browser-commander"));
815 assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
816 }
817
818 #[tokio::test]
819 async fn launch_fantoccini_is_unimplemented() {
820 let options = LaunchOptions::fantoccini();
821 let err = launch_browser(options).await.unwrap_err();
822 assert!(err.to_string().contains("fantoccini"));
823 }
824
825 #[test]
826 fn launch_options_carry_a_fingerprint_profile() {
827 let profile = create_default_fingerprint_preset("windows-chrome").expect("preset");
828 let options = LaunchOptions::default().fingerprint(profile.clone());
829
830 assert_eq!(options.fingerprint, Some(profile));
831 }
832
833 #[tokio::test]
834 async fn launch_playwright_refuses_a_fingerprint_it_cannot_apply() {
835 let options = LaunchOptions::playwright()
838 .headless(true)
839 .fingerprint(create_default_fingerprint_preset("windows-chrome").expect("preset"));
840
841 let err = launch_browser(options).await.unwrap_err();
842
843 assert!(err
844 .to_string()
845 .contains("cannot apply a fingerprint profile"));
846 }
847
848 #[test]
849 fn launch_options_carry_a_download_setting() {
850 assert!(matches!(
851 LaunchOptions::default().downloads,
852 DownloadSetting::Off
853 ));
854 assert!(matches!(
855 LaunchOptions::default().downloads(true).downloads,
856 DownloadSetting::On
857 ));
858
859 let configured = LaunchOptions::default()
860 .downloads(crate::downloads::DownloadOptions::default().directory("/tmp/bc-downloads"));
861 let DownloadSetting::Options(options) = configured.downloads else {
862 panic!("the caller's download options were dropped");
863 };
864 assert_eq!(options.directory.as_deref(), Some("/tmp/bc-downloads"));
865 }
866
867 #[tokio::test]
868 async fn launch_playwright_refuses_downloads_it_cannot_manage() {
869 let options = LaunchOptions::playwright().headless(true).downloads(true);
872
873 let err = launch_browser(options).await.unwrap_err();
874
875 assert!(
876 err.to_string()
877 .contains("managed downloads are not supported"),
878 "unexpected message: {err}"
879 );
880 }
881
882 #[tokio::test]
883 async fn launch_playwright_reports_missing_node_executable() {
884 let options = LaunchOptions::playwright()
885 .headless(true)
886 .node_executable("browser-commander-missing-node");
887 let err = launch_browser(options).await.unwrap_err();
888 assert!(err.to_string().contains("failed to start Node.js bridge"));
889 }
890}