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