1use std::path::PathBuf;
6use std::process::Command;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10static BROWSER_COUNTER: AtomicU64 = AtomicU64::new(0);
12
13use crate::cdp::transport::launch_chrome;
14use crate::cdp::types::{EmulationSetUserAgentOverride, UserAgentBrandVersion, UserAgentMetadata};
15use crate::cdp::{Connection, Transport};
16use crate::error::{Error, Result};
17use crate::page::Page;
18use crate::stealth::fingerprint::Fingerprint;
19use crate::stealth::{build_evasion_script_for, find_chrome, ChromePatcher};
20use crate::StealthConfig;
21
22fn stealth_args(config: &StealthConfig, fingerprint: &Fingerprint) -> Vec<String> {
24 let mut args = vec![
25 "--disable-blink-features=AutomationControlled".into(),
27 "--disable-features=IsolateOrigins,site-per-process,AutomationControlled,EnableAutomation"
28 .into(),
29 "--enable-features=NetworkService,NetworkServiceInProcess".into(),
30 "--disable-infobars".into(),
32 "--disable-dev-shm-usage".into(),
33 "--disable-ipc-flooding-protection".into(),
34 "--disable-renderer-backgrounding".into(),
35 "--disable-background-timer-throttling".into(),
36 "--disable-backgrounding-occluded-windows".into(),
37 "--no-first-run".into(),
39 "--no-default-browser-check".into(),
40 "--no-sandbox".into(),
41 "--disable-default-apps".into(),
42 "--disable-component-extensions-with-background-pages".into(),
43 "--disable-hang-monitor".into(),
44 "--disable-popup-blocking".into(),
45 "--disable-prompt-on-repost".into(),
46 "--disable-sync".into(),
47 "--disable-translate".into(),
48 "--metrics-recording-only".into(),
49 "--safebrowsing-disable-auto-update".into(),
50 "--disable-client-side-phishing-detection".into(),
51 "--password-store=basic".into(),
52 "--use-mock-keychain".into(),
53 "--lang=en-US".into(),
54 format!(
56 "--window-size={},{}",
57 config.viewport_width, config.viewport_height
58 ),
59 format!(
62 "--ozone-override-screen-size={},{}",
63 config.viewport_width, config.viewport_height
64 ),
65 ];
66
67 args.push(format!("--user-agent={}", fingerprint.user_agent));
69
70 if config.headless {
72 args.push("--headless=new".into());
73 }
74
75 if let Some(ref proxy) = config.proxy {
77 args.push(format!("--proxy-server={}", proxy));
78 }
79
80 for arg in &config.extra_args {
82 args.push(arg.clone());
83 }
84
85 args
86}
87
88#[derive(Debug, Clone)]
90pub struct TabInfo {
91 pub id: String,
93 pub title: String,
95 pub url: String,
97}
98
99fn ua_override_for(fp: &Fingerprint) -> EmulationSetUserAgentOverride {
101 let to_brands = |v: Vec<(String, String)>| -> Vec<UserAgentBrandVersion> {
102 v.into_iter()
103 .map(|(brand, version)| UserAgentBrandVersion { brand, version })
104 .collect()
105 };
106 let architecture = if fp.webgl_renderer.contains("Apple M") {
107 "arm"
108 } else {
109 "x86"
110 };
111 EmulationSetUserAgentOverride {
112 user_agent: fp.user_agent.clone(),
113 accept_language: Some("en-US,en;q=0.9".to_string()),
114 platform: Some(fp.nav_platform().to_string()),
115 user_agent_metadata: Some(UserAgentMetadata {
116 brands: to_brands(fp.ch_brands()),
117 full_version_list: to_brands(fp.ch_full_version_brands()),
118 platform: fp.ch_platform().to_string(),
119 platform_version: fp.platform_version.clone(),
120 architecture: architecture.to_string(),
121 model: String::new(),
122 mobile: false,
123 bitness: "64".to_string(),
124 wow64: false,
125 }),
126 }
127}
128
129pub struct Browser {
131 connection: Connection,
132 config: Arc<StealthConfig>,
133 user_data_dir: Option<PathBuf>,
135 fingerprint: Option<Fingerprint>,
137 evasion_script: String,
139}
140
141impl Browser {
142 pub async fn launch() -> Result<Self> {
144 Self::launch_with_config(StealthConfig::default()).await
145 }
146
147 pub async fn launch_visible() -> Result<Self> {
149 Self::launch_with_config(StealthConfig::visible()).await
150 }
151
152 pub async fn launch_debug() -> Result<Self> {
154 Self::launch_with_config(StealthConfig::debug()).await
155 }
156
157 pub async fn launch_with(configure: impl FnOnce(&mut StealthConfig)) -> Result<Self> {
162 let mut config = StealthConfig::default();
163 configure(&mut config);
164 Self::launch_with_config(config).await
165 }
166
167 pub async fn launch_with_config(config: StealthConfig) -> Result<Self> {
169 let config = Arc::new(config);
170
171 let (user_data_dir, owns_user_data_dir) = match config.user_data_dir.as_deref() {
174 Some(path) => {
175 let path = PathBuf::from(path);
176 std::fs::create_dir_all(&path)?;
177 (path, false)
178 }
179 None => {
180 let instance_id = BROWSER_COUNTER.fetch_add(1, Ordering::Relaxed);
181 let path = std::env::temp_dir().join(format!(
182 "eoka-browser-{}-{}",
183 std::process::id(),
184 instance_id
185 ));
186 let _ = std::fs::remove_dir_all(&path);
187 std::fs::create_dir_all(&path)?;
188 (path, true)
189 }
190 };
191
192 let launch = async {
194 let chrome_path = match &config.chrome_path {
195 Some(p) => PathBuf::from(p),
196 None => find_chrome()?,
197 };
198
199 let chrome_path = if config.patch_binary && owns_user_data_dir {
203 ChromePatcher::new(&chrome_path)?.get_patched_path()?
204 } else {
205 chrome_path
206 };
207
208 let detected_user_agent = if config.user_agent.is_none() {
209 installed_chrome_user_agent(&chrome_path)
210 } else {
211 None
212 };
213 let fingerprint = Fingerprint::resolve_for_profile(
214 config
215 .user_agent
216 .as_deref()
217 .or(detected_user_agent.as_deref()),
218 config.timezone.as_deref(),
219 (!owns_user_data_dir).then_some(user_data_dir.as_path()),
220 )?;
221
222 let mut args = stealth_args(&config, &fingerprint);
224 args.push(format!("--user-data-dir={}", user_data_dir.display()));
225
226 tracing::info!("Launching Chrome from {:?}", chrome_path);
227 let (child, ws_url) = launch_chrome(&chrome_path, &args)?;
228
229 let proxy_auth = match (&config.proxy_username, &config.proxy_password) {
230 (Some(u), Some(p)) => Some((u.clone(), p.clone())),
231 _ => None,
232 };
233 let transport =
234 Transport::new_with_options(child, &ws_url, proxy_auth, config.cdp_timeout).await?;
235 let connection = Connection::new(transport);
236
237 let version = connection.version().await?;
238 tracing::info!("Connected to Chrome: {}", version.product);
239
240 Ok::<(Connection, Fingerprint), Error>((connection, fingerprint))
241 }
242 .await;
243
244 let (connection, fingerprint) = match launch {
245 Ok(result) => result,
246 Err(e) => {
247 if owns_user_data_dir {
248 let _ = std::fs::remove_dir_all(&user_data_dir);
249 }
250 return Err(e);
251 }
252 };
253
254 let evasion_script = build_evasion_script_for(&config, &fingerprint);
255
256 Ok(Self {
257 connection,
258 config,
259 user_data_dir: owns_user_data_dir.then_some(user_data_dir),
260 fingerprint: Some(fingerprint),
261 evasion_script,
262 })
263 }
264
265 pub async fn connect(ws_url: &str) -> Result<Self> {
272 Self::connect_with_config(ws_url, StealthConfig::live()).await
273 }
274
275 pub async fn connect_with_config(ws_url: &str, config: StealthConfig) -> Result<Self> {
278 let config = Arc::new(config);
279 let transport = crate::cdp::transport::Transport::connect_with_options(
280 ws_url,
281 config.cdp_timeout,
282 config.filter_cdp,
283 )
284 .await?;
285 let connection = Connection::new(transport);
286 let version = connection.version().await?;
287 tracing::info!("Connected to Chrome: {}", version.product);
288 let (fingerprint, evasion_script) = if config.live_session {
290 (None, String::new())
291 } else {
292 let fp = Fingerprint::resolve(config.user_agent.as_deref(), config.timezone.as_deref());
293 let script = build_evasion_script_for(&config, &fp);
294 (Some(fp), script)
295 };
296 Ok(Self {
297 connection,
298 config,
299 user_data_dir: None,
300 fingerprint,
301 evasion_script,
302 })
303 }
304
305 pub async fn connect_port(port: u16) -> Result<Self> {
308 let ws_url = crate::cdp::discover::discover_browser_ws("127.0.0.1", port)?;
309 Self::connect(&ws_url).await
310 }
311
312 pub async fn connect_port_with_config(port: u16, config: StealthConfig) -> Result<Self> {
314 let ws_url = crate::cdp::discover::discover_browser_ws("127.0.0.1", port)?;
315 Self::connect_with_config(&ws_url, config).await
316 }
317
318 async fn setup_session(&self, session: &crate::cdp::Session) -> Result<()> {
323 session.page_enable().await?;
324
325 if self.config.proxy_username.is_some() && self.config.proxy_password.is_some() {
326 session.fetch_enable(true).await?;
327 }
328
329 if !self.config.live_session {
330 if let Some(ref fp) = self.fingerprint {
331 session.set_user_agent_full(ua_override_for(fp)).await?;
332 }
333 session
334 .add_script_to_evaluate_on_new_document(&self.evasion_script)
335 .await?;
336 }
337
338 Ok(())
339 }
340
341 pub async fn new_page(&self, url: &str) -> Result<Page> {
343 let target_id = self
344 .connection
345 .create_target("about:blank", None, None)
346 .await?;
347
348 let session = self.connection.attach_to_target(&target_id).await?;
349 self.setup_session(&session).await?;
350
351 let nav_result = session.navigate(url, None).await?;
353 if let Some(error) = nav_result.error_text {
354 return Err(Error::Navigation(error));
355 }
356
357 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
359
360 Ok(Page::new(session, Arc::clone(&self.config)))
361 }
362
363 pub async fn new_blank_page(&self) -> Result<Page> {
365 let target_id = self
366 .connection
367 .create_target("about:blank", None, None)
368 .await?;
369
370 let session = self.connection.attach_to_target(&target_id).await?;
371 self.setup_session(&session).await?;
372
373 Ok(Page::new(session, Arc::clone(&self.config)))
374 }
375
376 pub async fn version(&self) -> Result<String> {
378 let v = self.connection.version().await?;
379 Ok(v.product)
380 }
381
382 pub async fn tabs(&self) -> Result<Vec<TabInfo>> {
384 let targets = self.connection.get_targets().await?;
385 Ok(targets
386 .into_iter()
387 .filter(|t| t.r#type == "page")
388 .map(|t| TabInfo {
389 id: t.target_id,
390 title: t.title,
391 url: t.url,
392 })
393 .collect())
394 }
395
396 pub async fn attach_page(&self, target_id: &str) -> Result<Page> {
399 let session = self.connection.attach_to_target(target_id).await?;
400 self.setup_session(&session).await?;
401 Ok(Page::new(session, Arc::clone(&self.config)))
402 }
403
404 pub async fn activate_tab(&self, target_id: &str) -> Result<()> {
406 self.connection.activate_target(target_id).await
407 }
408
409 pub async fn close_tab(&self, target_id: &str) -> Result<()> {
411 self.connection.close_target(target_id).await?;
412 Ok(())
413 }
414
415 pub async fn close(self) -> Result<()> {
418 if self.config.live_session {
419 self.connection.transport().close().await?;
421 } else {
422 self.connection.close().await?;
423 }
424
425 if let Some(ref dir) = self.user_data_dir {
427 let _ = std::fs::remove_dir_all(dir);
428 }
429
430 Ok(())
431 }
432
433 pub async fn disconnect(self) -> Result<()> {
436 self.connection.transport().close().await
437 }
438}
439
440fn installed_chrome_user_agent(chrome_path: &std::path::Path) -> Option<String> {
443 let output = Command::new(chrome_path).arg("--version").output().ok()?;
444 let version = String::from_utf8_lossy(&output.stdout);
445 let full_version = version.split_whitespace().find(|part| {
446 let pieces: Vec<_> = part.split('.').collect();
447 pieces.len() == 4
448 && pieces
449 .iter()
450 .all(|piece| !piece.is_empty() && piece.bytes().all(|byte| byte.is_ascii_digit()))
451 })?;
452 Fingerprint::native_chrome_user_agent(full_version)
453}
454
455impl Drop for Browser {
456 fn drop(&mut self) {
457 if let Some(ref dir) = self.user_data_dir {
459 let _ = std::fs::remove_dir_all(dir);
460 }
461 }
462}