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