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