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