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 viewpoint_cdp::CdpConnection;
10use tokio::time::timeout;
11use tracing::{debug, info, instrument, trace, warn};
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}
47
48impl Default for BrowserBuilder {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl BrowserBuilder {
55 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 #[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 #[must_use]
79 pub fn headless(mut self, headless: bool) -> Self {
80 self.headless = headless;
81 self
82 }
83
84 #[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 #[must_use]
99 pub fn timeout(mut self, timeout: Duration) -> Self {
100 self.timeout = timeout;
101 self
102 }
103
104 #[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 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 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 if !self.args.is_empty() {
161 cmd.args(&self.args);
162 debug!(user_args = ?self.args, "Added user arguments");
163 }
164
165 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 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 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 #[instrument(level = "debug", skip(self))]
199 fn find_executable(&self) -> Result<PathBuf, BrowserError> {
200 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 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 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 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 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 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 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}