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
9const CDP_PORT: u16 = 9222;
10
11/// A persistent Chrome browser instance. Stays alive after Drop.
12pub struct Browser {
13    ws_url: String,
14}
15
16/// Saved browser state for reuse across commands.
17#[derive(Serialize, Deserialize)]
18struct BrowserState {
19    pid: u32,
20}
21
22impl Browser {
23    /// Launch or reuse a persistent Chrome browser on port 9222.
24    #[allow(clippy::result_large_err)]
25    pub async fn launch() -> Result<Self> {
26        tracing::info!("Checking for existing browser on port 9222");
27        if let Some(browser) = Self::find_existing().await {
28            tracing::info!("Found existing browser, reusing");
29            return Ok(browser);
30        }
31        tracing::info!("No existing browser found, launching new one");
32
33        let chrome_path = Self::find_chrome()
34            .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
35
36        let port = CDP_PORT;
37
38        // Use real profile to avoid onboarding / first-run dialogs
39        let profile_dir = Self::real_profile_dir()
40            .unwrap_or_else(|| std::path::PathBuf::from(format!("/tmp/cdp-profile-{}", port)));
41        {
42            let dir = profile_dir.clone();
43            tokio::task::spawn_blocking(move || {
44                Self::clean_profile_locks(&dir);
45            })
46            .await
47            .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
48        }
49
50        tracing::info!(
51            "Launching Chrome on port {} with profile {:?}",
52            port,
53            profile_dir
54        );
55
56        let mut cmd = Command::new(&chrome_path);
57        cmd.arg(format!("--remote-debugging-port={}", port))
58            .arg("--no-first-run")
59            .arg("--remote-allow-origins=*")
60            .arg(format!("--user-data-dir={}", profile_dir.display()))
61            .arg("about:blank")
62            .stderr(Stdio::piped())
63            .stdout(Stdio::null())
64            .stdin(Stdio::null());
65
66        let mut child = cmd
67            .spawn()
68            .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
69
70        let stderr = child
71            .stderr
72            .take()
73            .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
74
75        let reader = BufReader::new(stderr);
76        let mut ws_url = None;
77
78        for line in reader.lines() {
79            let line = line.map_err(|e| {
80                CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
81            })?;
82            tracing::debug!("Chrome: {}", line);
83
84            // "DevTools listening on ws://127.0.0.1:9222/..."
85            if let Some(url) = line.strip_prefix("DevTools listening on ") {
86                ws_url = Some(url.trim().to_string());
87                break;
88            }
89        }
90
91        let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
92        let pid = child.id();
93
94        let state = BrowserState { pid };
95        let state_json = serde_json::to_string(&state)?;
96        // Atomic write: temp file then rename
97        let final_path = Self::state_path();
98        let tmp_path = final_path.with_extension("json.tmp");
99        let json = state_json;
100        tokio::task::spawn_blocking(move || {
101            if let Some(parent) = final_path.parent() {
102                let _ = std::fs::create_dir_all(parent);
103            }
104            std::fs::write(&tmp_path, &json)
105                .map_err(|e| CdpError::LaunchFailed(format!("Cannot save state: {e}")))?;
106            std::fs::rename(&tmp_path, &final_path)
107                .map_err(|e| CdpError::LaunchFailed(format!("Cannot commit state: {e}")))?;
108            Ok::<_, CdpError>(())
109        })
110        .await
111        .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))??;
112
113        tracing::info!("Launched persistent browser (pid={})", pid);
114
115        // Detach — browser stays alive after Drop
116        drop(child);
117
118        Ok(Browser { ws_url })
119    }
120
121    /// Connect to CDP WebSocket.
122    pub async fn connect(&self) -> Result<Connection> {
123        tracing::info!("Connecting to CDP: {}", self.ws_url);
124
125        let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
126        // Leak kill_tx so kill_rx never fires
127        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
128        std::mem::forget(kill_tx);
129        Connection::new(ws_stream, kill_rx).await
130    }
131
132    /// Get the WebSocket URL.
133    pub fn ws_url(&self) -> &str {
134        &self.ws_url
135    }
136
137    /// Locate Chrome executable.
138    fn find_chrome() -> Option<String> {
139        let candidates = [
140            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
141            "/Applications/Chrome.app/Contents/MacOS/Chrome",
142            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
143            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
144            "/usr/bin/chromium",
145            "/usr/bin/chromium-browser",
146            "/snap/bin/chromium",
147            "/Applications/Dia.app/Contents/MacOS/Dia",
148        ];
149
150        for path in &candidates {
151            if std::path::Path::new(path).exists() {
152                return Some(path.to_string());
153            }
154        }
155
156        if let Ok(path) = std::process::Command::new("which")
157            .arg("google-chrome")
158            .arg("chromium")
159            .arg("google-chrome-stable")
160            .arg("dia")
161            .output()
162        {
163            let output = String::from_utf8_lossy(&path.stdout);
164            for line in output.lines() {
165                if !line.is_empty() {
166                    return Some(line.to_string());
167                }
168            }
169        }
170
171        None
172    }
173
174    /// Locate browser profile directory.
175    fn real_profile_dir() -> Option<std::path::PathBuf> {
176        let home = std::env::var("HOME").ok()?;
177        let candidates = [
178            std::path::PathBuf::from(&home).join("Library/Application Support/Dia/User Data"),
179            std::path::PathBuf::from(&home).join("Library/Application Support/Google/Chrome"),
180            std::path::PathBuf::from(&home).join("Library/Application Support/Chromium"),
181        ];
182
183        for candidate in &candidates {
184            if candidate.exists() {
185                return Some(candidate.clone());
186            }
187        }
188        None
189    }
190
191    /// Clean profile lock files.
192    fn clean_profile_locks(profile_dir: &std::path::Path) {
193        let lock_files = [
194            "SingletonLock",
195            "SingletonSocket",
196            "SingletonCookie",
197            "DevToolsActivePort",
198        ];
199        for name in &lock_files {
200            let path = profile_dir.join(name);
201            if path.exists() {
202                let _ = std::fs::remove_file(&path);
203            }
204        }
205    }
206
207    /// Check if the browser is alive by probing port 9222.
208    pub fn is_alive() -> bool {
209        Self::probe_port()
210    }
211
212    /// Get the path to the browser state file.
213    fn state_path() -> PathBuf {
214        Self::home_dir().join(".gthings/browser.json")
215    }
216
217    /// Get home directory.
218    fn home_dir() -> PathBuf {
219        std::env::var("HOME")
220            .map(PathBuf::from)
221            .unwrap_or_else(|_| PathBuf::from("/tmp"))
222    }
223
224    /// Get the path to the browser state file (public for CLI use).
225    pub fn state_file_path() -> PathBuf {
226        Self::state_path()
227    }
228
229    /// Find existing browser via state file and port probe.
230    pub async fn find_existing() -> Option<Self> {
231        let state_path = Self::state_path();
232        if !state_path.exists() {
233            return None;
234        }
235
236        let path = state_path.clone();
237        let state_str: String =
238            tokio::task::spawn_blocking(move || std::fs::read_to_string(&path).ok())
239                .await
240                .unwrap_or(None)?;
241        let state: BrowserState = serde_json::from_str(&state_str).ok()?;
242
243        if !Self::is_process_alive(state.pid) {
244            tracing::warn!("Browser pid={} is dead, removing stale state", state.pid);
245            let path = state_path.clone();
246            tokio::task::spawn_blocking(move || {
247                let _ = std::fs::remove_file(&path);
248            })
249            .await
250            .ok();
251            return None;
252        }
253
254        if !Self::probe_port() {
255            tracing::warn!(
256                "Browser port {} not responding, removing stale state",
257                CDP_PORT
258            );
259            let path = state_path.clone();
260            tokio::task::spawn_blocking(move || {
261                let _ = std::fs::remove_file(&path);
262            })
263            .await
264            .ok();
265            return None;
266        }
267
268        let ws_url = Self::fetch_ws_url().await?;
269
270        tracing::info!("Found existing browser (pid={})", state.pid);
271
272        Some(Browser { ws_url })
273    }
274
275    /// Fetch WebSocket debugger URL from /json/version.
276    async fn fetch_ws_url() -> Option<String> {
277        let url = format!("http://127.0.0.1:{}/json/version", CDP_PORT);
278        let resp = reqwest::get(&url).await.ok()?;
279        let json: serde_json::Value = resp.json().await.ok()?;
280        json["webSocketDebuggerUrl"].as_str().map(|s| s.to_string())
281    }
282
283    /// Check if a process is alive by pid.
284    fn is_process_alive(pid: u32) -> bool {
285        std::process::Command::new("kill")
286            .arg("-0")
287            .arg(pid.to_string())
288            .output()
289            .map(|output| output.status.success())
290            .unwrap_or(false)
291    }
292
293    /// Probe port 9222 to see if it's accepting connections.
294    /// Tries IPv4 first, then IPv6.
295    fn probe_port() -> bool {
296        let addrs = [
297            format!("127.0.0.1:{}", CDP_PORT),
298            format!("[::1]:{}", CDP_PORT),
299        ];
300        for addr in &addrs {
301            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
302                if std::net::TcpStream::connect_timeout(
303                    &parsed,
304                    std::time::Duration::from_millis(500),
305                )
306                .is_ok()
307                {
308                    return true;
309                }
310            }
311        }
312        false
313    }
314
315    /// Get the browser pid from the state file.
316    pub async fn pid(&self) -> Option<u32> {
317        let state_path = Self::state_path();
318        tokio::task::spawn_blocking(move || {
319            if state_path.exists() {
320                if let Ok(state_str) = std::fs::read_to_string(&state_path) {
321                    if let Ok(state) = serde_json::from_str::<BrowserState>(&state_str) {
322                        return Some(state.pid);
323                    }
324                }
325            }
326            None
327        })
328        .await
329        .unwrap_or(None)
330    }
331}
332
333impl Drop for Browser {
334    fn drop(&mut self) {
335        // Browser stays alive — it's persistent
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_probe_port_no_server() {
345        assert_eq!(Browser::is_alive(), Browser::probe_port());
346    }
347
348    #[test]
349    fn test_state_path_ends_correctly() {
350        let path = Browser::state_path();
351        assert!(path.ends_with(".gthings/browser.json"));
352    }
353
354    #[test]
355    fn test_find_chrome_returns_some_or_none() {
356        let result = Browser::find_chrome();
357        // Either finds Chrome or returns None — don't panic either way
358        if let Some(path) = result {
359            assert!(
360                std::path::Path::new(&path).exists(),
361                "Chrome path should exist: {}",
362                path
363            );
364        }
365    }
366
367    #[test]
368    fn test_error_types_compile() {
369        let _err = CdpError::LaunchFailed("test".into());
370        let _err = CdpError::NoWsUrl;
371        let _err = CdpError::Timeout(1000);
372    }
373}