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
19pub struct JavaDownloadResult {
22 pub java_path: String,
24 pub files: Vec<JavaFileItem>,
26}
27
28pub 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
91pub 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
135fn 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
177async 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 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
300async 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
383async 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#[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 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 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}