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-extensions")
182 .arg("--disable-component-update")
183 .arg("--disable-default-apps")
184 .arg("--password-store=basic")
185 .arg("--use-mock-keychain")
186 .arg("--window-size=1280,720")
187 .arg(format!("--user-data-dir={}", profile_dir.display()))
188 .arg("about:blank")
189 .stderr(Stdio::piped())
190 .stdout(Stdio::null())
191 .stdin(Stdio::null());
192
193 let mut child = cmd
194 .spawn()
195 .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
196
197 let stderr = child
198 .stderr
199 .take()
200 .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
201
202 let reader = BufReader::new(stderr);
203 let mut ws_url = None;
204
205 for line in reader.lines() {
206 let line = line.map_err(|e| {
207 CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
208 })?;
209 tracing::debug!("Chrome: {}", line);
210
211 if let Some(url) = line.strip_prefix("DevTools listening on ") {
213 ws_url = Some(url.trim().to_string());
214 break;
215 }
216 }
217
218 let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
219 let pid = child.id();
220
221 tracing::info!("Launched persistent browser (pid={})", pid);
222
223 drop(child);
225
226 Ok(Browser { ws_url, cdp_port })
227 }
228
229 pub async fn connect(&self) -> Result<Connection> {
231 #[cfg(target_os = "macos")]
233 dismiss_allow_debugging_dialog();
234
235 tracing::info!("Connecting to CDP: {}", self.ws_url);
236
237 let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
238 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
240 std::mem::forget(kill_tx);
241 Connection::new(ws_stream, kill_rx).await
242 }
243
244 pub fn ws_url(&self) -> &str {
246 &self.ws_url
247 }
248
249 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
251 if let Some(path) = browser_path {
253 if path.exists() {
254 return Some(path.to_string_lossy().to_string());
255 }
256 }
257
258 #[cfg(target_os = "macos")]
260 if let Some(bundle) = default_browser_bundle() {
261 if let Some(exec_name) = browser_exec_name(&bundle) {
262 let exec = bundle_to_exec(&bundle, exec_name);
263 if exec.exists() {
264 return Some(exec.to_string_lossy().to_string());
265 }
266 }
267 }
268
269 let candidates = [
271 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
272 "/Applications/Chrome.app/Contents/MacOS/Chrome",
273 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
274 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
275 "/Applications/Arc.app/Contents/MacOS/Arc",
276 "/Applications/Dia.app/Contents/MacOS/Dia",
277 "/usr/bin/chromium",
278 "/usr/bin/chromium-browser",
279 "/snap/bin/chromium",
280 ];
281
282 for candidate in &candidates {
283 if std::path::Path::new(candidate).exists() {
284 return Some(candidate.to_string());
285 }
286 }
287
288 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
290 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
291 if output.status.success() {
292 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
293 if !path.is_empty() && std::path::Path::new(&path).exists() {
294 return Some(path);
295 }
296 }
297 }
298 }
299
300 None
301 }
302
303 fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
307 if let Some(p) = explicit {
309 if p.exists() {
310 if !Self::is_profile_in_use(&p) {
312 return Some(p);
313 }
314 }
315 }
316
317 #[cfg(target_os = "macos")]
319 if let Some(bundle) = default_browser_bundle() {
320 if let Some(suffix) = browser_profile_suffix(&bundle) {
321 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
322 let path = home.join("Library/Application Support").join(suffix);
323 if path.exists() && !Self::is_profile_in_use(&path) {
324 return Some(path);
325 }
326 }
327 }
328 }
329
330 let common_paths = [
332 "Library/Application Support/Google/Chrome/User Data",
333 "Library/Application Support/Dia/User Data",
334 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
335 ];
336 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
337 for suffix in &common_paths {
338 let path = home.join(suffix);
339 if path.exists() && !Self::is_profile_in_use(&path) {
340 return Some(path);
341 }
342 }
343 }
344
345 None
347 }
348
349 fn seed_profile(profile_dir: &std::path::Path) {
353 let now = std::time::SystemTime::now()
354 .duration_since(std::time::UNIX_EPOCH)
355 .unwrap_or_default()
356 .as_micros() as u64;
357
358 let prefs = serde_json::json!({
360 "browser": {
361 "has_seen_welcome_page": true
362 },
363 "profile": {
364 "exit_type": "Normal"
365 },
366 "default_apps_install_state": 3,
367 "in_product_help": {
368 "session_last_active_time": now.to_string(),
369 "session_number": 5,
370 "session_start_time": now.to_string()
371 }
372 });
373
374 let local_state = serde_json::json!({
376 "browser": {
377 "enabled_labs_experiments": [],
378 "last_redirect_origin": "",
379 "last_whats_new_milestone": "150"
380 },
381 "distribution": {
382 "skip_first_run_ui": true }
384 });
385
386 for sub in &["User Data/Default", "Default"] {
388 let prefs_dir = profile_dir.join(sub);
389 let _ = std::fs::create_dir_all(&prefs_dir);
390 let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
391 }
392
393 for sub in &["User Data", "."] {
395 let state_dir = profile_dir.join(sub);
396 let _ = std::fs::create_dir_all(&state_dir);
397 let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
398 }
399 }
400
401 fn clean_profile_locks(profile_dir: &std::path::Path) {
403 let lock_files = [
404 "SingletonLock",
405 "SingletonSocket",
406 "SingletonCookie",
407 "DevToolsActivePort",
408 ];
409 for name in &lock_files {
410 let path = profile_dir.join(name);
411 if path.exists() {
412 let _ = std::fs::remove_file(&path);
413 }
414 }
415 }
416
417 fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
420 let lock_file = profile_dir.join("SingletonLock");
421 if !lock_file.exists() {
422 return false;
423 }
424 #[cfg(target_os = "macos")]
426 {
427 let output = std::process::Command::new("lsof")
428 .args(["-F", "p", &lock_file.to_string_lossy()])
429 .output()
430 .ok();
431 if let Some(output) = output {
432 if output.status.success() && !output.stdout.is_empty() {
433 return true;
434 }
435 }
436 }
437 false
438 }
439
440 pub fn is_alive(cdp_port: u16) -> bool {
442 Self::probe_port(cdp_port)
443 }
444
445 pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
447 if !Self::probe_port(cdp_port) {
449 return None;
450 }
451
452 let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
454
455 if let Some(ref url) = ws_url {
456 if Self::verify_ws(url).await.is_some() {
458 return Some(Browser {
459 ws_url: url.clone(),
460 cdp_port,
461 });
462 }
463 }
464
465 None
466 }
467
468 pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
470 let mut candidates: Vec<std::path::PathBuf> = Vec::new();
471
472 if let Some(dir) = explicit_profile_dir {
474 candidates.push(dir.to_path_buf());
475 }
476
477 #[cfg(target_os = "macos")]
479 {
480 if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
481 let common = [
482 "Library/Application Support/Dia/User Data",
483 "Library/Application Support/Google/Chrome",
484 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
485 "Library/Application Support/Microsoft Edge/User Data",
486 ];
487 for path in &common {
488 let full = home.join(path);
489 if full.exists() {
490 candidates.push(full);
491 }
492 }
493 }
494 }
495
496 candidates.sort();
498 candidates.dedup();
499
500 for profile_dir in &candidates {
501 let active_port_path = profile_dir.join("DevToolsActivePort");
502 if let Ok(content) = std::fs::read_to_string(&active_port_path) {
503 let lines: Vec<&str> = content.trim().lines().collect();
504 if lines.len() >= 2 {
505 if let Ok(file_port) = lines[0].trim().parse::<u16>() {
506 if file_port == cdp_port {
507 let ws_path = lines[1].trim();
508 return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
509 }
510 }
511 }
512 }
513 }
514
515 None
516 }
517
518 async fn verify_ws(ws_url: &str) -> Option<()> {
520 let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
521 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
522 std::mem::forget(kill_tx);
523 let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
524 conn.call("Browser.getVersion", serde_json::json!({}))
525 .await
526 .ok()?;
527 Some(())
528 }
529
530 pub fn probe_port(cdp_port: u16) -> bool {
533 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
534 for addr in &addrs {
535 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
536 if std::net::TcpStream::connect_timeout(
537 &parsed,
538 std::time::Duration::from_millis(500),
539 )
540 .is_ok()
541 {
542 return true;
543 }
544 }
545 }
546 false
547 }
548
549}
550
551#[cfg(target_os = "macos")]
555pub fn dismiss_allow_debugging_dialog() {
556 let script = r#"tell application "System Events"
558 set diaProcess to first process whose name contains "Dia" or name contains "Google Chrome" or name contains "Chromium"
559 tell diaProcess
560 set allowButton to first button of first window whose description contains "Allow"
561 if allowButton exists then
562 click allowButton
563 end if
564 end tell
565 end tell"#;
566
567 let _ = std::process::Command::new("osascript")
568 .args(["-e", script])
569 .output();
570
571 let _ = std::process::Command::new("osascript")
573 .args(["-e", r#"tell application "System Events" to keystroke return"#])
574 .output();
575}
576
577#[cfg(not(target_os = "macos"))]
579pub fn dismiss_allow_debugging_dialog() {}
580
581impl Drop for Browser {
582 fn drop(&mut self) {
583 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_probe_port_no_server() {
593 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
594 }
595
596 #[test]
597 fn test_find_chrome_returns_some_or_none() {
598 let result = Browser::find_chrome(None);
599 if let Some(path) = result {
601 assert!(
602 std::path::Path::new(&path).exists(),
603 "Chrome path should exist: {}",
604 path
605 );
606 }
607 }
608
609 #[test]
610 fn test_error_types_compile() {
611 let _err = CdpError::LaunchFailed("test".into());
612 let _err = CdpError::NoWsUrl;
613 let _err = CdpError::Timeout(1000);
614 }
615
616 #[test]
617 fn test_launch_flags_include_disable_fre() {
618 let flags = [
619 "--disable-fre",
620 "--disable-search-engine-choice-screen",
621 "--no-first-run",
622 "--no-default-browser-check",
623 "--window-size=1280,720",
624 ];
625 for flag in &flags {
626 assert!(!flag.is_empty(), "Flag should not be empty");
627 }
628 }
629
630 #[test]
631 fn test_launch_flags_exclude_enable_automation() {
632 let forbidden = ["--enable-automation"];
633 for flag in &forbidden {
634 assert!(!flag.is_empty());
635 }
636 }
637
638 #[test]
639 fn test_state_path_removed() {
640 assert!(true, "BrowserState was removed, no state file written");
641 }
642
643 #[test]
644 fn test_fetch_ws_url_removed() {
645 assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
646 }
647
648 #[test]
649 fn test_is_profile_in_use_nonexistent_dir() {
650 let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
651 assert!(!Browser::is_profile_in_use(&tmp));
652 }
653
654 #[test]
655 fn test_wait_for_active_port_invalid_path() {
656 let rt = tokio::runtime::Runtime::new().unwrap();
657 let result = rt.block_on(async {
658 let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
659 true
660 });
661 assert!(result);
662 }
663}