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
9pub struct Browser {
11 ws_url: String,
12 #[allow(dead_code)]
13 cdp_port: u16,
14}
15
16#[derive(Serialize, Deserialize)]
18struct BrowserState {
19 pid: u32,
20}
21
22#[cfg(target_os = "macos")]
25fn default_browser_bundle() -> Option<std::path::PathBuf> {
26 let script = r#"import AppKit; let ws = NSWorkspace.shared; if let url = ws.urlForApplication(toOpen: URL(string: "https://")!) { print(url.path) }"#;
27 let output = std::process::Command::new("swift")
28 .args(["-e", script])
29 .output()
30 .ok()?;
31 if !output.status.success() {
32 return None;
33 }
34 let path_str = std::str::from_utf8(&output.stdout).ok()?.trim();
35 if path_str.is_empty() {
36 return None;
37 }
38 let path = std::path::PathBuf::from(path_str);
39 if path.exists() { Some(path) } else { None }
40}
41
42#[cfg(not(target_os = "macos"))]
43fn default_browser_bundle() -> Option<std::path::PathBuf> {
44 None }
46
47fn browser_exec_name(bundle_path: &std::path::Path) -> Option<&'static str> {
49 let path_str = bundle_path.to_string_lossy();
50 if path_str.contains("Google Chrome") {
51 Some("Google Chrome")
52 } else if path_str.contains("Dia") {
53 Some("Dia")
54 } else if path_str.contains("Arc") {
55 Some("Arc")
56 } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
57 Some("Brave Browser")
58 } else if path_str.contains("Microsoft Edge") {
59 Some("Microsoft Edge")
60 } else if path_str.contains("Chromium") {
61 Some("Chromium")
62 } else {
63 None
64 }
65}
66
67fn bundle_to_exec(bundle: &std::path::Path, exec_name: &str) -> std::path::PathBuf {
69 bundle.join("Contents").join("MacOS").join(exec_name)
70}
71
72impl Browser {
73 #[allow(clippy::result_large_err)]
75 pub async fn launch(
76 browser_path: Option<std::path::PathBuf>,
77 profile_dir: Option<std::path::PathBuf>,
78 cdp_port: u16,
79 ) -> Result<Self> {
80 tracing::info!("Checking for existing browser on port {cdp_port}");
81 if let Some(browser) = Self::find_existing(cdp_port).await {
82 tracing::info!("Found existing browser, reusing");
83 return Ok(browser);
84 }
85 tracing::info!("No existing browser found, launching new one");
86
87 let chrome_path = Self::find_chrome(browser_path)
88 .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
89
90 let port = cdp_port;
91
92 let profile_dir = profile_dir.unwrap_or_else(|| {
96 let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", port));
97 let _ = std::fs::create_dir_all(&tmp);
98 tmp
99 });
100
101 {
103 let dir = profile_dir.clone();
104 tokio::task::spawn_blocking(move || {
105 Self::clean_profile_locks(&dir);
106 })
107 .await
108 .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
109 }
110
111 tracing::info!(
112 "Launching browser on port {} with profile {:?}",
113 port,
114 profile_dir
115 );
116
117 let mut cmd = Command::new(&chrome_path);
118 cmd.arg(format!("--remote-debugging-port={}", port))
119 .arg("--no-first-run")
120 .arg("--no-default-browser-check")
121 .arg("--disable-sync")
122 .arg("--remote-allow-origins=*")
123 .arg("--enable-automation")
124 .arg("--disable-background-networking")
125 .arg("--disable-extensions")
126 .arg("--disable-component-update")
127 .arg("--disable-default-apps")
128 .arg("--password-store=basic")
129 .arg("--use-mock-keychain")
130 .arg(format!("--user-data-dir={}", profile_dir.display()))
131 .arg("about:blank")
132 .stderr(Stdio::piped())
133 .stdout(Stdio::null())
134 .stdin(Stdio::null());
135
136 let mut child = cmd
137 .spawn()
138 .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
139
140 let stderr = child
141 .stderr
142 .take()
143 .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
144
145 let reader = BufReader::new(stderr);
146 let mut ws_url = None;
147
148 for line in reader.lines() {
149 let line = line.map_err(|e| {
150 CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
151 })?;
152 tracing::debug!("Chrome: {}", line);
153
154 if let Some(url) = line.strip_prefix("DevTools listening on ") {
156 ws_url = Some(url.trim().to_string());
157 break;
158 }
159 }
160
161 let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
162 let pid = child.id();
163
164 let state = BrowserState { pid };
165 let state_json = serde_json::to_string(&state)?;
166 let final_path = Self::state_path();
168 let tmp_path = final_path.with_extension("json.tmp");
169 let json = state_json;
170 tokio::task::spawn_blocking(move || {
171 if let Some(parent) = final_path.parent() {
172 let _ = std::fs::create_dir_all(parent);
173 }
174 std::fs::write(&tmp_path, &json)
175 .map_err(|e| CdpError::LaunchFailed(format!("Cannot save state: {e}")))?;
176 std::fs::rename(&tmp_path, &final_path)
177 .map_err(|e| CdpError::LaunchFailed(format!("Cannot commit state: {e}")))?;
178 Ok::<_, CdpError>(())
179 })
180 .await
181 .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))??;
182
183 tracing::info!("Launched persistent browser (pid={})", pid);
184
185 drop(child);
187
188 Ok(Browser { ws_url, cdp_port })
189 }
190
191 pub async fn connect(&self) -> Result<Connection> {
193 tracing::info!("Connecting to CDP: {}", self.ws_url);
194
195 let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
196 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
198 std::mem::forget(kill_tx);
199 Connection::new(ws_stream, kill_rx).await
200 }
201
202 pub fn ws_url(&self) -> &str {
204 &self.ws_url
205 }
206
207 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
209 if let Some(path) = browser_path {
211 if path.exists() {
212 return Some(path.to_string_lossy().to_string());
213 }
214 }
215
216 #[cfg(target_os = "macos")]
218 if let Some(bundle) = default_browser_bundle() {
219 if let Some(exec_name) = browser_exec_name(&bundle) {
220 let exec = bundle_to_exec(&bundle, exec_name);
221 if exec.exists() {
222 return Some(exec.to_string_lossy().to_string());
223 }
224 }
225 }
226
227 let candidates = [
229 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
230 "/Applications/Chrome.app/Contents/MacOS/Chrome",
231 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
232 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
233 "/Applications/Arc.app/Contents/MacOS/Arc",
234 "/Applications/Dia.app/Contents/MacOS/Dia",
235 "/usr/bin/chromium",
236 "/usr/bin/chromium-browser",
237 "/snap/bin/chromium",
238 ];
239
240 for candidate in &candidates {
241 if std::path::Path::new(candidate).exists() {
242 return Some(candidate.to_string());
243 }
244 }
245
246 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
248 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
249 if output.status.success() {
250 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
251 if !path.is_empty() && std::path::Path::new(&path).exists() {
252 return Some(path);
253 }
254 }
255 }
256 }
257
258 None
259 }
260
261 fn clean_profile_locks(profile_dir: &std::path::Path) {
263 let lock_files = [
264 "SingletonLock",
265 "SingletonSocket",
266 "SingletonCookie",
267 "DevToolsActivePort",
268 ];
269 for name in &lock_files {
270 let path = profile_dir.join(name);
271 if path.exists() {
272 let _ = std::fs::remove_file(&path);
273 }
274 }
275 }
276
277 pub fn is_alive(cdp_port: u16) -> bool {
279 Self::probe_port(cdp_port)
280 }
281
282 fn state_path() -> PathBuf {
284 Self::home_dir().join(".gthings/browser.json")
285 }
286
287 fn home_dir() -> PathBuf {
289 std::env::var("HOME")
290 .map(PathBuf::from)
291 .unwrap_or_else(|_| PathBuf::from("/tmp"))
292 }
293
294 pub fn state_file_path() -> PathBuf {
296 Self::state_path()
297 }
298
299 pub async fn find_existing(cdp_port: u16) -> Option<Self> {
301 let state_path = Self::state_path();
302 if !state_path.exists() {
303 return None;
304 }
305
306 let path = state_path.clone();
307 let state_str: String =
308 tokio::task::spawn_blocking(move || std::fs::read_to_string(&path).ok())
309 .await
310 .unwrap_or(None)?;
311 let state: BrowserState = serde_json::from_str(&state_str).ok()?;
312
313 if !Self::is_process_alive(state.pid) {
314 tracing::warn!("Browser pid={} is dead, removing stale state", state.pid);
315 let path = state_path.clone();
316 tokio::task::spawn_blocking(move || {
317 let _ = std::fs::remove_file(&path);
318 })
319 .await
320 .ok();
321 return None;
322 }
323
324 if !Self::probe_port(cdp_port) {
325 tracing::warn!(
326 "Browser port {} not responding, removing stale state",
327 cdp_port
328 );
329 let path = state_path.clone();
330 tokio::task::spawn_blocking(move || {
331 let _ = std::fs::remove_file(&path);
332 })
333 .await
334 .ok();
335 return None;
336 }
337
338 let ws_url = Self::fetch_ws_url(cdp_port).await?;
339
340 tracing::info!("Found existing browser (pid={})", state.pid);
341
342 Some(Browser { ws_url, cdp_port })
343 }
344
345 async fn fetch_ws_url(cdp_port: u16) -> Option<String> {
347 let url = format!("http://127.0.0.1:{cdp_port}/json/version");
348 let resp = reqwest::get(&url).await.ok()?;
349 let json: serde_json::Value = resp.json().await.ok()?;
350 json["webSocketDebuggerUrl"].as_str().map(|s| s.to_string())
351 }
352
353 fn is_process_alive(pid: u32) -> bool {
355 std::process::Command::new("kill")
356 .arg("-0")
357 .arg(pid.to_string())
358 .output()
359 .map(|output| output.status.success())
360 .unwrap_or(false)
361 }
362
363 fn probe_port(cdp_port: u16) -> bool {
366 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
367 for addr in &addrs {
368 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
369 if std::net::TcpStream::connect_timeout(
370 &parsed,
371 std::time::Duration::from_millis(500),
372 )
373 .is_ok()
374 {
375 return true;
376 }
377 }
378 }
379 false
380 }
381
382 pub async fn pid(&self) -> Option<u32> {
384 let state_path = Self::state_path();
385 tokio::task::spawn_blocking(move || {
386 if state_path.exists() {
387 if let Ok(state_str) = std::fs::read_to_string(&state_path) {
388 if let Ok(state) = serde_json::from_str::<BrowserState>(&state_str) {
389 return Some(state.pid);
390 }
391 }
392 }
393 None
394 })
395 .await
396 .unwrap_or(None)
397 }
398}
399
400impl Drop for Browser {
401 fn drop(&mut self) {
402 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn test_probe_port_no_server() {
412 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
413 }
414
415 #[test]
416 fn test_state_path_ends_correctly() {
417 let path = Browser::state_path();
418 assert!(path.ends_with(".gthings/browser.json"));
419 }
420
421 #[test]
422 fn test_find_chrome_returns_some_or_none() {
423 let result = Browser::find_chrome(None);
424 if let Some(path) = result {
426 assert!(
427 std::path::Path::new(&path).exists(),
428 "Chrome path should exist: {}",
429 path
430 );
431 }
432 }
433
434 #[test]
435 fn test_error_types_compile() {
436 let _err = CdpError::LaunchFailed("test".into());
437 let _err = CdpError::NoWsUrl;
438 let _err = CdpError::Timeout(1000);
439 }
440}