Skip to main content

gthings_cdp/
browser.rs

1use crate::connection::Connection;
2use crate::error::{CdpError, Result};
3use serde::{Deserialize, Serialize};
4use std::io::{BufRead, BufReader};
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7use tracing;
8
9/// A persistent Chrome browser instance. Stays alive after Drop.
10pub struct Browser {
11    ws_url: String,
12    #[allow(dead_code)]
13    cdp_port: u16,
14}
15
16/// Saved browser state for reuse across commands.
17#[derive(Serialize, Deserialize)]
18struct BrowserState {
19    pid: u32,
20}
21
22/// Detect the default browser for HTTP URLs using macOS Launch Services.
23/// Returns the path to the .app bundle (e.g., "/Applications/Google Chrome.app").
24#[cfg(target_os = "macos")]
25fn default_browser_bundle() -> Option<std::path::PathBuf> {
26    let script = r#"import AppKit; let ws = NSWorkspace.shared; if let url = ws.urlForApplication(toOpen: URL(string: "https://")!) { print(url.path) }"#;
27    let output = std::process::Command::new("swift")
28        .args(["-e", script])
29        .output()
30        .ok()?;
31    if !output.status.success() {
32        return None;
33    }
34    let path_str = std::str::from_utf8(&output.stdout).ok()?.trim();
35    if path_str.is_empty() {
36        return None;
37    }
38    let path = std::path::PathBuf::from(path_str);
39    if path.exists() { Some(path) } else { None }
40}
41
42#[cfg(not(target_os = "macos"))]
43fn default_browser_bundle() -> Option<std::path::PathBuf> {
44    None // Non-macOS fallback: no default browser detection
45}
46
47/// Map a browser bundle path to its executable name inside Contents/MacOS/
48fn browser_exec_name(bundle_path: &std::path::Path) -> Option<&'static str> {
49    let path_str = bundle_path.to_string_lossy();
50    if path_str.contains("Google Chrome") {
51        Some("Google Chrome")
52    } else if path_str.contains("Dia") {
53        Some("Dia")
54    } else if path_str.contains("Arc") {
55        Some("Arc")
56    } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
57        Some("Brave Browser")
58    } else if path_str.contains("Microsoft Edge") {
59        Some("Microsoft Edge")
60    } else if path_str.contains("Chromium") {
61        Some("Chromium")
62    } else {
63        None
64    }
65}
66
67/// Map a browser bundle path to its profile directory suffix (under ~/Library/Application Support/)
68fn browser_profile_suffix(bundle_path: &std::path::Path) -> Option<&'static str> {
69    let path_str = bundle_path.to_string_lossy();
70    if path_str.contains("Google Chrome") {
71        Some("Google/Chrome")
72    } else if path_str.contains("Dia") {
73        Some("Dia")
74    } else if path_str.contains("Arc") {
75        Some("Arc")
76    } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
77        Some("BraveSoftware/Brave-Browser")
78    } else if path_str.contains("Microsoft Edge") {
79        Some("Microsoft Edge")
80    } else if path_str.contains("Chromium") {
81        Some("Chromium")
82    } else {
83        None
84    }
85}
86
87/// Build the executable path from a bundle path
88fn bundle_to_exec(bundle: &std::path::Path, exec_name: &str) -> std::path::PathBuf {
89    bundle.join("Contents").join("MacOS").join(exec_name)
90}
91
92impl Browser {
93    /// Launch or reuse a persistent Chrome browser.
94    #[allow(clippy::result_large_err)]
95    pub async fn launch(
96        browser_path: Option<std::path::PathBuf>,
97        profile_dir: Option<std::path::PathBuf>,
98        cdp_port: u16,
99    ) -> Result<Self> {
100        tracing::info!("Checking for existing browser on port {cdp_port}");
101        if let Some(browser) = Self::find_existing(cdp_port).await {
102            tracing::info!("Found existing browser, reusing");
103            return Ok(browser);
104        }
105        tracing::info!("No existing browser found, launching new one");
106
107        let chrome_path = Self::find_chrome(browser_path)
108            .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
109
110        let port = cdp_port;
111
112        // Use real profile to avoid browser onboarding/login prompts.
113        // SingletonLock conflicts are avoided because find_existing() reuses
114        // the already-running browser instead of launching a second instance.
115        // If no existing browser is found, we clean locks and launch fresh.
116        let profile_dir = Self::real_profile_dir(profile_dir).unwrap_or_else(|| {
117            let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", port));
118            let _ = std::fs::create_dir_all(&tmp);
119            tmp
120        });
121
122        // Clean locks before fresh launch to avoid SingletonLock conflicts
123        {
124            let dir = profile_dir.clone();
125            tokio::task::spawn_blocking(move || {
126                Self::clean_profile_locks(&dir);
127            })
128            .await
129            .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
130        }
131
132        tracing::info!(
133            "Launching browser on port {} with profile {:?}",
134            port,
135            profile_dir
136        );
137
138        let mut cmd = Command::new(&chrome_path);
139        cmd.arg(format!("--remote-debugging-port={}", port))
140            .arg("--no-first-run")
141            .arg("--no-default-browser-check")
142            .arg("--disable-sync")
143            .arg("--remote-allow-origins=*")
144            .arg("--enable-automation")
145            .arg("--disable-background-networking")
146            .arg("--disable-extensions")
147            .arg("--disable-component-update")
148            .arg("--disable-default-apps")
149            .arg("--password-store=basic")
150            .arg("--use-mock-keychain")
151            .arg(format!("--user-data-dir={}", profile_dir.display()))
152            .arg("about:blank")
153            .stderr(Stdio::piped())
154            .stdout(Stdio::null())
155            .stdin(Stdio::null());
156
157        let mut child = cmd
158            .spawn()
159            .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
160
161        let stderr = child
162            .stderr
163            .take()
164            .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
165
166        let reader = BufReader::new(stderr);
167        let mut ws_url = None;
168
169        for line in reader.lines() {
170            let line = line.map_err(|e| {
171                CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
172            })?;
173            tracing::debug!("Chrome: {}", line);
174
175            // "DevTools listening on ws://127.0.0.1:9222/..."
176            if let Some(url) = line.strip_prefix("DevTools listening on ") {
177                ws_url = Some(url.trim().to_string());
178                break;
179            }
180        }
181
182        let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
183        let pid = child.id();
184
185        let state = BrowserState { pid };
186        let state_json = serde_json::to_string(&state)?;
187        // Atomic write: temp file then rename
188        let final_path = Self::state_path();
189        let tmp_path = final_path.with_extension("json.tmp");
190        let json = state_json;
191        tokio::task::spawn_blocking(move || {
192            if let Some(parent) = final_path.parent() {
193                let _ = std::fs::create_dir_all(parent);
194            }
195            std::fs::write(&tmp_path, &json)
196                .map_err(|e| CdpError::LaunchFailed(format!("Cannot save state: {e}")))?;
197            std::fs::rename(&tmp_path, &final_path)
198                .map_err(|e| CdpError::LaunchFailed(format!("Cannot commit state: {e}")))?;
199            Ok::<_, CdpError>(())
200        })
201        .await
202        .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))??;
203
204        tracing::info!("Launched persistent browser (pid={})", pid);
205
206        // Detach — browser stays alive after Drop
207        drop(child);
208
209        Ok(Browser { ws_url, cdp_port })
210    }
211
212    /// Connect to CDP WebSocket.
213    pub async fn connect(&self) -> Result<Connection> {
214        tracing::info!("Connecting to CDP: {}", self.ws_url);
215
216        let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
217        // Leak kill_tx so kill_rx never fires
218        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
219        std::mem::forget(kill_tx);
220        Connection::new(ws_stream, kill_rx).await
221    }
222
223    /// Get the WebSocket URL.
224    pub fn ws_url(&self) -> &str {
225        &self.ws_url
226    }
227
228    /// Locate Chrome executable.
229    fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
230        // 1. Check explicit env var first (already done in Level 0)
231        if let Some(path) = browser_path {
232            if path.exists() {
233                return Some(path.to_string_lossy().to_string());
234            }
235        }
236
237        // 2. Check macOS default browser
238        #[cfg(target_os = "macos")]
239        if let Some(bundle) = default_browser_bundle() {
240            if let Some(exec_name) = browser_exec_name(&bundle) {
241                let exec = bundle_to_exec(&bundle, exec_name);
242                if exec.exists() {
243                    return Some(exec.to_string_lossy().to_string());
244                }
245            }
246        }
247
248        // 3. Fallback: hardcoded path list (original behavior)
249        let candidates = [
250            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
251            "/Applications/Chrome.app/Contents/MacOS/Chrome",
252            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
253            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
254            "/Applications/Arc.app/Contents/MacOS/Arc",
255            "/Applications/Dia.app/Contents/MacOS/Dia",
256            "/usr/bin/chromium",
257            "/usr/bin/chromium-browser",
258            "/snap/bin/chromium",
259        ];
260
261        for candidate in &candidates {
262            if std::path::Path::new(candidate).exists() {
263                return Some(candidate.to_string());
264            }
265        }
266
267        // 4. Fallback: `which` search
268        for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
269            if let Ok(output) = std::process::Command::new("which").arg(name).output() {
270                if output.status.success() {
271                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
272                    if !path.is_empty() && std::path::Path::new(&path).exists() {
273                        return Some(path);
274                    }
275                }
276            }
277        }
278
279        None
280    }
281
282    /// Locate browser profile directory.
283    fn real_profile_dir(profile_dir: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
284        // 1. Check explicit env var first (GTHINGS_PROFILE_DIR)
285        if let Some(dir) = profile_dir {
286            if dir.exists() {
287                return Some(dir);
288            }
289        }
290
291        let home = std::env::var("HOME").ok()?;
292
293        // 2. Try to match profile to the default browser
294        #[cfg(target_os = "macos")]
295        if let Some(bundle) = default_browser_bundle() {
296            if let Some(suffix) = browser_profile_suffix(&bundle) {
297                let profile = std::path::PathBuf::from(&home)
298                    .join("Library/Application Support")
299                    .join(suffix);
300                if profile.exists() {
301                    return Some(profile);
302                }
303            }
304        }
305
306        // 3. Fallback: check common profile directories
307        let common_profiles = [
308            "Google/Chrome",
309            "Dia",
310            "Chromium",
311            "BraveSoftware/Brave-Browser",
312            "Microsoft Edge",
313            "Arc",
314        ];
315
316        for suffix in &common_profiles {
317            let profile = std::path::PathBuf::from(&home)
318                .join("Library/Application Support")
319                .join(suffix);
320            if profile.exists() {
321                return Some(profile);
322            }
323        }
324
325        None
326    }
327
328    /// Clean profile lock files.
329    fn clean_profile_locks(profile_dir: &std::path::Path) {
330        let lock_files = [
331            "SingletonLock",
332            "SingletonSocket",
333            "SingletonCookie",
334            "DevToolsActivePort",
335        ];
336        for name in &lock_files {
337            let path = profile_dir.join(name);
338            if path.exists() {
339                let _ = std::fs::remove_file(&path);
340            }
341        }
342    }
343
344    /// Check if the browser is alive by probing the given port.
345    pub fn is_alive(cdp_port: u16) -> bool {
346        Self::probe_port(cdp_port)
347    }
348
349    /// Get the path to the browser state file.
350    fn state_path() -> PathBuf {
351        Self::home_dir().join(".gthings/browser.json")
352    }
353
354    /// Get home directory.
355    fn home_dir() -> PathBuf {
356        std::env::var("HOME")
357            .map(PathBuf::from)
358            .unwrap_or_else(|_| PathBuf::from("/tmp"))
359    }
360
361    /// Get the path to the browser state file (public for CLI use).
362    pub fn state_file_path() -> PathBuf {
363        Self::state_path()
364    }
365
366    /// Find existing browser via state file and port probe.
367    pub async fn find_existing(cdp_port: u16) -> Option<Self> {
368        let state_path = Self::state_path();
369        if !state_path.exists() {
370            return None;
371        }
372
373        let path = state_path.clone();
374        let state_str: String =
375            tokio::task::spawn_blocking(move || std::fs::read_to_string(&path).ok())
376                .await
377                .unwrap_or(None)?;
378        let state: BrowserState = serde_json::from_str(&state_str).ok()?;
379
380        if !Self::is_process_alive(state.pid) {
381            tracing::warn!("Browser pid={} is dead, removing stale state", state.pid);
382            let path = state_path.clone();
383            tokio::task::spawn_blocking(move || {
384                let _ = std::fs::remove_file(&path);
385            })
386            .await
387            .ok();
388            return None;
389        }
390
391        if !Self::probe_port(cdp_port) {
392            tracing::warn!(
393                "Browser port {} not responding, removing stale state",
394                cdp_port
395            );
396            let path = state_path.clone();
397            tokio::task::spawn_blocking(move || {
398                let _ = std::fs::remove_file(&path);
399            })
400            .await
401            .ok();
402            return None;
403        }
404
405        let ws_url = Self::fetch_ws_url(cdp_port).await?;
406
407        tracing::info!("Found existing browser (pid={})", state.pid);
408
409        Some(Browser { ws_url, cdp_port })
410    }
411
412    /// Fetch WebSocket debugger URL from /json/version.
413    async fn fetch_ws_url(cdp_port: u16) -> Option<String> {
414        let url = format!("http://127.0.0.1:{cdp_port}/json/version");
415        let resp = reqwest::get(&url).await.ok()?;
416        let json: serde_json::Value = resp.json().await.ok()?;
417        json["webSocketDebuggerUrl"].as_str().map(|s| s.to_string())
418    }
419
420    /// Check if a process is alive by pid.
421    fn is_process_alive(pid: u32) -> bool {
422        std::process::Command::new("kill")
423            .arg("-0")
424            .arg(pid.to_string())
425            .output()
426            .map(|output| output.status.success())
427            .unwrap_or(false)
428    }
429
430    /// Probe port to see if it's accepting connections.
431    /// Tries IPv4 first, then IPv6.
432    fn probe_port(cdp_port: u16) -> bool {
433        let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
434        for addr in &addrs {
435            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
436                if std::net::TcpStream::connect_timeout(
437                    &parsed,
438                    std::time::Duration::from_millis(500),
439                )
440                .is_ok()
441                {
442                    return true;
443                }
444            }
445        }
446        false
447    }
448
449    /// Get the browser pid from the state file.
450    pub async fn pid(&self) -> Option<u32> {
451        let state_path = Self::state_path();
452        tokio::task::spawn_blocking(move || {
453            if state_path.exists() {
454                if let Ok(state_str) = std::fs::read_to_string(&state_path) {
455                    if let Ok(state) = serde_json::from_str::<BrowserState>(&state_str) {
456                        return Some(state.pid);
457                    }
458                }
459            }
460            None
461        })
462        .await
463        .unwrap_or(None)
464    }
465}
466
467impl Drop for Browser {
468    fn drop(&mut self) {
469        // Browser stays alive — it's persistent
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_probe_port_no_server() {
479        assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
480    }
481
482    #[test]
483    fn test_state_path_ends_correctly() {
484        let path = Browser::state_path();
485        assert!(path.ends_with(".gthings/browser.json"));
486    }
487
488    #[test]
489    fn test_find_chrome_returns_some_or_none() {
490        let result = Browser::find_chrome(None);
491        // Either finds Chrome or returns None — don't panic either way
492        if let Some(path) = result {
493            assert!(
494                std::path::Path::new(&path).exists(),
495                "Chrome path should exist: {}",
496                path
497            );
498        }
499    }
500
501    #[test]
502    fn test_error_types_compile() {
503        let _err = CdpError::LaunchFailed("test".into());
504        let _err = CdpError::NoWsUrl;
505        let _err = CdpError::Timeout(1000);
506    }
507}