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