Skip to main content

uranium_rs/downloaders/
minecraft_downloader.rs

1use std::io::Write;
2use std::{
3    fs::File,
4    path::{Path, PathBuf},
5    os::unix::fs::PermissionsExt
6};
7
8use log::{error, info};
9use mine_data_structs::minecraft::{
10    Library, MinecraftVersions, Profile, ProfilesJson, Resources, Root,
11};
12use reqwest;
13use tokio::io::AsyncWriteExt;
14
15use super::RuntimeDownloader;
16use super::gen_downloader::{DownloadState, DownloadableObject, FileDownloader, HashType};
17use crate::{
18    code_functions::N_THREADS,
19    error::{Result, UraniumError},
20    variables::constants::PROFILES_FILE,
21};
22
23const ASSETS_PATH: &str = "assets/";
24const OBJECTS_PATH: &str = "objects";
25const INSTANCES_LIST: &str = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
26
27/*
28
29   MINECRAFT INSTANCES VERSIONS/LIST ?
30
31*/
32
33/// Function that returns a list `Result<MinecraftInstances, UraniumError>`
34///
35/// Returns a `Result<_, _>` where the `Ok()` value is a `MinecraftInstances`
36/// struct and the `Err()` value a `UraniumError`.
37///
38/// # Errors
39/// This function can fail when fetching the minecraft versions from Microsoft
40/// page. In that case this function will return an
41/// `Err(UraniumError::RequestError)`
42pub async fn list_instances() -> Result<MinecraftVersions> {
43    let requester = reqwest::Client::new();
44
45    let instances = requester
46        .get(INSTANCES_LIST)
47        .send()
48        .await?
49        .json::<MinecraftVersions>()
50        .await?;
51
52    Ok(instances)
53}
54
55/// Function that returns the latest Minecraft snapshot version as a
56/// `Result<String, UraniumError>`.
57///
58/// Returns a `uranium_rs::error::Result<_, _>` where the `Ok()` value is a
59/// `String` representing the latest snapshot version, and the `Err()` value is
60/// a `UraniumError`.
61///
62/// # Errors
63/// This function can fail when fetching the Minecraft versions from the
64/// Microsoft page. In such a case, this function will return an
65/// `Err(UraniumError::RequestError)`.
66pub async fn get_last_snapshot() -> Result<String> {
67    let requester = reqwest::Client::new();
68    Ok(requester
69        .get(INSTANCES_LIST)
70        .send()
71        .await?
72        .json::<MinecraftVersions>()
73        .await?
74        .latest
75        .snapshot)
76}
77
78/// Function that returns the latest Minecraft release version as a
79/// `Result<String, UraniumError>`.
80///
81/// Returns a `uranium_rs::error::Result<_, _>` where the `Ok()` value is a
82/// `String` representing the latest release version, and the `Err()` value is a
83/// `UraniumError`.
84///
85/// # Errors
86/// This function can fail when fetching the Minecraft versions from the
87/// Microsoft page. In such a case, this function will return an
88/// `Err(UraniumError::RequestError)`.
89pub async fn get_last_release() -> Result<String> {
90    let requester = reqwest::Client::new();
91    Ok(requester
92        .get(INSTANCES_LIST)
93        .send()
94        .await?
95        .json::<MinecraftVersions>()
96        .await?
97        .latest
98        .release)
99}
100
101/*
102
103        DOWNLOAD MINECRAFT RESOURCES CODE SECTION
104
105*/
106
107/// Indicates the download state of a Minecraft instance.
108#[derive(Debug, Clone)]
109pub enum MinecraftDownloadState {
110    GettingSources,
111    DownloadingVersion,
112    DownloadingAssests,
113    DownloadingLibraries,
114    DownloadingRuntime,
115    CheckingFiles,
116    Completed,
117}
118
119/// This struct is responsible for downloading Minecraft and it's libraries.
120///
121///
122/// # Example:
123///
124/// ```no_run
125/// use uranium_rs::downloaders::{FileDownloader, MinecraftDownloader, MinecraftDownloadState};
126/// use uranium_rs::error::Result;
127///
128/// async fn foo<T: FileDownloader + Send + Sync>() -> Result<()> {
129///     // T: FileDownloader + Send + Sync
130///     let mut minecraft_down = MinecraftDownloader::<T>::init("my/path", "1.20.1").await?;
131///
132///     loop {
133///         let state = minecraft_down.progress().await;
134///
135///         match state {
136///             // If completed break
137///             Ok(MinecraftDownloadState::Completed) => {
138///                 println!("Instalation completed!");
139///                 break;
140///             },
141///             // Doing progress
142///             Ok(_) => {
143///                 println!("Instaling...");
144///             },
145///
146///             // Also if error break.
147///             Err(e) => {
148///                 eprintln!("Error while installing minecraft: {}", e);
149///                 return Err(e);
150///            },
151///         }
152///     }
153///     Ok(())
154/// }
155/// ```
156pub struct MinecraftDownloader<T: FileDownloader + Send> {
157    requester: reqwest::Client,
158    dot_minecraft_path: PathBuf,
159    minecraft_instance: Root,
160    download_state: MinecraftDownloadState,
161    downloader: T,
162}
163
164impl<T: FileDownloader + Send + Sync> MinecraftDownloader<T> {
165    /// Makes a new `MinecraftDownloader` struct.
166    ///
167    /// - `destination_path`: Where minecraft will be downloaded. (THIS IS
168    ///   USUALLY `.minecraft` DIRECTORY)
169    /// - `minecraft_version`: Which versions is going to be downloaded.
170    ///
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use uranium_rs::downloaders::MinecraftDownloader;
176    /// use uranium_rs::downloaders::FileDownloader;
177    /// use uranium_rs::error::Result;
178    ///
179    /// async fn foo<T: FileDownloader + Send + Sync>() -> Result<()>{
180    ///
181    ///     // This will result in an error since "league of legends" is mental illness.
182    ///     // (and also a game)
183    ///     MinecraftDownloader::<T>::init("my/mine/path", "league of legends").await?;
184    ///     Ok(())
185    /// }
186    /// ```
187    pub async fn init<I: AsRef<Path>>(
188        destination_path: I,
189        minecraft_version: &str,
190    ) -> Result<Self> {
191        let requester = reqwest::Client::new();
192        let instances = list_instances().await?;
193
194        let instance_url = instances
195            .get_instance_url(minecraft_version)
196            .ok_or(UraniumError::OtherWithReason(format!(
197                "Version {minecraft_version} doesn't exist"
198            )))?;
199
200        let minecraft_instance: Root = requester
201            .get(instance_url)
202            .send()
203            .await?
204            .json()
205            .await?;
206
207        let destination_path = destination_path
208            .as_ref()
209            .to_path_buf();
210
211        Ok(MinecraftDownloader::new(
212            destination_path,
213            minecraft_instance,
214        ))
215    }
216
217    /// WIP
218    fn new(destination_path: PathBuf, minecraft_instance: Root) -> Self {
219        MinecraftDownloader {
220            requester: reqwest::Client::new(),
221            dot_minecraft_path: destination_path,
222            minecraft_instance,
223            download_state: MinecraftDownloadState::GettingSources,
224            downloader: T::new(vec![]),
225        }
226    }
227
228    /// This function will start the download anb block until
229    /// `Ok(MinecraftDownloadState::Completed)`is returned if success or
230    /// `Err(UraniumError)` if failed.
231    ///
232    /// # Errors
233    /// This method will call `self.progress()` repeatedly. If there is any
234    /// error, this method will propagate it.
235    pub async fn start(&mut self) -> Result<MinecraftDownloadState> {
236        loop {
237            let state = self.progress().await;
238
239            match state {
240                Ok(MinecraftDownloadState::Completed) => break,
241                Err(e) => return Err(e),
242                _ => {}
243            }
244        }
245        Ok(MinecraftDownloadState::Completed)
246    }
247
248    /// This function will make progress in the installation. It will go through
249    /// all the installations steps (`MinecraftDownloadState`) so the user
250    /// can know what is the downloader doing and can show a progress bar,
251    /// info logs...
252    ///
253    ///
254    /// Every time a step is completed `self.download_state` will change to the
255    /// next step working like a FSM.
256    ///
257    /// # Errors
258    ///
259    /// Because this struct works like a State Machine this function can fail in
260    /// many steps. Each step will return the corresponding
261    /// `Err(UraniumError)` if an error occurs.
262    pub async fn progress(&mut self) -> Result<MinecraftDownloadState> {
263        match self.download_state {
264            MinecraftDownloadState::GettingSources => {
265                let assets: Box<[DownloadableObject]> = self
266                    .get_sources()
267                    .await?
268                    .collect();
269
270                if self
271                    .create_assets_folders(assets.iter())
272                    .is_err()
273                {
274                    error!("Error creating assets folders");
275                    return Err(UraniumError::CantCreateDir("assets"));
276                };
277
278                self.downloader
279                    .add_objects(assets);
280                self.download_state = MinecraftDownloadState::DownloadingVersion;
281            }
282
283            MinecraftDownloadState::DownloadingVersion => {
284                self.create_version_folder()
285                    .await?;
286                self.download_state = MinecraftDownloadState::DownloadingAssests;
287            }
288
289            MinecraftDownloadState::DownloadingAssests => {
290                let download_state = self
291                    .downloader
292                    .progress()
293                    .await;
294
295                match download_state {
296                    Ok(DownloadState::Completed) => {
297                        let libs: Box<[_]> = self
298                            .prepare_libraries()?
299                            .collect();
300                        self.downloader
301                            .add_objects(libs);
302                        self.download_state = MinecraftDownloadState::DownloadingLibraries;
303                    }
304                    Err(e) => {
305                        error!("Error downloading assets: {e}");
306                        return Err(e);
307                    }
308                    _ => {}
309                }
310            }
311
312            MinecraftDownloadState::DownloadingLibraries => {
313                let download_state = self
314                    .downloader
315                    .progress()
316                    .await;
317
318                match download_state {
319                    Ok(DownloadState::Completed) => {
320                        self.download_state = MinecraftDownloadState::DownloadingRuntime;
321                    }
322                    Err(e) => {
323                        error!("Error downloading libraries: {e}");
324                        return Err(e);
325                    }
326                    _ => {}
327                }
328            }
329
330            MinecraftDownloadState::DownloadingRuntime => {
331                let runtime_res = RuntimeDownloader::new(
332                    self.minecraft_instance
333                        .java_version
334                        .component
335                        .to_string(),
336                )
337                .download()
338                .await;
339
340                if let Err(err) = runtime_res {
341                    error!("Error downloading runtime: {}", err);
342                    return Err(err);
343                }
344                self.download_state = MinecraftDownloadState::CheckingFiles;
345            }
346
347            MinecraftDownloadState::CheckingFiles => {
348                // TODO: Check the files
349                self.download_state = MinecraftDownloadState::Completed;
350            }
351
352            MinecraftDownloadState::Completed => {
353                info!("Minecraft download complete!");
354            }
355        };
356
357        Ok(self.download_state.clone())
358    }
359
360    /// Creates the version folder structure for a Minecraft instance and
361    /// ensures required files are present.
362    ///
363    /// This method creates the necessary directory structure under
364    /// `.minecraft/versions/` for the current Minecraft instance. It
365    /// creates a folder named after the instance ID and ensures that both
366    /// the client JAR file and instance JSON file are properly downloaded
367    /// and validated.
368    ///
369    /// # Returns
370    ///
371    /// Returns `Ok(())` on successful completion of all operations.
372    async fn create_version_folder(&mut self) -> Result<()> {
373        let instance_folder = self
374            .dot_minecraft_path
375            .join("versions")
376            .join(&self.minecraft_instance.id);
377
378        info!("Instance folder: {instance_folder:?}");
379
380        if !instance_folder.exists() {
381            std::fs::create_dir_all(&instance_folder)?;
382        }
383
384        // .minectaft/versions/version/version.jar
385        self.check_client(&instance_folder)
386            .await?;
387
388        // .minectaft/versions/version/version.json
389        self.check_instance(&instance_folder)?;
390        Ok(())
391    }
392
393    /// Checks versions/version/version.jar file.
394    async fn check_client(&mut self, instance_folder: &Path) -> Result<()> {
395        let client_path = instance_folder.join(
396            self.minecraft_instance
397                .id
398                .clone()
399                + ".jar",
400        );
401        if !client_path.exists() {
402            info!("Downloading client!");
403            let (url, hash) = self
404                .minecraft_instance
405                .downloads
406                .get("client")
407                .map(|i| (&i.url, i.sha1.to_string()))
408                .ok_or(UraniumError::OtherWithReason(
409                    "Client .jar not found in the minecraft instance".to_owned(),
410                ))?;
411            let obj = DownloadableObject::new(url, &client_path, Some(HashType::Sha1(hash)));
412            self.downloader
413                .add_object(obj);
414            self.downloader
415                .complete()
416                .await?;
417            std::fs::set_permissions(&client_path, std::fs::Permissions::from_mode(0o766))?
418        }
419        Ok(())
420    }
421
422    fn check_instance(&self, instance_folder: &Path) -> Result<()> {
423        let instance_path = instance_folder.join(
424            self.minecraft_instance
425                .id
426                .clone()
427                + ".json",
428        );
429        if !instance_path.exists() {
430            info!("Writing client json!");
431            let mut instance_file = File::create(instance_path)?;
432            instance_file.write_all(
433                serde_json::to_string(&self.minecraft_instance)
434                    .unwrap()
435                    .as_bytes(),
436            )?;
437        }
438        Ok(())
439    }
440
441    /// Returns the number of requests left to be processed by the downloader,
442    /// taking into account the configured number of threads for concurrent
443    /// processing.
444    ///
445    /// This method checks if a downloader is associated with the current
446    /// instance, and if so, it queries the number of requests left from the
447    /// downloader. The result is then adjusted to distribute the workload
448    /// evenly among the configured number of threads.
449    ///
450    /// # Returns
451    /// The adjusted number of requests left to be processed by the downloader.
452    /// If there is no downloader associated with the current instance, it
453    /// returns 0.
454    pub fn requests_left(&self) -> usize {
455        self.downloader
456            .requests_left()
457    }
458
459    /// Returns the number of chunks of libs to download: `libs.len() /
460    /// N_THREADS()`
461    pub fn lib_chunks(&self) -> usize {
462        let n = self
463            .minecraft_instance
464            .libraries
465            .len() as f64;
466        (n / N_THREADS() as f64).ceil() as usize
467    }
468
469    /// Return the number of chunks to download.
470    ///
471    /// If the downloader is empty, then this method will download 0.
472    pub fn chunks(&self) -> usize {
473        let n = self.downloader.len() as f64;
474        (n / N_THREADS() as f64).ceil() as usize
475    }
476
477    async fn get_sources(&self) -> Result<impl Iterator<Item = DownloadableObject>> {
478        let resources: Resources = self
479            .requester
480            .get(
481                &self
482                    .minecraft_instance
483                    .asset_index
484                    .url,
485            )
486            .send()
487            .await?
488            .json::<Resources>()
489            .await?;
490
491        std::fs::create_dir_all(
492            self.dot_minecraft_path
493                .join("assets/indexes"),
494        )
495        .map_err(|err| {
496            error!("Cant create assets/indexes: [{err}]");
497            UraniumError::CantCreateDir("assets/indexes")
498        })?;
499
500        std::fs::create_dir_all(
501            self.dot_minecraft_path
502                .join("assets/objects"),
503        )
504        .map_err(|err| {
505            error!("Cant create assets/objects: [{err}]");
506            UraniumError::CantCreateDir("assets/objects")
507        })?;
508
509        self.create_indexes(&resources)
510            .await?;
511
512        let base = PathBuf::from(ASSETS_PATH).join(OBJECTS_PATH);
513
514        let x = resources
515            .objects
516            .into_values()
517            .map(move |obj| {
518                let url = obj.get_link();
519                let path = base
520                    .join(&obj.hash[..2])
521                    .join(&obj.hash);
522                DownloadableObject::new(
523                    &url,
524                    &self
525                        .dot_minecraft_path
526                        .join(path),
527                    Some(HashType::Sha1(obj.hash.to_owned())),
528                )
529            });
530        Ok(x)
531    }
532
533    /// Makes the minecraft index.json file
534    async fn create_indexes(&self, resources: &Resources) -> Result<()> {
535        let indexes_path = self
536            .dot_minecraft_path
537            .join(ASSETS_PATH)
538            .join("indexes")
539            .join(
540                self.minecraft_instance
541                    .get_index_name(),
542            );
543
544        let mut indexes = tokio::fs::File::create(indexes_path).await?;
545
546        indexes
547            .write_all(
548                serde_json::to_string(resources)
549                    .map_err(|_| {
550                        UraniumError::OtherWithReason(
551                            "Resources is not serializable or has wrong data".to_string(),
552                        )
553                    })?
554                    .as_bytes(),
555            )
556            .await?;
557
558        Ok(())
559    }
560
561    /// When success all the assets folder are created
562    fn create_assets_folders<'a>(
563        &self,
564        names: impl Iterator<Item = &'a DownloadableObject>,
565    ) -> Result<()> {
566        for dir in names {
567            std::fs::create_dir_all(
568                self.dot_minecraft_path
569                    .join(
570                        dir.name()
571                            .ok_or(UraniumError::other("No filename"))?,
572                    )
573                    .parent()
574                    .ok_or(UraniumError::other("Error creating assests forlder"))?,
575            )?;
576        }
577
578        Ok(())
579    }
580
581    // WIP
582    #[allow(dead_code)]
583    /// Return a `impl Iterator<Item = DownloadableObject>` with the urls of the
584    /// libraries for the current. If the lib has no specified Os then it
585    /// will be inside the vector too.
586    fn get_os_libraries(&self, libraries: &[Library]) -> impl Iterator<Item = DownloadableObject> {
587        let lib_path = self
588            .dot_minecraft_path
589            .join("libraries");
590
591        let current_os = match std::env::consts::OS {
592            "linux" => mine_data_structs::minecraft::Os::Linux,
593            "macos" => mine_data_structs::minecraft::Os::Other,
594            // "windows" => mine_data_structs::minecraft::Os::Windows,
595            _ => mine_data_structs::minecraft::Os::Windows,
596        };
597        libraries
598            .iter()
599            .filter(move |lib| {
600                lib.get_os()
601                    .is_none_or(|os| os == current_os)
602            })
603            .map(move |lib| {
604                DownloadableObject::new(
605                    lib.get_url(),
606                    &lib_path.join(lib.get_rel_path().unwrap()),
607                    None,
608                )
609            })
610    }
611
612    /// This function processes the minecraft instance libraries and creates a
613    /// vector of `DownloadableObject` instances containing the URLs, paths,
614    /// and SHA1 hashes needed for downloading the required libraries.
615    ///
616    /// # Returns
617    ///
618    /// A `Result` containing a `impl Iterator<Item = DownloadableObject>` with
619    /// all the library files that need to be downloaded, or an error if the
620    /// operation fails.
621    fn prepare_libraries(&self) -> Result<impl Iterator<Item = DownloadableObject>> {
622        let lib_path = self
623            .dot_minecraft_path
624            .join("libraries");
625
626        Ok(self
627            .minecraft_instance
628            .libraries
629            .iter()
630            .map(move |l| {
631                DownloadableObject::new(
632                    l.get_url(),
633                    &lib_path.join(
634                        l.get_rel_path()
635                            .expect("Missing download field for library {l:?}"),
636                    ),
637                    l.get_hash()
638                        .map(|h| HashType::Sha1(h.to_string())),
639                )
640            }))
641    }
642
643    /// This function will add a new minecraft profile to
644    /// `launcher_profiles.json` file located in `minecraft_path` dir.
645    ///
646    /// If `icon` is not specified the default Grass icon will be set.
647    ///
648    /// # Errors
649    /// If the `minecraft_path` doesn't exit or is not valid then
650    /// `Err(UraniumError::FileNotFound)` will be returned.
651    ///
652    /// Also, if the profile file is not valid
653    /// `Err(UraniumError::WrongFileFormat)` will be returned
654    ///
655    /// In case it is not possible to write into the file then
656    /// `Err(UraniumError::WriteError)` will be returned
657    pub fn add_instance<I: AsRef<Path>>(
658        &self,
659        minecraft_path: I,
660        instance_name: &str,
661        icon: Option<&str>,
662    ) -> Result<()> {
663        let profiles_path = minecraft_path
664            .as_ref()
665            .to_path_buf()
666            .join(PROFILES_FILE);
667
668        if !profiles_path.exists() {
669            error!("{profiles_path:?} doesn't exist!");
670            return Err(UraniumError::FileNotFound(
671                profiles_path
672                    .display()
673                    .to_string(),
674            ));
675        }
676
677        let mut profiles: ProfilesJson = match serde_json::from_reader(File::open(&profiles_path)?)
678        {
679            Ok(v) => v,
680            Err(e) => Err(UraniumError::OtherWithReason(e.to_string()))?,
681        };
682
683        let icon = icon.unwrap_or("Grass");
684
685        let new_profile = Profile::new(
686            icon,
687            &self.minecraft_instance.id,
688            instance_name,
689            "custom",
690            Some(&self.dot_minecraft_path),
691        );
692
693        profiles.insert(instance_name, new_profile);
694
695        info!("Writing new profile");
696
697        let Ok(content) = serde_json::to_string_pretty(&profiles) else {
698            return Err(UraniumError::WrongFileFormat);
699        };
700
701        if let Err(err) = std::fs::write(profiles_path, content) {
702            error!("Error writing the new profile");
703            return Err(err.into());
704        }
705
706        info!("Profile added!");
707        Ok(())
708    }
709}
710
711pub fn get_index_path(installation_path: &Path, index_name: &Path) -> PathBuf {
712    installation_path
713        .join(ASSETS_PATH)
714        .join("indexes")
715        .join(index_name)
716}
717
718pub fn get_lib_path(installation_path: &Path, lib_path: &Path) -> PathBuf {
719    installation_path
720        .join("libraries")
721        .join(
722            lib_path
723        )
724}
725
726#[cfg(test)]
727mod tests {
728    use log::warn;
729
730use super::*;
731    use crate::downloaders::Downloader;
732    use crate::error::Result;
733    use crate::init_logger;
734
735    #[tokio::test(flavor = "multi_thread")]
736    pub async fn download_minecraft() -> Result<()> {
737        let mut downloader =
738            MinecraftDownloader::<Downloader>::init("/home/sergio/.minecraft", "1.20.1").await?;
739
740        let mut stdout = tokio::io::stdout();
741        let _ = init_logger();
742        let r = loop {
743            let state = if let Ok(x) = downloader.progress().await {
744                x
745            } else {
746                break None;
747            };
748
749            if let MinecraftDownloadState::Completed = state {
750                let instance_res = downloader.add_instance("/home/sergio/.minecraft", "Vanilla 1.20.1", None);
751                if let Err(err) = instance_res {
752                    warn!("{err}");
753                }
754                break Some(());
755            }
756            stdout
757                .write_all(format!("{:?}  [{:?}]\n", state, downloader.requests_left()).as_bytes())
758                .await?;
759            tokio::io::stdout()
760                .flush()
761                .await?;
762        };
763
764        let exits = std::env::home_dir()
765            .unwrap()
766            .join(".minecraft/versions/1.20.1/1.20.1.jar")
767            .exists();
768
769        if r.is_some() {
770            assert!(exits);
771        }
772        Ok(())
773    }
774}