Skip to main content

grabcli_core/
cdp.rs

1//! Chrome DevTools Protocol client.
2//!
3//! Talks to a logged-in Chrome/Chromium exposing CDP (default
4//! `http://127.0.0.1:9222`) to do two things grabcli can't do with HTTP alone:
5//!
6//! - [`export_cookies`]: read the browser's Grab cookies (`Storage.getCookies`)
7//!   so the user never pastes credentials.
8//! - [`mint_hydra`]: open a `food.grab.com` page in the browser, let it mint a
9//!   real `x-hydra-jwt`, and sniff the header off the network events.
10//! - [`evaluate_in_origin`]: evaluate JS in a tab on a given origin (used to
11//!   mint GrabMart reCAPTCHA v3 tokens on `mart.grab.com`).
12//! - [`Browser`]: drive one tab inside an isolated browser context
13//!   (`Target.createBrowserContext`) for `grab auth login`; the context has
14//!   its own cookies and outlives the process until disposed.
15//!
16//! The endpoint is any CDP-speaking browser the user is logged into; grabcli
17//! doesn't assume a particular one. It only drives a tab it opens or a tab
18//! already on `food.grab.com`, and never touches unrelated tabs.
19
20use std::time::Duration;
21
22use futures_util::{SinkExt, StreamExt};
23use serde_json::{Value, json};
24use tokio_tungstenite::tungstenite::Message;
25
26use crate::error::{Error, ErrorKind, Result};
27use crate::jwt;
28use crate::session::{Cookie, Session};
29
30fn cdp_err(msg: impl Into<String>) -> Error {
31    Error::new(ErrorKind::CdpUnavailable, msg.into())
32}
33
34/// `http://host:port` -> the browser-level websocket debugger URL + User-Agent.
35async fn version(base: &str) -> Result<(String, String)> {
36    let url = format!("{}/json/version", base.trim_end_matches('/'));
37    let v: Value = reqwest::get(&url)
38        .await
39        .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?
40        .json()
41        .await
42        .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?;
43    let ws = v
44        .get("webSocketDebuggerUrl")
45        .and_then(Value::as_str)
46        .ok_or_else(|| cdp_err("CDP /json/version has no webSocketDebuggerUrl"))?
47        .to_string();
48    let ua = v
49        .get("User-Agent")
50        .and_then(Value::as_str)
51        .unwrap_or_default()
52        .to_string();
53    Ok((ws, ua))
54}
55
56async fn list_pages(base: &str) -> Result<Vec<Value>> {
57    let url = format!("{}/json", base.trim_end_matches('/'));
58    let v: Value = reqwest::get(&url)
59        .await
60        .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?
61        .json()
62        .await
63        .map_err(|e| cdp_err(format!("CDP {url}: {e}")))?;
64    Ok(v.as_array().cloned().unwrap_or_default())
65}
66
67/// A thin CDP websocket connection with monotonically increasing ids.
68struct Conn {
69    ws: tokio_tungstenite::WebSocketStream<
70        tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
71    >,
72    next_id: u64,
73    /// Events read while waiting for a reply, for [`Conn::next_event`].
74    events: std::collections::VecDeque<Value>,
75}
76
77impl Conn {
78    async fn connect(ws_url: &str) -> Result<Self> {
79        let (ws, _) = tokio_tungstenite::connect_async(ws_url)
80            .await
81            .map_err(|e| cdp_err(format!("CDP websocket {ws_url}: {e}")))?;
82        Ok(Self {
83            ws,
84            next_id: 0,
85            events: std::collections::VecDeque::new(),
86        })
87    }
88
89    async fn send(&mut self, method: &str, params: Value) -> Result<u64> {
90        self.send_to(None, method, params).await
91    }
92
93    /// Send to a flattened target session (`Target.attachToTarget`), or to
94    /// the connection itself when `session` is `None`.
95    async fn send_to(&mut self, session: Option<&str>, method: &str, params: Value) -> Result<u64> {
96        self.next_id += 1;
97        let id = self.next_id;
98        let mut msg = json!({"id": id, "method": method, "params": params});
99        if let Some(s) = session {
100            msg["sessionId"] = json!(s);
101        }
102        self.ws
103            .send(Message::Text(msg.to_string().into()))
104            .await
105            .map_err(|e| cdp_err(format!("CDP send {method}: {e}")))?;
106        Ok(id)
107    }
108
109    /// Read the reply to request `id`, ignoring events and other replies.
110    async fn result_for(&mut self, id: u64, timeout: Duration) -> Result<Value> {
111        let deadline = tokio::time::Instant::now() + timeout;
112        loop {
113            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
114            if remaining.is_zero() {
115                return Err(cdp_err("timed out waiting for CDP reply"));
116            }
117            let msg = tokio::time::timeout(remaining, self.ws.next())
118                .await
119                .map_err(|_| cdp_err("timed out waiting for CDP reply"))?
120                .ok_or_else(|| cdp_err("CDP connection closed"))?
121                .map_err(|e| cdp_err(format!("CDP recv: {e}")))?;
122            if let Message::Text(txt) = msg {
123                let v: Value = serde_json::from_str(&txt)?;
124                if v.get("id").and_then(Value::as_u64) == Some(id) {
125                    if let Some(err) = v.get("error") {
126                        return Err(cdp_err(format!("CDP error: {err}")));
127                    }
128                    return Ok(v.get("result").cloned().unwrap_or(Value::Null));
129                }
130                if v.get("method").is_some() && self.events.len() < 10_000 {
131                    self.events.push_back(v);
132                }
133            }
134        }
135    }
136
137    /// Send and wait for the reply.
138    async fn call(&mut self, session: Option<&str>, method: &str, params: Value) -> Result<Value> {
139        let id = self.send_to(session, method, params).await?;
140        self.result_for(id, Duration::from_secs(20)).await
141    }
142
143    /// Next event (buffered first), or `None` after `timeout`.
144    async fn next_event(&mut self, timeout: Duration) -> Result<Option<Value>> {
145        if let Some(v) = self.events.pop_front() {
146            return Ok(Some(v));
147        }
148        loop {
149            match tokio::time::timeout(timeout, self.ws.next()).await {
150                Err(_) => return Ok(None),
151                Ok(None) => return Err(cdp_err("CDP connection closed")),
152                Ok(Some(Err(e))) => return Err(cdp_err(format!("CDP recv: {e}"))),
153                Ok(Some(Ok(Message::Text(t)))) => {
154                    let v: Value = serde_json::from_str(&t)?;
155                    if v.get("method").is_some() {
156                        return Ok(Some(v));
157                    }
158                }
159                Ok(Some(Ok(_))) => {}
160            }
161        }
162    }
163}
164
165/// Convert CDP `Network.Cookie`s to session cookies, keeping only grab.com.
166fn grab_cookies(raw: &[Value]) -> Vec<Cookie> {
167    raw.iter()
168        .filter_map(|c| {
169            let domain = c.get("domain").and_then(Value::as_str)?;
170            let bare = domain.trim_start_matches('.');
171            if !(bare == "grab.com" || bare.ends_with(".grab.com")) {
172                return None;
173            }
174            Some(Cookie {
175                name: c.get("name").and_then(Value::as_str)?.to_string(),
176                value: c.get("value").and_then(Value::as_str)?.to_string(),
177                domain: domain.to_string(),
178                path: c
179                    .get("path")
180                    .and_then(Value::as_str)
181                    .unwrap_or("/")
182                    .to_string(),
183                expires: c.get("expires").and_then(Value::as_f64).unwrap_or(-1.0),
184            })
185        })
186        .collect()
187}
188
189/// A page target as listed by `/json` (no websocket needed).
190#[derive(Debug, Clone)]
191pub struct PageInfo {
192    pub id: String,
193    pub url: String,
194}
195
196/// All `page` targets of the browser, from `GET /json`. Read-only: nothing
197/// is attached to or changed.
198pub async fn pages(base: &str) -> Result<Vec<PageInfo>> {
199    Ok(list_pages(base)
200        .await?
201        .iter()
202        .filter(|p| p.get("type").and_then(Value::as_str) == Some("page"))
203        .filter_map(|p| {
204            Some(PageInfo {
205                id: p.get("id").and_then(Value::as_str)?.to_string(),
206                url: p.get("url").and_then(Value::as_str)?.to_string(),
207            })
208        })
209        .collect())
210}
211
212/// A browser-level CDP connection used to drive one page inside an
213/// **isolated browser context** (`Target.createBrowserContext`): its own
214/// cookie jar and storage, shared with no other tab, so nothing else in the
215/// browser can log it out and it can't disturb the user's tabs.
216///
217/// Contexts are created with `disposeOnDetach: false`, so the context and
218/// its tab survive this process exiting; a later process reconnects by id.
219pub struct Browser {
220    conn: Conn,
221    /// The browser's User-Agent.
222    pub user_agent: String,
223}
224
225impl Browser {
226    pub async fn connect(base: &str) -> Result<Self> {
227        let (ws_url, ua) = version(base).await?;
228        Ok(Self {
229            conn: Conn::connect(&ws_url).await?,
230            user_agent: ua,
231        })
232    }
233
234    /// Raw CDP call, optionally on an attached page session.
235    pub async fn call(
236        &mut self,
237        session: Option<&str>,
238        method: &str,
239        params: Value,
240    ) -> Result<Value> {
241        self.conn.call(session, method, params).await
242    }
243
244    /// Create an isolated context that outlives this connection.
245    pub async fn create_context(&mut self) -> Result<String> {
246        let r = self
247            .call(
248                None,
249                "Target.createBrowserContext",
250                json!({"disposeOnDetach": false}),
251            )
252            .await?;
253        r.get("browserContextId")
254            .and_then(Value::as_str)
255            .map(ToString::to_string)
256            .ok_or_else(|| cdp_err("Target.createBrowserContext returned no id"))
257    }
258
259    pub async fn context_exists(&mut self, context: &str) -> Result<bool> {
260        let r = self
261            .call(None, "Target.getBrowserContexts", json!({}))
262            .await?;
263        Ok(r.get("browserContextIds")
264            .and_then(Value::as_array)
265            .is_some_and(|ids| ids.iter().any(|i| i.as_str() == Some(context))))
266    }
267
268    /// Dispose a context we created (closes its tabs, drops its cookies).
269    pub async fn dispose_context(&mut self, context: &str) -> Result<()> {
270        self.call(
271            None,
272            "Target.disposeBrowserContext",
273            json!({"browserContextId": context}),
274        )
275        .await
276        .map(|_| ())
277    }
278
279    /// Open `url` in a new background tab of `context`.
280    pub async fn open_tab(&mut self, context: &str, url: &str) -> Result<String> {
281        let r = self
282            .call(
283                None,
284                "Target.createTarget",
285                json!({"url": url, "browserContextId": context, "background": true}),
286            )
287            .await?;
288        r.get("targetId")
289            .and_then(Value::as_str)
290            .map(ToString::to_string)
291            .ok_or_else(|| cdp_err("Target.createTarget returned no id"))
292    }
293
294    /// Whether `target` is still a page in `context`.
295    pub async fn tab_in_context(&mut self, context: &str, target: &str) -> Result<bool> {
296        let r = self.call(None, "Target.getTargets", json!({})).await?;
297        Ok(r.get("targetInfos")
298            .and_then(Value::as_array)
299            .is_some_and(|ts| {
300                ts.iter().any(|t| {
301                    t.get("targetId").and_then(Value::as_str) == Some(target)
302                        && t.get("browserContextId").and_then(Value::as_str) == Some(context)
303                })
304            }))
305    }
306
307    /// Attach to a tab (flattened session) and make it behave like a focused,
308    /// visible page: a background tab otherwise never runs
309    /// `requestAnimationFrame`, and the login SPA stays on its spinner.
310    pub async fn attach(&mut self, target: &str) -> Result<String> {
311        let r = self
312            .call(
313                None,
314                "Target.attachToTarget",
315                json!({"targetId": target, "flatten": true}),
316            )
317            .await?;
318        let s = r
319            .get("sessionId")
320            .and_then(Value::as_str)
321            .map(ToString::to_string)
322            .ok_or_else(|| cdp_err("Target.attachToTarget returned no session"))?;
323        self.call(Some(&s), "Page.enable", json!({})).await?;
324        self.call(
325            Some(&s),
326            "Emulation.setFocusEmulationEnabled",
327            json!({"enabled": true}),
328        )
329        .await?;
330        self.call(
331            Some(&s),
332            "Page.setWebLifecycleState",
333            json!({"state": "active"}),
334        )
335        .await?;
336        Ok(s)
337    }
338
339    /// Evaluate `expression` in the page (promises awaited) and return its
340    /// JSON value. Callers must not log values that are credentials.
341    pub async fn eval(&mut self, session: &str, expression: &str) -> Result<Value> {
342        let r = self
343            .call(
344                Some(session),
345                "Runtime.evaluate",
346                json!({"expression": expression, "awaitPromise": true, "returnByValue": true}),
347            )
348            .await?;
349        if let Some(ex) = r.get("exceptionDetails") {
350            let text = ex
351                .pointer("/exception/description")
352                .or_else(|| ex.get("text"))
353                .and_then(Value::as_str)
354                .unwrap_or("exception");
355            return Err(cdp_err(format!("page evaluation failed: {text}")));
356        }
357        Ok(r.pointer("/result/value").cloned().unwrap_or(Value::Null))
358    }
359
360    /// Navigate the attached page.
361    pub async fn navigate(&mut self, session: &str, url: &str) -> Result<()> {
362        self.call(Some(session), "Page.navigate", json!({"url": url}))
363            .await
364            .map(|_| ())
365    }
366
367    /// A trusted left click at viewport coordinates (as a user's would be).
368    pub async fn click_at(&mut self, session: &str, x: f64, y: f64) -> Result<()> {
369        for (kind, buttons) in [("mouseMoved", 0), ("mousePressed", 1), ("mouseReleased", 0)] {
370            self.call(
371                Some(session),
372                "Input.dispatchMouseEvent",
373                json!({"type": kind, "x": x, "y": y, "button": "left", "buttons": buttons, "clickCount": 1}),
374            )
375            .await?;
376        }
377        Ok(())
378    }
379
380    /// Type ASCII digits into the focused element, one key at a time.
381    pub async fn type_digits(&mut self, session: &str, digits: &str) -> Result<()> {
382        for ch in digits.chars().filter(char::is_ascii_digit) {
383            let key = ch.to_string();
384            let vk = 48 + ch.to_digit(10).unwrap_or(0);
385            for kind in ["keyDown", "keyUp"] {
386                let mut p = json!({
387                    "type": kind, "key": key, "code": format!("Digit{ch}"),
388                    "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
389                });
390                if kind == "keyDown" {
391                    p["text"] = json!(key);
392                }
393                self.call(Some(session), "Input.dispatchKeyEvent", p)
394                    .await?;
395            }
396            tokio::time::sleep(Duration::from_millis(90)).await;
397        }
398        Ok(())
399    }
400
401    /// The grab.com cookies of one browser context.
402    pub async fn context_cookies(&mut self, context: &str) -> Result<Vec<Cookie>> {
403        let r = self
404            .call(
405                None,
406                "Storage.getCookies",
407                json!({"browserContextId": context}),
408            )
409            .await?;
410        let raw = r
411            .get("cookies")
412            .and_then(Value::as_array)
413            .cloned()
414            .unwrap_or_default();
415        Ok(grab_cookies(&raw))
416    }
417
418    /// The grab.com cookies of the browser's default context (the user's
419    /// normal tabs), as opposed to [`context_cookies`](Self::context_cookies).
420    pub async fn default_cookies(&mut self) -> Result<Vec<Cookie>> {
421        let r = self.call(None, "Storage.getCookies", json!({})).await?;
422        let raw = r
423            .get("cookies")
424            .and_then(Value::as_array)
425            .cloned()
426            .unwrap_or_default();
427        Ok(grab_cookies(&raw))
428    }
429
430    /// Drain buffered CDP events (e.g. after a navigation), waiting up to
431    /// `wait` for more. Returns how many were seen.
432    pub async fn drain_events(&mut self, wait: Duration) -> Result<usize> {
433        let mut n = 0;
434        while self.conn.next_event(wait).await?.is_some() {
435            n += 1;
436        }
437        Ok(n)
438    }
439}
440
441/// The grab.com cookies of the browser's **default** context (the user's
442/// normal tabs): `Storage.getCookies` on the browser connection without a
443/// `browserContextId`. Values stay in memory; callers never print them.
444pub async fn default_context_cookies(base: &str) -> Result<(Vec<Cookie>, String)> {
445    let (ws_url, ua) = version(base).await?;
446    let mut conn = Conn::connect(&ws_url).await?;
447    let id = conn.send("Storage.getCookies", json!({})).await?;
448    let result = conn.result_for(id, Duration::from_secs(10)).await?;
449    let raw = result
450        .get("cookies")
451        .and_then(Value::as_array)
452        .ok_or_else(|| cdp_err("Storage.getCookies returned no cookies"))?;
453    Ok((grab_cookies(raw), ua))
454}
455
456/// Export Grab cookies from the browser into `session`, preserving any hydra
457/// token already present. Returns the browser User-Agent.
458pub async fn export_cookies(base: &str, session: &mut Session) -> Result<String> {
459    let (cookies, ua) = default_context_cookies(base).await?;
460    if cookies.is_empty() {
461        return Err(cdp_err(
462            "no grab.com cookies in the browser; is it logged in?",
463        ));
464    }
465    session.cookies = cookies;
466    if !ua.is_empty() {
467        session.user_agent = Some(ua.clone());
468    }
469    session.exported_at = Some(jwt::now() as f64);
470    Ok(ua)
471}
472
473/// Evaluate a JavaScript `expression` in a browser tab on `origin` (e.g.
474/// `https://mart.grab.com`) and return its JSON value. Promises are awaited.
475///
476/// Reuses an existing tab whose URL starts with `origin`; otherwise opens
477/// `open_url` in a new background tab (left open so later calls reuse it). It
478/// never navigates or evaluates in any other tab. Used to mint page-bound
479/// tokens such as GrabMart's reCAPTCHA v3 token; callers must not log the
480/// returned value when it is a credential.
481pub async fn evaluate_in_origin(
482    base: &str,
483    origin: &str,
484    open_url: &str,
485    expression: &str,
486    timeout: Duration,
487) -> Result<Value> {
488    let on_origin = |p: &Value| {
489        p.get("type").and_then(Value::as_str) == Some("page")
490            && p.get("webSocketDebuggerUrl").is_some()
491            && p.get("url")
492                .and_then(Value::as_str)
493                .is_some_and(|u| u.starts_with(origin))
494    };
495    let pages = list_pages(base).await?;
496    let target = match pages.iter().find(|p| on_origin(p)) {
497        Some(p) => p.clone(),
498        None => {
499            // Chrome wants PUT for /json/new.
500            let url = format!(
501                "{}/json/new?{}",
502                base.trim_end_matches('/'),
503                urlencoding::encode(open_url)
504            );
505            reqwest::Client::new()
506                .put(&url)
507                .send()
508                .await
509                .map_err(|e| cdp_err(format!("CDP /json/new: {e}")))?
510                .json::<Value>()
511                .await
512                .map_err(|e| cdp_err(format!("CDP /json/new: {e}")))?
513        }
514    };
515    let ws_url = target
516        .get("webSocketDebuggerUrl")
517        .and_then(Value::as_str)
518        .ok_or_else(|| cdp_err("target tab has no websocket URL"))?;
519    let mut conn = Conn::connect(ws_url).await?;
520
521    let deadline = tokio::time::Instant::now() + timeout;
522    let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
523    // Wait until the tab is actually on `origin` (a fresh tab starts blank).
524    loop {
525        let id = conn
526            .send(
527                "Runtime.evaluate",
528                json!({"expression": "location.origin + '|' + document.readyState", "returnByValue": true}),
529            )
530            .await?;
531        let r = conn.result_for(id, left()).await?;
532        let s = r
533            .pointer("/result/value")
534            .and_then(Value::as_str)
535            .unwrap_or_default();
536        if s.starts_with(origin) && !s.ends_with("|loading") {
537            break;
538        }
539        if left().is_zero() {
540            return Err(cdp_err(format!("browser tab never reached {origin}")));
541        }
542        tokio::time::sleep(Duration::from_millis(300)).await;
543    }
544
545    let id = conn
546        .send(
547            "Runtime.evaluate",
548            json!({"expression": expression, "awaitPromise": true, "returnByValue": true}),
549        )
550        .await?;
551    let r = conn.result_for(id, left()).await?;
552    if let Some(ex) = r.get("exceptionDetails") {
553        let text = ex
554            .pointer("/exception/description")
555            .or_else(|| ex.get("text"))
556            .and_then(Value::as_str)
557            .unwrap_or("exception");
558        return Err(cdp_err(format!("browser evaluation failed: {text}")));
559    }
560    Ok(r.pointer("/result/value").cloned().unwrap_or(Value::Null))
561}
562
563/// Mint a hydra JWT by driving the browser: open/select a `food.grab.com` tab,
564/// navigate it to `page`, and sniff the `x-hydra-jwt` request header. Updates
565/// `session.hydra_jwt`, `hydra_exp` and `web_app_version`.
566pub async fn mint_hydra(base: &str, page: &str, session: &mut Session) -> Result<()> {
567    let pages = list_pages(base).await?;
568    // Prefer an existing food.grab.com tab; else a tab we can navigate.
569    let target = pages
570        .iter()
571        .find(|p| {
572            p.get("type").and_then(Value::as_str) == Some("page")
573                && p.get("url")
574                    .and_then(Value::as_str)
575                    .is_some_and(|u| u.contains("food.grab.com"))
576        })
577        .or_else(|| {
578            pages.iter().find(|p| {
579                p.get("type").and_then(Value::as_str) == Some("page")
580                    && p.get("webSocketDebuggerUrl").is_some()
581            })
582        })
583        .ok_or_else(|| cdp_err("no usable browser tab found over CDP"))?;
584
585    let ws_url = target
586        .get("webSocketDebuggerUrl")
587        .and_then(Value::as_str)
588        .ok_or_else(|| cdp_err("target tab has no websocket URL"))?;
589
590    let mut conn = Conn::connect(ws_url).await?;
591    conn.send("Network.enable", json!({})).await?;
592    conn.send("Page.enable", json!({})).await?;
593    conn.send("Page.navigate", json!({ "url": page })).await?;
594
595    // Sniff request headers for up to 25s.
596    let deadline = tokio::time::Instant::now() + Duration::from_secs(25);
597    loop {
598        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
599        if remaining.is_zero() {
600            return Err(Error::new(
601                ErrorKind::HydraExpired,
602                "no x-hydra-jwt seen from the browser within 25s",
603            ));
604        }
605        let msg = match tokio::time::timeout(remaining, conn.ws.next()).await {
606            Ok(Some(Ok(Message::Text(t)))) => t,
607            Ok(Some(Ok(_))) => continue,
608            Ok(Some(Err(e))) => return Err(cdp_err(format!("CDP recv: {e}"))),
609            Ok(None) => return Err(cdp_err("CDP connection closed")),
610            Err(_) => continue,
611        };
612        let v: Value = serde_json::from_str(&msg)?;
613        let method = v.get("method").and_then(Value::as_str).unwrap_or_default();
614        if method != "Network.requestWillBeSent" && method != "Network.requestWillBeSentExtraInfo" {
615            continue;
616        }
617        let headers = v
618            .pointer("/params/request/headers")
619            .or_else(|| v.pointer("/params/headers"));
620        let Some(headers) = headers.and_then(Value::as_object) else {
621            continue;
622        };
623        let find = |want: &str| {
624            headers
625                .iter()
626                .find(|(k, _)| k.eq_ignore_ascii_case(want))
627                .and_then(|(_, val)| val.as_str())
628                .map(ToString::to_string)
629        };
630        if let Some(jwt_tok) = find("x-hydra-jwt") {
631            let exp = jwt::exp(&jwt_tok).unwrap_or_else(|| jwt::now() + 600);
632            session.hydra_jwt = Some(jwt_tok);
633            session.hydra_exp = Some(exp);
634            if let Some(ver) = find("x-grab-web-app-version") {
635                session.web_app_version = Some(ver);
636            }
637            return Ok(());
638        }
639    }
640}