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}