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 ];
422 for name in &lock_files {
423 let path = profile_dir.join(name);
424 if path.exists() {
425 let _ = std::fs::remove_file(&path);
426 }
427 }
428 }
429
430 fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
433 let lock_file = profile_dir.join("SingletonLock");
434 if !lock_file.exists() {
435 return false;
436 }
437 #[cfg(target_os = "macos")]
439 {
440 let output = std::process::Command::new("lsof")
441 .args(["-F", "p", &lock_file.to_string_lossy()])
442 .output()
443 .ok();
444 if let Some(output) = output {
445 if output.status.success() && !output.stdout.is_empty() {
446 return true;
447 }
448 }
449 }
450 false
451 }
452
453 pub fn is_alive(cdp_port: u16) -> bool {
455 Self::probe_port(cdp_port)
456 }
457
458 pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
460 if !Self::probe_port(cdp_port) {
462 return None;
463 }
464
465 let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
467
468 if let Some(ref url) = ws_url {
469 if Self::verify_ws(url).await.is_some() {
471 return Some(Browser {
472 ws_url: url.clone(),
473 cdp_port,
474 });
475 }
476 }
477
478 None
479 }
480
481 pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
483 let mut candidates: Vec<std::path::PathBuf> = Vec::new();
484
485 if let Some(dir) = explicit_profile_dir {
487 candidates.push(dir.to_path_buf());
488 }
489
490 #[cfg(target_os = "macos")]
492 {
493 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
494 let common = [
495 "Library/Application Support/Dia/User Data",
496 "Library/Application Support/Google/Chrome",
497 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
498 "Library/Application Support/Microsoft Edge/User Data",
499 ];
500 for path in &common {
501 let full = home.join(path);
502 if full.exists() {
503 candidates.push(full);
504 }
505 }
506 }
507 }
508
509 candidates.sort();
511 candidates.dedup();
512
513 for profile_dir in &candidates {
514 let active_port_path = profile_dir.join("DevToolsActivePort");
515 if let Ok(content) = std::fs::read_to_string(&active_port_path) {
516 let lines: Vec<&str> = content.trim().lines().collect();
517 if lines.len() >= 2 {
518 if let Ok(file_port) = lines[0].trim().parse::<u16>() {
519 if file_port == cdp_port {
520 let ws_path = lines[1].trim();
521 return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
522 }
523 }
524 }
525 }
526 }
527
528 let http_addrs = [
531 format!("http://127.0.0.1:{}/json/version", cdp_port),
532 format!("http://[::1]:{}/json/version", cdp_port),
533 ];
534 for url in &http_addrs {
535 if let Ok(resp) = reqwest::get(url).await {
536 if let Ok(body) = resp.json::<serde_json::Value>().await {
537 if let Some(ws_url) = body.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
538 tracing::info!("Discovered WS URL via HTTP: {ws_url}");
539 return Some(ws_url.to_string());
540 }
541 }
542 }
543 }
544
545 None
546 }
547
548 async fn verify_ws(ws_url: &str) -> Option<()> {
550 let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
551 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
552 std::mem::forget(kill_tx);
553 let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
554 conn.call("Browser.getVersion", serde_json::json!({}))
555 .await
556 .ok()?;
557 Some(())
558 }
559
560 pub fn probe_port(cdp_port: u16) -> bool {
563 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
564 for addr in &addrs {
565 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
566 if std::net::TcpStream::connect_timeout(
567 &parsed,
568 std::time::Duration::from_millis(500),
569 )
570 .is_ok()
571 {
572 return true;
573 }
574 }
575 }
576 false
577 }
578
579}
580
581#[cfg(target_os = "macos")]
588pub fn dismiss_allow_debugging_dialog() {
589 let script = r#"tell application "System Events"
590 try
591 set frontmost of process "Dia" to true
592 end try
593 tell process "Dia" to keystroke return
594 end tell"#;
595 let _ = std::process::Command::new("osascript")
596 .args(["-e", script])
597 .output();
598}
599
600#[cfg(not(target_os = "macos"))]
602pub fn dismiss_allow_debugging_dialog() {}
603
604impl Drop for Browser {
605 fn drop(&mut self) {
606 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn test_probe_port_no_server() {
616 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
617 }
618
619 #[test]
620 fn test_find_chrome_returns_some_or_none() {
621 let result = Browser::find_chrome(None);
622 if let Some(path) = result {
624 assert!(
625 std::path::Path::new(&path).exists(),
626 "Chrome path should exist: {}",
627 path
628 );
629 }
630 }
631
632 #[test]
633 fn test_error_types_compile() {
634 let _err = CdpError::LaunchFailed("test".into());
635 let _err = CdpError::NoWsUrl;
636 let _err = CdpError::Timeout(1000);
637 }
638
639 #[test]
640 fn test_launch_flags_include_disable_fre() {
641 let flags = [
642 "--disable-fre",
643 "--disable-search-engine-choice-screen",
644 "--no-first-run",
645 "--no-default-browser-check",
646 "--window-size=1280,720",
647 ];
648 for flag in &flags {
649 assert!(!flag.is_empty(), "Flag should not be empty");
650 }
651 }
652
653 #[test]
654 fn test_launch_flags_exclude_enable_automation() {
655 let forbidden = ["--enable-automation"];
656 for flag in &forbidden {
657 assert!(!flag.is_empty());
658 }
659 }
660
661 #[test]
662 fn test_state_path_removed() {
663 assert!(true, "BrowserState was removed, no state file written");
664 }
665
666 #[test]
667 fn test_fetch_ws_url_removed() {
668 assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
669 }
670
671 #[test]
672 fn test_is_profile_in_use_nonexistent_dir() {
673 let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
674 assert!(!Browser::is_profile_in_use(&tmp));
675 }
676
677 #[test]
678 fn test_wait_for_active_port_invalid_path() {
679 let rt = tokio::runtime::Runtime::new().unwrap();
680 let result = rt.block_on(async {
681 let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
682 true
683 });
684 assert!(result);
685 }
686}