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
16struct 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 pub async fn new() -> Result<Self> {
76 Self::launch(true, None).await
77 }
78
79 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 pub async fn new_with_head() -> Result<Self> {
86 Self::launch(false, None).await
87 }
88
89 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 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 fn find_chrome(custom_path: Option<PathBuf>) -> Result<PathBuf> {
153 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 if let Ok(p) = std::env::var("CHROME") {
163 return Ok(p.into());
164 }
165
166 #[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 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 if p_str.contains("/var/lib/flatpak") || p_str.contains("/snap/") {
239 continue;
240 }
241
242 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 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 }
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 pub async fn instance() -> Self {
366 Self::instance_internal(None).await
367 }
368
369 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 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}