Skip to main content

gthings_cdp/
tab.rs

1use crate::connection::CdpEvent;
2use crate::error::{CdpError, Result};
3use crate::session::Session;
4use serde_json::{json, Value};
5use std::time::Duration;
6use tracing;
7
8/// Represents a browser tab/page
9#[derive(Debug, Clone)]
10pub struct Tab {
11    pub target_id: String,
12    pub session_id: Option<String>,
13}
14
15impl Tab {
16    /// Create a new tab via CDP. If background=true, opens without focusing.
17    pub async fn create(session: &Session, url: &str) -> Result<Self> {
18        let conn = session.connection();
19        let result = conn
20            .call(
21                "Target.createTarget",
22                json!({ "url": url }),
23                None,
24            )
25            .await?;
26
27        // Try to get sessionId first (standard CDP behavior)
28        if let Some(session_id) = result.get("sessionId").and_then(|v| v.as_str()) {
29            let target_id = result
30                .get("targetId")
31                .and_then(|v| v.as_str())
32                .ok_or_else(|| CdpError::CdpCallFailed {
33                    method: "Target.createTarget".into(),
34                    detail: "no targetId in response".into(),
35                })?
36                .to_string();
37
38            tracing::debug!(
39                "Created tab: target={target}, session={session_id}",
40                target = target_id
41            );
42
43            return Ok(Tab {
44                session_id: Some(session_id.to_string()),
45                target_id,
46            });
47        }
48
49        // Missing sessionId — Dia returns targetId without sessionId.
50        // Attach to the target via CDP instead of falling back to HTTP.
51        if let Some(target_id) = result.get("targetId").and_then(|v| v.as_str()) {
52            tracing::warn!(
53                "Target.createTarget returned targetId without sessionId, attaching..."
54            );
55            let attach = conn
56                .call(
57                    "Target.attachToTarget",
58                    json!({
59                        "targetId": target_id,
60                        "flatten": true,
61                    }),
62                    None,
63                )
64                .await
65                .map_err(|e| CdpError::CdpCallFailed {
66                    method: "Target.attachToTarget".into(),
67                    detail: format!("attach failed: {e}"),
68                })?;
69
70            let session_id = attach
71                .get("sessionId")
72                .and_then(|v| v.as_str())
73                .ok_or_else(|| CdpError::CdpCallFailed {
74                    method: "Target.attachToTarget".into(),
75                    detail: "no sessionId in attach response".into(),
76                })?
77                .to_string();
78
79            tracing::info!(
80                "Attached to created target: {target_id} (session={session_id})",
81            );
82
83            return Ok(Tab {
84                session_id: Some(session_id),
85                target_id: target_id.to_string(),
86            });
87        }
88
89        Err(CdpError::CdpCallFailed {
90            method: "Target.createTarget".into(),
91            detail: "could not create tab: no targetId or sessionId in response".into(),
92        })
93    }
94
95    /// Navigate to URL and wait for fully loaded. Delegates to Session::navigate.
96    pub async fn navigate(&self, session: &Session, url: &str) -> Result<()> {
97        session.navigate(self, url).await
98    }
99
100    /// Evaluate JS in tab context, return JSON result
101    pub async fn evaluate(&self, session: &Session, js: &str) -> Result<Value> {
102        let conn = session.connection();
103        let sid = self.session_id.as_deref();
104
105        let result = conn
106            .call(
107                "Runtime.evaluate",
108                json!({
109                    "expression": js,
110                    "returnByValue": true,
111                }),
112                sid,
113            )
114            .await?;
115
116        Ok(result)
117    }
118
119    /// Wait until page is fully loaded using lifecycle events
120    pub async fn wait_loaded(&self, session: &Session, timeout: Duration) -> Result<()> {
121        let sid = self.session_id.clone();
122
123        session
124            .wait_for(
125                "Page.lifecycleEvent",
126                move |evt: &CdpEvent| {
127                    // Check that the event belongs to our session (if we have one)
128                    let session_match = match &sid {
129                        Some(sid) => evt.session_id.as_deref() == Some(sid.as_str()),
130                        None => true,
131                    };
132                    // Check lifecycle name is networkIdle
133                    let name_match = evt
134                        .params
135                        .get("name")
136                        .and_then(|v| v.as_str())
137                        == Some("networkIdle");
138                    session_match && name_match
139                },
140                timeout,
141            )
142            .await?;
143
144        Ok(())
145    }
146
147    /// Extract page title
148    pub async fn title(&self, session: &Session) -> Result<String> {
149        match self.evaluate(session, "document.title || ''").await {
150            Ok(val) => {
151                let title = val
152                    .get("result")
153                    .and_then(|r| r.get("value"))
154                    .and_then(|v| v.as_str())
155                    .unwrap_or_else(|| {
156                        tracing::warn!("failed to extract title");
157                        ""
158                    });
159                Ok(title.to_string())
160            }
161            Err(e) => {
162                tracing::warn!("failed to extract title: {e}");
163                Ok(String::new())
164            }
165        }
166    }
167
168    /// Close the tab
169    pub async fn close(self, session: &Session) -> Result<()> {
170        let conn = session.connection();
171        let sid = self.session_id.as_deref();
172
173        // Best-effort: close via JS first (Dia needs this before CDP close)
174        let _ = conn
175            .call(
176                "Runtime.evaluate",
177                json!({
178                    "expression": "window.close()",
179                    "userGesture": true,
180                }),
181                sid,
182            )
183            .await;
184
185        // Wait briefly for the JS close to take effect
186        tokio::time::sleep(Duration::from_millis(100)).await;
187
188        // Then close via CDP
189        conn.call(
190            "Target.closeTarget",
191            json!({ "targetId": self.target_id }),
192            None,
193        )
194        .await?;
195        Ok(())
196    }
197}
198
199