Skip to main content

gthings_cdp/
tab.rs

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