This commit is contained in:
LordMZTE 2021-08-29 15:14:10 +02:00
commit dd97213de9
18 changed files with 914 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target/
Cargo.lock

5
Cargo.toml Normal file
View file

@ -0,0 +1,5 @@
[workspace]
members = [
"mpt",
"addonscript",
]

11
addonscript/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "addonscript"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
serde = { version = "1.0.129", features = ["derive"] }
thiserror = "1.0.26"
url = { version = "2.2.2", features = ["serde"] }

2
addonscript/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod manifest;
pub mod util;

View file

@ -0,0 +1,96 @@
use serde::{de::Visitor, Deserializer};
use std::{path::PathBuf, str::FromStr};
use thiserror::Error;
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub enum Installer {
Override,
Dir(PathBuf),
}
#[derive(Debug, Error)]
pub enum InstallerParseError {
#[error("Unknown Installer type")]
UnknownType,
#[error("Invalid installer arguments")]
InvalidArguments,
#[error("Invalid installer syntax")]
InvalidSyntax,
}
impl FromStr for Installer {
type Err = InstallerParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut splits = s.split(':');
let installer_type = splits.next().ok_or(InstallerParseError::InvalidSyntax)?;
match installer_type {
"internal.override" => {
if splits.count() == 0 {
Ok(Installer::Override)
} else {
Err(InstallerParseError::InvalidArguments)
}
},
"internal.dir" => {
let dir = splits.next().ok_or(InstallerParseError::InvalidArguments)?;
let dir = PathBuf::from(dir);
if splits.count() == 0 {
Ok(Installer::Dir(dir))
} else {
Err(InstallerParseError::InvalidArguments)
}
},
_ => Err(InstallerParseError::UnknownType),
}
}
}
impl ToString for Installer {
fn to_string(&self) -> String {
match self {
Installer::Override => String::from("internal.override"),
Installer::Dir(dir) => format!("internal.dir:{}", dir.to_string_lossy()),
}
}
}
impl Serialize for Installer {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Installer {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct Vis;
impl<'de> Visitor<'de> for Vis {
type Value = Installer;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an AddonScript installer")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
v.parse::<Installer>()
.map_err(|e| E::custom(format!("invalid installer: {}", e)))
}
}
deserializer.deserialize_str(Vis)
}
}

View file

@ -0,0 +1,81 @@
use serde::{de::Visitor, Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
use url::Url;
use std::path::PathBuf;
#[derive(Debug)]
pub enum Link {
File(PathBuf),
Http(Url),
}
#[derive(Debug, Error)]
pub enum LinkParseError {
#[error("Unknown protocol")]
UnkownProtocol,
#[error("Error parsing url: {0}")]
UrlParseError(url::ParseError),
}
impl FromStr for Link {
type Err = LinkParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.starts_with("file://") {
Ok(Link::File(PathBuf::from(s.trim_start_matches("file://"))))
} else if s.starts_with("http://") || s.starts_with("https://") {
s.parse::<Url>()
.map(Link::Http)
.map_err(LinkParseError::UrlParseError)
} else {
Err(LinkParseError::UnkownProtocol)
}
}
}
impl ToString for Link {
fn to_string(&self) -> String {
match self {
Link::File(path) => format!("file://{}", path.to_string_lossy()),
Link::Http(url) => url.to_string(),
}
}
}
impl Serialize for Link {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Link {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Vis;
impl<'de> Visitor<'de> for Vis {
type Value = Link;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(
"either a (somewhat invalid) relative file:// url or a http/https url",
)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
v.parse::<Link>()
.map_err(|e| E::custom(format!("invalid link: {}", e)))
}
}
deserializer.deserialize_str(Vis)
}
}

View file

@ -0,0 +1,100 @@
use crate::manifest::{installer::Installer, link::Link};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use url::Url;
pub mod installer;
pub mod link;
#[derive(Deserialize, Serialize, Debug)]
pub struct Manifest {
pub id: String,
#[serde(rename = "type")]
pub manifest_type: ManifestType,
pub versions: Vec<Version>,
pub repositories: Vec<Repository>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Repository {
pub id: String,
#[serde(rename = "type")]
pub repo_type: RepositoryType,
pub url: Url,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum RepositoryType {
Maven,
Curseforge,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Version {
pub version: String,
pub mcversion: Vec<String>,
#[serde(default)]
pub files: Vec<File>,
#[serde(default)]
pub relations: Vec<Relation>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Relation {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<File>,
#[serde(skip_serializing_if = "Option::is_none")]
pub versions: Option<String>,
#[serde(rename = "type")]
pub relation_type: RelationType,
pub options: HashSet<FileOpt>,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum RelationType {
Mod,
Modloader,
}
#[derive(Deserialize, Serialize, Debug, Hash, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum FileOpt {
Required,
Client,
Server,
Included,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
pub enum File {
Link {
id: Option<String>,
installer: Installer,
link: Link,
options: Option<HashSet<FileOpt>>,
},
Maven {
installer: Installer,
artifact: String,
repository: String,
},
}
impl File {
pub fn installer(&self) -> &Installer {
match self {
File::Link { installer, .. } => installer,
File::Maven { installer, .. } => installer,
}
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ManifestType {
Modpack,
}

14
addonscript/src/util.rs Normal file
View file

@ -0,0 +1,14 @@
use std::collections::HashSet;
use crate::manifest::FileOpt;
pub fn default_file_opts() -> HashSet<FileOpt> {
let mut set = HashSet::with_capacity(4);
set.insert(FileOpt::Client);
set.insert(FileOpt::Server);
set.insert(FileOpt::Required);
set.insert(FileOpt::Included);
set
}

26
mpt/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "mpt"
version = "0.1.0"
edition = "2018"
[[bin]]
name = "lmpt"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
addonscript = { path = "../addonscript"}
anyhow = "1.0.43"
crossterm = "0.21.0"
futures = "0.3.16"
indicatif = "0.16.2"
json5 = "0.3.0"
reqwest = { version = "0.11.4", features = ["stream"] }
serde = { version = "1.0.129", features = ["derive"] }
serde_json = "1.0.67"
structopt = "0.3.22"
thiserror = "1.0.28"
tokio = { version = "1.10.1", features = ["rt-multi-thread", "macros", "fs"] }
toml = "0.5.8"
url = "2.2.2"

View file

@ -0,0 +1,11 @@
[Locations]
# The location of the source
src = "src"
# The location used to store temporary files
tempDir = ".mpt"
[Downloads]
# The maximum number of threads that will be used for downloads
maxThreads = 5
# The timeout of http requests in ms
httpTimeout = 30000

View file

@ -0,0 +1,201 @@
use crate::{config::Config, util::mvn_artifact_to_url};
use addonscript::manifest::{
installer::Installer,
link::Link,
File,
FileOpt,
Manifest,
Relation,
Repository,
RepositoryType,
};
use anyhow::{bail, Context};
use crossterm::style::{Attribute, Color, Stylize};
use futures::stream::{self, StreamExt};
use indicatif::ProgressBar;
use reqwest::{Client, StatusCode};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tokio::{fs::File as TokioFile, io::AsyncWriteExt};
use url::Url;
pub async fn run(
(config, manifest): (Config, Manifest),
dir: PathBuf,
all: bool,
) -> anyhow::Result<()> {
let http = Arc::new(Client::new());
let Manifest {
mut versions,
repositories,
..
} = manifest;
let repositories = Arc::new(repositories);
let futs = versions
.pop()
.context("Manifest has no versions!")?
.relations
.into_iter()
.filter(|rel| {
rel.options.contains(&FileOpt::Client)
&& rel
.file
.as_ref()
.map(|f| {
matches!(f.installer(), Installer::Dir(dir) if all || dir == Path::new("mods"))
})
.unwrap_or(false)
})
.map(|rel| download_file(Arc::clone(&http), Arc::clone(&repositories), dir.clone(), all, rel))
.collect::<Vec<_>>();
let pb = ProgressBar::new(futs.len() as u64);
let mut futs = stream::iter(futs).buffer_unordered(config.downloads.max_threads as usize);
while let Some(res) = futs.next().await {
match res {
Ok(i) => match i {
DownloadInfo::Local { from, to } => pb.println(format!(
"{} {} {} {}",
"Copied local file".with(Color::Green),
from.with(Color::Cyan).attribute(Attribute::Bold),
"to".with(Color::Green),
to.with(Color::Cyan).attribute(Attribute::Bold),
)),
DownloadInfo::Http { status, from, to } => pb.println(format!(
"{} {} {} {} {} {}",
"Downloaded file".with(Color::Green),
from.with(Color::Cyan).attribute(Attribute::Bold),
"to".with(Color::Green),
to.with(Color::Cyan).attribute(Attribute::Bold),
"with status".with(Color::Green),
status
.to_string()
.with(Color::Cyan)
.attribute(Attribute::Bold),
)),
},
Err(e) => {
pb.println(format!(
"{} {:?}",
"Error downloading file:"
.with(Color::Red)
.attribute(Attribute::Bold),
e,
));
},
}
pb.inc(1);
}
Ok(())
}
enum DownloadInfo {
Local {
from: String,
to: String,
},
Http {
status: StatusCode,
from: String,
to: String,
},
}
async fn download_file(
http: Arc<Client>,
repos: Arc<Vec<Repository>>,
mut target_dir: PathBuf,
all: bool,
rel: Relation,
) -> anyhow::Result<DownloadInfo> {
let link;
let link = match &rel.file {
Some(File::Link { link, .. }) => link,
Some(File::Maven {
artifact,
repository,
..
}) => {
if let Some(repo) = repos.iter().find(|r| &r.id == repository) {
match repo.repo_type {
RepositoryType::Maven => {
link = Link::Http(mvn_artifact_to_url(&artifact, &repo)?);
&link
},
RepositoryType::Curseforge => {
let mut splits = artifact.split(':').skip(1);
let p_id = splits
.next()
.context("Couldn't parse curseforge artifact!")?;
let f_id = splits
.next()
.context("Couldn't parse curseforge artifact!")?;
let url = format!(
"https://addons-ecs.forgesvc.net/api/v2/addon/{}/file/{}/download-url",
p_id, f_id
);
link = Link::Http(
Url::parse(http.get(url).send().await?.text().await?.trim())
.context("failed to parse curseforge URL")?,
);
&link
},
}
} else {
bail!("Rel {:?} references non-existant repository!")
}
},
None => bail!("Rel {:?} has no file!", rel),
};
if let (true, Installer::Dir(d)) = (all, rel.file.as_ref().unwrap().installer()) {
target_dir.push(d);
}
tokio::fs::create_dir_all(&target_dir).await?;
match link {
Link::File(f) => {
let to = target_dir.join(f.file_name().context("File to copy is not a file!")?);
tokio::fs::copy(
f,
target_dir.join(f.file_name().context("File to copy is not a file!")?),
)
.await
.context("Failed to install file with file link.")?;
Ok(DownloadInfo::Local {
from: f.to_string_lossy().to_string(),
to: to.to_string_lossy().to_string(),
})
},
Link::Http(l) => {
let res = http.get(l.clone()).send().await?;
let p = target_dir.join(
Path::new(l.path())
.file_name()
.context("HTTP File has no file name!")?,
);
let mut file = TokioFile::create(&p).await?;
let status = res.status();
let mut stream = res.bytes_stream();
while let Some(b) = stream.next().await {
file.write_all_buf(&mut b?).await?;
}
Ok(DownloadInfo::Http {
status,
from: l.to_string(),
to: p.to_string_lossy().to_string(),
})
},
}
}

168
mpt/src/commands/init.rs Normal file
View file

@ -0,0 +1,168 @@
use crate::{config::Locations, forge};
use addonscript::{
manifest::{
installer::Installer,
link::Link,
File,
Manifest,
ManifestType,
Relation,
RelationType,
Repository,
RepositoryType,
Version,
},
util::default_file_opts,
};
use anyhow::Context;
use crossterm::{
execute,
style::{
Attribute,
Color,
Print,
PrintStyledContent,
ResetColor,
SetAttribute,
SetForegroundColor,
Stylize,
},
ExecutableCommand,
};
use reqwest::Client;
use std::path::Path;
use url::Url;
use crate::config::Config;
const DEFAULT_CONFIG: &[u8] = include_bytes!("../../assets/modpacktoolsconfig.toml");
pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()> {
let mut stdout = std::io::stdout();
execute!(
stdout,
SetForegroundColor(Color::Green),
Print("Creating modpack "),
SetForegroundColor(Color::Cyan),
SetAttribute(Attribute::Bold),
Print(&modpack_name),
ResetColor,
SetForegroundColor(Color::Green),
Print(" on minecraft version "),
SetForegroundColor(Color::Cyan),
SetAttribute(Attribute::Bold),
Print(&mcversion),
ResetColor,
Print('\n'),
)?;
let config_path = Path::new("modpacktoolsconfig.toml");
if !config_path.exists() {
tokio::fs::write(config_path, DEFAULT_CONFIG).await?;
stdout.execute(PrintStyledContent(
"Created config!\n"
.with(Color::Green)
.attribute(Attribute::Bold),
))?;
} else {
stdout.execute(PrintStyledContent(
"Config already exists, skipping...\n"
.with(Color::Red)
.attribute(Attribute::Italic),
))?;
}
let config = tokio::fs::read(config_path).await?;
let Config {
locations: Locations { src, .. },
..
} = toml::from_slice::<Config>(&config).context("failed to deserialize config")?;
let path = Path::new(&src);
if path.join("modpack.json").exists() || path.join("modpack.json5").exists() {
stdout.execute(PrintStyledContent(
"Manifest already exists, skipping...\n"
.with(Color::Red)
.attribute(Attribute::Italic),
))?;
} else {
let mut relations = vec![];
stdout.execute(PrintStyledContent(
"Trying to find newest forge version...\n".with(Color::Magenta),
))?;
if let Some(ver) = forge::newest_forge_version(&Client::new(), &mcversion).await? {
execute!(
stdout,
SetForegroundColor(Color::Green),
Print("Found newest forge version "),
SetForegroundColor(Color::Cyan),
SetAttribute(Attribute::Bold),
Print(&ver),
ResetColor,
Print('\n'),
)?;
relations.push(Relation {
id: "forge".into(),
file: None,
relation_type: RelationType::Modloader,
options: default_file_opts(),
versions: Some(format!(
"[{mcver}-{forgever}-{mcver}]",
mcver = mcversion,
forgever = ver,
)),
})
} else {
execute!(
stdout,
SetForegroundColor(Color::Red),
Print("Couldn't find forge version for minecraft "),
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::Cyan),
Print(&mcversion),
ResetColor,
SetForegroundColor(Color::Red),
Print(" skipping forge...\n"),
ResetColor,
)?;
}
tokio::fs::create_dir_all(path).await?;
let data = serde_json::to_vec_pretty(&Manifest {
id: modpack_name,
manifest_type: ManifestType::Modpack,
versions: vec![Version {
version: "1.0".into(),
mcversion: vec![mcversion],
files: vec![File::Link {
id: Some("overrides".into()),
link: Link::File("overrides".into()),
installer: Installer::Override,
options: Some(default_file_opts()),
}],
relations,
}],
repositories: vec![Repository {
id: "curseforge".into(),
repo_type: RepositoryType::Curseforge,
url: Url::parse("https://cursemaven.com").unwrap(), // unwrap is ok on fixed value
}],
})?;
tokio::fs::write(path.join("modpack.json5"), data).await?;
stdout.execute(PrintStyledContent(
"Created manifest!\n"
.with(Color::Green)
.attribute(Attribute::Bold),
))?;
}
Ok(())
}

2
mpt/src/commands/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod downloadmods;
pub mod init;

22
mpt/src/config.rs Normal file
View file

@ -0,0 +1,22 @@
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Config {
pub locations: Locations,
pub downloads: Downloads,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Locations {
pub src: String,
pub temp_dir: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Downloads {
pub max_threads: u16,
pub http_timeout: u32,
}

30
mpt/src/forge.rs Normal file
View file

@ -0,0 +1,30 @@
use std::collections::HashMap;
use reqwest::Client;
use serde::Deserialize;
#[derive(Deserialize)]
struct ForgeVersionResponse {
promos: HashMap<String, String>,
}
pub async fn newest_forge_version(
http: &Client,
mcversion: &str,
) -> anyhow::Result<Option<String>> {
let resp = http
.get("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json")
.send()
.await?
.bytes()
.await?;
let mut resp = json5::from_str::<ForgeVersionResponse>(std::str::from_utf8(&resp)?)?;
Ok(resp.promos.remove(&format!("{}-latest", mcversion)))
}
#[inline]
pub fn parse_version(version: &str) -> Option<&str> {
version.split('-').nth(1)
}

49
mpt/src/main.rs Normal file
View file

@ -0,0 +1,49 @@
use std::path::PathBuf;
use structopt::StructOpt;
mod commands;
mod config;
mod forge;
mod util;
#[derive(StructOpt)]
struct Opt {
#[structopt(subcommand)]
cmd: Command,
}
#[derive(StructOpt)]
enum Command {
Init {
#[structopt(help = "The name of the modpack")]
modpack_name: String,
#[structopt(help = "The minecraft version of the modpack")]
mcversion: String,
},
#[structopt(name = "downloadmods")]
DownloadMods {
#[structopt(help = "Directory to download mods to")]
dir: PathBuf,
#[structopt(short, long, help = "Download all relations and not just mods")]
all: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let Opt { cmd } = Opt::from_args();
match cmd {
Command::Init {
modpack_name,
mcversion,
} => commands::init::run(modpack_name, mcversion).await?,
Command::DownloadMods { dir, all } => {
commands::downloadmods::run(util::parse_config_and_manifest().await?, dir, all).await?
},
}
Ok(())
}

82
mpt/src/util.rs Normal file
View file

@ -0,0 +1,82 @@
use addonscript::manifest::{Manifest, Repository};
use std::path::Path;
use thiserror::Error;
use url::Url;
use crate::config::Config;
pub async fn parse_config() -> anyhow::Result<Config> {
let conf = tokio::fs::read("modpacktoolsconfig.toml").await?;
Ok(toml::from_slice(&conf)?)
}
pub async fn parse_config_and_manifest() -> anyhow::Result<(Config, Manifest)> {
let config = parse_config().await?;
let src = Path::new(&config.locations.src);
let path = if src.join("modpack.json5").exists() {
src.join("modpack.json5")
} else {
src.join("modpack.json")
};
let data = tokio::fs::read(path).await?;
let data = std::str::from_utf8(&data)?;
let manifest = json5::from_str::<Manifest>(data)?;
Ok((config, manifest))
}
#[derive(Debug, Error)]
pub enum MvnArtifactUrlError {
#[error("Maven Artifact specifier has invalid format!")]
InvalidFormat,
#[error("Url parse error while processing maven artifact: {0}")]
UrlParseError(#[from] url::ParseError),
}
pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MvnArtifactUrlError> {
let mut splits = art.split(':');
let group_id = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
let artifact_id = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
let version = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
let mut url = repo.url.clone();
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", repo.url.path()));
}
let url = url.join(&format!(
"{gid}/{aid}/{v}/{aid}-{v}.jar",
gid = group_id.replace('.', "/"),
aid = artifact_id,
v = version
))?;
Ok(url)
}
#[cfg(test)]
mod tests {
use addonscript::manifest::RepositoryType;
use super::*;
fn repo() -> Repository {
Repository {
id: "test".into(),
repo_type: RepositoryType::Maven,
url: Url::parse("https://example.com/maven").unwrap(),
}
}
#[test]
fn artifact_to_url_valid() {
let res = mvn_artifact_to_url("de.mzte:test:0.1", &repo()).unwrap();
assert_eq!(
res,
Url::parse("https://example.com/maven/de/mzte/test/0.1/test-0.1.jar").unwrap()
);
}
}

12
rustfmt.toml Normal file
View file

@ -0,0 +1,12 @@
unstable_features = true
binop_separator = "Back"
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
imports_layout = "HorizontalVertical"
match_block_trailing_comma = true
merge_imports = true
normalize_comments = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true