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