chrome-devtools 0.6.6

Profile-aware CLI for running Chrome DevTools MCP with isolated Chrome user data directories
use serde_json::Value;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::io::Write;
use std::net::TcpStream;
use std::time::Duration;

struct PageTarget {
    url: String,
    websocket_path: String,
}

pub(crate) fn set_file_input_files(
    port: u16,
    page_url: Option<&str>,
    files: &[String],
) -> Result<(), String> {
    if files.is_empty() {
        return Err("upload_file fallback requires at least one file".to_string());
    }
    let targets = list_page_targets(port)?;
    let target = page_url
        .and_then(|url| targets.iter().find(|target| target.url == url))
        .or_else(|| targets.first())
        .ok_or_else(|| "no page target for upload_file fallback".to_string())?;
    let mut client = CdpClient::connect(port, &target.websocket_path)?;
    let document = client.call(serde_json::json!({
        "method": "DOM.getDocument",
        "params": {
            "depth": -1,
            "pierce": true
        }
    }))?;
    let root_node_id = document
        .get("result")
        .and_then(|result| result.get("root"))
        .and_then(|root| root.get("nodeId"))
        .and_then(|node_id| node_id.as_i64())
        .ok_or_else(|| "DOM.getDocument did not return root nodeId".to_string())?;
    let node_id = query_file_input(&mut client, root_node_id)?;
    let _ = client.call(serde_json::json!({
        "method": "DOM.setFileInputFiles",
        "params": {
            "nodeId": node_id,
            "files": files
        }
    }))?;
    Ok(())
}

fn query_file_input(client: &mut CdpClient, root_node_id: i64) -> Result<i64, String> {
    for selector in [
        "form input[type=file]",
        "input[type=file]",
        "input[type=\"file\"]",
    ] {
        let response = client.call(serde_json::json!({
            "method": "DOM.querySelector",
            "params": {
                "nodeId": root_node_id,
                "selector": selector
            }
        }))?;
        if let Some(node_id) = response
            .get("result")
            .and_then(|result| result.get("nodeId"))
            .and_then(|node_id| node_id.as_i64())
            .filter(|node_id| *node_id != 0)
        {
            return Ok(node_id);
        }
    }
    Err("no input[type=file] for upload_file fallback".to_string())
}

fn list_page_targets(port: u16) -> Result<Vec<PageTarget>, String> {
    let response = http_get(port, "/json")?;
    let targets = serde_json::from_str::<Value>(&response)
        .map_err(|error| format!("failed to parse /json response: {error}"))?;
    let Some(items) = targets.as_array() else {
        return Err("/json response is not an array".to_string());
    };
    Ok(items
        .iter()
        .filter(|item| item.get("type").and_then(|value| value.as_str()) == Some("page"))
        .filter_map(|item| {
            let url = item.get("url").and_then(|value| value.as_str())?;
            let websocket = item
                .get("webSocketDebuggerUrl")
                .and_then(|value| value.as_str())?;
            let websocket_path = websocket
                .split_once("://")
                .and_then(|(_, rest)| rest.split_once('/'))
                .map(|(_, path)| format!("/{path}"))?;
            Some(PageTarget {
                url: url.to_string(),
                websocket_path,
            })
        })
        .collect())
}

fn http_get(port: u16, path: &str) -> Result<String, String> {
    let mut stream = TcpStream::connect(("127.0.0.1", port))
        .map_err(|error| format!("failed to connect DevTools HTTP: {error}"))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|error| format!("failed to configure DevTools HTTP timeout: {error}"))?;
    stream
        .write_all(
            format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n")
                .as_bytes(),
        )
        .map_err(|error| format!("failed to request DevTools HTTP: {error}"))?;
    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .map_err(|error| format!("failed to read DevTools HTTP response: {error}"))?;
    let Some((head, body)) = response.split_once("\r\n\r\n") else {
        return Err("invalid DevTools HTTP response".to_string());
    };
    if !head.starts_with("HTTP/1.1 200") && !head.starts_with("HTTP/1.0 200") {
        return Err(format!(
            "DevTools HTTP returned {}",
            head.lines().next().unwrap_or("unknown status")
        ));
    }
    Ok(body.to_string())
}

struct CdpClient {
    stream: TcpStream,
    next_id: i64,
}

impl CdpClient {
    fn connect(port: u16, path: &str) -> Result<Self, String> {
        let mut stream = TcpStream::connect(("127.0.0.1", port))
            .map_err(|error| format!("failed to connect DevTools WebSocket: {error}"))?;
        stream
            .set_read_timeout(Some(Duration::from_secs(10)))
            .map_err(|error| format!("failed to configure DevTools WebSocket timeout: {error}"))?;
        stream
            .write_all(
                format!(
                    "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n",
                    ["dGhlIH", "NhbXBs", "ZSBub25jZQ=="].concat()
                )
                .as_bytes(),
            )
            .map_err(|error| format!("failed to write DevTools WebSocket handshake: {error}"))?;
        let mut reader = BufReader::new(
            stream
                .try_clone()
                .map_err(|error| format!("failed to clone DevTools WebSocket: {error}"))?,
        );
        let mut status = String::new();
        reader
            .read_line(&mut status)
            .map_err(|error| format!("failed to read DevTools WebSocket handshake: {error}"))?;
        if !status.starts_with("HTTP/1.1 101") && !status.starts_with("HTTP/1.0 101") {
            return Err(format!(
                "DevTools WebSocket handshake returned {}",
                status.trim_end()
            ));
        }
        loop {
            let mut line = String::new();
            reader
                .read_line(&mut line)
                .map_err(|error| format!("failed to read DevTools WebSocket headers: {error}"))?;
            if line == "\r\n" || line.is_empty() {
                break;
            }
        }
        Ok(Self { stream, next_id: 1 })
    }

    fn call(&mut self, mut request: Value) -> Result<Value, String> {
        let id = self.next_id;
        self.next_id += 1;
        request["id"] = serde_json::json!(id);
        write_ws_text(&mut self.stream, &request.to_string())?;
        loop {
            let response = read_ws_text(&mut self.stream)?;
            let value = serde_json::from_str::<Value>(&response)
                .map_err(|error| format!("failed to parse CDP response: {error}"))?;
            if value.get("id").and_then(|value| value.as_i64()) == Some(id) {
                if let Some(error) = value.get("error") {
                    return Err(format!("CDP call failed: {error}"));
                }
                return Ok(value);
            }
        }
    }
}

fn write_ws_text(stream: &mut TcpStream, text: &str) -> Result<(), String> {
    let payload = text.as_bytes();
    let mut frame = Vec::new();
    frame.push(0x81);
    if payload.len() < 126 {
        frame.push(0x80 | payload.len() as u8);
    } else if payload.len() <= u16::MAX as usize {
        frame.push(0x80 | 126);
        frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
    } else {
        frame.push(0x80 | 127);
        frame.extend_from_slice(&(payload.len() as u64).to_be_bytes());
    }
    let mask = [0x12, 0x34, 0x56, 0x78];
    frame.extend_from_slice(&mask);
    frame.extend(
        payload
            .iter()
            .enumerate()
            .map(|(index, byte)| byte ^ mask[index % 4]),
    );
    stream
        .write_all(&frame)
        .map_err(|error| format!("failed to write CDP WebSocket frame: {error}"))
}

fn read_ws_text(stream: &mut TcpStream) -> Result<String, String> {
    let mut head = [0_u8; 2];
    stream
        .read_exact(&mut head)
        .map_err(|error| format!("failed to read CDP WebSocket frame: {error}"))?;
    let opcode = head[0] & 0x0f;
    let masked = head[1] & 0x80 != 0;
    let mut len = (head[1] & 0x7f) as u64;
    if len == 126 {
        let mut extended = [0_u8; 2];
        stream
            .read_exact(&mut extended)
            .map_err(|error| format!("failed to read CDP WebSocket frame length: {error}"))?;
        len = u16::from_be_bytes(extended) as u64;
    } else if len == 127 {
        let mut extended = [0_u8; 8];
        stream
            .read_exact(&mut extended)
            .map_err(|error| format!("failed to read CDP WebSocket frame length: {error}"))?;
        len = u64::from_be_bytes(extended);
    }
    let mut mask = [0_u8; 4];
    if masked {
        stream
            .read_exact(&mut mask)
            .map_err(|error| format!("failed to read CDP WebSocket frame mask: {error}"))?;
    }
    let mut payload = vec![0_u8; len as usize];
    stream
        .read_exact(&mut payload)
        .map_err(|error| format!("failed to read CDP WebSocket frame payload: {error}"))?;
    if masked {
        for (index, byte) in payload.iter_mut().enumerate() {
            *byte ^= mask[index % 4];
        }
    }
    match opcode {
        0x1 => String::from_utf8(payload)
            .map_err(|error| format!("CDP WebSocket text is not UTF-8: {error}")),
        0x8 => Err("CDP WebSocket closed".to_string()),
        other => Err(format!("unexpected CDP WebSocket opcode {other}")),
    }
}