Skip to main content

browser_commander/browser/
launcher.rs

1//! Browser launcher for browser automation.
2//!
3//! This module provides utilities for launching browser instances
4//! with appropriate configuration.
5
6use 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/// Options for launching a browser.
20#[derive(Debug, Clone)]
21pub struct LaunchOptions {
22    /// The browser engine to use.
23    pub engine: EngineType,
24    /// Path to user data directory.
25    pub user_data_dir: Option<PathBuf>,
26    /// Run in headless mode.
27    pub headless: bool,
28    /// Slow down operations by this many milliseconds.
29    pub slow_mo: u64,
30    /// Enable verbose logging.
31    pub verbose: bool,
32    /// Additional Chrome arguments.
33    pub args: Vec<String>,
34    /// Additional Chrome arguments appended after the compatibility `args`.
35    pub extra_args: Vec<String>,
36    /// Browser Commander default arguments to omit.
37    pub ignore_default_args: Vec<String>,
38    /// Omit every Browser Commander and engine default argument.
39    pub ignore_all_default_args: bool,
40    /// Installed browser channel for Playwright/Puppeteer (for example, `chrome`).
41    pub channel: Option<String>,
42    /// Explicit path to a Chrome or Chromium executable.
43    pub executable_path: Option<PathBuf>,
44    /// Color scheme to emulate. `None` uses the system default.
45    pub color_scheme: Option<ColorScheme>,
46    /// Optional timeout for the browser launch handshake.
47    pub launch_timeout: Option<Duration>,
48    /// Whether to run the browser with the Chromium sandbox enabled.
49    ///
50    /// Defaults to `true`. Disable when running in environments where the
51    /// sandbox is unavailable (e.g. CI containers without the required
52    /// capabilities). This translates to the `--no-sandbox` /
53    /// `--disable-setuid-sandbox` Chromium flags.
54    pub sandbox: bool,
55    /// Node.js executable for Playwright/Puppeteer fallback engines.
56    pub node_executable: Option<PathBuf>,
57    /// Working directory used to resolve Playwright/Puppeteer Node packages.
58    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    /// Set the browser automation engine.
86    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    /// Create options for chromiumoxide engine.
95    pub fn chromiumoxide() -> Self {
96        Self {
97            engine: EngineType::Chromiumoxide,
98            ..Default::default()
99        }
100    }
101
102    /// Create options for fantoccini (WebDriver) engine.
103    pub fn fantoccini() -> Self {
104        Self {
105            engine: EngineType::Fantoccini,
106            ..Default::default()
107        }
108    }
109
110    /// Create options for Playwright through the Node.js CLI bridge.
111    pub fn playwright() -> Self {
112        Self {
113            engine: EngineType::Playwright,
114            slow_mo: 150,
115            ..Default::default()
116        }
117    }
118
119    /// Create options for Puppeteer through the Node.js CLI bridge.
120    pub fn puppeteer() -> Self {
121        Self {
122            engine: EngineType::Puppeteer,
123            ..Default::default()
124        }
125    }
126
127    /// Set headless mode.
128    pub fn headless(mut self, headless: bool) -> Self {
129        self.headless = headless;
130        self
131    }
132
133    /// Set the user data directory.
134    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    /// Set slow motion delay.
140    pub fn slow_mo(mut self, ms: u64) -> Self {
141        self.slow_mo = ms;
142        self
143    }
144
145    /// Enable verbose logging.
146    pub fn verbose(mut self, verbose: bool) -> Self {
147        self.verbose = verbose;
148        self
149    }
150
151    /// Add additional Chrome arguments.
152    pub fn with_args(mut self, args: Vec<String>) -> Self {
153        self.args = args;
154        self
155    }
156
157    /// Add Chrome arguments after the compatibility `args` field.
158    pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
159        self.extra_args = args;
160        self
161    }
162
163    /// Omit selected Browser Commander defaults.
164    pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
165        self.ignore_default_args = args;
166        self
167    }
168
169    /// Omit every Browser Commander and engine default argument.
170    pub fn ignore_all_default_args(mut self) -> Self {
171        self.ignore_all_default_args = true;
172        self
173    }
174
175    /// Select an installed browser channel for Playwright or Puppeteer.
176    pub fn channel(mut self, channel: impl Into<String>) -> Self {
177        self.channel = Some(channel.into());
178        self
179    }
180
181    /// Select an explicit Chrome or Chromium executable.
182    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    /// Set the color scheme for media emulation.
188    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
189        self.color_scheme = Some(color_scheme);
190        self
191    }
192
193    /// Override the browser launch timeout.
194    pub fn launch_timeout(mut self, timeout: Duration) -> Self {
195        self.launch_timeout = Some(timeout);
196        self
197    }
198
199    /// Enable or disable the Chromium sandbox for the launched browser.
200    pub fn sandbox(mut self, sandbox: bool) -> Self {
201        self.sandbox = sandbox;
202        self
203    }
204
205    /// Override the Node.js executable used by Playwright/Puppeteer engines.
206    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
207        self.node_executable = Some(executable.into());
208        self
209    }
210
211    /// Set the directory where Node resolves `playwright` or `puppeteer`.
212    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    /// Get all Chrome arguments (default + custom).
218    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    /// Get the user data directory, using a default if not specified.
239    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/// Browser metadata returned alongside a launched page.
251#[derive(Debug, Clone)]
252pub struct Browser {
253    /// The engine type being used.
254    pub engine: EngineType,
255    /// The user data directory.
256    pub user_data_dir: PathBuf,
257    /// Whether the browser is running headless.
258    pub headless: bool,
259}
260
261/// Result of a browser launch.
262///
263/// Contains both static metadata (`browser`) and a live
264/// [`EngineAdapter`] (`page`) that can be passed to the navigation,
265/// interaction, and query helpers exposed by this crate.
266pub struct LaunchResult {
267    /// The browser metadata.
268    pub browser: Browser,
269    /// A live page/adapter tied to the launched browser.
270    ///
271    /// For `Chromiumoxide`, this is a [`ChromiumoxidePage`]
272    /// implementing [`EngineAdapter`]. Pass `launch_result.page.as_ref()` to
273    /// `goto`, `click`, `evaluate`, and other helpers.
274    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
286/// Launch a browser with the given options.
287///
288/// For the `Chromiumoxide` engine, this starts a Chromium process, waits for
289/// the CDP handshake, opens a blank page, and returns a [`LaunchResult`]
290/// containing both the metadata (`browser`) and a live page adapter (`page`)
291/// implementing [`EngineAdapter`].
292///
293/// For the `Playwright` and `Puppeteer` engines, this starts a local Node.js
294/// subprocess and uses the official Node package as a CLI bridge. The selected
295/// package must be available to Node module resolution, usually by running
296/// `npm install playwright` or `npm install puppeteer` in the configured
297/// `node_working_dir`.
298///
299/// The `Fantoccini` engine is not yet implemented as a managed launcher; use
300/// chromiumoxide or connect to an externally-managed WebDriver session.
301///
302/// # Arguments
303///
304/// * `options` - Launch options
305///
306/// # Returns
307///
308/// The launch result containing the browser metadata and a page adapter
309///
310/// # Errors
311///
312/// Returns an error if the browser fails to launch.
313pub 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    // Chromiumoxide only exposes an all-or-nothing switch for its own default
367    // layer. Disable that layer whenever the caller requests an omission so an
368    // engine-provided duplicate cannot silently re-add the selected flag.
369    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    // Drain the CDP event stream on a background task. Dropping the handler
394    // causes the browser to hang, so we must keep polling it for the lifetime
395    // of the browser. Errors are logged but do not abort the task — the CDP
396    // channel naturally returns errors once the browser is closed.
397    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    // Apply color scheme emulation (best-effort).
417    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    // Bring the page to front so the address bar is not focused when running
426    // headful — mirrors the JS launcher's behavior.
427    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}