viewpoint_core/browser/launcher/
mod.rs1use 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
16const DEFAULT_LAUNCH_TIMEOUT: Duration = Duration::from_secs(30);
18
19const CHROMIUM_PATHS: &[&str] = &[
21 "chromium",
23 "chromium-browser",
24 "/usr/bin/chromium",
25 "/usr/bin/chromium-browser",
26 "/snap/bin/chromium",
27 "/Applications/Chromium.app/Contents/MacOS/Chromium",
29 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
30 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
32 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
33];
34
35#[derive(Debug, Clone)]
37pub struct BrowserBuilder {
38 executable_path: Option<PathBuf>,
40 headless: bool,
42 args: Vec<String>,
44 timeout: Duration,
46 user_data_dir: Option<PathBuf>,
48}
49
50impl Default for BrowserBuilder {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl BrowserBuilder {
57 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 #[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 #[must_use]
82 pub fn headless(mut self, headless: bool) -> Self {
83 self.headless = headless;
84 self
85 }
86
87 #[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 #[must_use]
102 pub fn timeout(mut self, timeout: Duration) -> Self {
103 self.timeout = timeout;
104 self
105 }
106
107 #[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 #[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 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 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 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 if !self.args.is_empty() {
194 cmd.args(&self.args);
195 debug!(user_args = ?self.args, "Added user arguments");
196 }
197
198 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 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 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 #[instrument(level = "debug", skip(self))]
235 fn find_executable(&self) -> Result<PathBuf, BrowserError> {
236 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 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 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 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 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 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 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}