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 channel: Option<String>,
36 pub executable_path: Option<PathBuf>,
38 pub color_scheme: Option<ColorScheme>,
40 pub launch_timeout: Option<Duration>,
42 pub sandbox: bool,
49 pub node_executable: Option<PathBuf>,
51 pub node_working_dir: Option<PathBuf>,
53}
54
55impl Default for LaunchOptions {
56 fn default() -> Self {
57 Self {
58 engine: EngineType::Chromiumoxide,
59 user_data_dir: None,
60 headless: false,
61 slow_mo: 0,
62 verbose: false,
63 args: Vec::new(),
64 channel: None,
65 executable_path: None,
66 color_scheme: None,
67 launch_timeout: None,
68 sandbox: true,
69 node_executable: None,
70 node_working_dir: None,
71 }
72 }
73}
74
75impl LaunchOptions {
76 pub fn engine(mut self, engine: EngineType) -> Self {
78 self.engine = engine;
79 if engine == EngineType::Playwright && self.slow_mo == 0 {
80 self.slow_mo = 150;
81 }
82 self
83 }
84
85 pub fn chromiumoxide() -> Self {
87 Self {
88 engine: EngineType::Chromiumoxide,
89 ..Default::default()
90 }
91 }
92
93 pub fn fantoccini() -> Self {
95 Self {
96 engine: EngineType::Fantoccini,
97 ..Default::default()
98 }
99 }
100
101 pub fn playwright() -> Self {
103 Self {
104 engine: EngineType::Playwright,
105 slow_mo: 150,
106 ..Default::default()
107 }
108 }
109
110 pub fn puppeteer() -> Self {
112 Self {
113 engine: EngineType::Puppeteer,
114 ..Default::default()
115 }
116 }
117
118 pub fn headless(mut self, headless: bool) -> Self {
120 self.headless = headless;
121 self
122 }
123
124 pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
126 self.user_data_dir = Some(dir.into());
127 self
128 }
129
130 pub fn slow_mo(mut self, ms: u64) -> Self {
132 self.slow_mo = ms;
133 self
134 }
135
136 pub fn verbose(mut self, verbose: bool) -> Self {
138 self.verbose = verbose;
139 self
140 }
141
142 pub fn with_args(mut self, args: Vec<String>) -> Self {
144 self.args = args;
145 self
146 }
147
148 pub fn channel(mut self, channel: impl Into<String>) -> Self {
150 self.channel = Some(channel.into());
151 self
152 }
153
154 pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
156 self.executable_path = Some(executable_path.into());
157 self
158 }
159
160 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
162 self.color_scheme = Some(color_scheme);
163 self
164 }
165
166 pub fn launch_timeout(mut self, timeout: Duration) -> Self {
168 self.launch_timeout = Some(timeout);
169 self
170 }
171
172 pub fn sandbox(mut self, sandbox: bool) -> Self {
174 self.sandbox = sandbox;
175 self
176 }
177
178 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
180 self.node_executable = Some(executable.into());
181 self
182 }
183
184 pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
186 self.node_working_dir = Some(dir.into());
187 self
188 }
189
190 pub fn all_chrome_args(&self) -> Vec<String> {
192 let mut all_args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
193 all_args.extend(self.args.clone());
194 all_args
195 }
196
197 pub fn get_user_data_dir(&self) -> PathBuf {
199 if let Some(ref dir) = self.user_data_dir {
200 dir.clone()
201 } else {
202 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
203 home.join(".browser-commander")
204 .join(format!("{}-data", self.engine))
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
211pub struct Browser {
212 pub engine: EngineType,
214 pub user_data_dir: PathBuf,
216 pub headless: bool,
218}
219
220pub struct LaunchResult {
226 pub browser: Browser,
228 pub page: Arc<dyn EngineAdapter>,
234}
235
236impl std::fmt::Debug for LaunchResult {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.debug_struct("LaunchResult")
239 .field("browser", &self.browser)
240 .field("page", &"<dyn EngineAdapter>")
241 .finish()
242 }
243}
244
245pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
273 if options.verbose {
274 tracing::info!("Launching browser with {} engine...", options.engine);
275 }
276
277 let user_data_dir = options.get_user_data_dir();
278 std::fs::create_dir_all(&user_data_dir)?;
279
280 match options.engine {
281 EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
282 EngineType::Playwright | EngineType::Puppeteer => {
283 launch_node_bridge(options, user_data_dir).await
284 }
285 EngineType::Fantoccini => Err(anyhow::anyhow!(
286 "fantoccini engine launch is not yet implemented; \
287 connect to an existing WebDriver session or use EngineType::Chromiumoxide"
288 )),
289 }
290}
291
292async fn launch_node_bridge(
293 options: LaunchOptions,
294 user_data_dir: PathBuf,
295) -> Result<LaunchResult, anyhow::Error> {
296 let engine = options.engine;
297 let headless = options.headless;
298 let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
299
300 Ok(LaunchResult {
301 browser: Browser {
302 engine,
303 user_data_dir,
304 headless,
305 },
306 page: Arc::new(adapter),
307 })
308}
309
310async fn launch_chromiumoxide(
311 options: LaunchOptions,
312 user_data_dir: PathBuf,
313) -> Result<LaunchResult, anyhow::Error> {
314 let headless_mode = if options.headless {
315 HeadlessMode::New
316 } else {
317 HeadlessMode::False
318 };
319
320 let mut builder = BrowserConfig::builder()
321 .user_data_dir(&user_data_dir)
322 .headless_mode(headless_mode)
323 .args(options.all_chrome_args());
324
325 if !options.sandbox {
326 builder = builder.no_sandbox();
327 }
328
329 if let Some(ref executable_path) = options.executable_path {
330 builder = builder.chrome_executable(executable_path);
331 }
332
333 if let Some(timeout) = options.launch_timeout {
334 builder = builder.launch_timeout(timeout);
335 }
336
337 let config = builder
338 .build()
339 .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
340
341 let (browser, mut handler) = CdpBrowser::launch(config)
342 .await
343 .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
344
345 let handler_task = tokio::spawn(async move {
350 while let Some(event) = handler.next().await {
351 if let Err(err) = event {
352 tracing::debug!(error = %err, "chromiumoxide handler event error");
353 }
354 }
355 });
356
357 let page = browser
358 .new_page("about:blank")
359 .await
360 .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
361
362 let engine = options.engine;
363 let headless = options.headless;
364 let color_scheme = options.color_scheme.clone();
365
366 let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
367
368 if let Some(ref cs) = color_scheme {
370 if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
371 if options.verbose {
372 tracing::warn!(error = %err, "could not set color scheme");
373 }
374 }
375 }
376
377 if !headless {
380 if let Err(err) = adapter.bring_to_front().await {
381 if options.verbose {
382 tracing::debug!(error = %err, "bring_to_front failed");
383 }
384 }
385 }
386
387 if options.verbose {
388 tracing::info!("Browser launched with {} engine", engine);
389 }
390
391 Ok(LaunchResult {
392 browser: Browser {
393 engine,
394 user_data_dir,
395 headless,
396 },
397 page: Arc::new(adapter),
398 })
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn launch_options_default() {
407 let options = LaunchOptions::default();
408 assert_eq!(options.engine, EngineType::Chromiumoxide);
409 assert!(!options.headless);
410 assert_eq!(options.slow_mo, 0);
411 assert!(!options.verbose);
412 assert!(options.args.is_empty());
413 assert!(options.channel.is_none());
414 assert!(options.executable_path.is_none());
415 assert!(options.node_executable.is_none());
416 assert!(options.node_working_dir.is_none());
417 }
418
419 #[test]
420 fn launch_options_builder() {
421 let options = LaunchOptions::chromiumoxide()
422 .headless(true)
423 .slow_mo(100)
424 .verbose(true)
425 .with_args(vec!["--custom-arg".to_string()]);
426
427 assert_eq!(options.engine, EngineType::Chromiumoxide);
428 assert!(options.headless);
429 assert_eq!(options.slow_mo, 100);
430 assert!(options.verbose);
431 assert_eq!(options.args, vec!["--custom-arg"]);
432 }
433
434 #[test]
435 fn launch_options_fantoccini() {
436 let options = LaunchOptions::fantoccini();
437 assert_eq!(options.engine, EngineType::Fantoccini);
438 }
439
440 #[test]
441 fn launch_options_playwright() {
442 let options = LaunchOptions::playwright();
443 assert_eq!(options.engine, EngineType::Playwright);
444 assert_eq!(options.slow_mo, 150);
445 }
446
447 #[test]
448 fn launch_options_puppeteer() {
449 let options = LaunchOptions::puppeteer();
450 assert_eq!(options.engine, EngineType::Puppeteer);
451 }
452
453 #[test]
454 fn launch_options_node_bridge_configuration() {
455 let options = LaunchOptions::playwright()
456 .node_executable("/custom/node")
457 .node_working_dir("/project/js")
458 .channel("chrome-beta")
459 .executable_path("/opt/google/chrome-beta");
460
461 assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
462 assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
463 assert_eq!(options.channel.as_deref(), Some("chrome-beta"));
464 assert_eq!(
465 options.executable_path,
466 Some(PathBuf::from("/opt/google/chrome-beta"))
467 );
468 }
469
470 #[test]
471 fn all_chrome_args_includes_defaults() {
472 let options = LaunchOptions::default();
473 let args = options.all_chrome_args();
474
475 assert!(args.contains(&"--disable-infobars".to_string()));
476 assert!(args.contains(&"--no-first-run".to_string()));
477 }
478
479 #[test]
480 fn all_chrome_args_includes_custom() {
481 let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
482 let args = options.all_chrome_args();
483
484 assert!(args.contains(&"--custom".to_string()));
485 }
486
487 #[test]
488 fn get_user_data_dir_uses_custom() {
489 let options = LaunchOptions::default().user_data_dir("/custom/path");
490 assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
491 }
492
493 #[test]
494 fn get_user_data_dir_creates_default() {
495 let options = LaunchOptions::default();
496 let dir = options.get_user_data_dir();
497 assert!(dir.to_string_lossy().contains("browser-commander"));
498 assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
499 }
500
501 #[tokio::test]
502 async fn launch_fantoccini_is_unimplemented() {
503 let options = LaunchOptions::fantoccini();
504 let err = launch_browser(options).await.unwrap_err();
505 assert!(err.to_string().contains("fantoccini"));
506 }
507
508 #[tokio::test]
509 async fn launch_playwright_reports_missing_node_executable() {
510 let options = LaunchOptions::playwright()
511 .headless(true)
512 .node_executable("browser-commander-missing-node");
513 let err = launch_browser(options).await.unwrap_err();
514 assert!(err.to_string().contains("failed to start Node.js bridge"));
515 }
516}