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 debug!("Enabling target discovery");
289 connection
290 .send_command::<_, serde_json::Value>(
291 "Target.setDiscoverTargets",
292 Some(
293 viewpoint_cdp::protocol::target_domain::SetDiscoverTargetsParams {
294 discover: true,
295 },
296 ),
297 None,
298 )
299 .await
300 .map_err(|e| {
301 BrowserError::LaunchFailed(format!("Failed to enable target discovery: {e}"))
302 })?;
303
304 info!(pid = pid, "Browser launched and connected successfully");
305 Ok(Browser::from_launch(connection, child, temp_dir))
306 }
307
308 fn prepare_user_data_dir(&self) -> Result<(Option<PathBuf>, Option<TempDir>), BrowserError> {
314 match &self.user_data_dir {
315 UserDataDir::Temp => {
316 let temp_dir = TempDir::with_prefix("viewpoint-browser-").map_err(|e| {
318 BrowserError::LaunchFailed(format!(
319 "Failed to create temporary user data directory: {e}"
320 ))
321 })?;
322 let path = temp_dir.path().to_path_buf();
323 debug!(path = %path.display(), "Created temporary user data directory");
324 Ok((Some(path), Some(temp_dir)))
325 }
326 UserDataDir::TempFromTemplate(template_path) => {
327 if !template_path.exists() {
329 return Err(BrowserError::LaunchFailed(format!(
330 "Template profile directory does not exist: {}",
331 template_path.display()
332 )));
333 }
334 if !template_path.is_dir() {
335 return Err(BrowserError::LaunchFailed(format!(
336 "Template profile path is not a directory: {}",
337 template_path.display()
338 )));
339 }
340
341 let temp_dir = TempDir::with_prefix("viewpoint-browser-").map_err(|e| {
343 BrowserError::LaunchFailed(format!(
344 "Failed to create temporary user data directory: {e}"
345 ))
346 })?;
347 let dest_path = temp_dir.path().to_path_buf();
348
349 debug!(
351 template = %template_path.display(),
352 dest = %dest_path.display(),
353 "Copying template profile to temporary directory"
354 );
355 copy_dir_recursive(template_path, &dest_path).map_err(|e| {
356 BrowserError::LaunchFailed(format!("Failed to copy template profile: {e}"))
357 })?;
358
359 info!(
360 template = %template_path.display(),
361 dest = %dest_path.display(),
362 "Template profile copied to temporary directory"
363 );
364 Ok((Some(dest_path), Some(temp_dir)))
365 }
366 UserDataDir::Persist(path) => {
367 debug!(path = %path.display(), "Using persistent user data directory");
369 Ok((Some(path.clone()), None))
370 }
371 UserDataDir::System => {
372 debug!("Using system default user data directory");
374 Ok((None, None))
375 }
376 }
377 }
378
379 #[instrument(level = "debug", skip(self))]
381 fn find_executable(&self) -> Result<PathBuf, BrowserError> {
382 if let Some(ref path) = self.executable_path {
384 debug!(path = %path.display(), "Checking explicit executable path");
385 if path.exists() {
386 info!(path = %path.display(), "Using explicit executable path");
387 return Ok(path.clone());
388 }
389 warn!(path = %path.display(), "Explicit executable path does not exist");
390 return Err(BrowserError::ChromiumNotFound);
391 }
392
393 if let Ok(path_str) = env::var("CHROMIUM_PATH") {
395 let path = PathBuf::from(&path_str);
396 debug!(path = %path.display(), "Checking CHROMIUM_PATH environment variable");
397 if path.exists() {
398 info!(path = %path.display(), "Using CHROMIUM_PATH");
399 return Ok(path);
400 }
401 warn!(path = %path.display(), "CHROMIUM_PATH does not exist");
402 }
403
404 debug!("Searching common Chromium paths");
406 for path_str in CHROMIUM_PATHS {
407 let path = PathBuf::from(path_str);
408 if path.exists() {
409 info!(path = %path.display(), "Found Chromium at common path");
410 return Ok(path);
411 }
412
413 if let Ok(output) = Command::new("which").arg(path_str).output() {
415 if output.status.success() {
416 let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
417 if !found.is_empty() {
418 let found_path = PathBuf::from(&found);
419 info!(path = %found_path.display(), "Found Chromium via 'which'");
420 return Ok(found_path);
421 }
422 }
423 }
424 }
425
426 warn!("Chromium not found in any expected location");
427 Err(BrowserError::ChromiumNotFound)
428 }
429
430 async fn read_ws_url(child: &mut Child) -> Result<String, BrowserError> {
432 let stderr = child
433 .stderr
434 .take()
435 .ok_or_else(|| BrowserError::LaunchFailed("failed to capture stderr".into()))?;
436
437 let handle = tokio::task::spawn_blocking(move || {
439 let reader = BufReader::new(stderr);
440
441 for line in reader.lines() {
442 let Ok(line) = line else { continue };
443
444 trace!(line = %line, "Read line from Chromium stderr");
445
446 if let Some(pos) = line.find("DevTools listening on ") {
448 let url = &line[pos + 22..];
449 return Some(url.trim().to_string());
450 }
451 }
452
453 None
454 });
455
456 handle
457 .await
458 .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?
459 .ok_or(BrowserError::LaunchFailed(
460 "failed to find WebSocket URL in browser output".into(),
461 ))
462 }
463}