1use crate::connection::Connection;
2use crate::error::{CdpError, Result};
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6#[derive(Debug, Clone, serde::Serialize)]
8pub struct DetectedBrowser {
9 pub ws_url: String,
10 pub browser: String,
11 pub version: String,
12}
13
14pub async fn detect(port: u16) -> Result<DetectedBrowser> {
30 if let Ok(ws_url) = std::env::var("GTHINGS_CDP_WS_URL") {
32 if !ws_url.is_empty() {
33 tracing::info!("detect: using GTHINGS_CDP_WS_URL env var");
34 return Ok(DetectedBrowser {
35 ws_url,
36 browser: "env".into(),
37 version: "unknown".into(),
38 });
39 }
40 }
41
42 if let Some(browser) = probe_http_version(port).await {
44 tracing::info!("detect: found via /json/version");
45 return Ok(browser);
46 }
47
48 if let Some(ws_url) = probe_http_list(port, "/json").await {
50 tracing::info!("detect: found via /json");
51 return Ok(DetectedBrowser {
52 ws_url,
53 browser: "unknown".into(),
54 version: "unknown".into(),
55 });
56 }
57
58 if let Some(ws_url) = probe_http_list(port, "/json/list").await {
60 tracing::info!("detect: found via /json/list");
61 return Ok(DetectedBrowser {
62 ws_url,
63 browser: "unknown".into(),
64 version: "unknown".into(),
65 });
66 }
67
68 if let Some(browser) = probe_devtools_active_port(port).await {
70 tracing::info!("detect: found via DevToolsActivePort");
71 return Ok(browser);
72 }
73
74 Err(CdpError::BrowserNotFound { port })
75}
76
77pub async fn connect(ws_url: &str) -> Result<Connection> {
81 Connection::connect(ws_url).await
82}
83
84#[cfg(target_os = "macos")]
89pub fn dismiss_allow_debugging_dialog() {
90 let script = r#"tell application "System Events"
91 try
92 set frontmost of process "Dia" to true
93 end try
94 tell process "Dia" to keystroke return
95 end tell"#;
96 let _ = std::process::Command::new("osascript")
97 .args(["-e", script])
98 .output();
99}
100
101#[cfg(not(target_os = "macos"))]
103pub fn dismiss_allow_debugging_dialog() {}
104fn http_client() -> &'static reqwest::Client {
110 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
111 CLIENT.get_or_init(|| {
112 reqwest::Client::builder()
113 .connect_timeout(std::time::Duration::from_secs(3))
114 .timeout(std::time::Duration::from_secs(10))
115 .build()
116 .expect("valid reqwest client config")
117 })
118}
119
120async fn probe_http_version(port: u16) -> Option<DetectedBrowser> {
123 let url = format!("http://127.0.0.1:{port}/json/version");
124 let client = http_client();
125
126 let resp = client.get(&url).send().await.ok()?;
127 let body: serde_json::Value = resp.json().await.ok()?;
128
129 let ws_url = body.get("webSocketDebuggerUrl")?.as_str()?;
130 let full_browser = body
131 .get("Browser")
132 .and_then(|v| v.as_str())
133 .unwrap_or("unknown");
134 let version = full_browser.to_string();
135 let browser = full_browser
137 .split('/')
138 .next()
139 .unwrap_or("unknown")
140 .to_string();
141
142 Some(DetectedBrowser {
143 ws_url: ws_url.to_string(),
144 browser,
145 version,
146 })
147}
148
149async fn probe_http_list(port: u16, path: &str) -> Option<String> {
152 let url = format!("http://127.0.0.1:{port}{path}");
153 let client = http_client();
154
155 let resp = client.get(&url).send().await.ok()?;
156 let list: Vec<serde_json::Value> = resp.json().await.ok()?;
157
158 for entry in &list {
159 if let Some(ws_url) = entry.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
160 return Some(ws_url.to_string());
161 }
162 }
163
164 None
165}
166
167async fn probe_devtools_active_port(port: u16) -> Option<DetectedBrowser> {
170 let profile_dirs = get_profile_dirs();
171 if profile_dirs.is_empty() {
172 return None;
173 }
174
175 let result: Option<DetectedBrowser> = tokio::task::spawn_blocking(move || {
177 for dir in &profile_dirs {
178 let active_port_path = dir.join("DevToolsActivePort");
179 let content = match std::fs::read_to_string(&active_port_path) {
180 Ok(c) => c,
181 Err(_) => continue,
182 };
183 let lines: Vec<&str> = content.trim().lines().collect();
184 if lines.len() < 2 {
185 continue;
186 }
187 let file_port: u16 = match lines[0].trim().parse().ok() {
188 Some(p) => p,
189 None => continue,
190 };
191 if file_port != port {
192 continue;
193 }
194 let ws_path = lines[1].trim();
195 let ws_url = format!("ws://127.0.0.1:{port}{ws_path}");
196
197 let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().ok()?;
199 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500))
200 .is_ok()
201 {
202 let browser_name = infer_browser_name(dir);
203 return Some(DetectedBrowser {
204 ws_url,
205 browser: browser_name,
206 version: "unknown".into(),
207 });
208 }
209 }
210 None
211 })
212 .await
213 .ok()?;
214 result
215}
216
217fn get_profile_dirs() -> Vec<PathBuf> {
219 let home = std::env::var("HOME").ok().map(PathBuf::from);
220 let home = match home {
221 Some(h) => h,
222 None => return Vec::new(),
223 };
224
225 let app_support = home.join("Library/Application Support");
226 let dirs = vec![
227 app_support.join("Dia/User Data"),
228 app_support.join("Google/Chrome"),
229 app_support.join("Google/Chrome Canary"),
230 app_support.join("Chromium"),
231 app_support.join("Microsoft Edge"),
232 app_support.join("Microsoft Edge Canary"),
233 app_support.join("BraveSoftware/Brave-Browser"),
234 app_support.join("Arc/User Data"),
235 app_support.join("Vivaldi"),
236 app_support.join("com.operasoftware.Opera"),
237 ];
238 dirs.into_iter().filter(|p| p.exists()).collect()
239}
240
241fn infer_browser_name(path: &std::path::Path) -> String {
243 let s = path.to_string_lossy();
244 let s = s.as_ref();
245 if s.contains("Dia") {
246 "Dia".into()
247 } else if s.contains("Chrome Canary") {
248 "Google Chrome Canary".into()
249 } else if s.contains("Chrome") {
250 "Google Chrome".into()
251 } else if s.contains("Chromium") {
252 "Chromium".into()
253 } else if s.contains("Edge Canary") {
254 "Microsoft Edge Canary".into()
255 } else if s.contains("Edge") {
256 "Microsoft Edge".into()
257 } else if s.contains("Brave") {
258 "Brave".into()
259 } else if s.contains("Arc") {
260 "Arc".into()
261 } else if s.contains("Vivaldi") {
262 "Vivaldi".into()
263 } else if s.contains("Opera") {
264 "Opera".into()
265 } else {
266 "unknown".into()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_detect_no_browser() {
276 let rt = tokio::runtime::Runtime::new().unwrap();
277 let result = rt.block_on(async { detect(29_999).await });
278 assert!(result.is_err(), "detect on unused port should return error");
279 match result {
280 Err(CdpError::BrowserNotFound { port }) => assert_eq!(port, 29_999),
281 _ => panic!("expected BrowserNotFound"),
282 }
283 }
284
285 #[test]
286 fn test_infer_browser_name() {
287 fn p(s: &str) -> &std::path::Path {
288 std::path::Path::new(s)
289 }
290 assert_eq!(infer_browser_name(p("/Dia/User Data")), "Dia");
291 assert_eq!(infer_browser_name(p("/Google/Chrome")), "Google Chrome");
292 assert_eq!(
293 infer_browser_name(p("/Google/Chrome Canary")),
294 "Google Chrome Canary"
295 );
296 assert_eq!(infer_browser_name(p("/Chromium")), "Chromium");
297 assert_eq!(infer_browser_name(p("/Microsoft Edge")), "Microsoft Edge");
298 assert_eq!(
299 infer_browser_name(p("/BraveSoftware/Brave-Browser")),
300 "Brave"
301 );
302 assert_eq!(infer_browser_name(p("/Arc/User Data")), "Arc");
303 assert_eq!(infer_browser_name(p("/Vivaldi")), "Vivaldi");
304 assert_eq!(infer_browser_name(p("/com.operasoftware.Opera")), "Opera");
305 assert_eq!(infer_browser_name(p("/Unknown/Path")), "unknown");
306 }
307
308 #[test]
309 fn test_detected_browser_serialize() {
310 let db = DetectedBrowser {
311 ws_url: "ws://127.0.0.1:9222/devtools/browser/abc".into(),
312 browser: "Chrome".into(),
313 version: "130.0.0.0".into(),
314 };
315 let json_str = serde_json::to_string(&db).unwrap();
316 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
318 assert_eq!(
319 parsed["ws_url"].as_str(),
320 Some("ws://127.0.0.1:9222/devtools/browser/abc")
321 );
322 assert_eq!(parsed["browser"].as_str(), Some("Chrome"));
323 assert_eq!(parsed["version"].as_str(), Some("130.0.0.0"));
324 }
325}