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")]
55pub use slider::{GapMethod, ImageSource, SliderConfig, SliderGap, SliderResult, SuccessCheck};
56pub use storage::{OriginStorage, StorageState};
57pub use tab::{
58 ContextOverride, Cookie, CookieParam, DialogInfo, DownloadInfo, GetOptions, ImageFormat,
59 ListenStream, LoadMode, PageRect, ShotOpts, Tab,
60};
61pub use websocket::{WsDirection, WsFilter, WsListener, WsMessage, WsSocket, WsSteps};
62
63pub struct Browser {
65 conn: Connection,
66 child: Mutex<Option<crate::transport::Child>>,
67 options: Arc<BrowserOptions>,
68 tabs: Mutex<Vec<Tab>>,
69 profile_dir: PathBuf,
70 profile_is_temp: bool,
71}
72
73impl Browser {
74 pub async fn launch_default() -> Result<Self> {
79 Self::launch(BrowserOptions::default()).await
80 }
81
82 pub async fn launch(opts: BrowserOptions) -> Result<Self> {
84 let mut opts = opts;
85 let Launched {
86 child,
87 writer,
88 reader,
89 profile_dir,
90 profile_is_temp,
91 } = launcher::launch(&opts).await?;
92
93 let conn = Connection::from_pipe(writer, reader);
94 init_session(&conn, &mut opts).await?;
95
96 let browser = Self {
97 conn,
98 child: Mutex::new(Some(child)),
99 options: Arc::new(opts),
100 tabs: Mutex::new(Vec::new()),
101 profile_dir,
102 profile_is_temp,
103 };
104
105 let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
107 browser.tabs.lock().await.push(tab);
108
109 Ok(browser)
110 }
111
112 pub async fn connect(ws_url: &str) -> Result<Self> {
120 Self::connect_with(ws_url, BrowserOptions::default()).await
121 }
122
123 pub async fn connect_with(ws_url: &str, opts: BrowserOptions) -> Result<Self> {
126 let mut opts = opts;
127 let ws = crate::transport::ws_connect(ws_url).await?;
128 let conn = Connection::from_ws(ws);
129 init_session(&conn, &mut opts).await?;
130
131 let browser = Self {
132 conn,
133 child: Mutex::new(None),
134 options: Arc::new(opts),
135 tabs: Mutex::new(Vec::new()),
136 profile_dir: PathBuf::new(),
137 profile_is_temp: false,
138 };
139
140 let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
141 browser.tabs.lock().await.push(tab);
142
143 Ok(browser)
144 }
145
146 pub async fn close_remote(&self) -> Result<()> {
148 self.conn
149 .fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}))
150 }
151
152 pub async fn new_tab(&self, url: Option<&str>) -> Result<Tab> {
154 let tab = Tab::open(self.conn.clone(), &self.options).await?;
155 if let Some(u) = url {
156 tab.get(u).await?;
157 }
158 self.tabs.lock().await.push(tab.clone());
159 Ok(tab)
160 }
161
162 pub async fn new_tab_with(&self, overrides: &ContextOverride) -> Result<Tab> {
167 let merged = overrides.merge_into((*self.options).clone());
168 let tab = Tab::open(self.conn.clone(), &merged).await?;
169 self.tabs.lock().await.push(tab.clone());
170 Ok(tab)
171 }
172
173 pub async fn latest_tab(&self) -> Result<Tab> {
175 self.tabs
176 .lock()
177 .await
178 .last()
179 .cloned()
180 .ok_or_else(|| Error::Other("没有可用标签".into()))
181 }
182
183 pub async fn get_tab(&self, index: usize) -> Result<Tab> {
185 self.tabs
186 .lock()
187 .await
188 .get(index)
189 .cloned()
190 .ok_or_else(|| Error::Other(format!("标签索引越界: {index}")))
191 }
192
193 pub async fn tab_count(&self) -> usize {
195 self.tabs.lock().await.len()
196 }
197
198 pub async fn quit(&self) -> Result<()> {
200 if let Some(mut child) = self.child.lock().await.take() {
201 let _ = self
202 .conn
203 .fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}));
204 tokio::select! {
205 _ = child.wait() => {}
206 _ = tokio::time::sleep(Duration::from_secs(3)) => {
207 let _ = child.kill().await;
208 }
209 }
210 }
211 if self.profile_is_temp {
212 let _ = tokio::fs::remove_dir_all(&self.profile_dir).await;
213 }
214 Ok(())
215 }
216}
217
218async fn init_session(conn: &Connection, opts: &mut BrowserOptions) -> Result<()> {
221 let prefs = opts.collect_firefox_prefs();
224 let mut enable_params = json!({ "attachToDefaultContext": false });
225 if !prefs.is_empty() {
226 let user_prefs: Vec<serde_json::Value> = prefs
227 .into_iter()
228 .map(|(name, value)| json!({ "name": name, "value": value }))
229 .collect();
230 enable_params["userPrefs"] = json!(user_prefs);
231 }
232 conn.send("Browser.enable", enable_params, None).await?;
233
234 if opts.mask_ua && opts.fingerprint.user_agent.is_none() {
238 if let Ok(info) = conn.send("Browser.getInfo", json!({}), None).await {
239 if let Some(ua) = info.get("userAgent").and_then(|v| v.as_str()) {
240 if let Some(cleaned) = clean_camoufox_ua(ua) {
241 tracing::debug!(to = %cleaned, "补环境:屏蔽 Camoufox UA 令牌");
242 opts.fingerprint.user_agent = Some(cleaned);
243 }
244 }
245 }
246 }
247 Ok(())
248}
249
250fn clean_camoufox_ua(ua: &str) -> Option<String> {
257 const TOKEN: &str = "Camoufox/";
258 let idx = ua.find(TOKEN)?;
259 let digits = |s: &str| -> Option<String> {
260 let d: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
261 if d.is_empty() { None } else { Some(d) }
262 };
263 let major = ua
264 .find("rv:")
265 .and_then(|i| digits(&ua[i + 3..]))
266 .or_else(|| digits(&ua[idx + TOKEN.len()..]));
267 let prefix = &ua[..idx];
268 Some(match major {
269 Some(m) => format!("{prefix}Firefox/{m}.0"),
270 None => format!("{prefix}Firefox"),
271 })
272}
273
274impl Drop for Browser {
275 fn drop(&mut self) {
282 if let Ok(mut guard) = self.child.try_lock() {
283 if let Some(mut child) = guard.take() {
284 let _ = child.start_kill();
285 }
286 }
287 if self.profile_is_temp {
288 let _ = std::fs::remove_dir_all(&self.profile_dir);
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::clean_camoufox_ua;
296
297 #[test]
298 fn masks_camoufox_token_to_firefox() {
299 let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Camoufox/150.0.2-beta.25";
300 assert_eq!(
301 clean_camoufox_ua(ua).as_deref(),
302 Some(
303 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0"
304 )
305 );
306 }
307
308 #[test]
309 fn major_falls_back_to_token_when_no_rv() {
310 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Camoufox/133.1";
311 assert_eq!(
312 clean_camoufox_ua(ua).as_deref(),
313 Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/133.0")
314 );
315 }
316
317 #[test]
318 fn leaves_clean_firefox_ua_untouched() {
319 let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
320 assert_eq!(clean_camoufox_ua(ua), None);
321 }
322}