use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::Duration;
pub enum CdpCommand {
Send(String),
Close,
}
pub enum CdpEvent {
Connected { ws_url: String },
Message(serde_json::Value),
Closed(String),
}
const CHROME_BINS: &[&str] = &[
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"chrome-for-testing",
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
];
pub fn find_chrome_for_testing_puppeteer_cache() -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
let candidates = [
format!("{home}/.cache/puppeteer/chrome"),
format!("{home}/Library/Caches/puppeteer/chrome"),
format!("{home}/chrome-for-testing"),
];
for base in &candidates {
let base = std::path::Path::new(base);
if !base.exists() {
continue;
}
let Ok(entries) = std::fs::read_dir(base) else {
continue;
};
for entry in entries.flatten() {
let p = entry.path();
if let Ok(inner) = std::fs::read_dir(&p) {
for e in inner.flatten() {
let bin = e.path().join(
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
);
if bin.exists() {
return Some(bin);
}
}
}
}
}
None
}
pub fn rpc(id: i64, method: &str, params: serde_json::Value) -> String {
serde_json::json!({ "id": id, "method": method, "params": params }).to_string()
}
pub fn with_session(message: String, session_id: &str) -> String {
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&message) else {
return message;
};
if let Some(obj) = v.as_object_mut() {
obj.insert(
"sessionId".into(),
serde_json::Value::String(session_id.to_string()),
);
}
v.to_string()
}
pub fn navigate(id: i64, url: &str) -> String {
rpc(id, "Page.navigate", serde_json::json!({ "url": url }))
}
pub fn reload(id: i64) -> String {
rpc(id, "Page.reload", serde_json::json!({}))
}
pub fn evaluate(id: i64, expr: &str) -> String {
rpc(
id,
"Runtime.evaluate",
serde_json::json!({ "expression": expr, "returnByValue": true, "userGesture": true }),
)
}
pub fn capture_screenshot(id: i64) -> String {
rpc(
id,
"Page.captureScreenshot",
serde_json::json!({ "format": "png", "captureBeyondViewport": false }),
)
}
pub fn capture_screenshot_clip(id: i64, x: f64, y: f64, width: f64, height: f64) -> String {
rpc(
id,
"Page.captureScreenshot",
serde_json::json!({
"format": "png",
"captureBeyondViewport": false,
"clip": {
"x": x,
"y": y,
"width": width,
"height": height,
"scale": 1.0,
}
}),
)
}
pub fn set_user_agent_override(id: i64, user_agent: &str) -> String {
rpc(
id,
"Network.setUserAgentOverride",
serde_json::json!({ "userAgent": user_agent }),
)
}
pub fn set_device_metrics_override(
id: i64,
width: u32,
height: u32,
device_scale_factor: f64,
mobile: bool,
) -> String {
rpc(
id,
"Emulation.setDeviceMetricsOverride",
serde_json::json!({
"width": width,
"height": height,
"deviceScaleFactor": device_scale_factor,
"mobile": mobile,
}),
)
}
pub fn emulate_network_conditions(
id: i64,
offline: bool,
latency_ms: u32,
download_bps: i32,
upload_bps: i32,
) -> String {
rpc(
id,
"Network.emulateNetworkConditions",
serde_json::json!({
"offline": offline,
"latency": latency_ms,
"downloadThroughput": download_bps,
"uploadThroughput": upload_bps,
}),
)
}
pub fn clear_device_metrics_override(id: i64) -> String {
rpc(
id,
"Emulation.clearDeviceMetricsOverride",
serde_json::json!({}),
)
}
pub fn print_to_pdf(id: i64) -> String {
rpc(
id,
"Page.printToPDF",
serde_json::json!({
"printBackground": true,
"preferCSSPageSize": false,
"transferMode": "ReturnAsBase64",
}),
)
}
pub fn get_box_model(id: i64, node_id: i64) -> String {
rpc(
id,
"DOM.getBoxModel",
serde_json::json!({ "nodeId": node_id }),
)
}
pub fn get_request_post_data(id: i64, request_id: &str) -> String {
rpc(
id,
"Network.getRequestPostData",
serde_json::json!({ "requestId": request_id }),
)
}
pub fn get_document(id: i64) -> String {
rpc(
id,
"DOM.getDocument",
serde_json::json!({ "depth": -1, "pierce": true }),
)
}
pub fn highlight_node(id: i64, node_id: i64) -> String {
rpc(
id,
"Overlay.highlightNode",
serde_json::json!({
"nodeId": node_id,
"highlightConfig": {
"showInfo": true,
"contentColor": { "r": 111, "g": 168, "b": 220, "a": 0.4 },
"paddingColor": { "r": 200, "g": 200, "b": 100, "a": 0.35 },
"marginColor": { "r": 230, "g": 130, "b": 100, "a": 0.30 },
"borderColor": { "r": 80, "g": 100, "b": 160, "a": 0.6 }
}
}),
)
}
pub fn hide_highlight(id: i64) -> String {
rpc(id, "Overlay.hideHighlight", serde_json::json!({}))
}
pub fn get_cookies(id: i64) -> String {
rpc(id, "Network.getCookies", serde_json::json!({}))
}
pub fn set_cookie(id: i64, name: &str, value: &str, domain: &str, path: &str) -> String {
rpc(
id,
"Network.setCookie",
serde_json::json!({
"name": name,
"value": value,
"domain": domain,
"path": path,
}),
)
}
pub fn delete_cookies(id: i64, name: &str, domain: &str, path: &str) -> String {
rpc(
id,
"Network.deleteCookies",
serde_json::json!({
"name": name,
"domain": domain,
"path": path,
}),
)
}
pub fn scroll_into_view_if_needed(id: i64, node_id: i64) -> String {
rpc(
id,
"DOM.scrollIntoViewIfNeeded",
serde_json::json!({ "nodeId": node_id }),
)
}
pub fn run_session(
url: &str,
profile_dir: &Path,
headless: bool,
out: &Sender<CdpEvent>,
cmds: &Receiver<CdpCommand>,
) {
macro_rules! bail {
($child:expr, $msg:expr) => {{
if let Some(c) = $child.as_mut() {
let _ = c.kill();
}
let _ = out.send(CdpEvent::Closed($msg));
return;
}};
}
let mut child = match spawn_chrome(url, profile_dir, headless) {
Ok(c) => Some(c),
Err(e) => bail!(None::<Child>, e),
};
let port = match child.as_mut().and_then(read_debug_port) {
Some(p) => p,
None => bail!(
child,
"couldn't find Chrome's DevTools port — did it start?".into()
),
};
let ws_url = match page_ws_url(port) {
Ok(u) => u,
Err(e) => bail!(child, e),
};
let mut ws = match tungstenite::connect(&ws_url) {
Ok((ws, _)) => ws,
Err(e) => bail!(child, format!("connecting to {ws_url}: {e}")),
};
if let tungstenite::stream::MaybeTlsStream::Plain(s) = ws.get_mut() {
let _ = s.set_read_timeout(Some(Duration::from_millis(60)));
}
for (id, method) in (1i64..).zip([
"Page.enable",
"Runtime.enable",
"Log.enable",
"Network.enable",
"DOM.enable",
"Overlay.enable",
]) {
let _ = ws.send(tungstenite::Message::text(rpc(
id,
method,
serde_json::json!({}),
)));
}
let _ = ws.send(tungstenite::Message::text(rpc(
99,
"Target.setDiscoverTargets",
serde_json::json!({ "discover": true }),
)));
let _ = ws.send(tungstenite::Message::text(rpc(
98,
"Target.setAutoAttach",
serde_json::json!({
"autoAttach": true,
"waitForDebuggerOnStart": false,
"flatten": true,
}),
)));
let _ = out.send(CdpEvent::Connected { ws_url });
loop {
loop {
match cmds.try_recv() {
Ok(CdpCommand::Send(json)) => {
let _ = ws.send(tungstenite::Message::text(json));
}
Ok(CdpCommand::Close) => {
let _ = ws.close(None);
bail!(child, "closed".into());
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => bail!(child, "closed".into()),
}
}
if matches!(child.as_mut().map(Child::try_wait), Some(Ok(Some(_)))) {
bail!(child, "Chrome exited".into());
}
match ws.read() {
Ok(tungstenite::Message::Text(t)) => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(t.as_str()) {
let _ = out.send(CdpEvent::Message(v));
}
}
Ok(tungstenite::Message::Close(_)) => bail!(child, "page closed".into()),
Ok(_) => {} Err(tungstenite::Error::Io(e))
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) => {}
Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
bail!(child, "WebSocket closed".into())
}
Err(e) => bail!(child, format!("WebSocket error: {e}")),
}
}
}
fn spawn_chrome(url: &str, profile_dir: &Path, headless: bool) -> Result<Child, String> {
let url = if url.trim().is_empty() {
"about:blank"
} else {
url.trim()
};
let mut all_bins: Vec<String> = Vec::with_capacity(CHROME_BINS.len() + 1);
if let Some(cft) = find_chrome_for_testing_puppeteer_cache() {
all_bins.push(cft.display().to_string());
}
all_bins.extend(CHROME_BINS.iter().map(|s| s.to_string()));
for bin in &all_bins {
let mut cmd = Command::new(bin);
cmd.arg("--remote-debugging-port=0")
.arg(format!("--user-data-dir={}", profile_dir.display()))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-background-networking")
.arg("--disable-component-update")
.arg("--disable-default-apps");
if headless {
cmd.arg("--headless=new")
.arg("--no-sandbox")
.arg("--disable-gpu");
}
cmd.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
if let Ok(c) = cmd.spawn() {
return Ok(c);
}
}
Err(
"Chrome not found. Install Chrome for Testing:\n npx @puppeteer/browsers install chrome@stable\nor run `:browser.install_cft`."
.to_string(),
)
}
fn read_debug_port(child: &mut Child) -> Option<u16> {
let stderr = child.stderr.take()?;
let mut reader = BufReader::new(stderr);
let mut line = String::new();
let mut port = None;
for _ in 0..200 {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break, Ok(_) => {
if let Some(rest) = line.split("ws://").nth(1)
&& let Some(hostport) = rest.split('/').next()
&& let Some(p) = hostport
.split(':')
.nth(1)
.and_then(|s| s.trim().parse::<u16>().ok())
{
port = Some(p);
break;
}
}
Err(_) => break,
}
}
if port.is_some() {
std::thread::spawn(move || {
let mut sink = String::new();
while reader.read_line(&mut sink).map(|n| n > 0).unwrap_or(false) {
sink.clear();
}
});
}
port
}
fn page_ws_url(port: u16) -> Result<String, String> {
let url = format!("http://127.0.0.1:{port}/json");
let mut last = "no response".to_string();
for _ in 0..25 {
match reqwest::blocking::Client::new()
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.and_then(|r| r.text())
{
Ok(body) => {
if let Ok(serde_json::Value::Array(arr)) =
serde_json::from_str::<serde_json::Value>(&body)
{
let page = arr
.iter()
.find(|t| t.get("type").and_then(|x| x.as_str()) == Some("page"))
.or_else(|| arr.first());
if let Some(ws) = page
.and_then(|p| p.get("webSocketDebuggerUrl"))
.and_then(|x| x.as_str())
{
return Ok(ws.to_string());
}
}
last = "no page target in /json".to_string();
}
Err(e) => last = e.to_string(),
}
std::thread::sleep(Duration::from_millis(150));
}
Err(format!("couldn't reach Chrome's /json endpoint: {last}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_session_adds_session_id_to_top_level() {
let raw = rpc(42, "Page.navigate", serde_json::json!({"url": "https://x"}));
let wrapped = with_session(raw, "sess-7");
let v: serde_json::Value = serde_json::from_str(&wrapped).unwrap();
assert_eq!(v["id"], 42);
assert_eq!(v["sessionId"], "sess-7");
assert_eq!(v["method"], "Page.navigate");
assert_eq!(v["params"]["url"], "https://x");
assert_eq!(with_session("nope".into(), "s"), "nope");
}
#[test]
fn rpc_helpers_shape_json() {
let v: serde_json::Value = serde_json::from_str(&navigate(7, "https://x.test")).unwrap();
assert_eq!(v["id"], 7);
assert_eq!(v["method"], "Page.navigate");
assert_eq!(v["params"]["url"], "https://x.test");
let v: serde_json::Value = serde_json::from_str(&evaluate(2, "1+1")).unwrap();
assert_eq!(v["method"], "Runtime.evaluate");
assert_eq!(v["params"]["expression"], "1+1");
assert_eq!(v["params"]["returnByValue"], true);
}
}