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        conn.call("Runtime.evaluate", json!({
96            "expression": js,
97            "returnByValue": true,
98            "awaitPromise": true,
99            "timeout": 10000,
100        }), sid).await
101    }
102
103    /// Wait until page is fully loaded using lifecycle events
104    pub async fn wait_loaded(&self, session: &Session, timeout: Duration) -> Result<()> {
105        let sid = self.session_id.clone();
106
107        session
108            .wait_for(
109                "Page.lifecycleEvent",
110                move |evt: &CdpEvent| {
111                    // Check that the event belongs to our session (if we have one)
112                    let session_match = match &sid {
113                        Some(sid) => evt.session_id.as_deref() == Some(sid.as_str()),
114                        None => true,
115                    };
116                    // Check lifecycle name is networkIdle
117                    let name_match =
118                        evt.params.get("name").and_then(|v| v.as_str()) == Some("networkIdle");
119                    session_match && name_match
120                },
121                timeout,
122            )
123            .await?;
124
125        Ok(())
126    }
127
128    /// Extract page title
129    pub async fn title(&self, session: &Session) -> Result<String> {
130        match self.evaluate(session, "document.title || ''").await {
131            Ok(val) => {
132                let title = val
133                    .get("result")
134                    .and_then(|r| r.get("value"))
135                    .and_then(|v| v.as_str())
136                    .unwrap_or_else(|| {
137                        tracing::warn!("failed to extract title");
138                        ""
139                    });
140                Ok(title.to_string())
141            }
142            Err(e) => {
143                tracing::warn!("failed to extract title: {e}");
144                Ok(String::new())
145            }
146        }
147    }
148
149    /// Close the tab
150    pub async fn close(self, session: &Session) -> Result<()> {
151        let conn = session.connection();
152        let sid = self.session_id.as_deref();
153
154        // Best-effort: close via JS first (Dia needs this before CDP close)
155        let _ = conn
156            .call(
157                "Runtime.evaluate",
158                json!({
159                    "expression": "window.close()",
160                    "userGesture": true,
161                }),
162                sid,
163            )
164            .await;
165
166        // Wait briefly for the JS close to take effect
167        tokio::time::sleep(Duration::from_millis(100)).await;
168
169        // Then close via CDP
170        conn.call(
171            "Target.closeTarget",
172            json!({ "targetId": self.target_id }),
173            None,
174        )
175        .await?;
176        Ok(())
177    }
178}
179
180