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