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 viewpoint_cdp::CdpConnection;
10use tokio::time::timeout;
11use tracing::{debug, info, instrument, trace, warn};
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}
47
48impl Default for BrowserBuilder {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl BrowserBuilder {
55    /// Create a new browser builder with default settings.
56    pub fn new() -> Self {
57        Self {
58            executable_path: None,
59            headless: true,
60            args: Vec::new(),
61            timeout: DEFAULT_LAUNCH_TIMEOUT,
62        }
63    }
64
65    /// Set the path to the Chromium executable.
66    ///
67    /// If not set, the launcher will search common paths and
68    /// check the `CHROMIUM_PATH` environment variable.
69    #[must_use]
70    pub fn executable_path(mut self, path: impl Into<PathBuf>) -> Self {
71        self.executable_path = Some(path.into());
72        self
73    }
74
75    /// Set whether to run in headless mode.
76    ///
77    /// Default is `true`.
78    #[must_use]
79    pub fn headless(mut self, headless: bool) -> Self {
80        self.headless = headless;
81        self
82    }
83
84    /// Add additional command line arguments.
85    #[must_use]
86    pub fn args<I, S>(mut self, args: I) -> Self
87    where
88        I: IntoIterator<Item = S>,
89        S: Into<String>,
90    {
91        self.args.extend(args.into_iter().map(Into::into));
92        self
93    }
94
95    /// Set the launch timeout.
96    ///
97    /// Default is 30 seconds.
98    #[must_use]
99    pub fn timeout(mut self, timeout: Duration) -> Self {
100        self.timeout = timeout;
101        self
102    }
103
104    /// Launch the browser.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if:
109    /// - Chromium is not found
110    /// - The process fails to spawn
111    /// - The browser doesn't start within the timeout
112    #[instrument(level = "info", skip(self), fields(headless = self.headless, timeout_ms = self.timeout.as_millis()))]
113    pub async fn launch(self) -> Result<Browser, BrowserError> {
114        info!("Launching browser");
115        
116        let executable = self.find_executable()?;
117        info!(executable = %executable.display(), "Found Chromium executable");
118
119        let mut cmd = Command::new(&executable);
120
121        // Add default arguments
122        cmd.arg("--remote-debugging-port=0");
123
124        if self.headless {
125            cmd.arg("--headless=new");
126            debug!("Running in headless mode");
127        } else {
128            debug!("Running in headed mode");
129        }
130
131        // Add common stability flags
132        let stability_args = [
133            "--disable-background-networking",
134            "--disable-background-timer-throttling",
135            "--disable-backgrounding-occluded-windows",
136            "--disable-breakpad",
137            "--disable-component-extensions-with-background-pages",
138            "--disable-component-update",
139            "--disable-default-apps",
140            "--disable-dev-shm-usage",
141            "--disable-extensions",
142            "--disable-features=TranslateUI",
143            "--disable-hang-monitor",
144            "--disable-ipc-flooding-protection",
145            "--disable-popup-blocking",
146            "--disable-prompt-on-repost",
147            "--disable-renderer-backgrounding",
148            "--disable-sync",
149            "--enable-features=NetworkService,NetworkServiceInProcess",
150            "--force-color-profile=srgb",
151            "--metrics-recording-only",
152            "--no-first-run",
153            "--password-store=basic",
154            "--use-mock-keychain",
155        ];
156        cmd.args(stability_args);
157        trace!(arg_count = stability_args.len(), "Added stability flags");
158
159        // Add user arguments
160        if !self.args.is_empty() {
161            cmd.args(&self.args);
162            debug!(user_args = ?self.args, "Added user arguments");
163        }
164
165        // Capture stderr for the WebSocket URL
166        cmd.stderr(Stdio::piped());
167        cmd.stdout(Stdio::null());
168
169        info!("Spawning Chromium process");
170        let mut child = cmd.spawn().map_err(|e| {
171            warn!(error = %e, "Failed to spawn Chromium process");
172            BrowserError::LaunchFailed(e.to_string())
173        })?;
174        
175        let pid = child.id();
176        info!(pid = pid, "Chromium process spawned");
177
178        // Read the WebSocket URL from stderr
179        debug!("Waiting for DevTools WebSocket URL");
180        let ws_url = timeout(self.timeout, Self::read_ws_url(&mut child))
181            .await
182            .map_err(|_| {
183                warn!(timeout_ms = self.timeout.as_millis(), "Browser launch timed out");
184                BrowserError::LaunchTimeout(self.timeout)
185            })??;
186
187        info!(ws_url = %ws_url, "Got DevTools WebSocket URL");
188
189        // Connect to the browser
190        debug!("Connecting to browser via CDP");
191        let connection = CdpConnection::connect(&ws_url).await?;
192        
193        info!(pid = pid, "Browser launched and connected successfully");
194        Ok(Browser::from_connection_and_process(connection, child))
195    }
196
197    /// Find the Chromium executable.
198    #[instrument(level = "debug", skip(self))]
199    fn find_executable(&self) -> Result<PathBuf, BrowserError> {
200        // Check if explicitly set
201        if let Some(ref path) = self.executable_path {
202            debug!(path = %path.display(), "Checking explicit executable path");
203            if path.exists() {
204                info!(path = %path.display(), "Using explicit executable path");
205                return Ok(path.clone());
206            }
207            warn!(path = %path.display(), "Explicit executable path does not exist");
208            return Err(BrowserError::ChromiumNotFound);
209        }
210
211        // Check environment variable
212        if let Ok(path_str) = env::var("CHROMIUM_PATH") {
213            let path = PathBuf::from(&path_str);
214            debug!(path = %path.display(), "Checking CHROMIUM_PATH environment variable");
215            if path.exists() {
216                info!(path = %path.display(), "Using CHROMIUM_PATH");
217                return Ok(path);
218            }
219            warn!(path = %path.display(), "CHROMIUM_PATH does not exist");
220        }
221
222        // Search common paths
223        debug!("Searching common Chromium paths");
224        for path_str in CHROMIUM_PATHS {
225            let path = PathBuf::from(path_str);
226            if path.exists() {
227                info!(path = %path.display(), "Found Chromium at common path");
228                return Ok(path);
229            }
230
231            // Also try which/where
232            if let Ok(output) = Command::new("which").arg(path_str).output() {
233                if output.status.success() {
234                    let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
235                    if !found.is_empty() {
236                        let found_path = PathBuf::from(&found);
237                        info!(path = %found_path.display(), "Found Chromium via 'which'");
238                        return Ok(found_path);
239                    }
240                }
241            }
242        }
243
244        warn!("Chromium not found in any expected location");
245        Err(BrowserError::ChromiumNotFound)
246    }
247
248    /// Read the WebSocket URL from the browser's stderr.
249    async fn read_ws_url(child: &mut Child) -> Result<String, BrowserError> {
250        let stderr = child
251            .stderr
252            .take()
253            .ok_or_else(|| BrowserError::LaunchFailed("failed to capture stderr".into()))?;
254
255        // Spawn blocking read in a separate task
256        let handle = tokio::task::spawn_blocking(move || {
257            let reader = BufReader::new(stderr);
258
259            for line in reader.lines() {
260                let Ok(line) = line else { continue };
261                
262                trace!(line = %line, "Read line from Chromium stderr");
263
264                // Look for "DevTools listening on ws://..."
265                if let Some(pos) = line.find("DevTools listening on ") {
266                    let url = &line[pos + 22..];
267                    return Some(url.trim().to_string());
268                }
269            }
270
271            None
272        });
273
274        handle
275            .await
276            .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?
277            .ok_or(BrowserError::LaunchFailed(
278                "failed to find WebSocket URL in browser output".into(),
279            ))
280    }
281}