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")]
93pub fn dismiss_allow_debugging_dialog() {
94 const SCRIPT: &str = r#"tell application "System Events"
100 set browserName to ""
101 try
102 if exists (sheet 1 of window 1 of process "Dia") then
103 tell process "Dia" to click button "Allow" of sheet 1 of window 1
104 set browserName to "Dia"
105 end if
106 end try
107 try
108 if browserName is "" and exists (sheet 1 of window 1 of process "Chromium") then
109 tell process "Chromium" to click button "Allow" of sheet 1 of window 1
110 set browserName to "Chromium"
111 end if
112 end try
113 try
114 if browserName is "" and exists (sheet 1 of window 1 of process "Google Chrome") then
115 tell process "Google Chrome" to click button "Allow" of sheet 1 of window 1
116 set browserName to "Google Chrome"
117 end if
118 end try
119 try
120 if browserName is "" and exists (sheet 1 of window 1 of process "Google Chrome Canary") then
121 tell process "Google Chrome Canary" to click button "Allow" of sheet 1 of window 1
122 set browserName to "Google Chrome Canary"
123 end if
124 end try
125 try
126 if browserName is "" and exists (sheet 1 of window 1 of process "Microsoft Edge") then
127 tell process "Microsoft Edge" to click button "Allow" of sheet 1 of window 1
128 set browserName to "Microsoft Edge"
129 end if
130 end try
131 try
132 if browserName is "" and exists (sheet 1 of window 1 of process "Microsoft Edge Canary") then
133 tell process "Microsoft Edge Canary" to click button "Allow" of sheet 1 of window 1
134 set browserName to "Microsoft Edge Canary"
135 end if
136 end try
137 try
138 if browserName is "" and exists (sheet 1 of window 1 of process "Brave Browser") then
139 tell process "Brave Browser" to click button "Allow" of sheet 1 of window 1
140 set browserName to "Brave Browser"
141 end if
142 end try
143 try
144 if browserName is "" and exists (sheet 1 of window 1 of process "Arc") then
145 tell process "Arc" to click button "Allow" of sheet 1 of window 1
146 set browserName to "Arc"
147 end if
148 end try
149 try
150 if browserName is "" and exists (sheet 1 of window 1 of process "Vivaldi") then
151 tell process "Vivaldi" to click button "Allow" of sheet 1 of window 1
152 set browserName to "Vivaldi"
153 end if
154 end try
155 try
156 if browserName is "" and exists (sheet 1 of window 1 of process "Opera") then
157 tell process "Opera" to click button "Allow" of sheet 1 of window 1
158 set browserName to "Opera"
159 end if
160 end try
161 return browserName
162 end tell"#;
163
164 for attempt in 1..=20 {
165 let output = std::process::Command::new("osascript")
166 .args(["-e", SCRIPT])
167 .output();
168
169 match output {
170 Ok(out) => {
171 let stdout = String::from_utf8_lossy(&out.stdout);
172 let browser = stdout.trim();
173 if !browser.is_empty() {
174 tracing::warn!("Dismissed remote debugging dialog for {browser} (attempt {attempt})");
175 return;
176 }
177 }
178 Err(e) => {
179 tracing::warn!("osascript failed: {e}");
180 }
181 }
182
183 if attempt < 20 {
184 std::thread::sleep(std::time::Duration::from_millis(500));
185 }
186 }
187
188 tracing::warn!(
189 "Remote debugging dialog not found after 20 attempts — continuing"
190 );
191}
192
193#[cfg(not(target_os = "macos"))]
195pub fn dismiss_allow_debugging_dialog() {}
196fn http_client() -> &'static reqwest::Client {
202 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
203 CLIENT.get_or_init(|| {
204 reqwest::Client::builder()
205 .connect_timeout(std::time::Duration::from_secs(3))
206 .timeout(std::time::Duration::from_secs(10))
207 .build()
208 .expect("valid reqwest client config")
209 })
210}
211
212async fn probe_http_version(port: u16) -> Option<DetectedBrowser> {
215 let url = format!("http://127.0.0.1:{port}/json/version");
216 let client = http_client();
217
218 let resp = client.get(&url).send().await.ok()?;
219 let body: serde_json::Value = resp.json().await.ok()?;
220
221 let ws_url = body.get("webSocketDebuggerUrl")?.as_str()?;
222 let full_browser = body
223 .get("Browser")
224 .and_then(|v| v.as_str())
225 .unwrap_or("unknown");
226 let version = full_browser.to_string();
227 let browser = full_browser
229 .split('/')
230 .next()
231 .unwrap_or("unknown")
232 .to_string();
233
234 Some(DetectedBrowser {
235 ws_url: ws_url.to_string(),
236 browser,
237 version,
238 })
239}
240
241async fn probe_http_list(port: u16, path: &str) -> Option<String> {
244 let url = format!("http://127.0.0.1:{port}{path}");
245 let client = http_client();
246
247 let resp = client.get(&url).send().await.ok()?;
248 let list: Vec<serde_json::Value> = resp.json().await.ok()?;
249
250 for entry in &list {
251 if let Some(ws_url) = entry.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
252 return Some(ws_url.to_string());
253 }
254 }
255
256 None
257}
258
259async fn probe_devtools_active_port(port: u16) -> Option<DetectedBrowser> {
262 let profile_dirs = get_profile_dirs();
263 if profile_dirs.is_empty() {
264 return None;
265 }
266
267 let result: Option<DetectedBrowser> = tokio::task::spawn_blocking(move || {
269 for dir in &profile_dirs {
270 let active_port_path = dir.join("DevToolsActivePort");
271 let content = match std::fs::read_to_string(&active_port_path) {
272 Ok(c) => c,
273 Err(_) => continue,
274 };
275 let lines: Vec<&str> = content.trim().lines().collect();
276 if lines.len() < 2 {
277 continue;
278 }
279 let file_port: u16 = match lines[0].trim().parse().ok() {
280 Some(p) => p,
281 None => continue,
282 };
283 if file_port != port {
284 continue;
285 }
286 let ws_path = lines[1].trim();
287 let ws_url = format!("ws://127.0.0.1:{port}{ws_path}");
288
289 let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().ok()?;
291 if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500))
292 .is_ok()
293 {
294 let browser_name = infer_browser_name(dir);
295 return Some(DetectedBrowser {
296 ws_url,
297 browser: browser_name,
298 version: "unknown".into(),
299 });
300 }
301 }
302 None
303 })
304 .await
305 .ok()?;
306 result
307}
308
309fn get_profile_dirs() -> Vec<PathBuf> {
311 let home = std::env::var("HOME").ok().map(PathBuf::from);
312 let home = match home {
313 Some(h) => h,
314 None => return Vec::new(),
315 };
316
317 let app_support = home.join("Library/Application Support");
318 let dirs = vec![
319 app_support.join("Dia/User Data"),
320 app_support.join("Google/Chrome"),
321 app_support.join("Google/Chrome Canary"),
322 app_support.join("Chromium"),
323 app_support.join("Microsoft Edge"),
324 app_support.join("Microsoft Edge Canary"),
325 app_support.join("BraveSoftware/Brave-Browser"),
326 app_support.join("Arc/User Data"),
327 app_support.join("Vivaldi"),
328 app_support.join("com.operasoftware.Opera"),
329 ];
330 dirs.into_iter().filter(|p| p.exists()).collect()
331}
332
333fn infer_browser_name(path: &std::path::Path) -> String {
335 let s = path.to_string_lossy();
336 let s = s.as_ref();
337 if s.contains("Dia") {
338 "Dia".into()
339 } else if s.contains("Chrome Canary") {
340 "Google Chrome Canary".into()
341 } else if s.contains("Chrome") {
342 "Google Chrome".into()
343 } else if s.contains("Chromium") {
344 "Chromium".into()
345 } else if s.contains("Edge Canary") {
346 "Microsoft Edge Canary".into()
347 } else if s.contains("Edge") {
348 "Microsoft Edge".into()
349 } else if s.contains("Brave") {
350 "Brave".into()
351 } else if s.contains("Arc") {
352 "Arc".into()
353 } else if s.contains("Vivaldi") {
354 "Vivaldi".into()
355 } else if s.contains("Opera") {
356 "Opera".into()
357 } else {
358 "unknown".into()
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_detect_no_browser() {
368 let rt = tokio::runtime::Runtime::new().unwrap();
369 let result = rt.block_on(async { detect(29_999).await });
370 assert!(result.is_err(), "detect on unused port should return error");
371 match result {
372 Err(CdpError::BrowserNotFound { port }) => assert_eq!(port, 29_999),
373 _ => panic!("expected BrowserNotFound"),
374 }
375 }
376
377 #[test]
378 fn test_infer_browser_name() {
379 fn p(s: &str) -> &std::path::Path {
380 std::path::Path::new(s)
381 }
382 assert_eq!(infer_browser_name(p("/Dia/User Data")), "Dia");
383 assert_eq!(infer_browser_name(p("/Google/Chrome")), "Google Chrome");
384 assert_eq!(
385 infer_browser_name(p("/Google/Chrome Canary")),
386 "Google Chrome Canary"
387 );
388 assert_eq!(infer_browser_name(p("/Chromium")), "Chromium");
389 assert_eq!(infer_browser_name(p("/Microsoft Edge")), "Microsoft Edge");
390 assert_eq!(
391 infer_browser_name(p("/BraveSoftware/Brave-Browser")),
392 "Brave"
393 );
394 assert_eq!(infer_browser_name(p("/Arc/User Data")), "Arc");
395 assert_eq!(infer_browser_name(p("/Vivaldi")), "Vivaldi");
396 assert_eq!(infer_browser_name(p("/com.operasoftware.Opera")), "Opera");
397 assert_eq!(infer_browser_name(p("/Unknown/Path")), "unknown");
398 }
399
400 #[test]
401 fn test_detected_browser_serialize() {
402 let db = DetectedBrowser {
403 ws_url: "ws://127.0.0.1:9222/devtools/browser/abc".into(),
404 browser: "Chrome".into(),
405 version: "130.0.0.0".into(),
406 };
407 let json_str = serde_json::to_string(&db).unwrap();
408 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
410 assert_eq!(
411 parsed["ws_url"].as_str(),
412 Some("ws://127.0.0.1:9222/devtools/browser/abc")
413 );
414 assert_eq!(parsed["browser"].as_str(), Some("Chrome"));
415 assert_eq!(parsed["version"].as_str(), Some("130.0.0.0"));
416 }
417}