use daedalus::download_file_mirrors; use futures::future; use serde::{Deserialize, Serialize}; use std::{ collections::HashSet, hash::Hash, iter::FromIterator, path::{Path, PathBuf}, }; use tokio::fs; use super::{ modrinth_api::{self, ModrinthV1}, ModpackError, ModpackResult, }; use crate::launcher::ModLoader; pub const MODRINTH_DEFAULT_MODPACK_DOMAINS: &[&str] = &[ "cdn.modrinth.com", "edge.forgecdn.net", "github.com", "raw.githubusercontent.com", ]; pub const MODRINTH_MODPACK_DOMAIN_WHITELIST_VAR: &str = "WHITELISTED_MODPACK_DOMAINS"; #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct Modpack { pub game: ModpackGame, pub version: String, pub name: String, pub summary: Option, pub files: HashSet, } impl Modpack { pub fn new(game: ModpackGame, version: &str, name: &str, summary: Option<&str>) -> Self { Self { game, version: String::from(version), name: String::from(name), summary: summary.map(String::from), files: HashSet::new(), } } /// Download a modpack's files for a given side to a given destination /// Assumes the destination exists and is a directory pub async fn download_files(&self, dest: &Path, side: ModpackSide) -> ModpackResult<()> { let handles = self.files.iter().cloned().map(move |file| { let (dest, side) = (dest.to_owned(), side); tokio::spawn(async move { file.fetch(&dest, side).await }) }); future::try_join_all(handles) .await? .into_iter() .collect::>()?; // TODO Integrate instance format to save other metadata Ok(()) } pub async fn add_project( &mut self, project: &str, base_path: &Path, source: Option<&dyn modrinth_api::ModrinthAPI>, channel: Option<&str>, ) -> ModpackResult<()> { let default_api = ModrinthV1(String::from("https://api.modrinth.com")); let channel = channel.unwrap_or("release"); let source = source.unwrap_or(&default_api); let files = source .get_latest_version(project, channel, &self.game) .await? .into_iter() .map(|mut it: ModpackFile| { it.path = base_path.join(it.path); it }); self.files.extend(files); Ok(()) } pub async fn add_version( &mut self, version: &str, base_path: &Path, source: Option<&dyn modrinth_api::ModrinthAPI>, ) -> ModpackResult<()> { let default_api = ModrinthV1(String::from("https://api.modrinth.com")); let source = source.unwrap_or(&default_api); let files = source .get_version(version) .await? .into_iter() .map(|mut it: ModpackFile| { it.path = base_path.join(it.path); it }); self.files.extend(files); Ok(()) } pub async fn add_file( &mut self, source: reqwest::Url, dest: &Path, hashes: Option, env: Option, ) -> ModpackResult<()> { let whitelisted_domains = std::env::var(MODRINTH_MODPACK_DOMAIN_WHITELIST_VAR) .map(|it| serde_json::from_str::>(&it).ok().unwrap()) .unwrap_or_else(|_| { MODRINTH_DEFAULT_MODPACK_DOMAINS .iter() .cloned() .map(String::from) .collect::>() }); if !whitelisted_domains .iter() .any(|it| it == source.host_str().unwrap()) { return Err(ModpackError::SourceWhitelistError(String::from( source.host_str().unwrap(), ))); } let file = ModpackFile { path: PathBuf::from(dest), hashes, env: env.unwrap_or(ModpackEnv::Both), downloads: HashSet::from_iter([String::from(source)].iter().cloned()), }; self.files.insert(file); Ok(()) } } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum ModpackGame { // TODO: Currently, the launcher does not support specifying mod loader versions, so I just // store the loader here. Minecraft(String, ModLoader), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct ModpackFile { pub path: PathBuf, pub hashes: Option, pub env: ModpackEnv, pub downloads: HashSet, } #[allow(clippy::derive_hash_xor_eq)] impl Hash for ModpackFile { fn hash(&self, state: &mut H) { if let Some(ref hashes) = self.hashes { hashes.sha1.hash(state); } self.path.hash(state); } } impl ModpackFile { pub async fn fetch(&self, dest: &Path, side: ModpackSide) -> ModpackResult<()> { if !self.env.supports(side) { return Ok(()); } let output = dest.join(&self.path); // HACK: Since Daedalus appends a file name to all mirrors and the manifest supplies full // URLs, I'm supplying it with an empty string to avoid reinventing the wheel. let bytes = download_file_mirrors( "", self.downloads .iter() .map(|it| it.as_str()) .collect::>() .as_slice(), self.hashes.as_ref().map(|it| it.sha1.as_str()), ) .await?; fs::create_dir_all(output.parent().unwrap()).await?; fs::write(output, bytes).await?; Ok(()) } } #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ModpackEnv { ClientOnly, ServerOnly, Both, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModpackSide { Client, Server, } impl ModpackEnv { pub fn supports(&self, side: ModpackSide) -> bool { match self { Self::ClientOnly => side == ModpackSide::Client, Self::ServerOnly => side == ModpackSide::Server, Self::Both => true, } } } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct ModpackFileHashes { pub sha1: String, } #[cfg(test)] mod tests { use std::{ collections::HashSet, path::{Path, PathBuf}, }; use super::*; use crate::launcher::ModLoader; #[tokio::test] async fn add_version() -> ModpackResult<()> { const TEST_VERSION: &'static str = "TpnSObJ7"; let mut test_pack = Modpack::new( ModpackGame::Minecraft(String::from("1.16.5"), ModLoader::Fabric), "0.1.0", "Example Modpack", None, ); test_pack .add_version(TEST_VERSION, Path::new("mods/"), None) .await?; assert_eq!( test_pack, Modpack { game: ModpackGame::Minecraft(String::from("1.16.5"), ModLoader::Fabric), version: String::from("0.1.0"), name: String::from("Example Modpack"), summary: None, files: { let mut files = HashSet::new(); files.insert(ModpackFile { path: PathBuf::from("mods/gravestones-v1.9.jar"), hashes: Some(ModpackFileHashes { sha1: String::from("3f0f6d523d218460310b345be03ab3f1d294e04d"), }), env: ModpackEnv::Both, downloads: { let mut downloads = HashSet::new(); downloads.insert(String::from("https://cdn.modrinth.com/data/ssUbhMkL/versions/v1.9/gravestones-v1.9.jar")); downloads } }); files }, }, ); Ok(()) } }