Skip to main content

gthings_cdp/
browser.rs

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