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