Skip to main content

minecraft_java_rs_core/game/
java.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use tokio::sync::mpsc::Sender;
5
6use crate::error::LaunchError;
7use crate::launcher::events::LaunchEvent;
8use crate::launcher::options::LaunchOptions;
9use crate::models::java::{
10    AdoptiumRelease, JavaFileItem, JavaManifestData, JavaVersionManifest,
11};
12use crate::models::minecraft::MinecraftVersionJson;
13use crate::net::downloader::{DownloadItem, Downloader};
14use crate::net::http::{fetch_json, fetch_text};
15use crate::utils::archive::extract_tar_gz;
16
17const ALL_JSON_URL: &str =
18    "https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json";
19const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3/assets/latest";
20
21// ── Public types ──────────────────────────────────────────────────────────────
22
23pub struct JavaDownloadResult {
24    /// Absolute path to the `java` (or `javaw.exe`) executable.
25    pub java_path: String,
26    /// Flat list of downloaded runtime files for `JavaInfo` / bundle checks.
27    pub files: Vec<JavaFileItem>,
28}
29
30// ── Public API ────────────────────────────────────────────────────────────────
31
32/// Resolve and (if needed) download the Java runtime for a Minecraft version.
33///
34/// Priority:
35/// 1. `options.java.path` set → use verbatim.
36/// 2. Binary already cached at the computed runtime path → return fast.
37/// 3. Mojang all.json has an entry for this platform/component → Mojang path.
38/// 4. Adoptium API fallback.
39pub async fn get_java_files(
40    options: &LaunchOptions,
41    version_json: &MinecraftVersionJson,
42    client: &reqwest::Client,
43    event_tx: &Sender<LaunchEvent>,
44) -> Result<JavaDownloadResult, LaunchError> {
45    if let Some(java_path) = &options.java.path {
46        return Ok(JavaDownloadResult {
47            java_path: java_path.to_string_lossy().into_owned(),
48            files: vec![],
49        });
50    }
51
52    let (component, major_version) = java_component(options, version_json);
53    let platform = mojang_platform_key(options.intel_enabled_mac);
54    let runtime_root = options
55        .path
56        .join("runtime")
57        .join(&component)
58        .join(&platform);
59
60    let java_bin = java_bin_path(&runtime_root);
61
62    if java_bin.exists() {
63        return Ok(JavaDownloadResult {
64            java_path: java_bin.to_string_lossy().into_owned(),
65            files: vec![],
66        });
67    }
68
69    if let Some(result) =
70        try_mojang(options, &component, &platform, &runtime_root, client, event_tx).await?
71    {
72        return Ok(result);
73    }
74
75    get_from_adoptium(
76        options,
77        &component,
78        &runtime_root,
79        major_version,
80        client,
81        event_tx,
82    )
83    .await
84}
85
86// ── Platform helpers ──────────────────────────────────────────────────────────
87
88pub fn mojang_platform_key(intel_enabled_mac: bool) -> String {
89    use std::env::consts::{ARCH, OS};
90    match (OS, ARCH) {
91        ("linux", "x86_64") => "linux",
92        ("linux", "x86") => "linux-i386",
93        ("macos", "x86_64") => "mac-os",
94        ("macos", "aarch64") if intel_enabled_mac => "mac-os",
95        ("macos", "aarch64") => "mac-os-arm64",
96        ("windows", "x86_64") => "windows-x64",
97        ("windows", "x86") => "windows-x86",
98        ("windows", "aarch64") => "windows-arm64",
99        _ => "linux",
100    }
101    .to_string()
102}
103
104pub fn java_component(options: &LaunchOptions, version_json: &MinecraftVersionJson) -> (String, u32) {
105    if let Some(ver) = &options.java.version {
106        let major = ver.parse::<u32>().unwrap_or(8);
107        return (format!("jre-{major}"), major);
108    }
109    match &version_json.java_version {
110        Some(jv) => {
111            let major = jv.major_version.unwrap_or(8);
112            (format!("jre-{major}"), major)
113        }
114        None => ("jre-8".into(), 8),
115    }
116}
117
118pub fn java_bin_path(runtime_root: &Path) -> PathBuf {
119    let bin = if cfg!(target_os = "windows") {
120        "javaw.exe"
121    } else {
122        "java"
123    };
124    runtime_root.join("bin").join(bin)
125}
126
127fn adoptium_os() -> &'static str {
128    match std::env::consts::OS {
129        "linux" => "linux",
130        "macos" => "mac",
131        "windows" => "windows",
132        _ => "linux",
133    }
134}
135
136fn adoptium_arch(intel_enabled_mac: bool) -> &'static str {
137    use std::env::consts::{ARCH, OS};
138    if intel_enabled_mac && OS == "macos" {
139        return "x64";
140    }
141    match ARCH {
142        "x86_64" => "x64",
143        "x86" => "x86",
144        "aarch64" => "aarch64",
145        "arm" => "arm",
146        _ => "x64",
147    }
148}
149
150// ── Mojang download path ──────────────────────────────────────────────────────
151
152async fn try_mojang(
153    options: &LaunchOptions,
154    component: &str,
155    platform: &str,
156    runtime_root: &Path,
157    client: &reqwest::Client,
158    event_tx: &Sender<LaunchEvent>,
159) -> Result<Option<JavaDownloadResult>, LaunchError> {
160    let all_text = match fetch_text(client, ALL_JSON_URL).await {
161        Ok(t) => t,
162        Err(_) => return Ok(None),
163    };
164
165    let all: HashMap<String, HashMap<String, Vec<JavaVersionManifest>>> =
166        serde_json::from_str(&all_text)?;
167
168    let manifest_url = all
169        .get(platform)
170        .and_then(|p| p.get(component))
171        .and_then(|versions| versions.first())
172        .and_then(|v| v.manifest.as_ref())
173        .map(|m| m.url.clone());
174
175    let manifest_url = match manifest_url {
176        Some(url) => url,
177        None => return Ok(None),
178    };
179
180    let manifest_text = fetch_text(client, &manifest_url)
181        .await
182        .map_err(LaunchError::InvalidData)?;
183
184    let manifest: JavaManifestData = serde_json::from_str(&manifest_text)?;
185
186    let mut items: Vec<DownloadItem> = Vec::new();
187    let mut file_records: Vec<JavaFileItem> = Vec::new();
188
189    for (rel_path, entry) in &manifest.files {
190        if entry.file_type != "file" {
191            continue;
192        }
193        let raw = match entry.downloads.as_ref().and_then(|d| d.raw.as_ref()) {
194            Some(r) => r,
195            None => continue,
196        };
197
198        let dest = runtime_root.join(rel_path);
199        let folder = dest
200            .parent()
201            .map(|p| p.to_path_buf())
202            .unwrap_or_else(|| runtime_root.to_path_buf());
203
204        items.push(DownloadItem {
205            url: raw.url.clone(),
206            path: dest,
207            folder,
208            name: rel_path.clone(),
209            size: raw.size,
210            r#type: Some("java".into()),
211            sha1: Some(raw.sha1.clone()),
212        });
213
214        file_records.push(JavaFileItem {
215            path: rel_path.clone(),
216            executable: entry.executable,
217            sha1: Some(raw.sha1.clone()),
218            size: Some(raw.size),
219            url: Some(raw.url.clone()),
220            file_type: Some("file".into()),
221        });
222    }
223
224    let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
225    downloader.download_multiple(items, event_tx.clone()).await?;
226
227    #[cfg(unix)]
228    for (rel_path, entry) in &manifest.files {
229        if entry.executable == Some(true) {
230            use std::os::unix::fs::PermissionsExt;
231            let path = runtime_root.join(rel_path);
232            if path.exists() {
233                let perms = std::fs::Permissions::from_mode(0o755);
234                let _ = std::fs::set_permissions(&path, perms);
235            }
236        }
237    }
238
239    let java_bin = java_bin_path(runtime_root);
240    Ok(Some(JavaDownloadResult {
241        java_path: java_bin.to_string_lossy().into_owned(),
242        files: file_records,
243    }))
244}
245
246// ── Adoptium fallback ─────────────────────────────────────────────────────────
247
248async fn get_from_adoptium(
249    options: &LaunchOptions,
250    _component: &str,
251    runtime_root: &Path,
252    major_version: u32,
253    client: &reqwest::Client,
254    event_tx: &Sender<LaunchEvent>,
255) -> Result<JavaDownloadResult, LaunchError> {
256    let os = adoptium_os();
257    let arch = adoptium_arch(options.intel_enabled_mac);
258    let image_type = &options.java.image_type;
259
260    let url = format!(
261        "{ADOPTIUM_API_BASE}/{major_version}/hotspot?os={os}&architecture={arch}&image_type={image_type}&jvm_impl=hotspot&vendor=eclipse"
262    );
263
264    let releases: Vec<AdoptiumRelease> = fetch_json(client, &url)
265        .await
266        .map_err(LaunchError::InvalidData)?;
267
268    let release = releases.into_iter().next().ok_or_else(|| {
269        LaunchError::Io(std::io::Error::new(
270            std::io::ErrorKind::NotFound,
271            format!("No Adoptium release found for Java {major_version} on {os}/{arch}"),
272        ))
273    })?;
274
275    let pkg = release.binary.package;
276    let is_windows = cfg!(target_os = "windows");
277    let ext = if is_windows { "zip" } else { "tar.gz" };
278    let archive_path = runtime_root.join(format!("adoptium-jre.{ext}"));
279
280    if let Some(parent) = archive_path.parent() {
281        tokio::fs::create_dir_all(parent).await?;
282    }
283
284    let item = DownloadItem {
285        url: pkg.link.clone(),
286        path: archive_path.clone(),
287        folder: runtime_root.to_path_buf(),
288        name: pkg.name.clone(),
289        size: 0,
290        r#type: Some("java".into()),
291        sha1: None,
292    };
293
294    let downloader = Downloader::new(options.timeout_secs, 1);
295    downloader.download_multiple(vec![item], event_tx.clone()).await?;
296
297    if is_windows {
298        extract_zip_to(archive_path.clone(), runtime_root).await?;
299    } else {
300        extract_tar_gz(archive_path.clone(), runtime_root.to_path_buf(), 1).await?;
301    }
302
303    let _ = tokio::fs::remove_file(&archive_path).await;
304
305    let java_bin = java_bin_path(runtime_root);
306
307    #[cfg(unix)]
308    if java_bin.exists() {
309        use std::os::unix::fs::PermissionsExt;
310        let perms = std::fs::Permissions::from_mode(0o755);
311        let _ = std::fs::set_permissions(&java_bin, perms);
312    }
313
314    Ok(JavaDownloadResult {
315        java_path: java_bin.to_string_lossy().into_owned(),
316        files: vec![JavaFileItem {
317            path: java_bin.to_string_lossy().into_owned(),
318            executable: Some(true),
319            sha1: None,
320            size: None,
321            url: Some(pkg.link),
322            file_type: Some("file".into()),
323        }],
324    })
325}
326
327// ── ZIP extraction (Windows) ──────────────────────────────────────────────────
328
329async fn extract_zip_to(archive: PathBuf, dest: &Path) -> Result<(), LaunchError> {
330    let dest = dest.to_path_buf();
331    tokio::task::spawn_blocking(move || -> Result<(), LaunchError> {
332        let file = std::fs::File::open(&archive)?;
333        let mut zip = zip::ZipArchive::new(file).map_err(|e| {
334            std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
335        })?;
336        for i in 0..zip.len() {
337            let mut entry = zip.by_index(i).map_err(|e| {
338                std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
339            })?;
340            if entry.is_dir() {
341                continue;
342            }
343            let name = entry.name().to_owned();
344            let stripped = name.splitn(2, '/').nth(1).unwrap_or(&name).to_owned();
345            let out = dest.join(&stripped);
346            if let Some(parent) = out.parent() {
347                std::fs::create_dir_all(parent)?;
348            }
349            let mut f = std::fs::File::create(&out)?;
350            std::io::copy(&mut entry, &mut f)?;
351        }
352        Ok(())
353    })
354    .await
355    .map_err(|e| LaunchError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))??;
356    Ok(())
357}
358
359// ── Tests ─────────────────────────────────────────────────────────────────────
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use std::path::PathBuf;
365
366    fn bare_version() -> MinecraftVersionJson {
367        MinecraftVersionJson {
368            id: "1.20.4".into(),
369            version_type: "release".into(),
370            assets: None,
371            asset_index: None,
372            downloads: None,
373            libraries: vec![],
374            arguments: None,
375            minecraft_arguments: None,
376            java_version: None,
377            main_class: None,
378            has_natives: false,
379        }
380    }
381
382    fn bare_options() -> LaunchOptions {
383        use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
384        use crate::models::minecraft::Authenticator;
385        LaunchOptions {
386            path: PathBuf::from("/mc"),
387            version: "1.20.4".into(),
388            authenticator: Authenticator {
389                access_token: "tok".into(),
390                name: "Player".into(),
391                uuid: "uuid".into(),
392                xbox_account: None,
393                user_properties: None,
394                client_id: None,
395                client_token: None,
396            },
397            timeout_secs: 10,
398            download_concurrency: 5,
399            verify_concurrency: 4,
400            memory: MemoryConfig::default(),
401            java: JavaOptions::default(),
402            loader: LoaderConfig::default(),
403            screen: ScreenConfig::default(),
404            verify: false,
405            game_args: vec![],
406            jvm_args: vec![],
407            instance: None,
408            url: None,
409            mcp: None,
410            intel_enabled_mac: false,
411            bypass_offline: false,
412        }
413    }
414
415    #[test]
416    fn java_component_defaults_when_no_java_version() {
417        let opts = bare_options();
418        let vj = bare_version();
419        let (comp, major) = java_component(&opts, &vj);
420        assert_eq!(comp, "jre-8");
421        assert_eq!(major, 8);
422    }
423
424    #[test]
425    fn java_component_uses_version_json() {
426        use crate::models::minecraft::JavaVersionInfo;
427        let opts = bare_options();
428        let mut vj = bare_version();
429        vj.java_version = Some(JavaVersionInfo {
430            component: Some("java-runtime-gamma".into()),
431            major_version: Some(17),
432        });
433        let (comp, major) = java_component(&opts, &vj);
434        assert_eq!(comp, "jre-17");
435        assert_eq!(major, 17);
436    }
437
438    #[test]
439    fn java_component_java_option_overrides_version_json() {
440        use crate::models::minecraft::JavaVersionInfo;
441        let mut opts = bare_options();
442        opts.java.version = Some("21".into());
443        let mut vj = bare_version();
444        vj.java_version = Some(JavaVersionInfo {
445            component: Some("java-runtime-gamma".into()),
446            major_version: Some(17),
447        });
448        let (comp, major) = java_component(&opts, &vj);
449        assert_eq!(comp, "jre-21");
450        assert_eq!(major, 21);
451    }
452
453    #[test]
454    fn java_bin_path_is_runtime_root_bin_java() {
455        let root = PathBuf::from("/mc/runtime/jre-legacy/linux");
456        let bin = java_bin_path(&root);
457        let path_str = bin.to_string_lossy();
458        // Must be exactly runtime_root/bin/java — no extra component segment.
459        assert!(path_str.ends_with("java") || path_str.ends_with("javaw.exe"));
460        assert!(path_str.contains("/bin/"));
461        assert!(!path_str[root.to_str().unwrap().len()..].contains("jre-legacy"),
462            "component name must not appear after runtime_root: {path_str}");
463    }
464
465    #[test]
466    fn mojang_platform_key_returns_non_empty() {
467        let key = mojang_platform_key(false);
468        assert!(!key.is_empty());
469    }
470
471    #[test]
472    fn mojang_platform_key_intel_mac_overrides_arm() {
473        // On any platform intel_enabled_mac=true must not produce the arm64 key.
474        let key = mojang_platform_key(true);
475        assert_ne!(key, "mac-os-arm64");
476    }
477
478    #[tokio::test]
479    async fn get_java_files_respects_custom_java_path() {
480        use crate::launcher::options::JavaOptions;
481        use tokio::sync::mpsc;
482        let mut opts = bare_options();
483        opts.java = JavaOptions {
484            path: Some(PathBuf::from("/usr/bin/java")),
485            version: None,
486            image_type: "jre".into(),
487        };
488        let client = reqwest::Client::new();
489        let (tx, _rx) = mpsc::channel(16);
490        let result = get_java_files(&opts, &bare_version(), &client, &tx)
491            .await
492            .unwrap();
493        assert_eq!(result.java_path, "/usr/bin/java");
494        assert!(result.files.is_empty());
495    }
496
497    #[tokio::test]
498    async fn get_java_files_returns_cached_when_binary_exists() {
499        use tempfile::TempDir;
500        use tokio::sync::mpsc;
501
502        let dir = TempDir::new().unwrap();
503        let mut opts = bare_options();
504        opts.path = dir.path().to_path_buf();
505
506        let (comp, _) = java_component(&opts, &bare_version());
507        let platform = mojang_platform_key(false);
508        let runtime_root = dir.path().join("runtime").join(&comp).join(&platform);
509        let bin_dir = runtime_root.join("bin");
510        tokio::fs::create_dir_all(&bin_dir).await.unwrap();
511        tokio::fs::write(bin_dir.join("java"), b"#!/bin/sh\nexec java").await.unwrap();
512
513        let client = reqwest::Client::new();
514        let (tx, _rx) = mpsc::channel(16);
515        let result = get_java_files(&opts, &bare_version(), &client, &tx)
516            .await
517            .unwrap();
518
519        assert!(result.java_path.contains("java"));
520        assert!(result.files.is_empty());
521    }
522}