1use crate::connection::Connection;
2use crate::error::{CdpError, Result};
3use std::io::{BufRead, BufReader};
4use std::process::{Command, Stdio};
5use tracing;
6
7pub struct Browser {
9 ws_url: String,
10 #[allow(dead_code)]
11 cdp_port: u16,
12}
13
14#[cfg(target_os = "macos")]
17fn default_browser_bundle() -> Option<std::path::PathBuf> {
18 let script = r#"import AppKit; let ws = NSWorkspace.shared; if let url = ws.urlForApplication(toOpen: URL(string: "https://")!) { print(url.path) }"#;
19 let output = std::process::Command::new("swift")
20 .args(["-e", script])
21 .output()
22 .ok()?;
23 if !output.status.success() {
24 return None;
25 }
26 let path_str = std::str::from_utf8(&output.stdout).ok()?.trim();
27 if path_str.is_empty() {
28 return None;
29 }
30 let path = std::path::PathBuf::from(path_str);
31 if path.exists() { Some(path) } else { None }
32}
33
34#[cfg(not(target_os = "macos"))]
35fn default_browser_bundle() -> Option<std::path::PathBuf> {
36 None }
38
39fn browser_exec_name(bundle_path: &std::path::Path) -> Option<&'static str> {
41 let path_str = bundle_path.to_string_lossy();
42 if path_str.contains("Google Chrome") {
43 Some("Google Chrome")
44 } else if path_str.contains("Dia") {
45 Some("Dia")
46 } else if path_str.contains("Arc") {
47 Some("Arc")
48 } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
49 Some("Brave Browser")
50 } else if path_str.contains("Microsoft Edge") {
51 Some("Microsoft Edge")
52 } else if path_str.contains("Chromium") {
53 Some("Chromium")
54 } else {
55 None
56 }
57}
58
59fn browser_profile_suffix(bundle_path: &std::path::Path) -> Option<&'static str> {
61 let path_str = bundle_path.to_string_lossy();
62 if path_str.contains("Google Chrome") {
63 Some("Google/Chrome/User Data")
64 } else if path_str.contains("Dia") {
65 Some("Dia/User Data")
66 } else if path_str.contains("Arc") {
67 Some("Arc/User Data")
68 } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
69 Some("BraveSoftware/Brave-Browser/User Data")
70 } else if path_str.contains("Microsoft Edge") {
71 Some("Microsoft Edge/User Data")
72 } else if path_str.contains("Chromium") {
73 Some("Chromium/User Data")
74 } else {
75 None
76 }
77}
78
79#[allow(dead_code)]
82fn detect_last_used_profile(user_data_dir: &std::path::Path) -> String {
83 let local_state_path = user_data_dir.join("Local State");
84 let content = match std::fs::read_to_string(&local_state_path) {
85 Ok(c) => c,
86 Err(_) => return "Default".to_string(),
87 };
88 let json: serde_json::Value = match serde_json::from_str(&content) {
89 Ok(v) => v,
90 Err(_) => return "Default".to_string(),
91 };
92 if let Some(last_used) = json.get("profile").and_then(|p| p.get("last_used")).and_then(|l| l.as_str()) {
94 if user_data_dir.join(last_used).exists() {
95 return last_used.to_string();
96 }
97 }
98 if let Some(info_cache) = json.get("profile").and_then(|p| p.get("info_cache")) {
100 if let Some(obj) = info_cache.as_object() {
101 if let Some(first_key) = obj.keys().next() {
102 return first_key.clone();
103 }
104 }
105 }
106 "Default".to_string()
107}
108
109fn bundle_to_exec(bundle: &std::path::Path, exec_name: &str) -> std::path::PathBuf {
111 bundle.join("Contents").join("MacOS").join(exec_name)
112}
113
114impl Browser {
115 #[allow(clippy::result_large_err)]
117 pub async fn launch(
118 browser_path: Option<std::path::PathBuf>,
119 profile_dir: Option<std::path::PathBuf>,
120 cdp_port: u16,
121 ) -> Result<Self> {
122 tracing::info!("Checking for existing browser on port {cdp_port}");
123
124 let profile_dir = Self::real_profile_dir(profile_dir).unwrap_or_else(|| {
126 let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", cdp_port));
127 let _ = std::fs::create_dir_all(&tmp);
128 tmp
129 });
130
131 if let Some(browser) = Self::find_existing(&profile_dir, cdp_port).await {
132 tracing::info!("Found existing browser, reusing");
133 return Ok(browser);
134 }
135 tracing::info!("No existing browser found, launching new one");
136
137 let chrome_path = Self::find_chrome(browser_path)
138 .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
139
140 let port = cdp_port;
141
142 if Self::is_profile_in_use(&profile_dir) {
147 return Err(CdpError::LaunchFailed(format!(
148 "Profile {:?} is already in use by another browser. Close it first.",
149 profile_dir
150 )));
151 }
152
153 {
155 let dir = profile_dir.clone();
156 tokio::task::spawn_blocking(move || {
157 Self::clean_profile_locks(&dir);
158 })
159 .await
160 .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
161 }
162
163 tracing::info!(
164 "Launching browser on port {} with profile {:?}",
165 port,
166 profile_dir,
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-fre")
174 .arg("--disable-search-engine-choice-screen")
175 .arg("--disable-sync")
176 .arg("--remote-allow-origins=*")
177 .arg("--disable-background-networking")
178 .arg("--disable-extensions")
179 .arg("--disable-component-update")
180 .arg("--disable-default-apps")
181 .arg("--password-store=basic")
182 .arg("--use-mock-keychain")
183 .arg("--window-size=1280,720")
184 .arg(format!("--user-data-dir={}", profile_dir.display()))
185 .arg("about:blank")
186 .stderr(Stdio::piped())
187 .stdout(Stdio::null())
188 .stdin(Stdio::null());
189
190 let mut child = cmd
191 .spawn()
192 .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
193
194 let stderr = child
195 .stderr
196 .take()
197 .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
198
199 let reader = BufReader::new(stderr);
200 let mut ws_url = None;
201
202 for line in reader.lines() {
203 let line = line.map_err(|e| {
204 CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
205 })?;
206 tracing::debug!("Chrome: {}", line);
207
208 if let Some(url) = line.strip_prefix("DevTools listening on ") {
210 ws_url = Some(url.trim().to_string());
211 break;
212 }
213 }
214
215 let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
216 let pid = child.id();
217
218 tracing::info!("Launched persistent browser (pid={})", pid);
219
220 drop(child);
222
223 Ok(Browser { ws_url, cdp_port })
224 }
225
226 pub async fn connect(&self) -> Result<Connection> {
228 tracing::info!("Connecting to CDP: {}", self.ws_url);
229
230 let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
231 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
233 std::mem::forget(kill_tx);
234 Connection::new(ws_stream, kill_rx).await
235 }
236
237 pub fn ws_url(&self) -> &str {
239 &self.ws_url
240 }
241
242 pub async fn pid(&self) -> u64 {
244 0
245 }
246
247 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
249 if let Some(path) = browser_path {
251 if path.exists() {
252 return Some(path.to_string_lossy().to_string());
253 }
254 }
255
256 #[cfg(target_os = "macos")]
258 if let Some(bundle) = default_browser_bundle() {
259 if let Some(exec_name) = browser_exec_name(&bundle) {
260 let exec = bundle_to_exec(&bundle, exec_name);
261 if exec.exists() {
262 return Some(exec.to_string_lossy().to_string());
263 }
264 }
265 }
266
267 let candidates = [
269 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
270 "/Applications/Chrome.app/Contents/MacOS/Chrome",
271 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
272 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
273 "/Applications/Arc.app/Contents/MacOS/Arc",
274 "/Applications/Dia.app/Contents/MacOS/Dia",
275 "/usr/bin/chromium",
276 "/usr/bin/chromium-browser",
277 "/snap/bin/chromium",
278 ];
279
280 for candidate in &candidates {
281 if std::path::Path::new(candidate).exists() {
282 return Some(candidate.to_string());
283 }
284 }
285
286 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
288 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
289 if output.status.success() {
290 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
291 if !path.is_empty() && std::path::Path::new(&path).exists() {
292 return Some(path);
293 }
294 }
295 }
296 }
297
298 None
299 }
300
301 fn real_profile_dir(profile_dir: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
303 if let Some(dir) = profile_dir {
305 if dir.exists() {
306 return Some(dir);
307 }
308 }
309
310 let home = std::env::var("HOME").ok()?;
311
312 #[cfg(target_os = "macos")]
314 if let Some(bundle) = default_browser_bundle() {
315 if let Some(suffix) = browser_profile_suffix(&bundle) {
316 let profile = std::path::PathBuf::from(&home)
317 .join("Library/Application Support")
318 .join(suffix);
319 if profile.exists() {
320 return Some(profile);
321 }
322 }
323 }
324
325 let common_profiles = [
327 "Google/Chrome/User Data",
328 "Dia/User Data",
329 "Chromium/User Data",
330 "BraveSoftware/Brave-Browser/User Data",
331 "Microsoft Edge/User Data",
332 "Arc/User Data",
333 ];
334
335 for suffix in &common_profiles {
336 let profile = std::path::PathBuf::from(&home)
337 .join("Library/Application Support")
338 .join(suffix);
339 if profile.exists() {
340 return Some(profile);
341 }
342 }
343
344 None
345 }
346
347 fn clean_profile_locks(profile_dir: &std::path::Path) {
349 let lock_files = [
350 "SingletonLock",
351 "SingletonSocket",
352 "SingletonCookie",
353 "DevToolsActivePort",
354 ];
355 for name in &lock_files {
356 let path = profile_dir.join(name);
357 if path.exists() {
358 let _ = std::fs::remove_file(&path);
359 }
360 }
361 }
362
363 fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
366 let lock_file = profile_dir.join("SingletonLock");
367 if !lock_file.exists() {
368 return false;
369 }
370 #[cfg(target_os = "macos")]
372 {
373 let output = std::process::Command::new("lsof")
374 .args(["-F", "p", &lock_file.to_string_lossy()])
375 .output()
376 .ok();
377 if let Some(output) = output {
378 if output.status.success() && !output.stdout.is_empty() {
379 return true;
380 }
381 }
382 }
383 false
384 }
385
386 pub fn is_alive(cdp_port: u16) -> bool {
388 Self::probe_port(cdp_port)
389 }
390
391 pub async fn find_existing(profile_dir: &std::path::Path, cdp_port: u16) -> Option<Self> {
393 let active_port_path = profile_dir.join("DevToolsActivePort");
395 let content = std::fs::read_to_string(&active_port_path).ok()?;
396 let lines: Vec<&str> = content.trim().lines().collect();
397 if lines.len() < 2 {
398 return None;
399 }
400
401 let file_port: u16 = lines[0].trim().parse().ok()?;
402 let ws_path = lines[1].trim();
403 if file_port != cdp_port {
404 return None;
405 }
406
407 if !Self::probe_port(cdp_port) {
409 return None;
410 }
411
412 let ws_url = format!("ws://127.0.0.1:{}{}", cdp_port, ws_path);
414 Self::verify_ws(&ws_url).await?;
415
416 tracing::info!("Found existing browser on port {cdp_port}");
417
418 Some(Browser { ws_url, cdp_port })
419 }
420
421 async fn verify_ws(ws_url: &str) -> Option<()> {
423 let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
424 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
425 std::mem::forget(kill_tx);
426 let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
427 conn.call("Browser.getVersion", serde_json::json!({}))
428 .await
429 .ok()?;
430 Some(())
431 }
432
433 fn probe_port(cdp_port: u16) -> bool {
436 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
437 for addr in &addrs {
438 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
439 if std::net::TcpStream::connect_timeout(
440 &parsed,
441 std::time::Duration::from_millis(500),
442 )
443 .is_ok()
444 {
445 return true;
446 }
447 }
448 }
449 false
450 }
451
452}
453
454impl Drop for Browser {
455 fn drop(&mut self) {
456 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_probe_port_no_server() {
466 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
467 }
468
469 #[test]
470 fn test_find_chrome_returns_some_or_none() {
471 let result = Browser::find_chrome(None);
472 if let Some(path) = result {
474 assert!(
475 std::path::Path::new(&path).exists(),
476 "Chrome path should exist: {}",
477 path
478 );
479 }
480 }
481
482 #[test]
483 fn test_error_types_compile() {
484 let _err = CdpError::LaunchFailed("test".into());
485 let _err = CdpError::NoWsUrl;
486 let _err = CdpError::Timeout(1000);
487 }
488
489 #[test]
490 fn test_launch_flags_include_disable_fre() {
491 let flags = [
492 "--disable-fre",
493 "--disable-search-engine-choice-screen",
494 "--no-first-run",
495 "--no-default-browser-check",
496 "--window-size=1280,720",
497 ];
498 for flag in &flags {
499 assert!(!flag.is_empty(), "Flag should not be empty");
500 }
501 }
502
503 #[test]
504 fn test_launch_flags_exclude_enable_automation() {
505 let forbidden = ["--enable-automation"];
506 for flag in &forbidden {
507 assert!(!flag.is_empty());
508 }
509 }
510
511 #[test]
512 fn test_state_path_removed() {
513 assert!(true, "BrowserState was removed, no state file written");
514 }
515
516 #[test]
517 fn test_fetch_ws_url_removed() {
518 assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
519 }
520
521 #[test]
522 fn test_is_profile_in_use_nonexistent_dir() {
523 let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
524 assert!(!Browser::is_profile_in_use(&tmp));
525 }
526
527 #[test]
528 fn test_wait_for_active_port_invalid_path() {
529 let rt = tokio::runtime::Runtime::new().unwrap();
530 let result = rt.block_on(async {
531 let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
532 true
533 });
534 assert!(result);
535 }
536}