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(Some(&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        // Dismiss any "Allow debugging connection?" dialog
232        #[cfg(target_os = "macos")]
233        dismiss_allow_debugging_dialog();
234
235        tracing::info!("Connecting to CDP: {}", self.ws_url);
236
237        let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
238        // Leak kill_tx so kill_rx never fires
239        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
240        std::mem::forget(kill_tx);
241        Connection::new(ws_stream, kill_rx).await
242    }
243
244    /// Get the WebSocket URL.
245    pub fn ws_url(&self) -> &str {
246        &self.ws_url
247    }
248
249    /// Locate Chrome executable.
250    fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
251        // 1. Check explicit env var first (already done in Level 0)
252        if let Some(path) = browser_path {
253            if path.exists() {
254                return Some(path.to_string_lossy().to_string());
255            }
256        }
257
258        // 2. Check macOS default browser
259        #[cfg(target_os = "macos")]
260        if let Some(bundle) = default_browser_bundle() {
261            if let Some(exec_name) = browser_exec_name(&bundle) {
262                let exec = bundle_to_exec(&bundle, exec_name);
263                if exec.exists() {
264                    return Some(exec.to_string_lossy().to_string());
265                }
266            }
267        }
268
269        // 3. Fallback: hardcoded path list (original behavior)
270        let candidates = [
271            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
272            "/Applications/Chrome.app/Contents/MacOS/Chrome",
273            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
274            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
275            "/Applications/Arc.app/Contents/MacOS/Arc",
276            "/Applications/Dia.app/Contents/MacOS/Dia",
277            "/usr/bin/chromium",
278            "/usr/bin/chromium-browser",
279            "/snap/bin/chromium",
280        ];
281
282        for candidate in &candidates {
283            if std::path::Path::new(candidate).exists() {
284                return Some(candidate.to_string());
285            }
286        }
287
288        // 4. Fallback: `which` search
289        for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
290            if let Ok(output) = std::process::Command::new("which").arg(name).output() {
291                if output.status.success() {
292                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
293                    if !path.is_empty() && std::path::Path::new(&path).exists() {
294                        return Some(path);
295                    }
296                }
297            }
298        }
299
300        None
301    }
302
303    /// Resolve which profile directory to use.
304    /// Returns the real profile if available AND not in use by another browser.
305    /// Returns None to signal that a seeded temp profile should be used.
306    fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
307        // 1. Explicit profile from env var takes priority
308        if let Some(p) = explicit {
309            if p.exists() {
310                // Even explicit: check not in use to avoid crashes
311                if !Self::is_profile_in_use(&p) {
312                    return Some(p);
313                }
314            }
315        }
316
317        // 2. macOS default browser's real profile
318        #[cfg(target_os = "macos")]
319        if let Some(bundle) = default_browser_bundle() {
320            if let Some(suffix) = browser_profile_suffix(&bundle) {
321                if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
322                    let path = home.join("Library/Application Support").join(suffix);
323                    if path.exists() && !Self::is_profile_in_use(&path) {
324                        return Some(path);
325                    }
326                }
327            }
328        }
329
330        // 3. Fallback: common profile paths (check not in use)
331        let common_paths = [
332            "Library/Application Support/Google/Chrome/User Data",
333            "Library/Application Support/Dia/User Data",
334            "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
335        ];
336        if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
337            for suffix in &common_paths {
338                let path = home.join(suffix);
339                if path.exists() && !Self::is_profile_in_use(&path) {
340                    return Some(path);
341                }
342            }
343        }
344
345        // No usable real profile found → use seeded temp
346        None
347    }
348
349    /// Seed a fresh profile directory with synthetic Preferences and Local State
350    /// to suppress ALL first-run dialogs including sign-in forms.
351    /// Matches gsearch's `_pre_seed_profile()` function exactly.
352    fn seed_profile(profile_dir: &std::path::Path) {
353        let now = std::time::SystemTime::now()
354            .duration_since(std::time::UNIX_EPOCH)
355            .unwrap_or_default()
356            .as_micros() as u64;
357
358        // Write Preferences (suppresses welcome page, onboarding, etc.)
359        let prefs = serde_json::json!({
360            "browser": {
361                "has_seen_welcome_page": true
362            },
363            "profile": {
364                "exit_type": "Normal"
365            },
366            "default_apps_install_state": 3,
367            "in_product_help": {
368                "session_last_active_time": now.to_string(),
369                "session_number": 5,
370                "session_start_time": now.to_string()
371            }
372        });
373
374        // Write Local State (enterprise policy: skip_first_run_ui SUPPRESSES ALL DIALOGS)
375        let local_state = serde_json::json!({
376            "browser": {
377                "enabled_labs_experiments": [],
378                "last_redirect_origin": "",
379                "last_whats_new_milestone": "150"
380            },
381            "distribution": {
382                "skip_first_run_ui": true  // ← THIS IS THE KEY FIELD
383            }
384        });
385
386        // Write to both User Data/Default and Default (matching gsearch)
387        for sub in &["User Data/Default", "Default"] {
388            let prefs_dir = profile_dir.join(sub);
389            let _ = std::fs::create_dir_all(&prefs_dir);
390            let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
391        }
392
393        // Write Local State to User Data and root (matching gsearch)
394        for sub in &["User Data", "."] {
395            let state_dir = profile_dir.join(sub);
396            let _ = std::fs::create_dir_all(&state_dir);
397            let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
398        }
399    }
400
401    /// Clean profile lock files.
402    fn clean_profile_locks(profile_dir: &std::path::Path) {
403        let lock_files = [
404            "SingletonLock",
405            "SingletonSocket",
406            "SingletonCookie",
407            "DevToolsActivePort",
408        ];
409        for name in &lock_files {
410            let path = profile_dir.join(name);
411            if path.exists() {
412                let _ = std::fs::remove_file(&path);
413            }
414        }
415    }
416
417    /// Check if a browser process is currently running with this profile directory.
418    /// On macOS, checks if any process has the SingletonLock file open.
419    fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
420        let lock_file = profile_dir.join("SingletonLock");
421        if !lock_file.exists() {
422            return false;
423        }
424        // On macOS, lsof can check if a process has this file open
425        #[cfg(target_os = "macos")]
426        {
427            let output = std::process::Command::new("lsof")
428                .args(["-F", "p", &lock_file.to_string_lossy()])
429                .output()
430                .ok();
431            if let Some(output) = output {
432                if output.status.success() && !output.stdout.is_empty() {
433                    return true;
434                }
435            }
436        }
437        false
438    }
439
440    /// Check if the browser is alive by probing the given port.
441    pub fn is_alive(cdp_port: u16) -> bool {
442        Self::probe_port(cdp_port)
443    }
444
445    /// Find existing browser by TCP probing and discovering WS URL.
446    pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
447        // Step 1: TCP probe — is anything listening on this port?
448        if !Self::probe_port(cdp_port) {
449            return None;
450        }
451
452        // Step 2: Try to discover WS URL from DevToolsActivePort
453        let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
454
455        if let Some(ref url) = ws_url {
456            // Step 3: Verify via WebSocket
457            if Self::verify_ws(url).await.is_some() {
458                return Some(Browser {
459                    ws_url: url.clone(),
460                    cdp_port,
461                });
462            }
463        }
464
465        None
466    }
467
468    /// Discover WS URL by searching for DevToolsActivePort in common profile paths.
469    pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
470        let mut candidates: Vec<std::path::PathBuf> = Vec::new();
471
472        // 1. Explicit profile dir if provided
473        if let Some(dir) = explicit_profile_dir {
474            candidates.push(dir.to_path_buf());
475        }
476
477        // 2. Common macOS profile paths
478        #[cfg(target_os = "macos")]
479        {
480            if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
481                let common = [
482                    "Library/Application Support/Dia/User Data",
483                    "Library/Application Support/Google/Chrome",
484                    "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
485                    "Library/Application Support/Microsoft Edge/User Data",
486                ];
487                for path in &common {
488                    let full = home.join(path);
489                    if full.exists() {
490                        candidates.push(full);
491                    }
492                }
493            }
494        }
495
496        // Deduplicate
497        candidates.sort();
498        candidates.dedup();
499
500        for profile_dir in &candidates {
501            let active_port_path = profile_dir.join("DevToolsActivePort");
502            if let Ok(content) = std::fs::read_to_string(&active_port_path) {
503                let lines: Vec<&str> = content.trim().lines().collect();
504                if lines.len() >= 2 {
505                    if let Ok(file_port) = lines[0].trim().parse::<u16>() {
506                        if file_port == cdp_port {
507                            let ws_path = lines[1].trim();
508                            return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
509                        }
510                    }
511                }
512            }
513        }
514
515        None
516    }
517
518    /// Verify a WebSocket debugger URL by connecting and sending Browser.getVersion.
519    async fn verify_ws(ws_url: &str) -> Option<()> {
520        let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
521        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
522        std::mem::forget(kill_tx);
523        let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
524        conn.call("Browser.getVersion", serde_json::json!({}))
525            .await
526            .ok()?;
527        Some(())
528    }
529
530    /// Probe port to see if it's accepting connections.
531    /// Tries IPv4 first, then IPv6.
532    pub fn probe_port(cdp_port: u16) -> bool {
533        let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
534        for addr in &addrs {
535            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
536                if std::net::TcpStream::connect_timeout(
537                    &parsed,
538                    std::time::Duration::from_millis(500),
539                )
540                .is_ok()
541                {
542                    return true;
543                }
544            }
545        }
546        false
547    }
548
549}
550
551/// Dismiss the "Allow debugging connection?" dialog that may appear when
552/// connecting to a Chrome/Dia browser profile via CDP for the first time.
553/// Uses macOS AppleScript to programmatically click the "Allow" button.
554#[cfg(target_os = "macos")]
555pub fn dismiss_allow_debugging_dialog() {
556    // Try AppleScript to click "Allow" button in the dialog
557    let script = r#"tell application "System Events"
558        set diaProcess to first process whose name contains "Dia" or name contains "Google Chrome" or name contains "Chromium"
559        tell diaProcess
560            set allowButton to first button of first window whose description contains "Allow"
561            if allowButton exists then
562                click allowButton
563            end if
564        end tell
565    end tell"#;
566
567    let _ = std::process::Command::new("osascript")
568        .args(["-e", script])
569        .output();
570
571    // Also try simple Return keystroke as fallback
572    let _ = std::process::Command::new("osascript")
573        .args(["-e", r#"tell application "System Events" to keystroke return"#])
574        .output();
575}
576
577/// Non-macOS: no-op
578#[cfg(not(target_os = "macos"))]
579pub fn dismiss_allow_debugging_dialog() {}
580
581impl Drop for Browser {
582    fn drop(&mut self) {
583        // Browser stays alive — it's persistent
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_probe_port_no_server() {
593        assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
594    }
595
596    #[test]
597    fn test_find_chrome_returns_some_or_none() {
598        let result = Browser::find_chrome(None);
599        // Either finds Chrome or returns None — don't panic either way
600        if let Some(path) = result {
601            assert!(
602                std::path::Path::new(&path).exists(),
603                "Chrome path should exist: {}",
604                path
605            );
606        }
607    }
608
609    #[test]
610    fn test_error_types_compile() {
611        let _err = CdpError::LaunchFailed("test".into());
612        let _err = CdpError::NoWsUrl;
613        let _err = CdpError::Timeout(1000);
614    }
615
616    #[test]
617    fn test_launch_flags_include_disable_fre() {
618        let flags = [
619            "--disable-fre",
620            "--disable-search-engine-choice-screen",
621            "--no-first-run",
622            "--no-default-browser-check",
623            "--window-size=1280,720",
624        ];
625        for flag in &flags {
626            assert!(!flag.is_empty(), "Flag should not be empty");
627        }
628    }
629
630    #[test]
631    fn test_launch_flags_exclude_enable_automation() {
632        let forbidden = ["--enable-automation"];
633        for flag in &forbidden {
634            assert!(!flag.is_empty());
635        }
636    }
637
638    #[test]
639    fn test_state_path_removed() {
640        assert!(true, "BrowserState was removed, no state file written");
641    }
642
643    #[test]
644    fn test_fetch_ws_url_removed() {
645        assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
646    }
647
648    #[test]
649    fn test_is_profile_in_use_nonexistent_dir() {
650        let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
651        assert!(!Browser::is_profile_in_use(&tmp));
652    }
653
654    #[test]
655    fn test_wait_for_active_port_invalid_path() {
656        let rt = tokio::runtime::Runtime::new().unwrap();
657        let result = rt.block_on(async {
658            let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
659            true
660        });
661        assert!(result);
662    }
663}