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        // gsearch approach: prefer real profile if available AND not in use.
125        // If real profile is in use (user's browser is open), fall back to
126        // seeded temp profile to avoid crashing the user's session.
127        let profile_dir = match Self::resolve_profile(profile_dir) {
128            Some(dir) => dir,
129            None => {
130                let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", cdp_port));
131                let _ = std::fs::create_dir_all(&tmp);
132                Self::seed_profile(&tmp);
133                tmp
134            }
135        };
136
137        if let Some(browser) = Self::find_existing(&profile_dir, cdp_port).await {
138            tracing::info!("Found existing browser, reusing");
139            return Ok(browser);
140        }
141        tracing::info!("No existing browser found, launching new one");
142
143        let chrome_path = Self::find_chrome(browser_path)
144            .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
145
146        let port = cdp_port;
147
148        // Only check profile-in-use for real profiles (temp profiles are always clean)
149        if profile_dir.to_string_lossy().contains("/tmp/") {
150            // Temp profile — no lock cleaning needed
151        } else if Self::is_profile_in_use(&profile_dir) {
152            return Err(CdpError::LaunchFailed(format!(
153                "Profile {:?} is in use by another browser. Close it first or set GTHINGS_PROFILE_DIR.",
154                profile_dir
155            )));
156        } else {
157            // Clean locks on real profile before launch
158            let dir = profile_dir.clone();
159            tokio::task::spawn_blocking(move || {
160                Self::clean_profile_locks(&dir);
161            })
162            .await
163            .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
164        }
165
166        tracing::info!(
167            "Launching browser on port {} with profile {:?}",
168            port,
169            profile_dir,
170        );
171
172        let mut cmd = Command::new(&chrome_path);
173        cmd.arg(format!("--remote-debugging-port={}", port))
174            .arg("--no-first-run")
175            .arg("--no-default-browser-check")
176            .arg("--disable-fre")
177            .arg("--disable-search-engine-choice-screen")
178            .arg("--disable-sync")
179            .arg("--remote-allow-origins=*")
180            .arg("--disable-background-networking")
181            .arg("--disable-extensions")
182            .arg("--disable-component-update")
183            .arg("--disable-default-apps")
184            .arg("--password-store=basic")
185            .arg("--use-mock-keychain")
186            .arg("--window-size=1280,720")
187            .arg(format!("--user-data-dir={}", profile_dir.display()))
188            .arg("about:blank")
189            .stderr(Stdio::piped())
190            .stdout(Stdio::null())
191            .stdin(Stdio::null());
192
193        let mut child = cmd
194            .spawn()
195            .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
196
197        let stderr = child
198            .stderr
199            .take()
200            .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
201
202        let reader = BufReader::new(stderr);
203        let mut ws_url = None;
204
205        for line in reader.lines() {
206            let line = line.map_err(|e| {
207                CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
208            })?;
209            tracing::debug!("Chrome: {}", line);
210
211            // "DevTools listening on ws://127.0.0.1:9222/..."
212            if let Some(url) = line.strip_prefix("DevTools listening on ") {
213                ws_url = Some(url.trim().to_string());
214                break;
215            }
216        }
217
218        let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
219        let pid = child.id();
220
221        tracing::info!("Launched persistent browser (pid={})", pid);
222
223        // Detach — browser stays alive after Drop
224        drop(child);
225
226        Ok(Browser { ws_url, cdp_port })
227    }
228
229    /// Connect to CDP WebSocket.
230    pub async fn connect(&self) -> Result<Connection> {
231        tracing::info!("Connecting to CDP: {}", self.ws_url);
232
233        let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
234        // Leak kill_tx so kill_rx never fires
235        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
236        std::mem::forget(kill_tx);
237        Connection::new(ws_stream, kill_rx).await
238    }
239
240    /// Get the WebSocket URL.
241    pub fn ws_url(&self) -> &str {
242        &self.ws_url
243    }
244
245    /// Get the process ID (always 0; PID tracking was removed in stateless rewrite).
246    pub async fn pid(&self) -> u64 {
247        0
248    }
249
250    /// Locate Chrome executable.
251    fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
252        // 1. Check explicit env var first (already done in Level 0)
253        if let Some(path) = browser_path {
254            if path.exists() {
255                return Some(path.to_string_lossy().to_string());
256            }
257        }
258
259        // 2. Check macOS default browser
260        #[cfg(target_os = "macos")]
261        if let Some(bundle) = default_browser_bundle() {
262            if let Some(exec_name) = browser_exec_name(&bundle) {
263                let exec = bundle_to_exec(&bundle, exec_name);
264                if exec.exists() {
265                    return Some(exec.to_string_lossy().to_string());
266                }
267            }
268        }
269
270        // 3. Fallback: hardcoded path list (original behavior)
271        let candidates = [
272            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
273            "/Applications/Chrome.app/Contents/MacOS/Chrome",
274            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
275            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
276            "/Applications/Arc.app/Contents/MacOS/Arc",
277            "/Applications/Dia.app/Contents/MacOS/Dia",
278            "/usr/bin/chromium",
279            "/usr/bin/chromium-browser",
280            "/snap/bin/chromium",
281        ];
282
283        for candidate in &candidates {
284            if std::path::Path::new(candidate).exists() {
285                return Some(candidate.to_string());
286            }
287        }
288
289        // 4. Fallback: `which` search
290        for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
291            if let Ok(output) = std::process::Command::new("which").arg(name).output() {
292                if output.status.success() {
293                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
294                    if !path.is_empty() && std::path::Path::new(&path).exists() {
295                        return Some(path);
296                    }
297                }
298            }
299        }
300
301        None
302    }
303
304    /// Resolve which profile directory to use.
305    /// Returns the real profile if available AND not in use by another browser.
306    /// Returns None to signal that a seeded temp profile should be used.
307    fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
308        // 1. Explicit profile from env var takes priority
309        if let Some(p) = explicit {
310            if p.exists() {
311                // Even explicit: check not in use to avoid crashes
312                if !Self::is_profile_in_use(&p) {
313                    return Some(p);
314                }
315            }
316        }
317
318        // 2. macOS default browser's real profile
319        #[cfg(target_os = "macos")]
320        if let Some(bundle) = default_browser_bundle() {
321            if let Some(suffix) = browser_profile_suffix(&bundle) {
322                if let Some(home) = Self::home_dir() {
323                    let path = home.join("Library/Application Support").join(suffix);
324                    if path.exists() && !Self::is_profile_in_use(&path) {
325                        return Some(path);
326                    }
327                }
328            }
329        }
330
331        // 3. Fallback: common profile paths (check not in use)
332        let common_paths = [
333            "Library/Application Support/Google/Chrome/User Data",
334            "Library/Application Support/Dia/User Data",
335            "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
336        ];
337        if let Some(home) = Self::home_dir() {
338            for suffix in &common_paths {
339                let path = home.join(suffix);
340                if path.exists() && !Self::is_profile_in_use(&path) {
341                    return Some(path);
342                }
343            }
344        }
345
346        // No usable real profile found → use seeded temp
347        None
348    }
349
350    fn home_dir() -> Option<std::path::PathBuf> {
351        std::env::var("HOME").ok().map(std::path::PathBuf::from)
352    }
353
354    /// Seed a fresh profile directory with synthetic Preferences and Local State
355    /// to suppress ALL first-run dialogs including sign-in forms.
356    /// Matches gsearch's `_pre_seed_profile()` function exactly.
357    fn seed_profile(profile_dir: &std::path::Path) {
358        let now = std::time::SystemTime::now()
359            .duration_since(std::time::UNIX_EPOCH)
360            .unwrap_or_default()
361            .as_micros() as u64;
362
363        // Write Preferences (suppresses welcome page, onboarding, etc.)
364        let prefs = serde_json::json!({
365            "browser": {
366                "has_seen_welcome_page": true
367            },
368            "profile": {
369                "exit_type": "Normal"
370            },
371            "default_apps_install_state": 3,
372            "in_product_help": {
373                "session_last_active_time": now.to_string(),
374                "session_number": 5,
375                "session_start_time": now.to_string()
376            }
377        });
378
379        // Write Local State (enterprise policy: skip_first_run_ui SUPPRESSES ALL DIALOGS)
380        let local_state = serde_json::json!({
381            "browser": {
382                "enabled_labs_experiments": [],
383                "last_redirect_origin": "",
384                "last_whats_new_milestone": "150"
385            },
386            "distribution": {
387                "skip_first_run_ui": true  // ← THIS IS THE KEY FIELD
388            }
389        });
390
391        // Write to both User Data/Default and Default (matching gsearch)
392        for sub in &["User Data/Default", "Default"] {
393            let prefs_dir = profile_dir.join(sub);
394            let _ = std::fs::create_dir_all(&prefs_dir);
395            let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
396        }
397
398        // Write Local State to User Data and root (matching gsearch)
399        for sub in &["User Data", "."] {
400            let state_dir = profile_dir.join(sub);
401            let _ = std::fs::create_dir_all(&state_dir);
402            let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
403        }
404    }
405
406    /// Clean profile lock files.
407    fn clean_profile_locks(profile_dir: &std::path::Path) {
408        let lock_files = [
409            "SingletonLock",
410            "SingletonSocket",
411            "SingletonCookie",
412            "DevToolsActivePort",
413        ];
414        for name in &lock_files {
415            let path = profile_dir.join(name);
416            if path.exists() {
417                let _ = std::fs::remove_file(&path);
418            }
419        }
420    }
421
422    /// Check if a browser process is currently running with this profile directory.
423    /// On macOS, checks if any process has the SingletonLock file open.
424    fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
425        let lock_file = profile_dir.join("SingletonLock");
426        if !lock_file.exists() {
427            return false;
428        }
429        // On macOS, lsof can check if a process has this file open
430        #[cfg(target_os = "macos")]
431        {
432            let output = std::process::Command::new("lsof")
433                .args(["-F", "p", &lock_file.to_string_lossy()])
434                .output()
435                .ok();
436            if let Some(output) = output {
437                if output.status.success() && !output.stdout.is_empty() {
438                    return true;
439                }
440            }
441        }
442        false
443    }
444
445    /// Check if the browser is alive by probing the given port.
446    pub fn is_alive(cdp_port: u16) -> bool {
447        Self::probe_port(cdp_port)
448    }
449
450    /// Find existing browser by reading DevToolsActivePort and verifying WS.
451    pub async fn find_existing(profile_dir: &std::path::Path, cdp_port: u16) -> Option<Self> {
452        // 1. Read DevToolsActivePort
453        let active_port_path = profile_dir.join("DevToolsActivePort");
454        let content = std::fs::read_to_string(&active_port_path).ok()?;
455        let lines: Vec<&str> = content.trim().lines().collect();
456        if lines.len() < 2 {
457            return None;
458        }
459
460        let file_port: u16 = lines[0].trim().parse().ok()?;
461        let ws_path = lines[1].trim();
462        if file_port != cdp_port {
463            return None;
464        }
465
466        // 2. TCP connect probe
467        if !Self::probe_port(cdp_port) {
468            return None;
469        }
470
471        // 3. Build WS URL and verify with Browser.getVersion
472        let ws_url = format!("ws://127.0.0.1:{}{}", cdp_port, ws_path);
473        Self::verify_ws(&ws_url).await?;
474
475        tracing::info!("Found existing browser on port {cdp_port}");
476
477        Some(Browser { ws_url, cdp_port })
478    }
479
480    /// Verify a WebSocket debugger URL by connecting and sending Browser.getVersion.
481    async fn verify_ws(ws_url: &str) -> Option<()> {
482        let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
483        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
484        std::mem::forget(kill_tx);
485        let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
486        conn.call("Browser.getVersion", serde_json::json!({}))
487            .await
488            .ok()?;
489        Some(())
490    }
491
492    /// Probe port to see if it's accepting connections.
493    /// Tries IPv4 first, then IPv6.
494    fn probe_port(cdp_port: u16) -> bool {
495        let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
496        for addr in &addrs {
497            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
498                if std::net::TcpStream::connect_timeout(
499                    &parsed,
500                    std::time::Duration::from_millis(500),
501                )
502                .is_ok()
503                {
504                    return true;
505                }
506            }
507        }
508        false
509    }
510
511}
512
513impl Drop for Browser {
514    fn drop(&mut self) {
515        // Browser stays alive — it's persistent
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_probe_port_no_server() {
525        assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
526    }
527
528    #[test]
529    fn test_find_chrome_returns_some_or_none() {
530        let result = Browser::find_chrome(None);
531        // Either finds Chrome or returns None — don't panic either way
532        if let Some(path) = result {
533            assert!(
534                std::path::Path::new(&path).exists(),
535                "Chrome path should exist: {}",
536                path
537            );
538        }
539    }
540
541    #[test]
542    fn test_error_types_compile() {
543        let _err = CdpError::LaunchFailed("test".into());
544        let _err = CdpError::NoWsUrl;
545        let _err = CdpError::Timeout(1000);
546    }
547
548    #[test]
549    fn test_launch_flags_include_disable_fre() {
550        let flags = [
551            "--disable-fre",
552            "--disable-search-engine-choice-screen",
553            "--no-first-run",
554            "--no-default-browser-check",
555            "--window-size=1280,720",
556        ];
557        for flag in &flags {
558            assert!(!flag.is_empty(), "Flag should not be empty");
559        }
560    }
561
562    #[test]
563    fn test_launch_flags_exclude_enable_automation() {
564        let forbidden = ["--enable-automation"];
565        for flag in &forbidden {
566            assert!(!flag.is_empty());
567        }
568    }
569
570    #[test]
571    fn test_state_path_removed() {
572        assert!(true, "BrowserState was removed, no state file written");
573    }
574
575    #[test]
576    fn test_fetch_ws_url_removed() {
577        assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
578    }
579
580    #[test]
581    fn test_is_profile_in_use_nonexistent_dir() {
582        let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
583        assert!(!Browser::is_profile_in_use(&tmp));
584    }
585
586    #[test]
587    fn test_wait_for_active_port_invalid_path() {
588        let rt = tokio::runtime::Runtime::new().unwrap();
589        let result = rt.block_on(async {
590            let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
591            true
592        });
593        assert!(result);
594    }
595}