use std::path::{Path, PathBuf};
use tokio::fs;
use crate::{
download::{download_file_with_progress, DownloadTask},
error::{HexoError, Result},
install::loader::{no_progress, ProgressFn},
};
const ADOPTIUM_API: &str = "https://api.adoptium.net/v3/assets/latest";
pub async fn download_java(version: u32, base_dir: &Path) -> Result<PathBuf> {
download_java_with_progress(version, base_dir, no_progress()).await
}
pub async fn download_java_with_progress(
version: u32,
base_dir: &Path,
progress: ProgressFn,
) -> Result<PathBuf> {
let java_dir = base_dir.join("java").join(version.to_string());
let extract_dir = java_dir.join("jre");
if let Some(java_bin) = find_java_bin_in(&extract_dir) {
progress(0, 0, "Using installed Java");
return Ok(java_bin);
}
fs::create_dir_all(&java_dir).await?;
progress(0, 0, "Fetching Java release");
let (os, arch, archive_type) = platform_info();
let url = format!(
"{}/{}/hotspot?image_type=jre&os={}&architecture={}&vendor=eclipse",
ADOPTIUM_API, version, os, arch
);
let client = reqwest::Client::new();
let releases: Vec<AdoptiumRelease> = client
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
let release = releases
.into_iter()
.next()
.ok_or_else(|| HexoError::JavaNotFound { required: version })?;
let binary = &release.binary;
let pkg = &binary.package;
let archive_path = java_dir.join(&pkg.name);
download_file_with_progress(&pkg.download_task(&archive_path), |downloaded, total| {
let total = if total == 0 { pkg.size as usize } else { total };
progress(downloaded, total, "Downloading Java");
})
.await?;
progress(0, 0, "Extracting Java");
fs::create_dir_all(&extract_dir).await?;
if archive_type == "zip" {
extract_zip(&archive_path, &extract_dir).await?;
} else {
extract_tar_gz(&archive_path, &extract_dir).await?;
}
let java_bin =
find_java_bin_in(&extract_dir).ok_or(HexoError::JavaNotFound { required: version })?;
progress(0, 0, "Java ready");
Ok(java_bin)
}
fn platform_info() -> (&'static str, &'static str, &'static str) {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"mac"
} else {
"linux"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x32"
};
let archive = if cfg!(target_os = "windows") {
"zip"
} else {
"tar.gz"
};
(os, arch, archive)
}
#[derive(serde::Deserialize)]
struct AdoptiumRelease {
binary: AdoptiumBinary,
}
#[derive(serde::Deserialize)]
struct AdoptiumBinary {
package: AdoptiumPackage,
}
#[derive(serde::Deserialize)]
struct AdoptiumPackage {
name: String,
link: String,
checksum: String,
#[serde(default)]
size: u64,
}
impl AdoptiumPackage {
fn download_task(&self, path: &Path) -> DownloadTask {
DownloadTask::new(&self.link, path).with_sha256(&self.checksum)
}
}
async fn extract_zip(archive: &Path, dest: &Path) -> Result<()> {
let archive = archive.to_owned();
let dest = dest.to_owned();
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive)?;
let mut zip = zip::ZipArchive::new(file)?;
zip.extract(&dest)?;
Ok(())
})
.await
.map_err(|e| HexoError::Other(e.to_string()))?
}
async fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<()> {
let archive = archive.to_owned();
let dest = dest.to_owned();
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive)?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
tar.unpack(&dest)?;
Ok(())
})
.await
.map_err(|e| HexoError::Other(e.to_string()))?
}
fn find_java_bin_in(dir: &Path) -> Option<PathBuf> {
let java_name = if cfg!(target_os = "windows") {
"java.exe"
} else {
"java"
};
let find_in = |root: &Path| {
[
root.join("bin").join(java_name),
root.join("Contents/Home/bin").join(java_name),
]
.into_iter()
.find(|candidate| candidate.is_file())
};
find_in(dir).or_else(|| {
std::fs::read_dir(dir)
.ok()?
.flatten()
.find_map(|entry| find_in(&entry.path()))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn extracted_java_is_reused_without_network_or_extraction() {
for layout in ["bin", "jdk-21/bin", "jdk-21/Contents/Home/bin"] {
let temp = tempfile::tempdir().unwrap();
let java_dir = temp.path().join("java/21");
let bin_dir = java_dir.join("jre").join(layout);
fs::create_dir_all(&bin_dir).await.unwrap();
let java_bin = bin_dir.join(if cfg!(windows) { "java.exe" } else { "java" });
fs::write(&java_bin, b"existing runtime").await.unwrap();
let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let collected = events.clone();
let progress: ProgressFn = std::sync::Arc::new(move |done, total, stage| {
collected
.lock()
.unwrap()
.push((done, total, stage.to_string()));
});
let actual = download_java_with_progress(21, temp.path(), progress)
.await
.unwrap();
assert_eq!(actual, java_bin);
assert_eq!(download_java(21, temp.path()).await.unwrap(), java_bin);
assert_eq!(fs::read(&java_bin).await.unwrap(), b"existing runtime");
assert_eq!(
*events.lock().unwrap(),
vec![(0, 0, "Using installed Java".into())]
);
assert_eq!(std::fs::read_dir(&java_dir).unwrap().count(), 1);
}
}
#[test]
fn java_directory_is_not_mistaken_for_executable() {
let temp = tempfile::tempdir().unwrap();
let name = if cfg!(windows) { "java.exe" } else { "java" };
std::fs::create_dir_all(temp.path().join("bin").join(name)).unwrap();
assert!(find_java_bin_in(temp.path()).is_none());
}
#[tokio::test]
async fn adoptium_package_validates_cached_archive_with_sha256() {
let package: AdoptiumPackage = serde_json::from_str(
r#"{
"name": "jre.zip", "link": "http://127.0.0.1:0/jre.zip",
"checksum": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
}"#,
)
.unwrap();
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(&package.name);
fs::write(&path, b"abc").await.unwrap();
let task = package.download_task(&path);
assert!(task.sha1.is_none());
assert_eq!(task.sha256.as_deref(), Some(package.checksum.as_str()));
crate::download::download_file(&task).await.unwrap();
}
}