mod config;
use crate::cdp::{CdpClient, CdpError};
use crate::page::Page;
use serde::Deserialize;
use serde_json::json;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
pub use config::BrowserConfig;
#[derive(Debug, Clone)]
pub struct BrowserVersion {
pub browser: String,
pub protocol_version: String,
pub user_agent: String,
pub web_socket_debugger_url: String,
}
pub struct Browser {
client: Arc<CdpClient>,
_child: Option<Child>,
pub(crate) browser_endpoint: Option<String>,
}
impl Browser {
pub fn connect(endpoint: &str) -> Result<Self, CdpError> {
let endpoint = normalize_endpoint(endpoint);
let ws_url = fetch_ws_url_from_endpoint(&endpoint)?;
let client = CdpClient::connect(&ws_url)?;
Ok(Self {
client: Arc::new(client),
_child: None,
browser_endpoint: Some(endpoint),
})
}
pub fn launch(config: BrowserConfig) -> Result<Self, CdpError> {
let (endpoint, child) = launch_chrome(&config)?;
let mut browser = Self::connect(&endpoint)?;
browser._child = Some(child);
browser.browser_endpoint = Some(endpoint);
Ok(browser)
}
pub fn connect_or_launch(config: BrowserConfig) -> Result<Self, CdpError> {
let address = config
.get_address()
.map(String::from)
.unwrap_or_else(|| format!("127.0.0.1:{}", config.get_remote_debugging_port()));
let endpoint = format!("http://{}", address);
if config.get_existing_only() {
return Self::connect(&endpoint);
}
let (host, port_str) = address.split_once(':').unwrap_or(("127.0.0.1", "9222"));
let port: u16 = port_str.parse().unwrap_or(9222);
let in_use = port_in_use(host, port);
if host != "127.0.0.1" || in_use {
return Self::connect(&endpoint);
}
Self::launch(config)
}
pub fn new_tab(&self) -> Result<Page, CdpError> {
let params = json!({ "url": "about:blank" });
let result = self
.client
.send("Target.createTarget", Some(params))?
.get("targetId")
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "Target.createTarget did not return targetId".into(),
})?;
let target_id = result;
let params = json!({ "targetId": target_id, "flatten": true });
let result = self
.client
.send("Target.attachToTarget", Some(params))?
.get("sessionId")
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "Target.attachToTarget did not return sessionId".into(),
})?;
let session_id = result;
Ok(Page::new(
Arc::clone(&self.client),
session_id,
target_id,
self.browser_endpoint.clone(),
))
}
pub fn tabs(&self) -> Result<Vec<Page>, CdpError> {
let result = self.client.send("Target.getTargets", None)?;
let list = result
.get("targetInfos")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "Target.getTargets did not return targetInfos".into(),
})?;
let mut pages = Vec::new();
for info in list {
let typ = info.get("type").and_then(serde_json::Value::as_str).unwrap_or("");
if typ != "page" {
continue;
}
let target_id = info
.get("targetId")
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "A target entry did not include targetId".into(),
})?;
let params = json!({ "targetId": target_id, "flatten": true });
let res = self.client.send("Target.attachToTarget", Some(params))?;
let session_id = res
.get("sessionId")
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "Target.attachToTarget did not return sessionId".into(),
})?;
pages.push(Page::new(
Arc::clone(&self.client),
session_id,
target_id,
self.browser_endpoint.clone(),
));
}
Ok(pages)
}
pub fn tab_ids(&self) -> Result<Vec<String>, CdpError> {
let result = self.client.send("Target.getTargets", None)?;
let list = result
.get("targetInfos")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "Target.getTargets did not return targetInfos".into(),
})?;
let ids: Vec<String> = list
.iter()
.filter(|info| info.get("type").and_then(serde_json::Value::as_str) == Some("page"))
.filter_map(|info| info.get("targetId").and_then(serde_json::Value::as_str).map(String::from))
.collect();
Ok(ids)
}
pub fn tabs_count(&self) -> Result<usize, CdpError> {
let ids = self.tab_ids()?;
Ok(ids.len())
}
pub fn latest_tab(&self) -> Result<Page, CdpError> {
let tabs = self.tabs()?;
tabs.into_iter().last().ok_or_else(|| CdpError::Protocol {
id: None,
code: -1,
message: "No open tabs were found".into(),
})
}
pub fn get_tab(&self, id_or_num: &str, title: Option<&str>, url: Option<&str>, tab_type: Option<&str>) -> Result<Option<Page>, CdpError> {
let tabs = self.tabs()?;
if let Ok(num) = id_or_num.parse::<usize>() {
if num == 0 {
return Ok(None);
}
return Ok(tabs.into_iter().nth(num - 1));
}
for tab in tabs {
if tab.target_id == id_or_num {
return Ok(Some(tab));
}
let matches_title = title.map(|t| {
tab.title().map(|tab_title| tab_title.contains(t)).unwrap_or(false)
}).unwrap_or(true);
let matches_url = url.map(|u| {
tab.url().map(|tab_url| tab_url.contains(u)).unwrap_or(false)
}).unwrap_or(true);
let matches_type = tab_type.map(|tp| tp == "page").unwrap_or(true);
if matches_title && matches_url && matches_type {
return Ok(Some(tab));
}
}
Ok(None)
}
pub fn get_tabs(&self, title: Option<&str>, url: Option<&str>, tab_type: Option<&str>) -> Result<Vec<Page>, CdpError> {
let tabs = self.tabs()?;
let mut result = Vec::new();
for tab in tabs {
let matches_title = title.map(|t| {
tab.title().map(|title| title.contains(t)).unwrap_or(false)
}).unwrap_or(true);
let matches_url = url.map(|u| {
tab.url().map(|url| url.contains(u)).unwrap_or(false)
}).unwrap_or(true);
let matches_type = tab_type.map(|tp| tp == "page").unwrap_or(true);
if matches_title && matches_url && matches_type {
result.push(tab);
}
}
Ok(result)
}
pub fn activate_tab(&self, target_id: &str) -> Result<(), CdpError> {
let params = json!({ "targetId": target_id });
self.client.send("Target.activateTarget", Some(params))?;
Ok(())
}
pub fn close_tabs(&self, tab_ids: &[String], others: bool) -> Result<(), CdpError> {
let all_ids = self.tab_ids()?;
let to_close: Vec<String> = if others {
all_ids.into_iter().filter(|id| !tab_ids.contains(id)).collect()
} else {
tab_ids.to_vec()
};
for id in to_close {
let params = json!({ "targetId": id });
let _ = self.client.send("Target.closeTarget", Some(params));
}
Ok(())
}
pub fn version(&self) -> Result<BrowserVersion, CdpError> {
let endpoint = "http://127.0.0.1:9222";
let body = ureq::get(&format!("{}/json/version", endpoint))
.call()
.map_err(|e| CdpError::Http(e.to_string()))?
.into_string()
.map_err(|e| CdpError::Http(e.to_string()))?;
let v: JsonVersion = serde_json::from_str(&body).map_err(CdpError::Json)?;
Ok(BrowserVersion {
browser: v.browser.unwrap_or_default(),
protocol_version: v.protocol_version.unwrap_or_default(),
user_agent: v.user_agent.unwrap_or_default(),
web_socket_debugger_url: v.web_socket_debugger_url.unwrap_or_default(),
})
}
pub fn close(&mut self) {
if let Some(mut child) = self._child.take() {
let _ = child.kill();
}
}
}
fn normalize_endpoint(endpoint: &str) -> String {
let s = endpoint.trim();
if s.is_empty() {
return "http://127.0.0.1:9222".to_string();
}
if s.starts_with("http://") || s.starts_with("https://") {
return s.to_string();
}
format!("http://{}", s)
}
fn port_in_use(host: &str, port: u16) -> bool {
let addr = match format!("{}:{}", host, port).parse::<std::net::SocketAddr>() {
Ok(a) => a,
Err(_) => return false,
};
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(100)).is_ok()
}
pub(crate) fn fetch_ws_url_from_endpoint(endpoint: &str) -> Result<String, CdpError> {
let url = format!("{}/json/version", endpoint.trim_end_matches('/'));
let body = ureq::get(&url)
.call()
.map_err(|e| CdpError::Http(e.to_string()))?
.into_string()
.map_err(|e| CdpError::Http(e.to_string()))?;
let v: JsonVersion = serde_json::from_str(&body).map_err(CdpError::Json)?;
v.web_socket_debugger_url
.ok_or_else(|| CdpError::Http("The /json/version response did not include webSocketDebuggerUrl".into()))
}
#[derive(Deserialize)]
struct JsonVersion {
#[serde(rename = "Browser")]
browser: Option<String>,
#[serde(rename = "Protocol-Version")]
protocol_version: Option<String>,
#[serde(rename = "User-Agent")]
user_agent: Option<String>,
#[serde(rename = "webSocketDebuggerUrl")]
web_socket_debugger_url: Option<String>,
}
fn launch_chrome(config: &BrowserConfig) -> Result<(String, Child), CdpError> {
let port = config.get_remote_debugging_port();
let host = config
.get_address()
.and_then(|a| a.split_once(':').map(|(h, _)| h.to_string()))
.unwrap_or_else(|| "127.0.0.1".to_string());
let endpoint = format!("http://{}:{}", host, port);
let chrome = resolve_chrome_path(config)?;
let mut args = vec![format!("--remote-debugging-port={}", port)];
let user_data_dir = match config.get_user_data_dir() {
Some(d) => d.to_string(),
None => {
let tmp = config
.get_tmp_path()
.map(std::path::Path::new)
.map(|p| p.to_path_buf())
.unwrap_or_else(std::env::temp_dir);
let dir = tmp.join("DrissionPage").join("userData").join(port.to_string());
if let Some(p) = dir.to_str() {
std::fs::create_dir_all(&dir).ok();
p.to_string()
} else {
return Err(CdpError::Http("Failed to build the default user-data-dir path".into()));
}
}
};
args.push(format!("--user-data-dir={}", user_data_dir));
args.push("--window-size=1920,1080".to_string());
if config.get_headless() {
let has_headless = config.get_args().iter().any(|a| a.starts_with("--headless"));
if !has_headless {
args.push("--headless=new".to_string());
}
}
args.extend(config.get_args().to_vec());
let mut child = Command::new(&chrome)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CdpError::Http(format!("Failed to launch Chrome: {}", e)))?;
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(100));
if ureq::get(&format!("{}/json/version", endpoint)).call().is_ok() {
return Ok((endpoint, child));
}
}
let _ = child.kill();
Err(CdpError::Http("Chrome did not become ready within 5 seconds after launch".into()))
}
fn resolve_chrome_path(config: &BrowserConfig) -> Result<String, CdpError> {
if let Some(p) = config.get_chrome_path() {
let path = std::path::Path::new(p);
if path.is_dir() {
#[cfg(windows)]
let exe = path.join("chrome.exe");
#[cfg(not(windows))]
let exe = path.join("chrome");
if exe.exists() {
return Ok(exe.to_string_lossy().into_owned());
}
}
return Ok(p.to_string());
}
Ok(find_chrome_executable())
}
fn find_chrome_executable() -> String {
#[cfg(windows)]
{
if let Some(p) = find_chrome_windows() {
return p;
}
}
#[cfg(target_os = "macos")]
{
const MAC_PATHS: &[&str] = &[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
];
for path in MAC_PATHS {
if std::path::Path::new(path).exists() {
return path.to_string();
}
}
for name in [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
] {
if let Some(p) = which(name) {
return p;
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
for name in [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
] {
if which(name).is_some() {
return name.to_string();
}
}
}
default_chrome_name().to_string()
}
#[cfg(windows)]
fn find_chrome_windows() -> Option<String> {
use std::path::Path;
let key_path = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe";
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
if let Ok(path) = get_reg_string(hkcu, key_path, "") {
if Path::new(&path).exists() {
return Some(path);
}
}
let hklm = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE);
if let Ok(path) = get_reg_string(hklm, key_path, "") {
if Path::new(&path).exists() {
return Some(path);
}
}
for dir in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
let exe = dir.join("chrome.exe");
if exe.exists() {
return Some(exe.to_string_lossy().into_owned());
}
}
None
}
#[cfg(windows)]
fn get_reg_string(
hkey: winreg::RegKey,
subkey: &str,
name: &str,
) -> Result<String, std::io::Error> {
let key = hkey.open_subkey_with_flags(subkey, winreg::enums::KEY_READ)?;
key.get_value(name)
}
#[cfg(unix)]
fn which(name: &str) -> Option<String> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let exe = dir.join(name);
if exe.exists() {
return Some(exe.to_string_lossy().into_owned());
}
}
None
}
#[cfg(windows)]
fn default_chrome_name() -> &'static str {
"chrome"
}
#[cfg(not(windows))]
fn default_chrome_name() -> &'static str {
"google-chrome"
}