use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{info, warn};
use super::types::InstallTarget;
use crate::error::{AuditError, Result};
const CFT_API_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
const FALLBACK_VERSION: &str = "131.0.6778.108";
pub struct BrowserInstaller;
impl BrowserInstaller {
pub async fn install(
target: InstallTarget,
version: Option<&str>,
force: bool,
) -> Result<PathBuf> {
let base_dir = Self::browsers_dir()?;
let (target_name, channel) = match target {
InstallTarget::ChromeForTesting => ("chrome-for-testing", "chrome"),
InstallTarget::HeadlessShell => ("headless-shell", "chrome-headless-shell"),
};
let target_dir = base_dir.join(target_name);
if target_dir.exists() && !force {
let existing_version = fs::read_to_string(target_dir.join("version.txt"))
.unwrap_or_default()
.trim()
.to_string();
if !existing_version.is_empty() {
println!("Already installed: {} v{}", target_name, existing_version);
println!("Use --force to reinstall.");
let binary_path = Self::binary_path(&target_dir, target);
return Ok(binary_path);
}
}
let resolved_version = if let Some(v) = version {
v.to_string()
} else {
Self::fetch_latest_version().await.unwrap_or_else(|e| {
warn!("Could not fetch latest version from API: {}", e);
info!("Using fallback version: {}", FALLBACK_VERSION);
FALLBACK_VERSION.to_string()
})
};
println!("Installing {} v{}...", target_name, resolved_version);
let download_url = Self::build_download_url(&resolved_version, channel);
let archive_name = format!("{}.zip", target_name);
if target_dir.exists() {
fs::remove_dir_all(&target_dir).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to clean existing install: {}", e),
})?;
}
fs::create_dir_all(&target_dir).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to create install directory: {}", e),
})?;
let archive_path = target_dir.join(&archive_name);
println!("Downloading from: {}", download_url);
Self::download_file(&download_url, &archive_path).await?;
println!("Extracting...");
Self::extract_archive(&archive_path, &target_dir)?;
if let Err(e) = fs::remove_file(&archive_path) {
warn!(
"Failed to remove browser archive {}: {}",
archive_path.display(),
e
);
}
if let Err(e) = fs::write(target_dir.join("version.txt"), &resolved_version) {
warn!("Failed to write version.txt for {}: {}", target_name, e);
}
let binary_path = Self::binary_path(&target_dir, target);
Self::set_executable(&binary_path)?;
#[cfg(target_os = "macos")]
Self::remove_quarantine(&target_dir);
println!(
"Installed {} v{} at {}",
target_name,
resolved_version,
binary_path.display()
);
Ok(binary_path)
}
pub fn remove(target: InstallTarget) -> Result<()> {
let base_dir = Self::browsers_dir()?;
let target_name = match target {
InstallTarget::ChromeForTesting => "chrome-for-testing",
InstallTarget::HeadlessShell => "headless-shell",
};
let target_dir = base_dir.join(target_name);
if target_dir.exists() {
fs::remove_dir_all(&target_dir).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to remove {}: {}", target_name, e),
})?;
println!("Removed {}", target_name);
} else {
println!("{} is not installed", target_name);
}
Ok(())
}
pub fn remove_all() -> Result<()> {
Self::remove(InstallTarget::ChromeForTesting)?;
Self::remove(InstallTarget::HeadlessShell)?;
if let Ok(legacy_dir) = Self::legacy_chromium_dir() {
if legacy_dir.exists() {
match fs::remove_dir_all(&legacy_dir) {
Ok(()) => println!("Removed legacy chromium install"),
Err(e) => warn!("Failed to remove legacy chromium install: {}", e),
}
}
}
Ok(())
}
async fn fetch_latest_version() -> Result<String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("HTTP client error: {}", e),
})?;
let resp: serde_json::Value = client
.get(CFT_API_URL)
.send()
.await
.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to fetch versions: {}", e),
})?
.json()
.await
.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to parse versions: {}", e),
})?;
resp["channels"]["Stable"]["version"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| AuditError::BrowserLaunchFailed {
reason: "Could not find stable version in API response".to_string(),
})
}
fn build_download_url(version: &str, channel: &str) -> String {
let platform = Self::platform_string();
format!(
"https://storage.googleapis.com/chrome-for-testing-public/{}/{}/{}-{}.zip",
version, platform, channel, platform
)
}
fn platform_string() -> &'static str {
if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
"mac-arm64"
} else {
"mac-x64"
}
} else if cfg!(target_os = "linux") {
"linux64"
} else {
"win64"
}
}
pub(crate) fn binary_path(target_dir: &Path, target: InstallTarget) -> PathBuf {
let platform = Self::platform_string();
match target {
InstallTarget::ChromeForTesting => {
let inner_dir = format!("chrome-{}", platform);
if cfg!(target_os = "macos") {
target_dir
.join(&inner_dir)
.join("Google Chrome for Testing.app")
.join("Contents")
.join("MacOS")
.join("Google Chrome for Testing")
} else if cfg!(target_os = "windows") {
target_dir.join(&inner_dir).join("chrome.exe")
} else {
target_dir.join(&inner_dir).join("chrome")
}
}
InstallTarget::HeadlessShell => {
let inner_dir = format!("chrome-headless-shell-{}", platform);
if cfg!(target_os = "windows") {
target_dir
.join(&inner_dir)
.join("chrome-headless-shell.exe")
} else {
target_dir.join(&inner_dir).join("chrome-headless-shell")
}
}
}
}
fn browsers_dir() -> Result<PathBuf> {
let dir = dirs::home_dir()
.ok_or_else(|| AuditError::ConfigError("Could not find home directory".to_string()))?
.join(".auditmysite")
.join("browsers");
Ok(dir)
}
fn legacy_chromium_dir() -> Result<PathBuf> {
let dir = dirs::home_dir()
.ok_or_else(|| AuditError::ConfigError("Could not find home directory".to_string()))?
.join(".auditmysite")
.join("chromium");
Ok(dir)
}
async fn download_file(url: &str, dest: &Path) -> Result<()> {
use futures::StreamExt;
let max_retries = 2;
let mut last_error = None;
let mut response = None;
for attempt in 0..=max_retries {
if attempt > 0 {
let delay = Duration::from_secs(2u64.pow(attempt as u32));
warn!(
"Retrying download (attempt {}/{}, backoff {:?})",
attempt + 1,
max_retries + 1,
delay
);
tokio::time::sleep(delay).await;
}
match reqwest::get(url).await {
Ok(resp) if resp.status().is_success() => {
response = Some(resp);
break;
}
Ok(resp) => {
last_error = Some(format!("HTTP {}", resp.status()));
}
Err(e) => {
last_error = Some(format!("Download failed: {}", e));
}
}
}
let response = response.ok_or_else(|| AuditError::BrowserLaunchFailed {
reason: last_error.unwrap_or_else(|| "Download failed after retries".to_string()),
})?;
let total_size = response.content_length().unwrap_or(0);
let mut downloaded = 0u64;
let mut file = fs::File::create(dest).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to create file: {}", e),
})?;
let mut stream = response.bytes_stream();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Download chunk failed: {}", e),
})?;
file.write_all(&chunk)
.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Write failed: {}", e),
})?;
downloaded += chunk.len() as u64;
if total_size > 0 {
let percent = (downloaded * 100).checked_div(total_size).unwrap_or(0);
if downloaded % (total_size / 10).max(1) < chunk.len() as u64 {
println!(
" {}% ({}/{} MB)",
percent,
downloaded / 1_000_000,
total_size / 1_000_000
);
}
}
}
Ok(())
}
fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<()> {
let file = fs::File::open(archive_path).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to open archive: {}", e),
})?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to read zip: {}", e),
})?;
let total_files = archive.len();
for i in 0..total_files {
let mut file = archive
.by_index(i)
.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to read file from archive: {}", e),
})?;
let outpath = match file.enclosed_name() {
Some(path) => dest_dir.join(path),
None => continue,
};
if file.name().ends_with('/') {
fs::create_dir_all(&outpath).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to create directory during extraction: {}", e),
})?;
} else {
if let Some(p) = outpath.parent() {
fs::create_dir_all(p).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to create directory during extraction: {}", e),
})?;
}
let mut outfile =
fs::File::create(&outpath).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to create extracted file: {}", e),
})?;
std::io::copy(&mut file, &mut outfile).map_err(|e| {
AuditError::BrowserLaunchFailed {
reason: format!("Failed to extract file: {}", e),
}
})?;
}
}
Ok(())
}
fn set_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if path.exists() {
let mut perms = fs::metadata(path)
.map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to get permissions: {}", e),
})?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).map_err(|e| AuditError::BrowserLaunchFailed {
reason: format!("Failed to set permissions: {}", e),
})?;
}
}
Ok(())
}
#[cfg(target_os = "macos")]
fn remove_quarantine(dir: &Path) {
use std::process::Command;
match Command::new("xattr")
.args(["-rd", "com.apple.quarantine"])
.arg(dir)
.output()
{
Ok(output) if !output.status.success() => {
warn!(
"xattr failed to remove quarantine attribute from {}: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
warn!(
"Failed to run xattr to remove quarantine attribute from {}: {}",
dir.display(),
e
);
}
Ok(_) => {}
}
}
}