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::core::constants::CHROME_ARGS;
16use crate::core::engine::{EngineAdapter, EngineType};
17
18/// Options for launching a browser.
19#[derive(Debug, Clone)]
20pub struct LaunchOptions {
21    /// The browser engine to use.
22    pub engine: EngineType,
23    /// Path to user data directory.
24    pub user_data_dir: Option<PathBuf>,
25    /// Run in headless mode.
26    pub headless: bool,
27    /// Slow down operations by this many milliseconds.
28    pub slow_mo: u64,
29    /// Enable verbose logging.
30    pub verbose: bool,
31    /// Additional Chrome arguments.
32    pub args: Vec<String>,
33    /// Color scheme to emulate. `None` uses the system default.
34    pub color_scheme: Option<ColorScheme>,
35    /// Optional timeout for the browser launch handshake.
36    pub launch_timeout: Option<Duration>,
37    /// Whether to run the browser with the Chromium sandbox enabled.
38    ///
39    /// Defaults to `true`. Disable when running in environments where the
40    /// sandbox is unavailable (e.g. CI containers without the required
41    /// capabilities). This translates to the `--no-sandbox` /
42    /// `--disable-setuid-sandbox` Chromium flags.
43    pub sandbox: bool,
44}
45
46impl Default for LaunchOptions {
47    fn default() -> Self {
48        Self {
49            engine: EngineType::Chromiumoxide,
50            user_data_dir: None,
51            headless: false,
52            slow_mo: 0,
53            verbose: false,
54            args: Vec::new(),
55            color_scheme: None,
56            launch_timeout: None,
57            sandbox: true,
58        }
59    }
60}
61
62impl LaunchOptions {
63    /// Create options for chromiumoxide engine.
64    pub fn chromiumoxide() -> Self {
65        Self {
66            engine: EngineType::Chromiumoxide,
67            ..Default::default()
68        }
69    }
70
71    /// Create options for fantoccini (WebDriver) engine.
72    pub fn fantoccini() -> Self {
73        Self {
74            engine: EngineType::Fantoccini,
75            ..Default::default()
76        }
77    }
78
79    /// Set headless mode.
80    pub fn headless(mut self, headless: bool) -> Self {
81        self.headless = headless;
82        self
83    }
84
85    /// Set the user data directory.
86    pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
87        self.user_data_dir = Some(dir.into());
88        self
89    }
90
91    /// Set slow motion delay.
92    pub fn slow_mo(mut self, ms: u64) -> Self {
93        self.slow_mo = ms;
94        self
95    }
96
97    /// Enable verbose logging.
98    pub fn verbose(mut self, verbose: bool) -> Self {
99        self.verbose = verbose;
100        self
101    }
102
103    /// Add additional Chrome arguments.
104    pub fn with_args(mut self, args: Vec<String>) -> Self {
105        self.args = args;
106        self
107    }
108
109    /// Set the color scheme for media emulation.
110    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
111        self.color_scheme = Some(color_scheme);
112        self
113    }
114
115    /// Override the browser launch timeout.
116    pub fn launch_timeout(mut self, timeout: Duration) -> Self {
117        self.launch_timeout = Some(timeout);
118        self
119    }
120
121    /// Enable or disable the Chromium sandbox for the launched browser.
122    pub fn sandbox(mut self, sandbox: bool) -> Self {
123        self.sandbox = sandbox;
124        self
125    }
126
127    /// Get all Chrome arguments (default + custom).
128    pub fn all_chrome_args(&self) -> Vec<String> {
129        let mut all_args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
130        all_args.extend(self.args.clone());
131        all_args
132    }
133
134    /// Get the user data directory, using a default if not specified.
135    pub fn get_user_data_dir(&self) -> PathBuf {
136        if let Some(ref dir) = self.user_data_dir {
137            dir.clone()
138        } else {
139            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
140            home.join(".browser-commander")
141                .join(format!("{}-data", self.engine))
142        }
143    }
144}
145
146/// Browser metadata returned alongside a launched page.
147#[derive(Debug, Clone)]
148pub struct Browser {
149    /// The engine type being used.
150    pub engine: EngineType,
151    /// The user data directory.
152    pub user_data_dir: PathBuf,
153    /// Whether the browser is running headless.
154    pub headless: bool,
155}
156
157/// Result of a browser launch.
158///
159/// Contains both static metadata (`browser`) and a live
160/// [`EngineAdapter`] (`page`) that can be passed to the navigation,
161/// interaction, and query helpers exposed by this crate.
162pub struct LaunchResult {
163    /// The browser metadata.
164    pub browser: Browser,
165    /// A live page/adapter tied to the launched browser.
166    ///
167    /// For `Chromiumoxide`, this is a [`ChromiumoxidePage`](crate::browser::ChromiumoxidePage)
168    /// implementing [`EngineAdapter`]. Pass `launch_result.page.as_ref()` to
169    /// `goto`, `click`, `evaluate`, and other helpers.
170    pub page: Arc<dyn EngineAdapter>,
171}
172
173impl std::fmt::Debug for LaunchResult {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.debug_struct("LaunchResult")
176            .field("browser", &self.browser)
177            .field("page", &"<dyn EngineAdapter>")
178            .finish()
179    }
180}
181
182/// Launch a browser with the given options.
183///
184/// For the `Chromiumoxide` engine, this starts a Chromium process, waits for
185/// the CDP handshake, opens a blank page, and returns a [`LaunchResult`]
186/// containing both the metadata (`browser`) and a live page adapter (`page`)
187/// implementing [`EngineAdapter`].
188///
189/// The `Fantoccini` engine is not yet implemented as a managed launcher; use
190/// chromiumoxide or connect to an externally-managed WebDriver session.
191///
192/// # Arguments
193///
194/// * `options` - Launch options
195///
196/// # Returns
197///
198/// The launch result containing the browser metadata and a page adapter
199///
200/// # Errors
201///
202/// Returns an error if the browser fails to launch.
203pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
204    if options.verbose {
205        tracing::info!("Launching browser with {} engine...", options.engine);
206    }
207
208    let user_data_dir = options.get_user_data_dir();
209    std::fs::create_dir_all(&user_data_dir)?;
210
211    match options.engine {
212        EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
213        EngineType::Fantoccini => Err(anyhow::anyhow!(
214            "fantoccini engine launch is not yet implemented; \
215             connect to an existing WebDriver session or use EngineType::Chromiumoxide"
216        )),
217    }
218}
219
220async fn launch_chromiumoxide(
221    options: LaunchOptions,
222    user_data_dir: PathBuf,
223) -> Result<LaunchResult, anyhow::Error> {
224    let headless_mode = if options.headless {
225        HeadlessMode::New
226    } else {
227        HeadlessMode::False
228    };
229
230    let mut builder = BrowserConfig::builder()
231        .user_data_dir(&user_data_dir)
232        .headless_mode(headless_mode)
233        .args(options.all_chrome_args());
234
235    if !options.sandbox {
236        builder = builder.no_sandbox();
237    }
238
239    if let Some(timeout) = options.launch_timeout {
240        builder = builder.launch_timeout(timeout);
241    }
242
243    let config = builder
244        .build()
245        .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
246
247    let (browser, mut handler) = CdpBrowser::launch(config)
248        .await
249        .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
250
251    // Drain the CDP event stream on a background task. Dropping the handler
252    // causes the browser to hang, so we must keep polling it for the lifetime
253    // of the browser. Errors are logged but do not abort the task — the CDP
254    // channel naturally returns errors once the browser is closed.
255    let handler_task = tokio::spawn(async move {
256        while let Some(event) = handler.next().await {
257            if let Err(err) = event {
258                tracing::debug!(error = %err, "chromiumoxide handler event error");
259            }
260        }
261    });
262
263    let page = browser
264        .new_page("about:blank")
265        .await
266        .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
267
268    let engine = options.engine;
269    let headless = options.headless;
270    let color_scheme = options.color_scheme.clone();
271
272    let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
273
274    // Apply color scheme emulation (best-effort).
275    if let Some(ref cs) = color_scheme {
276        if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
277            if options.verbose {
278                tracing::warn!(error = %err, "could not set color scheme");
279            }
280        }
281    }
282
283    // Bring the page to front so the address bar is not focused when running
284    // headful — mirrors the JS launcher's behavior.
285    if !headless {
286        if let Err(err) = adapter.bring_to_front().await {
287            if options.verbose {
288                tracing::debug!(error = %err, "bring_to_front failed");
289            }
290        }
291    }
292
293    if options.verbose {
294        tracing::info!("Browser launched with {} engine", engine);
295    }
296
297    Ok(LaunchResult {
298        browser: Browser {
299            engine,
300            user_data_dir,
301            headless,
302        },
303        page: Arc::new(adapter),
304    })
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn launch_options_default() {
313        let options = LaunchOptions::default();
314        assert_eq!(options.engine, EngineType::Chromiumoxide);
315        assert!(!options.headless);
316        assert_eq!(options.slow_mo, 0);
317        assert!(!options.verbose);
318        assert!(options.args.is_empty());
319    }
320
321    #[test]
322    fn launch_options_builder() {
323        let options = LaunchOptions::chromiumoxide()
324            .headless(true)
325            .slow_mo(100)
326            .verbose(true)
327            .with_args(vec!["--custom-arg".to_string()]);
328
329        assert_eq!(options.engine, EngineType::Chromiumoxide);
330        assert!(options.headless);
331        assert_eq!(options.slow_mo, 100);
332        assert!(options.verbose);
333        assert_eq!(options.args, vec!["--custom-arg"]);
334    }
335
336    #[test]
337    fn launch_options_fantoccini() {
338        let options = LaunchOptions::fantoccini();
339        assert_eq!(options.engine, EngineType::Fantoccini);
340    }
341
342    #[test]
343    fn all_chrome_args_includes_defaults() {
344        let options = LaunchOptions::default();
345        let args = options.all_chrome_args();
346
347        assert!(args.contains(&"--disable-infobars".to_string()));
348        assert!(args.contains(&"--no-first-run".to_string()));
349    }
350
351    #[test]
352    fn all_chrome_args_includes_custom() {
353        let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
354        let args = options.all_chrome_args();
355
356        assert!(args.contains(&"--custom".to_string()));
357    }
358
359    #[test]
360    fn get_user_data_dir_uses_custom() {
361        let options = LaunchOptions::default().user_data_dir("/custom/path");
362        assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
363    }
364
365    #[test]
366    fn get_user_data_dir_creates_default() {
367        let options = LaunchOptions::default();
368        let dir = options.get_user_data_dir();
369        assert!(dir.to_string_lossy().contains("browser-commander"));
370        assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
371    }
372
373    #[tokio::test]
374    async fn launch_fantoccini_is_unimplemented() {
375        let options = LaunchOptions::fantoccini();
376        let err = launch_browser(options).await.unwrap_err();
377        assert!(err.to_string().contains("fantoccini"));
378    }
379}