use crate::color; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::{exit, Command, Stdio}; const LAST_KNOWN_GOOD_URL: &str = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"; pub fn get_browsers_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".agent-browser") .join("browsers") } pub fn find_installed_chrome() -> Option { let browsers_dir = get_browsers_dir(); if !browsers_dir.exists() { return None; } let mut versions: Vec<_> = fs::read_dir(&browsers_dir) .ok()? .filter_map(|e| e.ok()) .filter(|e| { e.file_name() .to_str() .is_some_and(|n| n.starts_with("chrome-")) }) .collect(); versions.sort_by_key(|b| std::cmp::Reverse(b.file_name())); for entry in versions { if let Some(bin) = chrome_binary_in_dir(&entry.path()) { if bin.exists() { return Some(bin); } } } None } fn chrome_binary_in_dir(dir: &Path) -> Option { #[cfg(target_os = "macos")] { let app = dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"); if app.exists() { return Some(app); } let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"); if inner.exists() { return Some(inner); } let inner_x64 = dir.join( "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", ); if inner_x64.exists() { return Some(inner_x64); } None } #[cfg(target_os = "linux")] { let bin = dir.join("chrome"); if bin.exists() { return Some(bin); } let inner = dir.join("chrome-linux64/chrome"); if inner.exists() { return Some(inner); } None } #[cfg(target_os = "windows")] { let bin = dir.join("chrome.exe"); if bin.exists() { return Some(bin); } let inner = dir.join("chrome-win64/chrome.exe"); if inner.exists() { return Some(inner); } None } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] { None } } fn platform_key() -> &'static str { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { "mac-arm64" } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { "mac-x64" } #[cfg(all(target_os = "linux", target_arch = "x86_64"))] { "linux64" } #[cfg(all(target_os = "windows", target_arch = "x86_64"))] { "win64" } #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "macos", target_arch = "x86_64"), all(target_os = "linux", target_arch = "x86_64"), all(target_os = "windows", target_arch = "x86_64"), )))] { // Compiles on unsupported platforms (e.g. linux aarch64) so the binary // can still be used for other commands like `connect`. The install path // guards against this at runtime before calling platform_key(). panic!("Unsupported platform for Chrome for Testing download") } } async fn fetch_download_url() -> Result<(String, String), String> { let resp = reqwest::get(LAST_KNOWN_GOOD_URL) .await .map_err(|e| format!("Failed to fetch version info: {}", e))?; let body: serde_json::Value = resp .json() .await .map_err(|e| format!("Failed to parse version info: {}", e))?; let channel = body .get("channels") .and_then(|c| c.get("Stable")) .ok_or("No Stable channel found in version info")?; let version = channel .get("version") .and_then(|v| v.as_str()) .ok_or("No version string found")? .to_string(); let platform = platform_key(); let url = channel .get("downloads") .and_then(|d| d.get("chrome")) .and_then(|c| c.as_array()) .and_then(|arr| { arr.iter().find_map(|entry| { if entry.get("platform")?.as_str()? == platform { Some(entry.get("url")?.as_str()?.to_string()) } else { None } }) }) .ok_or_else(|| format!("No download URL found for platform: {}", platform))?; Ok((version, url)) } async fn download_bytes(url: &str) -> Result, String> { let resp = reqwest::get(url) .await .map_err(|e| format!("Download failed: {}", e))?; let total = resp.content_length(); let mut bytes = Vec::new(); let mut stream = resp; let mut downloaded: u64 = 0; let mut last_pct: u64 = 0; loop { let chunk = stream .chunk() .await .map_err(|e| format!("Download error: {}", e))?; match chunk { Some(data) => { downloaded += data.len() as u64; bytes.extend_from_slice(&data); if let Some(total) = total { let pct = (downloaded * 100) / total; if pct >= last_pct + 5 { last_pct = pct; let mb = downloaded as f64 / 1_048_576.0; let total_mb = total as f64 / 1_048_576.0; eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb); let _ = io::stderr().flush(); } } } None => break, } } eprintln!(); Ok(bytes) } fn extract_zip(bytes: Vec, dest: &Path) -> Result<(), String> { fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?; let cursor = io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; for i in 0..archive.len() { let mut file = archive .by_index(i) .map_err(|e| format!("Failed to read zip entry: {}", e))?; let enclosed = match file.enclosed_name() { Some(name) => name.to_owned(), None => continue, }; let raw_name = enclosed.to_string_lossy().to_string(); let rel_path = raw_name .strip_prefix("chrome-") .and_then(|s| s.split_once('/')) .map(|(_, rest)| rest.to_string()) .unwrap_or(raw_name.clone()); if rel_path.is_empty() { continue; } let out_path = dest.join(&rel_path); // Defense-in-depth: ensure the resolved path is inside dest if !out_path.starts_with(dest) { continue; } if file.is_dir() { fs::create_dir_all(&out_path) .map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?; } else { if let Some(parent) = out_path.parent() { fs::create_dir_all(parent).map_err(|e| { format!("Failed to create parent dir {}: {}", parent.display(), e) })?; } let mut out_file = fs::File::create(&out_path) .map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?; io::copy(&mut file, &mut out_file) .map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Some(mode) = file.unix_mode() { let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode)); } } } } Ok(()) } pub fn run_install(with_deps: bool) { if cfg!(all(target_os = "linux", target_arch = "aarch64")) { eprintln!( "{} Chrome for Testing does not provide Linux ARM64 builds.", color::error_indicator() ); eprintln!(" Install Chromium from your system package manager instead:"); eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu"); eprintln!(" sudo dnf install chromium # Fedora"); eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium"); exit(1); } let is_linux = cfg!(target_os = "linux"); if is_linux { if with_deps { install_linux_deps(); } else { println!( "{} Linux detected. If browser fails to launch, run:", color::warning_indicator() ); println!(" agent-browser install --with-deps"); println!(); } } println!("{}", color::cyan("Installing Chrome...")); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap_or_else(|e| { eprintln!( "{} Failed to create runtime: {}", color::error_indicator(), e ); exit(1); }); let (version, url) = match rt.block_on(fetch_download_url()) { Ok(v) => v, Err(e) => { eprintln!("{} {}", color::error_indicator(), e); exit(1); } }; let dest = get_browsers_dir().join(format!("chrome-{}", version)); if let Some(bin) = chrome_binary_in_dir(&dest) { if bin.exists() { println!( "{} Chrome {} is already installed", color::success_indicator(), version ); return; } } println!(" Downloading Chrome {} for {}", version, platform_key()); println!(" {}", url); let bytes = match rt.block_on(download_bytes(&url)) { Ok(b) => b, Err(e) => { eprintln!("{} {}", color::error_indicator(), e); exit(1); } }; match extract_zip(bytes, &dest) { Ok(()) => { println!( "{} Chrome {} installed successfully", color::success_indicator(), version ); println!(" Location: {}", dest.display()); if is_linux && !with_deps { println!(); println!( "{} If you see \"shared library\" errors when running, use:", color::yellow("Note:") ); println!(" agent-browser install --with-deps"); } } Err(e) => { let _ = fs::remove_dir_all(&dest); eprintln!("{} {}", color::error_indicator(), e); exit(1); } } } fn install_linux_deps() { println!("{}", color::cyan("Installing system dependencies...")); let (pkg_mgr, deps) = if which_exists("apt-get") { let libasound = if package_exists_apt("libasound2t64") { "libasound2t64" } else { "libasound2" }; ( "apt-get", vec![ "libxcb-shm0", "libx11-xcb1", "libx11-6", "libxcb1", "libxext6", "libxrandr2", "libxcomposite1", "libxcursor1", "libxdamage1", "libxfixes3", "libxi6", "libgtk-3-0", "libpangocairo-1.0-0", "libpango-1.0-0", "libatk1.0-0", "libcairo-gobject2", "libcairo2", "libgdk-pixbuf-2.0-0", "libxrender1", libasound, "libfreetype6", "libfontconfig1", "libdbus-1-3", "libnss3", "libnspr4", "libatk-bridge2.0-0", "libdrm2", "libxkbcommon0", "libatspi2.0-0", "libcups2", "libxshmfence1", "libgbm1", ], ) } else if which_exists("dnf") { ( "dnf", vec![ "nss", "nspr", "atk", "at-spi2-atk", "cups-libs", "libdrm", "libXcomposite", "libXdamage", "libXrandr", "mesa-libgbm", "pango", "alsa-lib", "libxkbcommon", "libxcb", "libX11-xcb", "libX11", "libXext", "libXcursor", "libXfixes", "libXi", "gtk3", "cairo-gobject", ], ) } else if which_exists("yum") { ( "yum", vec![ "nss", "nspr", "atk", "at-spi2-atk", "cups-libs", "libdrm", "libXcomposite", "libXdamage", "libXrandr", "mesa-libgbm", "pango", "alsa-lib", "libxkbcommon", ], ) } else { eprintln!( "{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator() ); exit(1); }; let install_cmd = match pkg_mgr { "apt-get" => { format!( "sudo apt-get update && sudo apt-get install -y {}", deps.join(" ") ) } _ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")), }; println!("Running: {}", install_cmd); let status = Command::new("sh").arg("-c").arg(&install_cmd).status(); match status { Ok(s) if s.success() => { println!( "{} System dependencies installed", color::success_indicator() ) } Ok(_) => eprintln!( "{} Failed to install some dependencies. You may need to run manually with sudo.", color::warning_indicator() ), Err(e) => eprintln!( "{} Could not run install command: {}", color::warning_indicator(), e ), } } fn which_exists(cmd: &str) -> bool { #[cfg(unix)] { Command::new("which") .arg(cmd) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } #[cfg(windows)] { Command::new("where") .arg(cmd) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } } fn package_exists_apt(pkg: &str) -> bool { Command::new("apt-cache") .arg("show") .arg(pkg) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) }