pub mod actions;
pub mod cloudflare;
pub mod console;
pub mod download;
pub mod dump_env;
pub mod element;
pub mod frame;
pub mod handles;
pub mod interceptor;
pub mod keys;
pub mod listener;
pub mod screencast;
pub mod serve;
pub mod shadow;
#[cfg(feature = "slider")]
pub mod slider;
pub mod static_element;
pub mod storage;
pub mod tab;
pub mod websocket;
pub(crate) mod xpath;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tokio::sync::Mutex;
use crate::launcher::{self, BrowserOptions, Launched};
use crate::protocol::{BROWSER_CLOSE_MESSAGE_ID, Connection};
use crate::{Error, Result};
pub use actions::{Actions, MouseButton};
pub use console::{Console, ConsoleData, ConsoleFilter, ConsoleSteps};
pub use download::{DownloadMission, DownloadState, Downloads};
pub use dump_env::{EnvDump, EnvDumper, EnvProbe, EnvScope, EnvTarget};
pub use element::{Element, ElementRect, ElementWait};
pub use frame::Frame;
pub use handles::{Intercept, Listen, Scroll, SetTab, Wait, Window};
pub use interceptor::{InterceptedRequest, ResumeOptions};
pub use keys::{KeyInput, Keys};
pub use listener::{DataPacket, ListenFilter, RequestData, ResponseData};
pub use screencast::{Screencast, ScreencastMode};
pub use serve::BrowserServer;
pub use shadow::ShadowRoot;
#[cfg(feature = "slider")]
pub use slider::{GapMethod, ImageSource, SliderConfig, SliderGap, SliderResult, SuccessCheck};
pub use static_element::StaticElement;
pub use storage::{OriginStorage, StorageState};
pub use tab::{
ContextOverride, Cookie, CookieParam, DialogInfo, DownloadInfo, GetOptions, ImageFormat,
ListenStream, LoadMode, PageRect, ShotOpts, Tab,
};
pub use websocket::{WsDirection, WsFilter, WsListener, WsMessage, WsSocket, WsSteps};
pub struct Browser {
conn: Connection,
child: Mutex<Option<crate::transport::Child>>,
options: Arc<BrowserOptions>,
tabs: Mutex<Vec<Tab>>,
profile_dir: PathBuf,
profile_is_temp: bool,
}
impl Browser {
pub async fn launch_default() -> Result<Self> {
Self::launch(BrowserOptions::default()).await
}
pub async fn launch(opts: BrowserOptions) -> Result<Self> {
let mut opts = opts;
let Launched {
child,
writer,
reader,
profile_dir,
profile_is_temp,
} = launcher::launch(&opts).await?;
let conn = Connection::from_pipe(writer, reader);
init_session(&conn, &mut opts).await?;
let browser = Self {
conn,
child: Mutex::new(Some(child)),
options: Arc::new(opts),
tabs: Mutex::new(Vec::new()),
profile_dir,
profile_is_temp,
};
let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
browser.tabs.lock().await.push(tab);
Ok(browser)
}
pub async fn connect(ws_url: &str) -> Result<Self> {
Self::connect_with(ws_url, BrowserOptions::default()).await
}
pub async fn connect_with(ws_url: &str, opts: BrowserOptions) -> Result<Self> {
let mut opts = opts;
let ws = crate::transport::ws_connect(ws_url).await?;
let conn = Connection::from_ws(ws);
init_session(&conn, &mut opts).await?;
let browser = Self {
conn,
child: Mutex::new(None),
options: Arc::new(opts),
tabs: Mutex::new(Vec::new()),
profile_dir: PathBuf::new(),
profile_is_temp: false,
};
let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
browser.tabs.lock().await.push(tab);
Ok(browser)
}
pub async fn close_remote(&self) -> Result<()> {
self.conn
.fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}))
}
pub async fn new_tab(&self, url: Option<&str>) -> Result<Tab> {
let tab = Tab::open(self.conn.clone(), &self.options).await?;
if let Some(u) = url {
tab.get(u).await?;
}
self.tabs.lock().await.push(tab.clone());
Ok(tab)
}
pub async fn new_tab_with(&self, overrides: &ContextOverride) -> Result<Tab> {
let merged = overrides.merge_into((*self.options).clone());
let tab = Tab::open(self.conn.clone(), &merged).await?;
self.tabs.lock().await.push(tab.clone());
Ok(tab)
}
pub async fn latest_tab(&self) -> Result<Tab> {
self.tabs
.lock()
.await
.last()
.cloned()
.ok_or_else(|| Error::Other("没有可用标签".into()))
}
pub async fn get_tab(&self, index: usize) -> Result<Tab> {
self.tabs
.lock()
.await
.get(index)
.cloned()
.ok_or_else(|| Error::Other(format!("标签索引越界: {index}")))
}
pub async fn tab_count(&self) -> usize {
self.tabs.lock().await.len()
}
pub async fn quit(&self) -> Result<()> {
if let Some(mut child) = self.child.lock().await.take() {
let _ = self.conn.fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}));
tokio::select! {
_ = child.wait() => {}
_ = tokio::time::sleep(Duration::from_secs(3)) => {
let _ = child.kill().await;
}
}
}
if self.profile_is_temp {
let _ = tokio::fs::remove_dir_all(&self.profile_dir).await;
}
Ok(())
}
}
async fn init_session(conn: &Connection, opts: &mut BrowserOptions) -> Result<()> {
let prefs = opts.collect_firefox_prefs();
let mut enable_params = json!({ "attachToDefaultContext": false });
if !prefs.is_empty() {
let user_prefs: Vec<serde_json::Value> = prefs
.into_iter()
.map(|(name, value)| json!({ "name": name, "value": value }))
.collect();
enable_params["userPrefs"] = json!(user_prefs);
}
conn.send("Browser.enable", enable_params, None).await?;
if opts.mask_ua && opts.fingerprint.user_agent.is_none() {
if let Ok(info) = conn.send("Browser.getInfo", json!({}), None).await {
if let Some(ua) = info.get("userAgent").and_then(|v| v.as_str()) {
if let Some(cleaned) = clean_camoufox_ua(ua) {
tracing::debug!(to = %cleaned, "补环境:屏蔽 Camoufox UA 令牌");
opts.fingerprint.user_agent = Some(cleaned);
}
}
}
}
Ok(())
}
fn clean_camoufox_ua(ua: &str) -> Option<String> {
const TOKEN: &str = "Camoufox/";
let idx = ua.find(TOKEN)?;
let digits = |s: &str| -> Option<String> {
let d: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
if d.is_empty() { None } else { Some(d) }
};
let major = ua
.find("rv:")
.and_then(|i| digits(&ua[i + 3..]))
.or_else(|| digits(&ua[idx + TOKEN.len()..]));
let prefix = &ua[..idx];
Some(match major {
Some(m) => format!("{prefix}Firefox/{m}.0"),
None => format!("{prefix}Firefox"),
})
}
impl Drop for Browser {
fn drop(&mut self) {
if let Ok(mut guard) = self.child.try_lock() {
if let Some(mut child) = guard.take() {
let _ = child.start_kill();
}
}
if self.profile_is_temp {
let _ = std::fs::remove_dir_all(&self.profile_dir);
}
}
}
#[cfg(test)]
mod tests {
use super::clean_camoufox_ua;
#[test]
fn masks_camoufox_token_to_firefox() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Camoufox/150.0.2-beta.25";
assert_eq!(
clean_camoufox_ua(ua).as_deref(),
Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0")
);
}
#[test]
fn major_falls_back_to_token_when_no_rv() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Camoufox/133.1";
assert_eq!(
clean_camoufox_ua(ua).as_deref(),
Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/133.0")
);
}
#[test]
fn leaves_clean_firefox_ua_untouched() {
let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
assert_eq!(clean_camoufox_ua(ua), None);
}
}