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;
5use tracing;
6
7/// Info about a running browser discovered by [`detect`].
8#[derive(Debug, Clone, serde::Serialize)]
9pub struct DetectedBrowser {
10    pub ws_url: String,
11    pub browser: String,
12    pub version: String,
13}
14
15// ---------------------------------------------------------------------------
16// Public API
17// ---------------------------------------------------------------------------
18
19/// Multi-strategy browser detection.
20///
21/// Tries each strategy in order and returns the first successful result:
22///
23/// 1. **`GTHINGS_CDP_WS_URL`** environment variable (fastest bypass).
24/// 2. **HTTP GET** `http://127.0.0.1:{port}/json/version` — parses `webSocketDebuggerUrl`.
25/// 3. **HTTP GET** `http://127.0.0.1:{port}/json` — finds first `webSocketDebuggerUrl`.
26/// 4. **HTTP GET** `http://127.0.0.1:{port}/json/list` — finds first `webSocketDebuggerUrl`.
27/// 5. **DevToolsActivePort** scan across 10+ browser profile directories on macOS.
28///
29/// Returns [`CdpError::BrowserNotFound`] if all strategies fail.
30pub async fn detect(port: u16) -> Result<DetectedBrowser> {
31    // 1. Environment variable bypass
32    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    // 2. HTTP /json/version
44    if let Some(browser) = probe_http_version(port).await {
45        tracing::info!("detect: found via /json/version");
46        return Ok(browser);
47    }
48
49    // 3. HTTP /json
50    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    // 4. HTTP /json/list
60    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    // 5. DevToolsActivePort scan
70    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
78/// Connect to a browser's CDP WebSocket endpoint.
79///
80/// This is a thin wrapper around [`Connection::connect`].
81pub async fn connect(ws_url: &str) -> Result<Connection> {
82    Connection::connect(ws_url).await
83}
84
85/// Dismiss the macOS "Allow remote debugging connection?" dialog that Dia
86/// shows when a CDP connection is first attempted.
87///
88/// Sends a Return keystroke to the Dia process via `osascript`/System Events.
89#[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/// Non-macOS: no-op.
103#[cfg(not(target_os = "macos"))]
104pub fn dismiss_allow_debugging_dialog() {}
105// ---------------------------------------------------------------------------
106// Internal probe helpers
107// ---------------------------------------------------------------------------
108
109/// Shared HTTP client with sensible timeouts.
110fn 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
121/// Probe `/json/version` — the richest endpoint (includes `webSocketDebuggerUrl`,
122/// `Browser`, and version info).
123async 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    // Extract short browser name from "Chrome/130.0.0.0" style string
137    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
150/// Probe `/json` or `/json/list` — returns the `webSocketDebuggerUrl` of
151/// the first available page target.
152async 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
168/// Scan well-known browser profile directories for a `DevToolsActivePort`
169/// file whose port matches the requested port, then verify via TCP connect.
170async 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    // Synchronous file reads are fine here — negligible for a handful of files.
177    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            // Verify port is accepting TCP connections
199            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
218/// Return all possible macOS browser profile directories.
219fn 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
242/// Infer a human-readable browser name from a profile directory path.
243fn 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        // Test the DevToolsActivePort parsing logic end-to-end
325        // by writing a temp file and calling probe_devtools_active_port.
326        let tmp = std::env::temp_dir().join("gthings-test-dtap-unit");
327        let _ = std::fs::create_dir_all(&tmp);
328
329        // Write valid DevToolsActivePort
330        let _ = std::fs::write(
331            tmp.join("DevToolsActivePort"),
332            b"29997\n/devtools/browser/test-uuid\n",
333        );
334
335        // We need HOME to point to the parent of our temp dir for get_profile_dirs
336        // Since we can't manipulate HOME, just verify infer_browser_name on the path
337        assert_eq!(infer_browser_name(&tmp), "unknown");
338
339        // Cleanup
340        let _ = std::fs::remove_dir_all(&tmp);
341    }
342}