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 onboarding / first-run dialogs
113        let profile_dir = Self::real_profile_dir(profile_dir).unwrap_or_else(|| {
114            let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", port));
115            let _ = std::fs::create_dir_all(&tmp);
116            tmp
117        });
118        {
119            let dir = profile_dir.clone();
120            tokio::task::spawn_blocking(move || {
121                Self::clean_profile_locks(&dir);
122            })
123            .await
124            .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
125        }
126
127        tracing::info!(
128            "Launching Chrome on port {} with profile {:?}",
129            port,
130            profile_dir
131        );
132
133        let mut cmd = Command::new(&chrome_path);
134        cmd.arg(format!("--remote-debugging-port={}", port))
135            .arg("--no-first-run")
136            .arg("--no-default-browser-check")
137            .arg("--disable-sync")
138            .arg("--remote-allow-origins=*")
139            .arg(format!("--user-data-dir={}", profile_dir.display()))
140            .arg("about:blank")
141            .stderr(Stdio::piped())
142            .stdout(Stdio::null())
143            .stdin(Stdio::null());
144
145        let mut child = cmd
146            .spawn()
147            .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
148
149        let stderr = child
150            .stderr
151            .take()
152            .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
153
154        let reader = BufReader::new(stderr);
155        let mut ws_url = None;
156
157        for line in reader.lines() {
158            let line = line.map_err(|e| {
159                CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
160            })?;
161            tracing::debug!("Chrome: {}", line);
162
163            // "DevTools listening on ws://127.0.0.1:9222/..."
164            if let Some(url) = line.strip_prefix("DevTools listening on ") {
165                ws_url = Some(url.trim().to_string());
166                break;
167            }
168        }
169
170        let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
171        let pid = child.id();
172
173        let state = BrowserState { pid };
174        let state_json = serde_json::to_string(&state)?;
175        // Atomic write: temp file then rename
176        let final_path = Self::state_path();
177        let tmp_path = final_path.with_extension("json.tmp");
178        let json = state_json;
179        tokio::task::spawn_blocking(move || {
180            if let Some(parent) = final_path.parent() {
181                let _ = std::fs::create_dir_all(parent);
182            }
183            std::fs::write(&tmp_path, &json)
184                .map_err(|e| CdpError::LaunchFailed(format!("Cannot save state: {e}")))?;
185            std::fs::rename(&tmp_path, &final_path)
186                .map_err(|e| CdpError::LaunchFailed(format!("Cannot commit state: {e}")))?;
187            Ok::<_, CdpError>(())
188        })
189        .await
190        .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))??;
191
192        tracing::info!("Launched persistent browser (pid={})", pid);
193
194        // Detach — browser stays alive after Drop
195        drop(child);
196
197        Ok(Browser { ws_url, cdp_port })
198    }
199
200    /// Connect to CDP WebSocket.
201    pub async fn connect(&self) -> Result<Connection> {
202        tracing::info!("Connecting to CDP: {}", self.ws_url);
203
204        let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
205        // Leak kill_tx so kill_rx never fires
206        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
207        std::mem::forget(kill_tx);
208        Connection::new(ws_stream, kill_rx).await
209    }
210
211    /// Get the WebSocket URL.
212    pub fn ws_url(&self) -> &str {
213        &self.ws_url
214    }
215
216    /// Locate Chrome executable.
217    fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
218        // 1. Check explicit env var first (already done in Level 0)
219        if let Some(path) = browser_path {
220            if path.exists() {
221                return Some(path.to_string_lossy().to_string());
222            }
223        }
224
225        // 2. Check macOS default browser
226        #[cfg(target_os = "macos")]
227        if let Some(bundle) = default_browser_bundle() {
228            if let Some(exec_name) = browser_exec_name(&bundle) {
229                let exec = bundle_to_exec(&bundle, exec_name);
230                if exec.exists() {
231                    return Some(exec.to_string_lossy().to_string());
232                }
233            }
234        }
235
236        // 3. Fallback: hardcoded path list (original behavior)
237        let candidates = [
238            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
239            "/Applications/Chrome.app/Contents/MacOS/Chrome",
240            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
241            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
242            "/Applications/Arc.app/Contents/MacOS/Arc",
243            "/Applications/Dia.app/Contents/MacOS/Dia",
244            "/usr/bin/chromium",
245            "/usr/bin/chromium-browser",
246            "/snap/bin/chromium",
247        ];
248
249        for candidate in &candidates {
250            if std::path::Path::new(candidate).exists() {
251                return Some(candidate.to_string());
252            }
253        }
254
255        // 4. Fallback: `which` search
256        for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
257            if let Ok(output) = std::process::Command::new("which").arg(name).output() {
258                if output.status.success() {
259                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
260                    if !path.is_empty() && std::path::Path::new(&path).exists() {
261                        return Some(path);
262                    }
263                }
264            }
265        }
266
267        None
268    }
269
270    /// Locate browser profile directory.
271    fn real_profile_dir(profile_dir: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
272        // 1. Check explicit env var first (already done in Level 0)
273        if let Some(dir) = profile_dir {
274            if dir.exists() {
275                return Some(dir);
276            }
277        }
278
279        let home = std::env::var("HOME").ok()?;
280
281        // 2. Try to match profile to the default browser
282        #[cfg(target_os = "macos")]
283        if let Some(bundle) = default_browser_bundle() {
284            if let Some(suffix) = browser_profile_suffix(&bundle) {
285                let profile = std::path::PathBuf::from(&home)
286                    .join("Library/Application Support")
287                    .join(suffix);
288                if profile.exists() {
289                    return Some(profile);
290                }
291            }
292        }
293
294        // 3. Fallback: check common profile directories (prioritize Chrome)
295        let common_profiles = [
296            "Google/Chrome",
297            "Dia",
298            "Chromium",
299            "BraveSoftware/Brave-Browser",
300            "Microsoft Edge",
301            "Arc",
302        ];
303
304        for suffix in &common_profiles {
305            let profile = std::path::PathBuf::from(&home)
306                .join("Library/Application Support")
307                .join(suffix);
308            if profile.exists() {
309                return Some(profile);
310            }
311        }
312
313        None
314    }
315
316    /// Clean profile lock files.
317    fn clean_profile_locks(profile_dir: &std::path::Path) {
318        let lock_files = [
319            "SingletonLock",
320            "SingletonSocket",
321            "SingletonCookie",
322            "DevToolsActivePort",
323        ];
324        for name in &lock_files {
325            let path = profile_dir.join(name);
326            if path.exists() {
327                let _ = std::fs::remove_file(&path);
328            }
329        }
330    }
331
332    /// Check if the browser is alive by probing the given port.
333    pub fn is_alive(cdp_port: u16) -> bool {
334        Self::probe_port(cdp_port)
335    }
336
337    /// Get the path to the browser state file.
338    fn state_path() -> PathBuf {
339        Self::home_dir().join(".gthings/browser.json")
340    }
341
342    /// Get home directory.
343    fn home_dir() -> PathBuf {
344        std::env::var("HOME")
345            .map(PathBuf::from)
346            .unwrap_or_else(|_| PathBuf::from("/tmp"))
347    }
348
349    /// Get the path to the browser state file (public for CLI use).
350    pub fn state_file_path() -> PathBuf {
351        Self::state_path()
352    }
353
354    /// Find existing browser via state file and port probe.
355    pub async fn find_existing(cdp_port: u16) -> Option<Self> {
356        let state_path = Self::state_path();
357        if !state_path.exists() {
358            return None;
359        }
360
361        let path = state_path.clone();
362        let state_str: String =
363            tokio::task::spawn_blocking(move || std::fs::read_to_string(&path).ok())
364                .await
365                .unwrap_or(None)?;
366        let state: BrowserState = serde_json::from_str(&state_str).ok()?;
367
368        if !Self::is_process_alive(state.pid) {
369            tracing::warn!("Browser pid={} is dead, removing stale state", state.pid);
370            let path = state_path.clone();
371            tokio::task::spawn_blocking(move || {
372                let _ = std::fs::remove_file(&path);
373            })
374            .await
375            .ok();
376            return None;
377        }
378
379        if !Self::probe_port(cdp_port) {
380            tracing::warn!(
381                "Browser port {} not responding, removing stale state",
382                cdp_port
383            );
384            let path = state_path.clone();
385            tokio::task::spawn_blocking(move || {
386                let _ = std::fs::remove_file(&path);
387            })
388            .await
389            .ok();
390            return None;
391        }
392
393        let ws_url = Self::fetch_ws_url(cdp_port).await?;
394
395        tracing::info!("Found existing browser (pid={})", state.pid);
396
397        Some(Browser { ws_url, cdp_port })
398    }
399
400    /// Fetch WebSocket debugger URL from /json/version.
401    async fn fetch_ws_url(cdp_port: u16) -> Option<String> {
402        let url = format!("http://127.0.0.1:{cdp_port}/json/version");
403        let resp = reqwest::get(&url).await.ok()?;
404        let json: serde_json::Value = resp.json().await.ok()?;
405        json["webSocketDebuggerUrl"].as_str().map(|s| s.to_string())
406    }
407
408    /// Check if a process is alive by pid.
409    fn is_process_alive(pid: u32) -> bool {
410        std::process::Command::new("kill")
411            .arg("-0")
412            .arg(pid.to_string())
413            .output()
414            .map(|output| output.status.success())
415            .unwrap_or(false)
416    }
417
418    /// Probe port to see if it's accepting connections.
419    /// Tries IPv4 first, then IPv6.
420    fn probe_port(cdp_port: u16) -> bool {
421        let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
422        for addr in &addrs {
423            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
424                if std::net::TcpStream::connect_timeout(
425                    &parsed,
426                    std::time::Duration::from_millis(500),
427                )
428                .is_ok()
429                {
430                    return true;
431                }
432            }
433        }
434        false
435    }
436
437    /// Get the browser pid from the state file.
438    pub async fn pid(&self) -> Option<u32> {
439        let state_path = Self::state_path();
440        tokio::task::spawn_blocking(move || {
441            if state_path.exists() {
442                if let Ok(state_str) = std::fs::read_to_string(&state_path) {
443                    if let Ok(state) = serde_json::from_str::<BrowserState>(&state_str) {
444                        return Some(state.pid);
445                    }
446                }
447            }
448            None
449        })
450        .await
451        .unwrap_or(None)
452    }
453}
454
455impl Drop for Browser {
456    fn drop(&mut self) {
457        // Browser stays alive — it's persistent
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_probe_port_no_server() {
467        assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
468    }
469
470    #[test]
471    fn test_state_path_ends_correctly() {
472        let path = Browser::state_path();
473        assert!(path.ends_with(".gthings/browser.json"));
474    }
475
476    #[test]
477    fn test_find_chrome_returns_some_or_none() {
478        let result = Browser::find_chrome(None);
479        // Either finds Chrome or returns None — don't panic either way
480        if let Some(path) = result {
481            assert!(
482                std::path::Path::new(&path).exists(),
483                "Chrome path should exist: {}",
484                path
485            );
486        }
487    }
488
489    #[test]
490    fn test_error_types_compile() {
491        let _err = CdpError::LaunchFailed("test".into());
492        let _err = CdpError::NoWsUrl;
493        let _err = CdpError::Timeout(1000);
494    }
495}