use crate::connection::Connection;
use crate::error::{CdpError, Result};
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing;
#[derive(Debug, Clone, serde::Serialize)]
pub struct DetectedBrowser {
pub ws_url: String,
pub browser: String,
pub version: String,
}
pub async fn detect(port: u16) -> Result<DetectedBrowser> {
if let Ok(ws_url) = std::env::var("GTHINGS_CDP_WS_URL") {
if !ws_url.is_empty() {
tracing::info!("detect: using GTHINGS_CDP_WS_URL env var");
return Ok(DetectedBrowser {
ws_url,
browser: "env".into(),
version: "unknown".into(),
});
}
}
if let Some(browser) = probe_http_version(port).await {
tracing::info!("detect: found via /json/version");
return Ok(browser);
}
if let Some(ws_url) = probe_http_list(port, "/json").await {
tracing::info!("detect: found via /json");
return Ok(DetectedBrowser {
ws_url,
browser: "unknown".into(),
version: "unknown".into(),
});
}
if let Some(ws_url) = probe_http_list(port, "/json/list").await {
tracing::info!("detect: found via /json/list");
return Ok(DetectedBrowser {
ws_url,
browser: "unknown".into(),
version: "unknown".into(),
});
}
if let Some(browser) = probe_devtools_active_port(port).await {
tracing::info!("detect: found via DevToolsActivePort");
return Ok(browser);
}
Err(CdpError::BrowserNotFound { port })
}
pub async fn connect(ws_url: &str) -> Result<Connection> {
Connection::connect(ws_url).await
}
#[cfg(target_os = "macos")]
pub fn dismiss_allow_debugging_dialog() {
let script = r#"tell application "System Events"
try
set frontmost of process "Dia" to true
end try
tell process "Dia" to keystroke return
end tell"#;
let _ = std::process::Command::new("osascript")
.args(["-e", script])
.output();
}
#[cfg(not(target_os = "macos"))]
pub fn dismiss_allow_debugging_dialog() {}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(3))
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("valid reqwest client config")
})
}
async fn probe_http_version(port: u16) -> Option<DetectedBrowser> {
let url = format!("http://127.0.0.1:{port}/json/version");
let client = http_client();
let resp = client.get(&url).send().await.ok()?;
let body: serde_json::Value = resp.json().await.ok()?;
let ws_url = body.get("webSocketDebuggerUrl")?.as_str()?;
let full_browser = body
.get("Browser")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let version = full_browser.to_string();
let browser = full_browser
.split('/')
.next()
.unwrap_or("unknown")
.to_string();
Some(DetectedBrowser {
ws_url: ws_url.to_string(),
browser,
version,
})
}
async fn probe_http_list(port: u16, path: &str) -> Option<String> {
let url = format!("http://127.0.0.1:{port}{path}");
let client = http_client();
let resp = client.get(&url).send().await.ok()?;
let list: Vec<serde_json::Value> = resp.json().await.ok()?;
for entry in &list {
if let Some(ws_url) = entry.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
return Some(ws_url.to_string());
}
}
None
}
async fn probe_devtools_active_port(port: u16) -> Option<DetectedBrowser> {
let profile_dirs = get_profile_dirs();
if profile_dirs.is_empty() {
return None;
}
let result: Option<DetectedBrowser> = tokio::task::spawn_blocking(move || {
for dir in &profile_dirs {
let active_port_path = dir.join("DevToolsActivePort");
let content = match std::fs::read_to_string(&active_port_path) {
Ok(c) => c,
Err(_) => continue,
};
let lines: Vec<&str> = content.trim().lines().collect();
if lines.len() < 2 {
continue;
}
let file_port: u16 = match lines[0].trim().parse().ok() {
Some(p) => p,
None => continue,
};
if file_port != port {
continue;
}
let ws_path = lines[1].trim();
let ws_url = format!("ws://127.0.0.1:{port}{ws_path}");
let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().ok()?;
if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500))
.is_ok()
{
let browser_name = infer_browser_name(dir);
return Some(DetectedBrowser {
ws_url,
browser: browser_name,
version: "unknown".into(),
});
}
}
None
})
.await
.ok()?;
result
}
fn get_profile_dirs() -> Vec<PathBuf> {
let home = std::env::var("HOME").ok().map(PathBuf::from);
let home = match home {
Some(h) => h,
None => return Vec::new(),
};
let app_support = home.join("Library/Application Support");
let dirs = vec![
app_support.join("Dia/User Data"),
app_support.join("Google/Chrome"),
app_support.join("Google/Chrome Canary"),
app_support.join("Chromium"),
app_support.join("Microsoft Edge"),
app_support.join("Microsoft Edge Canary"),
app_support.join("BraveSoftware/Brave-Browser"),
app_support.join("Arc/User Data"),
app_support.join("Vivaldi"),
app_support.join("com.operasoftware.Opera"),
];
dirs.into_iter().filter(|p| p.exists()).collect()
}
fn infer_browser_name(path: &std::path::Path) -> String {
let s = path.to_string_lossy();
let s = s.as_ref();
if s.contains("Dia") {
"Dia".into()
} else if s.contains("Chrome Canary") {
"Google Chrome Canary".into()
} else if s.contains("Chrome") {
"Google Chrome".into()
} else if s.contains("Chromium") {
"Chromium".into()
} else if s.contains("Edge Canary") {
"Microsoft Edge Canary".into()
} else if s.contains("Edge") {
"Microsoft Edge".into()
} else if s.contains("Brave") {
"Brave".into()
} else if s.contains("Arc") {
"Arc".into()
} else if s.contains("Vivaldi") {
"Vivaldi".into()
} else if s.contains("Opera") {
"Opera".into()
} else {
"unknown".into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_no_browser() {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async { detect(29_999).await });
assert!(result.is_err(), "detect on unused port should return error");
match result {
Err(CdpError::BrowserNotFound { port }) => assert_eq!(port, 29_999),
_ => panic!("expected BrowserNotFound"),
}
}
#[test]
fn test_infer_browser_name() {
fn p(s: &str) -> &std::path::Path {
std::path::Path::new(s)
}
assert_eq!(infer_browser_name(p("/Dia/User Data")), "Dia");
assert_eq!(infer_browser_name(p("/Google/Chrome")), "Google Chrome");
assert_eq!(
infer_browser_name(p("/Google/Chrome Canary")),
"Google Chrome Canary"
);
assert_eq!(infer_browser_name(p("/Chromium")), "Chromium");
assert_eq!(infer_browser_name(p("/Microsoft Edge")), "Microsoft Edge");
assert_eq!(
infer_browser_name(p("/BraveSoftware/Brave-Browser")),
"Brave"
);
assert_eq!(infer_browser_name(p("/Arc/User Data")), "Arc");
assert_eq!(infer_browser_name(p("/Vivaldi")), "Vivaldi");
assert_eq!(infer_browser_name(p("/com.operasoftware.Opera")), "Opera");
assert_eq!(infer_browser_name(p("/Unknown/Path")), "unknown");
}
#[test]
fn test_detected_browser_serialize() {
let db = DetectedBrowser {
ws_url: "ws://127.0.0.1:9222/devtools/browser/abc".into(),
browser: "Chrome".into(),
version: "130.0.0.0".into(),
};
let json = serde_json::to_string(&db).unwrap();
assert!(json.contains("\"ws_url\""));
assert!(json.contains("\"browser\""));
assert!(json.contains("\"version\""));
}
#[test]
fn test_devtools_active_port_parsing() {
let tmp = std::env::temp_dir().join("gthings-test-dtap-unit");
let _ = std::fs::create_dir_all(&tmp);
let _ = std::fs::write(
tmp.join("DevToolsActivePort"),
b"29997\n/devtools/browser/test-uuid\n",
);
assert_eq!(infer_browser_name(&tmp), "unknown");
let _ = std::fs::remove_dir_all(&tmp);
}
}