Skip to main content

xcelerate_core/
browser.rs

1use crate::connection::CdpClient;
2use crate::error::{XcelerateResult, XcelerateError};
3use crate::page::Page;
4use std::sync::Arc;
5use tokio_tungstenite::connect_async;
6use tokio::sync::mpsc;
7use std::process::Stdio;
8use std::path::PathBuf;
9use std::time::Duration;
10
11const CDC_PAYLOAD: &str = include_str!("cdc_payload.js");
12
13/// Configuration for the Browser instance.
14#[derive(uniffi::Record)]
15pub struct BrowserConfig {
16    /// Whether to run the browser in headless mode.
17    pub headless: bool,
18    /// Whether to apply stealth patches to the binary.
19    pub stealth: bool,
20    /// Whether to run the browser as a detached process.
21    pub detached: bool,
22    /// Optional path to the browser executable.
23    pub executable_path: Option<String>,
24}
25
26impl Default for BrowserConfig {
27    fn default() -> Self {
28        Self {
29            headless: true,
30            stealth: true,
31            detached: true,
32            executable_path: None,
33        }
34    }
35}
36
37/// Represents a browser instance (e.g., Chrome or Edge).
38#[derive(uniffi::Object)]
39pub struct Browser {
40    pub(crate) client: Arc<CdpClient>,
41    _process: tokio::sync::Mutex<Option<tokio::process::Child>>, 
42    _process_guard: Option<crate::stealth::process::ProcessGuard>,
43    _user_data_dir: Option<tempfile::TempDir>, 
44    _stealth: bool,
45}
46
47#[uniffi::export(async_runtime = "tokio")]
48impl Browser {
49    #[uniffi::constructor]
50    pub async fn launch(config: BrowserConfig) -> XcelerateResult<Arc<Self>> {
51        let exe = match config.executable_path {
52            Some(p) => Some(PathBuf::from(p)),
53            None => find_chrome_executable(),
54        }.ok_or_else(|| {
55            XcelerateError::NotFound("Chrome executable not found. Please specify executable_path.".into())
56        })?;
57
58        // 1. Setup environment
59        let user_data_dir = tempfile::tempdir().map_err(|_| XcelerateError::InternalError)?;
60        let port = get_free_port().ok_or(XcelerateError::InternalError)?;
61
62        let exe = if config.stealth {
63            crate::stealth::patcher::BinaryPatcher::patch_to_temp(&exe)?
64        } else {
65            exe
66        };
67
68
69        // 2. Spawn process
70        let mut cmd = std::process::Command::new(&exe);
71        setup_browser_args(&mut cmd, &user_data_dir, port, config.headless);
72
73        let (child, guard) = if config.detached {
74            let pid = crate::stealth::process::spawn_detached(cmd)?;
75            let guard = crate::stealth::process::ProcessGuard { pid, auto_kill: false };
76            (None, Some(guard))
77        } else {
78            let mut t_cmd = tokio::process::Command::from(cmd);
79            let child = t_cmd.spawn().map_err(|e| XcelerateError::NotFound(format!("Failed to start Chrome: {}", e)))?;
80            let pid = child.id().ok_or(XcelerateError::InternalError)?;
81            let guard = crate::stealth::process::ProcessGuard { pid, auto_kill: true };
82            (Some(child), Some(guard))
83        };
84
85        // 3. Connect to debugger
86        let ws_url = wait_for_ws_url(port).await?;
87        
88        let (ws, _) = connect_async(&ws_url).await?;
89        
90        let (tx, rx) = mpsc::unbounded_channel();
91        let (handler, _event_rx) = crate::connection::CdpHandler::new(ws, rx);
92        let client = Arc::new(CdpClient::new(tx, handler.event_tx.clone()));
93        
94        tokio::spawn(handler.run());
95
96        Ok(Arc::new(Self { 
97            client, 
98            _process: tokio::sync::Mutex::new(child),
99            _process_guard: guard,
100            _user_data_dir: Some(user_data_dir),
101            _stealth: config.stealth,
102        }))
103    }
104
105    pub async fn new_page(self: Arc<Self>, url: String) -> XcelerateResult<Arc<Page>> {
106        // 1. Create target with about:blank so we can inject scripts before loading the real URL
107        let target = self.client.execute(browser_protocol::target::CreateTargetParams {
108            url: "about:blank".into(),
109            ..Default::default()
110        }).await?;
111
112        // 2. Attach to target
113        let session = self.client.execute(browser_protocol::target::AttachToTargetParams {
114            targetId: target.targetId,
115            flatten: Some(true),
116        }).await?;
117
118        let page = Arc::new(Page {
119            client: Arc::clone(&self.client),
120            session_id: session.sessionId,
121        });
122
123        // 3. Inject stealth payload if enabled
124        if self._stealth {
125            page.add_script_to_evaluate_on_new_document(CDC_PAYLOAD.to_string()).await?;
126            // We also need to enable the Page domain for some events to fire correctly
127            self.client.execute_with_session(
128                Some(&page.session_id),
129                browser_protocol::page::EnableParams { ..Default::default() }
130            ).await?;
131        }
132
133        // 4. Finally navigate to the actual URL
134        page.navigate(url).await?;
135
136        Ok(page)
137    }
138
139    /// Returns the browser version information.
140    pub async fn version(&self) -> XcelerateResult<String> {
141        let res = self.client.execute(browser_protocol::browser::GetVersionParams {}).await?;
142        Ok(format!("{} (Protocol {})", res.product, res.protocolVersion))
143    }
144
145    /// Closes the browser and kills the process.
146    pub async fn close(&self) -> XcelerateResult<()> {
147        // Try to close gracefully via CDP first
148        let _ = self.client.execute(browser_protocol::browser::CloseParams {}).await;
149        
150        // Kill the process if it's still running
151        let mut lock = self._process.lock().await;
152        if let Some(mut child) = lock.take() {
153            let _ = child.kill().await;
154        }
155        Ok(())
156    }
157}
158
159fn find_chrome_executable() -> Option<PathBuf> {
160    let paths = if cfg!(windows) {
161        vec![
162            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
163            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
164            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
165        ]
166    } else if cfg!(target_os = "macos") {
167        vec![
168            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
169            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
170        ]
171    } else {
172        // Linux and others
173        vec![
174            "/usr/bin/google-chrome",
175            "/usr/bin/chromium-browser",
176            "/usr/bin/chromium",
177            "/usr/bin/microsoft-edge-stable",
178        ]
179    };
180
181    for path in paths {
182        let pb = PathBuf::from(path);
183        if pb.exists() {
184            return Some(pb);
185        }
186    }
187    None
188}
189
190fn get_free_port() -> Option<u16> {
191    use std::net::TcpListener;
192    TcpListener::bind("127.0.0.1:0")
193        .and_then(|listener| listener.local_addr())
194        .map(|addr| addr.port())
195        .ok()
196}
197
198fn setup_browser_args(cmd: &mut std::process::Command, user_data_dir: &tempfile::TempDir, port: u16, headless: bool) {
199    cmd.arg(format!("--remote-debugging-port={}", port))
200       .arg("--remote-debugging-address=127.0.0.1")
201       .arg(format!("--user-data-dir={}", user_data_dir.path().display()))
202       .arg("--no-first-run")
203       .arg("--no-default-browser-check")
204       .arg("--remote-allow-origins=*") 
205       .arg("--no-startup-window")
206       .stdout(Stdio::null())
207       .stderr(Stdio::null());
208
209    if headless {
210        cmd.arg("--headless=new");
211        cmd.arg("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
212    }
213}
214
215async fn wait_for_ws_url(port: u16) -> XcelerateResult<String> {
216    let version_url = format!("http://127.0.0.1:{}/json/version", port);
217    
218    let mut attempts = 0;
219    loop {
220        match reqwest::get(&version_url).await {
221            Ok(resp) => {
222                let json: serde_json::Value = resp.json().await.map_err(|_| XcelerateError::InternalError)?;
223                if let Some(ws_url) = json["webSocketDebuggerUrl"].as_str() {
224                    return Ok(ws_url.to_string());
225                }
226            }
227            Err(_) => {
228                attempts += 1;
229                if attempts > 100 { 
230                    return Err(XcelerateError::NotFound("Timed out waiting for Chrome HTTP server".into())); 
231                }
232                tokio::time::sleep(Duration::from_millis(150)).await;
233            }
234        }
235    }
236}