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 = find_cached_java_bin(&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
127/// Like `java_bin_path` but also checks the macOS bundle layout used by some
128/// Mojang runtimes (e.g. jre-legacy): `jre.bundle/Contents/Home/bin/java`.
129/// Returns the first path that exists on disk, or the standard path as a
130/// fallback so callers can still attempt the download.
131fn find_cached_java_bin(runtime_root: &Path) -> PathBuf {
132    let primary = java_bin_path(runtime_root);
133    if primary.exists() {
134        return primary;
135    }
136    #[cfg(target_os = "macos")]
137    {
138        let bundle = runtime_root.join("jre.bundle/Contents/Home/bin/java");
139        if bundle.exists() {
140            return bundle;
141        }
142    }
143    primary
144}
145
146fn adoptium_os() -> &'static str {
147    match std::env::consts::OS {
148        "linux" => "linux",
149        "macos" => "mac",
150        "windows" => "windows",
151        _ => "linux",
152    }
153}
154
155fn adoptium_arch(intel_enabled_mac: bool) -> &'static str {
156    use std::env::consts::{ARCH, OS};
157    if intel_enabled_mac && OS == "macos" {
158        return "x64";
159    }
160    match ARCH {
161        "x86_64" => "x64",
162        "x86" => "x86",
163        "aarch64" => "aarch64",
164        "arm" => "arm",
165        _ => "x64",
166    }
167}
168
169// ── Mojang download path ──────────────────────────────────────────────────────
170
171async fn try_mojang(
172    options: &LaunchOptions,
173    component: &str,
174    platform: &str,
175    runtime_root: &Path,
176    client: &reqwest::Client,
177    event_tx: &Sender<LaunchEvent>,
178) -> Result<Option<JavaDownloadResult>, LaunchError> {
179    let all_text = match fetch_text(client, ALL_JSON_URL).await {
180        Ok(t) => t,
181        Err(_) => return Ok(None),
182    };
183
184    let all: HashMap<String, HashMap<String, Vec<JavaVersionManifest>>> =
185        serde_json::from_str(&all_text)?;
186
187    let manifest_url = all
188        .get(platform)
189        .and_then(|p| p.get(component))
190        .and_then(|versions| versions.first())
191        .and_then(|v| v.manifest.as_ref())
192        .map(|m| m.url.clone());
193
194    let manifest_url = match manifest_url {
195        Some(url) => url,
196        None => return Ok(None),
197    };
198
199    let manifest_text = fetch_text(client, &manifest_url)
200        .await
201        .map_err(LaunchError::InvalidData)?;
202
203    let manifest: JavaManifestData = serde_json::from_str(&manifest_text)?;
204
205    let mut items: Vec<DownloadItem> = Vec::new();
206    let mut file_records: Vec<JavaFileItem> = Vec::new();
207
208    for (rel_path, entry) in &manifest.files {
209        if entry.file_type != "file" {
210            continue;
211        }
212        let raw = match entry.downloads.as_ref().and_then(|d| d.raw.as_ref()) {
213            Some(r) => r,
214            None => continue,
215        };
216
217        let dest = runtime_root.join(rel_path);
218        let folder = dest
219            .parent()
220            .map(|p| p.to_path_buf())
221            .unwrap_or_else(|| runtime_root.to_path_buf());
222
223        items.push(DownloadItem {
224            url: raw.url.clone(),
225            path: dest,
226            folder,
227            name: rel_path.clone(),
228            size: raw.size,
229            r#type: Some("java".into()),
230            sha1: Some(raw.sha1.clone()),
231        });
232
233        file_records.push(JavaFileItem {
234            path: rel_path.clone(),
235            executable: entry.executable,
236            sha1: Some(raw.sha1.clone()),
237            size: Some(raw.size),
238            url: Some(raw.url.clone()),
239            file_type: Some("file".into()),
240        });
241    }
242
243    let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
244    downloader.download_multiple(items, event_tx.clone()).await?;
245
246    #[cfg(unix)]
247    for (rel_path, entry) in &manifest.files {
248        if entry.executable == Some(true) {
249            use std::os::unix::fs::PermissionsExt;
250            let path = runtime_root.join(rel_path);
251            if path.exists() {
252                let perms = std::fs::Permissions::from_mode(0o755);
253                let _ = std::fs::set_permissions(&path, perms);
254            }
255        }
256    }
257
258    // Find the java binary by scanning manifest entries — some Mojang runtimes
259    // on macOS use a bundle layout (e.g. jre.bundle/Contents/Home/bin/java)
260    // rather than the flat bin/java expected by java_bin_path.
261    let java_bin = manifest.files.iter()
262        .filter_map(|(rel_path, entry)| {
263            if entry.executable != Some(true) {
264                return None;
265            }
266            let p = std::path::Path::new(rel_path);
267            let fname = p.file_name()?.to_str()?;
268            let in_bin = p.parent()?.file_name()?.to_str()? == "bin";
269            if in_bin && (fname == "java" || fname == "javaw.exe") {
270                Some(runtime_root.join(rel_path))
271            } else {
272                None
273            }
274        })
275        .next()
276        .unwrap_or_else(|| java_bin_path(runtime_root));
277
278    Ok(Some(JavaDownloadResult {
279        java_path: java_bin.to_string_lossy().into_owned(),
280        files: file_records,
281    }))
282}
283
284// ── Adoptium fallback ─────────────────────────────────────────────────────────
285
286async fn get_from_adoptium(
287    options: &LaunchOptions,
288    _component: &str,
289    runtime_root: &Path,
290    major_version: u32,
291    client: &reqwest::Client,
292    event_tx: &Sender<LaunchEvent>,
293) -> Result<JavaDownloadResult, LaunchError> {
294    let os = adoptium_os();
295    let arch = adoptium_arch(options.intel_enabled_mac);
296    let image_type = &options.java.image_type;
297
298    let url = format!(
299        "{ADOPTIUM_API_BASE}/{major_version}/hotspot?os={os}&architecture={arch}&image_type={image_type}&jvm_impl=hotspot&vendor=eclipse"
300    );
301
302    let releases: Vec<AdoptiumRelease> = fetch_json(client, &url)
303        .await
304        .map_err(LaunchError::InvalidData)?;
305
306    let release = releases.into_iter().next().ok_or_else(|| {
307        LaunchError::Io(std::io::Error::new(
308            std::io::ErrorKind::NotFound,
309            format!("No Adoptium release found for Java {major_version} on {os}/{arch}"),
310        ))
311    })?;
312
313    let pkg = release.binary.package;
314    let is_windows = cfg!(target_os = "windows");
315    let ext = if is_windows { "zip" } else { "tar.gz" };
316    let archive_path = runtime_root.join(format!("adoptium-jre.{ext}"));
317
318    if let Some(parent) = archive_path.parent() {
319        tokio::fs::create_dir_all(parent).await?;
320    }
321
322    let item = DownloadItem {
323        url: pkg.link.clone(),
324        path: archive_path.clone(),
325        folder: runtime_root.to_path_buf(),
326        name: pkg.name.clone(),
327        size: 0,
328        r#type: Some("java".into()),
329        sha1: None,
330    };
331
332    let downloader = Downloader::new(options.timeout_secs, 1);
333    downloader.download_multiple(vec![item], event_tx.clone()).await?;
334
335    if is_windows {
336        extract_zip_to(archive_path.clone(), runtime_root).await?;
337    } else {
338        extract_tar_gz(archive_path.clone(), runtime_root.to_path_buf(), 1).await?;
339    }
340
341    let _ = tokio::fs::remove_file(&archive_path).await;
342
343    let java_bin = java_bin_path(runtime_root);
344
345    #[cfg(unix)]
346    if java_bin.exists() {
347        use std::os::unix::fs::PermissionsExt;
348        let perms = std::fs::Permissions::from_mode(0o755);
349        let _ = std::fs::set_permissions(&java_bin, perms);
350    }
351
352    Ok(JavaDownloadResult {
353        java_path: java_bin.to_string_lossy().into_owned(),
354        files: vec![JavaFileItem {
355            path: java_bin.to_string_lossy().into_owned(),
356            executable: Some(true),
357            sha1: None,
358            size: None,
359            url: Some(pkg.link),
360            file_type: Some("file".into()),
361        }],
362    })
363}
364
365// ── ZIP extraction (Windows) ──────────────────────────────────────────────────
366
367async fn extract_zip_to(archive: PathBuf, dest: &Path) -> Result<(), LaunchError> {
368    let dest = dest.to_path_buf();
369    tokio::task::spawn_blocking(move || -> Result<(), LaunchError> {
370        let file = std::fs::File::open(&archive)?;
371        let mut zip = zip::ZipArchive::new(file).map_err(|e| {
372            std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
373        })?;
374        for i in 0..zip.len() {
375            let mut entry = zip.by_index(i).map_err(|e| {
376                std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
377            })?;
378            if entry.is_dir() {
379                continue;
380            }
381            let name = entry.name().to_owned();
382            let stripped = name.splitn(2, '/').nth(1).unwrap_or(&name).to_owned();
383            let out = dest.join(&stripped);
384            if let Some(parent) = out.parent() {
385                std::fs::create_dir_all(parent)?;
386            }
387            let mut f = std::fs::File::create(&out)?;
388            std::io::copy(&mut entry, &mut f)?;
389        }
390        Ok(())
391    })
392    .await
393    .map_err(|e| LaunchError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))??;
394    Ok(())
395}
396
397// ── Tests ─────────────────────────────────────────────────────────────────────
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use std::path::PathBuf;
403
404    fn bare_version() -> MinecraftVersionJson {
405        MinecraftVersionJson {
406            id: "1.20.4".into(),
407            version_type: "release".into(),
408            assets: None,
409            asset_index: None,
410            downloads: None,
411            libraries: vec![],
412            arguments: None,
413            minecraft_arguments: None,
414            java_version: None,
415            main_class: None,
416            has_natives: false,
417        }
418    }
419
420    fn bare_options() -> LaunchOptions {
421        use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
422        use crate::models::minecraft::Authenticator;
423        LaunchOptions {
424            path: PathBuf::from("/mc"),
425            version: "1.20.4".into(),
426            authenticator: Authenticator {
427                access_token: "tok".into(),
428                name: "Player".into(),
429                uuid: "uuid".into(),
430                xbox_account: None,
431                user_properties: None,
432                client_id: None,
433                client_token: None,
434            },
435            timeout_secs: 10,
436            download_concurrency: 5,
437            verify_concurrency: 4,
438            memory: MemoryConfig::default(),
439            java: JavaOptions::default(),
440            loader: LoaderConfig::default(),
441            screen: ScreenConfig::default(),
442            verify: false,
443            game_args: vec![],
444            jvm_args: vec![],
445            instance: None,
446            url: None,
447            mcp: None,
448            intel_enabled_mac: false,
449            bypass_offline: false,
450        }
451    }
452
453    #[test]
454    fn java_component_defaults_when_no_java_version() {
455        let opts = bare_options();
456        let vj = bare_version();
457        let (comp, major) = java_component(&opts, &vj);
458        assert_eq!(comp, "jre-8");
459        assert_eq!(major, 8);
460    }
461
462    #[test]
463    fn java_component_uses_version_json() {
464        use crate::models::minecraft::JavaVersionInfo;
465        let opts = bare_options();
466        let mut vj = bare_version();
467        vj.java_version = Some(JavaVersionInfo {
468            component: Some("java-runtime-gamma".into()),
469            major_version: Some(17),
470        });
471        let (comp, major) = java_component(&opts, &vj);
472        assert_eq!(comp, "jre-17");
473        assert_eq!(major, 17);
474    }
475
476    #[test]
477    fn java_component_java_option_overrides_version_json() {
478        use crate::models::minecraft::JavaVersionInfo;
479        let mut opts = bare_options();
480        opts.java.version = Some("21".into());
481        let mut vj = bare_version();
482        vj.java_version = Some(JavaVersionInfo {
483            component: Some("java-runtime-gamma".into()),
484            major_version: Some(17),
485        });
486        let (comp, major) = java_component(&opts, &vj);
487        assert_eq!(comp, "jre-21");
488        assert_eq!(major, 21);
489    }
490
491    #[test]
492    fn java_bin_path_is_runtime_root_bin_java() {
493        let root = PathBuf::from("/mc/runtime/jre-legacy/linux");
494        let bin = java_bin_path(&root);
495        let path_str = bin.to_string_lossy();
496        // Must be exactly runtime_root/bin/java — no extra component segment.
497        assert!(path_str.ends_with("java") || path_str.ends_with("javaw.exe"));
498        assert!(path_str.contains("/bin/"));
499        assert!(!path_str[root.to_str().unwrap().len()..].contains("jre-legacy"),
500            "component name must not appear after runtime_root: {path_str}");
501    }
502
503    #[test]
504    fn mojang_platform_key_returns_non_empty() {
505        let key = mojang_platform_key(false);
506        assert!(!key.is_empty());
507    }
508
509    #[test]
510    fn mojang_platform_key_intel_mac_overrides_arm() {
511        // On any platform intel_enabled_mac=true must not produce the arm64 key.
512        let key = mojang_platform_key(true);
513        assert_ne!(key, "mac-os-arm64");
514    }
515
516    #[tokio::test]
517    async fn get_java_files_respects_custom_java_path() {
518        use crate::launcher::options::JavaOptions;
519        use tokio::sync::mpsc;
520        let mut opts = bare_options();
521        opts.java = JavaOptions {
522            path: Some(PathBuf::from("/usr/bin/java")),
523            version: None,
524            image_type: "jre".into(),
525        };
526        let client = reqwest::Client::new();
527        let (tx, _rx) = mpsc::channel(16);
528        let result = get_java_files(&opts, &bare_version(), &client, &tx)
529            .await
530            .unwrap();
531        assert_eq!(result.java_path, "/usr/bin/java");
532        assert!(result.files.is_empty());
533    }
534
535    #[tokio::test]
536    async fn get_java_files_returns_cached_when_binary_exists() {
537        use tempfile::TempDir;
538        use tokio::sync::mpsc;
539
540        let dir = TempDir::new().unwrap();
541        let mut opts = bare_options();
542        opts.path = dir.path().to_path_buf();
543
544        let (comp, _) = java_component(&opts, &bare_version());
545        let platform = mojang_platform_key(false);
546        let runtime_root = dir.path().join("runtime").join(&comp).join(&platform);
547        let bin_dir = runtime_root.join("bin");
548        tokio::fs::create_dir_all(&bin_dir).await.unwrap();
549        tokio::fs::write(bin_dir.join("java"), b"#!/bin/sh\nexec java").await.unwrap();
550
551        let client = reqwest::Client::new();
552        let (tx, _rx) = mpsc::channel(16);
553        let result = get_java_files(&opts, &bare_version(), &client, &tx)
554            .await
555            .unwrap();
556
557        assert!(result.java_path.contains("java"));
558        assert!(result.files.is_empty());
559    }
560}