Skip to main content

eoka/
browser.rs

1//! Browser Launcher
2//!
3//! Handles Chrome discovery, launching with stealth flags, and binary patching.
4
5use std::path::PathBuf;
6use std::process::Command;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10/// Global counter for unique user data directories
11static 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
22/// Stealth browser arguments (pre-built for zero allocation).
23fn stealth_args(config: &StealthConfig, fingerprint: &Fingerprint) -> Vec<String> {
24    let mut args = vec![
25        // Core automation hiding
26        "--disable-blink-features=AutomationControlled".into(),
27        "--disable-features=IsolateOrigins,site-per-process,AutomationControlled,EnableAutomation"
28            .into(),
29        "--enable-features=NetworkService,NetworkServiceInProcess".into(),
30        // Additional flags to hide automation
31        "--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        // Make browser look natural
38        "--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        // Window size
55        format!(
56            "--window-size={},{}",
57            config.viewport_width, config.viewport_height
58        ),
59        // Keep Chromium's virtual screen aligned with the viewport in new
60        // headless mode; otherwise it reports a fixed 800x600 screen.
61        format!(
62            "--ozone-override-screen-size={},{}",
63            config.viewport_width, config.viewport_height
64        ),
65    ];
66
67    // User agent
68    args.push(format!("--user-agent={}", fingerprint.user_agent));
69
70    // Headless mode
71    if config.headless {
72        args.push("--headless=new".into());
73    }
74
75    // Proxy
76    if let Some(ref proxy) = config.proxy {
77        args.push(format!("--proxy-server={}", proxy));
78    }
79
80    // Extra user-supplied args (e.g. --use-fake-ui-for-media-stream)
81    for arg in &config.extra_args {
82        args.push(arg.clone());
83    }
84
85    args
86}
87
88/// Info about an open tab
89#[derive(Debug, Clone)]
90pub struct TabInfo {
91    /// Target ID of the tab.
92    pub id: String,
93    /// Tab title.
94    pub title: String,
95    /// URL currently loaded in the tab.
96    pub url: String,
97}
98
99/// Build the `Emulation.setUserAgentOverride` payload from the resolved fingerprint.
100fn 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
129/// The main stealth browser
130pub struct Browser {
131    connection: Connection,
132    config: Arc<StealthConfig>,
133    /// User data directory (cleaned up on close; None when connecting to existing instance)
134    user_data_dir: Option<PathBuf>,
135    /// Resolved fingerprint (None in live-session mode).
136    fingerprint: Option<Fingerprint>,
137    /// Evasion script (cached)
138    evasion_script: String,
139}
140
141impl Browser {
142    /// Launch a new stealth browser with default config
143    pub async fn launch() -> Result<Self> {
144        Self::launch_with_config(StealthConfig::default()).await
145    }
146
147    /// Launch a new stealth browser with a visible window.
148    pub async fn launch_visible() -> Result<Self> {
149        Self::launch_with_config(StealthConfig::visible()).await
150    }
151
152    /// Launch a new visible stealth browser with debug logging behavior enabled.
153    pub async fn launch_debug() -> Result<Self> {
154        Self::launch_with_config(StealthConfig::debug()).await
155    }
156
157    /// Launch with the default stealth config after applying inline changes.
158    ///
159    /// This avoids constructing a full [`StealthConfig`] when only one or two
160    /// options need to change.
161    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    /// Launch with custom config
168    pub async fn launch_with_config(config: StealthConfig) -> Result<Self> {
169        let config = Arc::new(config);
170
171        // Ephemeral calls use a disposable profile. A caller-provided profile
172        // is durable and must never be wiped by Eoka.
173        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        // Clean up the temp user-data-dir on any failure past this point.
193        let launch = async {
194            let chrome_path = match &config.chrome_path {
195                Some(p) => PathBuf::from(p),
196                None => find_chrome()?,
197            };
198
199            // Patching a browser binary is not appropriate for a durable
200            // user-owned profile. Keep that mode close to normal Chromium and
201            // let its profile provide continuity instead of fresh spoofing.
202            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            // Build args
223            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    /// Connect to an existing Chrome instance at the given WebSocket CDP URL.
266    /// Obtain the URL from `curl http://localhost:9222/json/version` or use
267    /// `Browser::connect_port` for HTTP discovery.
268    ///
269    /// Defaults to `StealthConfig::live()` so attaching to a user's real browser
270    /// leaves their tabs untouched (no evasion script injection, full CDP access).
271    pub async fn connect(ws_url: &str) -> Result<Self> {
272        Self::connect_with_config(ws_url, StealthConfig::live()).await
273    }
274
275    /// Connect to an existing Chrome instance with a custom config.
276    /// Allows customizing CDP timeout, evasion scripts, proxy auth, etc.
277    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        // Skip the evasion script and fingerprint for live sessions.
289        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    /// Discover the DevTools URL on `127.0.0.1:<port>` and connect.
306    /// Equivalent to `curl http://127.0.0.1:<port>/json/version` then `Browser::connect`.
307    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    /// `connect_port` with a custom config.
313    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    /// Set up a session with evasion scripts and proxy auth.
319    /// Common logic shared by new_page, new_blank_page, and attach_page.
320    /// In live-session mode, skips the evasion-script injection so we don't
321    /// pollute the user's tab with extra `addScriptToEvaluateOnNewDocument`.
322    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    /// Create a new page and navigate to URL
342    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        // Navigate to URL
352        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        // Brief settle time for the initial page load to start.
358        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
359
360        Ok(Page::new(session, Arc::clone(&self.config)))
361    }
362
363    /// Create a new page without navigation (at about:blank)
364    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    /// Get the browser version
377    pub async fn version(&self) -> Result<String> {
378        let v = self.connection.version().await?;
379        Ok(v.product)
380    }
381
382    /// List all open tabs
383    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    /// Attach to an existing browser target (e.g., a popup opened by window.open()).
397    /// Use `tabs()` to discover popup target IDs, then call this to get a Page handle.
398    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    /// Activate (focus) a tab by target ID
405    pub async fn activate_tab(&self, target_id: &str) -> Result<()> {
406        self.connection.activate_target(target_id).await
407    }
408
409    /// Close a specific tab by target ID
410    pub async fn close_tab(&self, target_id: &str) -> Result<()> {
411        self.connection.close_target(target_id).await?;
412        Ok(())
413    }
414
415    /// Close the browser. In live-session mode, this is equivalent to
416    /// `disconnect()` — we never send `Browser.close` to a Chrome we don't own.
417    pub async fn close(self) -> Result<()> {
418        if self.config.live_session {
419            // Don't kill the user's Chrome; just drop the connection.
420            self.connection.transport().close().await?;
421        } else {
422            self.connection.close().await?;
423        }
424
425        // Clean up user data directory (None when connecting).
426        if let Some(ref dir) = self.user_data_dir {
427            let _ = std::fs::remove_dir_all(dir);
428        }
429
430        Ok(())
431    }
432
433    /// Drop the WebSocket without sending `Browser.close`. Use this when
434    /// connected to a user-owned browser to disconnect without killing it.
435    pub async fn disconnect(self) -> Result<()> {
436        self.connection.transport().close().await
437    }
438}
439
440/// Read Chrome's executable version and derive a coherent native UA. Failure
441/// is intentionally non-fatal: callers fall back to a current fingerprint.
442fn 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        // Best-effort cleanup of the temp user-data-dir if close() wasn't called.
458        if let Some(ref dir) = self.user_data_dir {
459            let _ = std::fs::remove_dir_all(dir);
460        }
461    }
462}