Skip to main content

gthings_cdp/
connection.rs

1use crate::error::{CdpError, Result};
2use futures_util::{SinkExt, StreamExt};
3use serde_json::Value;
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::Duration;
7use tokio::sync::{broadcast, mpsc, oneshot};
8use tokio::task::JoinHandle;
9use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
10
11#[cfg(target_os = "macos")]
12use crate::browser::dismiss_allow_debugging_dialog;
13use tokio_tungstenite::tungstenite::Message;
14static NEXT_CDP_ID: AtomicU64 = AtomicU64::new(1);
15
16/// A CDP event received from the browser (no "id" field, has "method" field).
17#[derive(Debug, Clone)]
18pub struct CdpEvent {
19    pub method: String,
20    pub params: Value,
21    pub session_id: Option<String>,
22}
23
24/// Internal bookkeeping for an in-flight CDP command.
25pub(crate) struct PendingCall {
26    method: String,
27    tx: oneshot::Sender<Result<Value>>,
28}
29
30pub(crate) type PendingMap = HashMap<u64, PendingCall>;
31
32/// Internal messages sent from `Connection::call` to the background I/O task.
33pub(crate) enum InternalMessage {
34    Call {
35        id: u64,
36        method: String,
37        params: Value,
38        session_id: Option<String>,
39        tx: oneshot::Sender<Result<Value>>,
40    },
41}
42
43/// Event-driven CDP WebSocket connection.
44///
45/// Spawns a background task that multiplexes outgoing CDP commands and
46/// incoming messages (responses → oneshot dispatch, events → broadcast).
47pub struct Connection {
48    write: mpsc::UnboundedSender<InternalMessage>,
49    events: broadcast::Sender<CdpEvent>,
50    handle: JoinHandle<()>,
51}
52
53impl std::fmt::Debug for Connection {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("Connection").finish_non_exhaustive()
56    }
57}
58
59impl Connection {
60    /// Connect to a CDP WebSocket endpoint.
61    pub async fn connect(ws_url: &str) -> Result<Self> {
62        // Start connecting in background
63        let ws_url_owned = ws_url.to_owned();
64        let connect_fut = tokio::spawn(async move { connect_async(&ws_url_owned).await });
65
66        // Wait 600ms, then dismiss the Dia dialog (if on macOS).
67        // The dialog appears during the WebSocket handshake, so we must
68        // dismiss it *while* the connection is still in progress.
69        #[cfg(target_os = "macos")]
70        {
71            tokio::time::sleep(Duration::from_millis(600)).await;
72            dismiss_allow_debugging_dialog();
73        }
74
75        // Await the connection
76        let (ws_stream, _) = connect_fut
77            .await
78            .map_err(|e| CdpError::ConnectionFailed {
79                detail: format!("task join: {e}"),
80            })?
81            .map_err(|e| CdpError::ConnectionFailed {
82                detail: format!("WebSocket connect to {ws_url} failed: {e}"),
83            })?;
84
85        let (write_tx, write_rx) = mpsc::unbounded_channel::<InternalMessage>();
86        let (events_tx, _) = broadcast::channel::<CdpEvent>(256);
87        let pending: PendingMap = HashMap::new();
88
89        let (ws_writer, ws_reader) = ws_stream.split();
90        let events_clone = events_tx.clone();
91
92        let handle = tokio::spawn(async move {
93            Self::run(write_rx, ws_writer, ws_reader, pending, events_clone).await;
94        });
95
96        Ok(Connection {
97            write: write_tx,
98            events: events_tx,
99            handle,
100        })
101    }
102
103    /// Background I/O loop: processes outgoing commands and incoming WebSocket messages.
104    async fn run(
105        mut write_rx: mpsc::UnboundedReceiver<InternalMessage>,
106        mut ws_writer: futures_util::stream::SplitSink<
107            WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
108            Message,
109        >,
110        mut ws_reader: futures_util::stream::SplitStream<
111            WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
112        >,
113        mut pending: PendingMap,
114        events: broadcast::Sender<CdpEvent>,
115    ) {
116        loop {
117            tokio::select! {
118                // Outgoing CDP commands from Connection::call
119                msg = write_rx.recv() => {
120                    match msg {
121                        Some(InternalMessage::Call { id, method, params, session_id, tx }) => {
122                            let cmd = if let Some(ref sid) = session_id {
123                                serde_json::json!({
124                                    "id": id,
125                                    "method": method,
126                                    "params": params,
127                                    "sessionId": sid,
128                                })
129                            } else {
130                                serde_json::json!({
131                                    "id": id,
132                                    "method": method,
133                                    "params": params,
134                                })
135                            };
136                            let text = match serde_json::to_string(&cmd) {
137                                Ok(t) => t,
138                                Err(e) => {
139                                    tracing::warn!("Failed to serialize CDP command: {e}");
140                                    let _ = tx.send(Err(CdpError::Json(e)));
141                                    continue;
142                                }
143                            };
144                            // Store pending before sending to avoid race
145                            pending.insert(id, PendingCall { method, tx });
146                            if let Err(e) = ws_writer.send(Message::Text(text)).await {
147                                tracing::warn!("WS send error: {e}");
148                                if let Some(pc) = pending.remove(&id) {
149                                    let _ = pc.tx.send(Err(CdpError::ConnectionFailed {
150                                        detail: format!("WebSocket send failed: {e}"),
151                                    }));
152                                }
153                                break;
154                            }
155                        }
156                        None => break,
157                    }
158                }
159                // Incoming WebSocket messages
160                msg = ws_reader.next() => {
161                    match msg {
162                        Some(Ok(Message::Text(text))) => {
163                            if let Ok(value) = serde_json::from_str::<Value>(&text) {
164                                Self::dispatch_message(value, &mut pending, &events).await;
165                            }
166                        }
167                        Some(Ok(Message::Close(frame))) => {
168                            tracing::debug!("CDP WebSocket closed: {frame:?}");
169                            break;
170                        }
171                        Some(Ok(Message::Binary(_))) => {
172                            // Binary frames not expected from CDP
173                        }
174                        Some(Err(e)) => {
175                            tracing::warn!("CDP WebSocket read error: {e}");
176                            break;
177                        }
178                        None => {
179                            tracing::debug!("CDP WebSocket stream ended");
180                            break;
181                        }
182                        _ => {}
183                    }
184                }
185            }
186        }
187
188        // Clean up all pending calls when the loop exits
189        for (_, pc) in pending.drain() {
190            let _ = pc.tx.send(Err(CdpError::ConnectionFailed {
191                detail: "WebSocket connection closed".into(),
192            }));
193        }
194    }
195
196    /// Route an incoming JSON message: either a response (has "id") or an event (has "method").
197    async fn dispatch_message(
198        value: Value,
199        pending: &mut PendingMap,
200        events: &broadcast::Sender<CdpEvent>,
201    ) {
202        if let Some(id) = value.get("id").and_then(|v| v.as_u64()) {
203            // Command response — route to the waiting oneshot
204            if let Some(pc) = pending.remove(&id) {
205                let result = if let Some(err) = value.get("error") {
206                    let detail = err
207                        .get("message")
208                        .and_then(|v| v.as_str())
209                        .unwrap_or("unknown error")
210                        .to_string();
211                    Err(CdpError::CdpCallFailed {
212                        method: pc.method,
213                        detail,
214                    })
215                } else {
216                    Ok(value.get("result").cloned().unwrap_or(Value::Null))
217                };
218                let _ = pc.tx.send(result);
219            }
220        } else if let Some(method) = value.get("method").and_then(|v| v.as_str()) {
221            // CDP event — broadcast to subscribers
222            let session_id = value
223                .get("sessionId")
224                .and_then(|v| v.as_str())
225                .map(String::from);
226            let evt = CdpEvent {
227                method: method.to_string(),
228                params: value.get("params").cloned().unwrap_or(Value::Null),
229                session_id,
230            };
231            let _ = events.send(evt);
232        }
233    }
234
235    /// Send a CDP command and wait for the response.
236    ///
237    /// If `session_id` is `Some`, the command is sent as a Target-scoped message;
238    /// if `None`, it is sent as a Browser-level message.
239    pub async fn call(
240        &self,
241        method: &str,
242        params: Value,
243        session_id: Option<&str>,
244    ) -> Result<Value> {
245        let id = NEXT_CDP_ID.fetch_add(1, Ordering::Relaxed);
246        let (tx, rx) = oneshot::channel();
247
248        let msg = InternalMessage::Call {
249            id,
250            method: method.to_string(),
251            params,
252            session_id: session_id.map(String::from),
253            tx,
254        };
255
256        self.write
257            .send(msg)
258            .map_err(|_| CdpError::ConnectionFailed {
259                detail: "background I/O task has terminated".into(),
260            })?;
261
262        tokio::time::timeout(Duration::from_secs(30), rx)
263            .await
264            .map_err(|_| CdpError::CdpCallFailed {
265                method: method.to_string(),
266                detail: "timeout waiting for response".into(),
267            })? // -> Result<Result<Value, CdpError>, RecvError>
268            .map_err(|_| CdpError::CdpCallFailed {
269                method: method.to_string(),
270                detail: "oneshot channel closed".into(),
271            })? // -> Result<Value, CdpError>
272    }
273
274    /// Subscribe to all CDP events broadcast from the browser.
275    pub fn event_rx(&self) -> broadcast::Receiver<CdpEvent> {
276        self.events.subscribe()
277    }
278
279    /// Disconnect cleanly. Closes the command channel and waits for the
280    /// background I/O task to finish.
281    pub async fn close(self) {
282        let Self {
283            write,
284            events: _,
285            handle,
286        } = self;
287        drop(write); // Signals the background loop to exit
288        let _ = handle.await;
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use serde_json::json;
296
297    // -----------------------------------------------------------------------
298    // dispatch_message routing tests
299    // -----------------------------------------------------------------------
300
301    #[test]
302    fn test_dispatch_message_routes_response_to_oneshot() {
303        let mut pending: PendingMap = HashMap::new();
304        let (tx, mut rx) = oneshot::channel();
305        pending.insert(
306            1,
307            PendingCall {
308                method: "Test.method".into(),
309                tx,
310            },
311        );
312        let (event_tx, _) = broadcast::channel::<CdpEvent>(256);
313
314        let rt = tokio::runtime::Runtime::new().unwrap();
315        rt.block_on(Connection::dispatch_message(
316            json!({"id": 1, "result": {"data": "hello"}}),
317            &mut pending,
318            &event_tx,
319        ));
320
321        assert!(pending.is_empty(), "pending call should be removed");
322        let received = rx.try_recv().expect("oneshot should have been sent");
323        assert!(received.is_ok(), "response should be Ok");
324        assert_eq!(
325            received.unwrap().get("data").and_then(|v| v.as_str()),
326            Some("hello")
327        );
328    }
329
330    #[test]
331    fn test_dispatch_message_routes_error_to_oneshot() {
332        let mut pending: PendingMap = HashMap::new();
333        let (tx, mut rx) = oneshot::channel();
334        pending.insert(
335            42,
336            PendingCall {
337                method: "Test.method".into(),
338                tx,
339            },
340        );
341        let (event_tx, _) = broadcast::channel::<CdpEvent>(256);
342
343        let rt = tokio::runtime::Runtime::new().unwrap();
344        rt.block_on(Connection::dispatch_message(
345            json!({"id": 42, "error": {"code": -32000, "message": "Cannot find context"}}),
346            &mut pending,
347            &event_tx,
348        ));
349
350        assert!(pending.is_empty(), "pending call should be removed");
351        let received = rx.try_recv().expect("oneshot should have been sent");
352        assert!(received.is_err(), "error response should be Err");
353    }
354
355    #[test]
356    fn test_dispatch_message_unknown_id_ignored() {
357        let mut pending: PendingMap = HashMap::new();
358        let (event_tx, _) = broadcast::channel::<CdpEvent>(256);
359
360        // A response with an id not in pending should be silently ignored
361        let rt = tokio::runtime::Runtime::new().unwrap();
362        rt.block_on(Connection::dispatch_message(
363            json!({"id": 999, "result": {}}),
364            &mut pending,
365            &event_tx,
366        ));
367
368        assert!(pending.is_empty(), "pending should remain empty");
369    }
370
371    #[test]
372    fn test_dispatch_message_broadcasts_event_with_session_id() {
373        let mut pending: PendingMap = HashMap::new();
374        let (event_tx, mut event_rx) = broadcast::channel::<CdpEvent>(256);
375
376        let rt = tokio::runtime::Runtime::new().unwrap();
377        rt.block_on(Connection::dispatch_message(
378            json!({"method": "Runtime.consoleAPICalled", "params": {"level": "info"}, "sessionId": "sess-1"}),
379            &mut pending,
380            &event_tx,
381        ));
382
383        assert!(pending.is_empty());
384        let evt = event_rx.try_recv().expect("event should be broadcast");
385        assert_eq!(evt.method, "Runtime.consoleAPICalled");
386        assert_eq!(
387            evt.params.get("level").and_then(|v| v.as_str()),
388            Some("info")
389        );
390        assert_eq!(evt.session_id.as_deref(), Some("sess-1"));
391    }
392
393    #[test]
394    fn test_dispatch_message_unrecognized_message_is_noop() {
395        let mut pending: PendingMap = HashMap::new();
396        let (event_tx, mut event_rx) = broadcast::channel::<CdpEvent>(256);
397
398        let rt = tokio::runtime::Runtime::new().unwrap();
399        rt.block_on(Connection::dispatch_message(
400            json!({"some": "garbage"}),
401            &mut pending,
402            &event_tx,
403        ));
404
405        assert!(pending.is_empty());
406        match event_rx.try_recv() {
407            Err(broadcast::error::TryRecvError::Empty) => {} // expected
408            other => panic!("expected Empty, got {other:?}"),
409        }
410    }
411}