1use std::path::PathBuf;
7use std::time::Duration;
8
9use serde_json::{Value, json};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum OsType {
14 Windows,
15 MacOS,
16 Linux,
17}
18
19impl OsType {
20 pub fn as_camoufox(&self) -> &'static str {
22 match self {
23 OsType::Windows => "windows",
24 OsType::MacOS => "macos",
25 OsType::Linux => "linux",
26 }
27 }
28}
29
30#[derive(Debug, Clone, Copy)]
32pub struct Geolocation {
33 pub latitude: f64,
34 pub longitude: f64,
35 pub accuracy: Option<f64>,
36}
37
38#[derive(Debug, Clone)]
40pub struct Proxy {
41 pub server: String,
42 pub username: Option<String>,
43 pub password: Option<String>,
44 pub bypass: Vec<String>,
45}
46
47impl Proxy {
48 pub fn new(server: impl Into<String>) -> Self {
50 Self {
51 server: server.into(),
52 username: None,
53 password: None,
54 bypass: Vec::new(),
55 }
56 }
57
58 pub fn auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
60 self.username = Some(user.into());
61 self.password = Some(pass.into());
62 self
63 }
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct Fingerprint {
69 pub user_agent: Option<String>,
70 pub platform: Option<String>,
71 pub locale: Option<String>,
72 pub timezone_id: Option<String>,
73 pub geolocation: Option<Geolocation>,
74 pub os: Option<OsType>,
75}
76
77impl Fingerprint {
78 pub fn is_empty(&self) -> bool {
80 self.user_agent.is_none()
81 && self.platform.is_none()
82 && self.locale.is_none()
83 && self.timezone_id.is_none()
84 && self.geolocation.is_none()
85 && self.os.is_none()
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct BrowserOptions {
92 pub binary_path: Option<PathBuf>,
94 pub user_data_dir: Option<PathBuf>,
96 pub headless: bool,
98 pub args: Vec<String>,
100 pub launch_timeout: Duration,
102 pub window_size: Option<(u32, u32)>,
104 pub proxy: Option<Proxy>,
106 pub fingerprint: Fingerprint,
108 pub humanize: bool,
110 pub ignore_https_errors: bool,
112 pub bypass_csp: bool,
114 pub humanize_max_time: Option<f64>,
116 pub block_webrtc: bool,
118 pub mask_ua: bool,
123 pub screen: Option<(u32, u32)>,
127 pub firefox_prefs: Vec<(String, Value)>,
129 pub camou_config: Vec<(String, Value)>,
133 pub download_path: Option<PathBuf>,
136}
137
138impl Default for BrowserOptions {
139 fn default() -> Self {
148 Self {
149 binary_path: None,
150 user_data_dir: None,
151 headless: false,
152 args: Vec::new(),
153 launch_timeout: Duration::from_secs(180),
154 window_size: None,
155 proxy: None,
156 fingerprint: Fingerprint::default(),
157 humanize: true,
158 ignore_https_errors: false,
159 bypass_csp: false,
160 humanize_max_time: None,
161 block_webrtc: true,
162 mask_ua: true,
163 screen: Some((1920, 1080)),
164 firefox_prefs: Vec::new(),
165 camou_config: Vec::new(),
166 download_path: None,
167 }
168 }
169}
170
171impl BrowserOptions {
172 pub fn new() -> Self {
174 Self::default()
175 }
176
177 pub fn headless(mut self, yes: bool) -> Self {
179 self.headless = yes;
180 self
181 }
182
183 pub fn binary_path(mut self, p: impl Into<PathBuf>) -> Self {
185 self.binary_path = Some(p.into());
186 self
187 }
188
189 pub fn user_data_dir(mut self, p: impl Into<PathBuf>) -> Self {
191 self.user_data_dir = Some(p.into());
192 self
193 }
194
195 pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
197 self.args.push(arg.into());
198 self
199 }
200
201 pub fn window_size(mut self, width: u32, height: u32) -> Self {
203 self.window_size = Some((width, height));
204 self
205 }
206
207 pub fn proxy(mut self, proxy: Proxy) -> Self {
209 self.proxy = Some(proxy);
210 self
211 }
212
213 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
215 self.fingerprint.user_agent = Some(ua.into());
216 self
217 }
218
219 pub fn locale(mut self, locale: impl Into<String>) -> Self {
221 self.fingerprint.locale = Some(locale.into());
222 self
223 }
224
225 pub fn timezone(mut self, tz: impl Into<String>) -> Self {
227 self.fingerprint.timezone_id = Some(tz.into());
228 self
229 }
230
231 pub fn platform(mut self, platform: impl Into<String>) -> Self {
233 self.fingerprint.platform = Some(platform.into());
234 self
235 }
236
237 pub fn os(mut self, os: OsType) -> Self {
239 self.fingerprint.os = Some(os);
240 self
241 }
242
243 pub fn geolocation(mut self, latitude: f64, longitude: f64) -> Self {
245 self.fingerprint.geolocation = Some(Geolocation {
246 latitude,
247 longitude,
248 accuracy: None,
249 });
250 self
251 }
252
253 pub fn humanize(mut self, yes: bool) -> Self {
255 self.humanize = yes;
256 self
257 }
258
259 pub fn ignore_https_errors(mut self, yes: bool) -> Self {
261 self.ignore_https_errors = yes;
262 self
263 }
264
265 pub fn bypass_csp(mut self, yes: bool) -> Self {
267 self.bypass_csp = yes;
268 self
269 }
270
271 pub fn humanize_max_time(mut self, seconds: f64) -> Self {
273 self.humanize = true;
274 self.humanize_max_time = Some(seconds);
275 self
276 }
277
278 pub fn block_webrtc(mut self, yes: bool) -> Self {
280 self.block_webrtc = yes;
281 self
282 }
283
284 pub fn add_pref(mut self, name: impl Into<String>, value: Value) -> Self {
286 self.firefox_prefs.push((name.into(), value));
287 self
288 }
289
290 pub fn mask_ua(mut self, yes: bool) -> Self {
292 self.mask_ua = yes;
293 self
294 }
295
296 pub fn screen(mut self, width: u32, height: u32) -> Self {
298 self.screen = Some((width, height));
299 self
300 }
301
302 pub fn raw_screen(mut self) -> Self {
304 self.screen = None;
305 self
306 }
307
308 pub fn add_camou_config(mut self, name: impl Into<String>, value: Value) -> Self {
310 self.camou_config.push((name.into(), value));
311 self
312 }
313
314 pub fn download_path(mut self, p: impl Into<PathBuf>) -> Self {
316 self.download_path = Some(p.into());
317 self
318 }
319
320 pub fn build_camou_config(&self) -> serde_json::Map<String, Value> {
326 let mut cfg = serde_json::Map::new();
327 if self.humanize {
329 cfg.insert("humanize".into(), Value::Bool(true));
330 if let Some(t) = self.humanize_max_time {
331 cfg.insert("humanize:maxTime".into(), json!(t));
332 }
333 cfg.insert("showcursor".into(), Value::Bool(false));
335 }
336 if let Some((w, h)) = self.screen {
338 let avail_top: u32 = 25; cfg.insert("screen.width".into(), json!(w));
340 cfg.insert("screen.height".into(), json!(h));
341 cfg.insert("screen.availWidth".into(), json!(w));
342 cfg.insert(
343 "screen.availHeight".into(),
344 json!(h.saturating_sub(avail_top)),
345 );
346 cfg.insert("screen.availTop".into(), json!(avail_top));
347 cfg.insert("screen.availLeft".into(), json!(0));
348 cfg.insert("screen.colorDepth".into(), json!(24));
349 cfg.insert("screen.pixelDepth".into(), json!(24));
350 }
351 for (k, v) in &self.camou_config {
353 cfg.insert(k.clone(), v.clone());
354 }
355 cfg
356 }
357
358 pub fn collect_firefox_prefs(&self) -> Vec<(String, Value)> {
360 let mut prefs: Vec<(String, Value)> = Vec::new();
361 if self.block_webrtc {
362 prefs.push((
363 "media.peerconnection.enabled".to_string(),
364 Value::Bool(false),
365 ));
366 }
367 if let Some(dir) = &self.download_path {
369 prefs.push(("browser.download.folderList".into(), json!(2)));
370 prefs.push((
371 "browser.download.dir".into(),
372 json!(dir.display().to_string()),
373 ));
374 prefs.push(("browser.download.useDownloadDir".into(), json!(true)));
375 prefs.push((
376 "browser.download.manager.showWhenStarting".into(),
377 json!(false),
378 ));
379 prefs.push(("browser.download.alwaysOpenPanel".into(), json!(false)));
380 prefs.push(("pdfjs.disabled".into(), json!(true)));
381 prefs.push((
382 "browser.helperApps.neverAsk.saveToDisk".into(),
383 json!(
384 "application/octet-stream,application/pdf,application/zip,application/x-zip-compressed,application/x-msdownload,application/msword,application/vnd.ms-excel,text/csv,text/plain,application/json,image/png,image/jpeg,application/x-binary,application/force-download"
385 ),
386 ));
387 }
388 prefs.extend(self.firefox_prefs.iter().cloned());
389 prefs
390 }
391
392 pub fn validate(&self) -> crate::Result<()> {
394 for a in &self.args {
395 let lower = a.trim_start_matches('-').to_ascii_lowercase();
396 if lower.starts_with("profile") || lower.starts_with("juggler") {
397 return Err(crate::Error::Other(format!(
398 "非法启动参数 `{a}`:`-profile`/`-juggler` 由库内部管理"
399 )));
400 }
401 }
402 Ok(())
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn builder_chains() {
412 let opts = BrowserOptions::new()
413 .headless(true)
414 .window_size(1280, 800)
415 .user_agent("UA/1.0")
416 .locale("zh-CN")
417 .timezone("Asia/Shanghai")
418 .os(OsType::MacOS)
419 .geolocation(31.23, 121.47);
420 assert!(opts.headless);
421 assert_eq!(opts.window_size, Some((1280, 800)));
422 assert_eq!(opts.fingerprint.user_agent.as_deref(), Some("UA/1.0"));
423 assert_eq!(opts.fingerprint.locale.as_deref(), Some("zh-CN"));
424 assert_eq!(opts.fingerprint.os, Some(OsType::MacOS));
425 assert!(!opts.fingerprint.is_empty());
426 }
427
428 #[test]
429 fn defaults_are_headful_and_stealth() {
430 let o = BrowserOptions::new();
431 assert!(!o.headless, "默认有头");
432 assert!(o.humanize, "默认开启拟人化");
433 assert!(o.block_webrtc, "默认阻断 WebRTC");
434 assert!(o.binary_path.is_none(), "默认自动定位浏览器");
435 assert!(
437 o.collect_firefox_prefs()
438 .iter()
439 .any(|(k, _)| k == "media.peerconnection.enabled")
440 );
441 assert!(o.fingerprint.locale.is_none() && o.fingerprint.timezone_id.is_none());
443 assert!(BrowserOptions::new().headless(true).headless);
445 }
446
447 #[test]
448 fn rejects_protected_args() {
449 let opts = BrowserOptions::new().add_arg("-profile /tmp/x");
450 assert!(opts.validate().is_err());
451 let opts = BrowserOptions::new().add_arg("--juggler-pipe");
452 assert!(opts.validate().is_err());
453 let ok = BrowserOptions::new().add_arg("--no-remote");
454 assert!(ok.validate().is_ok());
455 }
456}