viewpoint_core/browser/launcher/
mod.rs

1//! Browser launching functionality.
2
3use std::env;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Stdio};
8use std::time::Duration;
9
10use tempfile::TempDir;
11use tokio::time::timeout;
12use tracing::{debug, info, instrument, trace, warn};
13use viewpoint_cdp::CdpConnection;
14
15use super::Browser;
16use crate::error::BrowserError;
17
18/// User data directory configuration for browser profiles.
19///
20/// Controls how the browser manages user data (cookies, localStorage, settings).
21/// The default is [`UserDataDir::Temp`], which creates an isolated temporary
22/// directory that is automatically cleaned up when the browser closes.
23///
24/// # Breaking Change
25///
26/// Prior to this change, browsers used the system default profile (`~/.config/chromium/`)
27/// by default. To restore the old behavior, use [`UserDataDir::System`] explicitly:
28///
29/// ```no_run
30/// use viewpoint_core::Browser;
31///
32/// # async fn example() -> Result<(), viewpoint_core::CoreError> {
33/// let browser = Browser::launch()
34///     .user_data_dir_system()
35///     .launch()
36///     .await?;
37/// # Ok(())
38/// # }
39/// ```
40#[derive(Debug, Clone)]
41pub enum UserDataDir {
42    /// Create a unique temporary directory per session.
43    ///
44    /// This is the default mode. Each browser instance gets its own isolated
45    /// profile that is automatically deleted when the browser closes or is dropped.
46    /// This prevents conflicts when running multiple browser instances concurrently.
47    Temp,
48
49    /// Copy a template profile to a temporary directory.
50    ///
51    /// The template directory contents are copied to a new temporary directory.
52    /// The temporary directory is cleaned up when the browser closes.
53    /// The original template directory is unchanged.
54    ///
55    /// Use this when you need pre-configured settings, extensions, or cookies
56    /// as a starting point, but still want isolation between sessions.
57    TempFromTemplate(PathBuf),
58
59    /// Use a persistent directory for browser data.
60    ///
61    /// Browser state (cookies, localStorage, settings) persists in the specified
62    /// directory across browser restarts. The directory is NOT cleaned up when
63    /// the browser closes.
64    ///
65    /// Note: Using the same persistent directory for multiple concurrent browser
66    /// instances will cause profile lock conflicts.
67    Persist(PathBuf),
68
69    /// Use the system default profile.
70    ///
71    /// On Linux, this is typically `~/.config/chromium/`.
72    /// No `--user-data-dir` flag is passed to Chromium.
73    ///
74    /// **Warning**: This can cause conflicts if another Chromium instance is running,
75    /// or if a previous session crashed without proper cleanup. Prefer [`UserDataDir::Temp`]
76    /// for automation scenarios.
77    System,
78}
79
80/// Default timeout for browser launch.
81const DEFAULT_LAUNCH_TIMEOUT: Duration = Duration::from_secs(30);
82
83/// Common Chromium paths on different platforms.
84const CHROMIUM_PATHS: &[&str] = &[
85    // Linux
86    "chromium",
87    "chromium-browser",
88    "/usr/bin/chromium",
89    "/usr/bin/chromium-browser",
90    "/snap/bin/chromium",
91    // macOS
92    "/Applications/Chromium.app/Contents/MacOS/Chromium",
93    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
94    // Windows
95    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
96    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
97];
98
99/// Builder for launching a browser.
100#[derive(Debug, Clone)]
101pub struct BrowserBuilder {
102    /// Path to Chromium executable.
103    executable_path: Option<PathBuf>,
104    /// Whether to run in headless mode.
105    headless: bool,
106    /// Additional command line arguments.
107    args: Vec<String>,
108    /// Launch timeout.
109    timeout: Duration,
110    /// User data directory configuration.
111    user_data_dir: UserDataDir,
112}
113
114impl Default for BrowserBuilder {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl BrowserBuilder {
121    /// Create a new browser builder with default settings.
122    ///
123    /// By default, the browser uses an isolated temporary directory for user data.
124    /// This prevents conflicts when running multiple browser instances and ensures
125    /// clean sessions for automation.
126    pub fn new() -> Self {
127        Self {
128            executable_path: None,
129            headless: true,
130            args: Vec::new(),
131            timeout: DEFAULT_LAUNCH_TIMEOUT,
132            user_data_dir: UserDataDir::Temp,
133        }
134    }
135
136    /// Set the path to the Chromium executable.
137    ///
138    /// If not set, the launcher will search common paths and
139    /// check the `CHROMIUM_PATH` environment variable.
140    #[must_use]
141    pub fn executable_path(mut self, path: impl Into<PathBuf>) -> Self {
142        self.executable_path = Some(path.into());
143        self
144    }
145
146    /// Set whether to run in headless mode.
147    ///
148    /// Default is `true`.
149    #[must_use]
150    pub fn headless(mut self, headless: bool) -> Self {
151        self.headless = headless;
152        self
153    }
154
155    /// Add additional command line arguments.
156    #[must_use]
157    pub fn args<I, S>(mut self, args: I) -> Self
158    where
159        I: IntoIterator<Item = S>,
160        S: Into<String>,
161    {
162        self.args.extend(args.into_iter().map(Into::into));
163        self
164    }
165
166    /// Set the launch timeout.
167    ///
168    /// Default is 30 seconds.
169    #[must_use]
170    pub fn timeout(mut self, timeout: Duration) -> Self {
171        self.timeout = timeout;
172        self
173    }
174
175    /// Set a persistent user data directory for browser profile.
176    ///
177    /// When set, browser state (cookies, localStorage, settings) persists
178    /// in the specified directory across browser restarts. The directory
179    /// is NOT cleaned up when the browser closes.
180    ///
181    /// **Note**: Using the same directory for multiple concurrent browser
182    /// instances will cause profile lock conflicts.
183    ///
184    /// # Example
185    ///
186    /// ```no_run
187    /// use viewpoint_core::Browser;
188    ///
189    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
190    /// let browser = Browser::launch()
191    ///     .user_data_dir("/path/to/profile")
192    ///     .launch()
193    ///     .await?;
194    /// # Ok(())
195    /// # }
196    /// ```
197    #[must_use]
198    pub fn user_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
199        self.user_data_dir = UserDataDir::Persist(path.into());
200        self
201    }
202
203    /// Use the system default profile directory.
204    ///
205    /// On Linux, this is typically `~/.config/chromium/`.
206    /// No `--user-data-dir` flag is passed to Chromium.
207    ///
208    /// **Warning**: This can cause conflicts if another Chromium instance is running,
209    /// or if a previous session crashed without proper cleanup. Prefer the default
210    /// isolated temp profile for automation scenarios.
211    ///
212    /// # Example
213    ///
214    /// ```no_run
215    /// use viewpoint_core::Browser;
216    ///
217    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
218    /// let browser = Browser::launch()
219    ///     .user_data_dir_system()
220    ///     .launch()
221    ///     .await?;
222    /// # Ok(())
223    /// # }
224    /// ```
225    #[must_use]
226    pub fn user_data_dir_system(mut self) -> Self {
227        self.user_data_dir = UserDataDir::System;
228        self
229    }
230
231    /// Use a template profile copied to a temporary directory.
232    ///
233    /// The contents of the template directory are copied to a new temporary
234    /// directory. This allows starting with pre-configured settings, extensions,
235    /// or cookies while maintaining isolation between sessions.
236    ///
237    /// The temporary directory is automatically cleaned up when the browser
238    /// closes or is dropped. The original template directory is unchanged.
239    ///
240    /// # Example
241    ///
242    /// ```no_run
243    /// use viewpoint_core::Browser;
244    ///
245    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
246    /// // Create a browser with extensions from a template profile
247    /// let browser = Browser::launch()
248    ///     .user_data_dir_template_from("/path/to/template-profile")
249    ///     .launch()
250    ///     .await?;
251    /// # Ok(())
252    /// # }
253    /// ```
254    ///
255    /// # Loading Extensions
256    ///
257    /// Extensions can also be loaded at runtime without a template profile:
258    ///
259    /// ```no_run
260    /// use viewpoint_core::Browser;
261    ///
262    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
263    /// let browser = Browser::launch()
264    ///     .args(["--load-extension=/path/to/unpacked-extension"])
265    ///     .launch()
266    ///     .await?;
267    /// # Ok(())
268    /// # }
269    /// ```
270    #[must_use]
271    pub fn user_data_dir_template_from(mut self, template_path: impl Into<PathBuf>) -> Self {
272        self.user_data_dir = UserDataDir::TempFromTemplate(template_path.into());
273        self
274    }
275
276    /// Launch the browser.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if:
281    /// - Chromium is not found
282    /// - The process fails to spawn
283    /// - The browser doesn't start within the timeout
284    /// - Template directory doesn't exist or can't be copied
285    #[instrument(level = "info", skip(self), fields(headless = self.headless, timeout_ms = self.timeout.as_millis()))]
286    pub async fn launch(self) -> Result<Browser, BrowserError> {
287        info!("Launching browser");
288
289        let executable = self.find_executable()?;
290        info!(executable = %executable.display(), "Found Chromium executable");
291
292        // Handle user data directory configuration
293        let (user_data_path, temp_dir) = self.prepare_user_data_dir()?;
294
295        let mut cmd = Command::new(&executable);
296
297        // Add default arguments
298        cmd.arg("--remote-debugging-port=0");
299
300        if self.headless {
301            cmd.arg("--headless=new");
302            debug!("Running in headless mode");
303        } else {
304            debug!("Running in headed mode");
305        }
306
307        // Add common stability flags
308        let stability_args = [
309            "--disable-background-networking",
310            "--disable-background-timer-throttling",
311            "--disable-backgrounding-occluded-windows",
312            "--disable-breakpad",
313            "--disable-component-extensions-with-background-pages",
314            "--disable-component-update",
315            "--disable-default-apps",
316            "--disable-dev-shm-usage",
317            "--disable-extensions",
318            "--disable-features=TranslateUI",
319            "--disable-hang-monitor",
320            "--disable-ipc-flooding-protection",
321            "--disable-popup-blocking",
322            "--disable-prompt-on-repost",
323            "--disable-renderer-backgrounding",
324            "--disable-sync",
325            "--enable-features=NetworkService,NetworkServiceInProcess",
326            "--force-color-profile=srgb",
327            "--metrics-recording-only",
328            "--no-first-run",
329            "--password-store=basic",
330            "--use-mock-keychain",
331        ];
332        cmd.args(stability_args);
333        trace!(arg_count = stability_args.len(), "Added stability flags");
334
335        // Add user data directory if we have one
336        if let Some(ref user_data_dir) = user_data_path {
337            cmd.arg(format!("--user-data-dir={}", user_data_dir.display()));
338            debug!(user_data_dir = %user_data_dir.display(), "Using user data directory");
339        } else {
340            debug!("Using system default user data directory");
341        }
342
343        // Add user arguments
344        if !self.args.is_empty() {
345            cmd.args(&self.args);
346            debug!(user_args = ?self.args, "Added user arguments");
347        }
348
349        // Capture stderr for the WebSocket URL
350        cmd.stderr(Stdio::piped());
351        cmd.stdout(Stdio::null());
352
353        info!("Spawning Chromium process");
354        let mut child = cmd.spawn().map_err(|e| {
355            warn!(error = %e, "Failed to spawn Chromium process");
356            BrowserError::LaunchFailed(e.to_string())
357        })?;
358
359        let pid = child.id();
360        info!(pid = pid, "Chromium process spawned");
361
362        // Read the WebSocket URL from stderr
363        debug!("Waiting for DevTools WebSocket URL");
364        let ws_url = timeout(self.timeout, Self::read_ws_url(&mut child))
365            .await
366            .map_err(|_| {
367                warn!(
368                    timeout_ms = self.timeout.as_millis(),
369                    "Browser launch timed out"
370                );
371                BrowserError::LaunchTimeout(self.timeout)
372            })??;
373
374        info!(ws_url = %ws_url, "Got DevTools WebSocket URL");
375
376        // Connect to the browser
377        debug!("Connecting to browser via CDP");
378        let connection = CdpConnection::connect(&ws_url).await?;
379
380        info!(pid = pid, "Browser launched and connected successfully");
381        Ok(Browser::from_launch(connection, child, temp_dir))
382    }
383
384    /// Prepare the user data directory based on configuration.
385    ///
386    /// Returns the path to use for `--user-data-dir` (if any) and an optional
387    /// `TempDir` handle that should be stored in the `Browser` struct to ensure
388    /// cleanup on drop.
389    fn prepare_user_data_dir(&self) -> Result<(Option<PathBuf>, Option<TempDir>), BrowserError> {
390        match &self.user_data_dir {
391            UserDataDir::Temp => {
392                // Create a unique temporary directory
393                let temp_dir = TempDir::with_prefix("viewpoint-browser-").map_err(|e| {
394                    BrowserError::LaunchFailed(format!(
395                        "Failed to create temporary user data directory: {e}"
396                    ))
397                })?;
398                let path = temp_dir.path().to_path_buf();
399                debug!(path = %path.display(), "Created temporary user data directory");
400                Ok((Some(path), Some(temp_dir)))
401            }
402            UserDataDir::TempFromTemplate(template_path) => {
403                // Validate template exists
404                if !template_path.exists() {
405                    return Err(BrowserError::LaunchFailed(format!(
406                        "Template profile directory does not exist: {}",
407                        template_path.display()
408                    )));
409                }
410                if !template_path.is_dir() {
411                    return Err(BrowserError::LaunchFailed(format!(
412                        "Template profile path is not a directory: {}",
413                        template_path.display()
414                    )));
415                }
416
417                // Create temporary directory
418                let temp_dir = TempDir::with_prefix("viewpoint-browser-").map_err(|e| {
419                    BrowserError::LaunchFailed(format!(
420                        "Failed to create temporary user data directory: {e}"
421                    ))
422                })?;
423                let dest_path = temp_dir.path().to_path_buf();
424
425                // Copy template contents to temp directory
426                debug!(
427                    template = %template_path.display(),
428                    dest = %dest_path.display(),
429                    "Copying template profile to temporary directory"
430                );
431                copy_dir_recursive(template_path, &dest_path).map_err(|e| {
432                    BrowserError::LaunchFailed(format!(
433                        "Failed to copy template profile: {e}"
434                    ))
435                })?;
436
437                info!(
438                    template = %template_path.display(),
439                    dest = %dest_path.display(),
440                    "Template profile copied to temporary directory"
441                );
442                Ok((Some(dest_path), Some(temp_dir)))
443            }
444            UserDataDir::Persist(path) => {
445                // Use the specified path, no cleanup
446                debug!(path = %path.display(), "Using persistent user data directory");
447                Ok((Some(path.clone()), None))
448            }
449            UserDataDir::System => {
450                // No --user-data-dir flag, use system default
451                debug!("Using system default user data directory");
452                Ok((None, None))
453            }
454        }
455    }
456
457    /// Find the Chromium executable.
458    #[instrument(level = "debug", skip(self))]
459    fn find_executable(&self) -> Result<PathBuf, BrowserError> {
460        // Check if explicitly set
461        if let Some(ref path) = self.executable_path {
462            debug!(path = %path.display(), "Checking explicit executable path");
463            if path.exists() {
464                info!(path = %path.display(), "Using explicit executable path");
465                return Ok(path.clone());
466            }
467            warn!(path = %path.display(), "Explicit executable path does not exist");
468            return Err(BrowserError::ChromiumNotFound);
469        }
470
471        // Check environment variable
472        if let Ok(path_str) = env::var("CHROMIUM_PATH") {
473            let path = PathBuf::from(&path_str);
474            debug!(path = %path.display(), "Checking CHROMIUM_PATH environment variable");
475            if path.exists() {
476                info!(path = %path.display(), "Using CHROMIUM_PATH");
477                return Ok(path);
478            }
479            warn!(path = %path.display(), "CHROMIUM_PATH does not exist");
480        }
481
482        // Search common paths
483        debug!("Searching common Chromium paths");
484        for path_str in CHROMIUM_PATHS {
485            let path = PathBuf::from(path_str);
486            if path.exists() {
487                info!(path = %path.display(), "Found Chromium at common path");
488                return Ok(path);
489            }
490
491            // Also try which/where
492            if let Ok(output) = Command::new("which").arg(path_str).output() {
493                if output.status.success() {
494                    let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
495                    if !found.is_empty() {
496                        let found_path = PathBuf::from(&found);
497                        info!(path = %found_path.display(), "Found Chromium via 'which'");
498                        return Ok(found_path);
499                    }
500                }
501            }
502        }
503
504        warn!("Chromium not found in any expected location");
505        Err(BrowserError::ChromiumNotFound)
506    }
507
508    /// Read the WebSocket URL from the browser's stderr.
509    async fn read_ws_url(child: &mut Child) -> Result<String, BrowserError> {
510        let stderr = child
511            .stderr
512            .take()
513            .ok_or_else(|| BrowserError::LaunchFailed("failed to capture stderr".into()))?;
514
515        // Spawn blocking read in a separate task
516        let handle = tokio::task::spawn_blocking(move || {
517            let reader = BufReader::new(stderr);
518
519            for line in reader.lines() {
520                let Ok(line) = line else { continue };
521
522                trace!(line = %line, "Read line from Chromium stderr");
523
524                // Look for "DevTools listening on ws://..."
525                if let Some(pos) = line.find("DevTools listening on ") {
526                    let url = &line[pos + 22..];
527                    return Some(url.trim().to_string());
528                }
529            }
530
531            None
532        });
533
534        handle
535            .await
536            .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?
537            .ok_or(BrowserError::LaunchFailed(
538                "failed to find WebSocket URL in browser output".into(),
539            ))
540    }
541}
542
543/// Recursively copy a directory and its contents.
544///
545/// This copies files and subdirectories from `src` to `dst`.
546/// The destination directory must already exist.
547fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
548    for entry in fs::read_dir(src)? {
549        let entry = entry?;
550        let src_path = entry.path();
551        let dst_path = dst.join(entry.file_name());
552
553        if src_path.is_dir() {
554            fs::create_dir_all(&dst_path)?;
555            copy_dir_recursive(&src_path, &dst_path)?;
556        } else {
557            fs::copy(&src_path, &dst_path)?;
558        }
559    }
560    Ok(())
561}