From 5ffddd6c8aa3a2c15925df635c7c9002be04f17f Mon Sep 17 00:00:00 2001 From: Daniel Hutzley Date: Sun, 5 Dec 2021 10:41:56 -0800 Subject: [PATCH] Added modpack creation and file adding --- Cargo.lock | 12 ++ theseus/Cargo.toml | 1 + theseus/src/modpack/manifest.rs | 15 +-- theseus/src/modpack/mod.rs | 8 +- theseus/src/modpack/modrinth_api.rs | 176 ++++++++++++++++++++++++++++ theseus/src/modpack/pack.rs | 152 +++++++++++++++++++++--- 6 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 theseus/src/modpack/modrinth_api.rs diff --git a/Cargo.lock b/Cargo.lock index c1b51820a..87e05e039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38de00daab4eac7d753e97697066238d67ce9d7e2d823ab4f72fe14af29f3f33" +[[package]] +name = "async-trait" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44318e776df68115a881de9a8fd1b9e53368d7a4a5ce4cc48517da3393233a5e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.0.1" @@ -1022,6 +1033,7 @@ name = "theseus" version = "0.1.0" dependencies = [ "argh", + "async-trait", "bytes", "chrono", "daedalus", diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index b6a71ea89..283d57262 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -8,6 +8,7 @@ edition = "2018" [dependencies] thiserror = "1.0" +async-trait = "0.1.51" daedalus = "0.1.6" diff --git a/theseus/src/modpack/manifest.rs b/theseus/src/modpack/manifest.rs index 4e6973162..dab367429 100644 --- a/theseus/src/modpack/manifest.rs +++ b/theseus/src/modpack/manifest.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::convert::TryFrom; @@ -11,7 +12,7 @@ use serde::{Deserialize, Serialize}; pub const DEFAULT_FORMAT_VERSION: u32 = 1; -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Manifest<'a> { pub format_version: u32, @@ -32,7 +33,7 @@ impl TryFrom> for pack::Modpack { .files .into_iter() .map(pack::ModpackFile::try_from) - .collect::>>()?; + .collect::>>()?; Ok(Self { name: String::from(manifest.name), @@ -60,7 +61,7 @@ fn get_loader_version(loader: ModLoader, version: &str) -> ModpackResult .iter() .find(|&it| it.id == version) .ok_or(ModpackError::VersionError(format!( - "No versions of {:?} exist for Minecraft {}", + "No versions of modloader {:?} exist for Minecraft {}", loader, version )))? .loaders[&LoaderType::Latest] @@ -94,7 +95,7 @@ impl<'a> TryFrom<&'a pack::Modpack> for Manifest<'a> { } } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ManifestFile<'a> { #[serde(borrow)] @@ -134,7 +135,7 @@ impl<'a> From<&'a pack::ModpackFile> for ManifestFile<'a> { } } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] pub struct ManifestHashes<'a> { pub sha1: &'a str, } @@ -153,7 +154,7 @@ impl<'a> From<&'a pack::ModpackFileHashes> for ManifestHashes<'a> { } } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] pub struct ManifestEnvs { pub client: ManifestEnv, pub server: ManifestEnv, @@ -208,7 +209,7 @@ impl From for ManifestEnvs { } } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(untagged)] // HACK: I've tried for hours to get this working zero-copy, but I'm beat. If someone else wants to // go through the # = Result; diff --git a/theseus/src/modpack/modrinth_api.rs b/theseus/src/modpack/modrinth_api.rs new file mode 100644 index 000000000..bd663d9ee --- /dev/null +++ b/theseus/src/modpack/modrinth_api.rs @@ -0,0 +1,176 @@ +use std::{ + collections::HashSet, + convert::TryFrom, + path::{Path, PathBuf}, +}; + +use crate::launcher::ModLoader; + +use super::{ + manifest::{ManifestEnv, ManifestEnvs, ManifestHashes}, + pack::{ModpackEnv, ModpackFile, ModpackFileHashes, ModpackGame}, + ModpackError, ModpackResult, +}; +use async_trait::async_trait; +use bytes::Bytes; +use futures::future::try_join_all; +use serde::Deserialize; +use tokio::try_join; + +#[async_trait] +pub trait ModrinthAPI { + async fn get_latest_version( + &self, + project: &str, + channel: &str, + game: &ModpackGame, + ) -> ModpackResult>; + async fn get_version(&self, version: &str) -> ModpackResult>; +} + +#[derive(Debug)] +pub struct ModrinthV1(pub String); + +#[derive(Debug, Deserialize)] +struct ModrinthV1Project<'a> { + title: &'a str, + client_side: &'a str, + server_side: &'a str, +} + +#[derive(Debug, Deserialize)] +struct ModrinthV1ProjectVersion<'a> { + #[serde(borrow)] + dependencies: HashSet<&'a str>, + #[serde(borrow)] + game_versions: HashSet<&'a str>, + version_type: &'a str, + files: Vec>, + #[serde(borrow)] + loaders: HashSet<&'a str>, +} + +#[derive(Clone, Debug, Deserialize)] +struct ModrinthV1ProjectVersionFile<'a> { + hashes: ManifestHashes<'a>, + url: &'a str, + filename: &'a str, +} + +impl From> for ModpackFile { + fn from(file: ModrinthV1ProjectVersionFile<'_>) -> Self { + Self { + hashes: ModpackFileHashes::from(file.hashes), + downloads: { + let mut downloads: HashSet = HashSet::new(); + downloads.insert(String::from(file.url)); + downloads + }, + path: PathBuf::from(file.filename), + // WARNING: Since the sidedness of version 1 API requests is unknown, the environemnt is + // set here as both. + env: ModpackEnv::Both, + } + } +} + +#[async_trait] +impl ModrinthAPI for ModrinthV1 { + async fn get_latest_version( + &self, + project: &str, + channel: &str, + game: &ModpackGame, + ) -> ModpackResult> { + // Fetch metadata + let (project_json, versions_json): (Bytes, Bytes) = try_join!( + try_get_json(format!("{}/api/v1/mod/{}", self.0, project)), + try_get_json(format!("{}/api/v1/mod/{}/version", self.0, project)), + )?; + + let (mut project_deserializer, mut versions_deserializer) = ( + serde_json::Deserializer::from_slice(&project_json), + serde_json::Deserializer::from_slice(&versions_json), + ); + + let (project, versions) = ( + ModrinthV1Project::deserialize(&mut project_deserializer)?, + Vec::deserialize(&mut versions_deserializer)?, + ); + + let (game_version, loader) = match game { + ModpackGame::Minecraft(_, ModLoader::Vanilla) => Err(ModpackError::VersionError( + String::from("Modrinth V1 does not support vanilla projects"), + )), + ModpackGame::Minecraft(ref version, ref loader) => Ok((version, loader)), + _ => Err(ModpackError::VersionError(String::from( + "Attempted to use Modrinth API V1 to install a non-Minecraft project!", + ))), + }?; + + let version: ModrinthV1ProjectVersion = versions + .into_iter() + .find(|it: &ModrinthV1ProjectVersion| { + let loader_str = match loader { + ModLoader::Fabric => "fabric", + ModLoader::Forge => "forge", + ModLoader::Vanilla => unreachable!(), + }; + it.version_type == channel + && it.game_versions.contains(&game_version.as_str()) + && it.loaders.contains(&loader_str) + }) + .ok_or(ModpackError::VersionError(format!( + "Unable to find compatible version of mod {}", + project.title + )))?; + + // Project fields + let envs = ModpackEnv::try_from(ManifestEnvs { + client: serde_json::from_str(project.client_side)?, + server: serde_json::from_str(project.server_side)?, + })?; + + // Conversions + let files = version + .files + .iter() + .cloned() + .map(ModpackFile::from) + .collect::>(); + + let dep_futures = version.dependencies.iter().map(|it| self.get_version(&it)); + let deps = try_join_all(dep_futures) + .await? + .into_iter() + .flatten() + .collect::>(); + + Ok(files + .into_iter() + .chain(deps.into_iter()) + .map(|mut it| { + it.env = envs; + it + }) + .collect()) + } + + async fn get_version(&self, version: &str) -> ModpackResult> { + let version_json = try_get_json(format!("{}/api/v1/version/{}", self.0, version)).await?; + let mut version_deserializer = serde_json::Deserializer::from_slice(&version_json); + let version = ModrinthV1ProjectVersion::deserialize(&mut version_deserializer)?; + let base_path = PathBuf::from("mods/"); + + Ok(version + .files + .into_iter() + .map(ModpackFile::from) + .collect::>()) + } +} + +// Helpers +async fn try_get_json(url: String) -> ModpackResult { + Ok(reqwest::get(url).await?.error_for_status()?.bytes().await?) +} diff --git a/theseus/src/modpack/pack.rs b/theseus/src/modpack/pack.rs index bd9a783e2..367ea0a9e 100644 --- a/theseus/src/modpack/pack.rs +++ b/theseus/src/modpack/pack.rs @@ -1,18 +1,25 @@ -use std::path::{Path, PathBuf}; use daedalus::download_file_mirrors; use futures::future; +use std::{ + collections::HashSet, + hash::Hash, + path::{Path, PathBuf}, +}; use tokio::fs; +use super::{ + modrinth_api::{self, ModrinthV1}, + ModpackResult, +}; use crate::launcher::ModLoader; -use super::ModpackResult; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Modpack { pub game: ModpackGame, pub version: String, pub name: String, pub summary: Option, - pub files: Vec, + pub files: HashSet, } impl Modpack { @@ -31,22 +38,84 @@ impl Modpack { // TODO Integrate instance format to save other metadata Ok(()) } + + 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(), + } + } + + 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(()) + } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] 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, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ModpackFile { pub path: PathBuf, pub hashes: ModpackFileHashes, pub env: ModpackEnv, - pub downloads: Vec, + pub downloads: HashSet, +} + +impl Hash for ModpackFile { + fn hash(&self, state: &mut H) { + self.hashes.sha1.hash(state); + self.path.hash(state); + } } impl ModpackFile { @@ -76,13 +145,19 @@ impl ModpackFile { } } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] 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 { @@ -93,14 +168,59 @@ impl ModpackEnv { } } -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ModpackSide { - Client, Server, -} - - -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, 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: 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(()) + } +}