forgewright 0.2.0

Standalone UI automation — CDP for browsers, UIA for Windows desktop apps
Documentation
// cdp.rs — Chrome DevTools Protocol client
//
// Raw WebSocket CDP, zero third-party browser-automation framework.

use std::path::Path;
use std::time::Duration;

use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream as TokioTcp;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};

pub struct Cdp {
    ws: WebSocketStream<MaybeTlsStream<TokioTcp>>,
    msg_id: u64,
}

impl Cdp {
    pub async fn connect(port: u16) -> Result<Self, Box<dyn std::error::Error>> {
        let ws_url = discover_ws_url(port).await?;
        let (ws, _) = connect_async(&ws_url).await?;
        let mut cdp = Cdp { ws, msg_id: 0 };
        cdp.send("Page.enable", json!({})).await?;
        cdp.send("Runtime.enable", json!({})).await?;
        Ok(cdp)
    }

    pub async fn send(&mut self, method: &str, params: Value) -> Result<Value, Box<dyn std::error::Error>> {
        self.msg_id += 1;
        let id = self.msg_id;
        let msg = json!({
            "id": id,
            "method": method,
            "params": params,
        });
        self.ws.send(Message::Text(msg.to_string())).await?;

        while let Some(resp) = self.ws.next().await {
            let msg = resp?;
            if let Message::Text(txt) = msg {
                let v: Value = serde_json::from_str(&txt)?;
                if v.get("id").and_then(|x| x.as_u64()) == Some(id) {
                    if let Some(err) = v.get("error") {
                        return Err(format!("CDP error [{}]: {}", method, err).into());
                    }
                    return Ok(v.get("result").cloned().unwrap_or(Value::Null));
                }
            }
        }
        Err("CDP WebSocket closed before response".into())
    }

    pub async fn evaluate(&mut self, expr: &str) -> Result<Value, Box<dyn std::error::Error>> {
        let result = self
            .send(
                "Runtime.evaluate",
                json!({
                    "expression": expr,
                    "returnByValue": true,
                    "awaitPromise": true,
                    "userGesture": true,
                }),
            )
            .await?;
        if let Some(exc) = result.get("exceptionDetails") {
            return Err(format!("JS exception: {}", exc).into());
        }
        Ok(result
            .get("result")
            .and_then(|r| r.get("value"))
            .cloned()
            .unwrap_or(Value::Null))
    }

    pub async fn navigate(&mut self, url: &str) -> Result<(), Box<dyn std::error::Error>> {
        self.send("Page.navigate", json!({ "url": url })).await?;
        Ok(())
    }

    pub async fn screenshot(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
        let result = self
            .send("Page.captureScreenshot", json!({ "format": "png" }))
            .await?;
        let data = result
            .get("data")
            .and_then(|d| d.as_str())
            .ok_or("no screenshot data in CDP response")?;
        let bytes = base64_decode(data)?;
        std::fs::write(path, bytes)?;
        Ok(())
    }
}

// ═══ HTTP + DISCOVERY ═══════════════════════════════════════════════════════

pub async fn http_get(host: &str, port: u16, path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let fut = async {
        let mut stream = TokioTcp::connect((host, port)).await?;
        let req = format!(
            "GET {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nAccept: application/json\r\n\r\n",
            path, host, port
        );
        stream.write_all(req.as_bytes()).await?;
        stream.flush().await?;

        let mut buf: Vec<u8> = Vec::with_capacity(2048);
        let mut tmp = [0u8; 2048];
        let header_end;
        loop {
            let n = stream.read(&mut tmp).await?;
            if n == 0 {
                return Err::<String, Box<dyn std::error::Error>>(
                    "connection closed before headers".into(),
                );
            }
            buf.extend_from_slice(&tmp[..n]);
            if let Some(i) = find_subslice(&buf, b"\r\n\r\n") {
                header_end = i + 4;
                break;
            }
            if buf.len() > 65536 {
                return Err("HTTP headers exceeded 64K".into());
            }
        }

        let headers_text = String::from_utf8_lossy(&buf[..header_end - 4]).to_string();
        let lower = headers_text.to_lowercase();

        let content_length: Option<usize> = lower
            .split("\r\n")
            .find_map(|line| line.strip_prefix("content-length:"))
            .and_then(|v| v.trim().parse().ok());
        let chunked = lower.contains("transfer-encoding: chunked");

        if let Some(len) = content_length {
            while buf.len() - header_end < len {
                let n = stream.read(&mut tmp).await?;
                if n == 0 { break; }
                buf.extend_from_slice(&tmp[..n]);
            }
            let end = std::cmp::min(buf.len(), header_end + len);
            return Ok(String::from_utf8_lossy(&buf[header_end..end]).into_owned());
        }

        if chunked {
            loop {
                if window_ends_with(&buf, b"\r\n0\r\n\r\n") || window_ends_with(&buf, b"0\r\n\r\n") {
                    break;
                }
                let n = stream.read(&mut tmp).await?;
                if n == 0 { break; }
                buf.extend_from_slice(&tmp[..n]);
            }
            let body = String::from_utf8_lossy(&buf[header_end..]).into_owned();
            return Ok(parse_chunked(&body));
        }

        loop {
            let n = stream.read(&mut tmp).await?;
            if n == 0 { break; }
            buf.extend_from_slice(&tmp[..n]);
        }
        Ok(String::from_utf8_lossy(&buf[header_end..]).into_owned())
    };

    match tokio::time::timeout(Duration::from_secs(5), fut).await {
        Ok(r) => r,
        Err(_) => Err(format!("HTTP GET {}:{}{} timed out after 5s", host, port, path).into()),
    }
}

pub async fn discover_ws_url(port: u16) -> Result<String, Box<dyn std::error::Error>> {
    let body = http_get("127.0.0.1", port, "/json/list").await?;
    let targets: Vec<Value> = serde_json::from_str(&body)
        .map_err(|e| format!("CDP /json/list parse failed: {} | body: {}", e, body))?;

    for target in &targets {
        if target.get("type").and_then(|v| v.as_str()) == Some("page") {
            if let Some(ws_url) = target.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
                return Ok(ws_url.to_string());
            }
        }
    }
    Err(format!("no page targets on CDP port {}", port).into())
}

// ═══ HELPERS ═════════════════════════════════════════════════════════════════

pub fn base64_decode(s: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut out = Vec::with_capacity(s.len() * 3 / 4);
    let mut buf: u32 = 0;
    let mut bits: u32 = 0;
    for c in s.chars() {
        if c.is_whitespace() { continue; }
        let v: u32 = match c {
            'A'..='Z' => (c as u32) - ('A' as u32),
            'a'..='z' => (c as u32) - ('a' as u32) + 26,
            '0'..='9' => (c as u32) - ('0' as u32) + 52,
            '+' => 62,
            '/' => 63,
            '=' => break,
            _ => return Err(format!("invalid base64 char: {:?}", c).into()),
        };
        buf = (buf << 6) | v;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push(((buf >> bits) & 0xFF) as u8);
        }
    }
    Ok(out)
}

fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    if needle.is_empty() || haystack.len() < needle.len() { return None; }
    for i in 0..=haystack.len() - needle.len() {
        if &haystack[i..i + needle.len()] == needle {
            return Some(i);
        }
    }
    None
}

fn window_ends_with(haystack: &[u8], needle: &[u8]) -> bool {
    haystack.len() >= needle.len() && &haystack[haystack.len() - needle.len()..] == needle
}

fn parse_chunked(body: &str) -> String {
    let mut out = String::new();
    let mut rest = body;
    loop {
        let line_end = match rest.find("\r\n") {
            Some(i) => i,
            None => break,
        };
        let size_hex = rest[..line_end].trim();
        let size = match usize::from_str_radix(size_hex, 16) {
            Ok(s) => s,
            Err(_) => break,
        };
        if size == 0 { break; }
        rest = &rest[line_end + 2..];
        if rest.len() < size { break; }
        out.push_str(&rest[..size]);
        rest = &rest[size..];
        if rest.starts_with("\r\n") { rest = &rest[2..]; }
    }
    out
}