playwright_cdp/
browser_process.rs1use crate::error::{Error, Result};
6use crate::options::LaunchOptions;
7use std::path::PathBuf;
8use std::time::Duration;
9use tokio::io::AsyncReadExt;
10use tokio::process::{Child, Command};
11
12pub struct BrowserProcess {
15 child: Option<Child>,
16 _user_data_dir: tempfile::TempDir,
17 ws_url: String,
18 stderr_buf: std::sync::Arc<parking_lot::Mutex<String>>,
19}
20
21impl BrowserProcess {
22 pub async fn launch(opts: &LaunchOptions) -> Result<Self> {
25 let executable = discover_executable(opts)
26 .map_err(|e| e.context("could not find a Chrome/Chromium executable"))?;
27
28 let user_data_dir = tempfile::Builder::new()
29 .prefix("playwright-cdp-")
30 .tempdir()
31 .map_err(|e| Error::Io(e).context("failed to create user-data-dir"))?;
32
33 let mut args = base_args(user_data_dir.path(), opts);
34 if opts.headless.unwrap_or(true) {
35 args.push("--headless=new".to_string());
36 }
37 if opts.devtools.unwrap_or(false) {
38 args.push("--auto-open-devtools-for-tabs".to_string());
39 }
40 if let Some(proxy) = &opts.proxy {
41 args.push(format!("--proxy-server={}", proxy.server));
42 }
43 if let Some(extra) = &opts.args {
45 args.extend(extra.iter().cloned());
46 }
47
48 tracing::debug!(exe = %executable.display(), args = ?args, "launching browser");
49
50 let stderr_buf = std::sync::Arc::new(parking_lot::Mutex::new(String::new()));
51 let stderr_for_task = stderr_buf.clone();
52
53 let mut cmd = Command::new(&executable);
54 cmd.args(&args)
55 .stdin(std::process::Stdio::null())
56 .stdout(std::process::Stdio::null())
57 .stderr(std::process::Stdio::piped())
58 .kill_on_drop(true);
59 #[cfg(target_os = "linux")]
61 if unsafe { libc_geteuid() } == 0 {
62 if !args.iter().any(|a| a == "--no-sandbox") {
64 cmd.arg("--no-sandbox");
65 }
66 }
67 if let Some(env) = &opts.env {
69 for (k, v) in env {
70 cmd.env(k, v);
71 }
72 }
73
74 let mut child = cmd
75 .spawn()
76 .map_err(|e| Error::LaunchFailed(format!("failed to spawn {executable:?}: {e}")))?;
77
78 if let Some(stderr) = child.stderr.take() {
80 tokio::spawn(async move {
81 drain_stderr(stderr, stderr_for_task).await;
82 });
83 }
84
85 let timeout_ms = opts.timeout_ms_or_default() as u64;
86 let port = wait_for_devtools_port(user_data_dir.path(), timeout_ms).await.map_err(|e| {
87 let stderr_snapshot = stderr_buf.lock().clone();
88 Error::LaunchFailed(format!(
89 "{e}\n--- browser stderr (tail) ---\n{}",
90 stderr_snapshot.chars().rev().take(4096).collect::<String>().chars().rev().collect::<String>()
91 ))
92 })?;
93
94 let ws_url = fetch_browser_ws_url(port, timeout_ms).await?;
95
96 Ok(Self {
97 child: Some(child),
98 _user_data_dir: user_data_dir,
99 ws_url,
100 stderr_buf,
101 })
102 }
103
104 pub fn ws_url(&self) -> &str {
106 &self.ws_url
107 }
108
109 pub async fn kill(&mut self) -> Result<()> {
111 if let Some(child) = self.child.as_mut() {
112 let _ = child.start_kill();
113 let _ = child.wait().await;
114 }
115 Ok(())
116 }
117
118 pub fn stderr_tail(&self) -> String {
120 self.stderr_buf.lock().clone()
121 }
122}
123
124impl Drop for BrowserProcess {
125 fn drop(&mut self) {
126 if let Some(child) = self.child.as_mut() {
128 let _ = child.start_kill();
129 }
130 }
131}
132
133async fn drain_stderr<R: tokio::io::AsyncRead + Unpin>(
134 mut stderr: R,
135 buf: std::sync::Arc<parking_lot::Mutex<String>>,
136) {
137 let mut tmp = [0u8; 1024];
138 loop {
139 match stderr.read(&mut tmp).await {
140 Ok(0) | Err(_) => break,
141 Ok(n) => {
142 let s = String::from_utf8_lossy(&tmp[..n]).to_string();
143 let mut guard = buf.lock();
144 guard.push_str(&s);
145 const MAX: usize = 16 * 1024;
147 if guard.len() > MAX {
148 let drop = guard.len() - MAX;
149 guard.drain(..drop);
150 }
151 }
152 }
153 }
154}
155
156fn base_args(user_data_dir: &std::path::Path, _opts: &LaunchOptions) -> Vec<String> {
157 vec![
158 format!(
159 "--user-data-dir={}",
160 user_data_dir.display()
161 ),
162 "--remote-debugging-port=0".to_string(),
163 "--no-first-run".to_string(),
164 "--no-default-browser-check".to_string(),
165 "--disable-background-networking".to_string(),
166 "--password-store=basic".to_string(),
167 "--use-mock-keychain".to_string(),
168 "--disable-features=Translate,IsolateOrigins,site-per-process".to_string(),
169 "about:blank".to_string(),
170 ]
171}
172
173async fn wait_for_devtools_port(user_data_dir: &std::path::Path, timeout_ms: u64) -> Result<u16> {
178 let path = user_data_dir.join("DevToolsActivePort");
179 let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.max(1000));
180 loop {
181 if let Ok(contents) = tokio::fs::read_to_string(&path).await {
182 if let Some(first_line) = contents.lines().next() {
183 if let Ok(port) = first_line.trim().parse::<u16>() {
184 if port != 0 {
185 return Ok(port);
186 }
187 }
188 }
189 }
190 if tokio::time::Instant::now() >= deadline {
191 return Err(Error::LaunchFailed(format!(
192 "timed out after {timeout_ms}ms waiting for DevToolsActivePort at {}",
193 path.display()
194 )));
195 }
196 tokio::time::sleep(Duration::from_millis(100)).await;
197 }
198}
199
200async fn fetch_browser_ws_url(port: u16, timeout_ms: u64) -> Result<String> {
202 let url = format!("http://127.0.0.1:{port}/json/version");
203 let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.max(1000));
204 loop {
205 match reqwest::get(&url).await {
206 Ok(resp) if resp.status().is_success() => {
207 let v: serde_json::Value = resp
208 .json()
209 .await
210 .map_err(|e| Error::Http(format!("/json/version parse: {e}")))?;
211 let ws = v
212 .get("webSocketDebuggerUrl")
213 .and_then(|x| x.as_str())
214 .ok_or_else(|| {
215 Error::ProtocolError("/json/version missing webSocketDebuggerUrl".into())
216 })?
217 .to_string();
218 return Ok(ws);
219 }
220 _ => {}
221 }
222 if tokio::time::Instant::now() >= deadline {
223 return Err(Error::ConnectionFailed(format!(
224 "timed out waiting for {url}"
225 )));
226 }
227 tokio::time::sleep(Duration::from_millis(100)).await;
228 }
229}
230
231pub async fn resolve_ws_endpoint(endpoint: &str) -> Result<String> {
236 if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
237 return Ok(endpoint.to_string());
238 }
239 let mut base = endpoint.trim_end_matches('/').to_string();
240 base.push_str("/json/version");
241 let resp = reqwest::get(&base)
242 .await
243 .map_err(|e| Error::Http(format!("GET {base}: {e}")))?;
244 let v: serde_json::Value = resp
245 .json()
246 .await
247 .map_err(|e| Error::Http(format!("parse {base}: {e}")))?;
248 v.get("webSocketDebuggerUrl")
249 .and_then(|x| x.as_str())
250 .map(|s| s.to_string())
251 .ok_or_else(|| Error::ProtocolError("response missing webSocketDebuggerUrl".into()))
252}
253
254pub(crate) fn discover_executable(opts: &LaunchOptions) -> Result<PathBuf> {
259 if let Some(p) = &opts.executable_path {
260 let path = PathBuf::from(p);
261 if path.exists() {
262 return Ok(path);
263 }
264 return Err(Error::LaunchFailed(format!(
265 "executable_path does not exist: {p}"
266 )));
267 }
268
269 for var in ["PWC_CHROME_PATH", "CHROME_PATH"] {
270 if let Ok(p) = std::env::var(var) {
271 let path = PathBuf::from(&p);
272 if path.exists() {
273 return Ok(path);
274 }
275 }
276 }
277
278 for candidate in executable_candidates(opts.channel.as_deref()) {
279 if candidate.exists() {
280 return Ok(candidate);
281 }
282 }
283
284 Err(Error::LaunchFailed(
285 "no Chrome/Chromium executable found; set executable_path, the CHROME_PATH env var, or channel".into(),
286 ))
287}
288
289fn executable_candidates(channel: Option<&str>) -> Vec<PathBuf> {
291 let channels: Vec<&str> = match channel {
292 Some(c) => vec![c],
293 None => vec!["chrome", "chromium", "msedge"],
294 };
295
296 let mut out = Vec::new();
297 for ch in channels {
298 out.extend(known_locations(ch));
299 out.extend(path_lookup(ch));
300 }
301 out
302}
303
304fn path_lookup(channel: &str) -> Vec<PathBuf> {
306 let names: &[&str] = match channel {
307 "chrome" => &["google-chrome", "google-chrome-stable", "chrome"],
308 "chromium" => &["chromium", "chromium-browser"],
309 "msedge" => &["microsoft-edge", "microsoft-edge-stable"],
310 _ => &[],
311 };
312 let path = match std::env::var_os("PATH") {
313 Some(p) => std::env::split_paths(&p).collect::<Vec<_>>(),
314 None => return Vec::new(),
315 };
316 let mut out = Vec::new();
317 for name in names {
318 for dir in &path {
319 let candidate = dir.join(name);
320 if candidate.is_file() {
321 out.push(candidate);
322 }
323 }
324 }
325 out
326}
327
328fn known_locations(channel: &str) -> Vec<PathBuf> {
330 let mut out = Vec::new();
331
332 match channel {
333 "chrome" => {
334 #[cfg(target_os = "linux")]
335 out.extend([
336 "/opt/google/chrome/chrome",
337 "/usr/bin/google-chrome",
338 "/usr/bin/google-chrome-stable",
339 "/usr/bin/google-chrome-beta",
340 ]);
341 #[cfg(target_os = "macos")]
342 out.extend([
343 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
344 "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
345 ]);
346 #[cfg(target_os = "windows")]
347 {
348 for dir in windows_program_dirs() {
349 out.push(PathBuf::from(dir).join("Google\\Chrome\\Application\\chrome.exe"));
350 }
351 }
352 }
353 "chromium" => {
354 #[cfg(target_os = "linux")]
355 out.extend(["/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"]);
356 #[cfg(target_os = "macos")]
357 out.push("/Applications/Chromium.app/Contents/MacOS/Chromium");
358 #[cfg(target_os = "windows")]
359 {
360 for dir in windows_program_dirs() {
361 out.push(PathBuf::from(dir).join("Chromium\\Application\\chrome.exe"));
362 }
363 }
364 }
365 "msedge" => {
366 #[cfg(target_os = "linux")]
367 out.extend(["/usr/bin/microsoft-edge", "/opt/microsoft/edge/microsoft-edge"]);
368 #[cfg(target_os = "macos")]
369 out.push("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge");
370 #[cfg(target_os = "windows")]
371 {
372 for dir in windows_program_dirs() {
373 out.push(PathBuf::from(dir).join("Microsoft\\Edge\\Application\\msedge.exe"));
374 }
375 }
376 }
377 _ => {}
378 }
379
380 out.into_iter().map(PathBuf::from).collect()
381}
382
383#[cfg(target_os = "windows")]
385fn windows_program_dirs() -> Vec<String> {
386 let mut dirs = Vec::new();
387 if let Ok(v) = std::env::var("ProgramFiles") {
388 dirs.push(v);
389 } else {
390 dirs.push("C:\\Program Files".into());
391 }
392 if let Ok(v) = std::env::var("ProgramFiles(x86)") {
393 dirs.push(v);
394 }
395 if let Ok(v) = std::env::var("LOCALAPPDATA") {
396 dirs.push(v);
397 }
398 dirs
399}
400
401#[cfg(target_os = "linux")]
403unsafe fn libc_geteuid() -> u32 {
404 unsafe extern "C" {
405 fn geteuid() -> u32;
406 }
407 unsafe { geteuid() }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn known_locations_returns_something_on_supported_platforms() {
416 let c = known_locations("chrome");
418 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
419 assert!(!c.is_empty(), "expected chrome known locations");
420 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
421 let _ = c;
422 }
423
424 #[test]
425 fn discover_respects_explicit_executable_path() {
426 let opts = LaunchOptions::default().executable_path("/nonexistent/path/to/chrome-xyz");
427 let r = discover_executable(&opts);
428 assert!(r.is_err());
429 }
430
431 #[test]
432 fn devtools_port_parses_well_formed_contents() {
433 let contents = "9333\n/devtools/browser/abc-123\n";
435 let port: u16 = contents
436 .lines()
437 .next()
438 .unwrap()
439 .trim()
440 .parse()
441 .unwrap();
442 assert_eq!(port, 9333);
443 }
444}