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
11pub struct Browser {
13 ws_url: String,
14}
15
16#[derive(Serialize, Deserialize)]
18struct BrowserState {
19 pid: u32,
20}
21
22impl Browser {
23 #[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 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 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 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 drop(child);
117
118 Ok(Browser { ws_url })
119 }
120
121 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 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 pub fn ws_url(&self) -> &str {
134 &self.ws_url
135 }
136
137 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 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 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 pub fn is_alive() -> bool {
209 Self::probe_port()
210 }
211
212 fn state_path() -> PathBuf {
214 Self::home_dir().join(".gthings/browser.json")
215 }
216
217 fn home_dir() -> PathBuf {
219 std::env::var("HOME")
220 .map(PathBuf::from)
221 .unwrap_or_else(|_| PathBuf::from("/tmp"))
222 }
223
224 pub fn state_file_path() -> PathBuf {
226 Self::state_path()
227 }
228
229 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 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 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 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 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 }
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 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}