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(())
}
}
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())
}
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
}