1use std::{
2 borrow::BorrowMut,
3 ffi::OsStr,
4 io::{prelude::*, BufRead, BufReader},
5 net,
6 process::{Child, Command, Stdio},
7 time::Duration,
8};
9
10#[cfg(test)]
11use std::cell::RefCell;
12
13use derive_builder::Builder;
14
15use anyhow::{anyhow, Result};
16use log::*;
17use rand::rng;
18use rand::seq::SliceRandom;
19use regex::Regex;
20use thiserror::Error;
21use url::Url;
22#[cfg(windows)]
23use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey};
24
25#[cfg(not(feature = "fetch"))]
26use crate::browser::default_executable;
27use crate::util;
28
29#[cfg(feature = "fetch")]
30use super::fetcher::{Fetcher, FetcherOptions};
31use std::collections::HashMap;
32
33#[cfg(test)]
34struct ForTesting;
35#[cfg(test)]
36impl ForTesting {
37 thread_local! {
38 static USER_DATA_DIR: RefCell<Option<String>> = const { RefCell::new(None) };
39 }
40}
41
42pub struct Process {
43 child_process: TemporaryProcess,
44 pub debug_ws_url: Url,
45}
46
47#[derive(Debug, Error)]
48enum ChromeLaunchError {
49 #[error("Chrome launched, but didn't give us a WebSocket URL before we timed out")]
50 PortOpenTimeout,
51 #[error("There are no available ports between 8000 and 9000 for debugging")]
52 NoAvailablePorts,
53 #[error("The chosen debugging port is already in use")]
54 DebugPortInUse,
55 #[error("You need to set the sandbox(false) option when running as root")]
56 RunningAsRootWithoutNoSandbox,
57}
58
59#[cfg(windows)]
60pub(crate) fn get_chrome_path_from_registry() -> Option<std::path::PathBuf> {
61 RegKey::predef(HKEY_LOCAL_MACHINE)
62 .open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe")
63 .and_then(|key| key.get_value::<String, _>(""))
64 .map(std::path::PathBuf::from)
65 .ok()
66}
67
68struct TemporaryProcess(Child, Option<tempfile::TempDir>);
69
70impl Drop for TemporaryProcess {
71 fn drop(&mut self) {
72 info!("Killing Chrome. PID: {}", self.0.id());
73 self.0.kill().and_then(|()| self.0.wait()).ok();
74 if let Some(dir) = self.1.take() {
75 if let Err(e) = dir.close() {
76 warn!("Failed to close temporary directory: {}", e);
77 }
78 };
79 }
80}
81
82#[derive(Clone, Debug, Builder)]
85pub struct LaunchOptions<'a> {
86 #[builder(default = "true")]
88 pub headless: bool,
89
90 #[builder(default = "true")]
92 pub sandbox: bool,
93
94 #[builder(default = "false")]
96 pub devtools: bool,
97
98 #[builder(default = "false")]
100 pub enable_gpu: bool,
101
102 #[builder(default = "false")]
106 pub enable_logging: bool,
107
108 #[builder(default = "None")]
110 pub window_size: Option<(u32, u32)>,
111
112 #[builder(default = "None")]
114 pub port: Option<u16>,
115 #[builder(default = "true")]
119 pub ignore_certificate_errors: bool,
120
121 #[builder(default = "None")]
125 pub path: Option<std::path::PathBuf>,
126
127 #[builder(default = "None")]
131 pub user_data_dir: Option<std::path::PathBuf>,
132
133 #[builder(default)]
141 pub extensions: Vec<&'a OsStr>,
142
143 #[builder(default)]
146 pub args: Vec<&'a OsStr>,
147
148 #[builder(default)]
150 pub ignore_default_args: Vec<&'a OsStr>,
151
152 #[builder(default)]
154 pub disable_default_args: bool,
155
156 #[cfg_attr(feature = "fetch", builder(default))]
161 #[cfg(feature = "fetch")]
162 pub fetcher_options: FetcherOptions,
163
164 #[builder(default = "Duration::from_secs(30)")]
167 pub idle_browser_timeout: Duration,
168
169 #[builder(default = "None")]
172 pub process_envs: Option<HashMap<String, String>>,
173
174 #[builder(default = "None")]
176 pub proxy_server: Option<&'a str>,
177}
178
179impl Default for LaunchOptions<'_> {
180 fn default() -> Self {
181 LaunchOptions {
182 headless: true,
183 devtools: false,
184 sandbox: true,
185 enable_gpu: false,
186 enable_logging: false,
187 idle_browser_timeout: Duration::from_secs(30),
188 window_size: None,
189 path: None,
190 user_data_dir: None,
191 port: None,
192 ignore_certificate_errors: true,
193 extensions: Vec::new(),
194 process_envs: None,
195 #[cfg(feature = "fetch")]
196 fetcher_options: Default::default(),
197 args: Vec::new(),
198 ignore_default_args: Vec::new(),
199 disable_default_args: false,
200 proxy_server: None,
201 }
202 }
203}
204
205impl<'a> LaunchOptions<'a> {
206 pub fn default_builder() -> LaunchOptionsBuilder<'a> {
207 LaunchOptionsBuilder::default()
208 }
209}
210
211pub static DEFAULT_ARGS: [&str; 23] = [
214 "--disable-background-networking",
215 "--enable-features=NetworkService,NetworkServiceInProcess",
216 "--disable-background-timer-throttling",
217 "--disable-backgrounding-occluded-windows",
218 "--disable-breakpad",
219 "--disable-client-side-phishing-detection",
220 "--disable-component-extensions-with-background-pages",
221 "--disable-default-apps",
222 "--disable-dev-shm-usage",
223 "--disable-extensions",
224 "--disable-features=TranslateUI,BlinkGenPropertyTrees",
226 "--disable-hang-monitor",
227 "--disable-ipc-flooding-protection",
228 "--disable-popup-blocking",
229 "--disable-prompt-on-repost",
230 "--disable-renderer-backgrounding",
231 "--disable-sync",
232 "--force-color-profile=srgb",
233 "--metrics-recording-only",
234 "--no-first-run",
235 "--enable-automation",
236 "--password-store=basic",
237 "--use-mock-keychain",
238];
239
240impl Process {
241 pub fn new(mut launch_options: LaunchOptions) -> Result<Self> {
242 if launch_options.path.is_none() {
243 #[cfg(feature = "fetch")]
244 {
245 let fetch = Fetcher::new(launch_options.fetcher_options.clone());
246 launch_options.path = Some(fetch.fetch()?);
247 }
248 #[cfg(not(feature = "fetch"))]
249 {
250 launch_options.path = Some(default_executable().map_err(|e| anyhow!("{}", e))?);
251 }
252 }
253
254 let mut process = Self::start_process(&launch_options)?;
255
256 info!("Started Chrome. PID: {}", process.0.id());
257
258 let url;
259 let mut attempts = 0;
260 loop {
261 if attempts > 10 {
262 return Err(ChromeLaunchError::NoAvailablePorts {}.into());
263 }
264
265 match Self::ws_url_from_output(process.0.borrow_mut()) {
266 Ok(debug_ws_url) => {
267 url = debug_ws_url;
268 debug!("Found debugging WS URL: {:?}", url);
269 break;
270 }
271 Err(error) => {
272 trace!("Problem getting WebSocket URL from Chrome: {}", error);
273
274 if let Some(&ChromeLaunchError::RunningAsRootWithoutNoSandbox) =
275 error.downcast_ref::<ChromeLaunchError>()
276 {
277 return Err(error);
278 }
279
280 if launch_options.port.is_none() {
281 process = Self::start_process(&launch_options)?;
282 } else {
283 return Err(error);
284 }
285 }
286 }
287
288 trace!(
289 "Trying again to find available debugging port. Attempts: {}",
290 attempts
291 );
292 attempts += 1;
293 }
294
295 let child = process.0.borrow_mut();
296 child.stderr = None;
297
298 Ok(Self {
299 child_process: process,
300 debug_ws_url: url,
301 })
302 }
303
304 fn start_process(launch_options: &LaunchOptions) -> Result<TemporaryProcess> {
305 let debug_port = if let Some(port) = launch_options.port {
306 port
307 } else {
308 get_available_port().ok_or(ChromeLaunchError::NoAvailablePorts {})?
309 };
310 let port_option = format!("--remote-debugging-port={debug_port}");
311
312 let window_size_option = if let Some((width, height)) = launch_options.window_size {
313 format!("--window-size={width},{height}")
314 } else {
315 String::new()
316 };
317
318 let mut temp_user_data_dir = None;
319
320 let user_data_dir = if let Some(dir) = &launch_options.user_data_dir {
322 dir.clone()
323 } else {
324 let dir = ::tempfile::Builder::new()
327 .prefix("rust-headless-chrome-profile")
328 .tempdir()?;
329
330 let buf = dir.path().to_path_buf();
331 temp_user_data_dir = Some(dir);
332 buf
333 };
334 let data_dir_option = format!("--user-data-dir={}", &user_data_dir.to_str().unwrap());
335
336 #[cfg(test)]
337 ForTesting::USER_DATA_DIR.with(|dir| {
338 *dir.borrow_mut() = user_data_dir.to_str().map(std::borrow::ToOwned::to_owned);
339 });
340
341 trace!("Chrome will have profile: {}", data_dir_option);
342
343 let mut args = vec![
344 port_option.as_str(),
345 "--verbose",
346 "--log-level=0",
347 "--no-first-run",
348 data_dir_option.as_str(),
349 ];
350
351 if !launch_options.disable_default_args {
352 let ignore_default_args: Vec<&str> = launch_options
353 .ignore_default_args
354 .iter()
355 .map(|arg| arg.to_str().unwrap())
356 .collect();
357
358 let defaults: Vec<_> = DEFAULT_ARGS
359 .iter()
360 .filter(|arg| !ignore_default_args.contains(arg))
361 .collect();
362
363 args.extend(defaults);
364 }
365
366 if !launch_options.args.is_empty() {
367 let extra_args: Vec<&str> = launch_options
368 .args
369 .iter()
370 .map(|a| a.to_str().unwrap())
371 .collect();
372 args.extend(extra_args);
373 }
374
375 if !window_size_option.is_empty() {
376 args.extend([window_size_option.as_str()]);
377 }
378
379 if launch_options.headless && !launch_options.devtools {
380 args.extend(["--headless"]);
381 } else if launch_options.devtools {
382 args.extend(["--auto-open-devtools-for-tabs"]);
383 }
384
385 if launch_options.ignore_certificate_errors {
386 args.extend(["--ignore-certificate-errors"]);
387 }
388
389 if launch_options.enable_logging {
390 args.extend(["--enable-logging"]);
391 }
392
393 if !launch_options.enable_gpu {
394 args.extend(["--disable-gpu"]);
395 }
396
397 let proxy_server_option = if let Some(proxy_server) = launch_options.proxy_server {
398 format!("--proxy-server={proxy_server}")
399 } else {
400 String::new()
401 };
402
403 if !proxy_server_option.is_empty() {
404 args.extend([proxy_server_option.as_str()]);
405 }
406
407 if !launch_options.sandbox {
408 args.extend(["--no-sandbox", "--disable-setuid-sandbox"]);
409 }
410
411 let extension_args: Vec<String> = launch_options
412 .extensions
413 .iter()
414 .map(|e| format!("--load-extension={}", e.to_str().unwrap()))
415 .collect();
416
417 args.extend(extension_args.iter().map(String::as_str));
418
419 let path = launch_options
420 .path
421 .as_ref()
422 .ok_or_else(|| anyhow!("Chrome path required"))?;
423
424 info!("Launching Chrome binary at {:?}", &path);
425 trace!("with CLI arguments: {:?}", args);
426
427 let mut command = Command::new(path);
428
429 if let Some(process_envs) = launch_options.process_envs.clone() {
430 command.envs(process_envs);
431 }
432
433 #[cfg(windows)]
435 {
436 use std::os::windows::process::CommandExt;
437 const CREATE_NO_WINDOW: u32 = 0x08000000;
438 command.creation_flags(CREATE_NO_WINDOW);
439 }
440
441 let process = TemporaryProcess(
442 command.args(&args).stderr(Stdio::piped()).spawn()?,
443 temp_user_data_dir,
444 );
445 Ok(process)
446 }
447
448 fn ws_url_from_reader<R>(reader: BufReader<R>) -> Result<Option<String>>
449 where
450 R: Read,
451 {
452 let port_taken_re = Regex::new(r"ERROR.*bind\(\)")?;
453 let root_sandbox = "Running as root without --no-sandbox is not supported";
454
455 let re = Regex::new(r"listening on (.*/devtools/browser/.*)$")?;
456
457 let extract = |text: &str| -> Option<String> {
458 let caps = re.captures(text);
459 let cap = &caps?[1];
460 Some(cap.into())
461 };
462
463 for line in reader.lines() {
464 let chrome_output = line?;
465 trace!("Chrome output: {}", chrome_output);
466
467 if chrome_output.contains(root_sandbox) {
468 return Err(ChromeLaunchError::RunningAsRootWithoutNoSandbox {}.into());
469 }
470
471 if port_taken_re.is_match(&chrome_output) {
472 return Err(ChromeLaunchError::DebugPortInUse {}.into());
473 }
474
475 if let Some(answer) = extract(&chrome_output) {
476 return Ok(Some(answer));
477 }
478 }
479
480 Ok(None)
481 }
482
483 fn ws_url_from_output(child_process: &mut Child) -> Result<Url> {
484 let chrome_output_result = util::Wait::with_timeout(Duration::from_secs(30)).until(|| {
485 let my_stderr = BufReader::new(child_process.stderr.as_mut()?);
486 match Self::ws_url_from_reader(my_stderr) {
487 Ok(output_option) => output_option.map(Ok),
488 Err(err) => Some(Err(err)),
489 }
490 });
491
492 if let Ok(output_result) = chrome_output_result {
493 Ok(Url::parse(&output_result?)?)
494 } else {
495 Err(ChromeLaunchError::PortOpenTimeout {}.into())
496 }
497 }
498
499 pub fn get_id(&self) -> u32 {
500 self.child_process.0.id()
501 }
502}
503
504fn get_available_port() -> Option<u16> {
505 let mut ports: Vec<u16> = (8000..9000).collect();
506 ports.shuffle(&mut rng());
507 ports.iter().find(|port| port_is_available(**port)).copied()
508}
509
510fn port_is_available(port: u16) -> bool {
511 net::TcpListener::bind(("127.0.0.1", port)).is_ok()
512}
513
514#[cfg(test)]
515mod tests {
516 #[cfg(feature = "fetch")]
517 use std::fs;
518 #[cfg(feature = "fetch")]
519 use std::path::PathBuf;
520
521 use std::sync::Once;
522 use std::thread;
523
524 use crate::browser::default_executable;
525
526 use super::*;
527
528 static INIT: Once = Once::new();
529
530 fn setup() {
531 INIT.call_once(|| {
532 env_logger::try_init().unwrap_or(());
533 });
534 }
535
536 #[test]
537 fn can_launch_chrome_and_get_ws_url() {
538 setup();
539 let chrome = super::Process::new(
540 LaunchOptions::default_builder()
541 .path(Some(default_executable().unwrap()))
542 .build()
543 .unwrap(),
544 )
545 .unwrap();
546 info!("{:?}", chrome.debug_ws_url);
547 }
548
549 #[test]
550 #[cfg(feature = "fetch")]
551 fn can_install_chrome_to_dir_and_launch() {
552 use crate::browser::fetcher::CUR_REV;
553 #[cfg(target_os = "linux")]
554 const PLATFORM: &str = "linux";
555 #[cfg(target_os = "macos")]
556 const PLATFORM: &str = "mac";
557 #[cfg(windows)]
558 const PLATFORM: &str = "win";
559
560 let tests_temp_dir = [env!("CARGO_MANIFEST_DIR"), "tests", "temp"]
561 .iter()
562 .collect::<PathBuf>();
563
564 setup();
565
566 let mut installed_dir = tests_temp_dir.clone();
570 installed_dir.push(format!("{PLATFORM}-{CUR_REV}"));
571
572 if installed_dir.exists() {
573 info!("Deleting pre-existing install at {:?}", &installed_dir);
574 fs::remove_dir_all(&installed_dir).expect("Could not delete pre-existing install");
575 }
576
577 let chrome = super::Process::new(
578 LaunchOptions::default_builder()
579 .fetcher_options(FetcherOptions::default().with_install_dir(Some(&tests_temp_dir)))
580 .build()
581 .unwrap(),
582 )
583 .unwrap();
584 info!("{:?}", chrome.debug_ws_url);
585 }
586
587 #[test]
588 fn handle_errors_in_chrome_output() {
589 setup();
590 let lines = "[0228/194641.093619:ERROR:socket_posix.cc(144)] bind() returned an error, errno=0: Cannot assign requested address (99)";
591 let reader = BufReader::new(lines.as_bytes());
592 let ws_url_result = Process::ws_url_from_reader(reader);
593 assert!(ws_url_result.is_err());
594 }
595
596 #[test]
597 fn handle_errors_in_chrome_output_gvisor_netlink() {
598 setup();
600 let lines = "[0703/145506.975691:ERROR:address_tracker_linux.cc(214)] Could not bind NETLINK socket: Permission denied (13)";
601
602 let reader = BufReader::new(lines.as_bytes());
603 let ws_url_result = Process::ws_url_from_reader(reader);
604 assert!(ws_url_result.is_ok());
605 }
606
607 #[cfg(target_os = "linux")]
608 fn current_child_pids() -> Vec<i32> {
609 use std::fs::File;
610 use std::io::prelude::*;
611 let current_pid = std::process::id();
612 let mut current_process_children_file =
613 File::open(format!("/proc/{current_pid}/task/{current_pid}/children")).unwrap();
614 let mut child_pids = String::new();
615 current_process_children_file
616 .read_to_string(&mut child_pids)
617 .unwrap();
618 child_pids
619 .split_whitespace()
620 .map(|pid_str| pid_str.parse::<i32>().unwrap())
621 .collect()
622 }
623
624 #[test]
625 #[cfg(target_os = "linux")]
626 fn kills_process_on_drop() {
627 setup();
628 {
629 let _chrome = &mut super::Process::new(
630 LaunchOptions::default_builder()
631 .path(Some(default_executable().unwrap()))
632 .build()
633 .unwrap(),
634 )
635 .unwrap();
636 }
637
638 let child_pids = current_child_pids();
639 assert!(child_pids.is_empty());
640 }
641
642 #[test]
643 fn launch_multiple_non_headless_instances() {
644 setup();
645 let mut handles = Vec::new();
646
647 for _ in 0..10 {
648 let handle = thread::spawn(|| {
649 std::thread::sleep(std::time::Duration::from_millis(10));
651 let chrome = super::Process::new(
652 LaunchOptions::default_builder()
653 .path(Some(default_executable().unwrap()))
654 .build()
655 .unwrap(),
656 )
657 .unwrap();
658 std::thread::sleep(std::time::Duration::from_millis(100));
659 chrome.debug_ws_url
660 });
661 handles.push(handle);
662 }
663
664 for handle in handles {
665 handle.join().unwrap();
666 }
667 }
668
669 #[test]
670 fn no_instance_sharing() {
671 setup();
672
673 let mut handles = Vec::new();
674
675 for _ in 0..10 {
676 let chrome = super::Process::new(
677 LaunchOptions::default_builder()
678 .path(Some(default_executable().unwrap()))
679 .headless(true)
680 .build()
681 .unwrap(),
682 )
683 .unwrap();
684 handles.push(chrome);
685 }
686 }
687
688 #[test]
689 fn test_temporary_user_data_dir_is_removed_automatically() {
690 setup();
691
692 let options = LaunchOptions::default_builder().build().unwrap();
693
694 let temp_dir = options.user_data_dir.clone();
696 assert_eq!(None, temp_dir);
697
698 let user_data_dir = {
699 let _chrome = &mut super::Process::new(options).unwrap();
700
701 ForTesting::USER_DATA_DIR.with(|dir| dir.borrow_mut().take())
702 };
703
704 match user_data_dir {
705 Some(temp_path) => {
706 let user_data_dir_exists = std::path::Path::new(&temp_path).is_dir();
707 assert!(!user_data_dir_exists);
708 }
709 None => panic!("No user data dir was created"),
710 }
711 }
712}