Skip to main content

gthings_cdp/
browser.rs

1use crate::connection::Connection;
2use crate::error::{CdpError, Result};
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6/// Info about a running browser discovered by [`detect`].
7#[derive(Debug, Clone, serde::Serialize)]
8pub struct DetectedBrowser {
9    pub ws_url: String,
10    pub browser: String,
11    pub version: String,
12}
13
14// ---------------------------------------------------------------------------
15// Public API
16// ---------------------------------------------------------------------------
17
18/// Multi-strategy browser detection.
19///
20/// Tries each strategy in order and returns the first successful result:
21///
22/// 1. **`GTHINGS_CDP_WS_URL`** environment variable (fastest bypass).
23/// 2. **HTTP GET** `http://127.0.0.1:{port}/json/version` — parses `webSocketDebuggerUrl`.
24/// 3. **HTTP GET** `http://127.0.0.1:{port}/json` — finds first `webSocketDebuggerUrl`.
25/// 4. **HTTP GET** `http://127.0.0.1:{port}/json/list` — finds first `webSocketDebuggerUrl`.
26/// 5. **DevToolsActivePort** scan across 10+ browser profile directories on macOS.
27///
28/// Returns [`CdpError::BrowserNotFound`] if all strategies fail.
29pub async fn detect(port: u16) -> Result<DetectedBrowser> {
30    // 1. Environment variable bypass
31    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    // 2. HTTP /json/version
43    if let Some(browser) = probe_http_version(port).await {
44        tracing::info!("detect: found via /json/version");
45        return Ok(browser);
46    }
47
48    // 3. HTTP /json
49    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    // 4. HTTP /json/list
59    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    // 5. DevToolsActivePort scan
69    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
77/// Connect to a browser's CDP WebSocket endpoint.
78///
79/// This is a thin wrapper around [`Connection::connect`].
80pub async fn connect(ws_url: &str) -> Result<Connection> {
81    Connection::connect(ws_url).await
82}
83
84/// Dismiss the macOS "Allow remote debugging connection?" dialog that Dia
85/// shows when a CDP connection is first attempted.
86///
87/// Sends a Return keystroke to the Dia process via `osascript`/System Events.
88#[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/// Non-macOS: no-op.
102#[cfg(not(target_os = "macos"))]
103pub fn dismiss_allow_debugging_dialog() {}
104// ---------------------------------------------------------------------------
105// Internal probe helpers
106// ---------------------------------------------------------------------------
107
108/// Shared HTTP client with sensible timeouts.
109fn 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
120/// Probe `/json/version` — the richest endpoint (includes `webSocketDebuggerUrl`,
121/// `Browser`, and version info).
122async 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    // Extract short browser name from "Chrome/130.0.0.0" style string
136    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
149/// Probe `/json` or `/json/list` — returns the `webSocketDebuggerUrl` of
150/// the first available page target.
151async 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
167/// Scan well-known browser profile directories for a `DevToolsActivePort`
168/// file whose port matches the requested port, then verify via TCP connect.
169async 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    // Synchronous file reads are fine here — negligible for a handful of files.
176    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            // Verify port is accepting TCP connections
198            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
217/// Return all possible macOS browser profile directories.
218fn 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
241/// Infer a human-readable browser name from a profile directory path.
242fn 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        // Round-trip through Value to assert specific field values
317        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}