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;
14pub(crate) static 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    /// (crate-internal) Clone the write channel for fire-and-forget CDP commands
280    /// from spawned background tasks.
281    pub(crate) fn write_tx(&self) -> mpsc::UnboundedSender<InternalMessage> {
282        self.write.clone()
283    }
284
285    /// Disconnect cleanly. Closes the command channel and waits for the
286    /// background I/O task to finish.
287    pub async fn close(self) {
288        let Self {
289            write,
290            events: _,
291            handle,
292        } = self;
293        drop(write); // Signals the background loop to exit
294        let _ = handle.await;
295    }
296}
297
298/// Send a CDP command without waiting for the response (fire-and-forget).
299/// Used by background tasks that don't need the result.
300pub(crate) fn call_async(
301    write: &mpsc::UnboundedSender<InternalMessage>,
302    method: &str,
303    params: Value,
304    session_id: Option<String>,
305) {
306    let id = NEXT_CDP_ID.fetch_add(1, Ordering::Relaxed);
307    let (tx, _) = oneshot::channel();
308    let msg = InternalMessage::Call {
309        id,
310        method: method.to_string(),
311        params,
312        session_id,
313        tx,
314    };
315    let _ = write.send(msg);
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use serde_json::json;
322
323    // -----------------------------------------------------------------------
324    // dispatch_message routing tests
325    // -----------------------------------------------------------------------
326
327    #[test]
328    fn test_dispatch_message_routes_response_to_oneshot() {
329        let mut pending: PendingMap = HashMap::new();
330        let (tx, mut rx) = oneshot::channel();
331        pending.insert(
332            1,
333            PendingCall {
334                method: "Test.method".into(),
335                tx,
336            },
337        );
338        let (event_tx, _) = broadcast::channel::<CdpEvent>(256);
339
340        let rt = tokio::runtime::Runtime::new().unwrap();
341        rt.block_on(Connection::dispatch_message(
342            json!({"id": 1, "result": {"data": "hello"}}),
343            &mut pending,
344            &event_tx,
345        ));
346
347        assert!(pending.is_empty(), "pending call should be removed");
348        let received = rx.try_recv().expect("oneshot should have been sent");
349        assert!(received.is_ok(), "response should be Ok");
350        assert_eq!(
351            received.unwrap().get("data").and_then(|v| v.as_str()),
352            Some("hello")
353        );
354    }
355
356    #[test]
357    fn test_dispatch_message_routes_error_to_oneshot() {
358        let mut pending: PendingMap = HashMap::new();
359        let (tx, mut rx) = oneshot::channel();
360        pending.insert(
361            42,
362            PendingCall {
363                method: "Test.method".into(),
364                tx,
365            },
366        );
367        let (event_tx, _) = broadcast::channel::<CdpEvent>(256);
368
369        let rt = tokio::runtime::Runtime::new().unwrap();
370        rt.block_on(Connection::dispatch_message(
371            json!({"id": 42, "error": {"code": -32000, "message": "Cannot find context"}}),
372            &mut pending,
373            &event_tx,
374        ));
375
376        assert!(pending.is_empty(), "pending call should be removed");
377        let received = rx.try_recv().expect("oneshot should have been sent");
378        assert!(received.is_err(), "error response should be Err");
379    }
380
381    #[test]
382    fn test_dispatch_message_unknown_id_ignored() {
383        let mut pending: PendingMap = HashMap::new();
384        let (event_tx, _) = broadcast::channel::<CdpEvent>(256);
385
386        // A response with an id not in pending should be silently ignored
387        let rt = tokio::runtime::Runtime::new().unwrap();
388        rt.block_on(Connection::dispatch_message(
389            json!({"id": 999, "result": {}}),
390            &mut pending,
391            &event_tx,
392        ));
393
394        assert!(pending.is_empty(), "pending should remain empty");
395    }
396
397    #[test]
398    fn test_dispatch_message_broadcasts_event_with_session_id() {
399        let mut pending: PendingMap = HashMap::new();
400        let (event_tx, mut event_rx) = broadcast::channel::<CdpEvent>(256);
401
402        let rt = tokio::runtime::Runtime::new().unwrap();
403        rt.block_on(Connection::dispatch_message(
404            json!({"method": "Runtime.consoleAPICalled", "params": {"level": "info"}, "sessionId": "sess-1"}),
405            &mut pending,
406            &event_tx,
407        ));
408
409        assert!(pending.is_empty());
410        let evt = event_rx.try_recv().expect("event should be broadcast");
411        assert_eq!(evt.method, "Runtime.consoleAPICalled");
412        assert_eq!(
413            evt.params.get("level").and_then(|v| v.as_str()),
414            Some("info")
415        );
416        assert_eq!(evt.session_id.as_deref(), Some("sess-1"));
417    }
418
419    #[test]
420    fn test_dispatch_message_unrecognized_message_is_noop() {
421        let mut pending: PendingMap = HashMap::new();
422        let (event_tx, mut event_rx) = broadcast::channel::<CdpEvent>(256);
423
424        let rt = tokio::runtime::Runtime::new().unwrap();
425        rt.block_on(Connection::dispatch_message(
426            json!({"some": "garbage"}),
427            &mut pending,
428            &event_tx,
429        ));
430
431        assert!(pending.is_empty());
432        match event_rx.try_recv() {
433            Err(broadcast::error::TryRecvError::Empty) => {} // expected
434            other => panic!("expected Empty, got {other:?}"),
435        }
436    }
437}