cdp_html_shot/
browser.rs

1use crate::tab::Tab;
2use crate::transport::{Transport, TransportResponse, next_id};
3use crate::types::{CaptureOptions, Viewport};
4use anyhow::{Context, Result, anyhow};
5use rand::{Rng, rng};
6use regex::Regex;
7use serde_json::json;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Stdio};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::{Mutex, oneshot};
14use which::which;
15
16/// Temporary directory for browser user data, deleted on drop.
17struct CustomTempDir {
18    path: PathBuf,
19}
20
21impl CustomTempDir {
22    fn new(base: PathBuf, prefix: &str) -> Result<Self> {
23        std::fs::create_dir_all(&base)?;
24        let name = format!(
25            "{}_{}_{}",
26            prefix,
27            chrono::Local::now().format("%Y%m%d_%H%M%S"),
28            rng()
29                .sample_iter(&rand::distr::Alphanumeric)
30                .take(6)
31                .map(char::from)
32                .collect::<String>()
33        );
34        let path = base.join(name);
35        std::fs::create_dir(&path)?;
36        Ok(Self { path })
37    }
38}
39
40impl Drop for CustomTempDir {
41    fn drop(&mut self) {
42        for i in 0..10 {
43            if std::fs::remove_dir_all(&self.path).is_ok() {
44                return;
45            }
46            std::thread::sleep(Duration::from_millis(100 * (i as u64 + 1).min(3)));
47        }
48        let _ = std::fs::remove_dir_all(&self.path);
49    }
50}
51
52struct BrowserProcess {
53    child: Child,
54    _temp: CustomTempDir,
55}
56
57impl Drop for BrowserProcess {
58    fn drop(&mut self) {
59        let _ = self.child.kill();
60        let _ = self.child.wait();
61        std::thread::sleep(Duration::from_millis(200));
62    }
63}
64
65#[derive(Clone)]
66pub struct Browser {
67    transport: Arc<Transport>,
68    process: Arc<Mutex<Option<BrowserProcess>>>,
69}
70
71static GLOBAL_BROWSER: Mutex<Option<Browser>> = Mutex::const_new(None);
72
73impl Browser {
74    /// Launches a new headless browser instance using the default browser path.
75    pub async fn new() -> Result<Self> {
76        Self::launch(true, None).await
77    }
78
79    /// Launches a new headless browser instance using a custom executable path.
80    pub async fn new_with_path(path: impl AsRef<Path>) -> Result<Self> {
81        Self::launch(true, Some(path.as_ref().to_path_buf())).await
82    }
83
84    /// Launches a new browser instance with head visible using the default browser path.
85    pub async fn new_with_head() -> Result<Self> {
86        Self::launch(false, None).await
87    }
88
89    /// Launches a new browser instance with head visible using a custom executable path.
90    pub async fn new_with_head_and_path(path: impl AsRef<Path>) -> Result<Self> {
91        Self::launch(false, Some(path.as_ref().to_path_buf())).await
92    }
93
94    /// Internal function to start the browser with given headless flag and optional path.
95    async fn launch(headless: bool, custom_path: Option<PathBuf>) -> Result<Self> {
96        let temp = CustomTempDir::new(std::env::current_dir()?.join("temp"), "cdp-shot")?;
97        let exe = Self::find_chrome(custom_path)?;
98        let port = (8000..9000)
99            .find(|&p| std::net::TcpListener::bind(("127.0.0.1", p)).is_ok())
100            .ok_or(anyhow!("No available port"))?;
101
102        let mut args = vec![
103            format!("--remote-debugging-port={}", port),
104            format!("--user-data-dir={}", temp.path.display()),
105            "--no-sandbox".into(),
106            "--no-zygote".into(),
107            "--in-process-gpu".into(),
108            "--disable-dev-shm-usage".into(),
109            "--disable-background-networking".into(),
110            "--disable-default-apps".into(),
111            "--disable-extensions".into(),
112            "--disable-sync".into(),
113            "--disable-translate".into(),
114            "--metrics-recording-only".into(),
115            "--safebrowsing-disable-auto-update".into(),
116            "--mute-audio".into(),
117            "--no-first-run".into(),
118            "--hide-scrollbars".into(),
119            "--window-size=1200,1600".into(),
120            "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36".into()
121        ];
122        if headless {
123            args.push("--headless=new".into());
124        }
125
126        #[cfg(windows)]
127        let mut cmd = {
128            use std::os::windows::process::CommandExt;
129            let mut c = Command::new(&exe);
130            c.creation_flags(0x08000000);
131            c
132        };
133        #[cfg(not(windows))]
134        let mut cmd = Command::new(&exe);
135
136        let mut child = cmd
137            .args(args)
138            .stderr(Stdio::piped())
139            .spawn()
140            .with_context(|| format!("Failed to spawn browser executable: {:?}", exe))?;
141
142        let stderr = child.stderr.take().context("No stderr")?;
143        let ws_url = Self::wait_for_ws(stderr).await?;
144
145        Ok(Self {
146            transport: Arc::new(Transport::new(&ws_url).await?),
147            process: Arc::new(Mutex::new(Some(BrowserProcess { child, _temp: temp }))),
148        })
149    }
150
151    /// Attempts to locate a Chrome or Edge executable in the system.
152    fn find_chrome(custom_path: Option<PathBuf>) -> Result<PathBuf> {
153        // 1. Try custom path if provided
154        if let Some(path) = custom_path {
155            if path.exists() {
156                return Ok(path);
157            }
158            return Err(anyhow!("Custom browser path found: {:?}", path));
159        }
160
161        // 2. Try environment variable
162        if let Ok(p) = std::env::var("CHROME") {
163            return Ok(p.into());
164        }
165
166        // 3. Try platform specific paths
167        #[cfg(target_os = "windows")]
168        {
169            let paths = [
170                r"C:\Program Files\Google\Chrome\Application\chrome.exe",
171                r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
172                r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
173                r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
174            ];
175            for p in paths {
176                if Path::new(p).exists() {
177                    return Ok(p.into());
178                }
179            }
180
181            use winreg::{RegKey, enums::HKEY_LOCAL_MACHINE};
182            let keys = [
183                r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
184                r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe",
185            ];
186            for k in keys {
187                if let Ok(rk) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(k)
188                    && let Ok(v) = rk.get_value::<String, _>("")
189                {
190                    if Path::new(&v).exists() {
191                        return Ok(v.into());
192                    }
193                }
194            }
195        }
196
197        #[cfg(target_os = "macos")]
198        {
199            let paths = [
200                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
201                "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
202            ];
203            for p in paths {
204                if Path::new(p).exists() {
205                    return Ok(p.into());
206                }
207            }
208        }
209
210        #[cfg(target_os = "linux")]
211        {
212            let paths = [
213                "/usr/bin/google-chrome",
214                "/usr/bin/google-chrome-stable",
215                "/usr/bin/chromium",
216                "/usr/bin/chromium-browser",
217            ];
218            for p in paths {
219                if Path::new(p).exists() {
220                    return Ok(p.into());
221                }
222            }
223        }
224
225        // 4. Try common commands using `which`
226        let apps = [
227            "google-chrome-stable",
228            "chromium",
229            "chromium-browser",
230            "chrome",
231            "msedge",
232            "microsoft-edge",
233        ];
234        for app in apps {
235            if let Ok(p) = which(app) {
236                let p_str = p.to_string_lossy();
237                // Check direct path for obvious flatpak/snap markers
238                if p_str.contains("/var/lib/flatpak") || p_str.contains("/snap/") {
239                    continue;
240                }
241
242                // Check resolved path (in case of symlinks like /usr/bin/msedge -> /var/lib/flatpak/...)
243                // Flatpak often installs a symlink in /usr/bin that points to the internal flatpak data directory.
244                // We must filter these because they cannot be executed directly without `flatpak run`.
245                if let Ok(resolved) = std::fs::canonicalize(&p) {
246                    let r_str = resolved.to_string_lossy();
247                    if r_str.contains("/var/lib/flatpak") || r_str.contains("/snap/") {
248                        continue;
249                    }
250                }
251
252                return Ok(p);
253            }
254        }
255
256        Err(anyhow!(
257            "Chrome/Edge not found. Set CHROME env var or use new_with_path."
258        ))
259    }
260
261    async fn wait_for_ws(stderr: std::process::ChildStderr) -> Result<String> {
262        let (tx, rx) = oneshot::channel();
263
264        // Spawn a blocking task to read stderr.
265        // Important: We loop until the stream ends (process exit) to drain stderr,
266        // preventing the pipe from filling up or closing prematurely which could kill the browser.
267        tokio::task::spawn_blocking(move || {
268            let reader = BufReader::new(stderr);
269            let re =
270                Regex::new(r"listening on (.*/devtools/browser/.*)\s*$").expect("Invalid regex");
271            let mut found = false;
272            let mut tx = Some(tx);
273
274            for line in reader.lines() {
275                match line {
276                    Ok(l) => {
277                        if !found
278                            && let Some(cap) = re.captures(&l) {
279                                if let Some(tx) = tx.take() {
280                                    let _ = tx.send(Ok(cap[1].to_string()));
281                                }
282                                found = true;
283                            }
284                    }
285                    Err(_) => break,
286                }
287            }
288
289            if !found
290                && let Some(tx) = tx.take() {
291                    let _ = tx.send(Err(anyhow!("WS URL not found in stderr")));
292                }
293        });
294
295        rx.await.map_err(|_| anyhow!("Stderr reader dropped"))?
296    }
297
298    pub async fn new_tab(&self) -> Result<Tab> {
299        Tab::new(self.transport.clone()).await
300    }
301
302    pub async fn capture_html(&self, html: &str, selector: &str) -> Result<String> {
303        self.capture_html_with_options(html, selector, CaptureOptions::default())
304            .await
305    }
306
307    pub async fn capture_html_with_options(
308        &self,
309        html: &str,
310        selector: &str,
311        opts: CaptureOptions,
312    ) -> Result<String> {
313        let tab = self.new_tab().await?;
314
315        if let Some(ref viewport) = opts.viewport {
316            tab.set_viewport(viewport).await?;
317        }
318
319        tab.set_content(html).await?;
320        let el = tab.find_element(selector).await?;
321        let shot = el.screenshot_with_options(opts).await?;
322        let _ = tab.close().await;
323        Ok(shot)
324    }
325
326    pub async fn capture_html_hidpi(
327        &self,
328        html: &str,
329        selector: &str,
330        scale: f64,
331    ) -> Result<String> {
332        let opts = CaptureOptions::new()
333            .with_viewport(Viewport::default().with_device_scale_factor(scale));
334        self.capture_html_with_options(html, selector, opts).await
335    }
336
337    pub async fn shutdown_global() {
338        let mut lock = GLOBAL_BROWSER.lock().await;
339        if let Some(browser) = lock.take() {
340            let _ = browser.close_async().await;
341        }
342    }
343
344    pub async fn close_async(&self) -> Result<()> {
345        self.transport.shutdown().await;
346        let mut lock = self.process.lock().await;
347        if let Some(_proc) = lock.take() {
348            // Drop triggers cleanup
349        }
350        Ok(())
351    }
352
353    async fn is_alive(&self) -> bool {
354        self.transport
355            .send(json!({
356                "id": next_id(),
357                "method": "Target.getTargets",
358                "params": {}
359            }))
360            .await
361            .is_ok()
362    }
363
364    /// Returns a shared singleton browser instance, launching if necessary.
365    pub async fn instance() -> Self {
366        Self::instance_internal(None).await
367    }
368
369    /// Returns a shared singleton browser instance, launching with the specified path if necessary.
370    ///
371    /// Note: If the global browser instance is already running, this path argument will be ignored
372    /// and the existing instance will be returned.
373    pub async fn instance_with_path(path: impl AsRef<Path>) -> Self {
374        Self::instance_internal(Some(path.as_ref().to_path_buf())).await
375    }
376
377    async fn instance_internal(custom_path: Option<PathBuf>) -> Self {
378        let mut lock = GLOBAL_BROWSER.lock().await;
379
380        if let Some(b) = &*lock {
381            if b.is_alive().await {
382                return b.clone();
383            }
384            println!("[cdp-html-shot] Browser instance died, recreating...");
385            let _ = b.close_async().await;
386        }
387
388        let b = Self::launch(true, custom_path)
389            .await
390            .expect("Init global browser failed");
391
392        // Close default blank page to save resources
393        if let Ok(TransportResponse::Response(res)) = b
394            .transport
395            .send(json!({"id": next_id(), "method":"Target.getTargets", "params":{}}))
396            .await
397            && let Some(list) = res.result["targetInfos"].as_array()
398            && let Some(id) = list
399                .iter()
400                .find(|t| t["type"] == "page")
401                .and_then(|t| t["targetId"].as_str())
402        {
403            let _ = b
404                .transport
405                .send(json!({"id":next_id(), "method":"Target.closeTarget", "params":{"targetId":id}}))
406                .await;
407        }
408
409        *lock = Some(b.clone());
410        b
411    }
412}