1pub mod actions;
9pub mod cloudflare;
10pub mod console;
11pub mod download;
12pub mod dump_env;
13pub mod element;
14pub mod frame;
15pub mod handles;
16pub mod interceptor;
17pub mod listener;
18pub mod screencast;
19pub mod serve;
20pub mod shadow;
21#[cfg(feature = "slider")]
22pub mod slider;
23pub mod storage;
24pub mod tab;
25pub mod websocket;
26pub use crate::static_element;
28
29use std::path::PathBuf;
30use std::sync::Arc;
31use std::time::Duration;
32
33use serde_json::json;
34use tokio::sync::Mutex;
35
36use crate::launcher::{self, BrowserOptions, Launched};
37use crate::protocol::{BROWSER_CLOSE_MESSAGE_ID, Connection};
38use crate::{Error, Result};
39
40pub use crate::keys::{KeyInput, Keys};
41pub use crate::static_element::StaticElement;
42pub use actions::{Actions, MouseButton};
43pub use console::{Console, ConsoleData, ConsoleFilter, ConsoleSteps};
44pub use download::{DownloadMission, DownloadState, Downloads};
45pub use dump_env::{EnvDump, EnvDumper, EnvProbe, EnvScope, EnvTarget};
46pub use element::{Element, ElementRect, ElementWait};
47pub use frame::Frame;
48pub use handles::{Intercept, Listen, Scroll, SetTab, Wait, Window};
49pub use interceptor::{InterceptedRequest, ResumeOptions};
50pub use listener::{DataPacket, ListenFilter, RequestData, ResponseData};
51pub use screencast::{Screencast, ScreencastMode};
52pub use serve::BrowserServer;
53pub use shadow::ShadowRoot;
54#[cfg(feature = "slider")]
57pub use crate::slider::{
58 GapMethod, ImageSource, SliderConfig, SliderGap, SliderResult, SliderTab, SuccessCheck,
59};
60pub use storage::{OriginStorage, StorageState};
61pub use tab::{
62 ContextOverride, Cookie, CookieParam, DialogInfo, DownloadInfo, GetOptions, ImageFormat,
63 ListenStream, LoadMode, PageRect, ShotOpts, Tab,
64};
65pub use websocket::{WsDirection, WsFilter, WsListener, WsMessage, WsSocket, WsSteps};
66
67pub struct Browser {
69 conn: Connection,
70 child: Mutex<Option<crate::transport::Child>>,
71 options: Arc<BrowserOptions>,
72 tabs: Mutex<Vec<Tab>>,
73 profile_dir: PathBuf,
74 profile_is_temp: bool,
75}
76
77impl Browser {
78 pub async fn launch_default() -> Result<Self> {
83 Self::launch(BrowserOptions::default()).await
84 }
85
86 pub async fn launch(opts: BrowserOptions) -> Result<Self> {
88 let mut opts = opts;
89 let Launched {
90 child,
91 writer,
92 reader,
93 profile_dir,
94 profile_is_temp,
95 } = launcher::launch(&opts).await?;
96
97 let conn = Connection::from_pipe(writer, reader);
98 init_session(&conn, &mut opts).await?;
99
100 let browser = Self {
101 conn,
102 child: Mutex::new(Some(child)),
103 options: Arc::new(opts),
104 tabs: Mutex::new(Vec::new()),
105 profile_dir,
106 profile_is_temp,
107 };
108
109 let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
111 browser.tabs.lock().await.push(tab);
112
113 Ok(browser)
114 }
115
116 pub async fn connect(ws_url: &str) -> Result<Self> {
124 Self::connect_with(ws_url, BrowserOptions::default()).await
125 }
126
127 pub async fn connect_with(ws_url: &str, opts: BrowserOptions) -> Result<Self> {
130 let mut opts = opts;
131 let ws = crate::transport::ws_connect(ws_url).await?;
132 let conn = Connection::from_ws(ws);
133 init_session(&conn, &mut opts).await?;
134
135 let browser = Self {
136 conn,
137 child: Mutex::new(None),
138 options: Arc::new(opts),
139 tabs: Mutex::new(Vec::new()),
140 profile_dir: PathBuf::new(),
141 profile_is_temp: false,
142 };
143
144 let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
145 browser.tabs.lock().await.push(tab);
146
147 Ok(browser)
148 }
149
150 pub async fn close_remote(&self) -> Result<()> {
152 self.conn
153 .fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}))
154 }
155
156 pub async fn new_tab(&self, url: Option<&str>) -> Result<Tab> {
158 let tab = Tab::open(self.conn.clone(), &self.options).await?;
159 if let Some(u) = url {
160 tab.get(u).await?;
161 }
162 self.tabs.lock().await.push(tab.clone());
163 Ok(tab)
164 }
165
166 pub async fn new_tab_with(&self, overrides: &ContextOverride) -> Result<Tab> {
171 let merged = overrides.merge_into((*self.options).clone());
172 let tab = Tab::open(self.conn.clone(), &merged).await?;
173 self.tabs.lock().await.push(tab.clone());
174 Ok(tab)
175 }
176
177 pub async fn latest_tab(&self) -> Result<Tab> {
179 self.tabs
180 .lock()
181 .await
182 .last()
183 .cloned()
184 .ok_or_else(|| Error::Other("没有可用标签".into()))
185 }
186
187 pub async fn get_tab(&self, index: usize) -> Result<Tab> {
189 self.tabs
190 .lock()
191 .await
192 .get(index)
193 .cloned()
194 .ok_or_else(|| Error::Other(format!("标签索引越界: {index}")))
195 }
196
197 pub async fn tab_count(&self) -> usize {
199 self.tabs.lock().await.len()
200 }
201
202 pub async fn quit(&self) -> Result<()> {
204 if let Some(mut child) = self.child.lock().await.take() {
205 let _ = self
206 .conn
207 .fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}));
208 tokio::select! {
209 _ = child.wait() => {}
210 _ = tokio::time::sleep(Duration::from_secs(3)) => {
211 let _ = child.kill().await;
212 }
213 }
214 }
215 if self.profile_is_temp {
216 let _ = tokio::fs::remove_dir_all(&self.profile_dir).await;
217 }
218 Ok(())
219 }
220}
221
222async fn init_session(conn: &Connection, opts: &mut BrowserOptions) -> Result<()> {
225 let prefs = opts.collect_firefox_prefs();
228 let mut enable_params = json!({ "attachToDefaultContext": false });
229 if !prefs.is_empty() {
230 let user_prefs: Vec<serde_json::Value> = prefs
231 .into_iter()
232 .map(|(name, value)| json!({ "name": name, "value": value }))
233 .collect();
234 enable_params["userPrefs"] = json!(user_prefs);
235 }
236 conn.send("Browser.enable", enable_params, None).await?;
237
238 if opts.mask_ua && opts.fingerprint.user_agent.is_none() {
242 if let Ok(info) = conn.send("Browser.getInfo", json!({}), None).await {
243 if let Some(ua) = info.get("userAgent").and_then(|v| v.as_str()) {
244 if let Some(cleaned) = clean_camoufox_ua(ua) {
245 tracing::debug!(to = %cleaned, "补环境:屏蔽 Camoufox UA 令牌");
246 opts.fingerprint.user_agent = Some(cleaned);
247 }
248 }
249 }
250 }
251 Ok(())
252}
253
254fn clean_camoufox_ua(ua: &str) -> Option<String> {
261 const TOKEN: &str = "Camoufox/";
262 let idx = ua.find(TOKEN)?;
263 let digits = |s: &str| -> Option<String> {
264 let d: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
265 if d.is_empty() { None } else { Some(d) }
266 };
267 let major = ua
268 .find("rv:")
269 .and_then(|i| digits(&ua[i + 3..]))
270 .or_else(|| digits(&ua[idx + TOKEN.len()..]));
271 let prefix = &ua[..idx];
272 Some(match major {
273 Some(m) => format!("{prefix}Firefox/{m}.0"),
274 None => format!("{prefix}Firefox"),
275 })
276}
277
278impl Drop for Browser {
279 fn drop(&mut self) {
286 if let Ok(mut guard) = self.child.try_lock() {
287 if let Some(mut child) = guard.take() {
288 let _ = child.start_kill();
289 }
290 }
291 if self.profile_is_temp {
292 let _ = std::fs::remove_dir_all(&self.profile_dir);
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::clean_camoufox_ua;
300
301 #[test]
302 fn masks_camoufox_token_to_firefox() {
303 let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Camoufox/150.0.2-beta.25";
304 assert_eq!(
305 clean_camoufox_ua(ua).as_deref(),
306 Some(
307 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0"
308 )
309 );
310 }
311
312 #[test]
313 fn major_falls_back_to_token_when_no_rv() {
314 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Camoufox/133.1";
315 assert_eq!(
316 clean_camoufox_ua(ua).as_deref(),
317 Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/133.0")
318 );
319 }
320
321 #[test]
322 fn leaves_clean_firefox_ua_untouched() {
323 let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
324 assert_eq!(clean_camoufox_ua(ua), None);
325 }
326}