1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GpuProfile {
15 pub vendor: String,
17 pub renderer: String,
19 pub version: String,
21 pub shading_language_version: String,
23 pub unmasked_vendor: String,
25 pub unmasked_renderer: String,
27 pub extensions: Vec<String>,
29 pub params: Vec<(u32, serde_json::Value)>,
31 pub shader_precision: Vec<(u32, u32, [i32; 3])>,
33 #[serde(default)]
35 pub webgl1: Option<WebGL1Surface>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WebGL1Surface {
41 pub version: String,
42 pub shading_language_version: String,
43 pub extensions: Vec<String>,
44}
45
46impl Default for GpuProfile {
47 fn default() -> Self {
48 nvidia_rtx_3060_windows()
49 }
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub enum DeviceClass {
57 #[default]
58 Desktop,
59 MobileAndroid,
60 MobileIOS,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct MediaDeviceInfo {
68 pub device_id: String,
69 pub kind: String,
70 pub label: String,
71 pub group_id: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct StealthProfile {
82 pub user_agent: String,
84 pub browser_name: String,
85 pub browser_version: String,
86 pub os_name: String,
87 pub os_version: String,
88 pub platform: String,
89 pub vendor: String,
90 pub vendor_sub: String,
91 pub product_sub: String,
92 pub app_version: String,
93
94 pub screen_width: u32,
96 pub screen_height: u32,
97 pub screen_avail_width: u32,
98 pub screen_avail_height: u32,
99 pub screen_avail_top: u32,
100 pub screen_color_depth: u32,
101 pub device_pixel_ratio: f64,
102 pub cpu_cores: u8,
103 pub device_memory: u8,
104 pub max_touch_points: u8,
105
106 pub webgl_vendor: String,
108 pub webgl_renderer: String,
109 #[serde(default = "default_gpu_profile")]
110 pub gpu_profile: GpuProfile,
111
112 pub language: String,
114 pub languages: Vec<String>,
115 pub timezone: String,
116
117 #[serde(default = "default_cpu_architecture")]
119 pub cpu_architecture: String,
120 #[serde(default = "default_cpu_bitness")]
121 pub cpu_bitness: String,
122 #[serde(default)]
123 pub platform_version: String,
124 #[serde(default)]
125 pub ua_model: String,
126 #[serde(default)]
127 pub ua_wow64: bool,
128
129 #[serde(default)]
131 pub device_class: DeviceClass,
132 pub tls_impersonate: String,
133 pub connection_effective_type: String,
134 pub connection_rtt: u32,
135 pub connection_downlink: f64,
136
137 pub pdf_viewer_enabled: bool,
139 pub plugins_count: u32,
140 pub mime_types_count: u32,
141
142 pub canvas_seed: u64,
144 pub audio_seed: u64,
145 #[serde(default = "default_audio_sample_rate")]
146 pub audio_sample_rate: u32,
147
148 #[serde(default)]
150 pub has_platform_authenticator: bool,
151 #[serde(default = "default_true")]
152 pub conditional_mediation: bool,
153
154 #[serde(default)]
156 pub allow_http3: bool,
157
158 pub prefers_color_scheme: String,
160 pub pointer_type: String,
161 pub hover_capability: String,
162 #[serde(default = "default_color_gamut")]
163 pub color_gamut: String,
164
165 pub inner_width: u32,
167 pub inner_height: u32,
168 pub outer_width: u32,
169 pub outer_height: u32,
170
171 #[serde(default)]
173 pub proxy: Option<String>,
174
175 #[serde(default)]
177 pub media_devices: Vec<MediaDeviceInfo>,
178
179 #[serde(default = "default_true")]
181 pub enforce_csp: bool,
182}
183
184fn default_color_gamut() -> String {
185 "srgb".into()
186}
187fn default_true() -> bool {
188 true
189}
190fn default_gpu_profile() -> GpuProfile {
191 nvidia_rtx_3060_windows()
192}
193fn default_cpu_architecture() -> String {
194 "x86".into()
195}
196fn default_cpu_bitness() -> String {
197 "64".into()
198}
199fn default_audio_sample_rate() -> u32 {
200 44100
201}
202
203impl Default for StealthProfile {
204 fn default() -> Self {
205 chrome_148_windows()
206 }
207}
208
209impl StealthProfile {
212 pub fn validate(&self) -> Result<(), Vec<String>> {
214 let mut errors = Vec::new();
215
216 let ua_major = self.browser_version.split('.').next().unwrap_or("");
218 let chrome_form = format!("{ua_major}.0.0.0");
219 let firefox_form = format!("{ua_major}.0");
220 if !self.user_agent.contains(&chrome_form) && !self.user_agent.contains(&firefox_form) {
221 errors.push(format!(
222 "UA '{}' doesn't contain reduced major version '{}' or '{}'",
223 self.user_agent, chrome_form, firefox_form
224 ));
225 }
226
227 match self.os_name.as_str() {
229 "Windows" if self.platform != "Win32" => {
230 errors.push(format!("Windows OS but platform is '{}'", self.platform));
231 }
232 "macOS" if self.platform != "MacIntel" => {
233 errors.push(format!("macOS but platform is '{}'", self.platform));
234 }
235 "Linux" if !self.platform.starts_with("Linux") => {
236 errors.push(format!("Linux OS but platform is '{}'", self.platform));
237 }
238 _ => {}
239 }
240
241 if self.max_touch_points > 0 && self.screen_width > 1024 && self.pointer_type == "fine" {
243 errors.push("Touch points > 0 but desktop pointer type".into());
244 }
245
246 if self.webgl_renderer.contains("NVIDIA") && !self.webgl_vendor.contains("NVIDIA") {
248 errors.push("WebGL renderer is NVIDIA but vendor doesn't match".into());
249 }
250 if self.webgl_renderer.contains("Intel") && !self.webgl_vendor.contains("Intel") {
251 errors.push("WebGL renderer is Intel but vendor doesn't match".into());
252 }
253 if self.webgl_renderer.contains("Apple") && !self.webgl_vendor.contains("Apple") {
254 errors.push("WebGL renderer is Apple but vendor doesn't match".into());
255 }
256
257 if self.webgl_renderer.contains("Apple")
259 && !matches!(self.os_name.as_str(), "macOS" | "iOS")
260 {
261 errors.push("Apple GPU on non-Apple OS".into());
262 }
263
264 if self.screen_width == 0 || self.screen_height == 0 {
266 errors.push("Screen dimensions cannot be zero".into());
267 }
268 if self.inner_width > self.screen_width {
269 errors.push("inner_width > screen_width".into());
270 }
271 if self.outer_width < self.inner_width {
272 errors.push("outer_width < inner_width".into());
273 }
274
275 if self.cpu_cores == 0 || self.cpu_cores > 128 {
277 errors.push(format!("Unrealistic cpu_cores: {}", self.cpu_cores));
278 }
279 if self.device_memory == 0 && self.os_name != "iOS" {
280 errors.push(format!("Unrealistic device_memory: {}", self.device_memory));
281 }
282
283 if !self.languages.contains(&self.language) {
285 errors.push(format!(
286 "language '{}' not in languages {:?}",
287 self.language, self.languages
288 ));
289 }
290
291 if !matches!(self.cpu_architecture.as_str(), "x86" | "arm" | "") {
293 errors.push(format!(
294 "cpu_architecture must be 'x86', 'arm', or '' (got '{}')",
295 self.cpu_architecture
296 ));
297 }
298 if !matches!(self.cpu_bitness.as_str(), "64" | "32") {
299 errors.push(format!(
300 "cpu_bitness must be '64' or '32' (got '{}')",
301 self.cpu_bitness
302 ));
303 }
304 if self.ua_wow64 && (self.os_name != "Windows" || self.cpu_bitness != "32") {
305 errors.push(format!(
306 "ua_wow64=true requires os_name=Windows and cpu_bitness=32 (got {} / {})",
307 self.os_name, self.cpu_bitness
308 ));
309 }
310 if self.os_name == "Linux" && !self.platform_version.is_empty() {
311 errors.push(format!(
312 "Chrome on Linux must report empty platform_version (got '{}')",
313 self.platform_version
314 ));
315 }
316 if self.cpu_architecture == "arm"
317 && !matches!(
318 self.os_name.as_str(),
319 "macOS" | "Android" | "ChromeOS" | "iOS"
320 )
321 {
322 errors.push(format!(
323 "cpu_architecture=arm only on macOS/Android/ChromeOS/iOS (got '{}')",
324 self.os_name
325 ));
326 }
327 if !self.ua_model.is_empty() && self.max_touch_points == 0 {
328 errors.push(format!(
329 "ua_model='{}' on a desktop (max_touch_points=0) profile",
330 self.ua_model
331 ));
332 }
333 if !matches!(self.audio_sample_rate, 44100 | 48000 | 96000 | 192000) {
334 errors.push(format!(
335 "audio_sample_rate must be one of {{44100, 48000, 96000, 192000}} (got {})",
336 self.audio_sample_rate
337 ));
338 }
339
340 if errors.is_empty() {
341 Ok(())
342 } else {
343 Err(errors)
344 }
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352pub enum Handedness {
353 Right,
354 Left,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359pub enum ScrollStyle {
360 Trackpad,
361 Wheel,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct BehaviorProfile {
368 #[serde(default = "default_behavior_seed")]
369 pub seed: u64,
370 #[serde(default = "default_handedness")]
371 pub handedness: Handedness,
372 #[serde(default = "default_mouse_dpi")]
373 pub mouse_dpi: u16,
374 #[serde(default = "default_typing_wpm_mean")]
375 pub typing_wpm_mean: f32,
376 #[serde(default = "default_typing_wpm_sigma")]
377 pub typing_wpm_sigma: f32,
378 #[serde(default = "default_scroll_style")]
379 pub scroll_style: ScrollStyle,
380 #[serde(default = "default_fitts_b")]
381 pub fitts_b: f32,
382}
383
384fn default_behavior_seed() -> u64 {
385 rand::random::<u64>()
386}
387fn default_handedness() -> Handedness {
388 Handedness::Right
389}
390fn default_mouse_dpi() -> u16 {
391 1600
392}
393fn default_typing_wpm_mean() -> f32 {
394 50.0
395}
396fn default_typing_wpm_sigma() -> f32 {
397 15.0
398}
399fn default_scroll_style() -> ScrollStyle {
400 ScrollStyle::Trackpad
401}
402fn default_fitts_b() -> f32 {
403 166.0
404}
405
406impl Default for BehaviorProfile {
407 fn default() -> Self {
408 Self {
409 seed: default_behavior_seed(),
410 handedness: default_handedness(),
411 mouse_dpi: default_mouse_dpi(),
412 typing_wpm_mean: default_typing_wpm_mean(),
413 typing_wpm_sigma: default_typing_wpm_sigma(),
414 scroll_style: default_scroll_style(),
415 fitts_b: default_fitts_b(),
416 }
417 }
418}
419
420impl BehaviorProfile {
421 pub fn rng_for(&self, salt: u64) -> rand_chacha::ChaCha20Rng {
423 use rand_chacha::rand_core::SeedableRng;
424 let combined = self
425 .seed
426 .wrapping_mul(0x9E3779B97F4A7C15)
427 .wrapping_add(salt);
428 rand_chacha::ChaCha20Rng::seed_from_u64(combined)
429 }
430}
431
432#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
434pub struct MousePoint {
435 pub t_ms: f32,
436 pub x: f32,
437 pub y: f32,
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
442pub struct KeystrokeTiming {
443 pub ch: char,
444 pub dwell_ms: f32,
445 pub flight_ms: f32,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
450pub struct WheelTick {
451 pub t_ms: f32,
452 pub delta_y: f32,
453 pub mode: u32,
454}
455
456struct Stroke {
459 amplitude: f32,
460 sigma: f32,
461 mu: f32,
462 t0: f32,
463 theta: f32,
464}
465
466fn integrate_x(strokes: &[Stroke], t: f32) -> f32 {
467 strokes
468 .iter()
469 .map(|s| {
470 let dt = t - s.t0;
471 if dt <= 0.0 {
472 return 0.0;
473 }
474 let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
475 let cdf = 0.5 * (1.0 + erf(z));
476 s.amplitude * cdf * s.theta.cos()
477 })
478 .sum()
479}
480
481fn integrate_y(strokes: &[Stroke], t: f32) -> f32 {
482 strokes
483 .iter()
484 .map(|s| {
485 let dt = t - s.t0;
486 if dt <= 0.0 {
487 return 0.0;
488 }
489 let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
490 let cdf = 0.5 * (1.0 + erf(z));
491 s.amplitude * cdf * s.theta.sin()
492 })
493 .sum()
494}
495
496fn erf(x: f32) -> f32 {
498 let sign = x.signum();
499 let x = x.abs();
500 let a1 = 0.254_829_6;
501 let a2 = -0.284_496_72;
502 let a3 = 1.421_413_8;
503 let a4 = -1.453_152_1;
504 let a5 = 1.061_405_4;
505 let p = 0.3275911;
506 let t = 1.0 / (1.0 + p * x);
507 let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
508 sign * y
509}
510
511pub fn mouse_trajectory(
513 from: (f32, f32),
514 to: (f32, f32),
515 target_w: f32,
516 profile: &BehaviorProfile,
517) -> Vec<MousePoint> {
518 let mut rng = profile
519 .rng_for(((from.0 as u64) << 32) | (from.1 as u64) ^ ((to.0 as u64) << 16) ^ (to.1 as u64));
520 mouse_trajectory_with_rng(from, to, target_w, profile, &mut rng)
521}
522
523pub fn mouse_trajectory_with_rng<R: rand::Rng>(
525 from: (f32, f32),
526 to: (f32, f32),
527 target_w: f32,
528 profile: &BehaviorProfile,
529 rng: &mut R,
530) -> Vec<MousePoint> {
531 use rand_distr::{Distribution, LogNormal, Normal};
532
533 let dx = to.0 - from.0;
534 let dy = to.1 - from.1;
535 let distance = (dx * dx + dy * dy).sqrt().max(1.0);
536 let target_w = target_w.max(1.0);
537
538 let id_bits = ((distance / target_w) + 1.0).log2();
539 let n_strokes = ((1.3 * id_bits).round() as usize).clamp(2, 7);
540
541 let total_ms = 230.0 + profile.fitts_b * id_bits;
542
543 let mut amplitudes: Vec<f32> = Vec::with_capacity(n_strokes);
544 let primary = 0.85 * distance;
545 amplitudes.push(primary);
546 let remaining = distance - primary;
547 let per_corrective = remaining / (n_strokes - 1).max(1) as f32;
548 for _ in 1..n_strokes {
549 let jitter: f32 = Normal::new(0.0_f32, per_corrective * 0.15)
550 .ok()
551 .map_or(0.0, |d| d.sample(rng));
552 amplitudes.push((per_corrective + jitter).max(1.0));
553 }
554
555 let sigma_dist = Normal::new(0.25_f32, 0.05).ok();
556 let mu_dist = Normal::new(-1.6_f32, 0.2).ok();
557 let onset_dist = LogNormal::new(90.0_f32.ln(), 0.3).ok();
558 let theta_dist = Normal::new(0.0_f32, 8.0_f32.to_radians()).ok();
559
560 let target_angle = dy.atan2(dx);
561 let mut strokes: Vec<Stroke> = Vec::with_capacity(n_strokes);
562 let mut t0 = 0.0_f32;
563 for (i, amp) in amplitudes.iter().enumerate() {
564 let sigma = sigma_dist
565 .as_ref()
566 .map_or(0.25, |d| d.sample(rng).clamp(0.15, 0.40));
567 let mu = mu_dist.as_ref().map_or(-1.6, |d| d.sample(rng));
568 let jitter = theta_dist.as_ref().map_or(0.0, |d| d.sample(rng));
569 let theta = if i == 0 {
570 target_angle + jitter
571 } else {
572 target_angle + jitter * 1.5
573 };
574 strokes.push(Stroke {
575 amplitude: *amp,
576 sigma,
577 mu,
578 t0,
579 theta,
580 });
581 t0 += onset_dist.as_ref().map_or(90.0, |d| d.sample(rng));
582 }
583
584 let dt_ms = 8.0_f32;
585 let n_samples = (total_ms / dt_ms).ceil() as usize + 1;
586 let mut points: Vec<MousePoint> = Vec::with_capacity(n_samples);
587
588 let tremor_dist = Normal::new(0.0_f32, 1.5).ok();
589 let mut tremor_x = 0.0_f32;
590 let mut tremor_y = 0.0_f32;
591 let tremor_alpha = 0.3_f32;
592
593 for i in 0..n_samples {
594 let t = (i as f32) * dt_ms;
595
596 let tx = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
597 let ty = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
598 tremor_x = tremor_alpha * tremor_x + (1.0 - tremor_alpha) * tx;
599 tremor_y = tremor_alpha * tremor_y + (1.0 - tremor_alpha) * ty;
600
601 let x = from.0 + integrate_x(&strokes, t) + tremor_x;
602 let y = from.1 + integrate_y(&strokes, t) + tremor_y;
603 points.push(MousePoint { t_ms: t, x, y });
604 }
605
606 if points.len() >= 2 {
608 let n = points.len();
609 let last = &points[n - 1];
610 let res_x = to.0 - last.x;
611 let res_y = to.1 - last.y;
612 let tail = 15.min(n - 1);
613 let start = n - tail - 1;
614 for (k, p) in points.iter_mut().enumerate().skip(start) {
615 let u = (k - start) as f32 / tail as f32;
616 let s = u * u * (3.0 - 2.0 * u);
617 p.x += res_x * s;
618 p.y += res_y * s;
619 }
620 if let Some(last) = points.last_mut() {
621 last.x = to.0;
622 last.y = to.1;
623 }
624 } else if let Some(last) = points.last_mut() {
625 last.x = to.0;
626 last.y = to.1;
627 }
628 points
629}
630
631fn bigram_ratio(prev: char, cur: char) -> f32 {
634 let key = (
635 prev.to_ascii_lowercase() as u8,
636 cur.to_ascii_lowercase() as u8,
637 );
638 match key {
639 (b't', b'h')
640 | (b'h', b'e')
641 | (b'i', b'n')
642 | (b'a', b'n')
643 | (b'o', b'n')
644 | (b'a', b't')
645 | (b'i', b's')
646 | (b'i', b't')
647 | (b'o', b'r')
648 | (b'o', b'f') => 0.7,
649 (b'e', b'd')
650 | (b'u', b'n')
651 | (b'r', b'e')
652 | (b'e', b'r')
653 | (b'e', b'n')
654 | (b'n', b'd')
655 | (b'e', b's')
656 | (b't', b'e')
657 | (b'a', b'l')
658 | (b'a', b'r') => 1.4,
659 (a, b) if a == b => 2.0,
660 _ => 1.0,
661 }
662}
663
664pub fn keystroke_timings(text: &str, profile: &BehaviorProfile) -> Vec<KeystrokeTiming> {
666 let mut rng = profile.rng_for(0xCAFEBABE ^ text.len() as u64);
667 keystroke_timings_with_rng(text, profile, &mut rng)
668}
669
670pub fn keystroke_timings_with_rng<R: rand::Rng>(
672 text: &str,
673 profile: &BehaviorProfile,
674 rng: &mut R,
675) -> Vec<KeystrokeTiming> {
676 use rand_distr::{Distribution, LogNormal};
677
678 let ms_per_char = 60_000.0 / (profile.typing_wpm_mean * 5.0);
679 let flight_median = (ms_per_char - 95.0).max(40.0);
680 let flight_dist = LogNormal::new(flight_median.ln(), 0.55).ok();
681 let dwell_dist = LogNormal::new(95.0_f32.ln(), 0.30).ok();
682
683 let mut out = Vec::with_capacity(text.len());
684 let mut prev_ch: Option<char> = None;
685 for ch in text.chars() {
686 let dwell = dwell_dist
687 .as_ref()
688 .map_or(95.0, |d| d.sample(rng).clamp(40.0, 400.0));
689 let flight = if let Some(p) = prev_ch {
690 let ratio = bigram_ratio(p, ch);
691 flight_dist
692 .as_ref()
693 .map_or(130.0, |d| (d.sample(rng) * ratio).clamp(20.0, 1000.0))
694 } else {
695 0.0
696 };
697 out.push(KeystrokeTiming {
698 ch,
699 dwell_ms: dwell,
700 flight_ms: flight,
701 });
702 prev_ch = Some(ch);
703 }
704 out
705}
706
707pub fn wheel_burst(target_dy: f32, profile: &BehaviorProfile) -> Vec<WheelTick> {
711 let mut rng = profile.rng_for(0xDEAD_BEEF ^ target_dy.to_bits() as u64);
712 wheel_burst_with_rng(target_dy, profile, &mut rng)
713}
714
715pub fn wheel_burst_with_rng<R: rand::RngExt>(
717 target_dy: f32,
718 profile: &BehaviorProfile,
719 rng: &mut R,
720) -> Vec<WheelTick> {
721 use rand_distr::{Distribution, LogNormal};
722
723 let dir = if target_dy >= 0.0 { 1.0 } else { -1.0 };
724 let abs_dy = target_dy.abs().max(1.0);
725
726 match profile.scroll_style {
727 ScrollStyle::Trackpad => {
728 let v0 = LogNormal::new((abs_dy / 8.0).ln(), 0.3)
729 .ok()
730 .map_or(abs_dy / 8.0, |d| d.sample(rng));
731 let decay = 0.94 + rng.random_range(0.0_f32..0.04);
732 let mut t = 0.0_f32;
733 let mut v = v0;
734 let mut ticks = Vec::new();
735 let mut accumulated = 0.0_f32;
736 while v > 0.5 && accumulated < abs_dy * 1.1 {
737 let step = (v.min(abs_dy - accumulated)).max(0.5);
738 ticks.push(WheelTick {
739 t_ms: t,
740 delta_y: step * dir,
741 mode: 0,
742 });
743 accumulated += step;
744 t += 16.0;
745 v *= decay;
746 }
747 ticks
748 }
749 ScrollStyle::Wheel => {
750 let notches = ((abs_dy / 100.0).round() as u32).max(1);
751 let interval_dist = LogNormal::new(180.0_f32.ln(), 0.4).ok();
752 let mut t = 0.0_f32;
753 let mut ticks = Vec::with_capacity(notches as usize);
754 for _ in 0..notches {
755 ticks.push(WheelTick {
756 t_ms: t,
757 delta_y: 100.0 * dir,
758 mode: 0,
759 });
760 t += interval_dist.as_ref().map_or(180.0, |d| d.sample(rng));
761 }
762 ticks
763 }
764 }
765}
766
767fn common_params_desktop() -> Vec<(u32, serde_json::Value)> {
770 use serde_json::json;
771 vec![
772 (0x0D33, json!(16384)),
773 (0x851C, json!(16384)),
774 (0x84E8, json!(16384)),
775 (0x8073, json!(2048)),
776 (0x8869, json!(16)),
777 (0x8DFB, json!(1024)),
778 (0x8DFD, json!(15)),
779 (0x8DFC, json!(1024)),
780 (0x8872, json!(16)),
781 (0x8B4D, json!(16)),
782 (0x8B4C, json!(32)),
783 (0x846D, json!([1.0, 8190.0])),
784 (0x846E, json!([1.0, 1.0])),
785 (0x0D3A, json!([32767, 32767])),
786 (0x0D56, json!(8)),
787 (0x0D57, json!(8)),
788 (0x80AA, json!(2)),
789 (0x80A9, json!(4)),
790 ]
791}
792
793fn standard_shader_precision() -> Vec<(u32, u32, [i32; 3])> {
794 let mut out = Vec::with_capacity(12);
795 for &shader_type in &[0x8B31u32, 0x8B30u32] {
796 out.push((shader_type, 0x8DF0, [127, 127, 23]));
797 out.push((shader_type, 0x8DF1, [127, 127, 23]));
798 out.push((shader_type, 0x8DF2, [127, 127, 23]));
799 out.push((shader_type, 0x8DF3, [15, 14, 0]));
800 out.push((shader_type, 0x8DF4, [31, 30, 0]));
801 out.push((shader_type, 0x8DF5, [31, 30, 0]));
802 }
803 out
804}
805
806pub fn nvidia_rtx_3060_windows() -> GpuProfile {
808 GpuProfile {
809 vendor: "WebKit".into(),
810 renderer: "WebKit WebGL".into(),
811 version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
812 shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
813 unmasked_vendor: "Google Inc. (NVIDIA)".into(),
814 unmasked_renderer:
815 "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
816 extensions: vec![
817 "ANGLE_instanced_arrays".into(),
818 "EXT_blend_minmax".into(),
819 "EXT_clip_control".into(),
820 "EXT_color_buffer_half_float".into(),
821 "EXT_depth_clamp".into(),
822 "EXT_disjoint_timer_query".into(),
823 "EXT_float_blend".into(),
824 "EXT_frag_depth".into(),
825 "EXT_polygon_offset_clamp".into(),
826 "EXT_shader_texture_lod".into(),
827 "EXT_texture_compression_bptc".into(),
828 "EXT_texture_compression_rgtc".into(),
829 "EXT_texture_filter_anisotropic".into(),
830 "EXT_texture_mirror_clamp_to_edge".into(),
831 "EXT_sRGB".into(),
832 "KHR_parallel_shader_compile".into(),
833 "OES_element_index_uint".into(),
834 "OES_fbo_render_mipmap".into(),
835 "OES_standard_derivatives".into(),
836 "OES_texture_float".into(),
837 "OES_texture_float_linear".into(),
838 "OES_texture_half_float".into(),
839 "OES_texture_half_float_linear".into(),
840 "OES_vertex_array_object".into(),
841 "WEBGL_blend_func_extended".into(),
842 "WEBGL_color_buffer_float".into(),
843 "WEBGL_compressed_texture_s3tc".into(),
844 "WEBGL_compressed_texture_s3tc_srgb".into(),
845 "WEBGL_debug_renderer_info".into(),
846 "WEBGL_debug_shaders".into(),
847 "WEBGL_depth_texture".into(),
848 "WEBGL_draw_buffers".into(),
849 "WEBGL_lose_context".into(),
850 "WEBGL_multi_draw".into(),
851 "WEBGL_polygon_mode".into(),
852 ],
853 params: common_params_desktop(),
854 shader_precision: standard_shader_precision(),
855 webgl1: None,
856 }
857}
858
859fn apple_m3_family_profile(chip_name: &str) -> GpuProfile {
860 GpuProfile {
861 vendor: "WebKit".into(),
862 renderer: "WebKit WebGL".into(),
863 version: "WebGL 2.0 (OpenGL ES 3.0 Chromium)".into(),
864 shading_language_version: "WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)".into(),
865 unmasked_vendor: "Google Inc. (Apple)".into(),
866 unmasked_renderer: format!(
867 "ANGLE (Apple, ANGLE Metal Renderer: {chip_name}, Unspecified Version)"
868 ),
869 extensions: vec![
870 "EXT_clip_control".into(),
871 "EXT_color_buffer_float".into(),
872 "EXT_color_buffer_half_float".into(),
873 "EXT_conservative_depth".into(),
874 "EXT_depth_clamp".into(),
875 "EXT_disjoint_timer_query_webgl2".into(),
876 "EXT_float_blend".into(),
877 "EXT_polygon_offset_clamp".into(),
878 "EXT_render_snorm".into(),
879 "EXT_texture_compression_bptc".into(),
880 "EXT_texture_compression_rgtc".into(),
881 "EXT_texture_filter_anisotropic".into(),
882 "EXT_texture_mirror_clamp_to_edge".into(),
883 "EXT_texture_norm16".into(),
884 "KHR_parallel_shader_compile".into(),
885 "NV_shader_noperspective_interpolation".into(),
886 "OES_draw_buffers_indexed".into(),
887 "OES_sample_variables".into(),
888 "OES_shader_multisample_interpolation".into(),
889 "OES_texture_float_linear".into(),
890 "WEBGL_blend_func_extended".into(),
891 "WEBGL_clip_cull_distance".into(),
892 "WEBGL_compressed_texture_astc".into(),
893 "WEBGL_compressed_texture_etc".into(),
894 "WEBGL_compressed_texture_etc1".into(),
895 "WEBGL_compressed_texture_pvrtc".into(),
896 "WEBGL_compressed_texture_s3tc".into(),
897 "WEBGL_compressed_texture_s3tc_srgb".into(),
898 "WEBGL_debug_renderer_info".into(),
899 "WEBGL_debug_shaders".into(),
900 "WEBGL_lose_context".into(),
901 "WEBGL_multi_draw".into(),
902 "WEBGL_polygon_mode".into(),
903 "WEBGL_provoking_vertex".into(),
904 "WEBGL_render_shared_exponent".into(),
905 "WEBGL_stencil_texturing".into(),
906 ],
907 params: apple_m3_params(),
908 shader_precision: standard_shader_precision(),
909 webgl1: Some(apple_m3_webgl1_surface()),
910 }
911}
912
913fn apple_m3_webgl1_surface() -> WebGL1Surface {
914 WebGL1Surface {
915 version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
916 shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
917 extensions: vec![
918 "ANGLE_instanced_arrays".into(),
919 "EXT_blend_minmax".into(),
920 "EXT_clip_control".into(),
921 "EXT_color_buffer_half_float".into(),
922 "EXT_depth_clamp".into(),
923 "EXT_disjoint_timer_query".into(),
924 "EXT_float_blend".into(),
925 "EXT_frag_depth".into(),
926 "EXT_polygon_offset_clamp".into(),
927 "EXT_sRGB".into(),
928 "EXT_shader_texture_lod".into(),
929 "EXT_texture_compression_bptc".into(),
930 "EXT_texture_compression_rgtc".into(),
931 "EXT_texture_filter_anisotropic".into(),
932 "EXT_texture_mirror_clamp_to_edge".into(),
933 "KHR_parallel_shader_compile".into(),
934 "OES_element_index_uint".into(),
935 "OES_fbo_render_mipmap".into(),
936 "OES_standard_derivatives".into(),
937 "OES_texture_float".into(),
938 "OES_texture_float_linear".into(),
939 "OES_texture_half_float".into(),
940 "OES_texture_half_float_linear".into(),
941 "OES_vertex_array_object".into(),
942 "WEBGL_blend_func_extended".into(),
943 "WEBGL_color_buffer_float".into(),
944 "WEBGL_compressed_texture_astc".into(),
945 "WEBGL_compressed_texture_etc".into(),
946 "WEBGL_compressed_texture_etc1".into(),
947 "WEBGL_compressed_texture_pvrtc".into(),
948 "WEBGL_compressed_texture_s3tc".into(),
949 "WEBGL_compressed_texture_s3tc_srgb".into(),
950 "WEBGL_debug_renderer_info".into(),
951 "WEBGL_debug_shaders".into(),
952 "WEBGL_depth_texture".into(),
953 "WEBGL_draw_buffers".into(),
954 "WEBGL_lose_context".into(),
955 "WEBGL_multi_draw".into(),
956 "WEBGL_polygon_mode".into(),
957 ],
958 }
959}
960
961fn apple_m3_params() -> Vec<(u32, serde_json::Value)> {
962 use serde_json::json;
963 let mut params = common_params_desktop();
964 for (pname, value) in params.iter_mut() {
965 match *pname {
966 0x0D3A => *value = json!([16384, 16384]),
967 0x846D => *value = json!([1.0, 511.0]),
968 _ => {}
969 }
970 }
971 params
972}
973
974pub fn apple_m3_macos() -> GpuProfile {
976 apple_m3_family_profile("Apple M3")
977}
978
979pub fn apple_m3_pro_macos() -> GpuProfile {
981 apple_m3_family_profile("Apple M3 Pro")
982}
983
984pub fn apple_m3_max_macos() -> GpuProfile {
986 apple_m3_family_profile("Apple M3 Max")
987}
988
989pub fn apple_m2_pro_macos() -> GpuProfile {
991 GpuProfile {
992 vendor: "WebKit".into(),
993 renderer: "WebKit WebGL".into(),
994 version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
995 shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
996 unmasked_vendor: "Google Inc. (Apple)".into(),
997 unmasked_renderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version)"
998 .into(),
999 extensions: vec![
1000 "ANGLE_instanced_arrays".into(),
1001 "EXT_blend_minmax".into(),
1002 "EXT_clip_control".into(),
1003 "EXT_color_buffer_half_float".into(),
1004 "EXT_depth_clamp".into(),
1005 "EXT_float_blend".into(),
1006 "EXT_frag_depth".into(),
1007 "EXT_polygon_offset_clamp".into(),
1008 "EXT_shader_texture_lod".into(),
1009 "EXT_texture_compression_bptc".into(),
1010 "EXT_texture_compression_rgtc".into(),
1011 "EXT_texture_filter_anisotropic".into(),
1012 "EXT_texture_mirror_clamp_to_edge".into(),
1013 "EXT_sRGB".into(),
1014 "KHR_parallel_shader_compile".into(),
1015 "OES_element_index_uint".into(),
1016 "OES_fbo_render_mipmap".into(),
1017 "OES_standard_derivatives".into(),
1018 "OES_texture_float".into(),
1019 "OES_texture_float_linear".into(),
1020 "OES_texture_half_float".into(),
1021 "OES_texture_half_float_linear".into(),
1022 "OES_vertex_array_object".into(),
1023 "WEBGL_blend_func_extended".into(),
1024 "WEBGL_color_buffer_float".into(),
1025 "WEBGL_compressed_texture_astc".into(),
1026 "WEBGL_compressed_texture_etc".into(),
1027 "WEBGL_compressed_texture_etc1".into(),
1028 "WEBGL_compressed_texture_s3tc".into(),
1029 "WEBGL_compressed_texture_s3tc_srgb".into(),
1030 "WEBGL_debug_renderer_info".into(),
1031 "WEBGL_debug_shaders".into(),
1032 "WEBGL_depth_texture".into(),
1033 "WEBGL_draw_buffers".into(),
1034 "WEBGL_lose_context".into(),
1035 "WEBGL_multi_draw".into(),
1036 ],
1037 params: common_params_desktop(),
1038 shader_precision: standard_shader_precision(),
1039 webgl1: None,
1040 }
1041}
1042
1043pub fn intel_uhd_630_linux() -> GpuProfile {
1045 GpuProfile {
1046 vendor: "WebKit".into(),
1047 renderer: "WebKit WebGL".into(),
1048 version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
1049 shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
1050 unmasked_vendor: "Google Inc. (Intel)".into(),
1051 unmasked_renderer: "ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)"
1052 .into(),
1053 extensions: vec![
1054 "ANGLE_instanced_arrays".into(),
1055 "EXT_blend_minmax".into(),
1056 "EXT_clip_control".into(),
1057 "EXT_color_buffer_half_float".into(),
1058 "EXT_depth_clamp".into(),
1059 "EXT_disjoint_timer_query".into(),
1060 "EXT_float_blend".into(),
1061 "EXT_frag_depth".into(),
1062 "EXT_polygon_offset_clamp".into(),
1063 "EXT_shader_texture_lod".into(),
1064 "EXT_texture_compression_bptc".into(),
1065 "EXT_texture_compression_rgtc".into(),
1066 "EXT_texture_filter_anisotropic".into(),
1067 "EXT_texture_mirror_clamp_to_edge".into(),
1068 "EXT_sRGB".into(),
1069 "KHR_parallel_shader_compile".into(),
1070 "OES_element_index_uint".into(),
1071 "OES_fbo_render_mipmap".into(),
1072 "OES_standard_derivatives".into(),
1073 "OES_texture_float".into(),
1074 "OES_texture_float_linear".into(),
1075 "OES_texture_half_float".into(),
1076 "OES_texture_half_float_linear".into(),
1077 "OES_vertex_array_object".into(),
1078 "WEBGL_compressed_texture_s3tc".into(),
1079 "WEBGL_compressed_texture_s3tc_srgb".into(),
1080 "WEBGL_debug_renderer_info".into(),
1081 "WEBGL_debug_shaders".into(),
1082 "WEBGL_depth_texture".into(),
1083 "WEBGL_draw_buffers".into(),
1084 "WEBGL_lose_context".into(),
1085 "WEBGL_multi_draw".into(),
1086 ],
1087 params: common_params_desktop(),
1088 shader_precision: standard_shader_precision(),
1089 webgl1: None,
1090 }
1091}
1092
1093fn default_media_devices(seed: &str) -> Vec<MediaDeviceInfo> {
1096 use std::{
1097 collections::hash_map::DefaultHasher,
1098 hash::{Hash, Hasher},
1099 };
1100 let hash = |s: &str| -> String {
1101 let mut h = DefaultHasher::new();
1102 s.hash(&mut h);
1103 format!(
1104 "{:016x}{:016x}",
1105 h.finish(),
1106 h.finish().wrapping_mul(0x9e3779b97f4a7c15)
1107 )
1108 };
1109 vec![
1110 MediaDeviceInfo {
1111 device_id: hash(&format!("{seed}-audio-in")),
1112 kind: "audioinput".into(),
1113 label: "Default".into(),
1114 group_id: hash(&format!("{seed}-group-a")),
1115 },
1116 MediaDeviceInfo {
1117 device_id: hash(&format!("{seed}-audio-out")),
1118 kind: "audiooutput".into(),
1119 label: "Default".into(),
1120 group_id: hash(&format!("{seed}-group-a")),
1121 },
1122 MediaDeviceInfo {
1123 device_id: hash(&format!("{seed}-video-in")),
1124 kind: "videoinput".into(),
1125 label: "Integrated Camera".into(),
1126 group_id: hash(&format!("{seed}-group-v")),
1127 },
1128 ]
1129}
1130
1131pub fn chrome_148_windows() -> StealthProfile {
1135 StealthProfile {
1136 user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1137 browser_name: "Chrome".into(),
1138 browser_version: "148.0.7778.168".into(),
1139 os_name: "Windows".into(),
1140 os_version: "10.0".into(),
1141 platform: "Win32".into(),
1142 vendor: "Google Inc.".into(),
1143 vendor_sub: "".into(),
1144 product_sub: "20030107".into(),
1145 app_version: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1146 screen_width: 1920, screen_height: 1080,
1147 screen_avail_width: 1920, screen_avail_height: 1040,
1148 screen_avail_top: 0, screen_color_depth: 24,
1149 device_pixel_ratio: 1.0,
1150 cpu_cores: 8, device_memory: 8, max_touch_points: 0,
1151 webgl_vendor: "Google Inc. (NVIDIA)".into(),
1152 webgl_renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
1153 gpu_profile: nvidia_rtx_3060_windows(),
1154 language: "en-US".into(),
1155 languages: vec!["en-US".into(), "en".into()],
1156 timezone: "America/New_York".into(),
1157 cpu_architecture: "x86".into(), cpu_bitness: "64".into(),
1158 platform_version: "15.0.0".into(),
1159 ua_model: "".into(), ua_wow64: false,
1160 device_class: DeviceClass::Desktop,
1161 tls_impersonate: "chrome_147".into(),
1162 connection_effective_type: "4g".into(),
1163 connection_rtt: 50, connection_downlink: 10.0,
1164 pdf_viewer_enabled: true, plugins_count: 5, mime_types_count: 2,
1165 canvas_seed: 0x1234567890abcdef, audio_seed: 0xfedcba0987654321,
1166 audio_sample_rate: 44100,
1167 has_platform_authenticator: true, conditional_mediation: true,
1168 allow_http3: false,
1169 prefers_color_scheme: "light".into(),
1170 color_gamut: "srgb".into(),
1171 pointer_type: "fine".into(), hover_capability: "hover".into(),
1172 inner_width: 1920, inner_height: 969,
1173 outer_width: 1920, outer_height: 1080,
1174 proxy: None,
1175 media_devices: default_media_devices("win10"),
1176 enforce_csp: true,
1177 }
1178}
1179
1180pub fn chrome_148_macos() -> StealthProfile {
1182 StealthProfile {
1183 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1184 browser_name: "Chrome".into(),
1185 browser_version: "148.0.7778.168".into(),
1186 os_name: "macOS".into(),
1187 os_version: "15.2".into(),
1188 platform: "MacIntel".into(),
1189 vendor: "Google Inc.".into(),
1190 vendor_sub: "".into(),
1191 product_sub: "20030107".into(),
1192 app_version: "5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1193 screen_width: 1512, screen_height: 982,
1194 screen_avail_width: 1512, screen_avail_height: 949,
1195 screen_avail_top: 33, screen_color_depth: 30,
1196 device_pixel_ratio: 2.0,
1197 cpu_cores: 8, device_memory: 8, max_touch_points: 0,
1198 webgl_vendor: "Google Inc. (Apple)".into(),
1199 webgl_renderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)".into(),
1200 gpu_profile: apple_m3_macos(),
1201 language: "en-US".into(),
1202 languages: vec!["en-US".into(), "en".into()],
1203 timezone: "America/Los_Angeles".into(),
1204 cpu_architecture: "arm".into(), cpu_bitness: "64".into(),
1205 platform_version: "15.2.0".into(),
1206 ua_model: "".into(), ua_wow64: false,
1207 device_class: DeviceClass::Desktop,
1208 tls_impersonate: "chrome_147".into(),
1209 connection_effective_type: "4g".into(),
1210 connection_rtt: 50, connection_downlink: 10.0,
1211 pdf_viewer_enabled: true, plugins_count: 5, mime_types_count: 2,
1212 canvas_seed: 0xabcdef1234567890, audio_seed: 0x0987654321fedcba,
1213 audio_sample_rate: 48000,
1214 has_platform_authenticator: true, conditional_mediation: true,
1215 allow_http3: false,
1216 prefers_color_scheme: "light".into(),
1217 color_gamut: "p3".into(),
1218 pointer_type: "fine".into(), hover_capability: "hover".into(),
1219 inner_width: 1512, inner_height: 871,
1220 outer_width: 1512, outer_height: 982,
1221 proxy: None,
1222 media_devices: default_media_devices("macos"),
1223 enforce_csp: true,
1224 }
1225}
1226
1227pub fn chrome_148_linux() -> StealthProfile {
1229 StealthProfile {
1230 user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1231 browser_name: "Chrome".into(),
1232 browser_version: "148.0.7778.168".into(),
1233 os_name: "Linux".into(),
1234 os_version: "6.1".into(),
1235 platform: "Linux x86_64".into(),
1236 vendor: "Google Inc.".into(),
1237 vendor_sub: "".into(),
1238 product_sub: "20030107".into(),
1239 app_version: "5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1240 screen_width: 1920, screen_height: 1080,
1241 screen_avail_width: 1920, screen_avail_height: 1053,
1242 screen_avail_top: 0, screen_color_depth: 24,
1243 device_pixel_ratio: 1.0,
1244 cpu_cores: 8, device_memory: 8, max_touch_points: 0,
1245 webgl_vendor: "Google Inc. (Intel)".into(),
1246 webgl_renderer: "ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)".into(),
1247 gpu_profile: intel_uhd_630_linux(),
1248 language: "en-US".into(),
1249 languages: vec!["en-US".into(), "en".into()],
1250 timezone: "America/Chicago".into(),
1251 cpu_architecture: "x86".into(), cpu_bitness: "64".into(),
1252 platform_version: "".into(),
1253 ua_model: "".into(), ua_wow64: false,
1254 device_class: DeviceClass::Desktop,
1255 tls_impersonate: "chrome_147".into(),
1256 connection_effective_type: "4g".into(),
1257 connection_rtt: 50, connection_downlink: 10.0,
1258 pdf_viewer_enabled: true, plugins_count: 5, mime_types_count: 2,
1259 canvas_seed: 0x1111222233334444, audio_seed: 0x5555666677778888,
1260 audio_sample_rate: 44100,
1261 has_platform_authenticator: false, conditional_mediation: true,
1262 allow_http3: false,
1263 prefers_color_scheme: "light".into(),
1264 color_gamut: "srgb".into(),
1265 pointer_type: "fine".into(), hover_capability: "hover".into(),
1266 inner_width: 1920, inner_height: 969,
1267 outer_width: 1920, outer_height: 1080,
1268 proxy: None,
1269 media_devices: default_media_devices("linux"),
1270 enforce_csp: true,
1271 }
1272}
1273
1274pub fn chrome_148_ru() -> StealthProfile {
1276 StealthProfile {
1277 language: "ru-RU".into(),
1278 languages: vec!["ru-RU".into(), "ru".into(), "en-US".into(), "en".into()],
1279 timezone: "Europe/Moscow".into(),
1280 connection_rtt: 100,
1281 connection_downlink: 8.0,
1282 canvas_seed: 0xaaaa_bbbb_cccc_dddd,
1283 audio_seed: 0xdddd_cccc_bbbb_aaaa,
1284 media_devices: default_media_devices("ru"),
1285 webgl_renderer:
1286 "ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
1287 ..chrome_148_windows()
1288 }
1289}
1290
1291pub fn chrome_148_cn() -> StealthProfile {
1293 StealthProfile {
1294 language: "zh-CN".into(),
1295 languages: vec!["zh-CN".into(), "zh".into(), "en-US".into(), "en".into()],
1296 timezone: "Asia/Shanghai".into(),
1297 device_pixel_ratio: 1.25,
1298 cpu_cores: 12,
1299 device_memory: 16,
1300 connection_rtt: 150,
1301 connection_downlink: 6.0,
1302 canvas_seed: 0x1122_3344_5566_7788,
1303 audio_seed: 0x8877_6655_4433_2211,
1304 media_devices: default_media_devices("cn"),
1305 ..chrome_148_windows()
1306 }
1307}
1308
1309pub fn chrome_148_de() -> StealthProfile {
1311 StealthProfile {
1312 language: "de-DE".into(),
1313 languages: vec!["de-DE".into(), "de".into(), "en-US".into(), "en".into()],
1314 timezone: "Europe/Berlin".into(),
1315 canvas_seed: 0xdede_dede_dede_dede,
1316 audio_seed: 0xeded_eded_eded_eded,
1317 ..chrome_148_windows()
1318 }
1319}
1320
1321pub fn chrome_148_jp() -> StealthProfile {
1323 StealthProfile {
1324 language: "ja-JP".into(),
1325 languages: vec!["ja".into(), "en-US".into(), "en".into()],
1326 timezone: "Asia/Tokyo".into(),
1327 canvas_seed: 0x0a00_0000_0000_0001,
1328 audio_seed: 0x0a00_0000_0000_0002,
1329 ..chrome_148_windows()
1330 }
1331}
1332
1333pub fn firefox_135_macos() -> StealthProfile {
1337 StealthProfile {
1338 user_agent:
1339 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:135.0) Gecko/20100101 Firefox/135.0"
1340 .into(),
1341 browser_name: "Firefox".into(),
1342 browser_version: "135.0".into(),
1343 os_name: "macOS".into(),
1344 os_version: "14.5".into(),
1345 platform: "MacIntel".into(),
1346 vendor: "".into(),
1347 vendor_sub: "".into(),
1348 product_sub: "20100101".into(),
1349 app_version: "5.0 (Macintosh; Intel Mac OS X 14.5; rv:135.0) Gecko/20100101 Firefox/135.0"
1350 .into(),
1351 screen_width: 1440,
1352 screen_height: 900,
1353 screen_avail_width: 1440,
1354 screen_avail_height: 875,
1355 screen_avail_top: 25,
1356 screen_color_depth: 30,
1357 device_pixel_ratio: 2.0,
1358 cpu_cores: 10,
1359 device_memory: 16,
1360 max_touch_points: 0,
1361 webgl_vendor: "Mozilla".into(),
1362 webgl_renderer: "Mozilla".into(),
1363 gpu_profile: apple_m2_pro_macos(),
1364 language: "en-US".into(),
1365 languages: vec!["en-US".into(), "en".into()],
1366 timezone: "America/Los_Angeles".into(),
1367 cpu_architecture: "arm".into(),
1368 cpu_bitness: "64".into(),
1369 platform_version: "14.5.0".into(),
1370 ua_model: "".into(),
1371 ua_wow64: false,
1372 device_class: DeviceClass::Desktop,
1373 tls_impersonate: "firefox_135".into(),
1374 connection_effective_type: "4g".into(),
1375 connection_rtt: 50,
1376 connection_downlink: 10.0,
1377 pdf_viewer_enabled: true,
1378 plugins_count: 5,
1379 mime_types_count: 2,
1380 canvas_seed: 0xff0011_ff0022_ff0033_u128 as u64,
1381 audio_seed: 0x88aa_bbcc_ddee_ff00,
1382 audio_sample_rate: 44100,
1383 has_platform_authenticator: true,
1384 conditional_mediation: true,
1385 allow_http3: false,
1386 prefers_color_scheme: "light".into(),
1387 color_gamut: "p3".into(),
1388 pointer_type: "fine".into(),
1389 hover_capability: "hover".into(),
1390 inner_width: 1440,
1391 inner_height: 789,
1392 outer_width: 1440,
1393 outer_height: 900,
1394 proxy: None,
1395 media_devices: default_media_devices("macos"),
1396 enforce_csp: true,
1397 }
1398}
1399
1400pub fn firefox_135_windows() -> StealthProfile {
1402 StealthProfile {
1403 user_agent:
1404 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"
1405 .into(),
1406 browser_name: "Firefox".into(),
1407 browser_version: "135.0".into(),
1408 os_name: "Windows".into(),
1409 os_version: "10.0".into(),
1410 platform: "Win32".into(),
1411 vendor: "".into(),
1412 vendor_sub: "".into(),
1413 product_sub: "20100101".into(),
1414 app_version: "5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"
1415 .into(),
1416 screen_width: 1920,
1417 screen_height: 1080,
1418 screen_avail_width: 1920,
1419 screen_avail_height: 1040,
1420 screen_avail_top: 0,
1421 screen_color_depth: 24,
1422 device_pixel_ratio: 1.0,
1423 cpu_cores: 8,
1424 device_memory: 8,
1425 max_touch_points: 0,
1426 webgl_vendor: "Mozilla".into(),
1427 webgl_renderer: "Mozilla".into(),
1428 gpu_profile: nvidia_rtx_3060_windows(),
1429 language: "en-US".into(),
1430 languages: vec!["en-US".into(), "en".into()],
1431 timezone: "America/New_York".into(),
1432 cpu_architecture: "x86".into(),
1433 cpu_bitness: "64".into(),
1434 platform_version: "15.0.0".into(),
1435 ua_model: "".into(),
1436 ua_wow64: false,
1437 device_class: DeviceClass::Desktop,
1438 tls_impersonate: "firefox_135".into(),
1439 connection_effective_type: "4g".into(),
1440 connection_rtt: 50,
1441 connection_downlink: 10.0,
1442 pdf_viewer_enabled: true,
1443 plugins_count: 5,
1444 mime_types_count: 2,
1445 canvas_seed: 0x1122_3344_5566_7788,
1446 audio_seed: 0x99aa_bbcc_ddee_ff00,
1447 audio_sample_rate: 44100,
1448 has_platform_authenticator: true,
1449 conditional_mediation: true,
1450 allow_http3: false,
1451 prefers_color_scheme: "light".into(),
1452 color_gamut: "srgb".into(),
1453 pointer_type: "fine".into(),
1454 hover_capability: "hover".into(),
1455 inner_width: 1920,
1456 inner_height: 969,
1457 outer_width: 1920,
1458 outer_height: 1080,
1459 proxy: None,
1460 media_devices: default_media_devices("windows"),
1461 enforce_csp: true,
1462 }
1463}
1464
1465pub fn firefox_135_linux() -> StealthProfile {
1467 StealthProfile {
1468 user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".into(),
1469 browser_name: "Firefox".into(),
1470 browser_version: "135.0".into(),
1471 os_name: "Linux".into(),
1472 os_version: "6.1".into(),
1473 platform: "Linux x86_64".into(),
1474 vendor: "".into(),
1475 vendor_sub: "".into(),
1476 product_sub: "20100101".into(),
1477 app_version: "5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".into(),
1478 screen_width: 1920,
1479 screen_height: 1080,
1480 screen_avail_width: 1920,
1481 screen_avail_height: 1053,
1482 screen_avail_top: 0,
1483 screen_color_depth: 24,
1484 device_pixel_ratio: 1.0,
1485 cpu_cores: 8,
1486 device_memory: 8,
1487 max_touch_points: 0,
1488 webgl_vendor: "Mozilla".into(),
1489 webgl_renderer: "Mozilla".into(),
1490 gpu_profile: intel_uhd_630_linux(),
1491 language: "en-US".into(),
1492 languages: vec!["en-US".into(), "en".into()],
1493 timezone: "America/Chicago".into(),
1494 cpu_architecture: "x86".into(),
1495 cpu_bitness: "64".into(),
1496 platform_version: "".into(),
1497 ua_model: "".into(),
1498 ua_wow64: false,
1499 device_class: DeviceClass::Desktop,
1500 tls_impersonate: "firefox_135".into(),
1501 connection_effective_type: "4g".into(),
1502 connection_rtt: 50,
1503 connection_downlink: 10.0,
1504 pdf_viewer_enabled: true,
1505 plugins_count: 5,
1506 mime_types_count: 2,
1507 canvas_seed: 0xaaaa_bbbb_cccc_dddd,
1508 audio_seed: 0xdddd_cccc_bbbb_aaaa,
1509 audio_sample_rate: 44100,
1510 has_platform_authenticator: false,
1511 conditional_mediation: true,
1512 allow_http3: false,
1513 prefers_color_scheme: "light".into(),
1514 color_gamut: "srgb".into(),
1515 pointer_type: "fine".into(),
1516 hover_capability: "hover".into(),
1517 inner_width: 1920,
1518 inner_height: 969,
1519 outer_width: 1920,
1520 outer_height: 1080,
1521 proxy: None,
1522 media_devices: default_media_devices("linux"),
1523 enforce_csp: true,
1524 }
1525}
1526
1527pub fn pixel_9_pro_chrome_148() -> StealthProfile {
1531 StealthProfile {
1532 user_agent: "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro Build/AP4A.250105.002) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36".into(),
1533 browser_name: "Chrome".into(),
1534 browser_version: "148.0.7778.168".into(),
1535 os_name: "Android".into(),
1536 os_version: "15".into(),
1537 platform: "Linux armv81".into(),
1538 vendor: "Google Inc.".into(),
1539 vendor_sub: "".into(),
1540 product_sub: "20030107".into(),
1541 app_version: "5.0 (Linux; Android 15; Pixel 9 Pro Build/AP4A.250105.002) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36".into(),
1542 screen_width: 412, screen_height: 870,
1543 screen_avail_width: 412, screen_avail_height: 870,
1544 screen_avail_top: 0, screen_color_depth: 24,
1545 device_pixel_ratio: 2.625,
1546 cpu_cores: 8, device_memory: 8, max_touch_points: 5,
1547 webgl_vendor: "Google Inc. (Google)".into(),
1548 webgl_renderer: "ANGLE (Google, Mali-G715 MP7, OpenGL ES 3.2)".into(),
1549 gpu_profile: apple_m3_macos(), language: "en-US".into(),
1551 languages: vec!["en-US".into(), "en".into()],
1552 timezone: "America/Los_Angeles".into(),
1553 cpu_architecture: "".into(), cpu_bitness: "64".into(),
1554 platform_version: "15.0.0".into(),
1555 ua_model: "Pixel 9 Pro".into(), ua_wow64: false,
1556 device_class: DeviceClass::MobileAndroid,
1557 tls_impersonate: "chrome_147_android".into(),
1558 connection_effective_type: "4g".into(),
1559 connection_rtt: 50, connection_downlink: 10.0,
1560 pdf_viewer_enabled: false, plugins_count: 0, mime_types_count: 0,
1561 canvas_seed: 0xa5a5_d5d5_3c3c_e6e6, audio_seed: 0x9c9c_5e5e_4040_b1b1,
1562 audio_sample_rate: 44100,
1563 has_platform_authenticator: false, conditional_mediation: true,
1564 allow_http3: false,
1565 prefers_color_scheme: "light".into(),
1566 color_gamut: "srgb".into(),
1567 pointer_type: "coarse".into(), hover_capability: "none".into(),
1568 inner_width: 412, inner_height: 870,
1569 outer_width: 412, outer_height: 870,
1570 proxy: None,
1571 media_devices: default_media_devices("android"),
1572 enforce_csp: true,
1573 }
1574}
1575
1576pub fn iphone_15_pro_safari_18() -> StealthProfile {
1578 StealthProfile {
1579 user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1".into(),
1580 browser_name: "Safari".into(),
1581 browser_version: "18.0.1".into(),
1582 os_name: "iOS".into(),
1583 os_version: "18.0.1".into(),
1584 platform: "iPhone".into(),
1585 vendor: "Apple Computer, Inc.".into(),
1586 vendor_sub: "".into(),
1587 product_sub: "20030107".into(),
1588 app_version: "5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1".into(),
1589 screen_width: 393, screen_height: 852,
1590 screen_avail_width: 393, screen_avail_height: 852,
1591 screen_avail_top: 0, screen_color_depth: 24,
1592 device_pixel_ratio: 3.0,
1593 cpu_cores: 2, device_memory: 0, max_touch_points: 5,
1594 webgl_vendor: "Apple Inc.".into(),
1595 webgl_renderer: "Apple GPU".into(),
1596 gpu_profile: apple_m3_macos(), language: "en-US".into(),
1598 languages: vec!["en-US".into(), "en".into()],
1599 timezone: "America/Los_Angeles".into(),
1600 cpu_architecture: "arm".into(), cpu_bitness: "64".into(),
1601 platform_version: "18.0.1".into(),
1602 ua_model: "iPhone".into(), ua_wow64: false,
1603 device_class: DeviceClass::MobileIOS,
1604 tls_impersonate: "safari_18_ios".into(),
1605 connection_effective_type: "4g".into(),
1606 connection_rtt: 50, connection_downlink: 10.0,
1607 pdf_viewer_enabled: false, plugins_count: 0, mime_types_count: 0,
1608 canvas_seed: 0xa1b2_c3d4_e5f6_0708, audio_seed: 0x0807_0605_0403_0201,
1609 audio_sample_rate: 44100,
1610 has_platform_authenticator: false, conditional_mediation: true,
1611 allow_http3: false,
1612 prefers_color_scheme: "light".into(),
1613 color_gamut: "p3".into(),
1614 pointer_type: "coarse".into(), hover_capability: "none".into(),
1615 inner_width: 393, inner_height: 852,
1616 outer_width: 393, outer_height: 852,
1617 proxy: None,
1618 media_devices: default_media_devices("ios"),
1619 enforce_csp: true,
1620 }
1621}
1622
1623pub fn with_locale(
1627 mut base: StealthProfile,
1628 language: &str,
1629 languages: &[&str],
1630 timezone: &str,
1631) -> StealthProfile {
1632 base.language = language.into();
1633 base.languages = languages.iter().map(|s| (*s).to_string()).collect();
1634 base.timezone = timezone.into();
1635 base
1636}
1637
1638pub fn random_desktop() -> StealthProfile {
1640 use rand::RngExt;
1641 let mut rng = rand::rng();
1642 let mut profile = match rng.random_range(0..3u32) {
1643 0 => chrome_148_windows(),
1644 1 => chrome_148_macos(),
1645 _ => chrome_148_linux(),
1646 };
1647 profile.canvas_seed = rng.random();
1648 profile.audio_seed = rng.random();
1649 profile
1650}
1651
1652pub fn chrome_148_macos_sampled() -> StealthProfile {
1658 chrome_148_macos_sampled_with_rng(&mut rand::rng())
1659}
1660
1661pub fn chrome_148_macos_sampled_with_rng(rng: &mut impl rand::RngExt) -> StealthProfile {
1663 let mut p = chrome_148_macos();
1664
1665 type ChipConfig = (
1666 &'static [u8],
1667 &'static [u8],
1668 &'static [(u32, u32, u32)],
1669 GpuProfile,
1670 );
1671 let chip_idx = rng.random_range(0..3u32);
1672 let (cores_pool, ram_pool, screens, gpu): ChipConfig = match chip_idx {
1673 0 => (
1674 &[8],
1675 &[8, 16, 24],
1676 &[(1512, 982, 949), (1728, 1117, 1010)],
1677 apple_m3_macos(),
1678 ),
1679 1 => (
1680 &[11, 12],
1681 &[18, 36],
1682 &[(1800, 1169, 1100), (2056, 1329, 1253)],
1683 apple_m3_pro_macos(),
1684 ),
1685 _ => (
1686 &[14, 16],
1687 &[36, 48],
1688 &[(1800, 1169, 1100), (2056, 1329, 1253)],
1689 apple_m3_max_macos(),
1690 ),
1691 };
1692
1693 p.cpu_cores = cores_pool[rng.random_range(0..cores_pool.len())];
1694 p.device_memory = ram_pool[rng.random_range(0..ram_pool.len())];
1695
1696 let (w, h, ah) = screens[rng.random_range(0..screens.len())];
1697 p.screen_width = w;
1698 p.screen_height = h;
1699 p.screen_avail_width = w;
1700 p.screen_avail_height = ah;
1701 p.inner_width = w;
1702 p.inner_height = h.saturating_sub(111);
1703 p.outer_width = w;
1704 p.outer_height = h;
1705
1706 p.gpu_profile = gpu;
1707 p.webgl_renderer = p.gpu_profile.unmasked_renderer.clone();
1708
1709 p.canvas_seed = rng.random();
1710 p.audio_seed = rng.random();
1711
1712 debug_assert!(
1713 p.validate().is_ok(),
1714 "chrome_148_macos_sampled produced an invalid profile: {:?}",
1715 p.validate()
1716 );
1717
1718 p
1719}
1720
1721#[cfg(test)]
1725pub mod presets {
1726 use super::*;
1727
1728 pub fn chrome_147_macos() -> StealthProfile {
1729 chrome_148_macos()
1730 }
1731 pub fn chrome_147_windows() -> StealthProfile {
1732 chrome_148_windows()
1733 }
1734 pub fn chrome_147_linux() -> StealthProfile {
1735 chrome_148_linux()
1736 }
1737 pub fn firefox_135_macos() -> StealthProfile {
1738 super::firefox_135_macos()
1739 }
1740 pub fn safari_ios_18() -> StealthProfile {
1741 iphone_15_pro_safari_18()
1742 }
1743 pub fn pixel_9_pro_chrome_148() -> StealthProfile {
1744 super::pixel_9_pro_chrome_148()
1745 }
1746}
1747
1748#[cfg(test)]
1751mod tests {
1752 use super::*;
1753
1754 #[test]
1755 fn chrome_148_windows_validates() {
1756 let p = chrome_148_windows();
1757 assert!(p.validate().is_ok(), "{:?}", p.validate());
1758 }
1759
1760 #[test]
1761 fn chrome_148_macos_validates() {
1762 let p = chrome_148_macos();
1763 assert!(p.validate().is_ok(), "{:?}", p.validate());
1764 }
1765
1766 #[test]
1767 fn chrome_148_linux_validates() {
1768 let p = chrome_148_linux();
1769 assert!(p.validate().is_ok(), "{:?}", p.validate());
1770 }
1771
1772 #[test]
1773 fn chrome_148_ru_validates() {
1774 let p = chrome_148_ru();
1775 assert!(p.validate().is_ok(), "{:?}", p.validate());
1776 }
1777
1778 #[test]
1779 fn chrome_148_cn_validates() {
1780 let p = chrome_148_cn();
1781 assert!(p.validate().is_ok(), "{:?}", p.validate());
1782 }
1783
1784 #[test]
1785 fn firefox_135_macos_validates() {
1786 let p = firefox_135_macos();
1787 assert!(p.validate().is_ok(), "{:?}", p.validate());
1788 assert_eq!(p.browser_name, "Firefox");
1789 assert_eq!(p.vendor, "");
1790 assert_eq!(p.product_sub, "20100101");
1791 assert!(p.user_agent.contains("rv:135.0"));
1792 assert!(p.user_agent.contains("Firefox/135.0"));
1793 assert!(!p.user_agent.contains("Chrome"));
1794 }
1795
1796 #[test]
1797 fn firefox_135_windows_validates() {
1798 let p = firefox_135_windows();
1799 assert!(p.validate().is_ok(), "{:?}", p.validate());
1800 assert!(p.user_agent.contains("Firefox/135.0"));
1801 }
1802
1803 #[test]
1804 fn firefox_135_linux_validates() {
1805 let p = firefox_135_linux();
1806 assert!(p.validate().is_ok(), "{:?}", p.validate());
1807 assert!(p.user_agent.contains("Firefox/135.0"));
1808 }
1809
1810 #[test]
1811 fn pixel_9_pro_validates() {
1812 let p = pixel_9_pro_chrome_148();
1813 assert!(p.validate().is_ok(), "{:?}", p.validate());
1814 }
1815
1816 #[test]
1817 fn iphone_15_pro_validates() {
1818 let p = iphone_15_pro_safari_18();
1819 assert!(p.validate().is_ok(), "{:?}", p.validate());
1820 }
1821
1822 #[test]
1823 fn http3_disabled_by_default_on_all_presets() {
1824 for profile in [
1825 chrome_148_windows(),
1826 chrome_148_macos(),
1827 chrome_148_linux(),
1828 chrome_148_ru(),
1829 chrome_148_cn(),
1830 chrome_148_de(),
1831 chrome_148_jp(),
1832 firefox_135_macos(),
1833 firefox_135_windows(),
1834 firefox_135_linux(),
1835 ] {
1836 assert!(
1837 !profile.allow_http3,
1838 "Profile sets allow_http3=true: {}",
1839 profile.user_agent
1840 );
1841 }
1842 }
1843
1844 #[test]
1845 fn firefox_webgl_is_masked() {
1846 for profile in [
1847 firefox_135_macos(),
1848 firefox_135_windows(),
1849 firefox_135_linux(),
1850 ] {
1851 assert_eq!(profile.webgl_vendor, "Mozilla");
1852 assert_eq!(profile.webgl_renderer, "Mozilla");
1853 }
1854 }
1855
1856 #[test]
1857 fn random_desktop_validates() {
1858 for _ in 0..10 {
1859 let p = random_desktop();
1860 assert!(p.validate().is_ok(), "{:?}", p.validate());
1861 }
1862 }
1863
1864 #[test]
1865 fn random_desktop_diversity() {
1866 use std::collections::HashSet;
1867 let mut names = HashSet::new();
1868 for _ in 0..30 {
1869 let p = random_desktop();
1870 names.insert(p.browser_name.clone());
1871 }
1872 assert!(!names.is_empty());
1875 }
1876
1877 #[test]
1878 fn invalid_profile_detected() {
1879 let mut p = chrome_148_windows();
1880 p.platform = "MacIntel".into();
1881 assert!(p.validate().is_err());
1882 }
1883
1884 #[test]
1885 fn invalid_gpu_os_mismatch() {
1886 let mut p = chrome_148_windows();
1887 p.webgl_renderer =
1888 "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)".into();
1889 p.webgl_vendor = "Google Inc. (Apple)".into();
1890 assert!(p.validate().is_err());
1891 }
1892
1893 #[test]
1894 fn ua_contains_version() {
1895 let p = chrome_148_windows();
1896 assert!(p.user_agent.contains("148.0.0.0"));
1897 assert_eq!(p.browser_version, "148.0.7778.168");
1898 }
1899
1900 #[test]
1901 fn serialization_roundtrip() {
1902 let p = chrome_148_windows();
1903 let json = serde_json::to_string(&p).unwrap();
1904 let deserialized: StealthProfile = serde_json::from_str(&json).unwrap();
1905 assert_eq!(p.user_agent, deserialized.user_agent);
1906 assert_eq!(p.screen_width, deserialized.screen_width);
1907 }
1908
1909 #[test]
1910 fn macos_sampler_produces_valid_profiles() {
1911 for _ in 0..200 {
1912 let p = chrome_148_macos_sampled();
1913 p.validate()
1914 .unwrap_or_else(|e| panic!("invalid sampled profile: {e:?}"));
1915 assert!(matches!(p.screen_width, 1512 | 1728 | 1800 | 2056));
1916 assert!(matches!(p.cpu_cores, 8 | 11 | 12 | 14 | 16));
1917 assert!(matches!(p.device_memory, 8 | 16 | 18 | 24 | 36 | 48));
1918 assert_eq!(p.device_pixel_ratio, 2.0);
1919 assert_eq!(p.audio_sample_rate, 48000);
1920 assert_eq!(p.cpu_architecture, "arm");
1921 assert_eq!(p.platform, "MacIntel");
1922 assert_eq!(p.inner_height + 111, p.screen_height);
1923 }
1924 }
1925
1926 #[test]
1927 fn macos_sampler_keeps_cross_api_consistency() {
1928 for _ in 0..50 {
1929 let p = chrome_148_macos_sampled();
1930 let r = &p.gpu_profile.unmasked_renderer;
1931 match p.cpu_cores {
1932 8 => {
1933 assert!(r.contains("Apple M3,"));
1934 assert!(matches!(p.device_memory, 8 | 16 | 24));
1935 }
1936 11 | 12 => {
1937 assert!(r.contains("Apple M3 Pro"));
1938 assert!(matches!(p.device_memory, 18 | 36));
1939 }
1940 14 | 16 => {
1941 assert!(r.contains("Apple M3 Max"));
1942 assert!(matches!(p.device_memory, 36 | 48));
1943 }
1944 other => panic!("unexpected cpu_cores {other}"),
1945 }
1946 assert_eq!(p.webgl_renderer, *r);
1947 }
1948 }
1949
1950 use rand_chacha::rand_core::SeedableRng;
1953
1954 fn fixed_rng() -> rand_chacha::ChaCha20Rng {
1955 rand_chacha::ChaCha20Rng::seed_from_u64(42)
1956 }
1957
1958 #[test]
1959 fn behavior_profile_defaults_are_sensible() {
1960 let p = BehaviorProfile::default();
1961 assert!((30.0..=80.0).contains(&p.typing_wpm_mean));
1962 assert!((130.0..=220.0).contains(&p.fitts_b));
1963 assert_eq!(p.handedness, Handedness::Right);
1964 }
1965
1966 #[test]
1967 fn rng_for_is_deterministic_per_seed() {
1968 let p = BehaviorProfile {
1969 seed: 99,
1970 ..BehaviorProfile::default()
1971 };
1972 let mut a = p.rng_for(123);
1973 let mut b = p.rng_for(123);
1974 use rand::RngExt;
1975 assert_eq!(a.random::<u64>(), b.random::<u64>());
1976 }
1977
1978 #[test]
1979 fn rng_for_differs_across_salts() {
1980 let p = BehaviorProfile {
1981 seed: 99,
1982 ..BehaviorProfile::default()
1983 };
1984 let mut a = p.rng_for(1);
1985 let mut b = p.rng_for(2);
1986 use rand::RngExt;
1987 assert_ne!(a.random::<u64>(), b.random::<u64>());
1988 }
1989
1990 #[test]
1991 fn mouse_trajectory_starts_at_from_and_ends_at_to() {
1992 let p = BehaviorProfile {
1993 seed: 42,
1994 ..BehaviorProfile::default()
1995 };
1996 let pts = mouse_trajectory((100.0, 100.0), (500.0, 400.0), 50.0, &p);
1997 assert!(pts.len() > 5);
1998 let first = pts[0];
1999 let last = pts[pts.len() - 1];
2000 assert!((first.x - 100.0).abs() < 10.0, "first x={}", first.x);
2001 assert!((first.y - 100.0).abs() < 10.0, "first y={}", first.y);
2002 assert_eq!(last.x, 500.0);
2003 assert_eq!(last.y, 400.0);
2004 }
2005
2006 #[test]
2007 fn mouse_trajectory_obeys_fitts_law_total_time() {
2008 let p = BehaviorProfile {
2009 seed: 42,
2010 ..BehaviorProfile::default()
2011 };
2012 let pts = mouse_trajectory((0.0, 0.0), (500.0, 0.0), 50.0, &p);
2013 let last_t = pts[pts.len() - 1].t_ms;
2014 assert!(
2015 (700.0..=950.0).contains(&last_t),
2016 "expected ~805 ms, got {last_t}"
2017 );
2018 }
2019
2020 #[test]
2021 fn mouse_trajectory_uses_8ms_sample_rate() {
2022 let p = BehaviorProfile {
2023 seed: 42,
2024 ..BehaviorProfile::default()
2025 };
2026 let pts = mouse_trajectory((0.0, 0.0), (200.0, 0.0), 30.0, &p);
2027 for w in pts.windows(2) {
2028 let dt = w[1].t_ms - w[0].t_ms;
2029 assert!((dt - 8.0).abs() < 1e-3, "gap {} not 8 ms", dt);
2030 }
2031 }
2032
2033 #[test]
2034 fn mouse_trajectory_has_velocity_diversity() {
2035 let p = BehaviorProfile {
2036 seed: 42,
2037 ..BehaviorProfile::default()
2038 };
2039 let mut rng = fixed_rng();
2040 let pts = mouse_trajectory_with_rng((0.0, 0.0), (600.0, 400.0), 40.0, &p, &mut rng);
2041 let speeds: Vec<f32> = pts
2042 .windows(2)
2043 .map(|w| ((w[1].x - w[0].x).powi(2) + (w[1].y - w[0].y).powi(2)).sqrt())
2044 .collect();
2045 let mean = speeds.iter().sum::<f32>() / speeds.len() as f32;
2046 let var = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / speeds.len() as f32;
2047 let std = var.sqrt();
2048 let cv = std / mean.max(1e-3);
2049 assert!(cv > 0.4, "speed CV too low: {cv}");
2050 }
2051
2052 #[test]
2053 fn mouse_trajectory_deterministic_per_seed() {
2054 let p = BehaviorProfile {
2055 seed: 123,
2056 ..BehaviorProfile::default()
2057 };
2058 let mut r1 = p.rng_for(1);
2059 let mut r2 = p.rng_for(1);
2060 let a = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r1);
2061 let b = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r2);
2062 assert_eq!(a.len(), b.len());
2063 for (pa, pb) in a.iter().zip(b.iter()) {
2064 assert_eq!(pa, pb);
2065 }
2066 }
2067
2068 #[test]
2069 fn mouse_trajectory_no_endpoint_jerk_spike() {
2070 for seed in 0..40u64 {
2071 let p = BehaviorProfile {
2072 seed,
2073 ..BehaviorProfile::default()
2074 };
2075 let mut r = p.rng_for(2);
2076 let tr = mouse_trajectory_with_rng((12.0, 30.0), (840.0, 510.0), 28.0, &p, &mut r);
2077 assert!(tr.len() >= 8);
2078 let step =
2079 |a: &MousePoint, b: &MousePoint| ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
2080 let steps: Vec<f32> = tr.windows(2).map(|w| step(&w[0], &w[1])).collect();
2081 let n = steps.len();
2082 let final_step = steps[n - 1];
2083 let mut sorted = steps.clone();
2084 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2085 let median = sorted[n / 2];
2086 let max_step = sorted[n - 1];
2087 assert!(
2088 final_step <= max_step + 1e-3,
2089 "seed {seed}: final step {final_step} exceeds max interior {max_step}"
2090 );
2091 assert!(
2092 final_step <= median * 6.0 + 5.0,
2093 "seed {seed}: final step {final_step} is jerk outlier vs median {median}"
2094 );
2095 let last = tr.last().unwrap();
2096 assert!((last.x - 840.0).abs() < 1e-2 && (last.y - 510.0).abs() < 1e-2);
2097 }
2098 }
2099
2100 #[test]
2101 fn keystroke_first_has_no_flight() {
2102 let p = BehaviorProfile {
2103 seed: 42,
2104 ..BehaviorProfile::default()
2105 };
2106 let ks = keystroke_timings("hi", &p);
2107 assert_eq!(ks[0].flight_ms, 0.0);
2108 assert!(ks[1].flight_ms > 0.0);
2109 }
2110
2111 #[test]
2112 fn keystroke_dwell_in_realistic_range() {
2113 let p = BehaviorProfile {
2114 seed: 42,
2115 ..BehaviorProfile::default()
2116 };
2117 let ks = keystroke_timings("the quick brown fox jumps over the lazy dog", &p);
2118 let mean_dwell: f32 = ks.iter().map(|k| k.dwell_ms).sum::<f32>() / ks.len() as f32;
2119 assert!(
2120 (70.0..=150.0).contains(&mean_dwell),
2121 "mean dwell {mean_dwell} outside plausible range"
2122 );
2123 }
2124
2125 #[test]
2126 fn keystroke_flight_scales_with_wpm() {
2127 let slow = BehaviorProfile {
2128 seed: 42,
2129 typing_wpm_mean: 30.0,
2130 ..BehaviorProfile::default()
2131 };
2132 let fast = BehaviorProfile {
2133 seed: 42,
2134 typing_wpm_mean: 70.0,
2135 ..BehaviorProfile::default()
2136 };
2137 let s = keystroke_timings("the quick brown fox jumps over", &slow);
2138 let f = keystroke_timings("the quick brown fox jumps over", &fast);
2139 let mean = |ks: &[KeystrokeTiming]| -> f32 {
2140 ks.iter().skip(1).map(|k| k.flight_ms).sum::<f32>() / (ks.len() - 1) as f32
2141 };
2142 assert!(
2143 mean(&s) > mean(&f),
2144 "30 WPM flight {} should exceed 70 WPM flight {}",
2145 mean(&s),
2146 mean(&f)
2147 );
2148 }
2149
2150 #[test]
2151 fn keystroke_bigram_th_faster_than_dd() {
2152 let mut th_total = 0.0_f32;
2153 let mut dd_total = 0.0_f32;
2154 for seed in 0..50 {
2155 let prof = BehaviorProfile {
2156 seed,
2157 ..BehaviorProfile::default()
2158 };
2159 let th = keystroke_timings("th", &prof);
2160 let dd = keystroke_timings("dd", &prof);
2161 th_total += th[1].flight_ms;
2162 dd_total += dd[1].flight_ms;
2163 }
2164 let th_mean = th_total / 50.0;
2165 let dd_mean = dd_total / 50.0;
2166 assert!(
2167 dd_mean > th_mean * 1.5,
2168 "dd flight {dd_mean} should be > 1.5× th flight {th_mean}"
2169 );
2170 }
2171
2172 #[test]
2173 fn keystroke_deterministic_per_seed() {
2174 let mut rng_a = rand_chacha::ChaCha20Rng::seed_from_u64(7);
2175 let mut rng_b = rand_chacha::ChaCha20Rng::seed_from_u64(7);
2176 let p = BehaviorProfile::default();
2177 let a = keystroke_timings_with_rng("hello world", &p, &mut rng_a);
2178 let b = keystroke_timings_with_rng("hello world", &p, &mut rng_b);
2179 assert_eq!(a, b);
2180 }
2181
2182 #[test]
2183 fn trackpad_burst_decays_to_zero() {
2184 let p = BehaviorProfile {
2185 seed: 42,
2186 scroll_style: ScrollStyle::Trackpad,
2187 ..BehaviorProfile::default()
2188 };
2189 let ticks = wheel_burst(-1000.0, &p);
2190 assert!(ticks.len() > 5);
2191 for t in &ticks {
2192 assert_eq!(t.mode, 0);
2193 assert!(t.delta_y < 0.0);
2194 }
2195 let cum: f32 = ticks.iter().map(|t| t.delta_y).sum();
2196 assert!(
2197 (cum + 1000.0).abs() < 200.0,
2198 "cumulative {cum} not close to -1000"
2199 );
2200 for w in ticks.windows(2) {
2201 let dt = w[1].t_ms - w[0].t_ms;
2202 assert!((dt - 16.0).abs() < 1e-3);
2203 }
2204 }
2205
2206 #[test]
2207 fn wheel_burst_uses_100px_notches() {
2208 let p = BehaviorProfile {
2209 seed: 42,
2210 scroll_style: ScrollStyle::Wheel,
2211 ..BehaviorProfile::default()
2212 };
2213 let ticks = wheel_burst(500.0, &p);
2214 assert_eq!(ticks.len(), 5);
2215 for t in &ticks {
2216 assert_eq!(t.delta_y, 100.0);
2217 assert_eq!(t.mode, 0);
2218 }
2219 }
2220
2221 #[test]
2222 fn wheel_burst_intervals_are_lognormal_distributed() {
2223 let p = BehaviorProfile {
2224 seed: 42,
2225 scroll_style: ScrollStyle::Wheel,
2226 ..BehaviorProfile::default()
2227 };
2228 let ticks = wheel_burst(2000.0, &p);
2229 let intervals: Vec<f32> = ticks.windows(2).map(|w| w[1].t_ms - w[0].t_ms).collect();
2230 let mean = intervals.iter().sum::<f32>() / intervals.len() as f32;
2231 assert!(
2232 (mean - 180.0).abs() < 200.0,
2233 "mean interval {mean} too far from 180 ms"
2234 );
2235 let mut sorted = intervals.clone();
2236 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2237 sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-3);
2238 assert!(sorted.len() > 5, "only {} distinct intervals", sorted.len());
2239 }
2240
2241 #[test]
2242 fn default_seeds_differ_across_instances() {
2243 let a = BehaviorProfile::default();
2244 let b = BehaviorProfile::default();
2245 assert_ne!(a.seed, b.seed);
2246 }
2247}