Skip to main content

minecraft_java_rs_core/game/
libraries.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::error::LaunchError;
7use crate::net::http::fetch_json;
8use crate::launcher::options::LaunchOptions;
9use crate::models::minecraft::{ArtifactInfo, AssetItem, Library, MinecraftVersionJson};
10use crate::utils::paths::get_path_libraries;
11use crate::utils::platform::{mojang_os, skip_library};
12
13// ── Public API ────────────────────────────────────────────────────────────────
14
15/// Build the full list of library/native items to include in the download
16/// bundle.
17///
18/// For each library in `version_json.libraries`:
19/// - **Native** (has a `natives` map): selects the platform-specific
20///   classifier and emits a `NativeAsset`.
21/// - **Regular**: applies Mojang rule evaluation via [`skip_library`]; emits
22///   an `Asset`.  Falls back to deriving the URL from `lib.url` + Maven
23///   coordinate when `downloads.artifact` is absent.
24///
25/// Appends the client JAR and the serialised version JSON (as a `CFile`) at
26/// the end.
27pub fn get_libraries(
28    options: &LaunchOptions,
29    version_json: &MinecraftVersionJson,
30) -> Vec<AssetItem> {
31    let base = &options.path;
32    let current_os = mojang_os();
33    let arch_suffix = arch_suffix_for_natives();
34
35    let mut items: Vec<AssetItem> = Vec::new();
36
37    for lib in &version_json.libraries {
38        if let Some(natives_map) = &lib.natives {
39            // ── Native branch ────────────────────────────────────────────────
40            let native_key = match natives_map.get(current_os) {
41                Some(k) => k.replace("${arch}", arch_suffix),
42                None => continue,
43            };
44
45            let artifact = lib
46                .downloads
47                .as_ref()
48                .and_then(|d| d.classifiers.as_ref())
49                .and_then(|c| c.get(&native_key));
50
51            if let Some(artifact) = artifact {
52                if let Some(item) = artifact_to_item(base, artifact, &lib.name, true) {
53                    items.push(item);
54                }
55            }
56        } else {
57            // ── Regular branch ───────────────────────────────────────────────
58            if skip_library(lib.rules.as_deref().unwrap_or(&[])) {
59                continue;
60            }
61
62            if let Some(item) = resolve_regular_library(base, lib) {
63                items.push(item);
64            }
65        }
66    }
67
68    // Client JAR
69    if let Some(dl) = &version_json.downloads {
70        items.push(AssetItem::Asset {
71            path: base
72                .join("versions")
73                .join(&version_json.id)
74                .join(format!("{}.jar", version_json.id))
75                .to_string_lossy()
76                .into_owned(),
77            sha1: dl.client.sha1.clone(),
78            size: dl.client.size,
79            url: dl.client.url.clone(),
80        });
81    }
82
83    // Version JSON as CFile (written verbatim, no download needed)
84    if let Ok(content) = serde_json::to_string(version_json) {
85        items.push(AssetItem::CFile {
86            path: base
87                .join("versions")
88                .join(&version_json.id)
89                .join(format!("{}.json", version_json.id))
90                .to_string_lossy()
91                .into_owned(),
92            content,
93        });
94    }
95
96    items
97}
98
99/// Fetch a list of custom/additional assets from a remote URL.
100///
101/// The URL is expected to return a JSON array of
102/// `{ path, hash, size, url }` objects.  Each entry is emitted as an
103/// `AssetItem::Asset` with its path prefixed by `options.path` (and
104/// `instances/<instance>/` when instanced).
105///
106/// Returns an empty `Vec` when `url` is `None`.
107pub async fn get_assets_others(
108    options: &LaunchOptions,
109    url: Option<&str>,
110    client: &reqwest::Client,
111) -> Result<Vec<AssetItem>, LaunchError> {
112    let url = match url {
113        Some(u) if !u.is_empty() => u,
114        _ => return Ok(vec![]),
115    };
116
117    let raw: Vec<CustomAssetItem> = fetch_json(client, url)
118        .await
119        .map_err(LaunchError::InvalidData)?;
120
121    let mut items = Vec::with_capacity(raw.len());
122
123    for asset in raw {
124        if asset.path.is_empty() {
125            continue;
126        }
127
128        let full_path = match &options.instance {
129            Some(inst) => options
130                .path
131                .join("instances")
132                .join(inst)
133                .join(&asset.path),
134            None => options.path.join(&asset.path),
135        };
136
137        items.push(AssetItem::Asset {
138            path: full_path.to_string_lossy().into_owned(),
139            sha1: asset.hash,
140            size: asset.size,
141            url: asset.url,
142        });
143    }
144
145    Ok(items)
146}
147
148/// Extract all native JARs in `bundle` (`NativeAsset` items) to
149/// `<options.path>/versions/<id>/natives/`.
150///
151/// Skips `META-INF/` entries; sets executable bit on Unix (0o755).
152/// Uses `spawn_blocking` so the synchronous `zip` operations don't block the
153/// Tokio runtime.
154pub async fn extract_natives(
155    options: &LaunchOptions,
156    version_json: &MinecraftVersionJson,
157    bundle: &[AssetItem],
158) -> Result<(), LaunchError> {
159    let native_paths: Vec<PathBuf> = bundle
160        .iter()
161        .filter_map(|item| match item {
162            AssetItem::NativeAsset { path, .. } => Some(PathBuf::from(path)),
163            _ => None,
164        })
165        .collect();
166
167    if native_paths.is_empty() {
168        return Ok(());
169    }
170
171    let natives_dir = options
172        .path
173        .join("versions")
174        .join(&version_json.id)
175        .join("natives");
176    tokio::fs::create_dir_all(&natives_dir).await?;
177
178    for jar_path in native_paths {
179        let dest = natives_dir.clone();
180        tokio::task::spawn_blocking(move || extract_jar_to_dir(&jar_path, &dest))
181            .await
182            .map_err(|e| LaunchError::Archive(e.to_string()))??;
183    }
184
185    Ok(())
186}
187
188// ── Internal helpers ──────────────────────────────────────────────────────────
189
190/// Arch suffix used in native classifier names (`${arch}` placeholder).
191///
192/// Mojang classifiers use `"32"` / `"64"` for x86 variants; ARM platforms
193/// leave the suffix empty.
194fn arch_suffix_for_natives() -> &'static str {
195    match std::env::consts::ARCH {
196        "x86" => "32",
197        "x86_64" => "64",
198        _ => "",
199    }
200}
201
202/// Convert an `ArtifactInfo` to an `AssetItem`, deriving the relative path
203/// from the Maven coordinate when `artifact.path` is absent.
204fn artifact_to_item(
205    base: &Path,
206    artifact: &ArtifactInfo,
207    lib_name: &str,
208    is_native: bool,
209) -> Option<AssetItem> {
210    let rel = artifact.path.clone().or_else(|| {
211        get_path_libraries(lib_name, None, None)
212            .ok()
213            .map(|lp| lp.path)
214    })?;
215
216    let full_path = base
217        .join("libraries")
218        .join(&rel)
219        .to_string_lossy()
220        .into_owned();
221
222    let sha1 = artifact.sha1.clone().unwrap_or_default();
223    let size = artifact.size.unwrap_or(0);
224    let url = artifact.url.clone();
225
226    if is_native {
227        Some(AssetItem::NativeAsset { path: full_path, sha1, size, url })
228    } else {
229        Some(AssetItem::Asset { path: full_path, sha1, size, url })
230    }
231}
232
233/// Resolve a regular (non-native) library to an `AssetItem`.
234///
235/// Priority:
236/// 1. `downloads.artifact` — direct download info from Mojang JSON.
237/// 2. `lib.url` + Maven coordinate — for loader-injected libraries
238///    (Fabric/Quilt) that carry a repository URL but no direct download block.
239fn resolve_regular_library(base: &Path, lib: &Library) -> Option<AssetItem> {
240    // Modern Minecraft (1.19+) encodes natives as separate library entries
241    // with a "natives-<platform>" classifier in the Maven coordinate
242    // (e.g. "org.lwjgl:lwjgl-glfw:3.3.2:natives-linux") instead of the
243    // old-style `lib.natives` map. OS filtering is handled via rules so
244    // by the time we reach here the library already matched the current
245    // platform. Mark it as a native so its contents are extracted to the
246    // natives directory rather than placed on the classpath.
247    let is_native = lib.name.split(':').nth(3)
248        .map(|c| c.starts_with("natives-"))
249        .unwrap_or(false);
250
251    // Priority 1 — explicit artifact download block
252    if let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) {
253        return artifact_to_item(base, artifact, &lib.name, is_native);
254    }
255
256    // Priority 2 — build URL from Maven coordinate + repo base URL
257    if let Some(repo) = &lib.url {
258        if let Ok(lp) = get_path_libraries(&lib.name, None, None) {
259            let url = format!("{}/{}", repo.trim_end_matches('/'), lp.path);
260            return Some(AssetItem::Asset {
261                path: base
262                    .join("libraries")
263                    .join(&lp.path)
264                    .to_string_lossy()
265                    .into_owned(),
266                sha1: String::new(),
267                size: 0,
268                url,
269            });
270        }
271    }
272
273    None
274}
275
276/// Extract the non-META-INF file entries from a JAR/ZIP to `dest`.
277///
278/// Called inside `spawn_blocking`; all I/O is synchronous.
279fn extract_jar_to_dir(jar_path: &Path, dest: &Path) -> Result<(), LaunchError> {
280    let file = std::fs::File::open(jar_path)?;
281    let mut archive =
282        zip::ZipArchive::new(file).map_err(|e| LaunchError::Archive(e.to_string()))?;
283
284    for i in 0..archive.len() {
285        let mut entry = archive
286            .by_index(i)
287            .map_err(|e| LaunchError::Archive(e.to_string()))?;
288
289        let name = entry.name().to_string();
290
291        if name.starts_with("META-INF") {
292            continue;
293        }
294
295        let out = dest.join(&name);
296
297        if entry.is_dir() {
298            std::fs::create_dir_all(&out)?;
299        } else {
300            if let Some(parent) = out.parent() {
301                std::fs::create_dir_all(parent)?;
302            }
303            let mut data = Vec::with_capacity(entry.size() as usize);
304            entry.read_to_end(&mut data)?;
305            std::fs::write(&out, &data)?;
306
307            #[cfg(unix)]
308            {
309                use std::os::unix::fs::PermissionsExt;
310                let mut perms = std::fs::metadata(&out)?.permissions();
311                perms.set_mode(0o755);
312                std::fs::set_permissions(&out, perms)?;
313            }
314        }
315    }
316
317    Ok(())
318}
319
320// ── Custom asset item (from remote URL) ───────────────────────────────────────
321
322#[derive(Deserialize)]
323struct CustomAssetItem {
324    path: String,
325    hash: String,
326    size: u64,
327    url: String,
328}
329
330// ── Tests ─────────────────────────────────────────────────────────────────────
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::io::Write;
336    use tempfile::TempDir;
337
338    use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
339    use crate::models::minecraft::{
340        ArtifactInfo, Authenticator, DownloadArtifact, LibraryDownloads, VersionDownloads,
341    };
342
343    fn opts(path: PathBuf) -> LaunchOptions {
344        LaunchOptions {
345            path,
346            version: "1.20.4".into(),
347            authenticator: Authenticator {
348                access_token: "tok".into(),
349                name: "Player".into(),
350                uuid: "uuid".into(),
351                xbox_account: None,
352                user_properties: None,
353                client_id: None,
354                client_token: None,
355            },
356            timeout_secs: 10,
357            download_concurrency: 5,
358            verify_concurrency: 4,
359            memory: MemoryConfig::default(),
360            java: JavaOptions::default(),
361            loader: LoaderConfig::default(),
362            screen: ScreenConfig::default(),
363            verify: false,
364            game_args: vec![],
365            jvm_args: vec![],
366            instance: None,
367            url: None,
368            mcp: None,
369            intel_enabled_mac: false,
370            bypass_offline: false,
371            skip_bundle_check: false,
372        }
373    }
374
375    fn bare_version() -> MinecraftVersionJson {
376        MinecraftVersionJson {
377            id: "1.20.4".into(),
378            version_type: "release".into(),
379            assets: None,
380            asset_index: None,
381            downloads: None,
382            libraries: vec![],
383            arguments: None,
384            minecraft_arguments: None,
385            java_version: None,
386            main_class: None,
387            has_natives: false,
388        }
389    }
390
391    fn artifact(path: &str, url: &str) -> ArtifactInfo {
392        ArtifactInfo {
393            path: Some(path.into()),
394            sha1: Some("aabbcc".into()),
395            size: Some(1024),
396            url: url.into(),
397        }
398    }
399
400    fn lib_with_artifact(name: &str, path: &str, url: &str) -> Library {
401        Library {
402            name: name.into(),
403            rules: None,
404            natives: None,
405            downloads: Some(LibraryDownloads {
406                artifact: Some(artifact(path, url)),
407                classifiers: None,
408            }),
409            url: None,
410            loader: None,
411        }
412    }
413
414    // ── get_libraries ─────────────────────────────────────────────────────────
415
416    #[test]
417    fn includes_client_jar_when_downloads_present() {
418        let dir = TempDir::new().unwrap();
419        let mut vj = bare_version();
420        vj.downloads = Some(VersionDownloads {
421            client: DownloadArtifact {
422                sha1: "abc".into(),
423                size: 42,
424                url: "https://example.com/client.jar".into(),
425            },
426            server: None,
427            client_mappings: None,
428            server_mappings: None,
429        });
430
431        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
432        assert!(items.iter().any(|i| matches!(i, AssetItem::Asset { path, .. } if path.ends_with("1.20.4.jar"))));
433    }
434
435    #[test]
436    fn includes_version_json_as_cfile() {
437        let dir = TempDir::new().unwrap();
438        let vj = bare_version();
439        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
440        assert!(items.iter().any(|i| matches!(i, AssetItem::CFile { path, .. } if path.ends_with("1.20.4.json"))));
441    }
442
443    #[test]
444    fn regular_library_becomes_asset() {
445        let dir = TempDir::new().unwrap();
446        let mut vj = bare_version();
447        vj.libraries = vec![lib_with_artifact(
448            "com.example:lib:1.0",
449            "com/example/lib/1.0/lib-1.0.jar",
450            "https://example.com/lib.jar",
451        )];
452
453        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
454        assert!(items.iter().any(|i| matches!(i, AssetItem::Asset { url, .. } if url == "https://example.com/lib.jar")));
455    }
456
457    #[test]
458    fn native_library_becomes_native_asset() {
459        let dir = TempDir::new().unwrap();
460        let mut vj = bare_version();
461
462        let current_os = mojang_os();
463        let classifier_key = format!("natives-{current_os}");
464
465        let mut classifiers = std::collections::HashMap::new();
466        classifiers.insert(
467            classifier_key.clone(),
468            artifact(
469                &format!("org/lwjgl/lwjgl/{classifier_key}/lwjgl-native.jar"),
470                "https://example.com/native.jar",
471            ),
472        );
473
474        let mut natives_map = std::collections::HashMap::new();
475        natives_map.insert(current_os.to_string(), classifier_key);
476
477        vj.libraries = vec![Library {
478            name: "org.lwjgl:lwjgl:3.3.1".into(),
479            rules: None,
480            natives: Some(natives_map),
481            downloads: Some(LibraryDownloads {
482                artifact: None,
483                classifiers: Some(classifiers),
484            }),
485            url: None,
486            loader: None,
487        }];
488
489        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
490        assert!(items.iter().any(|i| matches!(i, AssetItem::NativeAsset { url, .. } if url == "https://example.com/native.jar")));
491    }
492
493    #[test]
494    fn modern_native_classifier_in_name_becomes_native_asset() {
495        // 1.19+ LWJGL natives: no `lib.natives` map; classifier is in the name.
496        let dir = TempDir::new().unwrap();
497        let mut vj = bare_version();
498
499        let current_os = mojang_os();
500        let classifier = format!("natives-{current_os}");
501        let lib_name = format!("org.lwjgl:lwjgl-glfw:3.3.2:{classifier}");
502        let jar_path = format!("org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-{classifier}.jar");
503
504        vj.libraries = vec![Library {
505            name: lib_name,
506            rules: None,
507            natives: None,
508            downloads: Some(LibraryDownloads {
509                artifact: Some(artifact(&jar_path, "https://libraries.minecraft.net/native.jar")),
510                classifiers: None,
511            }),
512            url: None,
513            loader: None,
514        }];
515
516        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
517        assert!(
518            items.iter().any(|i| matches!(i, AssetItem::NativeAsset { .. })),
519            "expected NativeAsset for modern natives-<os> classifier, got: {items:?}"
520        );
521    }
522
523    #[test]
524    fn library_with_url_fallback_builds_url() {
525        let dir = TempDir::new().unwrap();
526        let mut vj = bare_version();
527        vj.libraries = vec![Library {
528            name: "net.fabricmc:fabric-loader:0.15.0".into(),
529            rules: None,
530            natives: None,
531            downloads: None,
532            url: Some("https://maven.fabricmc.net".into()),
533            loader: None,
534        }];
535
536        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
537        assert!(items.iter().any(|i| match i {
538            AssetItem::Asset { url, .. } => url.starts_with("https://maven.fabricmc.net"),
539            _ => false,
540        }));
541    }
542
543    // ── get_assets_others ─────────────────────────────────────────────────────
544
545    #[tokio::test]
546    async fn get_assets_others_none_url_returns_empty() {
547        let dir = TempDir::new().unwrap();
548        let client = reqwest::Client::new();
549        let result = get_assets_others(&opts(dir.path().to_path_buf()), None, &client)
550            .await
551            .unwrap();
552        assert!(result.is_empty());
553    }
554
555    #[tokio::test]
556    async fn get_assets_others_empty_string_returns_empty() {
557        let dir = TempDir::new().unwrap();
558        let client = reqwest::Client::new();
559        let result = get_assets_others(&opts(dir.path().to_path_buf()), Some(""), &client)
560            .await
561            .unwrap();
562        assert!(result.is_empty());
563    }
564
565    // ── extract_natives ───────────────────────────────────────────────────────
566
567    #[tokio::test]
568    async fn extract_natives_noop_with_empty_bundle() {
569        let dir = TempDir::new().unwrap();
570        let vj = bare_version();
571        extract_natives(&opts(dir.path().to_path_buf()), &vj, &[])
572            .await
573            .unwrap();
574        assert!(!dir.path().join("versions").exists());
575    }
576
577    #[tokio::test]
578    async fn extract_natives_extracts_to_natives_dir() {
579        // Build a tiny ZIP with one native file and one META-INF entry.
580        let dir = TempDir::new().unwrap();
581        let jar_path = dir.path().join("native.jar");
582
583        {
584            use zip::write::SimpleFileOptions;
585            let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
586            let opts_zip = SimpleFileOptions::default();
587
588            w.start_file("META-INF/MANIFEST.MF", opts_zip).unwrap();
589            w.write_all(b"Manifest-Version: 1.0\n").unwrap();
590
591            w.start_file("liblwjgl.so", opts_zip).unwrap();
592            w.write_all(b"ELF native library").unwrap();
593
594            let finished = w.finish().unwrap();
595            std::fs::write(&jar_path, finished.get_ref()).unwrap();
596        }
597
598        let vj = bare_version();
599        let options = opts(dir.path().to_path_buf());
600
601        let bundle = vec![AssetItem::NativeAsset {
602            path: jar_path.to_string_lossy().into_owned(),
603            sha1: String::new(),
604            size: 0,
605            url: String::new(),
606        }];
607
608        extract_natives(&options, &vj, &bundle).await.unwrap();
609
610        let natives_dir = dir.path().join("versions").join("1.20.4").join("natives");
611        assert!(natives_dir.join("liblwjgl.so").exists());
612        assert!(!natives_dir.join("META-INF").exists());
613
614        let content = std::fs::read(natives_dir.join("liblwjgl.so")).unwrap();
615        assert_eq!(content, b"ELF native library");
616    }
617}