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