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 browser_profile_suffix(bundle_path: &std::path::Path) -> Option<&'static str> {
69 let path_str = bundle_path.to_string_lossy();
70 if path_str.contains("Google Chrome") {
71 Some("Google/Chrome")
72 } else if path_str.contains("Dia") {
73 Some("Dia")
74 } else if path_str.contains("Arc") {
75 Some("Arc")
76 } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
77 Some("BraveSoftware/Brave-Browser")
78 } else if path_str.contains("Microsoft Edge") {
79 Some("Microsoft Edge")
80 } else if path_str.contains("Chromium") {
81 Some("Chromium")
82 } else {
83 None
84 }
85}
86
87fn bundle_to_exec(bundle: &std::path::Path, exec_name: &str) -> std::path::PathBuf {
89 bundle.join("Contents").join("MacOS").join(exec_name)
90}
91
92impl Browser {
93 #[allow(clippy::result_large_err)]
95 pub async fn launch(
96 browser_path: Option<std::path::PathBuf>,
97 profile_dir: Option<std::path::PathBuf>,
98 cdp_port: u16,
99 ) -> Result<Self> {
100 tracing::info!("Checking for existing browser on port {cdp_port}");
101 if let Some(browser) = Self::find_existing(cdp_port).await {
102 tracing::info!("Found existing browser, reusing");
103 return Ok(browser);
104 }
105 tracing::info!("No existing browser found, launching new one");
106
107 let chrome_path = Self::find_chrome(browser_path)
108 .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
109
110 let port = cdp_port;
111
112 let profile_dir = Self::real_profile_dir(profile_dir).unwrap_or_else(|| {
117 let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", port));
118 let _ = std::fs::create_dir_all(&tmp);
119 tmp
120 });
121
122 {
124 let dir = profile_dir.clone();
125 tokio::task::spawn_blocking(move || {
126 Self::clean_profile_locks(&dir);
127 })
128 .await
129 .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
130 }
131
132 tracing::info!(
133 "Launching browser on port {} with profile {:?}",
134 port,
135 profile_dir
136 );
137
138 let mut cmd = Command::new(&chrome_path);
139 cmd.arg(format!("--remote-debugging-port={}", port))
140 .arg("--no-first-run")
141 .arg("--no-default-browser-check")
142 .arg("--disable-sync")
143 .arg("--remote-allow-origins=*")
144 .arg("--enable-automation")
145 .arg("--disable-background-networking")
146 .arg("--disable-extensions")
147 .arg("--disable-component-update")
148 .arg("--disable-default-apps")
149 .arg("--password-store=basic")
150 .arg("--use-mock-keychain")
151 .arg(format!("--user-data-dir={}", profile_dir.display()))
152 .arg("about:blank")
153 .stderr(Stdio::piped())
154 .stdout(Stdio::null())
155 .stdin(Stdio::null());
156
157 let mut child = cmd
158 .spawn()
159 .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
160
161 let stderr = child
162 .stderr
163 .take()
164 .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
165
166 let reader = BufReader::new(stderr);
167 let mut ws_url = None;
168
169 for line in reader.lines() {
170 let line = line.map_err(|e| {
171 CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
172 })?;
173 tracing::debug!("Chrome: {}", line);
174
175 if let Some(url) = line.strip_prefix("DevTools listening on ") {
177 ws_url = Some(url.trim().to_string());
178 break;
179 }
180 }
181
182 let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
183 let pid = child.id();
184
185 let state = BrowserState { pid };
186 let state_json = serde_json::to_string(&state)?;
187 let final_path = Self::state_path();
189 let tmp_path = final_path.with_extension("json.tmp");
190 let json = state_json;
191 tokio::task::spawn_blocking(move || {
192 if let Some(parent) = final_path.parent() {
193 let _ = std::fs::create_dir_all(parent);
194 }
195 std::fs::write(&tmp_path, &json)
196 .map_err(|e| CdpError::LaunchFailed(format!("Cannot save state: {e}")))?;
197 std::fs::rename(&tmp_path, &final_path)
198 .map_err(|e| CdpError::LaunchFailed(format!("Cannot commit state: {e}")))?;
199 Ok::<_, CdpError>(())
200 })
201 .await
202 .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))??;
203
204 tracing::info!("Launched persistent browser (pid={})", pid);
205
206 drop(child);
208
209 Ok(Browser { ws_url, cdp_port })
210 }
211
212 pub async fn connect(&self) -> Result<Connection> {
214 tracing::info!("Connecting to CDP: {}", self.ws_url);
215
216 let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
217 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
219 std::mem::forget(kill_tx);
220 Connection::new(ws_stream, kill_rx).await
221 }
222
223 pub fn ws_url(&self) -> &str {
225 &self.ws_url
226 }
227
228 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
230 if let Some(path) = browser_path {
232 if path.exists() {
233 return Some(path.to_string_lossy().to_string());
234 }
235 }
236
237 #[cfg(target_os = "macos")]
239 if let Some(bundle) = default_browser_bundle() {
240 if let Some(exec_name) = browser_exec_name(&bundle) {
241 let exec = bundle_to_exec(&bundle, exec_name);
242 if exec.exists() {
243 return Some(exec.to_string_lossy().to_string());
244 }
245 }
246 }
247
248 let candidates = [
250 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
251 "/Applications/Chrome.app/Contents/MacOS/Chrome",
252 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
253 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
254 "/Applications/Arc.app/Contents/MacOS/Arc",
255 "/Applications/Dia.app/Contents/MacOS/Dia",
256 "/usr/bin/chromium",
257 "/usr/bin/chromium-browser",
258 "/snap/bin/chromium",
259 ];
260
261 for candidate in &candidates {
262 if std::path::Path::new(candidate).exists() {
263 return Some(candidate.to_string());
264 }
265 }
266
267 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
269 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
270 if output.status.success() {
271 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
272 if !path.is_empty() && std::path::Path::new(&path).exists() {
273 return Some(path);
274 }
275 }
276 }
277 }
278
279 None
280 }
281
282 fn real_profile_dir(profile_dir: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
284 if let Some(dir) = profile_dir {
286 if dir.exists() {
287 return Some(dir);
288 }
289 }
290
291 let home = std::env::var("HOME").ok()?;
292
293 #[cfg(target_os = "macos")]
295 if let Some(bundle) = default_browser_bundle() {
296 if let Some(suffix) = browser_profile_suffix(&bundle) {
297 let profile = std::path::PathBuf::from(&home)
298 .join("Library/Application Support")
299 .join(suffix);
300 if profile.exists() {
301 return Some(profile);
302 }
303 }
304 }
305
306 let common_profiles = [
308 "Google/Chrome",
309 "Dia",
310 "Chromium",
311 "BraveSoftware/Brave-Browser",
312 "Microsoft Edge",
313 "Arc",
314 ];
315
316 for suffix in &common_profiles {
317 let profile = std::path::PathBuf::from(&home)
318 .join("Library/Application Support")
319 .join(suffix);
320 if profile.exists() {
321 return Some(profile);
322 }
323 }
324
325 None
326 }
327
328 fn clean_profile_locks(profile_dir: &std::path::Path) {
330 let lock_files = [
331 "SingletonLock",
332 "SingletonSocket",
333 "SingletonCookie",
334 "DevToolsActivePort",
335 ];
336 for name in &lock_files {
337 let path = profile_dir.join(name);
338 if path.exists() {
339 let _ = std::fs::remove_file(&path);
340 }
341 }
342 }
343
344 pub fn is_alive(cdp_port: u16) -> bool {
346 Self::probe_port(cdp_port)
347 }
348
349 fn state_path() -> PathBuf {
351 Self::home_dir().join(".gthings/browser.json")
352 }
353
354 fn home_dir() -> PathBuf {
356 std::env::var("HOME")
357 .map(PathBuf::from)
358 .unwrap_or_else(|_| PathBuf::from("/tmp"))
359 }
360
361 pub fn state_file_path() -> PathBuf {
363 Self::state_path()
364 }
365
366 pub async fn find_existing(cdp_port: u16) -> Option<Self> {
368 let state_path = Self::state_path();
369 if !state_path.exists() {
370 return None;
371 }
372
373 let path = state_path.clone();
374 let state_str: String =
375 tokio::task::spawn_blocking(move || std::fs::read_to_string(&path).ok())
376 .await
377 .unwrap_or(None)?;
378 let state: BrowserState = serde_json::from_str(&state_str).ok()?;
379
380 if !Self::is_process_alive(state.pid) {
381 tracing::warn!("Browser pid={} is dead, removing stale state", state.pid);
382 let path = state_path.clone();
383 tokio::task::spawn_blocking(move || {
384 let _ = std::fs::remove_file(&path);
385 })
386 .await
387 .ok();
388 return None;
389 }
390
391 if !Self::probe_port(cdp_port) {
392 tracing::warn!(
393 "Browser port {} not responding, removing stale state",
394 cdp_port
395 );
396 let path = state_path.clone();
397 tokio::task::spawn_blocking(move || {
398 let _ = std::fs::remove_file(&path);
399 })
400 .await
401 .ok();
402 return None;
403 }
404
405 let ws_url = Self::fetch_ws_url(cdp_port).await?;
406
407 tracing::info!("Found existing browser (pid={})", state.pid);
408
409 Some(Browser { ws_url, cdp_port })
410 }
411
412 async fn fetch_ws_url(cdp_port: u16) -> Option<String> {
414 let url = format!("http://127.0.0.1:{cdp_port}/json/version");
415 let resp = reqwest::get(&url).await.ok()?;
416 let json: serde_json::Value = resp.json().await.ok()?;
417 json["webSocketDebuggerUrl"].as_str().map(|s| s.to_string())
418 }
419
420 fn is_process_alive(pid: u32) -> bool {
422 std::process::Command::new("kill")
423 .arg("-0")
424 .arg(pid.to_string())
425 .output()
426 .map(|output| output.status.success())
427 .unwrap_or(false)
428 }
429
430 fn probe_port(cdp_port: u16) -> bool {
433 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
434 for addr in &addrs {
435 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
436 if std::net::TcpStream::connect_timeout(
437 &parsed,
438 std::time::Duration::from_millis(500),
439 )
440 .is_ok()
441 {
442 return true;
443 }
444 }
445 }
446 false
447 }
448
449 pub async fn pid(&self) -> Option<u32> {
451 let state_path = Self::state_path();
452 tokio::task::spawn_blocking(move || {
453 if state_path.exists() {
454 if let Ok(state_str) = std::fs::read_to_string(&state_path) {
455 if let Ok(state) = serde_json::from_str::<BrowserState>(&state_str) {
456 return Some(state.pid);
457 }
458 }
459 }
460 None
461 })
462 .await
463 .unwrap_or(None)
464 }
465}
466
467impl Drop for Browser {
468 fn drop(&mut self) {
469 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_probe_port_no_server() {
479 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
480 }
481
482 #[test]
483 fn test_state_path_ends_correctly() {
484 let path = Browser::state_path();
485 assert!(path.ends_with(".gthings/browser.json"));
486 }
487
488 #[test]
489 fn test_find_chrome_returns_some_or_none() {
490 let result = Browser::find_chrome(None);
491 if let Some(path) = result {
493 assert!(
494 std::path::Path::new(&path).exists(),
495 "Chrome path should exist: {}",
496 path
497 );
498 }
499 }
500
501 #[test]
502 fn test_error_types_compile() {
503 let _err = CdpError::LaunchFailed("test".into());
504 let _err = CdpError::NoWsUrl;
505 let _err = CdpError::Timeout(1000);
506 }
507}