mod geo;
mod launch;
use launch::{discover_browser_ws, prepare_browser_launch, remove_dir_if_exists};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cdp::types::{EmulationSetUserAgentOverride, UserAgentBrandVersion, UserAgentMetadata};
use crate::cdp::{Connection, Transport};
use crate::error::{Error, Result};
use crate::page::Page;
use crate::stealth::build_evasion_script_for;
use crate::stealth::fingerprint::Fingerprint;
use crate::StealthConfig;
#[derive(Debug, Clone)]
pub struct TabInfo {
pub id: String,
pub title: String,
pub url: String,
}
fn ua_override_for(fp: &Fingerprint) -> EmulationSetUserAgentOverride {
let to_brands = |v: Vec<(String, String)>| -> Vec<UserAgentBrandVersion> {
v.into_iter()
.map(|(brand, version)| UserAgentBrandVersion { brand, version })
.collect()
};
let architecture = fp.ch_architecture();
EmulationSetUserAgentOverride {
user_agent: fp.user_agent.clone(),
accept_language: Some(accept_language_for(&fp.languages)),
platform: Some(fp.nav_platform().to_string()),
user_agent_metadata: Some(UserAgentMetadata {
brands: to_brands(fp.ch_brands()),
full_version_list: to_brands(fp.ch_full_version_brands()),
platform: fp.ch_platform().to_string(),
platform_version: fp.platform_version.clone(),
architecture: architecture.to_string(),
model: String::new(),
mobile: false,
bitness: "64".to_string(),
wow64: false,
}),
}
}
fn accept_language_for(languages: &[String]) -> String {
languages
.iter()
.enumerate()
.map(|(index, language)| match index {
0 => language.clone(),
_ => format!("{};q={:.1}", language, (1.0 - 0.1 * index as f64).max(0.1)),
})
.collect::<Vec<_>>()
.join(",")
}
pub struct Browser {
connection: Connection,
download_slot: Arc<tokio::sync::Semaphore>,
config: Arc<StealthConfig>,
user_data_dir: Option<PathBuf>,
reused: bool,
fingerprint: Option<Fingerprint>,
evasion_script: String,
}
impl Browser {
pub fn owns_process(&self) -> bool {
!self.reused
}
pub async fn watch_downloads(&self, directory: &Path) -> Result<crate::DownloadWatch> {
if !self.owns_process() {
return Err(Error::cdp_msg(
"download_unsupported: attached browsers are not supported",
));
}
crate::DownloadWatch::begin(
self.connection.transport().clone(),
self.download_slot.clone(),
directory,
)
.await
}
pub async fn launch() -> Result<Self> {
Self::launch_with_config(StealthConfig::default()).await
}
pub async fn launch_visible() -> Result<Self> {
Self::launch_with_config(StealthConfig::visible()).await
}
pub async fn launch_debug() -> Result<Self> {
Self::launch_with_config(StealthConfig::debug()).await
}
pub async fn launch_with(configure: impl FnOnce(&mut StealthConfig)) -> Result<Self> {
let mut config = StealthConfig::default();
configure(&mut config);
Self::launch_with_config(config).await
}
pub async fn launch_with_config(config: StealthConfig) -> Result<Self> {
let config = Arc::new(config);
let launch_config = Arc::clone(&config);
let mut prepared =
tokio::task::spawn_blocking(move || prepare_browser_launch(launch_config))
.await
.map_err(|error| {
Error::Launch(format!("Browser launch worker failed: {}", error))
})??;
let spawned = prepared.child.is_some();
let transport = match prepared.child.take() {
Some(child) => {
let proxy_auth = match (&config.proxy_username, &config.proxy_password) {
(Some(username), Some(password)) => Some((username.clone(), password.clone())),
_ => None,
};
Transport::new_with_options(
child,
&prepared.ws_url,
proxy_auth,
config.cdp_timeout,
config.strip_x_client_data,
)
.await?
}
None => {
Transport::connect_with_options(
&prepared.ws_url,
config.cdp_timeout,
config.filter_cdp,
config.strip_x_client_data,
)
.await?
}
};
let connection = Connection::new(transport);
let version = connection.version().await?;
tracing::info!("Connected to Chrome: {}", version.product);
let fingerprint = prepared
.fingerprint
.take()
.ok_or_else(|| Error::Launch("Browser launch lost its fingerprint".into()))?;
let user_data_dir = prepared.profile.browser_owned_path();
let evasion_script = build_evasion_script_for(&config, &fingerprint);
let mut browser = Self {
connection,
download_slot: Arc::new(tokio::sync::Semaphore::new(1)),
config,
user_data_dir,
reused: !spawned,
fingerprint: Some(fingerprint),
evasion_script,
};
if spawned && browser.config.geo_align && browser.config.timezone.is_none() {
browser.align_geo().await;
}
Ok(browser)
}
async fn align_geo(&mut self) {
let outcome = async {
let page = self.new_blank_page().await?;
let result = geo::lookup(&page).await;
let _ = self.close_tab(page.target_id()).await;
result
}
.await;
match outcome {
Ok(geo) => {
if let Some(fp) = self.fingerprint.as_mut() {
if let Some(timezone) = geo.timezone {
fp.timezone = timezone;
}
fp.languages = geo::languages_for_country(&geo.country);
self.evasion_script = build_evasion_script_for(&self.config, fp);
}
tracing::info!(
"Geo-aligned languages/timezone (IP country {})",
geo.country
);
}
Err(error) => {
tracing::warn!("Geo alignment failed, keeping random timezone: {}", error);
}
}
}
pub async fn connect(ws_url: &str) -> Result<Self> {
Self::connect_with_config(ws_url, StealthConfig::live()).await
}
pub async fn connect_with_config(ws_url: &str, config: StealthConfig) -> Result<Self> {
let config = Arc::new(config);
let transport = crate::cdp::transport::Transport::connect_with_options(
ws_url,
config.cdp_timeout,
config.filter_cdp,
config.strip_x_client_data && !config.live_session,
)
.await?;
let connection = Connection::new(transport);
let version = connection.version().await?;
tracing::info!("Connected to Chrome: {}", version.product);
let (fingerprint, evasion_script) = if config.live_session {
(None, String::new())
} else {
let fp = Fingerprint::resolve(config.user_agent.as_deref(), config.timezone.as_deref());
let script = build_evasion_script_for(&config, &fp);
(Some(fp), script)
};
Ok(Self {
connection,
download_slot: Arc::new(tokio::sync::Semaphore::new(1)),
config,
user_data_dir: None,
reused: true,
fingerprint,
evasion_script,
})
}
pub async fn connect_port(port: u16) -> Result<Self> {
let ws_url = discover_browser_ws(port).await?;
Self::connect(&ws_url).await
}
pub async fn connect_port_with_config(port: u16, config: StealthConfig) -> Result<Self> {
let ws_url = discover_browser_ws(port).await?;
Self::connect_with_config(&ws_url, config).await
}
async fn setup_session(&self, session: &crate::cdp::Session, fresh: bool) -> Result<()> {
session.monitor_network(fresh).await?;
session.page_enable().await?;
let proxy_auth =
self.config.proxy_username.is_some() && self.config.proxy_password.is_some();
if proxy_auth || self.config.strip_x_client_data {
session
.fetch_enable_interception(vec![], proxy_auth)
.await?;
}
if self.config.ignore_cert_errors {
session.set_ignore_cert_errors(true).await?;
}
if !self.config.live_session {
if let Some(ref fp) = self.fingerprint {
session.set_user_agent_full(ua_override_for(fp)).await?;
if let Err(error) = session.set_timezone_override(&fp.timezone).await {
tracing::warn!("Timezone override failed for {}: {}", fp.timezone, error);
}
}
session
.add_script_to_evaluate_on_new_document(&self.evasion_script)
.await?;
}
Ok(())
}
pub async fn new_page(&self, url: &str) -> Result<Page> {
let target_id = self
.connection
.create_target("about:blank", None, None)
.await?;
let session = self.connection.attach_to_target(&target_id).await?;
self.setup_session(&session, true).await?;
let nav_result = session.navigate(url, None).await?;
if let Some(error) = nav_result.error_text {
return Err(Error::Navigation(error));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
Ok(Page::new(session, Arc::clone(&self.config)))
}
pub async fn new_blank_page(&self) -> Result<Page> {
let target_id = self
.connection
.create_target("about:blank", None, None)
.await?;
let session = self.connection.attach_to_target(&target_id).await?;
self.setup_session(&session, true).await?;
Ok(Page::new(session, Arc::clone(&self.config)))
}
pub async fn version(&self) -> Result<String> {
let v = self.connection.version().await?;
Ok(v.product)
}
pub async fn tabs(&self) -> Result<Vec<TabInfo>> {
let targets = self.connection.get_targets().await?;
Ok(targets
.into_iter()
.filter(|t| t.r#type == "page")
.map(|t| TabInfo {
id: t.target_id,
title: t.title,
url: t.url,
})
.collect())
}
pub async fn attach_page(&self, target_id: &str) -> Result<Page> {
let session = self.connection.attach_to_target(target_id).await?;
self.setup_session(&session, false).await?;
Ok(Page::new(session, Arc::clone(&self.config)))
}
pub async fn activate_tab(&self, target_id: &str) -> Result<()> {
self.connection.activate_target(target_id).await
}
pub async fn close_tab(&self, target_id: &str) -> Result<()> {
self.connection.close_target(target_id).await?;
Ok(())
}
pub async fn close(self) -> Result<()> {
if self.reused {
self.connection.transport().close().await?;
} else {
self.connection.close().await?;
}
if let Some(ref dir) = self.user_data_dir {
remove_dir_if_exists(dir)?;
}
Ok(())
}
pub async fn disconnect(self) -> Result<()> {
self.connection.transport().close().await
}
}
impl Drop for Browser {
fn drop(&mut self) {
if let Some(ref dir) = self.user_data_dir {
if let Err(error) = remove_dir_if_exists(dir) {
tracing::warn!(
"Failed to remove temporary browser profile while dropping browser: {}",
error
);
}
}
}
}