Skip to main content

browser_control/session/
attach.rs

1//! Attach to a page target and expose engine-agnostic high-level operations.
2//!
3//! [`PageSession`] hides the CDP/BiDi split behind a single async API
4//! (`evaluate`, `navigate`, `screenshot`). The CLI subcommands instantiate
5//! a fresh session per call; the MCP server may pre-build a session backed
6//! by a long-lived BiDi client via [`PageSession::from_bidi_cache`].
7
8use std::sync::Arc;
9
10use anyhow::{anyhow, Result};
11use regex::Regex;
12use serde_json::{json, Value};
13
14use crate::bidi::BidiClient;
15use crate::cdp::CdpClient;
16use crate::detect::Engine;
17use crate::session::targets::{open_bidi, open_cdp};
18
19/// A bound page-level session. Variants are not constructed directly outside
20/// this module; use [`PageSession::attach`].
21pub enum PageSession {
22    Cdp(CdpPage),
23    /// A BiDi page session. The client is shared via `Arc` so the MCP server
24    /// can keep a single persistent BiDi session across many tool calls
25    /// (Firefox limits a browser to one BiDi session at a time).
26    Bidi(BidiPage),
27}
28
29pub struct CdpPage {
30    pub client: CdpClient,
31    pub session_id: String,
32    pub target_id: String,
33}
34
35pub struct BidiPage {
36    pub client: Arc<BidiClient>,
37    pub context: String,
38}
39
40impl PageSession {
41    /// Attach to a fresh page session over `engine`.
42    ///
43    /// If `url_regex` is `Some`, the first page target whose URL matches is
44    /// selected; otherwise the first page (or top-level browsing context) is
45    /// used.
46    pub async fn attach(
47        endpoint: &str,
48        engine: Engine,
49        url_regex: Option<&str>,
50    ) -> Result<Self> {
51        let pattern = url_regex.map(Regex::new).transpose()?;
52        match engine {
53            Engine::Cdp => {
54                let client = open_cdp(endpoint).await?;
55                let target_id = pick_cdp_page(&client, pattern.as_ref()).await?;
56                let session_id = client.attach_to_target(&target_id).await?;
57                Ok(PageSession::Cdp(CdpPage {
58                    client,
59                    session_id,
60                    target_id,
61                }))
62            }
63            Engine::Bidi => {
64                let client = Arc::new(open_bidi(endpoint).await?);
65                client.session_new().await?;
66                let context = pick_bidi_context(&client, pattern.as_ref()).await?;
67                Ok(PageSession::Bidi(BidiPage { client, context }))
68            }
69        }
70    }
71
72    /// Build a BiDi session from a pre-opened, possibly cached client.
73    ///
74    /// The MCP server uses this to share one BiDi client across tool calls;
75    /// `session.new` is invoked only when the client was freshly opened (the
76    /// caller is expected to have done so).
77    pub async fn from_bidi_cache(
78        client: Arc<BidiClient>,
79        url_regex: Option<&str>,
80    ) -> Result<Self> {
81        let pattern = url_regex.map(Regex::new).transpose()?;
82        let context = pick_bidi_context(&client, pattern.as_ref()).await?;
83        Ok(PageSession::Bidi(BidiPage { client, context }))
84    }
85
86    /// Evaluate `expression` in the page's main world.
87    ///
88    /// `await_promise = true` mirrors `Runtime.evaluate({awaitPromise:true})`
89    /// and is appropriate for fetch / promise-returning code. The returned
90    /// value is the raw `result.value` from CDP / BiDi after `returnByValue`.
91    pub async fn evaluate(&self, expression: &str, await_promise: bool) -> Result<Value> {
92        match self {
93            PageSession::Cdp(p) => {
94                let v = p
95                    .client
96                    .send_with_session(
97                        "Runtime.evaluate",
98                        json!({
99                            "expression": expression,
100                            "returnByValue": true,
101                            "awaitPromise": await_promise,
102                        }),
103                        Some(&p.session_id),
104                    )
105                    .await?;
106                Ok(v["result"]["value"].clone())
107            }
108            PageSession::Bidi(p) => {
109                let _ = await_promise; // BiDi always awaits per script_evaluate
110                let v = p.client.script_evaluate(&p.context, expression).await?;
111                Ok(v["result"]["value"].clone())
112            }
113        }
114    }
115
116    /// Navigate the current page to `url`.
117    pub async fn navigate(&self, url: &str) -> Result<()> {
118        match self {
119            PageSession::Cdp(p) => {
120                p.client
121                    .send_with_session(
122                        "Page.navigate",
123                        json!({"url": url}),
124                        Some(&p.session_id),
125                    )
126                    .await?;
127                Ok(())
128            }
129            PageSession::Bidi(p) => {
130                p.client
131                    .browsing_context_navigate(&p.context, url)
132                    .await?;
133                Ok(())
134            }
135        }
136    }
137
138    /// Capture a PNG screenshot of the current page; returns base64 data.
139    pub async fn screenshot(&self, full_page: bool) -> Result<String> {
140        match self {
141            PageSession::Cdp(p) => {
142                let v = p
143                    .client
144                    .send_with_session(
145                        "Page.captureScreenshot",
146                        json!({
147                            "format": "png",
148                            "captureBeyondViewport": full_page,
149                        }),
150                        Some(&p.session_id),
151                    )
152                    .await?;
153                v["data"]
154                    .as_str()
155                    .map(|s| s.to_string())
156                    .ok_or_else(|| anyhow!("no screenshot data"))
157            }
158            PageSession::Bidi(p) => {
159                let _ = full_page; // BiDi captures the viewport by default
160                p.client
161                    .browsing_context_capture_screenshot(&p.context)
162                    .await
163            }
164        }
165    }
166
167    /// Engine this session is bound to.
168    pub fn engine(&self) -> Engine {
169        match self {
170            PageSession::Cdp(_) => Engine::Cdp,
171            PageSession::Bidi(_) => Engine::Bidi,
172        }
173    }
174
175    /// Release the underlying CDP connection (no-op for BiDi, whose client
176    /// is shared via `Arc`).
177    pub async fn close(self) {
178        match self {
179            PageSession::Cdp(p) => p.client.close().await,
180            PageSession::Bidi(_) => {}
181        }
182    }
183}
184
185async fn pick_cdp_page(client: &CdpClient, pattern: Option<&Regex>) -> Result<String> {
186    let targets = client.list_targets().await?;
187    let mut pages = targets.iter().filter(|t| {
188        t.get("type").and_then(|v| v.as_str()) == Some("page")
189    });
190    let pick = if let Some(re) = pattern {
191        pages
192            .find(|t| {
193                t.get("url")
194                    .and_then(|v| v.as_str())
195                    .is_some_and(|u| re.is_match(u))
196            })
197            .ok_or_else(|| anyhow!("no CDP page target matched URL regex"))?
198    } else {
199        pages
200            .next()
201            .ok_or_else(|| anyhow!("no page target found"))?
202    };
203    pick.get("targetId")
204        .and_then(|v| v.as_str())
205        .map(|s| s.to_string())
206        .ok_or_else(|| anyhow!("targetId missing from page target"))
207}
208
209async fn pick_bidi_context(
210    client: &BidiClient,
211    pattern: Option<&Regex>,
212) -> Result<String> {
213    let tree = client.send("browsingContext.getTree", json!({})).await?;
214    let contexts = tree
215        .get("contexts")
216        .and_then(|v| v.as_array())
217        .ok_or_else(|| anyhow!("no contexts in browsingContext.getTree"))?;
218    if let Some(re) = pattern {
219        for c in contexts {
220            let url = c.get("url").and_then(|v| v.as_str()).unwrap_or("");
221            if re.is_match(url) {
222                return c
223                    .get("context")
224                    .and_then(|v| v.as_str())
225                    .map(|s| s.to_string())
226                    .ok_or_else(|| anyhow!("no context id"));
227            }
228        }
229        Err(anyhow!("no BiDi context matched URL regex"))
230    } else {
231        contexts
232            .first()
233            .and_then(|c| c.get("context").and_then(|v| v.as_str()))
234            .map(|s| s.to_string())
235            .ok_or_else(|| anyhow!("no top-level browsing context"))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use futures_util::{SinkExt, StreamExt};
243    use tokio_tungstenite::tungstenite::Message;
244
245    async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
246        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
247        let addr = listener.local_addr().unwrap();
248        tokio::spawn(async move {
249            let (stream, _) = listener.accept().await.unwrap();
250            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
251            while let Some(Ok(Message::Text(t))) = ws.next().await {
252                let req: Value = serde_json::from_str(&t).unwrap();
253                let id = req["id"].as_u64().unwrap();
254                let method = req["method"].as_str().unwrap_or("");
255                let result = match method {
256                    "Target.getTargets" => json!({"targetInfos": targets.clone()}),
257                    "Target.attachToTarget" => json!({"sessionId": "S1"}),
258                    "Runtime.evaluate" => json!({"result": {"value": "ok"}}),
259                    "Page.navigate" => json!({}),
260                    "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
261                    _ => json!({}),
262                };
263                let resp = json!({"id": id, "result": result});
264                ws.send(Message::Text(resp.to_string())).await.unwrap();
265            }
266        });
267        format!("ws://{addr}")
268    }
269
270    #[tokio::test]
271    async fn attach_cdp_picks_first_page_when_no_regex() {
272        let url = spawn_cdp_mock(vec![
273            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
274            json!({"targetId":"b","type":"page","url":"https://other.test/"}),
275        ])
276        .await;
277        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
278        match s {
279            PageSession::Cdp(p) => {
280                assert_eq!(p.target_id, "a");
281                assert_eq!(p.session_id, "S1");
282            }
283            _ => panic!("expected CDP"),
284        }
285    }
286
287    #[tokio::test]
288    async fn attach_cdp_url_regex_selects_matching() {
289        let url = spawn_cdp_mock(vec![
290            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
291            json!({"targetId":"b","type":"page","url":"https://other.test/"}),
292        ])
293        .await;
294        let s = PageSession::attach(&url, Engine::Cdp, Some(r"other"))
295            .await
296            .unwrap();
297        match s {
298            PageSession::Cdp(p) => assert_eq!(p.target_id, "b"),
299            _ => panic!("expected CDP"),
300        }
301    }
302
303    #[tokio::test]
304    async fn attach_cdp_url_regex_no_match_errors() {
305        let url = spawn_cdp_mock(vec![
306            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
307        ])
308        .await;
309        let err = match PageSession::attach(&url, Engine::Cdp, Some("nomatch")).await {
310            Ok(_) => panic!("expected error"),
311            Err(e) => e,
312        };
313        assert!(err.to_string().contains("no CDP page target matched"));
314    }
315
316    #[tokio::test]
317    async fn evaluate_round_trip_cdp() {
318        let url = spawn_cdp_mock(vec![
319            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
320        ])
321        .await;
322        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
323        let v = s.evaluate("1+1", false).await.unwrap();
324        assert_eq!(v, json!("ok"));
325        s.close().await;
326    }
327
328    #[tokio::test]
329    async fn screenshot_round_trip_cdp() {
330        let url = spawn_cdp_mock(vec![
331            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
332        ])
333        .await;
334        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
335        let b64 = s.screenshot(false).await.unwrap();
336        assert_eq!(b64, "PNGDATA");
337        s.close().await;
338    }
339}