viewpoint_core/browser/launcher/
mod.rs1mod chromium_args;
4mod fs_utils;
5mod user_data;
6
7use std::env;
8use std::io::{BufRead, BufReader};
9use std::path::PathBuf;
10use std::process::{Child, Command, Stdio};
11use std::time::Duration;
12
13use tempfile::TempDir;
14use tokio::time::timeout;
15use tracing::{debug, info, instrument, trace, warn};
16use viewpoint_cdp::CdpConnection;
17
18use super::Browser;
19use crate::error::BrowserError;
20
21pub use user_data::UserDataDir;
22
23use chromium_args::{CHROMIUM_PATHS, STABILITY_ARGS};
24use fs_utils::copy_dir_recursive;
25
26const DEFAULT_LAUNCH_TIMEOUT: Duration = Duration::from_secs(30);
28
29#[derive(Debug, Clone)]
31pub struct BrowserBuilder {
32 executable_path: Option<PathBuf>,
34 headless: bool,
36 args: Vec<String>,
38 timeout: Duration,
40 user_data_dir: UserDataDir,
42}
43
44impl Default for BrowserBuilder {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl BrowserBuilder {
51 pub fn new() -> Self {
57 Self {
58 executable_path: None,
59 headless: true,
60 args: Vec::new(),
61 timeout: DEFAULT_LAUNCH_TIMEOUT,
62 user_data_dir: UserDataDir::Temp,
63 }
64 }
65
66 #[must_use]
71 pub fn executable_path(mut self, path: impl Into<PathBuf>) -> Self {
72 self.executable_path = Some(path.into());
73 self
74 }
75
76 #[must_use]
80 pub fn headless(mut self, headless: bool) -> Self {
81 self.headless = headless;
82 self
83 }
84
85 #[must_use]
87 pub fn args<I, S>(mut self, args: I) -> Self
88 where
89 I: IntoIterator<Item = S>,
90 S: Into<String>,
91 {
92 self.args.extend(args.into_iter().map(Into::into));
93 self
94 }
95
96 #[must_use]
100 pub fn timeout(mut self, timeout: Duration) -> Self {
101 self.timeout = timeout;
102 self
103 }
104
105 #[must_use]
128 pub fn user_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
129 self.user_data_dir = UserDataDir::Persist(path.into());
130 self
131 }
132
133 #[must_use]
156 pub fn user_data_dir_system(mut self) -> Self {
157 self.user_data_dir = UserDataDir::System;
158 self
159 }
160
161 #[must_use]
201 pub fn user_data_dir_template_from(mut self, template_path: impl Into<PathBuf>) -> Self {
202 self.user_data_dir = UserDataDir::TempFromTemplate(template_path.into());
203 self
204 }
205
206 #[instrument(level = "info", skip(self), fields(headless = self.headless, timeout_ms = self.timeout.as_millis()))]
216 pub async fn launch(self) -> Result<Browser, BrowserError> {
217 info!("Launching browser");
218
219 let executable = self.find_executable()?;
220 info!(executable = %executable.display(), "Found Chromium executable");
221
222 let (user_data_path, temp_dir) = self.prepare_user_data_dir()?;
224
225 let mut cmd = Command::new(&executable);
226
227 cmd.arg("--remote-debugging-port=0");
229
230 if self.headless {
231 cmd.arg("--headless=new");
232 debug!("Running in headless mode");
233 } else {
234 debug!("Running in headed mode");
235 }
236
237 cmd.args(STABILITY_ARGS);
239 trace!(arg_count = STABILITY_ARGS.len(), "Added stability flags");
240
241 if let Some(ref user_data_dir) = user_data_path {
243 cmd.arg(format!("--user-data-dir={}", user_data_dir.display()));
244 debug!(user_data_dir = %user_data_dir.display(), "Using user data directory");
245 } else {
246 debug!("Using system default user data directory");
247 }
248
249 if !self.args.is_empty() {
251 cmd.args(&self.args);
252 debug!(user_args = ?self.args, "Added user arguments");
253 }
254
255 cmd.stderr(Stdio::piped());
257 cmd.stdout(Stdio::null());
258
259 info!("Spawning Chromium process");
260 let mut child = cmd.spawn().map_err(|e| {
261 warn!(error = %e, "Failed to spawn Chromium process");
262 BrowserError::LaunchFailed(e.to_string())
263 })?;
264
265 let pid = child.id();
266 info!(pid = pid, "Chromium process spawned");
267
268 debug!("Waiting for DevTools WebSocket URL");
270 let ws_url = timeout(self.timeout, Self::read_ws_url(&mut child))
271 .await
272 .map_err(|_| {
273 warn!(
274 timeout_ms = self.timeout.as_millis(),
275 "Browser launch timed out"
276 );
277 BrowserError::LaunchTimeout(self.timeout)
278 })??;
279
280 info!(ws_url = %ws_url, "Got DevTools WebSocket URL");
281
282 debug!("Connecting to browser via CDP");
284 let connection = CdpConnection::connect(&ws_url).await?;
285
286 info!(pid = pid, "Browser launched and connected successfully");
287 Ok(Browser::from_launch(connection, child, temp_dir))
288 }
289
290 fn prepare_user_data_dir(&self) -> Result<(Option<PathBuf>, Option<TempDir>), BrowserError> {
296 match &self.user_data_dir {
297 UserDataDir::Temp => {
298 let temp_dir = TempDir::with_prefix("viewpoint-browser-").map_err(|e| {
300 BrowserError::LaunchFailed(format!(
301 "Failed to create temporary user data directory: {e}"
302 ))
303 })?;
304 let path = temp_dir.path().to_path_buf();
305 debug!(path = %path.display(), "Created temporary user data directory");
306 Ok((Some(path), Some(temp_dir)))
307 }
308 UserDataDir::TempFromTemplate(template_path) => {
309 if !template_path.exists() {
311 return Err(BrowserError::LaunchFailed(format!(
312 "Template profile directory does not exist: {}",
313 template_path.display()
314 )));
315 }
316 if !template_path.is_dir() {
317 return Err(BrowserError::LaunchFailed(format!(
318 "Template profile path is not a directory: {}",
319 template_path.display()
320 )));
321 }
322
323 let temp_dir = TempDir::with_prefix("viewpoint-browser-").map_err(|e| {
325 BrowserError::LaunchFailed(format!(
326 "Failed to create temporary user data directory: {e}"
327 ))
328 })?;
329 let dest_path = temp_dir.path().to_path_buf();
330
331 debug!(
333 template = %template_path.display(),
334 dest = %dest_path.display(),
335 "Copying template profile to temporary directory"
336 );
337 copy_dir_recursive(template_path, &dest_path).map_err(|e| {
338 BrowserError::LaunchFailed(format!("Failed to copy template profile: {e}"))
339 })?;
340
341 info!(
342 template = %template_path.display(),
343 dest = %dest_path.display(),
344 "Template profile copied to temporary directory"
345 );
346 Ok((Some(dest_path), Some(temp_dir)))
347 }
348 UserDataDir::Persist(path) => {
349 debug!(path = %path.display(), "Using persistent user data directory");
351 Ok((Some(path.clone()), None))
352 }
353 UserDataDir::System => {
354 debug!("Using system default user data directory");
356 Ok((None, None))
357 }
358 }
359 }
360
361 #[instrument(level = "debug", skip(self))]
363 fn find_executable(&self) -> Result<PathBuf, BrowserError> {
364 if let Some(ref path) = self.executable_path {
366 debug!(path = %path.display(), "Checking explicit executable path");
367 if path.exists() {
368 info!(path = %path.display(), "Using explicit executable path");
369 return Ok(path.clone());
370 }
371 warn!(path = %path.display(), "Explicit executable path does not exist");
372 return Err(BrowserError::ChromiumNotFound);
373 }
374
375 if let Ok(path_str) = env::var("CHROMIUM_PATH") {
377 let path = PathBuf::from(&path_str);
378 debug!(path = %path.display(), "Checking CHROMIUM_PATH environment variable");
379 if path.exists() {
380 info!(path = %path.display(), "Using CHROMIUM_PATH");
381 return Ok(path);
382 }
383 warn!(path = %path.display(), "CHROMIUM_PATH does not exist");
384 }
385
386 debug!("Searching common Chromium paths");
388 for path_str in CHROMIUM_PATHS {
389 let path = PathBuf::from(path_str);
390 if path.exists() {
391 info!(path = %path.display(), "Found Chromium at common path");
392 return Ok(path);
393 }
394
395 if let Ok(output) = Command::new("which").arg(path_str).output() {
397 if output.status.success() {
398 let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
399 if !found.is_empty() {
400 let found_path = PathBuf::from(&found);
401 info!(path = %found_path.display(), "Found Chromium via 'which'");
402 return Ok(found_path);
403 }
404 }
405 }
406 }
407
408 warn!("Chromium not found in any expected location");
409 Err(BrowserError::ChromiumNotFound)
410 }
411
412 async fn read_ws_url(child: &mut Child) -> Result<String, BrowserError> {
414 let stderr = child
415 .stderr
416 .take()
417 .ok_or_else(|| BrowserError::LaunchFailed("failed to capture stderr".into()))?;
418
419 let handle = tokio::task::spawn_blocking(move || {
421 let reader = BufReader::new(stderr);
422
423 for line in reader.lines() {
424 let Ok(line) = line else { continue };
425
426 trace!(line = %line, "Read line from Chromium stderr");
427
428 if let Some(pos) = line.find("DevTools listening on ") {
430 let url = &line[pos + 22..];
431 return Some(url.trim().to_string());
432 }
433 }
434
435 None
436 });
437
438 handle
439 .await
440 .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?
441 .ok_or(BrowserError::LaunchFailed(
442 "failed to find WebSocket URL in browser output".into(),
443 ))
444 }
445}