use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use serde::Deserialize;
use crate::error::{Error, Result};
const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) fn read_singleton_lock(user_data_dir: &std::path::Path) -> Option<u32> {
#[cfg(unix)]
{
let target = std::fs::read_link(user_data_dir.join("SingletonLock")).ok()?;
target.to_str()?.rsplit('-').next()?.parse().ok()
}
#[cfg(not(unix))]
{
let _ = user_data_dir;
None
}
}
pub(crate) fn pid_is_alive(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{}", pid)).exists()
}
#[cfg(not(target_os = "linux"))]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
}
pub(crate) fn read_devtools_active_port(user_data_dir: &std::path::Path) -> Option<u16> {
let contents = std::fs::read_to_string(user_data_dir.join("DevToolsActivePort")).ok()?;
contents.lines().next()?.trim().parse().ok()
}
pub(crate) fn read_process_argv(pid: u32) -> Option<Vec<String>> {
#[cfg(target_os = "linux")]
{
let raw = std::fs::read(format!("/proc/{}/cmdline", pid)).ok()?;
Some(
raw.split(|&b| b == 0)
.filter(|piece| !piece.is_empty())
.map(|piece| String::from_utf8_lossy(piece).into_owned())
.collect(),
)
}
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "args="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.map(str::to_owned)
.collect(),
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PageTarget {
pub id: String,
#[serde(default)]
pub title: String,
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub url: String,
#[serde(rename = "webSocketDebuggerUrl", default)]
pub web_socket_debugger_url: String,
}
pub fn discover_browser_ws(host: &str, port: u16) -> Result<String> {
let body = http_get(host, port, "/json/version")?;
let value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| Error::transport(format!("Invalid JSON from /json/version: {}", e)))?;
let raw = value
.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::transport("No webSocketDebuggerUrl in /json/version"))?;
Ok(rewrite_ws_url_host(raw, host, port))
}
pub fn discover_pages(host: &str, port: u16) -> Result<Vec<PageTarget>> {
let body = http_get(host, port, "/json")?;
let mut targets: Vec<PageTarget> = serde_json::from_str(&body)
.map_err(|e| Error::transport(format!("Invalid JSON from /json: {}", e)))?;
for target in &mut targets {
target.web_socket_debugger_url =
rewrite_ws_url_host(&target.web_socket_debugger_url, host, port);
}
Ok(targets)
}
fn rewrite_ws_url_host(raw: &str, host: &str, port: u16) -> String {
let after_scheme = raw.split("://").nth(1).unwrap_or(raw);
let path = after_scheme
.find('/')
.map(|i| &after_scheme[i..])
.unwrap_or("");
format!("ws://{}:{}{}", host, port, path)
}
pub fn auto_connect(start: u16, end: u16) -> Option<(u16, String)> {
(start..=end).find_map(|port| {
discover_browser_ws("127.0.0.1", port)
.ok()
.map(|u| (port, u))
})
}
fn http_get(host: &str, port: u16, path: &str) -> Result<String> {
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr)
.map_err(|e| Error::transport_io(format!("Failed to connect to {}", addr), e))?;
stream
.set_read_timeout(Some(HTTP_TIMEOUT))
.map_err(|e| Error::transport_io("set_read_timeout", e))?;
stream
.set_write_timeout(Some(HTTP_TIMEOUT))
.map_err(|e| Error::transport_io("set_write_timeout", e))?;
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nConnection: close\r\n\r\n",
path
);
stream
.write_all(request.as_bytes())
.map_err(|e| Error::transport_io("HTTP write", e))?;
let mut buf = Vec::with_capacity(4096);
let mut chunk = [0u8; 4096];
let header_end = loop {
if let Some(pos) = find_double_crlf(&buf) {
break pos;
}
if read_more(&mut stream, &mut buf, &mut chunk)? == 0 {
return Err(Error::transport(
"Chrome closed the connection before headers",
));
}
};
let header_text = String::from_utf8_lossy(&buf[..header_end]).into_owned();
let status_line = header_text.lines().next().unwrap_or("");
if !(status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200")) {
return Err(Error::transport(format!(
"HTTP {} from Chrome at {}: {}",
path, addr, status_line
)));
}
let content_length = header_text.lines().find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")?
.trim()
.parse::<usize>()
.ok()
});
let body_start = header_end + 4;
if let Some(content_length) = content_length {
let body_end = body_start + content_length;
while buf.len() < body_end {
if read_more(&mut stream, &mut buf, &mut chunk)? == 0 {
return Err(Error::transport(
"Chrome closed the connection before sending the full response body",
));
}
}
Ok(String::from_utf8_lossy(&buf[body_start..body_end]).into_owned())
} else {
while read_more(&mut stream, &mut buf, &mut chunk)? > 0 {}
Ok(String::from_utf8_lossy(&buf[body_start..]).into_owned())
}
}
fn read_more(stream: &mut TcpStream, buf: &mut Vec<u8>, chunk: &mut [u8]) -> Result<usize> {
let n = stream
.read(chunk)
.map_err(|e| Error::transport_io("HTTP read", e))?;
buf.extend_from_slice(&chunk[..n]);
Ok(n)
}
fn find_double_crlf(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|window| window == b"\r\n\r\n")
}
#[cfg(test)]
mod url_rewrite_tests {
use super::rewrite_ws_url_host;
#[test]
fn replaces_portless_host_chrome_echoes_back() {
let raw = "ws://localhost/devtools/browser/1a65aa96-763b-40ff-bbdf-925f53bc4a66";
assert_eq!(
rewrite_ws_url_host(raw, "127.0.0.1", 41015),
"ws://127.0.0.1:41015/devtools/browser/1a65aa96-763b-40ff-bbdf-925f53bc4a66"
);
}
#[test]
fn replaces_host_that_already_has_a_port() {
let raw = "ws://127.0.0.1:9222/devtools/browser/GUID";
assert_eq!(
rewrite_ws_url_host(raw, "127.0.0.1", 9222),
"ws://127.0.0.1:9222/devtools/browser/GUID"
);
}
#[test]
fn handles_missing_path() {
assert_eq!(
rewrite_ws_url_host("ws://localhost", "127.0.0.1", 9222),
"ws://127.0.0.1:9222"
);
}
}
#[cfg(test)]
mod http_get_regression_tests {
use super::*;
use std::net::TcpListener;
fn spawn_fake_devtools_server(body: &'static str) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
std::thread::sleep(Duration::from_secs(30));
});
port
}
#[test]
fn discover_browser_ws_does_not_hang_on_keep_alive_server() {
let port = spawn_fake_devtools_server(
r#"{"webSocketDebuggerUrl":"ws://localhost/devtools/browser/regression-guid"}"#,
);
let start = std::time::Instant::now();
let ws_url = discover_browser_ws("127.0.0.1", port).expect("should succeed without EOF");
let elapsed = start.elapsed();
assert!(
elapsed < HTTP_TIMEOUT,
"took {:?}, looks like it waited for connection close instead of Content-Length",
elapsed
);
assert_eq!(
ws_url,
format!("ws://127.0.0.1:{}/devtools/browser/regression-guid", port)
);
}
#[test]
fn discover_pages_does_not_hang_on_keep_alive_server() {
let port = spawn_fake_devtools_server(
r#"[{"id":"1","type":"page","url":"about:blank","webSocketDebuggerUrl":"ws://localhost/devtools/page/regression-guid"}]"#,
);
let start = std::time::Instant::now();
let pages = discover_pages("127.0.0.1", port).expect("should succeed without EOF");
assert!(start.elapsed() < HTTP_TIMEOUT, "took {:?}", start.elapsed());
assert_eq!(pages.len(), 1);
assert_eq!(
pages[0].web_socket_debugger_url,
format!("ws://127.0.0.1:{}/devtools/page/regression-guid", port)
);
}
}
#[cfg(all(test, unix))]
mod singleton_tests {
use super::*;
use std::os::unix::fs::symlink;
fn temp_profile_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"eoka-discover-test-{}-{}-{}",
std::process::id(),
name,
fastrand::u64(..)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn read_singleton_lock_parses_trailing_pid() {
let dir = temp_profile_dir("lock");
symlink("some-host-54321", dir.join("SingletonLock")).unwrap();
assert_eq!(read_singleton_lock(&dir), Some(54321));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn read_singleton_lock_missing_is_none() {
let dir = temp_profile_dir("no-lock");
assert!(read_singleton_lock(&dir).is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn pid_is_alive_true_for_self() {
assert!(pid_is_alive(std::process::id()));
}
#[test]
fn read_devtools_active_port_parses_first_line() {
let dir = temp_profile_dir("port");
std::fs::write(
dir.join("DevToolsActivePort"),
"37469\n/devtools/browser/00000000-0000-0000-0000-000000000000\n",
)
.unwrap();
assert_eq!(read_devtools_active_port(&dir), Some(37469));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn read_devtools_active_port_missing_is_none() {
let dir = temp_profile_dir("no-port");
assert!(read_devtools_active_port(&dir).is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn read_process_argv_reads_own_cmdline() {
let argv = read_process_argv(std::process::id()).expect("own /proc entry must exist");
assert!(!argv.is_empty());
}
}