use std::path::{Path, PathBuf};
use futures_util::StreamExt;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use crate::{Error, Result};
const CFT_ENDPOINT: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
const USER_AGENT: &str = concat!("drission-rs/", env!("CARGO_PKG_VERSION"));
pub const DEFAULT_CHANNEL: &str = "Stable";
#[derive(Debug, Deserialize)]
struct CftIndex {
channels: std::collections::HashMap<String, Channel>,
}
#[derive(Debug, Deserialize)]
struct Channel {
#[serde(default)]
version: String,
downloads: Downloads,
}
#[derive(Debug, Deserialize)]
struct Downloads {
#[serde(default)]
chrome: Vec<Download>,
}
#[derive(Debug, Deserialize)]
struct Download {
platform: String,
url: String,
}
pub fn cft_platform() -> Result<&'static str> {
let p = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "mac-arm64",
("macos", "x86_64") => "mac-x64",
("windows", "x86_64") => "win64",
("windows", "x86") => "win32",
("linux", "x86_64") => "linux64",
(os, arch) => {
return Err(Error::UnsupportedPlatform(format!(
"Chrome for Testing 无 {os}/{arch} 资产"
)));
}
};
Ok(p)
}
pub fn cache_root() -> PathBuf {
if let Ok(custom) = std::env::var("DRISSION_CACHE") {
return PathBuf::from(custom);
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".cache").join("drission")
}
fn chrome_exe_name(platform: &str) -> &'static str {
if platform.starts_with("mac") {
"Google Chrome for Testing"
} else if platform.starts_with("win") {
"chrome.exe"
} else {
"chrome"
}
}
pub async fn ensure_chrome() -> Result<PathBuf> {
if let Ok(p) = super::locate::chrome_path() {
tracing::debug!(path = %p.display(), "使用系统已定位的 Chrome");
return Ok(p);
}
let platform = cft_platform()?;
download_chrome_for(platform, DEFAULT_CHANNEL).await
}
pub async fn download_chrome_for(platform: &str, channel: &str) -> Result<PathBuf> {
let chrome_root = cache_root().join("chrome");
let dest = chrome_root.join(platform);
let exe_name = chrome_exe_name(platform);
if let Some(found) = find_executable(&dest, exe_name) {
tracing::debug!(path = %found.display(), "复用缓存中的 Chrome for Testing");
return Ok(found);
}
tokio::fs::create_dir_all(&dest).await?;
let zip_path = chrome_root.join(format!("chrome-{platform}.zip"));
if zip_path.exists() {
tracing::info!(path = %zip_path.display(), "发现预下载的 Chrome zip,直接解压复用");
} else {
let (version, url) = pick_asset(platform, channel).await?;
tracing::info!(%version, %platform, %channel, "缓存未命中,开始下载 Chrome for Testing …");
download(&url, &zip_path).await?;
}
let dest_clone = dest.clone();
let zip_clone = zip_path.clone();
tokio::task::spawn_blocking(move || extract_zip(&zip_clone, &dest_clone))
.await
.map_err(|e| Error::Other(format!("解压任务 join 失败: {e}")))??;
let _ = tokio::fs::remove_file(&zip_path).await;
let found = find_executable(&dest, exe_name).ok_or_else(|| {
Error::BrowserNotFound(format!(
"解压后未找到 Chrome 可执行文件({exe_name}),目录: {}",
dest.display()
))
})?;
#[cfg(unix)]
ensure_executable_bit(&found);
Ok(found)
}
async fn pick_asset(platform: &str, channel: &str) -> Result<(String, String)> {
let client = reqwest::Client::builder().user_agent(USER_AGENT).build()?;
let index: CftIndex = client
.get(CFT_ENDPOINT)
.send()
.await?
.error_for_status()?
.json()
.await?;
let ch = index.channels.get(channel).ok_or_else(|| {
Error::BrowserNotFound(format!(
"Chrome for Testing 无渠道 `{channel}`(可选 Stable/Beta/Dev/Canary)"
))
})?;
for dl in &ch.downloads.chrome {
if dl.platform == platform {
return Ok((ch.version.clone(), dl.url.clone()));
}
}
Err(Error::BrowserNotFound(format!(
"Chrome for Testing 渠道 `{channel}` 无平台 `{platform}` 的 chrome 资产"
)))
}
async fn download(url: &str, dest: &Path) -> Result<()> {
let client = reqwest::Client::builder().user_agent(USER_AGENT).build()?;
let resp = client.get(url).send().await?.error_for_status()?;
let total = resp.content_length().unwrap_or(0);
let mut file = tokio::fs::File::create(dest).await?;
let mut downloaded: u64 = 0;
let mut last_logged: u64 = 0;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
downloaded += chunk.len() as u64;
if downloaded - last_logged > 16 * 1024 * 1024 {
last_logged = downloaded;
if total > 0 {
tracing::info!(
"下载进度 {:.1}% ({}/{} MiB)",
downloaded as f64 / total as f64 * 100.0,
downloaded / 1024 / 1024,
total / 1024 / 1024
);
}
}
}
file.flush().await?;
Ok(())
}
fn find_executable(root: &Path, target: &str) -> Option<PathBuf> {
fn walk(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
if depth == 0 {
return None;
}
let entries = std::fs::read_dir(dir).ok()?;
let mut subdirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_file() && entry.file_name().to_string_lossy() == target {
return Some(path);
}
if ft.is_dir() {
subdirs.push(path);
}
}
for sub in subdirs {
if let Some(found) = walk(&sub, target, depth - 1) {
return Some(found);
}
}
None
}
walk(root, target, 8)
}
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let rel = match entry.enclosed_name() {
Some(p) => p,
None => continue, };
let outpath = dest.join(rel);
let mode = entry.unix_mode();
let is_symlink = mode.map(|m| m & 0o170000 == 0o120000).unwrap_or(false);
if entry.is_dir() {
std::fs::create_dir_all(&outpath)?;
continue;
}
if let Some(parent) = outpath.parent() {
std::fs::create_dir_all(parent)?;
}
if is_symlink {
#[cfg(unix)]
{
use std::io::Read;
let mut target = String::new();
entry.read_to_string(&mut target)?;
let _ = std::fs::remove_file(&outpath);
std::os::unix::fs::symlink(&target, &outpath)?;
}
#[cfg(not(unix))]
{
let mut out = std::fs::File::create(&outpath)?;
std::io::copy(&mut entry, &mut out)?;
}
continue;
}
let mut out = std::fs::File::create(&outpath)?;
std::io::copy(&mut entry, &mut out)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(m) = mode {
std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(m))?;
}
}
}
Ok(())
}
#[cfg(unix)]
fn ensure_executable_bit(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mut perm = meta.permissions();
let mode = perm.mode();
if mode & 0o111 == 0 {
perm.set_mode(mode | 0o755);
let _ = std::fs::set_permissions(path, perm);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cft_platform_is_known() {
let p = cft_platform().expect("当前平台应被支持");
assert!(["mac-arm64", "mac-x64", "win64", "win32", "linux64"].contains(&p));
}
#[test]
fn chrome_exe_name_per_platform() {
assert_eq!(chrome_exe_name("mac-arm64"), "Google Chrome for Testing");
assert_eq!(chrome_exe_name("mac-x64"), "Google Chrome for Testing");
assert_eq!(chrome_exe_name("win64"), "chrome.exe");
assert_eq!(chrome_exe_name("win32"), "chrome.exe");
assert_eq!(chrome_exe_name("linux64"), "chrome");
}
#[test]
fn cache_root_under_home_or_override() {
let r = cache_root();
assert!(r.ends_with("drission") || std::env::var("DRISSION_CACHE").is_ok());
}
#[test]
fn find_executable_locates_nested_file() {
let base = std::env::temp_dir().join(format!(
"drission_fetch_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let nested = base.join("chrome-x").join("a.app").join("Contents");
std::fs::create_dir_all(&nested).expect("建嵌套目录");
let exe = nested.join("target-exe");
std::fs::write(&exe, b"x").expect("写目标文件");
assert_eq!(
find_executable(&base, "target-exe").as_deref(),
Some(exe.as_path())
);
assert!(find_executable(&base, "no-such-file").is_none());
let _ = std::fs::remove_dir_all(&base);
}
}