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(&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 tracing::info!("Connecting to CDP: {}", self.ws_url);
232
233 let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
234 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
236 std::mem::forget(kill_tx);
237 Connection::new(ws_stream, kill_rx).await
238 }
239
240 pub fn ws_url(&self) -> &str {
242 &self.ws_url
243 }
244
245 pub async fn pid(&self) -> u64 {
247 0
248 }
249
250 fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
252 if let Some(path) = browser_path {
254 if path.exists() {
255 return Some(path.to_string_lossy().to_string());
256 }
257 }
258
259 #[cfg(target_os = "macos")]
261 if let Some(bundle) = default_browser_bundle() {
262 if let Some(exec_name) = browser_exec_name(&bundle) {
263 let exec = bundle_to_exec(&bundle, exec_name);
264 if exec.exists() {
265 return Some(exec.to_string_lossy().to_string());
266 }
267 }
268 }
269
270 let candidates = [
272 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
273 "/Applications/Chrome.app/Contents/MacOS/Chrome",
274 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
275 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
276 "/Applications/Arc.app/Contents/MacOS/Arc",
277 "/Applications/Dia.app/Contents/MacOS/Dia",
278 "/usr/bin/chromium",
279 "/usr/bin/chromium-browser",
280 "/snap/bin/chromium",
281 ];
282
283 for candidate in &candidates {
284 if std::path::Path::new(candidate).exists() {
285 return Some(candidate.to_string());
286 }
287 }
288
289 for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
291 if let Ok(output) = std::process::Command::new("which").arg(name).output() {
292 if output.status.success() {
293 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
294 if !path.is_empty() && std::path::Path::new(&path).exists() {
295 return Some(path);
296 }
297 }
298 }
299 }
300
301 None
302 }
303
304 fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
308 if let Some(p) = explicit {
310 if p.exists() {
311 if !Self::is_profile_in_use(&p) {
313 return Some(p);
314 }
315 }
316 }
317
318 #[cfg(target_os = "macos")]
320 if let Some(bundle) = default_browser_bundle() {
321 if let Some(suffix) = browser_profile_suffix(&bundle) {
322 if let Some(home) = Self::home_dir() {
323 let path = home.join("Library/Application Support").join(suffix);
324 if path.exists() && !Self::is_profile_in_use(&path) {
325 return Some(path);
326 }
327 }
328 }
329 }
330
331 let common_paths = [
333 "Library/Application Support/Google/Chrome/User Data",
334 "Library/Application Support/Dia/User Data",
335 "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
336 ];
337 if let Some(home) = Self::home_dir() {
338 for suffix in &common_paths {
339 let path = home.join(suffix);
340 if path.exists() && !Self::is_profile_in_use(&path) {
341 return Some(path);
342 }
343 }
344 }
345
346 None
348 }
349
350 fn home_dir() -> Option<std::path::PathBuf> {
351 std::env::var("HOME").ok().map(std::path::PathBuf::from)
352 }
353
354 fn seed_profile(profile_dir: &std::path::Path) {
358 let now = std::time::SystemTime::now()
359 .duration_since(std::time::UNIX_EPOCH)
360 .unwrap_or_default()
361 .as_micros() as u64;
362
363 let prefs = serde_json::json!({
365 "browser": {
366 "has_seen_welcome_page": true
367 },
368 "profile": {
369 "exit_type": "Normal"
370 },
371 "default_apps_install_state": 3,
372 "in_product_help": {
373 "session_last_active_time": now.to_string(),
374 "session_number": 5,
375 "session_start_time": now.to_string()
376 }
377 });
378
379 let local_state = serde_json::json!({
381 "browser": {
382 "enabled_labs_experiments": [],
383 "last_redirect_origin": "",
384 "last_whats_new_milestone": "150"
385 },
386 "distribution": {
387 "skip_first_run_ui": true }
389 });
390
391 for sub in &["User Data/Default", "Default"] {
393 let prefs_dir = profile_dir.join(sub);
394 let _ = std::fs::create_dir_all(&prefs_dir);
395 let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
396 }
397
398 for sub in &["User Data", "."] {
400 let state_dir = profile_dir.join(sub);
401 let _ = std::fs::create_dir_all(&state_dir);
402 let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
403 }
404 }
405
406 fn clean_profile_locks(profile_dir: &std::path::Path) {
408 let lock_files = [
409 "SingletonLock",
410 "SingletonSocket",
411 "SingletonCookie",
412 "DevToolsActivePort",
413 ];
414 for name in &lock_files {
415 let path = profile_dir.join(name);
416 if path.exists() {
417 let _ = std::fs::remove_file(&path);
418 }
419 }
420 }
421
422 fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
425 let lock_file = profile_dir.join("SingletonLock");
426 if !lock_file.exists() {
427 return false;
428 }
429 #[cfg(target_os = "macos")]
431 {
432 let output = std::process::Command::new("lsof")
433 .args(["-F", "p", &lock_file.to_string_lossy()])
434 .output()
435 .ok();
436 if let Some(output) = output {
437 if output.status.success() && !output.stdout.is_empty() {
438 return true;
439 }
440 }
441 }
442 false
443 }
444
445 pub fn is_alive(cdp_port: u16) -> bool {
447 Self::probe_port(cdp_port)
448 }
449
450 pub async fn find_existing(profile_dir: &std::path::Path, cdp_port: u16) -> Option<Self> {
452 let active_port_path = profile_dir.join("DevToolsActivePort");
454 let content = std::fs::read_to_string(&active_port_path).ok()?;
455 let lines: Vec<&str> = content.trim().lines().collect();
456 if lines.len() < 2 {
457 return None;
458 }
459
460 let file_port: u16 = lines[0].trim().parse().ok()?;
461 let ws_path = lines[1].trim();
462 if file_port != cdp_port {
463 return None;
464 }
465
466 if !Self::probe_port(cdp_port) {
468 return None;
469 }
470
471 let ws_url = format!("ws://127.0.0.1:{}{}", cdp_port, ws_path);
473 Self::verify_ws(&ws_url).await?;
474
475 tracing::info!("Found existing browser on port {cdp_port}");
476
477 Some(Browser { ws_url, cdp_port })
478 }
479
480 async fn verify_ws(ws_url: &str) -> Option<()> {
482 let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
483 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
484 std::mem::forget(kill_tx);
485 let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
486 conn.call("Browser.getVersion", serde_json::json!({}))
487 .await
488 .ok()?;
489 Some(())
490 }
491
492 fn probe_port(cdp_port: u16) -> bool {
495 let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
496 for addr in &addrs {
497 if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
498 if std::net::TcpStream::connect_timeout(
499 &parsed,
500 std::time::Duration::from_millis(500),
501 )
502 .is_ok()
503 {
504 return true;
505 }
506 }
507 }
508 false
509 }
510
511}
512
513impl Drop for Browser {
514 fn drop(&mut self) {
515 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn test_probe_port_no_server() {
525 assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
526 }
527
528 #[test]
529 fn test_find_chrome_returns_some_or_none() {
530 let result = Browser::find_chrome(None);
531 if let Some(path) = result {
533 assert!(
534 std::path::Path::new(&path).exists(),
535 "Chrome path should exist: {}",
536 path
537 );
538 }
539 }
540
541 #[test]
542 fn test_error_types_compile() {
543 let _err = CdpError::LaunchFailed("test".into());
544 let _err = CdpError::NoWsUrl;
545 let _err = CdpError::Timeout(1000);
546 }
547
548 #[test]
549 fn test_launch_flags_include_disable_fre() {
550 let flags = [
551 "--disable-fre",
552 "--disable-search-engine-choice-screen",
553 "--no-first-run",
554 "--no-default-browser-check",
555 "--window-size=1280,720",
556 ];
557 for flag in &flags {
558 assert!(!flag.is_empty(), "Flag should not be empty");
559 }
560 }
561
562 #[test]
563 fn test_launch_flags_exclude_enable_automation() {
564 let forbidden = ["--enable-automation"];
565 for flag in &forbidden {
566 assert!(!flag.is_empty());
567 }
568 }
569
570 #[test]
571 fn test_state_path_removed() {
572 assert!(true, "BrowserState was removed, no state file written");
573 }
574
575 #[test]
576 fn test_fetch_ws_url_removed() {
577 assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
578 }
579
580 #[test]
581 fn test_is_profile_in_use_nonexistent_dir() {
582 let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
583 assert!(!Browser::is_profile_in_use(&tmp));
584 }
585
586 #[test]
587 fn test_wait_for_active_port_invalid_path() {
588 let rt = tokio::runtime::Runtime::new().unwrap();
589 let result = rt.block_on(async {
590 let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
591 true
592 });
593 assert!(result);
594 }
595}