Skip to main content

rustenium_identity/
cdp.rs

1use crate::error::IdentityError;
2use crate::identity::{Browser, Identity, Os};
3
4use crate::script;
5use crate::ua;
6
7use rustenium::browsers::chrome::browser::ChromeBrowser;
8use rustenium::browsers::cdp_browser::CdpBrowser;
9use rustenium::cdp::target_manager::InitScript;
10use rustenium_cdp_definitions::browser_protocol::emulation::commands::{
11    SetDeviceMetricsOverride, SetEmitTouchEventsForMouse, SetEmitTouchEventsForMouseConfiguration,
12    SetHardwareConcurrencyOverride, SetLocaleOverride, SetTimezoneOverride,
13    SetTouchEmulationEnabled, SetUserAgentOverride,
14};
15use rustenium_cdp_definitions::browser_protocol::emulation::types::{
16    UserAgentBrandVersion, UserAgentMetadata,
17};
18use rustenium_cdp_definitions::browser_protocol::network::commands::SetUserAgentOverride as NetworkSetUserAgentOverride;
19
20/// Build UserAgentMetadata (Client Hints) for Chrome/Edge identities.
21fn build_client_hints(identity: &Identity) -> UserAgentMetadata {
22    let major = identity.browser_version.first().copied().unwrap_or(0).to_string();
23    let full_version = identity
24        .browser_version
25        .iter()
26        .map(|v| v.to_string())
27        .collect::<Vec<_>>()
28        .join(".");
29    let is_mobile = identity.device_model.is_some();
30
31    let (brand_name, brand_name_full) = match identity.browser {
32        Browser::Edge => ("Microsoft Edge", "Microsoft Edge"),
33        _ => ("Google Chrome", "Google Chrome"),
34    };
35
36    let grease_brand = match identity.os {
37        Os::Android => "Not-A.Brand",
38        _ => "Not;A=Brand",
39    };
40
41    let brands = vec![
42        UserAgentBrandVersion::new("Chromium", &major),
43        UserAgentBrandVersion::new(grease_brand, "24"),
44        UserAgentBrandVersion::new(brand_name, &major),
45    ];
46
47    let full_version_list = vec![
48        UserAgentBrandVersion::new("Chromium", &full_version),
49        UserAgentBrandVersion::new(grease_brand, "24.0.0.0"),
50        UserAgentBrandVersion::new(brand_name_full, &full_version),
51    ];
52
53    let platform = match identity.os {
54        Os::Windows => "Windows",
55        Os::Macintosh => "macOS",
56        Os::Linux => "Linux",
57        Os::Android => "Android",
58        Os::Ios => "iOS",
59    };
60
61    // On mobile, architecture and bitness should be empty
62    let (architecture, bitness) = if is_mobile {
63        (String::new(), String::new())
64    } else {
65        (
66            identity.platform.architecture.clone().unwrap_or_else(|| "x86".into()),
67            identity.platform.bitness.clone().unwrap_or_else(|| "64".into()),
68        )
69    };
70    let model = identity.device_model.clone().unwrap_or_default();
71
72    UserAgentMetadata::builder()
73        .brands(brands)
74        .full_version_lists(full_version_list)
75        .platform(platform)
76        .platform_version(&identity.platform.version)
77        .architecture(&architecture)
78        .model(&model)
79        .mobile(is_mobile)
80        .bitness(bitness)
81        .build()
82        .unwrap()
83}
84
85/// Apply all CDP emulation commands and register the stealth script.
86pub async fn apply_identity(
87    browser: &mut ChromeBrowser,
88    identity: &Identity,
89    timezone: &str,
90) -> Result<(), IdentityError> {
91    let user_agent = ua::build_user_agent(identity)?;
92    let lang0 = identity
93        .language
94        .first()
95        .cloned()
96        .unwrap_or_else(|| "en-US".into());
97    let is_mobile = identity.device_model.is_some();
98
99    // Build client hints for Chrome/Edge (skip iOS — all iOS browsers are WebKit-based)
100    let client_hints = if matches!(identity.browser, Browser::Chrome | Browser::Edge)
101        && !matches!(identity.os, Os::Ios)
102    {
103        Some(build_client_hints(identity))
104    } else {
105        None
106    };
107
108    // Note the emulation overrides below are session-scoped: rustenium replicates
109    // them to every target automatically, so a worker's timezone, locale and UA-CH
110    // stay consistent with the page's rather than leaking the host's.
111
112    // Emulation.setUserAgentOverride
113    let mut ua_builder = SetUserAgentOverride::builder()
114        .user_agent(&user_agent)
115        .accept_language(&lang0)
116        .platform(identity.platform.navigator_platform.as_str());
117    if let Some(ref metadata) = client_hints {
118        ua_builder = ua_builder.user_agent_metadata(metadata.clone());
119    }
120    let ua_cmd = ua_builder.build().unwrap();
121
122    // `uaFullVersion` has no non-deprecated way to be set. Chrome derives it from
123    // the legacy scalar `fullVersion`, not from `fullVersionList`, and CDP dropped
124    // that field from the published protocol — so the generated UserAgentMetadata
125    // cannot express it and Chrome falls back to the *real* browser version. Left
126    // alone, getHighEntropyValues(['uaFullVersion']) reports the host's Chrome next
127    // to the persona's everywhere else.
128    //
129    // Measured: the browser still honours the field, so send it alongside.
130    let full_version = identity
131        .browser_version
132        .iter()
133        .map(|v| v.to_string())
134        .collect::<Vec<_>>()
135        .join(".");
136    let ua_extensions = serde_json::json!({
137        "userAgentMetadata": { "fullVersion": full_version }
138    });
139    browser
140        .send_command_extended(ua_cmd.into(), ua_extensions.clone())
141        .await
142        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
143
144    // Network.setUserAgentOverride (belt-and-suspenders, also with client hints)
145    let mut net_builder = NetworkSetUserAgentOverride::builder()
146        .user_agent(&user_agent)
147        .accept_language(&lang0);
148    if let Some(ref metadata) = client_hints {
149        net_builder = net_builder.user_agent_metadata(metadata.clone());
150    }
151    let net_ua_cmd = net_builder.build().unwrap();
152    // Carries its own copy of the metadata, so it needs the same extension —
153    // otherwise it lands second and resets `fullVersion` to the real version.
154    browser
155        .send_command_extended(net_ua_cmd.into(), ua_extensions.clone())
156        .await
157        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
158
159    // Emulation.setDeviceMetricsOverride — only spoof display dimensions for mobile
160    if is_mobile {
161        let screen_width = (identity.screen.logical_width as f64 * identity.screen.density_pixel_ratio as f64) as i64;
162        let screen_height = (identity.screen.logical_height as f64 * identity.screen.density_pixel_ratio as f64) as i64;
163        let device_cmd = SetDeviceMetricsOverride::builder()
164            .width(identity.screen.logical_width as i64)
165            .height(identity.screen.logical_height as i64)
166            .device_scale_factor(identity.screen.density_pixel_ratio as f64)
167            .mobile(true)
168            .screen_width(screen_width)
169            .screen_height(screen_height)
170            .build()
171            .unwrap();
172        browser
173            .send_command(device_cmd.into())
174            .await
175            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
176    }
177
178    // Emulation.setTouchEmulationEnabled
179    if identity.has_touch {
180        let touch_cmd = SetTouchEmulationEnabled::builder()
181            .enabled(true)
182            .max_touch_points(5i64)
183            .build()
184            .unwrap();
185        browser
186            .send_command(touch_cmd.into())
187            .await
188            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
189
190        // Emulation.setEmitTouchEventsForMouse — translate the automation's mouse
191        // input into synthetic touch events so a touch device receives touchstart/
192        // move/end (as a real finger would) instead of mouse events.
193        let configuration = if is_mobile {
194            SetEmitTouchEventsForMouseConfiguration::Mobile
195        } else {
196            SetEmitTouchEventsForMouseConfiguration::Desktop
197        };
198        let emit_touch_cmd = SetEmitTouchEventsForMouse::builder()
199            .enabled(true)
200            .configuration(configuration)
201            .build()
202            .unwrap();
203        browser
204            .send_command(emit_touch_cmd.into())
205            .await
206            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
207    }
208
209    // Emulation.setLocaleOverride
210    let locale_cmd = SetLocaleOverride::builder()
211        .locale(&lang0)
212        .build();
213    browser
214        .send_command(locale_cmd.into())
215        .await
216        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
217
218    // Emulation.setTimezoneOverride
219    let tz_cmd = SetTimezoneOverride::builder()
220        .timezone_id(timezone)
221        .build()
222        .unwrap();
223    browser
224        .send_command(tz_cmd.into())
225        .await
226        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
227
228    // Emulation.setHardwareConcurrencyOverride
229    let hc_cmd = SetHardwareConcurrencyOverride::builder()
230        .hardware_concurrency(identity.hardware_concurrency as i64)
231        .build()
232        .unwrap();
233    browser
234        .send_command(hc_cmd.into())
235        .await
236        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
237
238    // One registration covers every realm. The target manager applies it to each
239    // target already attached and to every one that appears later, installing it
240    // while the target is still frozen — before it has run a statement.
241    //
242    // This is what reaches workers and cross-origin iframes. They are separate
243    // targets with their own realms, so without it they report the host's real
244    // values while the page reports the persona, and comparing the two is a
245    // one-line detection. Service workers especially: they belong to no tab, so
246    // only a browser-scoped registration can reach them at all.
247    //
248    // Two sources because a worker realm has no window or document and uses
249    // WorkerNavigator, so the document script throws on its first statement there.
250    browser
251        .add_init_script(InitScript {
252            page: Some(script::build_stealth_script(identity)),
253            worker: Some(script::build_worker_script(identity)),
254        })
255        .await;
256    Ok(())
257}