use std::time::Duration;
use std::{
collections::HashMap,
io::{self, BufRead, BufReader},
path::{Path, PathBuf},
process::{self, Child, Stdio},
};
use futures::channel::mpsc::{channel, Sender};
use futures::channel::oneshot::channel as oneshot_channel;
use futures::SinkExt;
use chromiumoxide_cdp::cdp::browser_protocol::target::{
CreateBrowserContextParams, CreateTargetParams, DisposeBrowserContextParams,
};
use chromiumoxide_cdp::cdp::CdpEventMessage;
use chromiumoxide_types::*;
use crate::cmd::{to_command_response, CommandMessage};
use crate::conn::Connection;
use crate::error::{CdpError, Result};
use crate::handler::browser::BrowserContext;
use crate::handler::viewport::Viewport;
use crate::handler::{Handler, HandlerConfig, HandlerMessage, REQUEST_TIMEOUT};
use crate::page::Page;
use chromiumoxide_cdp::cdp::browser_protocol::browser::{GetVersionParams, GetVersionReturns};
#[derive(Debug)]
pub struct Browser {
sender: Sender<HandlerMessage>,
config: Option<BrowserConfig>,
child: Option<Child>,
debug_ws_url: String,
browser_context: BrowserContext,
}
impl Browser {
pub async fn connect(debug_ws_url: impl Into<String>) -> Result<(Self, Handler)> {
let debug_ws_url = debug_ws_url.into();
let conn = Connection::<CdpEventMessage>::connect(&debug_ws_url).await?;
let (tx, rx) = channel(1);
let fut = Handler::new(conn, rx, HandlerConfig::default());
let browser_context = fut.default_browser_context().clone();
let browser = Self {
sender: tx,
config: None,
child: None,
debug_ws_url,
browser_context,
};
Ok((browser, fut))
}
pub async fn launch(config: BrowserConfig) -> Result<(Self, Handler)> {
let mut child = config.launch()?;
let get_ws_url = ws_url_from_output(&mut child);
let dur = Duration::from_secs(20);
cfg_if::cfg_if! {
if #[cfg(feature = "async-std-runtime")] {
let debug_ws_url = async_std::future::timeout(dur, get_ws_url)
.await
.map_err(|_| CdpError::Timeout)?;
} else if #[cfg(feature = "tokio-runtime")] {
let debug_ws_url = tokio::time::timeout(dur, get_ws_url).await
.map_err(|_| CdpError::Timeout)?;
}
}
let conn = Connection::<CdpEventMessage>::connect(&debug_ws_url).await?;
let (tx, rx) = channel(1);
let handler_config = HandlerConfig {
ignore_https_errors: config.ignore_https_errors,
viewport: Some(config.viewport.clone()),
context_ids: Vec::new(),
request_timeout: config.request_timeout,
};
let fut = Handler::new(conn, rx, handler_config);
let browser_context = fut.default_browser_context().clone();
let browser = Self {
sender: tx,
config: Some(config),
child: Some(child),
debug_ws_url,
browser_context,
};
Ok((browser, fut))
}
pub async fn start_incognito_context(&mut self) -> Result<&mut Self> {
if !self.is_incognito_configured() {
let resp = self
.execute(CreateBrowserContextParams::default())
.await?
.result;
self.browser_context = BrowserContext::from(resp.browser_context_id);
self.sender
.clone()
.send(HandlerMessage::InsertContext(self.browser_context.clone()))
.await?;
}
Ok(self)
}
pub async fn quit_incognito_context(&mut self) -> Result<&mut Self> {
if let Some(id) = self.browser_context.take() {
self.execute(DisposeBrowserContextParams::new(id.clone()))
.await?;
self.sender
.clone()
.send(HandlerMessage::DisposeContext(BrowserContext::from(id)))
.await?;
}
Ok(self)
}
fn is_incognito_configured(&self) -> bool {
self.config
.as_ref()
.map(|c| c.incognito)
.unwrap_or_default()
}
pub fn websocket_address(&self) -> &String {
&self.debug_ws_url
}
pub fn is_incognito(&self) -> bool {
self.is_incognito_configured() || self.browser_context.is_incognito()
}
pub fn config(&self) -> Option<&BrowserConfig> {
self.config.as_ref()
}
pub async fn new_page(&self, params: impl Into<CreateTargetParams>) -> Result<Page> {
let (tx, rx) = oneshot_channel();
let mut params = params.into();
if let Some(id) = self.browser_context.id() {
if params.browser_context_id.is_none() {
params.browser_context_id = Some(id.clone());
}
}
self.sender
.clone()
.send(HandlerMessage::CreatePage(params, tx))
.await?;
rx.await?
}
pub async fn version(&self) -> Result<GetVersionReturns> {
Ok(self.execute(GetVersionParams::default()).await?.result)
}
pub async fn user_agent(&self) -> Result<String> {
Ok(self.version().await?.user_agent)
}
pub async fn execute<T: Command>(&self, cmd: T) -> Result<CommandResponse<T::Response>> {
let (tx, rx) = oneshot_channel();
let method = cmd.identifier();
let msg = CommandMessage::new(cmd, tx)?;
self.sender
.clone()
.send(HandlerMessage::Command(msg))
.await?;
let resp = rx.await??;
to_command_response::<T>(resp, method)
}
pub async fn pages(&self) -> Result<Vec<Page>> {
let (tx, rx) = oneshot_channel();
self.sender
.clone()
.send(HandlerMessage::GetPages(tx))
.await?;
Ok(rx.await?)
}
}
impl Drop for Browser {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
child.kill().expect("!kill");
}
}
}
async fn ws_url_from_output(child_process: &mut Child) -> String {
let stdout = child_process.stderr.take().expect("no stderror");
fn read_debug_url(stdout: std::process::ChildStderr) -> String {
let mut buf = BufReader::new(stdout);
let mut line = String::new();
loop {
if buf.read_line(&mut line).is_ok() {
if let Some(ws) = line.rsplit("listening on ").next() {
if ws.starts_with("ws") && ws.contains("devtools/browser") {
return ws.trim().to_string();
}
}
} else {
line = String::new();
}
}
}
cfg_if::cfg_if! {
if #[cfg(feature = "async-std-runtime")] {
async_std::task::spawn_blocking(|| read_debug_url(stdout)).await
} else if #[cfg(feature = "tokio-runtime")] {
tokio::task::spawn_blocking(move || read_debug_url(stdout)).await.expect("Failed to read debug url from process output")
}
}
}
#[derive(Debug, Clone)]
pub struct BrowserConfig {
headless: bool,
sandbox: bool,
window_size: Option<(u32, u32)>,
port: u16,
executable: std::path::PathBuf,
extensions: Vec<String>,
pub process_envs: Option<HashMap<String, String>>,
pub user_data_dir: Option<PathBuf>,
incognito: bool,
ignore_https_errors: bool,
viewport: Viewport,
request_timeout: Duration,
}
#[derive(Debug, Clone)]
pub struct BrowserConfigBuilder {
headless: bool,
sandbox: bool,
window_size: Option<(u32, u32)>,
port: u16,
executable: Option<PathBuf>,
extensions: Vec<String>,
process_envs: Option<HashMap<String, String>>,
user_data_dir: Option<PathBuf>,
incognito: bool,
ignore_https_errors: bool,
viewport: Viewport,
request_timeout: Duration,
}
impl BrowserConfig {
pub fn builder() -> BrowserConfigBuilder {
BrowserConfigBuilder::default()
}
pub fn with_executable(path: impl AsRef<Path>) -> Self {
Self::builder().chrome_executable(path).build().unwrap()
}
}
impl Default for BrowserConfigBuilder {
fn default() -> Self {
Self {
headless: true,
sandbox: true,
window_size: None,
port: 0,
executable: None,
extensions: Vec::new(),
process_envs: None,
user_data_dir: None,
incognito: false,
ignore_https_errors: true,
viewport: Default::default(),
request_timeout: Duration::from_millis(REQUEST_TIMEOUT),
}
}
}
impl BrowserConfigBuilder {
pub fn window_size(mut self, width: u32, height: u32) -> Self {
self.window_size = Some((width, height));
self
}
pub fn no_sandbox(mut self) -> Self {
self.sandbox = false;
self
}
pub fn with_head(mut self) -> Self {
self.headless = false;
self
}
pub fn incognito(mut self) -> Self {
self.incognito = true;
self
}
pub fn respect_https_errors(mut self) -> Self {
self.ignore_https_errors = false;
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn user_data_dir(mut self, data_dir: impl AsRef<Path>) -> Self {
self.user_data_dir = Some(data_dir.as_ref().to_path_buf());
self
}
pub fn chrome_executable(mut self, path: impl AsRef<Path>) -> Self {
self.executable = Some(path.as_ref().to_path_buf());
self
}
pub fn extension(mut self, extension: impl Into<String>) -> Self {
self.extensions.push(extension.into());
self
}
pub fn extensions<I, S>(mut self, extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for ext in extensions {
self.extensions.push(ext.into());
}
self
}
pub fn env(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
self.process_envs
.get_or_insert(HashMap::new())
.insert(key.into(), val.into());
self
}
pub fn envs<I, K, V>(mut self, envs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.process_envs
.get_or_insert(HashMap::new())
.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}
pub fn build(self) -> std::result::Result<BrowserConfig, String> {
let executable = if let Some(e) = self.executable {
e
} else {
default_executable()?
};
Ok(BrowserConfig {
headless: self.headless,
sandbox: self.sandbox,
window_size: self.window_size,
port: self.port,
executable,
extensions: self.extensions,
process_envs: None,
user_data_dir: None,
incognito: self.incognito,
ignore_https_errors: self.ignore_https_errors,
viewport: self.viewport,
request_timeout: self.request_timeout,
})
}
}
impl BrowserConfig {
pub fn launch(&self) -> io::Result<Child> {
let dbg_port = format!("--remote-debugging-port={}", self.port);
let args = [
dbg_port.as_str(),
"--disable-background-networking",
"--enable-features=NetworkService,NetworkServiceInProcess",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-features=TranslateUI",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--disable-sync",
"--force-color-profile=srgb",
"--metrics-recording-only",
"--no-first-run",
"--enable-automation",
"--password-store=basic",
"--use-mock-keychain",
"--enable-blink-features=IdleDetection",
];
let mut cmd = process::Command::new(&self.executable);
cmd.args(&args).args(&DEFAULT_ARGS).args(
self.extensions
.iter()
.map(|e| format!("--load-extension={}", e)),
);
if let Some(ref user_data) = self.user_data_dir {
cmd.arg(format!("--user-data-dir={}", user_data.display()));
}
if let Some((width, height)) = self.window_size {
cmd.arg(format!("--window-size={},{}", width, height));
}
if !self.sandbox {
cmd.args(&["--no-sandbox", "--disable-setuid-sandbox"]);
}
if self.headless {
cmd.args(&["--headless", "--hide-scrollbars", "--mute-audio"]);
}
if self.incognito {
cmd.arg("--incognito");
}
if let Some(ref envs) = self.process_envs {
cmd.envs(envs);
}
cmd.stderr(Stdio::piped()).spawn()
}
}
pub fn default_executable() -> Result<std::path::PathBuf, String> {
if let Ok(path) = std::env::var("CHROME") {
if std::path::Path::new(&path).exists() {
return Ok(path.into());
}
}
for app in &[
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
"chrome-browser",
] {
if let Ok(path) = which::which(app) {
return Ok(path);
}
}
#[cfg(target_os = "macos")]
{
let default_paths = &["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"][..];
for path in default_paths {
if std::path::Path::new(path).exists() {
return Ok(path.into());
}
}
}
#[cfg(windows)]
{
if let Some(path) = get_chrome_path_from_windows_registry() {
if path.exists() {
return Ok(path);
}
}
}
Err("Could not auto detect a chrome executable".to_string())
}
#[cfg(windows)]
pub(crate) fn get_chrome_path_from_windows_registry() -> Option<std::path::PathBuf> {
winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe")
.and_then(|key| key.get_value::<String, _>(""))
.map(std::path::PathBuf::from)
.ok()
}
static DEFAULT_ARGS: [&str; 23] = [
"--disable-background-networking",
"--enable-features=NetworkService,NetworkServiceInProcess",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-features=TranslateUI",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--disable-sync",
"--force-color-profile=srgb",
"--metrics-recording-only",
"--no-first-run",
"--enable-automation",
"--password-store=basic",
"--use-mock-keychain",
];