tail-fin-common 0.7.5

Shared infrastructure for tail-fin: error types, page_fetch, cookies, CDP helpers
Documentation
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio_tungstenite::{connect_async, tungstenite::Message};

use crate::TailFinError;

/// A minimal CDP (Chrome DevTools Protocol) connection to a specific browser tab.
///
/// Connect to an existing Chrome tab via its page-level WebSocket URL, then use
/// `eval`, `get_cookies`, or `send_command` to interact with it.
pub struct CdpTab {
    write: futures_util::stream::SplitSink<
        tokio_tungstenite::WebSocketStream<
            tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
        >,
        Message,
    >,
    read: futures_util::stream::SplitStream<
        tokio_tungstenite::WebSocketStream<
            tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
        >,
    >,
    msg_id: u64,
}

impl CdpTab {
    /// Connect to a Chrome tab via its page-level WebSocket URL.
    pub async fn connect(ws_url: &str) -> Result<Self, TailFinError> {
        let (ws_stream, _) = connect_async(ws_url)
            .await
            .map_err(|e| TailFinError::Api(format!("CDP connect failed: {}", e)))?;
        let (write, read) = ws_stream.split();
        Ok(Self {
            write,
            read,
            msg_id: 1,
        })
    }

    /// Evaluate a JavaScript expression and return its value.
    pub async fn eval(&mut self, expression: &str) -> Result<Value, TailFinError> {
        let id = self.msg_id;
        self.msg_id += 1;
        let msg = serde_json::json!({
            "id": id,
            "method": "Runtime.evaluate",
            "params": { "expression": expression, "awaitPromise": true, "returnByValue": true }
        });
        self.write
            .send(Message::Text(msg.to_string().into()))
            .await
            .map_err(|e| TailFinError::Api(format!("CDP send failed: {}", e)))?;

        let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
            while let Some(msg) = self.read.next().await {
                let msg = msg.map_err(|e| TailFinError::Api(format!("CDP recv failed: {}", e)))?;
                if let Message::Text(text) = msg {
                    if let Ok(resp) = serde_json::from_str::<Value>(&text) {
                        if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
                            if let Some(val) = resp.pointer("/result/result/value") {
                                return Ok(val.clone());
                            }
                            if let Some(val) = resp.pointer("/result/result") {
                                if val.get("type").and_then(|v| v.as_str()) == Some("string") {
                                    if let Some(s) = val.get("value") {
                                        return Ok(s.clone());
                                    }
                                }
                                return Ok(val.clone());
                            }
                            return Ok(Value::Null);
                        }
                    }
                }
            }
            Err(TailFinError::Api("CDP connection closed".into()))
        })
        .await
        .map_err(|_| TailFinError::Api("CDP eval timed out after 30s".into()))?;

        result
    }

    /// Get all cookies from the current page via `Network.getCookies`.
    pub async fn get_cookies(&mut self) -> Result<Vec<Value>, TailFinError> {
        let result = self
            .send_command("Network.getCookies", serde_json::json!({}))
            .await?;
        Ok(result
            .get("cookies")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default())
    }

    /// Get ALL cookies in the browser (not just the current page) via `Network.getAllCookies`.
    pub async fn get_all_cookies(&mut self) -> Result<Vec<Value>, TailFinError> {
        let result = self
            .send_command("Network.getAllCookies", serde_json::json!({}))
            .await?;
        Ok(result
            .pointer("/cookies")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default())
    }

    /// Send an arbitrary CDP command and return the `result` field.
    pub async fn send_command(
        &mut self,
        method: &str,
        params: Value,
    ) -> Result<Value, TailFinError> {
        let id = self.msg_id;
        self.msg_id += 1;
        let msg = serde_json::json!({ "id": id, "method": method, "params": params });
        self.write
            .send(Message::Text(msg.to_string().into()))
            .await
            .map_err(|e| TailFinError::Api(format!("CDP send failed: {}", e)))?;

        let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
            while let Some(msg) = self.read.next().await {
                let msg = msg.map_err(|e| TailFinError::Api(format!("CDP recv failed: {}", e)))?;
                if let Message::Text(text) = msg {
                    if let Ok(resp) = serde_json::from_str::<Value>(&text) {
                        if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
                            return Ok(resp.get("result").cloned().unwrap_or(Value::Null));
                        }
                    }
                }
            }
            Err(TailFinError::Api("CDP connection closed".into()))
        })
        .await
        .map_err(|_| TailFinError::Api("CDP send_command timed out after 30s".into()))?;

        result
    }
}

/// List all open tabs from Chrome's remote debugging API.
///
/// Returns the raw JSON tab list from `http://{chrome_host}/json/list`.
pub async fn list_chrome_tabs(chrome_host: &str) -> Result<Vec<Value>, TailFinError> {
    let url = format!("http://{}/json/list", chrome_host);
    let resp = reqwest::get(&url)
        .await
        .map_err(|e| TailFinError::Api(format!("Cannot reach Chrome at {}: {}", chrome_host, e)))?;
    let tabs: Vec<Value> = resp
        .json()
        .await
        .map_err(|e| TailFinError::Parse(format!("Invalid Chrome tab list: {}", e)))?;
    Ok(tabs)
}

/// Find the WebSocket debugger URL for the first page tab matching a URL substring.
///
/// Pass `url_hint = None` to accept any page tab.
pub async fn find_tab_ws_url(
    chrome_host: &str,
    url_hint: Option<&str>,
) -> Result<String, TailFinError> {
    let tabs = list_chrome_tabs(chrome_host).await?;
    let page_tabs: Vec<&Value> = tabs
        .iter()
        .filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("page"))
        .collect();

    if page_tabs.is_empty() {
        return Err(TailFinError::Api(
            "No Chrome page tabs found. Is Chrome running with --remote-debugging-port?".into(),
        ));
    }

    // If a hint is given, prefer a matching tab; otherwise take the first page tab.
    let tab = if let Some(hint) = url_hint {
        page_tabs
            .iter()
            .find(|t| {
                t.get("url")
                    .and_then(|v| v.as_str())
                    .map(|u| u.contains(hint))
                    .unwrap_or(false)
            })
            .copied()
            .unwrap_or(page_tabs[0])
    } else {
        page_tabs[0]
    };

    tab.get("webSocketDebuggerUrl")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| TailFinError::Api("Tab has no WebSocket debugger URL".into()))
}