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}
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!(
184 timeout_ms = self.timeout.as_millis(),
185 "Browser launch timed out"
186 );
187 BrowserError::LaunchTimeout(self.timeout)
188 })??;
189
190 info!(ws_url = %ws_url, "Got DevTools WebSocket URL");
191
192 debug!("Connecting to browser via CDP");
194 let connection = CdpConnection::connect(&ws_url).await?;
195
196 info!(pid = pid, "Browser launched and connected successfully");
197 Ok(Browser::from_connection_and_process(connection, child))
198 }
199
200 #[instrument(level = "debug", skip(self))]
202 fn find_executable(&self) -> Result<PathBuf, BrowserError> {
203 if let Some(ref path) = self.executable_path {
205 debug!(path = %path.display(), "Checking explicit executable path");
206 if path.exists() {
207 info!(path = %path.display(), "Using explicit executable path");
208 return Ok(path.clone());
209 }
210 warn!(path = %path.display(), "Explicit executable path does not exist");
211 return Err(BrowserError::ChromiumNotFound);
212 }
213
214 if let Ok(path_str) = env::var("CHROMIUM_PATH") {
216 let path = PathBuf::from(&path_str);
217 debug!(path = %path.display(), "Checking CHROMIUM_PATH environment variable");
218 if path.exists() {
219 info!(path = %path.display(), "Using CHROMIUM_PATH");
220 return Ok(path);
221 }
222 warn!(path = %path.display(), "CHROMIUM_PATH does not exist");
223 }
224
225 debug!("Searching common Chromium paths");
227 for path_str in CHROMIUM_PATHS {
228 let path = PathBuf::from(path_str);
229 if path.exists() {
230 info!(path = %path.display(), "Found Chromium at common path");
231 return Ok(path);
232 }
233
234 if let Ok(output) = Command::new("which").arg(path_str).output() {
236 if output.status.success() {
237 let found = String::from_utf8_lossy(&output.stdout).trim().to_string();
238 if !found.is_empty() {
239 let found_path = PathBuf::from(&found);
240 info!(path = %found_path.display(), "Found Chromium via 'which'");
241 return Ok(found_path);
242 }
243 }
244 }
245 }
246
247 warn!("Chromium not found in any expected location");
248 Err(BrowserError::ChromiumNotFound)
249 }
250
251 async fn read_ws_url(child: &mut Child) -> Result<String, BrowserError> {
253 let stderr = child
254 .stderr
255 .take()
256 .ok_or_else(|| BrowserError::LaunchFailed("failed to capture stderr".into()))?;
257
258 let handle = tokio::task::spawn_blocking(move || {
260 let reader = BufReader::new(stderr);
261
262 for line in reader.lines() {
263 let Ok(line) = line else { continue };
264
265 trace!(line = %line, "Read line from Chromium stderr");
266
267 if let Some(pos) = line.find("DevTools listening on ") {
269 let url = &line[pos + 22..];
270 return Some(url.trim().to_string());
271 }
272 }
273
274 None
275 });
276
277 handle
278 .await
279 .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?
280 .ok_or(BrowserError::LaunchFailed(
281 "failed to find WebSocket URL in browser output".into(),
282 ))
283 }
284}