Skip to main content

minecraft_java_rs_core/loader/
fabric.rs

1use tokio::sync::mpsc::Sender;
2
3use crate::error::LoaderError;
4use crate::launcher::events::LaunchEvent;
5use crate::launcher::options::LaunchOptions;
6use crate::models::loader::{FabricJson, FabricMeta, LoaderType};
7use crate::models::minecraft::AssetItem;
8use crate::net::downloader::{DownloadItem, Downloader};
9use crate::net::http::fetch_json;
10use crate::utils::paths::get_path_libraries;
11
12// ── Constants ─────────────────────────────────────────────────────────────────
13
14const FABRIC_META: &str = "https://meta.fabricmc.net/v2/versions";
15const FABRIC_PROFILE: &str =
16    "https://meta.fabricmc.net/v2/versions/loader/${version}/${build}/profile/json";
17
18const LEGACY_META: &str = "https://meta.legacyfabric.net/v2/versions";
19const LEGACY_PROFILE: &str =
20    "https://meta.legacyfabric.net/v2/versions/loader/${version}/${build}/profile/json";
21
22// ── Public types ──────────────────────────────────────────────────────────────
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FabricVariant {
26    Modern,
27    Legacy,
28}
29
30pub struct FabricMC {
31    variant: FabricVariant,
32}
33
34// ── Public API ────────────────────────────────────────────────────────────────
35
36impl FabricMC {
37    pub fn new(variant: FabricVariant) -> Self {
38        Self { variant }
39    }
40
41    pub fn loader_type(&self) -> LoaderType {
42        match self.variant {
43            FabricVariant::Modern => LoaderType::Fabric,
44            FabricVariant::Legacy => LoaderType::LegacyFabric,
45        }
46    }
47
48    /// Fetch the Fabric/LegacyFabric loader profile JSON for the given Minecraft version.
49    ///
50    /// `build` can be `"latest"`, `"recommended"` (treated same as latest for Fabric),
51    /// or an exact version string.
52    pub async fn download_json(
53        &self,
54        mc_version: &str,
55        build: &str,
56        client: &reqwest::Client,
57    ) -> Result<FabricJson, LoaderError> {
58        let (meta_url, profile_template) = match self.variant {
59            FabricVariant::Modern => (FABRIC_META, FABRIC_PROFILE),
60            FabricVariant::Legacy => (LEGACY_META, LEGACY_PROFILE),
61        };
62
63        let meta: FabricMeta = fetch_json(client, meta_url)
64            .await
65            .map_err(LoaderError::ApiError)?;
66
67        // Validate the MC version is supported.
68        let version_name = match self.variant {
69            FabricVariant::Modern => "FabricMC",
70            FabricVariant::Legacy => "LegacyFabric",
71        };
72        if !meta.game.iter().any(|g| g.version == mc_version) {
73            return Err(LoaderError::VersionNotFound(format!(
74                "{version_name} doesn't support Minecraft {mc_version}"
75            )));
76        }
77
78        // Resolve build.
79        let build_ver = if matches!(build, "latest" | "recommended") {
80            meta.loader
81                .first()
82                .map(|b| b.version.clone())
83                .ok_or_else(|| {
84                    LoaderError::VersionNotFound(format!("No {version_name} builds available"))
85                })?
86        } else {
87            meta.loader
88                .iter()
89                .find(|b| b.version == build)
90                .map(|b| b.version.clone())
91                .ok_or_else(|| {
92                    let available: Vec<_> =
93                        meta.loader.iter().map(|b| b.version.as_str()).collect();
94                    LoaderError::VersionNotFound(format!(
95                        "{version_name} build {build} not found. Available: {}",
96                        available.join(", ")
97                    ))
98                })?
99        };
100
101        let profile_url = profile_template
102            .replace("${version}", mc_version)
103            .replace("${build}", &build_ver);
104
105        let json: FabricJson = fetch_json(client, &profile_url)
106            .await
107            .map_err(LoaderError::ApiError)?;
108
109        Ok(json)
110    }
111
112    /// Download any libraries from `fabric_json` that are not yet on disk.
113    ///
114    /// Emits `LaunchEvent::Check` for each library processed.
115    /// Returns `Vec<AssetItem>` covering every library (for bundle-integrity tracking).
116    pub async fn download_libraries(
117        &self,
118        options: &LaunchOptions,
119        fabric_json: &FabricJson,
120        _client: &reqwest::Client,
121        event_tx: &Sender<LaunchEvent>,
122    ) -> Result<Vec<AssetItem>, LoaderError> {
123        let libs = &fabric_json.libraries;
124        let total = libs.len();
125        let mut items: Vec<AssetItem> = Vec::with_capacity(total);
126        let mut pending: Vec<DownloadItem> = Vec::new();
127
128        for (idx, lib) in libs.iter().enumerate() {
129            let _ = event_tx
130                .send(LaunchEvent::Check {
131                    current: idx + 1,
132                    total,
133                    kind: "libraries".into(),
134                })
135                .await;
136
137            // Skip libraries with OS rules — same as the JS loader.
138            if lib.rules.is_some() {
139                continue;
140            }
141
142            let lib_info = match get_path_libraries(&lib.name, None, None) {
143                Ok(i) => i,
144                Err(_) => continue,
145            };
146
147            let loader_name = match self.variant {
148                FabricVariant::Modern => "fabric",
149                FabricVariant::Legacy => "legacyfabric",
150            };
151            let folder = options
152                .loader_dir(loader_name)
153                .join("libraries")
154                .join(&lib_info.path);
155            let dest = folder.join(&lib_info.name);
156
157            let url = resolve_lib_url(lib, &lib_info.path, &lib_info.name);
158
159            items.push(AssetItem::Asset {
160                path: dest.to_string_lossy().into_owned(),
161                sha1: lib
162                    .downloads
163                    .as_ref()
164                    .and_then(|d| d.artifact.as_ref())
165                    .and_then(|a| a.sha1.clone())
166                    .unwrap_or_default(),
167                size: lib
168                    .downloads
169                    .as_ref()
170                    .and_then(|d| d.artifact.as_ref())
171                    .and_then(|a| a.size)
172                    .unwrap_or(0),
173                url: url.clone(),
174            });
175
176            if !dest.exists() {
177                pending.push(DownloadItem {
178                    url,
179                    path: dest,
180                    folder,
181                    name: lib_info.name,
182                    size: 0,
183                    r#type: Some("libraries".into()),
184                    sha1: None,
185                });
186            }
187        }
188
189        if !pending.is_empty() {
190            let downloader = Downloader::new(
191                options.timeout_secs,
192                options.clamped_concurrency(),
193                options.force_ipv4,
194                options.dns,
195            );
196            downloader
197                .download_multiple(pending, event_tx.clone())
198                .await
199                .map_err(|e| {
200                    LoaderError::Io(std::io::Error::new(
201                        std::io::ErrorKind::Other,
202                        e.to_string(),
203                    ))
204                })?;
205        }
206
207        Ok(items)
208    }
209}
210
211// ── Helpers ───────────────────────────────────────────────────────────────────
212
213pub(crate) fn resolve_lib_url(
214    lib: &crate::models::loader::LoaderLibrary,
215    rel_path: &str,
216    name: &str,
217) -> String {
218    // Prefer explicit download URL from the downloads section.
219    if let Some(url) = lib
220        .downloads
221        .as_ref()
222        .and_then(|d| d.artifact.as_ref())
223        .map(|a| a.url.as_str())
224    {
225        return url.to_owned();
226    }
227
228    // Build URL from the base Maven repo URL.
229    let base = lib
230        .url
231        .as_deref()
232        .unwrap_or("https://repo1.maven.org/maven2/");
233    let base = base.trim_end_matches('/');
234    format!("{base}/{rel_path}/{name}")
235}
236
237// ── Tests ─────────────────────────────────────────────────────────────────────
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn fabric_variant_modern_and_legacy_differ() {
245        assert_ne!(FabricVariant::Modern, FabricVariant::Legacy);
246    }
247
248    #[test]
249    fn resolve_lib_url_uses_downloads_url_when_present() {
250        use crate::models::loader::{LoaderArtifact, LoaderLibraryDownloads};
251        let lib = crate::models::loader::LoaderLibrary {
252            name: "a:b:1.0".into(),
253            url: Some("https://repo.example.com/".into()),
254            downloads: Some(LoaderLibraryDownloads {
255                artifact: Some(LoaderArtifact {
256                    sha1: None,
257                    size: None,
258                    path: None,
259                    url: "https://direct.example.com/b-1.0.jar".into(),
260                }),
261            }),
262            rules: None,
263            clientreq: None,
264        };
265        let url = resolve_lib_url(&lib, "a/b/1.0", "b-1.0.jar");
266        assert_eq!(url, "https://direct.example.com/b-1.0.jar");
267    }
268
269    #[test]
270    fn resolve_lib_url_constructs_from_base_url() {
271        let lib = crate::models::loader::LoaderLibrary {
272            name: "a:b:1.0".into(),
273            url: Some("https://maven.fabricmc.net/".into()),
274            downloads: None,
275            rules: None,
276            clientreq: None,
277        };
278        let url = resolve_lib_url(&lib, "a/b/1.0", "b-1.0.jar");
279        assert_eq!(url, "https://maven.fabricmc.net/a/b/1.0/b-1.0.jar");
280    }
281
282    #[test]
283    fn resolve_lib_url_falls_back_to_maven_central() {
284        let lib = crate::models::loader::LoaderLibrary {
285            name: "a:b:1.0".into(),
286            url: None,
287            downloads: None,
288            rules: None,
289            clientreq: None,
290        };
291        let url = resolve_lib_url(&lib, "a/b/1.0", "b-1.0.jar");
292        assert!(url.contains("repo1.maven.org"));
293        assert!(url.contains("b-1.0.jar"));
294    }
295}