1use serde::Serialize;
14use serde_json::{json, Map, Value};
15
16use super::profile::{FingerprintProfile, UserAgentData};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct CdpCommand {
21 pub method: &'static str,
23 pub params: Value,
25}
26
27impl CdpCommand {
28 fn new(method: &'static str, params: Value) -> Self {
29 Self { method, params }
30 }
31}
32
33fn insert_if_some<T: Serialize>(params: &mut Map<String, Value>, key: &str, value: &Option<T>) {
34 if let Some(value) = value {
35 params.insert(key.to_string(), json!(value));
36 }
37}
38
39fn user_agent_metadata(data: &UserAgentData) -> Value {
40 let mut metadata = Map::new();
43 metadata.insert(
44 "platform".to_string(),
45 json!(data.platform.clone().unwrap_or_default()),
46 );
47 metadata.insert(
48 "platformVersion".to_string(),
49 json!(data.platform_version.clone().unwrap_or_default()),
50 );
51 metadata.insert(
52 "architecture".to_string(),
53 json!(data.architecture.clone().unwrap_or_default()),
54 );
55 metadata.insert(
56 "model".to_string(),
57 json!(data.model.clone().unwrap_or_default()),
58 );
59 metadata.insert("mobile".to_string(), json!(data.mobile.unwrap_or(false)));
60 insert_if_some(&mut metadata, "brands", &data.brands);
61 insert_if_some(&mut metadata, "fullVersionList", &data.full_version_list);
62 insert_if_some(&mut metadata, "bitness", &data.bitness);
63 insert_if_some(&mut metadata, "fullVersion", &data.full_version);
67 insert_if_some(&mut metadata, "wow64", &data.wow64);
68 insert_if_some(&mut metadata, "formFactors", &data.form_factors);
69 Value::Object(metadata)
70}
71
72fn user_agent_command(profile: &FingerprintProfile) -> Option<CdpCommand> {
73 if profile.user_agent.is_none() && profile.accept_language.is_none() {
74 return None;
75 }
76 let mut params = Map::new();
77 params.insert(
79 "userAgent".to_string(),
80 json!(profile.user_agent.clone().unwrap_or_default()),
81 );
82 insert_if_some(&mut params, "acceptLanguage", &profile.accept_language);
83 insert_if_some(&mut params, "platform", &profile.platform);
84 if let Some(data) = &profile.user_agent_data {
85 params.insert("userAgentMetadata".to_string(), user_agent_metadata(data));
86 }
87 Some(CdpCommand::new(
88 "Emulation.setUserAgentOverride",
89 Value::Object(params),
90 ))
91}
92
93fn device_metrics(profile: &FingerprintProfile) -> Option<Value> {
94 if profile.viewport.is_none() && profile.screen.is_none() {
95 return None;
96 }
97 let viewport = profile.viewport.clone().unwrap_or_default();
98 let mut params = Map::new();
99 params.insert("width".to_string(), json!(viewport.width.unwrap_or(0)));
102 params.insert("height".to_string(), json!(viewport.height.unwrap_or(0)));
103 params.insert(
104 "deviceScaleFactor".to_string(),
105 json!(viewport.device_scale_factor.unwrap_or(0.0)),
106 );
107 params.insert(
108 "mobile".to_string(),
109 json!(viewport.mobile.unwrap_or(false)),
110 );
111 if let Some(screen) = &profile.screen {
114 if let (Some(width), Some(height)) = (screen.width, screen.height) {
115 params.insert("screenWidth".to_string(), json!(width));
116 params.insert("screenHeight".to_string(), json!(height));
117 }
118 }
119 Some(Value::Object(params))
120}
121
122fn emulated_media_features(profile: &FingerprintProfile) -> Vec<Value> {
123 let mut features = Vec::new();
124 if let Some(value) = &profile.reduced_motion {
125 features.push(json!({"name": "prefers-reduced-motion", "value": value}));
126 }
127 if let Some(value) = &profile.forced_colors {
128 features.push(json!({"name": "forced-colors", "value": value}));
129 }
130 if let Some(value) = &profile.color_scheme {
131 features.push(json!({"name": "prefers-color-scheme", "value": value}));
132 }
133 features
134}
135
136pub fn build_cdp_emulation_commands(profile: &FingerprintProfile) -> Vec<CdpCommand> {
138 let mut commands = Vec::new();
139
140 if let Some(command) = user_agent_command(profile) {
141 commands.push(command);
142 }
143
144 if let Some(timezone_id) = &profile.timezone_id {
145 commands.push(CdpCommand::new(
146 "Emulation.setTimezoneOverride",
147 json!({ "timezoneId": timezone_id }),
148 ));
149 }
150
151 if let Some(locale) = &profile.locale {
152 commands.push(CdpCommand::new(
153 "Emulation.setLocaleOverride",
154 json!({ "locale": locale }),
155 ));
156 }
157
158 if let Some(hardware_concurrency) = profile.hardware_concurrency {
159 commands.push(CdpCommand::new(
160 "Emulation.setHardwareConcurrencyOverride",
161 json!({ "hardwareConcurrency": hardware_concurrency }),
162 ));
163 }
164
165 if let Some(metrics) = device_metrics(profile) {
166 commands.push(CdpCommand::new(
167 "Emulation.setDeviceMetricsOverride",
168 metrics,
169 ));
170 }
171
172 if let Some(max_touch_points) = profile.max_touch_points {
173 commands.push(CdpCommand::new(
174 "Emulation.setTouchEmulationEnabled",
175 json!({
176 "enabled": max_touch_points > 0,
177 "maxTouchPoints": max_touch_points.max(1),
178 }),
179 ));
180 }
181
182 let features = emulated_media_features(profile);
183 if !features.is_empty() {
184 commands.push(CdpCommand::new(
185 "Emulation.setEmulatedMedia",
186 json!({ "features": features }),
187 ));
188 }
189
190 if let Some(geolocation) = &profile.geolocation {
191 commands.push(CdpCommand::new(
192 "Emulation.setGeolocationOverride",
193 json!(geolocation),
194 ));
195 }
196
197 commands
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::fingerprint::presets::create_default_fingerprint_preset;
204 use crate::fingerprint::profile::{
205 ColorScheme, ForcedColors, GeolocationProfile, ReducedMotion, ScreenProfile,
206 ViewportProfile,
207 };
208
209 const WINDOWS_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
210 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7000.55 Safari/537.36";
211
212 fn commands(profile: FingerprintProfile) -> Vec<CdpCommand> {
213 build_cdp_emulation_commands(&profile.resolve().expect("profile resolves"))
214 }
215
216 fn methods(profile: FingerprintProfile) -> Vec<&'static str> {
217 commands(profile)
218 .into_iter()
219 .map(|command| command.method)
220 .collect()
221 }
222
223 fn params(profile: FingerprintProfile, method: &str) -> Value {
224 commands(profile)
225 .into_iter()
226 .find(|command| command.method == method)
227 .unwrap_or_else(|| panic!("{method} was not sent"))
228 .params
229 }
230
231 #[test]
232 fn emits_nothing_for_an_empty_profile() {
233 assert!(commands(FingerprintProfile::default()).is_empty());
234 }
235
236 #[test]
237 fn sends_only_the_commands_the_profile_asks_for() {
238 assert_eq!(
239 methods(FingerprintProfile::default().timezone_id("Europe/Berlin")),
240 vec!["Emulation.setTimezoneOverride"]
241 );
242 assert_eq!(
243 methods(FingerprintProfile::default().hardware_concurrency(4)),
244 vec!["Emulation.setHardwareConcurrencyOverride"]
245 );
246 }
247
248 #[test]
249 fn keeps_a_stable_command_order_for_a_full_profile() {
250 let profile = FingerprintProfile::default()
251 .user_agent(WINDOWS_USER_AGENT)
252 .timezone_id("Europe/Berlin")
253 .locale("de-DE")
254 .hardware_concurrency(12)
255 .screen(ScreenProfile {
256 width: Some(2560),
257 height: Some(1440),
258 ..ScreenProfile::default()
259 })
260 .viewport(ViewportProfile {
261 width: Some(1280),
262 height: Some(720),
263 ..ViewportProfile::default()
264 })
265 .max_touch_points(0)
266 .color_scheme(ColorScheme::Dark)
267 .geolocation(GeolocationProfile {
268 latitude: 52.52,
269 longitude: 13.405,
270 accuracy: Some(20.0),
271 });
272
273 assert_eq!(
274 methods(profile),
275 vec![
276 "Emulation.setUserAgentOverride",
277 "Emulation.setTimezoneOverride",
278 "Emulation.setLocaleOverride",
279 "Emulation.setHardwareConcurrencyOverride",
280 "Emulation.setDeviceMetricsOverride",
281 "Emulation.setTouchEmulationEnabled",
282 "Emulation.setEmulatedMedia",
283 "Emulation.setGeolocationOverride",
284 ]
285 );
286 }
287
288 #[test]
289 fn supplies_the_required_empty_user_agent_when_only_the_language_changes() {
290 let params = params(
291 FingerprintProfile::default().languages(["fr-FR", "fr"]),
292 "Emulation.setUserAgentOverride",
293 );
294
295 assert_eq!(params["userAgent"], json!(""));
298 assert_eq!(params["acceptLanguage"], json!("fr-FR,fr"));
299 }
300
301 #[test]
302 fn carries_the_client_hints_including_the_deprecated_full_version() {
303 let params = params(
304 FingerprintProfile::default()
305 .user_agent(WINDOWS_USER_AGENT)
306 .platform("Win32"),
307 "Emulation.setUserAgentOverride",
308 );
309 let metadata = ¶ms["userAgentMetadata"];
310
311 assert_eq!(params["platform"], json!("Win32"));
312 assert_eq!(metadata["platform"], json!("Windows"));
313 assert_eq!(metadata["fullVersion"], json!("140.0.7000.55"));
316 assert_eq!(metadata["bitness"], json!("64"));
317 assert_eq!(metadata["wow64"], json!(false));
318 assert_eq!(metadata["formFactors"], json!(["Desktop"]));
319 }
320
321 #[test]
322 fn always_fills_the_protocol_required_metadata_fields() {
323 let params = params(
324 FingerprintProfile::default()
325 .user_agent("custom agent")
326 .user_agent_data(UserAgentData {
327 brands: Some(vec![crate::fingerprint::profile::BrandVersion {
328 brand: "Custom".to_string(),
329 version: "1".to_string(),
330 }]),
331 ..UserAgentData::default()
332 }),
333 "Emulation.setUserAgentOverride",
334 );
335 let metadata = params["userAgentMetadata"]
336 .as_object()
337 .expect("metadata object");
338
339 for field in [
341 "platform",
342 "platformVersion",
343 "architecture",
344 "model",
345 "mobile",
346 ] {
347 assert!(metadata.contains_key(field), "{field} must be present");
348 }
349 assert_eq!(metadata["mobile"], json!(false));
350 assert!(!metadata.contains_key("bitness"));
351 }
352
353 #[test]
354 fn leaves_the_window_size_alone_when_only_the_screen_is_described() {
355 let params = params(
356 FingerprintProfile::default().screen(ScreenProfile {
357 width: Some(2560),
358 height: Some(1440),
359 ..ScreenProfile::default()
360 }),
361 "Emulation.setDeviceMetricsOverride",
362 );
363
364 assert_eq!(
367 params,
368 json!({
369 "width": 0,
370 "height": 0,
371 "deviceScaleFactor": 0.0,
372 "mobile": false,
373 "screenWidth": 2560,
374 "screenHeight": 1440,
375 })
376 );
377 }
378
379 #[test]
380 fn sends_the_viewport_without_screen_dimensions_when_no_screen_is_set() {
381 let params = params(
382 FingerprintProfile::default().viewport(ViewportProfile {
383 width: Some(1280),
384 height: Some(720),
385 device_scale_factor: Some(2.0),
386 mobile: Some(true),
387 }),
388 "Emulation.setDeviceMetricsOverride",
389 );
390
391 assert_eq!(
392 params,
393 json!({
394 "width": 1280,
395 "height": 720,
396 "deviceScaleFactor": 2.0,
397 "mobile": true,
398 })
399 );
400 }
401
402 #[test]
403 fn disables_touch_emulation_for_a_profile_that_names_zero_touch_points() {
404 assert_eq!(
407 params(
408 FingerprintProfile::default().max_touch_points(0),
409 "Emulation.setTouchEmulationEnabled"
410 ),
411 json!({ "enabled": false, "maxTouchPoints": 1 })
412 );
413 assert_eq!(
414 params(
415 FingerprintProfile::default().max_touch_points(5),
416 "Emulation.setTouchEmulationEnabled"
417 ),
418 json!({ "enabled": true, "maxTouchPoints": 5 })
419 );
420 }
421
422 #[test]
423 fn collects_every_media_preference_into_a_single_command() {
424 assert_eq!(
425 params(
426 FingerprintProfile::default()
427 .color_scheme(ColorScheme::Dark)
428 .reduced_motion(ReducedMotion::Reduce)
429 .forced_colors(ForcedColors::Active),
430 "Emulation.setEmulatedMedia"
431 ),
432 json!({
433 "features": [
434 { "name": "prefers-reduced-motion", "value": "reduce" },
435 { "name": "forced-colors", "value": "active" },
436 { "name": "prefers-color-scheme", "value": "dark" },
437 ]
438 })
439 );
440 }
441
442 #[test]
443 fn passes_geolocation_through_as_the_protocol_spells_it() {
444 assert_eq!(
445 params(
446 FingerprintProfile::default().geolocation(GeolocationProfile {
447 latitude: 48.85,
448 longitude: 2.35,
449 accuracy: Some(10.0),
450 }),
451 "Emulation.setGeolocationOverride"
452 ),
453 json!({ "latitude": 48.85, "longitude": 2.35, "accuracy": 10.0 })
454 );
455 }
456
457 #[test]
461 fn sends_every_browser_enforced_field_of_a_preset() {
462 let profile = create_default_fingerprint_preset("android-chrome").expect("preset builds");
463 let commands = build_cdp_emulation_commands(&profile);
464 let methods: Vec<_> = commands.iter().map(|command| command.method).collect();
465
466 assert_eq!(
467 methods,
468 vec![
469 "Emulation.setUserAgentOverride",
470 "Emulation.setTimezoneOverride",
471 "Emulation.setLocaleOverride",
472 "Emulation.setHardwareConcurrencyOverride",
473 "Emulation.setDeviceMetricsOverride",
474 "Emulation.setTouchEmulationEnabled",
475 ]
476 );
477 assert_eq!(
478 commands[0].params["userAgentMetadata"]["mobile"],
479 json!(true)
480 );
481 }
482}