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 #[cfg(target_os = "macos")]
236 let _ws_url = self.ws_url.clone();
237 #[cfg(target_os = "macos")]
238 let dismiss_handle = tokio::spawn(async move {
239 for _i in 0..10 {
240 dismiss_allow_debugging_dialog();
241 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
242 }
243 });
244
245 let connect_result = tokio_tungstenite::connect_async(&self.ws_url).await;
246
247 #[cfg(target_os = "macos")]
249 let _ = dismiss_handle.await;
250
251 let (ws_stream, _) = connect_result.map_err(|e| {
252 #[cfg(target_os = "macos")]
254 dismiss_allow_debugging_dialog();
255 CdpError::LaunchFailed(format!("WebSocket connect failed: {e}"))
256 })?;
257
258 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
259 std::mem::forget(kill_tx);
260 Connection::new(ws_stream, kill_rx).await
261 }
262
263 pub fn ws_url(&self) -> &str {
265 &self.ws_url
266 }
267
268 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
270 if let Some(path) = browser_path {
272 if path.exists() {
273 return Some(path.to_string_lossy().to_string());
274 }
275 }
276
277 #[cfg(target_os = "macos")]
279 if let Some(bundle) = default_browser_bundle() {
280 if let Some(exec_name) = browser_exec_name(&bundle) {
281 let exec = bundle_to_exec(&bundle, exec_name);
282 if exec.exists() {
283 return Some(exec.to_string_lossy().to_string());
284 }
285 }
286 }
287
288 let candidates = [
290 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
291 "/Applications/Chrome.app/Contents/MacOS/Chrome",
292 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
293 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
294 "/Applications/Arc.app/Contents/MacOS/Arc",
295 "/Applications/Dia.app/Contents/MacOS/Dia",
296 "/usr/bin/chromium",
297 "/usr/bin/chromium-browser",
298 "/snap/bin/chromium",
299 ];
300
301 for candidate in &candidates {
302 if std::path::Path::new(candidate).exists() {
303 return Some(candidate.to_string());
304 }
305 }
306
307 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
309 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
310 if output.status.success() {
311 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
312 if !path.is_empty() && std::path::Path::new(&path).exists() {
313 return Some(path);
314 }
315 }
316 }
317 }
318
319 None
320 }
321
322 fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
326 if let Some(p) = explicit {
328 if p.exists() {
329 if !Self::is_profile_in_use(&p) {
331 return Some(p);
332 }
333 }
334 }
335
336 #[cfg(target_os = "macos")]
338 if let Some(bundle) = default_browser_bundle() {
339 if let Some(suffix) = browser_profile_suffix(&bundle) {
340 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
341 let path = home.join("Library/Application Support").join(suffix);
342 if path.exists() && !Self::is_profile_in_use(&path) {
343 return Some(path);
344 }
345 }
346 }
347 }
348
349 let common_paths = [
351 "Library/Application Support/Google/Chrome/User Data",
352 "Library/Application Support/Dia/User Data",
353 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
354 ];
355 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
356 for suffix in &common_paths {
357 let path = home.join(suffix);
358 if path.exists() && !Self::is_profile_in_use(&path) {
359 return Some(path);
360 }
361 }
362 }
363
364 None
366 }
367
368 fn seed_profile(profile_dir: &std::path::Path) {
372 let now = std::time::SystemTime::now()
373 .duration_since(std::time::UNIX_EPOCH)
374 .unwrap_or_default()
375 .as_micros() as u64;
376
377 let prefs = serde_json::json!({
379 "browser": {
380 "has_seen_welcome_page": true
381 },
382 "profile": {
383 "exit_type": "Normal"
384 },
385 "default_apps_install_state": 3,
386 "in_product_help": {
387 "session_last_active_time": now.to_string(),
388 "session_number": 5,
389 "session_start_time": now.to_string()
390 }
391 });
392
393 let local_state = serde_json::json!({
395 "browser": {
396 "enabled_labs_experiments": [],
397 "last_redirect_origin": "",
398 "last_whats_new_milestone": "150"
399 },
400 "distribution": {
401 "skip_first_run_ui": true }
403 });
404
405 for sub in &["User Data/Default", "Default"] {
407 let prefs_dir = profile_dir.join(sub);
408 let _ = std::fs::create_dir_all(&prefs_dir);
409 let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
410 }
411
412 for sub in &["User Data", "."] {
414 let state_dir = profile_dir.join(sub);
415 let _ = std::fs::create_dir_all(&state_dir);
416 let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
417 }
418 }
419
420 fn clean_profile_locks(profile_dir: &std::path::Path) {
422 let lock_files = [
423 "SingletonLock",
424 "SingletonSocket",
425 "SingletonCookie",
426 "DevToolsActivePort",
427 ];
428 for name in &lock_files {
429 let path = profile_dir.join(name);
430 if path.exists() {
431 let _ = std::fs::remove_file(&path);
432 }
433 }
434 }
435
436 fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
439 let lock_file = profile_dir.join("SingletonLock");
440 if !lock_file.exists() {
441 return false;
442 }
443 #[cfg(target_os = "macos")]
445 {
446 let output = std::process::Command::new("lsof")
447 .args(["-F", "p", &lock_file.to_string_lossy()])
448 .output()
449 .ok();
450 if let Some(output) = output {
451 if output.status.success() && !output.stdout.is_empty() {
452 return true;
453 }
454 }
455 }
456 false
457 }
458
459 pub fn is_alive(cdp_port: u16) -> bool {
461 Self::probe_port(cdp_port)
462 }
463
464 pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
466 if !Self::probe_port(cdp_port) {
468 return None;
469 }
470
471 let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
473
474 if let Some(ref url) = ws_url {
475 if Self::verify_ws(url).await.is_some() {
477 return Some(Browser {
478 ws_url: url.clone(),
479 cdp_port,
480 });
481 }
482 }
483
484 None
485 }
486
487 pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
489 let mut candidates: Vec<std::path::PathBuf> = Vec::new();
490
491 if let Some(dir) = explicit_profile_dir {
493 candidates.push(dir.to_path_buf());
494 }
495
496 #[cfg(target_os = "macos")]
498 {
499 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
500 let common = [
501 "Library/Application Support/Dia/User Data",
502 "Library/Application Support/Google/Chrome",
503 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
504 "Library/Application Support/Microsoft Edge/User Data",
505 ];
506 for path in &common {
507 let full = home.join(path);
508 if full.exists() {
509 candidates.push(full);
510 }
511 }
512 }
513 }
514
515 candidates.sort();
517 candidates.dedup();
518
519 for profile_dir in &candidates {
520 let active_port_path = profile_dir.join("DevToolsActivePort");
521 if let Ok(content) = std::fs::read_to_string(&active_port_path) {
522 let lines: Vec<&str> = content.trim().lines().collect();
523 if lines.len() >= 2 {
524 if let Ok(file_port) = lines[0].trim().parse::<u16>() {
525 if file_port == cdp_port {
526 let ws_path = lines[1].trim();
527 return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
528 }
529 }
530 }
531 }
532 }
533
534 None
535 }
536
537 async fn verify_ws(ws_url: &str) -> Option<()> {
539 let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
540 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
541 std::mem::forget(kill_tx);
542 let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
543 conn.call("Browser.getVersion", serde_json::json!({}))
544 .await
545 .ok()?;
546 Some(())
547 }
548
549 pub fn probe_port(cdp_port: u16) -> bool {
552 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
553 for addr in &addrs {
554 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
555 if std::net::TcpStream::connect_timeout(
556 &parsed,
557 std::time::Duration::from_millis(500),
558 )
559 .is_ok()
560 {
561 return true;
562 }
563 }
564 }
565 false
566 }
567
568}
569
570#[cfg(target_os = "macos")]
579pub fn dismiss_allow_debugging_dialog() {
580 let scripts = [
583 r#"tell application "System Events"
585 set browserProcesses to {"Dia", "Google Chrome", "Chromium", "Brave Browser", "Microsoft Edge"}
586 repeat with procName in browserProcesses
587 try
588 tell process procName
589 if exists (window 1) then
590 try
591 click button "Allow" of window 1
592 end try
593 end if
594 end tell
595 end try
596 end repeat
597 end tell"#,
598 r#"tell application "System Events"
600 key code 36
601 end tell"#,
602 ];
603
604 for script in &scripts {
605 let _ = std::process::Command::new("osascript")
606 .args(["-e", script])
607 .output();
608 }
609}
610
611#[cfg(not(target_os = "macos"))]
613pub fn dismiss_allow_debugging_dialog() {}
614
615impl Drop for Browser {
616 fn drop(&mut self) {
617 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn test_probe_port_no_server() {
627 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
628 }
629
630 #[test]
631 fn test_find_chrome_returns_some_or_none() {
632 let result = Browser::find_chrome(None);
633 if let Some(path) = result {
635 assert!(
636 std::path::Path::new(&path).exists(),
637 "Chrome path should exist: {}",
638 path
639 );
640 }
641 }
642
643 #[test]
644 fn test_error_types_compile() {
645 let _err = CdpError::LaunchFailed("test".into());
646 let _err = CdpError::NoWsUrl;
647 let _err = CdpError::Timeout(1000);
648 }
649
650 #[test]
651 fn test_launch_flags_include_disable_fre() {
652 let flags = [
653 "--disable-fre",
654 "--disable-search-engine-choice-screen",
655 "--no-first-run",
656 "--no-default-browser-check",
657 "--window-size=1280,720",
658 ];
659 for flag in &flags {
660 assert!(!flag.is_empty(), "Flag should not be empty");
661 }
662 }
663
664 #[test]
665 fn test_launch_flags_exclude_enable_automation() {
666 let forbidden = ["--enable-automation"];
667 for flag in &forbidden {
668 assert!(!flag.is_empty());
669 }
670 }
671
672 #[test]
673 fn test_state_path_removed() {
674 assert!(true, "BrowserState was removed, no state file written");
675 }
676
677 #[test]
678 fn test_fetch_ws_url_removed() {
679 assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
680 }
681
682 #[test]
683 fn test_is_profile_in_use_nonexistent_dir() {
684 let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
685 assert!(!Browser::is_profile_in_use(&tmp));
686 }
687
688 #[test]
689 fn test_wait_for_active_port_invalid_path() {
690 let rt = tokio::runtime::Runtime::new().unwrap();
691 let result = rt.block_on(async {
692 let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
693 true
694 });
695 assert!(result);
696 }
697}