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 = match Self::resolve_profile(profile_dir) {
128 Some(dir) => dir,
129 None => {
130 let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", cdp_port));
131 let _ = std::fs::create_dir_all(&tmp);
132 Self::seed_profile(&tmp);
133 tmp
134 }
135 };
136
137 if let Some(browser) = Self::find_existing(Some(&profile_dir), cdp_port).await {
138 tracing::info!("Found existing browser, reusing");
139 return Ok(browser);
140 }
141 tracing::info!("No existing browser found, launching new one");
142
143 let chrome_path = Self::find_chrome(browser_path)
144 .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
145
146 let port = cdp_port;
147
148 if profile_dir.to_string_lossy().contains("/tmp/") {
150 } else if Self::is_profile_in_use(&profile_dir) {
152 return Err(CdpError::LaunchFailed(format!(
153 "Profile {:?} is in use by another browser. Close it first or set GTHINGS_PROFILE_DIR.",
154 profile_dir
155 )));
156 } else {
157 let dir = profile_dir.clone();
159 tokio::task::spawn_blocking(move || {
160 Self::clean_profile_locks(&dir);
161 })
162 .await
163 .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
164 }
165
166 tracing::info!(
167 "Launching browser on port {} with profile {:?}",
168 port,
169 profile_dir,
170 );
171
172 let mut cmd = Command::new(&chrome_path);
173 cmd.arg(format!("--remote-debugging-port={}", port))
174 .arg("--no-first-run")
175 .arg("--no-default-browser-check")
176 .arg("--disable-fre")
177 .arg("--disable-search-engine-choice-screen")
178 .arg("--disable-sync")
179 .arg("--remote-allow-origins=*")
180 .arg("--disable-background-networking")
181 .arg("--disable-component-update")
182 .arg("--disable-default-apps")
183 .arg("--password-store=basic")
184 .arg("--use-mock-keychain")
185 .arg("--window-size=1280,720")
186 .arg(format!("--user-data-dir={}", profile_dir.display()))
187 .arg("about:blank")
188 .stderr(Stdio::piped())
189 .stdout(Stdio::null())
190 .stdin(Stdio::null());
191
192 let mut child = cmd
193 .spawn()
194 .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
195
196 let stderr = child
197 .stderr
198 .take()
199 .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
200
201 let reader = BufReader::new(stderr);
202 let mut ws_url = None;
203
204 for line in reader.lines() {
205 let line = line.map_err(|e| {
206 CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
207 })?;
208 tracing::debug!("Chrome: {}", line);
209
210 if let Some(url) = line.strip_prefix("DevTools listening on ") {
212 ws_url = Some(url.trim().to_string());
213 break;
214 }
215 }
216
217 let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
218 let pid = child.id();
219
220 tracing::info!("Launched persistent browser (pid={})", pid);
221
222 drop(child);
224
225 Ok(Browser { ws_url, cdp_port })
226 }
227
228 pub async fn connect(&self) -> Result<Connection> {
230 tracing::info!("Connecting to CDP: {}", self.ws_url);
231
232 let connect_fut = tokio_tungstenite::connect_async(&self.ws_url);
237 let dismiss_fut = tokio::time::sleep(std::time::Duration::from_millis(600));
238
239 tokio::pin!(connect_fut);
241 tokio::pin!(dismiss_fut);
242
243 (&mut dismiss_fut).await;
245 #[cfg(target_os = "macos")]
246 dismiss_allow_debugging_dialog();
247
248 let (ws_stream, _) = connect_fut.await.map_err(|e| {
250 CdpError::LaunchFailed(format!("WebSocket connect failed: {e}"))
251 })?;
252
253 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
254 std::mem::forget(kill_tx);
255 Connection::new(ws_stream, kill_rx).await
256 }
257
258 pub fn ws_url(&self) -> &str {
260 &self.ws_url
261 }
262
263 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
265 if let Some(path) = browser_path {
267 if path.exists() {
268 return Some(path.to_string_lossy().to_string());
269 }
270 }
271
272 #[cfg(target_os = "macos")]
274 if let Some(bundle) = default_browser_bundle() {
275 if let Some(exec_name) = browser_exec_name(&bundle) {
276 let exec = bundle_to_exec(&bundle, exec_name);
277 if exec.exists() {
278 return Some(exec.to_string_lossy().to_string());
279 }
280 }
281 }
282
283 let candidates = [
285 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
286 "/Applications/Chrome.app/Contents/MacOS/Chrome",
287 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
288 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
289 "/Applications/Arc.app/Contents/MacOS/Arc",
290 "/Applications/Dia.app/Contents/MacOS/Dia",
291 "/usr/bin/chromium",
292 "/usr/bin/chromium-browser",
293 "/snap/bin/chromium",
294 ];
295
296 for candidate in &candidates {
297 if std::path::Path::new(candidate).exists() {
298 return Some(candidate.to_string());
299 }
300 }
301
302 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
304 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
305 if output.status.success() {
306 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
307 if !path.is_empty() && std::path::Path::new(&path).exists() {
308 return Some(path);
309 }
310 }
311 }
312 }
313
314 None
315 }
316
317 fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
321 if let Some(p) = explicit {
323 if p.exists() {
324 if !Self::is_profile_in_use(&p) {
326 return Some(p);
327 }
328 }
329 }
330
331 #[cfg(target_os = "macos")]
333 if let Some(bundle) = default_browser_bundle() {
334 if let Some(suffix) = browser_profile_suffix(&bundle) {
335 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
336 let path = home.join("Library/Application Support").join(suffix);
337 if path.exists() && !Self::is_profile_in_use(&path) {
338 return Some(path);
339 }
340 }
341 }
342 }
343
344 let common_paths = [
346 "Library/Application Support/Google/Chrome/User Data",
347 "Library/Application Support/Dia/User Data",
348 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
349 ];
350 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
351 for suffix in &common_paths {
352 let path = home.join(suffix);
353 if path.exists() && !Self::is_profile_in_use(&path) {
354 return Some(path);
355 }
356 }
357 }
358
359 None
361 }
362
363 fn seed_profile(profile_dir: &std::path::Path) {
367 let now = std::time::SystemTime::now()
368 .duration_since(std::time::UNIX_EPOCH)
369 .unwrap_or_default()
370 .as_micros() as u64;
371
372 let prefs = serde_json::json!({
374 "browser": {
375 "has_seen_welcome_page": true
376 },
377 "profile": {
378 "exit_type": "Normal"
379 },
380 "default_apps_install_state": 3,
381 "in_product_help": {
382 "session_last_active_time": now.to_string(),
383 "session_number": 5,
384 "session_start_time": now.to_string()
385 }
386 });
387
388 let local_state = serde_json::json!({
390 "browser": {
391 "enabled_labs_experiments": [],
392 "last_redirect_origin": "",
393 "last_whats_new_milestone": "150"
394 },
395 "distribution": {
396 "skip_first_run_ui": true }
398 });
399
400 for sub in &["User Data/Default", "Default"] {
402 let prefs_dir = profile_dir.join(sub);
403 let _ = std::fs::create_dir_all(&prefs_dir);
404 let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
405 }
406
407 for sub in &["User Data", "."] {
409 let state_dir = profile_dir.join(sub);
410 let _ = std::fs::create_dir_all(&state_dir);
411 let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
412 }
413 }
414
415 fn clean_profile_locks(profile_dir: &std::path::Path) {
417 let lock_files = [
418 "SingletonLock",
419 "SingletonSocket",
420 "SingletonCookie",
421 "DevToolsActivePort",
422 ];
423 for name in &lock_files {
424 let path = profile_dir.join(name);
425 if path.exists() {
426 let _ = std::fs::remove_file(&path);
427 }
428 }
429 }
430
431 fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
434 let lock_file = profile_dir.join("SingletonLock");
435 if !lock_file.exists() {
436 return false;
437 }
438 #[cfg(target_os = "macos")]
440 {
441 let output = std::process::Command::new("lsof")
442 .args(["-F", "p", &lock_file.to_string_lossy()])
443 .output()
444 .ok();
445 if let Some(output) = output {
446 if output.status.success() && !output.stdout.is_empty() {
447 return true;
448 }
449 }
450 }
451 false
452 }
453
454 pub fn is_alive(cdp_port: u16) -> bool {
456 Self::probe_port(cdp_port)
457 }
458
459 pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
461 if !Self::probe_port(cdp_port) {
463 return None;
464 }
465
466 let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
468
469 if let Some(ref url) = ws_url {
470 if Self::verify_ws(url).await.is_some() {
472 return Some(Browser {
473 ws_url: url.clone(),
474 cdp_port,
475 });
476 }
477 }
478
479 None
480 }
481
482 pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
484 let mut candidates: Vec<std::path::PathBuf> = Vec::new();
485
486 if let Some(dir) = explicit_profile_dir {
488 candidates.push(dir.to_path_buf());
489 }
490
491 #[cfg(target_os = "macos")]
493 {
494 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
495 let common = [
496 "Library/Application Support/Dia/User Data",
497 "Library/Application Support/Google/Chrome",
498 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
499 "Library/Application Support/Microsoft Edge/User Data",
500 ];
501 for path in &common {
502 let full = home.join(path);
503 if full.exists() {
504 candidates.push(full);
505 }
506 }
507 }
508 }
509
510 candidates.sort();
512 candidates.dedup();
513
514 for profile_dir in &candidates {
515 let active_port_path = profile_dir.join("DevToolsActivePort");
516 if let Ok(content) = std::fs::read_to_string(&active_port_path) {
517 let lines: Vec<&str> = content.trim().lines().collect();
518 if lines.len() >= 2 {
519 if let Ok(file_port) = lines[0].trim().parse::<u16>() {
520 if file_port == cdp_port {
521 let ws_path = lines[1].trim();
522 return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
523 }
524 }
525 }
526 }
527 }
528
529 None
530 }
531
532 async fn verify_ws(ws_url: &str) -> Option<()> {
534 let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
535 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
536 std::mem::forget(kill_tx);
537 let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
538 conn.call("Browser.getVersion", serde_json::json!({}))
539 .await
540 .ok()?;
541 Some(())
542 }
543
544 pub fn probe_port(cdp_port: u16) -> bool {
547 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
548 for addr in &addrs {
549 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
550 if std::net::TcpStream::connect_timeout(
551 &parsed,
552 std::time::Duration::from_millis(500),
553 )
554 .is_ok()
555 {
556 return true;
557 }
558 }
559 }
560 false
561 }
562
563}
564
565#[cfg(target_os = "macos")]
572pub fn dismiss_allow_debugging_dialog() {
573 let script = r#"tell application "System Events"
574 try
575 set frontmost of process "Dia" to true
576 end try
577 tell process "Dia" to keystroke return
578 end tell"#;
579 let _ = std::process::Command::new("osascript")
580 .args(["-e", script])
581 .output();
582}
583
584#[cfg(not(target_os = "macos"))]
586pub fn dismiss_allow_debugging_dialog() {}
587
588impl Drop for Browser {
589 fn drop(&mut self) {
590 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn test_probe_port_no_server() {
600 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
601 }
602
603 #[test]
604 fn test_find_chrome_returns_some_or_none() {
605 let result = Browser::find_chrome(None);
606 if let Some(path) = result {
608 assert!(
609 std::path::Path::new(&path).exists(),
610 "Chrome path should exist: {}",
611 path
612 );
613 }
614 }
615
616 #[test]
617 fn test_error_types_compile() {
618 let _err = CdpError::LaunchFailed("test".into());
619 let _err = CdpError::NoWsUrl;
620 let _err = CdpError::Timeout(1000);
621 }
622
623 #[test]
624 fn test_launch_flags_include_disable_fre() {
625 let flags = [
626 "--disable-fre",
627 "--disable-search-engine-choice-screen",
628 "--no-first-run",
629 "--no-default-browser-check",
630 "--window-size=1280,720",
631 ];
632 for flag in &flags {
633 assert!(!flag.is_empty(), "Flag should not be empty");
634 }
635 }
636
637 #[test]
638 fn test_launch_flags_exclude_enable_automation() {
639 let forbidden = ["--enable-automation"];
640 for flag in &forbidden {
641 assert!(!flag.is_empty());
642 }
643 }
644
645 #[test]
646 fn test_state_path_removed() {
647 assert!(true, "BrowserState was removed, no state file written");
648 }
649
650 #[test]
651 fn test_fetch_ws_url_removed() {
652 assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
653 }
654
655 #[test]
656 fn test_is_profile_in_use_nonexistent_dir() {
657 let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
658 assert!(!Browser::is_profile_in_use(&tmp));
659 }
660
661 #[test]
662 fn test_wait_for_active_port_invalid_path() {
663 let rt = tokio::runtime::Runtime::new().unwrap();
664 let result = rt.block_on(async {
665 let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
666 true
667 });
668 assert!(result);
669 }
670}