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 appears
85/// as a **sheet** in Dia and other Chromium-based browsers when a CDP
86/// connection is first attempted.
87///
88/// Uses `osascript`/System Events to detect the dialog sheet and click the
89/// "Allow" button. Polls every 500 ms for up to ~10 seconds (20 attempts)
90/// since the dialog may take 1–3 seconds to appear. Logs a warning if the
91/// dialog is never found — the WebSocket handshake may still proceed.
92#[cfg(target_os = "macos")]
93pub fn dismiss_allow_debugging_dialog() {
94    /// AppleScript that checks each known browser process for a sheet dialog
95    /// (attached to window 1) and clicks the "Allow" button if found.
96    /// Returns the browser name if dismissed, or empty string otherwise.
97    /// Each `exists` check is wrapped in `try` so that missing processes
98    /// don't abort the script.
99    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/// Non-macOS: no-op.
194#[cfg(not(target_os = "macos"))]
195pub fn dismiss_allow_debugging_dialog() {}
196// ---------------------------------------------------------------------------
197// Internal probe helpers
198// ---------------------------------------------------------------------------
199
200/// Shared HTTP client with sensible timeouts.
201fn 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
212/// Probe `/json/version` — the richest endpoint (includes `webSocketDebuggerUrl`,
213/// `Browser`, and version info).
214async 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    // Extract short browser name from "Chrome/130.0.0.0" style string
228    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
241/// Probe `/json` or `/json/list` — returns the `webSocketDebuggerUrl` of
242/// the first available page target.
243async 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
259/// Scan well-known browser profile directories for a `DevToolsActivePort`
260/// file whose port matches the requested port, then verify via TCP connect.
261async 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    // Synchronous file reads are fine here — negligible for a handful of files.
268    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            // Verify port is accepting TCP connections
290            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
309/// Return all possible macOS browser profile directories.
310fn 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
333/// Infer a human-readable browser name from a profile directory path.
334fn 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        // Round-trip through Value to assert specific field values
409        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}