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 = 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
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 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
150async 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
246async 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
327async 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#[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 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 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}