viewpoint_core/browser/launcher/
mod.rs

1//! Browser launching functionality.
2
3use std::env;
4use std::io::{BufRead, BufReader};
5use std::path::PathBuf;
6use std::process::{Child, Command, Stdio};
7use std::time::Duration;
8
9use tokio::time::timeout;
10use tracing::{debug, info, instrument, trace, warn};
11use viewpoint_cdp::CdpConnection;
12
13use super::Browser;
14use crate::error::BrowserError;
15
16/// Default timeout for browser launch.
17const DEFAULT_LAUNCH_TIMEOUT: Duration = Duration::from_secs(30);
18
19/// Common Chromium paths on different platforms.
20const CHROMIUM_PATHS: &[&str] = &[
21    // Linux
22    "chromium",
23    "chromium-browser",
24    "/usr/bin/chromium",
25    "/usr/bin/chromium-browser",
26    "/snap/bin/chromium",
27    // macOS
28    "/Applications/Chromium.app/Contents/MacOS/Chromium",
29    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
30    // Windows
31    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
32    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
33];
34
35/// Builder for launching a browser.
36#[derive(Debug, Clone)]
37pub struct BrowserBuilder {
38    /// Path to Chromium executable.
39    executable_path: Option<PathBuf>,
40    /// Whether to run in headless mode.
41    headless: bool,
42    /// Additional command line arguments.
43    args: Vec<String>,
44    /// Launch timeout.
45    timeout: Duration,
46    /// User data directory for persistent browser profile.
47    user_data_dir: Option<PathBuf>,
48}
49
50impl Default for BrowserBuilder {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl BrowserBuilder {
57    /// Create a new browser builder with default settings.
58    pub fn new() -> Self {
59        Self {
60            executable_path: None,
61            headless: true,
62            args: Vec::new(),
63            timeout: DEFAULT_LAUNCH_TIMEOUT,
64            user_data_dir: None,
65        }
66    }
67
68    /// Set the path to the Chromium executable.
69    ///
70    /// If not set, the launcher will search common paths and
71    /// check the `CHROMIUM_PATH` environment variable.
72    #[must_use]
73    pub fn executable_path(mut self, path: impl Into<PathBuf>) -> Self {
74        self.executable_path = Some(path.into());
75        self
76    }
77
78    /// Set whether to run in headless mode.
79    ///
80    /// Default is `true`.
81    #[must_use]
82    pub fn headless(mut self, headless: bool) -> Self {
83        self.headless = headless;
84        self
85    }
86
87    /// Add additional command line arguments.
88    #[must_use]
89    pub fn args<I, S>(mut self, args: I) -> Self
90    where
91        I: IntoIterator<Item = S>,
92        S: Into<String>,
93    {
94        self.args.extend(args.into_iter().map(Into::into));
95        self
96    }
97
98    /// Set the launch timeout.
99    ///
100    /// Default is 30 seconds.
101    #[must_use]
102    pub fn timeout(mut self, timeout: Duration) -> Self {
103        self.timeout = timeout;
104        self
105    }
106
107    /// Set the user data directory for persistent browser profile.
108    ///
109    /// When set, browser state (cookies, localStorage, settings) persists
110    /// in the specified directory across browser restarts.
111    ///
112    /// # Example
113    ///
114    /// ```no_run
115    /// use viewpoint_core::Browser;
116    ///
117    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
118    /// let browser = Browser::launch()
119    ///     .user_data_dir("/path/to/profile")
120    ///     .launch()
121    ///     .await?;
122    /// # Ok(())
123    /// # }
124    /// ```
125    #[must_use]
126    pub fn user_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
127        self.user_data_dir = Some(path.into());
128        self
129    }
130
131    /// Launch the browser.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if:
136    /// - Chromium is not found
137    /// - The process fails to spawn
138    /// - The browser doesn't start within the timeout
139    #[instrument(level = "info", skip(self), fields(headless = self.headless, timeout_ms = self.timeout.as_millis()))]
140    pub async fn launch(self) -> Result<Browser, BrowserError> {
141        info!("Launching browser");
142
143        let executable = self.find_executable()?;
144        info!(executable = %executable.display(), "Found Chromium executable");
145
146        let mut cmd = Command::new(&executable);
147
148        // Add default arguments
149        cmd.arg("--remote-debugging-port=0");
150
151        if self.headless {
152            cmd.arg("--headless=new");
153            debug!("Running in headless mode");
154        } else {
155            debug!("Running in headed mode");
156        }
157
158        // Add common stability flags
159        let stability_args = [
160            "--disable-background-networking",
161            "--disable-background-timer-throttling",
162            "--disable-backgrounding-occluded-windows",
163            "--disable-breakpad",
164            "--disable-component-extensions-with-background-pages",
165            "--disable-component-update",
166            "--disable-default-apps",
167            "--disable-dev-shm-usage",
168            "--disable-extensions",
169            "--disable-features=TranslateUI",
170            "--disable-hang-monitor",
171            "--disable-ipc-flooding-protection",
172            "--disable-popup-blocking",
173            "--disable-prompt-on-repost",
174            "--disable-renderer-backgrounding",
175            "--disable-sync",
176            "--enable-features=NetworkService,NetworkServiceInProcess",
177            "--force-color-profile=srgb",
178            "--metrics-recording-only",
179            "--no-first-run",
180            "--password-store=basic",
181            "--use-mock-keychain",
182        ];
183        cmd.args(stability_args);
184        trace!(arg_count = stability_args.len(), "Added stability flags");
185
186        // Add user data directory if specified
187        if let Some(ref user_data_dir) = self.user_data_dir {
188            cmd.arg(format!("--user-data-dir={}", user_data_dir.display()));
189            debug!(user_data_dir = %user_data_dir.display(), "Using custom user data directory");
190        }
191
192        // Add user arguments
193        if !self.args.is_empty() {
194            cmd.args(&self.args);
195            debug!(user_args = ?self.args, "Added user arguments");
196        }
197
198        // Capture stderr for the WebSocket URL
199        cmd.stderr(Stdio::piped());
200        cmd.stdout(Stdio::null());
201
202        info!("Spawning Chromium process");
203        let mut child = cmd.spawn().map_err(|e| {
204            warn!(error = %e, "Failed to spawn Chromium process");
205            BrowserError::LaunchFailed(e.to_string())
206        })?;
207
208        let pid = child.id();
209        info!(pid = pid, "Chromium process spawned");
210
211        // Read the WebSocket URL from stderr
212        debug!("Waiting for DevTools WebSocket URL");
213        let ws_url = timeout(self.timeout, Self::read_ws_url(&mut child))
214            .await
215            .map_err(|_| {
216                warn!(
217                    timeout_ms = self.timeout.as_millis(),
218                    "Browser launch timed out"
219                );
220                BrowserError::LaunchTimeout(self.timeout)
221            })??;
222
223        info!(ws_url = %ws_url, "Got DevTools WebSocket URL");
224
225        // Connect to the browser
226        debug!("Connecting to browser via CDP");
227        let connection = CdpConnection::connect(&ws_url).await?;
228
229        info!(pid = pid, "Browser launched and connected successfully");
230        Ok(Browser::from_connection_and_process(connection, child))
231    }
232
233    /// Find the Chromium executable.
234    #[instrument(level = "debug", skip(self))]
235    fn find_executable(&self) -> Result<PathBuf, BrowserError> {
236        // Check if explicitly set
237        if let Some(ref path) = self.executable_path {
238            debug!(path = %path.display(), "Checking explicit executable path");
239            if path.exists() {
240                info!(path = %path.display(), "Using explicit executable path");
241                return Ok(path.clone());
242            }
243            warn!(path = %path.display(), "Explicit executable path does not exist");
244            return Err(BrowserError::ChromiumNotFound);
245        }
246
247        // Check environment variable
248        if let Ok(path_str) = env::var("CHROMIUM_PATH") {
249            let path = PathBuf::from(&path_str);
250            debug!(path = %path.display(), "Checking CHROMIUM_PATH environment variable");
251            if path.exists() {
252                info!(path = %path.display(), "Using CHROMIUM_PATH");
253                return Ok(path);
254            }
255            warn!(path = %path.display(), "CHROMIUM_PATH does not exist");
256        }
257
258        // Search common paths
259        debug!("Searching common Chromium paths");
260        for path_str in CHROMIUM_PATHS {
261            let path = PathBuf::from(path_str);
262            if path.exists() {
263                info!(path = %path.display(), "Found Chromium at common path");
264                return Ok(path);
265            }
266
267            // Also try which/where
268            if let Ok(output) = Command::new("which").arg(path_str).output() {
269                if output.status.success() {
270                    let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
271                    if !found.is_empty() {
272                        let found_path = PathBuf::from(&found);
273                        info!(path = %found_path.display(), "Found Chromium via 'which'");
274                        return Ok(found_path);
275                    }
276                }
277            }
278        }
279
280        warn!("Chromium not found in any expected location");
281        Err(BrowserError::ChromiumNotFound)
282    }
283
284    /// Read the WebSocket URL from the browser's stderr.
285    async fn read_ws_url(child: &mut Child) -> Result<String, BrowserError> {
286        let stderr = child
287            .stderr
288            .take()
289            .ok_or_else(|| BrowserError::LaunchFailed("failed to capture stderr".into()))?;
290
291        // Spawn blocking read in a separate task
292        let handle = tokio::task::spawn_blocking(move || {
293            let reader = BufReader::new(stderr);
294
295            for line in reader.lines() {
296                let Ok(line) = line else { continue };
297
298                trace!(line = %line, "Read line from Chromium stderr");
299
300                // Look for "DevTools listening on ws://..."
301                if let Some(pos) = line.find("DevTools listening on ") {
302                    let url = &line[pos + 22..];
303                    return Some(url.trim().to_string());
304                }
305            }
306
307            None
308        });
309
310        handle
311            .await
312            .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?
313            .ok_or(BrowserError::LaunchFailed(
314                "failed to find WebSocket URL in browser output".into(),
315            ))
316    }
317}