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
21pub struct JavaDownloadResult {
24 pub java_path: String,
26 pub files: Vec<JavaFileItem>,
28}
29
30pub 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
86pub 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 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
169async 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 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
284async 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
365async 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#[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 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 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}