cdp_html_shot/
lib.rs

1/*!
2[![GitHub]](https://github.com/araea/cdp-html-shot) [![crates-io]](https://crates.io/crates/cdp-html-shot) [![docs-rs]](crate)
3
4[GitHub]: https://img.shields.io/badge/github-8da0cb?style=for-the-badge&labelColor=555555&logo=github
5[crates-io]: https://img.shields.io/badge/crates.io-fc8d62?style=for-the-badge&labelColor=555555&logo=rust
6[docs-rs]: https://img.shields.io/badge/docs.rs-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs
7
8<br>
9
10A Rust library for capturing HTML screenshots using the Chrome DevTools Protocol (CDP).
11*/
12
13pub use browser::Browser;
14pub use element::Element;
15#[cfg(feature = "atexit")]
16pub use exit_hook::ExitHook;
17pub use tab::Tab;
18
19/// Viewport configuration for controlling page dimensions and device emulation.
20///
21/// Similar to Puppeteer's `page.setViewport()`, this allows you to control
22/// the page dimensions and device scale factor for higher quality screenshots.
23///
24/// # Example
25/// ```rust,ignore
26/// use cdp_html_shot::Viewport;
27///
28/// // Create a high-DPI viewport for sharper images
29/// let viewport = Viewport::new(1920, 1080)
30///     .with_device_scale_factor(2.0);
31///
32/// // Or use builder pattern for full control
33/// let viewport = Viewport::builder()
34///     .width(1280)
35///     .height(720)
36///     .device_scale_factor(3.0)
37///     .is_mobile(false)
38///     .build();
39/// ```
40#[derive(Debug, Clone)]
41pub struct Viewport {
42    /// Viewport width in pixels.
43    pub width: u32,
44    /// Viewport height in pixels.
45    pub height: u32,
46    /// Device scale factor (DPR). Higher values (e.g., 2.0, 3.0) produce sharper images.
47    /// Default is 1.0.
48    pub device_scale_factor: f64,
49    /// Whether to emulate a mobile device. Default is false.
50    pub is_mobile: bool,
51    /// Whether touch events are supported. Default is false.
52    pub has_touch: bool,
53    /// Whether viewport is in landscape mode. Default is false.
54    pub is_landscape: bool,
55}
56
57impl Default for Viewport {
58    fn default() -> Self {
59        Self {
60            width: 800,
61            height: 600,
62            device_scale_factor: 1.0,
63            is_mobile: false,
64            has_touch: false,
65            is_landscape: false,
66        }
67    }
68}
69
70impl Viewport {
71    /// Creates a new viewport with specified dimensions and default settings.
72    pub fn new(width: u32, height: u32) -> Self {
73        Self {
74            width,
75            height,
76            ..Default::default()
77        }
78    }
79
80    /// Creates a new viewport builder for fluent configuration.
81    pub fn builder() -> ViewportBuilder {
82        ViewportBuilder::default()
83    }
84
85    /// Sets the device scale factor (DPR) for higher quality images.
86    ///
87    /// Common values:
88    /// - 1.0: Standard resolution
89    /// - 2.0: Retina/HiDPI (2x sharper)
90    /// - 3.0: Ultra-high DPI (3x sharper)
91    pub fn with_device_scale_factor(mut self, factor: f64) -> Self {
92        self.device_scale_factor = factor;
93        self
94    }
95
96    /// Sets whether to emulate a mobile device.
97    pub fn with_mobile(mut self, is_mobile: bool) -> Self {
98        self.is_mobile = is_mobile;
99        self
100    }
101
102    /// Sets whether touch events are supported.
103    pub fn with_touch(mut self, has_touch: bool) -> Self {
104        self.has_touch = has_touch;
105        self
106    }
107
108    /// Sets whether the viewport is in landscape mode.
109    pub fn with_landscape(mut self, is_landscape: bool) -> Self {
110        self.is_landscape = is_landscape;
111        self
112    }
113}
114
115/// Builder for creating Viewport configurations with a fluent API.
116#[derive(Debug, Clone, Default)]
117pub struct ViewportBuilder {
118    width: Option<u32>,
119    height: Option<u32>,
120    device_scale_factor: Option<f64>,
121    is_mobile: Option<bool>,
122    has_touch: Option<bool>,
123    is_landscape: Option<bool>,
124}
125
126impl ViewportBuilder {
127    /// Sets the viewport width in pixels.
128    pub fn width(mut self, width: u32) -> Self {
129        self.width = Some(width);
130        self
131    }
132
133    /// Sets the viewport height in pixels.
134    pub fn height(mut self, height: u32) -> Self {
135        self.height = Some(height);
136        self
137    }
138
139    /// Sets the device scale factor (DPR).
140    pub fn device_scale_factor(mut self, factor: f64) -> Self {
141        self.device_scale_factor = Some(factor);
142        self
143    }
144
145    /// Sets whether to emulate a mobile device.
146    pub fn is_mobile(mut self, mobile: bool) -> Self {
147        self.is_mobile = Some(mobile);
148        self
149    }
150
151    /// Sets whether touch events are supported.
152    pub fn has_touch(mut self, touch: bool) -> Self {
153        self.has_touch = Some(touch);
154        self
155    }
156
157    /// Sets whether viewport is in landscape mode.
158    pub fn is_landscape(mut self, landscape: bool) -> Self {
159        self.is_landscape = Some(landscape);
160        self
161    }
162
163    /// Builds the Viewport with configured or default values.
164    pub fn build(self) -> Viewport {
165        let default = Viewport::default();
166        Viewport {
167            width: self.width.unwrap_or(default.width),
168            height: self.height.unwrap_or(default.height),
169            device_scale_factor: self
170                .device_scale_factor
171                .unwrap_or(default.device_scale_factor),
172            is_mobile: self.is_mobile.unwrap_or(default.is_mobile),
173            has_touch: self.has_touch.unwrap_or(default.has_touch),
174            is_landscape: self.is_landscape.unwrap_or(default.is_landscape),
175        }
176    }
177}
178
179/// Screenshot format options.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum ImageFormat {
182    /// JPEG format (smaller file size, lossy compression).
183    #[default]
184    Jpeg,
185    /// PNG format (lossless, supports transparency).
186    Png,
187    /// WebP format (modern format with good compression).
188    WebP,
189}
190
191impl ImageFormat {
192    /// Returns the format string used by CDP.
193    pub fn as_str(&self) -> &'static str {
194        match self {
195            ImageFormat::Jpeg => "jpeg",
196            ImageFormat::Png => "png",
197            ImageFormat::WebP => "webp",
198        }
199    }
200}
201
202/// Configuration options for HTML screenshot capture.
203///
204/// Provides fine-grained control over the screenshot capture process,
205/// including image format, quality, viewport settings, and more.
206///
207/// # Example
208/// ```rust,ignore
209/// use cdp_html_shot::{CaptureOptions, Viewport, ImageFormat};
210///
211/// let options = CaptureOptions::new()
212///     .with_format(ImageFormat::Png)
213///     .with_viewport(Viewport::new(1920, 1080).with_device_scale_factor(2.0))
214///     .with_full_page(true);
215/// ```
216#[derive(Debug, Clone, Default)]
217pub struct CaptureOptions {
218    /// Image format for the screenshot.
219    pub(crate) format: ImageFormat,
220    /// Quality for JPEG/WebP (0-100). Ignored for PNG.
221    pub(crate) quality: Option<u8>,
222    /// Viewport settings to apply before capture.
223    pub(crate) viewport: Option<Viewport>,
224    /// Whether to capture the full scrollable page.
225    pub(crate) full_page: bool,
226    /// Whether to omit the background (transparent for PNG).
227    pub(crate) omit_background: bool,
228    /// Optional clip region for the screenshot.
229    pub(crate) clip: Option<ClipRegion>,
230}
231
232/// Defines a rectangular region for clipping screenshots.
233#[derive(Debug, Clone, Copy)]
234pub struct ClipRegion {
235    /// X coordinate of the clip region.
236    pub x: f64,
237    /// Y coordinate of the clip region.
238    pub y: f64,
239    /// Width of the clip region.
240    pub width: f64,
241    /// Height of the clip region.
242    pub height: f64,
243    /// Scale factor for the clip region.
244    pub scale: f64,
245}
246
247impl ClipRegion {
248    /// Creates a new clip region with the specified dimensions.
249    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
250        Self {
251            x,
252            y,
253            width,
254            height,
255            scale: 1.0,
256        }
257    }
258
259    /// Sets the scale factor for the clip region.
260    pub fn with_scale(mut self, scale: f64) -> Self {
261        self.scale = scale;
262        self
263    }
264}
265
266impl CaptureOptions {
267    /// Creates a new `CaptureOptions` with default settings.
268    pub fn new() -> Self {
269        Self::default()
270    }
271
272    /// Sets the image format for the screenshot.
273    pub fn with_format(mut self, format: ImageFormat) -> Self {
274        self.format = format;
275        self
276    }
277
278    /// Sets the quality for JPEG/WebP (0-100). Ignored for PNG.
279    pub fn with_quality(mut self, quality: u8) -> Self {
280        self.quality = Some(quality.min(100));
281        self
282    }
283
284    /// Sets the viewport configuration for the capture.
285    ///
286    /// This is particularly useful for setting `deviceScaleFactor` to get
287    /// higher resolution screenshots.
288    pub fn with_viewport(mut self, viewport: Viewport) -> Self {
289        self.viewport = Some(viewport);
290        self
291    }
292
293    /// Sets whether to capture the full scrollable page.
294    pub fn with_full_page(mut self, full_page: bool) -> Self {
295        self.full_page = full_page;
296        self
297    }
298
299    /// Sets whether to omit the background (transparent for PNG).
300    pub fn with_omit_background(mut self, omit: bool) -> Self {
301        self.omit_background = omit;
302        self
303    }
304
305    /// Sets a clip region for the screenshot.
306    pub fn with_clip(mut self, clip: ClipRegion) -> Self {
307        self.clip = Some(clip);
308        self
309    }
310
311    /// Convenience method: creates options for raw PNG output.
312    pub fn raw_png() -> Self {
313        Self::new().with_format(ImageFormat::Png)
314    }
315
316    /// Convenience method: creates options for high-quality JPEG.
317    pub fn high_quality_jpeg() -> Self {
318        Self::new().with_format(ImageFormat::Jpeg).with_quality(95)
319    }
320
321    /// Convenience method: creates options for HiDPI (2x) screenshots.
322    pub fn hidpi() -> Self {
323        Self::new().with_viewport(Viewport::default().with_device_scale_factor(2.0))
324    }
325
326    /// Convenience method: creates options for ultra HiDPI (3x) screenshots.
327    pub fn ultra_hidpi() -> Self {
328        Self::new().with_viewport(Viewport::default().with_device_scale_factor(3.0))
329    }
330
331    // Legacy compatibility method
332    /// Specifies whether to capture screenshots as raw PNG (`true`) or JPEG (`false`).
333    #[deprecated(since = "0.2.0", note = "Use `with_format()` instead")]
334    pub fn with_raw_png(mut self, raw: bool) -> Self {
335        self.format = if raw {
336            ImageFormat::Png
337        } else {
338            ImageFormat::Jpeg
339        };
340        self
341    }
342}
343
344// ==========================================
345// Module: Transport
346// ==========================================
347mod transport {
348    use anyhow::{Result, anyhow};
349    use futures_util::stream::{SplitSink, SplitStream};
350    use futures_util::{SinkExt, StreamExt};
351    use serde::{Deserialize, Serialize};
352    use serde_json::{Value, json};
353    use std::collections::HashMap;
354    use std::sync::atomic::{AtomicUsize, Ordering};
355    use std::time::Duration;
356    use tokio::net::TcpStream;
357    use tokio::sync::{mpsc, oneshot};
358    use tokio::time;
359    use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message};
360
361    /// Global counter for generating unique message IDs
362    pub(crate) static GLOBAL_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
363
364    /// Generates the next unique message ID
365    pub(crate) fn next_id() -> usize {
366        GLOBAL_ID_COUNTER.fetch_add(1, Ordering::SeqCst) + 1
367    }
368
369    /// Messages that can be sent to the transport actor
370    #[derive(Debug)]
371    pub(crate) enum TransportMessage {
372        /// Send a CDP command and wait for response
373        Request(Value, oneshot::Sender<Result<TransportResponse>>),
374        /// Listen for a specific target message by ID
375        ListenTargetMessage(u64, oneshot::Sender<Result<TransportResponse>>),
376        /// Wait for a specific CDP event
377        WaitForEvent(String, String, oneshot::Sender<()>),
378        /// Shutdown the transport and browser
379        Shutdown,
380    }
381
382    /// Responses received from the transport
383    #[derive(Debug)]
384    pub(crate) enum TransportResponse {
385        /// Direct response from CDP
386        Response(Response),
387        /// Message from target session
388        Target(TargetMessage),
389    }
390
391    /// Standard CDP response format
392    #[derive(Debug, Serialize, Deserialize)]
393    pub(crate) struct Response {
394        /// Message ID matching the request
395        pub(crate) id: u64,
396        /// Response data
397        pub(crate) result: Value,
398    }
399
400    /// Message received from target session
401    #[derive(Debug, Serialize, Deserialize)]
402    pub(crate) struct TargetMessage {
403        /// Message parameters
404        pub(crate) params: Value,
405    }
406
407    /// Actor that manages WebSocket communication with Chrome DevTools Protocol
408    struct TransportActor {
409        /// Pending requests waiting for responses
410        pending_requests: HashMap<u64, oneshot::Sender<Result<TransportResponse>>>,
411        /// Event listeners waiting for specific CDP events
412        event_listeners: HashMap<(String, String), Vec<oneshot::Sender<()>>>,
413        /// WebSocket sink for sending messages
414        ws_sink: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
415        /// Channel for receiving commands
416        command_rx: mpsc::Receiver<TransportMessage>,
417    }
418
419    impl TransportActor {
420        /// Main event loop for processing WebSocket messages and commands
421        async fn run(
422            mut self,
423            mut ws_stream: SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
424        ) {
425            loop {
426                tokio::select! {
427                    Some(msg) = ws_stream.next() => {
428                        match msg {
429                            Ok(Message::Text(text)) => {
430                                // Try to parse as regular Response
431                                if let Ok(response) = serde_json::from_str::<Response>(&text) {
432                                    if let Some(sender) = self.pending_requests.remove(&response.id) {
433                                        let _ = sender.send(Ok(TransportResponse::Response(response)));
434                                    }
435                                }
436                                // Try to parse as TargetMessage (from Target.receivedMessageFromTarget)
437                                else if let Ok(target_msg) = serde_json::from_str::<TargetMessage>(&text)
438                                    && let Some(inner_str) = target_msg.params.get("message").and_then(|v| v.as_str())
439                                        && let Ok(inner_json) = serde_json::from_str::<Value>(inner_str) {
440
441                                            // Case A: This is a response to a request (has ID)
442                                            if let Some(id) = inner_json.get("id").and_then(|i| i.as_u64()) {
443                                                if let Some(sender) = self.pending_requests.remove(&id) {
444                                                    let _ = sender.send(Ok(TransportResponse::Target(target_msg)));
445                                                }
446                                            }
447                                            // Case B: This is an event (no ID, has Method) -> trigger listeners
448                                            else if let Some(method) = inner_json.get("method").and_then(|s| s.as_str())
449                                                && let Some(session_id) = target_msg.params.get("sessionId").and_then(|s| s.as_str()) {
450                                                    let key = (session_id.to_string(), method.to_string());
451                                                    if let Some(senders) = self.event_listeners.remove(&key) {
452                                                        for tx in senders {
453                                                            let _ = tx.send(());
454                                                        }
455                                                    }
456                                                }
457                                        }
458                            }
459                            Err(_) => break,
460                            _ => {}
461                        }
462                    }
463                    Some(msg) = self.command_rx.recv() => {
464                        match msg {
465                            TransportMessage::Request(cmd, tx) => {
466                                if let Some(id) = cmd["id"].as_u64()
467                                    && let Ok(text) = serde_json::to_string(&cmd) {
468                                        if self.ws_sink.send(Message::Text(text)).await.is_ok() {
469                                            self.pending_requests.insert(id, tx);
470                                        } else {
471                                            let _ = tx.send(Err(anyhow!("WebSocket send failed")));
472                                        }
473                                    }
474                            },
475                            TransportMessage::ListenTargetMessage(id, tx) => {
476                                self.pending_requests.insert(id, tx);
477                            },
478                            // Handle event listener registration
479                            TransportMessage::WaitForEvent(session_id, method, tx) => {
480                                self.event_listeners.entry((session_id, method)).or_default().push(tx);
481                            },
482                            TransportMessage::Shutdown => {
483                                let _ = self.ws_sink.send(Message::Text(json!({
484                                    "id": next_id(),
485                                    "method": "Browser.close",
486                                    "params": {}
487                                }).to_string())).await;
488                                let _ = self.ws_sink.close().await;
489                                break;
490                            }
491                        }
492                    }
493                    else => break,
494                }
495            }
496        }
497    }
498
499    /// WebSocket transport for Chrome DevTools Protocol communication
500    #[derive(Debug)]
501    pub(crate) struct Transport {
502        /// Channel for sending commands to the transport actor
503        tx: mpsc::Sender<TransportMessage>,
504    }
505
506    impl Transport {
507        /// Creates a new transport connection to the WebSocket URL
508        pub(crate) async fn new(ws_url: &str) -> Result<Self> {
509            let (ws_stream, _) = connect_async(ws_url).await?;
510            let (ws_sink, ws_stream) = ws_stream.split();
511            let (tx, rx) = mpsc::channel(100);
512
513            tokio::spawn(async move {
514                let actor = TransportActor {
515                    pending_requests: HashMap::new(),
516                    event_listeners: HashMap::new(),
517                    ws_sink,
518                    command_rx: rx,
519                };
520                actor.run(ws_stream).await;
521            });
522
523            Ok(Self { tx })
524        }
525
526        /// Sends a CDP command and waits for the response
527        pub(crate) async fn send(&self, command: Value) -> Result<TransportResponse> {
528            let (tx, rx) = oneshot::channel();
529            self.tx
530                .send(TransportMessage::Request(command, tx))
531                .await
532                .map_err(|_| anyhow!("Transport actor dropped"))?;
533            time::timeout(Duration::from_secs(30), rx)
534                .await
535                .map_err(|_| anyhow!("Timeout waiting for response"))?
536                .map_err(|_| anyhow!("Response channel closed"))?
537        }
538
539        /// Waits for a specific target message by ID
540        pub(crate) async fn get_target_msg(&self, msg_id: usize) -> Result<TransportResponse> {
541            let (tx, rx) = oneshot::channel();
542            self.tx
543                .send(TransportMessage::ListenTargetMessage(msg_id as u64, tx))
544                .await
545                .map_err(|_| anyhow!("Transport actor dropped"))?;
546            time::timeout(Duration::from_secs(30), rx)
547                .await
548                .map_err(|_| anyhow!("Timeout waiting for target message"))?
549                .map_err(|_| anyhow!("Response channel closed"))?
550        }
551
552        /// Waits for a specific CDP event from a session
553        pub(crate) async fn wait_for_event(&self, session_id: &str, method: &str) -> Result<()> {
554            let (tx, rx) = oneshot::channel();
555            self.tx
556                .send(TransportMessage::WaitForEvent(
557                    session_id.to_string(),
558                    method.to_string(),
559                    tx,
560                ))
561                .await
562                .map_err(|_| anyhow!("Transport actor dropped"))?;
563
564            time::timeout(Duration::from_secs(30), rx)
565                .await
566                .map_err(|_| anyhow!("Timeout waiting for event {}", method))?
567                .map_err(|_| anyhow!("Event channel closed"))?;
568            Ok(())
569        }
570
571        /// Shuts down the transport and browser
572        pub(crate) async fn shutdown(&self) {
573            let _ = self.tx.send(TransportMessage::Shutdown).await;
574        }
575    }
576}
577
578// ==========================================
579// Module: Utilities
580// ==========================================
581mod utils {
582    use crate::transport::{TargetMessage, Transport, TransportResponse, next_id};
583    use anyhow::{Result, anyhow};
584    use serde_json::{Value, json};
585    use std::sync::Arc;
586
587    /// Parses the contained JSON message string from a `TargetMessage`.
588    pub(crate) fn serde_msg(msg: &TargetMessage) -> Result<Value> {
589        let str_msg = msg.params["message"]
590            .as_str()
591            .ok_or_else(|| anyhow!("Invalid message format"))?;
592        Ok(serde_json::from_str(str_msg)?)
593    }
594
595    /// Sends a message to a target and waits for the corresponding response.
596    pub(crate) async fn send_and_get_msg(
597        transport: Arc<Transport>,
598        msg_id: usize,
599        session_id: &str,
600        msg: String,
601    ) -> Result<TargetMessage> {
602        let send_fut = transport.send(json!({
603            "id": next_id(),
604            "method": "Target.sendMessageToTarget",
605            "params": { "sessionId": session_id, "message": msg }
606        }));
607        let recv_fut = transport.get_target_msg(msg_id);
608
609        let (_, target_msg) = futures_util::try_join!(send_fut, recv_fut)?;
610
611        match target_msg {
612            TransportResponse::Target(res) => Ok(res),
613            other => Err(anyhow!("Unexpected response: {:?}", other)),
614        }
615    }
616}
617
618// ==========================================
619// Module: Element
620// ==========================================
621mod element {
622    use crate::tab::Tab;
623    use crate::transport::next_id;
624    use crate::utils::{self, send_and_get_msg};
625    use crate::{CaptureOptions, ImageFormat};
626    use anyhow::{Context, Result};
627    use serde_json::json;
628
629    /// Represents a DOM element controlled via CDP.
630    pub struct Element<'a> {
631        parent: &'a Tab,
632        backend_node_id: u64,
633    }
634
635    impl<'a> Element<'a> {
636        /// Constructs a new element from a node ID, fetching necessary info.
637        pub(crate) async fn new(parent: &'a Tab, node_id: u64) -> Result<Self> {
638            let msg_id = next_id();
639            let msg = json!({
640                "id": msg_id,
641                "method": "DOM.describeNode",
642                "params": { "nodeId": node_id, "depth": 100 }
643            })
644            .to_string();
645
646            let res =
647                send_and_get_msg(parent.transport.clone(), msg_id, &parent.session_id, msg).await?;
648            let data = utils::serde_msg(&res)?;
649            let backend_node_id = data["result"]["node"]["backendNodeId"]
650                .as_u64()
651                .context("Missing backendNodeId")?;
652
653            Ok(Self {
654                parent,
655                backend_node_id,
656            })
657        }
658
659        /// Captures a JPEG screenshot of the element with default quality.
660        pub async fn screenshot(&self) -> Result<String> {
661            self.screenshot_with_options(CaptureOptions::new().with_quality(90))
662                .await
663        }
664
665        /// Captures a raw PNG screenshot of the element.
666        pub async fn raw_screenshot(&self) -> Result<String> {
667            self.screenshot_with_options(CaptureOptions::raw_png())
668                .await
669        }
670
671        /// Captures a screenshot of the element with custom options.
672        ///
673        /// # Example
674        /// ```rust,ignore
675        /// let options = CaptureOptions::new()
676        ///     .with_format(ImageFormat::Png)
677        ///     .with_viewport(Viewport::new(1920, 1080).with_device_scale_factor(2.0));
678        ///
679        /// let base64_image = element.screenshot_with_options(options).await?;
680        /// ```
681        pub async fn screenshot_with_options(&self, opts: CaptureOptions) -> Result<String> {
682            // Apply viewport if specified
683            if let Some(ref viewport) = opts.viewport {
684                self.parent.set_viewport(viewport).await?;
685            }
686
687            // Get element bounding box
688            let msg_id = next_id();
689            let msg_box = json!({
690                "id": msg_id,
691                "method": "DOM.getBoxModel",
692                "params": { "backendNodeId": self.backend_node_id }
693            })
694            .to_string();
695
696            let res_box = send_and_get_msg(
697                self.parent.transport.clone(),
698                msg_id,
699                &self.parent.session_id,
700                msg_box,
701            )
702            .await?;
703            let data_box = utils::serde_msg(&res_box)?;
704            let border = &data_box["result"]["model"]["border"];
705
706            let (x, y, w, h) = (
707                border[0].as_f64().unwrap_or(0.0),
708                border[1].as_f64().unwrap_or(0.0),
709                (border[2].as_f64().unwrap_or(0.0) - border[0].as_f64().unwrap_or(0.0)),
710                (border[5].as_f64().unwrap_or(0.0) - border[1].as_f64().unwrap_or(0.0)),
711            );
712
713            // Build screenshot params
714            let mut params = json!({
715                "format": opts.format.as_str(),
716                "clip": { "x": x, "y": y, "width": w, "height": h, "scale": 1.0 },
717                "fromSurface": true,
718                "captureBeyondViewport": opts.full_page,
719            });
720
721            // Add quality for JPEG/WebP
722            if matches!(opts.format, ImageFormat::Jpeg | ImageFormat::WebP) {
723                params["quality"] = json!(opts.quality.unwrap_or(90));
724            }
725
726            // Handle transparent background for PNG
727            if opts.omit_background && matches!(opts.format, ImageFormat::Png) {
728                // Enable transparent background
729                let msg_id = next_id();
730                let msg = json!({
731                    "id": msg_id,
732                    "method": "Emulation.setDefaultBackgroundColorOverride",
733                    "params": { "color": { "r": 0, "g": 0, "b": 0, "a": 0 } }
734                })
735                .to_string();
736                send_and_get_msg(
737                    self.parent.transport.clone(),
738                    msg_id,
739                    &self.parent.session_id,
740                    msg,
741                )
742                .await?;
743            }
744
745            let msg_id = next_id();
746            let msg_cap = json!({
747                "id": msg_id,
748                "method": "Page.captureScreenshot",
749                "params": params
750            })
751            .to_string();
752
753            self.parent.activate().await?;
754            let res_cap = send_and_get_msg(
755                self.parent.transport.clone(),
756                msg_id,
757                &self.parent.session_id,
758                msg_cap,
759            )
760            .await?;
761            let data_cap = utils::serde_msg(&res_cap)?;
762
763            // Reset background color override if we changed it
764            if opts.omit_background && matches!(opts.format, ImageFormat::Png) {
765                let msg_id = next_id();
766                let msg = json!({
767                    "id": msg_id,
768                    "method": "Emulation.setDefaultBackgroundColorOverride",
769                    "params": {}
770                })
771                .to_string();
772                let _ = send_and_get_msg(
773                    self.parent.transport.clone(),
774                    msg_id,
775                    &self.parent.session_id,
776                    msg,
777                )
778                .await;
779            }
780
781            data_cap["result"]["data"]
782                .as_str()
783                .map(|s| s.to_string())
784                .context("No image data received")
785        }
786
787        /// Returns the backend node ID of this element.
788        pub fn backend_node_id(&self) -> u64 {
789            self.backend_node_id
790        }
791    }
792}
793
794// ==========================================
795// Module: Tab
796// ==========================================
797mod tab {
798    use crate::element::Element;
799    use crate::transport::{Transport, TransportResponse, next_id};
800    use crate::utils::{self, send_and_get_msg};
801    use crate::{CaptureOptions, ImageFormat, Viewport};
802    use anyhow::{Context, Result, anyhow};
803    use serde_json::{Value, json};
804    use std::sync::Arc;
805
806    /// Represents a CDP browser tab (target) session.
807    pub struct Tab {
808        pub(crate) transport: Arc<Transport>,
809        pub(crate) session_id: String,
810        pub(crate) target_id: String,
811    }
812
813    impl Tab {
814        /// Creates a new blank tab and attaches to it.
815        pub(crate) async fn new(transport: Arc<Transport>) -> Result<Self> {
816            let TransportResponse::Response(res_create) = transport
817                .send(json!({ "id": next_id(), "method": "Target.createTarget", "params": { "url": "about:blank" } }))
818                .await? else { return Err(anyhow!("Invalid response type")); };
819
820            let target_id = res_create.result["targetId"]
821                .as_str()
822                .context("No targetId")?
823                .to_string();
824
825            let TransportResponse::Response(res_attach) = transport
826                .send(json!({ "id": next_id(), "method": "Target.attachToTarget", "params": { "targetId": target_id } }))
827                .await? else { return Err(anyhow!("Invalid response type")); };
828
829            let session_id = res_attach.result["sessionId"]
830                .as_str()
831                .context("No sessionId")?
832                .to_string();
833
834            Ok(Self {
835                transport,
836                session_id,
837                target_id,
838            })
839        }
840
841        /// Helper function: sends a command to Target and waits for response.
842        pub(crate) async fn send_cmd(
843            &self,
844            method: &str,
845            params: serde_json::Value,
846        ) -> Result<Value> {
847            let msg_id = next_id();
848            let msg = json!({
849                "id": msg_id,
850                "method": method,
851                "params": params
852            })
853            .to_string();
854            let res =
855                send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg).await?;
856            utils::serde_msg(&res)
857        }
858
859        /// Sets the viewport size and device scale factor.
860        ///
861        /// This is similar to Puppeteer's `page.setViewport()` and is essential
862        /// for getting higher resolution screenshots via `deviceScaleFactor`.
863        ///
864        /// # Example
865        /// ```rust,ignore
866        /// let viewport = Viewport::new(1920, 1080)
867        ///     .with_device_scale_factor(2.0);  // 2x resolution for sharper images
868        ///
869        /// tab.set_viewport(&viewport).await?;
870        /// ```
871        pub async fn set_viewport(&self, viewport: &Viewport) -> Result<&Self> {
872            let screen_orientation = if viewport.is_landscape {
873                json!({"type": "landscapePrimary", "angle": 90})
874            } else {
875                json!({"type": "portraitPrimary", "angle": 0})
876            };
877
878            self.send_cmd(
879                "Emulation.setDeviceMetricsOverride",
880                json!({
881                    "width": viewport.width,
882                    "height": viewport.height,
883                    "deviceScaleFactor": viewport.device_scale_factor,
884                    "mobile": viewport.is_mobile,
885                    "screenOrientation": screen_orientation
886                }),
887            )
888            .await?;
889
890            // Set touch emulation if needed
891            if viewport.has_touch {
892                self.send_cmd(
893                    "Emulation.setTouchEmulationEnabled",
894                    json!({
895                        "enabled": true,
896                        "maxTouchPoints": 5
897                    }),
898                )
899                .await?;
900            }
901
902            Ok(self)
903        }
904
905        /// Clears the viewport override, returning to default browser behavior.
906        pub async fn clear_viewport(&self) -> Result<&Self> {
907            self.send_cmd("Emulation.clearDeviceMetricsOverride", json!({}))
908                .await?;
909            Ok(self)
910        }
911
912        /// Sets HTML content and waits for the "load" event.
913        pub async fn set_content(&self, content: &str) -> Result<&Self> {
914            // 1. Enable Page domain to ensure lifecycle events are sent
915            self.send_cmd("Page.enable", json!({})).await?;
916
917            // 2. Prepare to wait for `load` event
918            let load_event_future = self
919                .transport
920                .wait_for_event(&self.session_id, "Page.loadEventFired");
921
922            // 3. Execute document.write
923            let js_write = format!(
924                r#"document.open(); document.write({}); document.close();"#,
925                serde_json::to_string(content)?
926            );
927
928            self.send_cmd(
929                "Runtime.evaluate",
930                json!({
931                    "expression": js_write,
932                    "awaitPromise": true
933                }),
934            )
935            .await?;
936
937            // 4. Wait for the event to trigger
938            load_event_future.await?;
939
940            Ok(self)
941        }
942
943        /// Evaluates JavaScript in the page context and returns the result.
944        ///
945        /// # Example
946        /// ```rust,ignore
947        /// let result = tab.evaluate("document.title").await?;
948        /// println!("Page title: {}", result);
949        /// ```
950        pub async fn evaluate(&self, expression: &str) -> Result<Value> {
951            let result = self
952                .send_cmd(
953                    "Runtime.evaluate",
954                    json!({
955                        "expression": expression,
956                        "returnByValue": true,
957                        "awaitPromise": true
958                    }),
959                )
960                .await?;
961            Ok(result["result"]["result"]["value"].clone())
962        }
963
964        /// Evaluates JavaScript and returns the result as a string.
965        pub async fn evaluate_as_string(&self, expression: &str) -> Result<String> {
966            let value = self.evaluate(expression).await?;
967            value
968                .as_str()
969                .map(|s| s.to_string())
970                .or_else(|| Some(value.to_string()))
971                .context("Failed to convert result to string")
972        }
973
974        /// Finds the first element matching the given CSS selector.
975        pub async fn find_element(&self, selector: &str) -> Result<Element<'_>> {
976            let msg_id = next_id();
977            let msg_doc =
978                json!({ "id": msg_id, "method": "DOM.getDocument", "params": {} }).to_string();
979            let res_doc =
980                send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg_doc).await?;
981            let data_doc = utils::serde_msg(&res_doc)?;
982            let root_node_id = data_doc["result"]["root"]["nodeId"]
983                .as_u64()
984                .context("No root node")?;
985
986            let msg_id = next_id();
987            let msg_sel = json!({
988                "id": msg_id,
989                "method": "DOM.querySelector",
990                "params": { "nodeId": root_node_id, "selector": selector }
991            })
992            .to_string();
993            let res_sel =
994                send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg_sel).await?;
995            let data_sel = utils::serde_msg(&res_sel)?;
996            let node_id = data_sel["result"]["nodeId"]
997                .as_u64()
998                .context("Element not found")?;
999
1000            Element::new(self, node_id).await
1001        }
1002
1003        /// Waits for an element matching the selector to appear in the DOM.
1004        ///
1005        /// # Arguments
1006        /// * `selector` - CSS selector to wait for
1007        /// * `timeout_ms` - Maximum time to wait in milliseconds
1008        pub async fn wait_for_selector(
1009            &self,
1010            selector: &str,
1011            timeout_ms: u64,
1012        ) -> Result<Element<'_>> {
1013            let start = std::time::Instant::now();
1014            let timeout = std::time::Duration::from_millis(timeout_ms);
1015
1016            loop {
1017                match self.find_element(selector).await {
1018                    Ok(element) => return Ok(element),
1019                    Err(_) if start.elapsed() < timeout => {
1020                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1021                    }
1022                    Err(e) => return Err(e),
1023                }
1024            }
1025        }
1026
1027        /// Captures a screenshot of the entire page.
1028        ///
1029        /// # Example
1030        /// ```rust,ignore
1031        /// let options = CaptureOptions::new()
1032        ///     .with_format(ImageFormat::Png)
1033        ///     .with_viewport(Viewport::new(1920, 1080).with_device_scale_factor(2.0));
1034        ///
1035        /// let base64_image = tab.screenshot(options).await?;
1036        /// ```
1037        pub async fn screenshot(&self, opts: CaptureOptions) -> Result<String> {
1038            // Apply viewport if specified
1039            if let Some(ref viewport) = opts.viewport {
1040                self.set_viewport(viewport).await?;
1041            }
1042
1043            let mut params = json!({
1044                "format": opts.format.as_str(),
1045                "fromSurface": true,
1046                "captureBeyondViewport": opts.full_page,
1047            });
1048
1049            if matches!(opts.format, ImageFormat::Jpeg | ImageFormat::WebP) {
1050                params["quality"] = json!(opts.quality.unwrap_or(90));
1051            }
1052
1053            if let Some(ref clip) = opts.clip {
1054                params["clip"] = json!({
1055                    "x": clip.x,
1056                    "y": clip.y,
1057                    "width": clip.width,
1058                    "height": clip.height,
1059                    "scale": clip.scale
1060                });
1061            }
1062
1063            if opts.omit_background && matches!(opts.format, ImageFormat::Png) {
1064                self.send_cmd(
1065                    "Emulation.setDefaultBackgroundColorOverride",
1066                    json!({ "color": { "r": 0, "g": 0, "b": 0, "a": 0 } }),
1067                )
1068                .await?;
1069            }
1070
1071            self.activate().await?;
1072
1073            let result = self.send_cmd("Page.captureScreenshot", params).await?;
1074
1075            if opts.omit_background && matches!(opts.format, ImageFormat::Png) {
1076                let _ = self
1077                    .send_cmd("Emulation.setDefaultBackgroundColorOverride", json!({}))
1078                    .await;
1079            }
1080
1081            result["result"]["data"]
1082                .as_str()
1083                .map(|s| s.to_string())
1084                .context("No image data received")
1085        }
1086
1087        /// Activates the target tab to bring it to the foreground.
1088        pub async fn activate(&self) -> Result<&Self> {
1089            let msg_id = next_id();
1090            let msg = json!({ "id": msg_id, "method": "Target.activateTarget", "params": { "targetId": self.target_id } }).to_string();
1091            send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg).await?;
1092            Ok(self)
1093        }
1094
1095        /// Navigates the tab to the specified URL and waits for load.
1096        pub async fn goto(&self, url: &str) -> Result<&Self> {
1097            self.send_cmd("Page.enable", json!({})).await?;
1098
1099            let load_event_future = self
1100                .transport
1101                .wait_for_event(&self.session_id, "Page.loadEventFired");
1102
1103            let msg_id = next_id();
1104            let msg = json!({ "id": msg_id, "method": "Page.navigate", "params": { "url": url } })
1105                .to_string();
1106            send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg).await?;
1107
1108            load_event_future.await?;
1109            Ok(self)
1110        }
1111
1112        /// Navigates to URL without waiting for load event.
1113        pub async fn goto_no_wait(&self, url: &str) -> Result<&Self> {
1114            let msg_id = next_id();
1115            let msg = json!({ "id": msg_id, "method": "Page.navigate", "params": { "url": url } })
1116                .to_string();
1117            send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg).await?;
1118            Ok(self)
1119        }
1120
1121        /// Reloads the current page.
1122        pub async fn reload(&self) -> Result<&Self> {
1123            self.send_cmd("Page.enable", json!({})).await?;
1124
1125            let load_event_future = self
1126                .transport
1127                .wait_for_event(&self.session_id, "Page.loadEventFired");
1128
1129            self.send_cmd("Page.reload", json!({})).await?;
1130
1131            load_event_future.await?;
1132            Ok(self)
1133        }
1134
1135        /// Gets the current URL of the page.
1136        pub async fn url(&self) -> Result<String> {
1137            self.evaluate_as_string("window.location.href").await
1138        }
1139
1140        /// Gets the page title.
1141        pub async fn title(&self) -> Result<String> {
1142            self.evaluate_as_string("document.title").await
1143        }
1144
1145        /// Returns the session ID for this tab.
1146        pub fn session_id(&self) -> &str {
1147            &self.session_id
1148        }
1149
1150        /// Returns the target ID for this tab.
1151        pub fn target_id(&self) -> &str {
1152            &self.target_id
1153        }
1154
1155        /// Closes the target tab.
1156        pub async fn close(&self) -> Result<()> {
1157            let msg_id = next_id();
1158            let msg = json!({ "id": msg_id, "method": "Target.closeTarget", "params": { "targetId": self.target_id } }).to_string();
1159            send_and_get_msg(self.transport.clone(), msg_id, &self.session_id, msg).await?;
1160            Ok(())
1161        }
1162    }
1163}
1164
1165// ==========================================
1166// Module: Browser
1167// ==========================================
1168mod browser {
1169    use crate::transport::{Transport, TransportResponse, next_id};
1170    use crate::{CaptureOptions, Tab, Viewport};
1171    use anyhow::{Context, Result, anyhow};
1172    use rand::{Rng, thread_rng};
1173    use regex::Regex;
1174    use serde_json::json;
1175    use std::io::{BufRead, BufReader};
1176    use std::path::{Path, PathBuf};
1177    use std::process::{Child, Command, Stdio};
1178    use std::sync::Arc;
1179    use std::time::Duration;
1180    use tokio::sync::Mutex;
1181    use which::which;
1182
1183    /// Temporary directory for browser user data, deleted on drop.
1184    struct CustomTempDir {
1185        path: PathBuf,
1186    }
1187
1188    impl CustomTempDir {
1189        /// Creates a new temporary directory with timestamp and random suffix.
1190        fn new(base: PathBuf, prefix: &str) -> Result<Self> {
1191            std::fs::create_dir_all(&base)?;
1192            let name = format!(
1193                "{}_{}_{}",
1194                prefix,
1195                chrono::Local::now().format("%Y%m%d_%H%M%S"),
1196                thread_rng()
1197                    .sample_iter(&rand::distributions::Alphanumeric)
1198                    .take(6)
1199                    .map(char::from)
1200                    .collect::<String>()
1201            );
1202            let path = base.join(name);
1203            std::fs::create_dir(&path)?;
1204            Ok(Self { path })
1205        }
1206    }
1207
1208    impl Drop for CustomTempDir {
1209        fn drop(&mut self) {
1210            // More aggressive cleanup with longer delays for Windows
1211            // Total max wait: ~2.4 seconds (100+200+300*8 ms)
1212            for i in 0..10 {
1213                if std::fs::remove_dir_all(&self.path).is_ok() {
1214                    return;
1215                }
1216                // Increasing delay: 100ms, 200ms, 300ms, 300ms, ...
1217                std::thread::sleep(Duration::from_millis(100 * (i as u64 + 1).min(3)));
1218            }
1219            // Final attempt - ignore error as we've done our best
1220            let _ = std::fs::remove_dir_all(&self.path);
1221        }
1222    }
1223
1224    /// Holds the browser process and associated temporary directory.
1225    struct BrowserProcess {
1226        child: Child,
1227        _temp: CustomTempDir,
1228    }
1229
1230    impl Drop for BrowserProcess {
1231        fn drop(&mut self) {
1232            let _ = self.child.kill();
1233            let _ = self.child.wait();
1234            // Give Chrome time to release file handles before temp dir cleanup
1235            // This is especially important on Windows where file locks persist briefly
1236            std::thread::sleep(Duration::from_millis(200));
1237        }
1238    }
1239
1240    #[derive(Clone)]
1241    pub struct Browser {
1242        transport: Arc<Transport>,
1243        process: Arc<Mutex<Option<BrowserProcess>>>,
1244    }
1245
1246    static GLOBAL_BROWSER: Mutex<Option<Browser>> = Mutex::const_new(None);
1247
1248    impl Browser {
1249        /// Launches a new headless browser instance.
1250        pub async fn new() -> Result<Self> {
1251            Self::launch(true).await
1252        }
1253
1254        /// Launches a new browser instance with head visible.
1255        pub async fn new_with_head() -> Result<Self> {
1256            Self::launch(false).await
1257        }
1258
1259        /// Internal function to start the browser with given headless flag.
1260        async fn launch(headless: bool) -> Result<Self> {
1261            let temp = CustomTempDir::new(std::env::current_dir()?.join("temp"), "cdp-shot")?;
1262            let exe = Self::find_chrome()?;
1263            let port = (8000..9000)
1264                .find(|&p| std::net::TcpListener::bind(("127.0.0.1", p)).is_ok())
1265                .ok_or(anyhow!("No available port"))?;
1266
1267            let mut args = vec![
1268                format!("--remote-debugging-port={}", port),
1269                format!("--user-data-dir={}", temp.path.display()),
1270                "--no-sandbox".into(), "--no-zygote".into(), "--in-process-gpu".into(),
1271                "--disable-dev-shm-usage".into(), "--disable-background-networking".into(),
1272                "--disable-default-apps".into(), "--disable-extensions".into(),
1273                "--disable-sync".into(), "--disable-translate".into(),
1274                "--metrics-recording-only".into(), "--safebrowsing-disable-auto-update".into(),
1275                "--mute-audio".into(), "--no-first-run".into(), "--hide-scrollbars".into(),
1276                "--window-size=1200,1600".into(),
1277                "--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()
1278            ];
1279            if headless {
1280                args.push("--headless=new".into());
1281            }
1282
1283            #[cfg(windows)]
1284            let mut cmd = {
1285                use std::os::windows::process::CommandExt;
1286                let mut c = Command::new(&exe);
1287                c.creation_flags(0x08000000);
1288                c
1289            };
1290            #[cfg(not(windows))]
1291            let mut cmd = Command::new(&exe);
1292
1293            let mut child = cmd.args(args).stderr(Stdio::piped()).spawn()?;
1294            let stderr = child.stderr.take().context("No stderr")?;
1295            let ws_url = Self::wait_for_ws(stderr).await?;
1296
1297            Ok(Self {
1298                transport: Arc::new(Transport::new(&ws_url).await?),
1299                process: Arc::new(Mutex::new(Some(BrowserProcess { child, _temp: temp }))),
1300            })
1301        }
1302
1303        /// Attempts to locate a Chrome or Edge executable in the system.
1304        fn find_chrome() -> Result<PathBuf> {
1305            if let Ok(p) = std::env::var("CHROME") {
1306                return Ok(p.into());
1307            }
1308            let apps = [
1309                "google-chrome-stable",
1310                "chromium",
1311                "chrome",
1312                "msedge",
1313                "microsoft-edge",
1314                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1315                "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1316            ];
1317            for app in apps {
1318                if let Ok(p) = which(app) {
1319                    return Ok(p);
1320                }
1321                if Path::new(app).exists() {
1322                    return Ok(app.into());
1323                }
1324            }
1325
1326            #[cfg(windows)]
1327            {
1328                use winreg::{RegKey, enums::HKEY_LOCAL_MACHINE};
1329                let keys = [
1330                    r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
1331                    r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe",
1332                ];
1333                for k in keys {
1334                    if let Ok(rk) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(k)
1335                        && let Ok(v) = rk.get_value::<String, _>("")
1336                    {
1337                        return Ok(v.into());
1338                    }
1339                }
1340                let paths = [
1341                    r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
1342                    r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
1343                    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
1344                    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
1345                ];
1346                for p in paths {
1347                    if Path::new(p).exists() {
1348                        return Ok(p.into());
1349                    }
1350                }
1351            }
1352            Err(anyhow!("Chrome/Edge not found. Set CHROME env var."))
1353        }
1354
1355        /// Reads browser stderr lines to extract the WebSocket debugging URL.
1356        async fn wait_for_ws(stderr: std::process::ChildStderr) -> Result<String> {
1357            let reader = BufReader::new(stderr);
1358            let re = Regex::new(r"listening on (.*/devtools/browser/.*)$")?;
1359            tokio::task::spawn_blocking(move || {
1360                for line in reader.lines() {
1361                    let l = line?;
1362                    if let Some(cap) = re.captures(&l) {
1363                        return Ok(cap[1].to_string());
1364                    }
1365                }
1366                Err(anyhow!("WS URL not found in stderr"))
1367            })
1368            .await?
1369        }
1370
1371        /// Opens a new blank tab.
1372        pub async fn new_tab(&self) -> Result<Tab> {
1373            Tab::new(self.transport.clone()).await
1374        }
1375
1376        /// Captures a screenshot of HTML content clipped to the given selector using default options.
1377        pub async fn capture_html(&self, html: &str, selector: &str) -> Result<String> {
1378            self.capture_html_with_options(html, selector, CaptureOptions::default())
1379                .await
1380        }
1381
1382        /// Captures a screenshot with options such as format, quality, and viewport.
1383        ///
1384        /// # Example
1385        /// ```rust,ignore
1386        /// let options = CaptureOptions::new()
1387        ///     .with_format(ImageFormat::Png)
1388        ///     .with_viewport(Viewport::new(1920, 1080).with_device_scale_factor(2.0));
1389        ///
1390        /// let base64 = browser.capture_html_with_options(html, "body", options).await?;
1391        /// ```
1392        pub async fn capture_html_with_options(
1393            &self,
1394            html: &str,
1395            selector: &str,
1396            opts: CaptureOptions,
1397        ) -> Result<String> {
1398            let tab = self.new_tab().await?;
1399
1400            // Apply viewport if specified in options
1401            if let Some(ref viewport) = opts.viewport {
1402                tab.set_viewport(viewport).await?;
1403            }
1404
1405            tab.set_content(html).await?;
1406            let el = tab.find_element(selector).await?;
1407            let shot = el.screenshot_with_options(opts).await?;
1408            let _ = tab.close().await;
1409            Ok(shot)
1410        }
1411
1412        /// Captures a high-DPI screenshot with the specified scale factor.
1413        ///
1414        /// Convenience method for getting sharper images without manually
1415        /// configuring viewport and options.
1416        ///
1417        /// # Arguments
1418        /// * `html` - HTML content to render
1419        /// * `selector` - CSS selector for the element to capture
1420        /// * `scale` - Device scale factor (2.0 for retina, 3.0 for ultra-high DPI)
1421        pub async fn capture_html_hidpi(
1422            &self,
1423            html: &str,
1424            selector: &str,
1425            scale: f64,
1426        ) -> Result<String> {
1427            let opts = CaptureOptions::new()
1428                .with_viewport(Viewport::default().with_device_scale_factor(scale));
1429            self.capture_html_with_options(html, selector, opts).await
1430        }
1431
1432        /// Closes the browser process and cleans up resources asynchronously.
1433        pub async fn close_async(&self) -> Result<()> {
1434            self.transport.shutdown().await;
1435            let mut lock = self.process.lock().await;
1436            if let Some(_proc) = lock.take() {
1437                // Drop triggers process kill, wait and temp dir removal.
1438            }
1439            Ok(())
1440        }
1441
1442        /// Checks if the browser connection is still alive.
1443        async fn is_alive(&self) -> bool {
1444            self.transport
1445                .send(json!({
1446                    "id": next_id(),
1447                    "method": "Target.getTargets",
1448                    "params": {}
1449                }))
1450                .await
1451                .is_ok()
1452        }
1453
1454        /// Returns a shared singleton browser instance, launching if necessary.
1455        /// Automatically recreates the instance if it becomes invalid.
1456        pub async fn instance() -> Self {
1457            let mut lock = GLOBAL_BROWSER.lock().await;
1458
1459            // Check if existing instance is still valid
1460            if let Some(b) = &*lock {
1461                if b.is_alive().await {
1462                    return b.clone();
1463                }
1464                // Instance is dead, clean up old process
1465                log::warn!("[cdp-html-shot] Browser instance died, recreating...");
1466                let _ = b.close_async().await;
1467            }
1468
1469            // Recreate instance
1470            let b = Self::new().await.expect("Init global browser failed");
1471
1472            // Close default blank page
1473            if let Ok(TransportResponse::Response(res)) = b
1474                .transport
1475                .send(json!({"id": next_id(), "method":"Target.getTargets", "params":{}}))
1476                .await
1477                && let Some(list) = res.result["targetInfos"].as_array()
1478                && let Some(id) = list
1479                    .iter()
1480                    .find(|t| t["type"] == "page")
1481                    .and_then(|t| t["targetId"].as_str())
1482            {
1483                let _ = b.transport.send(json!({"id":next_id(), "method":"Target.closeTarget", "params":{"targetId":id}})).await;
1484            }
1485
1486            *lock = Some(b.clone());
1487            b
1488        }
1489    }
1490}
1491
1492// ==========================================
1493// Module: Exit Hook
1494// ==========================================
1495#[cfg(feature = "atexit")]
1496mod exit_hook {
1497    use std::sync::{Arc, Once};
1498
1499    /// Registers a function to be called on program exit or Ctrl+C signal.
1500    pub struct ExitHook {
1501        func: Arc<dyn Fn() + Send + Sync>,
1502    }
1503
1504    impl ExitHook {
1505        /// Creates a new exit hook with the specified closure.
1506        pub fn new<F: Fn() + Send + Sync + 'static>(f: F) -> Self {
1507            Self { func: Arc::new(f) }
1508        }
1509
1510        /// Registers the hook to run on Ctrl+C, guaranteeing single registration.
1511        pub fn register(&self) -> Result<(), Box<dyn std::error::Error>> {
1512            static ONCE: Once = Once::new();
1513            let f = self.func.clone();
1514            let res = Ok(());
1515            ONCE.call_once(|| {
1516                if let Err(e) = ctrlc::set_handler(move || {
1517                    f();
1518                    std::process::exit(0);
1519                }) {
1520                    eprintln!("Ctrl+C handler error: {}", e);
1521                }
1522            });
1523            res
1524        }
1525    }
1526
1527    impl Drop for ExitHook {
1528        fn drop(&mut self) {
1529            (self.func)();
1530        }
1531    }
1532}