Skip to main content

hexomc_lib/java/
installer.rs

1use std::path::{Path, PathBuf};
2use tokio::fs;
3
4use crate::{
5    download::{download_file_with_progress, DownloadTask},
6    error::{HexoError, Result},
7    install::loader::{no_progress, ProgressFn},
8};
9
10const ADOPTIUM_API: &str = "https://api.adoptium.net/v3/assets/latest";
11
12/// Download an Adoptium Temurin JRE to `{base_dir}/java/{version}/`.
13/// Returns the full path to the java executable.
14pub async fn download_java(version: u32, base_dir: &Path) -> Result<PathBuf> {
15    download_java_with_progress(version, base_dir, no_progress()).await
16}
17
18/// Reuse an extracted JRE or download one, reporting progress through the callback.
19/// During `Downloading Java`, counts are downloaded and total bytes, with zero total
20/// meaning unknown size. Counts reset on retries. Other stages report `(0, 0)`.
21/// The existing runtime is checked before any network request or archive extraction.
22pub async fn download_java_with_progress(
23    version: u32,
24    base_dir: &Path,
25    progress: ProgressFn,
26) -> Result<PathBuf> {
27    let java_dir = base_dir.join("java").join(version.to_string());
28    let extract_dir = java_dir.join("jre");
29    if let Some(java_bin) = find_java_bin_in(&extract_dir) {
30        progress(0, 0, "Using installed Java");
31        return Ok(java_bin);
32    }
33    fs::create_dir_all(&java_dir).await?;
34
35    progress(0, 0, "Fetching Java release");
36    let (os, arch, archive_type) = platform_info();
37    let url = format!(
38        "{}/{}/hotspot?image_type=jre&os={}&architecture={}&vendor=eclipse",
39        ADOPTIUM_API, version, os, arch
40    );
41
42    let client = reqwest::Client::new();
43    let releases: Vec<AdoptiumRelease> = client
44        .get(&url)
45        .send()
46        .await?
47        .error_for_status()?
48        .json()
49        .await?;
50
51    let release = releases
52        .into_iter()
53        .next()
54        .ok_or_else(|| HexoError::JavaNotFound { required: version })?;
55
56    let binary = &release.binary;
57    let pkg = &binary.package;
58
59    let archive_path = java_dir.join(&pkg.name);
60    download_file_with_progress(&pkg.download_task(&archive_path), |downloaded, total| {
61        let total = if total == 0 { pkg.size as usize } else { total };
62        progress(downloaded, total, "Downloading Java");
63    })
64    .await?;
65
66    progress(0, 0, "Extracting Java");
67    fs::create_dir_all(&extract_dir).await?;
68
69    if archive_type == "zip" {
70        extract_zip(&archive_path, &extract_dir).await?;
71    } else {
72        extract_tar_gz(&archive_path, &extract_dir).await?;
73    }
74
75    let java_bin =
76        find_java_bin_in(&extract_dir).ok_or(HexoError::JavaNotFound { required: version })?;
77    progress(0, 0, "Java ready");
78    Ok(java_bin)
79}
80
81fn platform_info() -> (&'static str, &'static str, &'static str) {
82    let os = if cfg!(target_os = "windows") {
83        "windows"
84    } else if cfg!(target_os = "macos") {
85        "mac"
86    } else {
87        "linux"
88    };
89
90    let arch = if cfg!(target_arch = "x86_64") {
91        "x64"
92    } else if cfg!(target_arch = "aarch64") {
93        "aarch64"
94    } else {
95        "x32"
96    };
97
98    let archive = if cfg!(target_os = "windows") {
99        "zip"
100    } else {
101        "tar.gz"
102    };
103
104    (os, arch, archive)
105}
106
107#[derive(serde::Deserialize)]
108struct AdoptiumRelease {
109    binary: AdoptiumBinary,
110}
111
112#[derive(serde::Deserialize)]
113struct AdoptiumBinary {
114    package: AdoptiumPackage,
115}
116
117#[derive(serde::Deserialize)]
118struct AdoptiumPackage {
119    name: String,
120    link: String,
121    checksum: String,
122    #[serde(default)]
123    size: u64,
124}
125
126impl AdoptiumPackage {
127    /// Adoptium's package checksum is SHA-256, not SHA1.
128    fn download_task(&self, path: &Path) -> DownloadTask {
129        DownloadTask::new(&self.link, path).with_sha256(&self.checksum)
130    }
131}
132
133async fn extract_zip(archive: &Path, dest: &Path) -> Result<()> {
134    let archive = archive.to_owned();
135    let dest = dest.to_owned();
136    tokio::task::spawn_blocking(move || {
137        let file = std::fs::File::open(&archive)?;
138        let mut zip = zip::ZipArchive::new(file)?;
139        zip.extract(&dest)?;
140        Ok(())
141    })
142    .await
143    .map_err(|e| HexoError::Other(e.to_string()))?
144}
145
146async fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<()> {
147    let archive = archive.to_owned();
148    let dest = dest.to_owned();
149    tokio::task::spawn_blocking(move || {
150        let file = std::fs::File::open(&archive)?;
151        let gz = flate2::read::GzDecoder::new(file);
152        let mut tar = tar::Archive::new(gz);
153        tar.unpack(&dest)?;
154        Ok(())
155    })
156    .await
157    .map_err(|e| HexoError::Other(e.to_string()))?
158}
159
160fn find_java_bin_in(dir: &Path) -> Option<PathBuf> {
161    let java_name = if cfg!(target_os = "windows") {
162        "java.exe"
163    } else {
164        "java"
165    };
166
167    let find_in = |root: &Path| {
168        [
169            root.join("bin").join(java_name),
170            root.join("Contents/Home/bin").join(java_name),
171        ]
172        .into_iter()
173        .find(|candidate| candidate.is_file())
174    };
175    find_in(dir).or_else(|| {
176        std::fs::read_dir(dir)
177            .ok()?
178            .flatten()
179            .find_map(|entry| find_in(&entry.path()))
180    })
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[tokio::test]
188    async fn extracted_java_is_reused_without_network_or_extraction() {
189        for layout in ["bin", "jdk-21/bin", "jdk-21/Contents/Home/bin"] {
190            let temp = tempfile::tempdir().unwrap();
191            let java_dir = temp.path().join("java/21");
192            let bin_dir = java_dir.join("jre").join(layout);
193            fs::create_dir_all(&bin_dir).await.unwrap();
194            let java_bin = bin_dir.join(if cfg!(windows) { "java.exe" } else { "java" });
195            fs::write(&java_bin, b"existing runtime").await.unwrap();
196            let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
197            let collected = events.clone();
198            let progress: ProgressFn = std::sync::Arc::new(move |done, total, stage| {
199                collected
200                    .lock()
201                    .unwrap()
202                    .push((done, total, stage.to_string()));
203            });
204            let actual = download_java_with_progress(21, temp.path(), progress)
205                .await
206                .unwrap();
207            assert_eq!(actual, java_bin);
208            assert_eq!(download_java(21, temp.path()).await.unwrap(), java_bin);
209            assert_eq!(fs::read(&java_bin).await.unwrap(), b"existing runtime");
210            assert_eq!(
211                *events.lock().unwrap(),
212                vec![(0, 0, "Using installed Java".into())]
213            );
214            assert_eq!(std::fs::read_dir(&java_dir).unwrap().count(), 1);
215        }
216    }
217
218    #[test]
219    fn java_directory_is_not_mistaken_for_executable() {
220        let temp = tempfile::tempdir().unwrap();
221        let name = if cfg!(windows) { "java.exe" } else { "java" };
222        std::fs::create_dir_all(temp.path().join("bin").join(name)).unwrap();
223        assert!(find_java_bin_in(temp.path()).is_none());
224    }
225
226    #[tokio::test]
227    async fn adoptium_package_validates_cached_archive_with_sha256() {
228        let package: AdoptiumPackage = serde_json::from_str(
229            r#"{
230            "name": "jre.zip", "link": "http://127.0.0.1:0/jre.zip",
231            "checksum": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
232        }"#,
233        )
234        .unwrap();
235        let temp = tempfile::tempdir().unwrap();
236        let path = temp.path().join(&package.name);
237        fs::write(&path, b"abc").await.unwrap();
238        let task = package.download_task(&path);
239        assert!(task.sha1.is_none());
240        assert_eq!(task.sha256.as_deref(), Some(package.checksum.as_str()));
241        crate::download::download_file(&task).await.unwrap();
242    }
243}