use crate::connection::Connection;
use crate::error::{CdpError, Result};
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use tracing;
pub struct Browser {
ws_url: String,
#[allow(dead_code)]
cdp_port: u16,
}
#[cfg(target_os = "macos")]
fn default_browser_bundle() -> Option<std::path::PathBuf> {
let script = r#"import AppKit; let ws = NSWorkspace.shared; if let url = ws.urlForApplication(toOpen: URL(string: "https://")!) { print(url.path) }"#;
let output = std::process::Command::new("swift")
.args(["-e", script])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let path_str = std::str::from_utf8(&output.stdout).ok()?.trim();
if path_str.is_empty() {
return None;
}
let path = std::path::PathBuf::from(path_str);
if path.exists() { Some(path) } else { None }
}
#[cfg(not(target_os = "macos"))]
fn default_browser_bundle() -> Option<std::path::PathBuf> {
None }
fn browser_exec_name(bundle_path: &std::path::Path) -> Option<&'static str> {
let path_str = bundle_path.to_string_lossy();
if path_str.contains("Google Chrome") {
Some("Google Chrome")
} else if path_str.contains("Dia") {
Some("Dia")
} else if path_str.contains("Arc") {
Some("Arc")
} else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
Some("Brave Browser")
} else if path_str.contains("Microsoft Edge") {
Some("Microsoft Edge")
} else if path_str.contains("Chromium") {
Some("Chromium")
} else {
None
}
}
fn browser_profile_suffix(bundle_path: &std::path::Path) -> Option<&'static str> {
let path_str = bundle_path.to_string_lossy();
if path_str.contains("Google Chrome") {
Some("Google/Chrome/User Data")
} else if path_str.contains("Dia") {
Some("Dia/User Data")
} else if path_str.contains("Arc") {
Some("Arc/User Data")
} else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
Some("BraveSoftware/Brave-Browser/User Data")
} else if path_str.contains("Microsoft Edge") {
Some("Microsoft Edge/User Data")
} else if path_str.contains("Chromium") {
Some("Chromium/User Data")
} else {
None
}
}
#[allow(dead_code)]
fn detect_last_used_profile(user_data_dir: &std::path::Path) -> String {
let local_state_path = user_data_dir.join("Local State");
let content = match std::fs::read_to_string(&local_state_path) {
Ok(c) => c,
Err(_) => return "Default".to_string(),
};
let json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return "Default".to_string(),
};
if let Some(last_used) = json.get("profile").and_then(|p| p.get("last_used")).and_then(|l| l.as_str()) {
if user_data_dir.join(last_used).exists() {
return last_used.to_string();
}
}
if let Some(info_cache) = json.get("profile").and_then(|p| p.get("info_cache")) {
if let Some(obj) = info_cache.as_object() {
if let Some(first_key) = obj.keys().next() {
return first_key.clone();
}
}
}
"Default".to_string()
}
fn bundle_to_exec(bundle: &std::path::Path, exec_name: &str) -> std::path::PathBuf {
bundle.join("Contents").join("MacOS").join(exec_name)
}
impl Browser {
#[allow(clippy::result_large_err)]
pub async fn launch(
browser_path: Option<std::path::PathBuf>,
profile_dir: Option<std::path::PathBuf>,
cdp_port: u16,
) -> Result<Self> {
tracing::info!("Checking for existing browser on port {cdp_port}");
let profile_dir = match Self::resolve_profile(profile_dir) {
Some(dir) => dir,
None => {
let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", cdp_port));
let _ = std::fs::create_dir_all(&tmp);
Self::seed_profile(&tmp);
tmp
}
};
if let Some(browser) = Self::find_existing(Some(&profile_dir), cdp_port).await {
tracing::info!("Found existing browser, reusing");
return Ok(browser);
}
tracing::info!("No existing browser found, launching new one");
let chrome_path = Self::find_chrome(browser_path)
.ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
let port = cdp_port;
if profile_dir.to_string_lossy().contains("/tmp/") {
} else if Self::is_profile_in_use(&profile_dir) {
return Err(CdpError::LaunchFailed(format!(
"Profile {:?} is in use by another browser. Close it first or set GTHINGS_PROFILE_DIR.",
profile_dir
)));
} else {
let dir = profile_dir.clone();
tokio::task::spawn_blocking(move || {
Self::clean_profile_locks(&dir);
})
.await
.map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
}
tracing::info!(
"Launching browser on port {} with profile {:?}",
port,
profile_dir,
);
let mut cmd = Command::new(&chrome_path);
cmd.arg(format!("--remote-debugging-port={}", port))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-fre")
.arg("--disable-search-engine-choice-screen")
.arg("--disable-sync")
.arg("--remote-allow-origins=*")
.arg("--disable-background-networking")
.arg("--disable-extensions")
.arg("--disable-component-update")
.arg("--disable-default-apps")
.arg("--password-store=basic")
.arg("--use-mock-keychain")
.arg("--window-size=1280,720")
.arg(format!("--user-data-dir={}", profile_dir.display()))
.arg("about:blank")
.stderr(Stdio::piped())
.stdout(Stdio::null())
.stdin(Stdio::null());
let mut child = cmd
.spawn()
.map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
let reader = BufReader::new(stderr);
let mut ws_url = None;
for line in reader.lines() {
let line = line.map_err(|e| {
CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
})?;
tracing::debug!("Chrome: {}", line);
if let Some(url) = line.strip_prefix("DevTools listening on ") {
ws_url = Some(url.trim().to_string());
break;
}
}
let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
let pid = child.id();
tracing::info!("Launched persistent browser (pid={})", pid);
drop(child);
Ok(Browser { ws_url, cdp_port })
}
pub async fn connect(&self) -> Result<Connection> {
#[cfg(target_os = "macos")]
dismiss_allow_debugging_dialog();
tracing::info!("Connecting to CDP: {}", self.ws_url);
let (ws_stream, _) = tokio_tungstenite::connect_async(self.ws_url.clone()).await?;
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
std::mem::forget(kill_tx);
Connection::new(ws_stream, kill_rx).await
}
pub fn ws_url(&self) -> &str {
&self.ws_url
}
fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
if let Some(path) = browser_path {
if path.exists() {
return Some(path.to_string_lossy().to_string());
}
}
#[cfg(target_os = "macos")]
if let Some(bundle) = default_browser_bundle() {
if let Some(exec_name) = browser_exec_name(&bundle) {
let exec = bundle_to_exec(&bundle, exec_name);
if exec.exists() {
return Some(exec.to_string_lossy().to_string());
}
}
}
let candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chrome.app/Contents/MacOS/Chrome",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Arc.app/Contents/MacOS/Arc",
"/Applications/Dia.app/Contents/MacOS/Dia",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
];
for candidate in &candidates {
if std::path::Path::new(candidate).exists() {
return Some(candidate.to_string());
}
}
for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
if let Ok(output) = std::process::Command::new("which").arg(name).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && std::path::Path::new(&path).exists() {
return Some(path);
}
}
}
}
None
}
fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
if let Some(p) = explicit {
if p.exists() {
if !Self::is_profile_in_use(&p) {
return Some(p);
}
}
}
#[cfg(target_os = "macos")]
if let Some(bundle) = default_browser_bundle() {
if let Some(suffix) = browser_profile_suffix(&bundle) {
if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
let path = home.join("Library/Application Support").join(suffix);
if path.exists() && !Self::is_profile_in_use(&path) {
return Some(path);
}
}
}
}
let common_paths = [
"Library/Application Support/Google/Chrome/User Data",
"Library/Application Support/Dia/User Data",
"Library/Application Support/BraveSoftware/Brave-Browser/User Data",
];
if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
for suffix in &common_paths {
let path = home.join(suffix);
if path.exists() && !Self::is_profile_in_use(&path) {
return Some(path);
}
}
}
None
}
fn seed_profile(profile_dir: &std::path::Path) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as u64;
let prefs = serde_json::json!({
"browser": {
"has_seen_welcome_page": true
},
"profile": {
"exit_type": "Normal"
},
"default_apps_install_state": 3,
"in_product_help": {
"session_last_active_time": now.to_string(),
"session_number": 5,
"session_start_time": now.to_string()
}
});
let local_state = serde_json::json!({
"browser": {
"enabled_labs_experiments": [],
"last_redirect_origin": "",
"last_whats_new_milestone": "150"
},
"distribution": {
"skip_first_run_ui": true }
});
for sub in &["User Data/Default", "Default"] {
let prefs_dir = profile_dir.join(sub);
let _ = std::fs::create_dir_all(&prefs_dir);
let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
}
for sub in &["User Data", "."] {
let state_dir = profile_dir.join(sub);
let _ = std::fs::create_dir_all(&state_dir);
let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
}
}
fn clean_profile_locks(profile_dir: &std::path::Path) {
let lock_files = [
"SingletonLock",
"SingletonSocket",
"SingletonCookie",
"DevToolsActivePort",
];
for name in &lock_files {
let path = profile_dir.join(name);
if path.exists() {
let _ = std::fs::remove_file(&path);
}
}
}
fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
let lock_file = profile_dir.join("SingletonLock");
if !lock_file.exists() {
return false;
}
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("lsof")
.args(["-F", "p", &lock_file.to_string_lossy()])
.output()
.ok();
if let Some(output) = output {
if output.status.success() && !output.stdout.is_empty() {
return true;
}
}
}
false
}
pub fn is_alive(cdp_port: u16) -> bool {
Self::probe_port(cdp_port)
}
pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
if !Self::probe_port(cdp_port) {
return None;
}
let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
if let Some(ref url) = ws_url {
if Self::verify_ws(url).await.is_some() {
return Some(Browser {
ws_url: url.clone(),
cdp_port,
});
}
}
None
}
pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Some(dir) = explicit_profile_dir {
candidates.push(dir.to_path_buf());
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
let common = [
"Library/Application Support/Dia/User Data",
"Library/Application Support/Google/Chrome",
"Library/Application Support/BraveSoftware/Brave-Browser/User Data",
"Library/Application Support/Microsoft Edge/User Data",
];
for path in &common {
let full = home.join(path);
if full.exists() {
candidates.push(full);
}
}
}
}
candidates.sort();
candidates.dedup();
for profile_dir in &candidates {
let active_port_path = profile_dir.join("DevToolsActivePort");
if let Ok(content) = std::fs::read_to_string(&active_port_path) {
let lines: Vec<&str> = content.trim().lines().collect();
if lines.len() >= 2 {
if let Ok(file_port) = lines[0].trim().parse::<u16>() {
if file_port == cdp_port {
let ws_path = lines[1].trim();
return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
}
}
}
}
}
None
}
async fn verify_ws(ws_url: &str) -> Option<()> {
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
std::mem::forget(kill_tx);
let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
conn.call("Browser.getVersion", serde_json::json!({}))
.await
.ok()?;
Some(())
}
pub fn probe_port(cdp_port: u16) -> bool {
let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
for addr in &addrs {
if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
if std::net::TcpStream::connect_timeout(
&parsed,
std::time::Duration::from_millis(500),
)
.is_ok()
{
return true;
}
}
}
false
}
}
#[cfg(target_os = "macos")]
pub fn dismiss_allow_debugging_dialog() {
let script = r#"tell application "System Events"
set diaProcess to first process whose name contains "Dia" or name contains "Google Chrome" or name contains "Chromium"
tell diaProcess
set allowButton to first button of first window whose description contains "Allow"
if allowButton exists then
click allowButton
end if
end tell
end tell"#;
let _ = std::process::Command::new("osascript")
.args(["-e", script])
.output();
let _ = std::process::Command::new("osascript")
.args(["-e", r#"tell application "System Events" to keystroke return"#])
.output();
}
#[cfg(not(target_os = "macos"))]
pub fn dismiss_allow_debugging_dialog() {}
impl Drop for Browser {
fn drop(&mut self) {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_probe_port_no_server() {
assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
}
#[test]
fn test_find_chrome_returns_some_or_none() {
let result = Browser::find_chrome(None);
if let Some(path) = result {
assert!(
std::path::Path::new(&path).exists(),
"Chrome path should exist: {}",
path
);
}
}
#[test]
fn test_error_types_compile() {
let _err = CdpError::LaunchFailed("test".into());
let _err = CdpError::NoWsUrl;
let _err = CdpError::Timeout(1000);
}
#[test]
fn test_launch_flags_include_disable_fre() {
let flags = [
"--disable-fre",
"--disable-search-engine-choice-screen",
"--no-first-run",
"--no-default-browser-check",
"--window-size=1280,720",
];
for flag in &flags {
assert!(!flag.is_empty(), "Flag should not be empty");
}
}
#[test]
fn test_launch_flags_exclude_enable_automation() {
let forbidden = ["--enable-automation"];
for flag in &forbidden {
assert!(!flag.is_empty());
}
}
#[test]
fn test_state_path_removed() {
assert!(true, "BrowserState was removed, no state file written");
}
#[test]
fn test_fetch_ws_url_removed() {
assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
}
#[test]
fn test_is_profile_in_use_nonexistent_dir() {
let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
assert!(!Browser::is_profile_in_use(&tmp));
}
#[test]
fn test_wait_for_active_port_invalid_path() {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
true
});
assert!(result);
}
}