Skip to main content

browser_control/session/
targets.rs

1//! Unified page-target listing across CDP and BiDi.
2
3use anyhow::{anyhow, Result};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8use crate::bidi::BidiClient;
9use crate::cdp::CdpClient;
10use crate::detect::Engine;
11
12/// Normalised view of a page-like target across engines.
13///
14/// For CDP this maps to a `targetInfo` entry of `type == "page"`. For BiDi
15/// this maps to a top-level browsing context.
16#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct TargetInfo {
18    /// Engine-specific id (CDP `targetId` or BiDi `context`).
19    pub id: String,
20    /// Page URL, possibly empty for new tabs.
21    pub url: String,
22    /// Page title, possibly empty.
23    pub title: String,
24    /// Always `"page"` for CDP; `"context"` for BiDi.
25    pub kind: String,
26}
27
28/// Wire shape of a CDP `Target.getTargets` (`targetInfos`) entry. Only
29/// `target_id` is required; other fields default to empty when absent.
30/// Deserialized directly from the protocol JSON via [`serde_json`].
31#[derive(Debug, Clone, Deserialize)]
32pub(crate) struct CdpTarget {
33    #[serde(rename = "targetId")]
34    pub id: String,
35    #[serde(rename = "type", default)]
36    pub kind: String,
37    #[serde(default)]
38    pub url: String,
39    #[serde(default)]
40    pub title: String,
41}
42
43impl CdpTarget {
44    /// Parse user-facing `type == "page"` entries from a `Target.getTargets`
45    /// / `list_targets` result, skipping any entry that fails to deserialize
46    /// (e.g. missing `targetId`) and Chromium DevTools frontend pages.
47    pub(crate) fn pages(targets: &[Value]) -> impl Iterator<Item = CdpTarget> + '_ {
48        targets
49            .iter()
50            .filter_map(|t| CdpTarget::deserialize(t).ok())
51            .filter(|t| t.kind == "page")
52            .filter(|t| !t.is_devtools_frontend())
53    }
54
55    fn is_devtools_frontend(&self) -> bool {
56        self.url.starts_with("devtools://") || self.url.starts_with("chrome-devtools://")
57    }
58}
59
60/// Wire shape of a BiDi `browsingContext.getTree` context node. Only
61/// `context` is required; `url`/`title` default to empty when absent.
62#[derive(Debug, Clone, Deserialize)]
63pub(crate) struct BidiContext {
64    pub context: String,
65    #[serde(default)]
66    pub url: String,
67    #[serde(default)]
68    pub title: String,
69}
70
71impl BidiContext {
72    /// Parse the top-level context nodes from a `browsingContext.getTree`
73    /// result, skipping any node that fails to deserialize. Children are not
74    /// recursed (callers only care about top-level contexts).
75    pub(crate) fn from_tree(tree: &Value) -> Vec<BidiContext> {
76        tree.get("contexts")
77            .and_then(|v| v.as_array())
78            .map(|arr| {
79                arr.iter()
80                    .filter_map(|c| BidiContext::deserialize(c).ok())
81                    .collect()
82            })
83            .unwrap_or_default()
84    }
85}
86
87/// Connect to `endpoint` (per `engine`) and return the list of page targets,
88/// filtered by `url_regex` if given. The regex is unanchored; use `^…$` if
89/// strict matching is desired.
90pub async fn list(
91    endpoint: &str,
92    engine: Engine,
93    url_regex: Option<&str>,
94) -> Result<Vec<TargetInfo>> {
95    let pattern = url_regex.map(Regex::new).transpose()?;
96    let raw = match engine {
97        Engine::Cdp => list_cdp(endpoint).await?,
98        Engine::Bidi => list_bidi(endpoint).await?,
99    };
100    Ok(raw
101        .into_iter()
102        .filter(|t| pattern.as_ref().map_or(true, |re| re.is_match(&t.url)))
103        .collect())
104}
105
106async fn list_cdp(endpoint: &str) -> Result<Vec<TargetInfo>> {
107    let client = open_cdp(endpoint).await?;
108    let targets = client.list_targets().await?;
109    client.close().await;
110    Ok(CdpTarget::pages(&targets)
111        .map(|t| TargetInfo {
112            id: t.id,
113            url: t.url,
114            title: t.title,
115            kind: "page".to_string(),
116        })
117        .collect())
118}
119
120async fn list_bidi(endpoint: &str) -> Result<Vec<TargetInfo>> {
121    let client = open_bidi(endpoint).await?;
122    client.session_new().await?;
123    let tree = client.send("browsingContext.getTree", json!({})).await;
124    let _ = client.session_end().await;
125    let tree = tree?;
126    Ok(BidiContext::from_tree(&tree)
127        .into_iter()
128        .map(|c| TargetInfo {
129            id: c.context,
130            url: c.url,
131            title: c.title,
132            kind: "context".to_string(),
133        })
134        .collect())
135}
136
137pub(crate) async fn open_cdp(endpoint: &str) -> Result<CdpClient> {
138    if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
139        CdpClient::connect(endpoint).await
140    } else {
141        CdpClient::connect_http(endpoint).await
142    }
143}
144
145pub(crate) async fn open_bidi(endpoint: &str) -> Result<BidiClient> {
146    if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
147        BidiClient::connect(endpoint).await
148    } else {
149        let client = reqwest::Client::new();
150        let v: Value = client
151            .get(format!("{}/json/version", endpoint.trim_end_matches('/')))
152            .send()
153            .await?
154            .json()
155            .await?;
156        let ws = v
157            .get("webSocketDebuggerUrl")
158            .and_then(|v| v.as_str())
159            .ok_or_else(|| anyhow!("no webSocketDebuggerUrl"))?
160            .to_string();
161        BidiClient::connect(&ws).await
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use futures_util::{SinkExt, StreamExt};
169    use tokio_tungstenite::tungstenite::Message;
170
171    async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
172        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
173        let addr = listener.local_addr().unwrap();
174        tokio::spawn(async move {
175            let (stream, _) = listener.accept().await.unwrap();
176            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
177            while let Some(Ok(Message::Text(t))) = ws.next().await {
178                let req: Value = serde_json::from_str(&t).unwrap();
179                let id = req["id"].as_u64().unwrap();
180                let method = req["method"].as_str().unwrap_or("");
181                let result = if method == "Target.getTargets" {
182                    json!({"targetInfos": targets.clone()})
183                } else {
184                    json!({})
185                };
186                let resp = json!({"id": id, "result": result});
187                ws.send(Message::Text(resp.to_string())).await.unwrap();
188            }
189        });
190        format!("ws://{addr}")
191    }
192
193    #[tokio::test]
194    async fn list_cdp_filters_pages_only() {
195        let url = spawn_cdp_mock(vec![
196            json!({"targetId":"a","type":"page","url":"https://example.com/","title":"Ex"}),
197            json!({"targetId":"b","type":"iframe","url":"https://example.com/","title":""}),
198            json!({"targetId":"c","type":"page","url":"https://other.test/","title":"Other"}),
199        ])
200        .await;
201        let out = list(&url, Engine::Cdp, None).await.unwrap();
202        assert_eq!(out.len(), 2);
203        assert_eq!(out[0].id, "a");
204        assert_eq!(out[1].id, "c");
205    }
206
207    #[tokio::test]
208    async fn list_cdp_filters_devtools_frontend_pages() {
209        let url = spawn_cdp_mock(vec![
210            json!({"targetId":"devtools","type":"page","url":"devtools://devtools/bundled/devtools_app.html?remoteBase=https://devtools.example/serve_file/very-long","title":"DevTools - 127.0.0.1:5174/pages"}),
211            json!({"targetId":"chrome-devtools","type":"page","url":"chrome-devtools://devtools/bundled/inspector.html","title":"DevTools"}),
212            json!({"targetId":"app","type":"page","url":"https://example.com/","title":"Example"}),
213        ])
214        .await;
215        let out = list(&url, Engine::Cdp, None).await.unwrap();
216        assert_eq!(out.len(), 1);
217        assert_eq!(out[0].id, "app");
218        assert_eq!(out[0].url, "https://example.com/");
219    }
220
221    #[tokio::test]
222    async fn list_cdp_applies_url_regex() {
223        let url = spawn_cdp_mock(vec![
224            json!({"targetId":"a","type":"page","url":"https://example.com/","title":"Ex"}),
225            json!({"targetId":"c","type":"page","url":"https://other.test/","title":"Other"}),
226        ])
227        .await;
228        let out = list(&url, Engine::Cdp, Some(r"example\.com"))
229            .await
230            .unwrap();
231        assert_eq!(out.len(), 1);
232        assert_eq!(out[0].url, "https://example.com/");
233    }
234
235    #[tokio::test]
236    async fn list_propagates_invalid_regex() {
237        let err = list("ws://127.0.0.1:1", Engine::Cdp, Some("(invalid"))
238            .await
239            .unwrap_err();
240        assert!(err.to_string().to_lowercase().contains("regex"));
241    }
242}