Modpack support (#60)

* Modpack support

* Finish feature

* Tauri errors fix (#61)

* async impl

* working

* fmt and redundancy

* moved ? to if let Ok block

* Finish modpacks support

* remove generated file

* fix compile err

* fix lint

* Fix code review comments + forge support

---------

Co-authored-by: Wyatt Verchere <wverchere@gmail.com>
This commit is contained in:
Geometrically
2023-04-05 19:04:09 -07:00
committed by GitHub
parent 6965487b56
commit b9a3a6dc11
28 changed files with 1006 additions and 534 deletions

View File

@@ -1,5 +1,6 @@
//! API for interacting with Theseus
pub mod auth;
pub mod pack;
pub mod process;
pub mod profile;
pub mod profile_create;
@@ -17,7 +18,7 @@ pub mod prelude {
pub use crate::{
auth::{self, Credentials},
data::*,
process,
pack, process,
profile::{self, Profile},
profile_create, settings, State,
};

338
theseus/src/api/pack.rs Normal file
View File

@@ -0,0 +1,338 @@
use crate::config::{MODRINTH_API_URL, REQWEST_CLIENT};
use crate::data::ModLoader;
use crate::state::{ModrinthProject, ModrinthVersion, SideType};
use crate::util::fetch::{fetch, fetch_mirrors, write, write_cached_icon};
use crate::State;
use async_zip::tokio::read::seek::ZipFileReader;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Component, PathBuf};
use tokio::fs;
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
struct PackFormat {
pub game: String,
pub format_version: i32,
pub version_id: String,
pub name: String,
pub summary: Option<String>,
pub files: Vec<PackFile>,
pub dependencies: HashMap<PackDependency, String>,
}
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
struct PackFile {
pub path: String,
pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>,
pub file_size: u32,
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase", from = "String")]
enum PackFileHash {
Sha1,
Sha512,
Unknown(String),
}
impl From<String> for PackFileHash {
fn from(s: String) -> Self {
return match s.as_str() {
"sha1" => PackFileHash::Sha1,
"sha512" => PackFileHash::Sha512,
_ => PackFileHash::Unknown(s),
};
}
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase")]
enum EnvType {
Client,
Server,
}
#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum PackDependency {
Forge,
FabricLoader,
QuiltLoader,
Minecraft,
}
pub async fn install_pack_from_version_id(
version_id: String,
) -> crate::Result<PathBuf> {
let version: ModrinthVersion = REQWEST_CLIENT
.get(format!("{}version/{}", MODRINTH_API_URL, version_id))
.send()
.await?
.json()
.await?;
let (url, hash) =
if let Some(file) = version.files.iter().find(|x| x.primary) {
Some((file.url.clone(), file.hashes.get("sha1")))
} else {
version
.files
.first()
.map(|file| (file.url.clone(), file.hashes.get("sha1")))
}
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Specified version has no files".to_string(),
)
})?;
let file = async {
let state = &State::get().await?;
let semaphore = state.io_semaphore.acquire().await?;
fetch(&url, hash.map(|x| &**x), &semaphore).await
}
.await?;
let project: ModrinthProject = REQWEST_CLIENT
.get(format!(
"{}project/{}",
MODRINTH_API_URL, version.project_id
))
.send()
.await?
.json()
.await?;
let icon = if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
let semaphore = state.io_semaphore.acquire().await?;
let icon_bytes = fetch(&icon_url, None, &semaphore).await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&semaphore,
)
.await?,
)
} else {
None
}
} else {
None
};
install_pack(file, icon, Some(version.project_id)).await
}
pub async fn install_pack_from_file(path: PathBuf) -> crate::Result<PathBuf> {
let file = fs::read(path).await?;
install_pack(bytes::Bytes::from(file), None, None).await
}
async fn install_pack(
file: bytes::Bytes,
icon: Option<PathBuf>,
project_id: Option<String>,
) -> crate::Result<PathBuf> {
let state = &State::get().await?;
let reader = Cursor::new(&file);
// Create zip reader around file
let mut zip_reader = ZipFileReader::new(reader).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?;
// Extract index of modrinth.index.json
let zip_index_option = zip_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == "modrinth.index.json");
if let Some(zip_index) = zip_index_option {
let mut manifest = String::new();
let entry = zip_reader
.file()
.entries()
.get(zip_index)
.unwrap()
.entry()
.clone();
let mut reader = zip_reader.entry(zip_index).await?;
reader.read_to_string_checked(&mut manifest, &entry).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;
if &*pack.game != "minecraft" {
return Err(crate::ErrorKind::InputError(
"Pack does not support Minecraft".to_string(),
)
.into());
}
let mut game_version = None;
let mut mod_loader = None;
let mut loader_version = None;
for (key, value) in pack.dependencies {
match key {
PackDependency::Forge => {
mod_loader = Some(ModLoader::Forge);
loader_version = Some(value);
}
PackDependency::FabricLoader => {
mod_loader = Some(ModLoader::Fabric);
loader_version = Some(value);
}
PackDependency::QuiltLoader => {
mod_loader = Some(ModLoader::Quilt);
loader_version = Some(value);
}
PackDependency::Minecraft => game_version = Some(value),
}
}
let game_version = if let Some(game_version) = game_version {
game_version
} else {
return Err(crate::ErrorKind::InputError(
"Pack did not specify Minecraft version".to_string(),
)
.into());
};
let profile = crate::api::profile_create::profile_create(
pack.name,
game_version.clone(),
mod_loader.unwrap_or(ModLoader::Vanilla),
loader_version,
icon,
project_id,
)
.await?;
use futures::StreamExt;
futures::stream::iter(pack.files.into_iter())
.map(Ok::<PackFile, crate::Error>)
.try_for_each_concurrent(None, |project| {
let profile = profile.clone();
async move {
// TODO: Future update: prompt user for optional files in a modpack
if let Some(env) = project.env {
if env
.get(&EnvType::Client)
.map(|x| x == &SideType::Unsupported)
.unwrap_or(false)
{
return Ok(());
}
}
let permit = state.io_semaphore.acquire().await?;
let file = fetch_mirrors(
&project
.downloads
.iter()
.map(|x| &**x)
.collect::<Vec<&str>>(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
&permit,
)
.await?;
let path =
std::path::Path::new(&project.path).components().next();
if let Some(path) = path {
match path {
Component::CurDir | Component::Normal(_) => {
let path = profile.join(project.path);
write(&path, &file, &permit).await?;
}
_ => {}
};
}
Ok(())
}
})
.await?;
let extract_overrides = |overrides: String| async {
let reader = Cursor::new(&file);
let mut overrides_zip =
ZipFileReader::new(reader).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed extract overrides Zip".to_string(),
))
})?;
let profile = profile.clone();
async move {
for index in 0..overrides_zip.file().entries().len() {
let file = overrides_zip
.file()
.entries()
.get(index)
.unwrap()
.entry()
.clone();
let file_path = PathBuf::from(file.filename());
if file.filename().starts_with(&overrides)
&& !file.filename().ends_with('/')
{
// Reads the file into the 'content' variable
let mut content = Vec::new();
let mut reader = overrides_zip.entry(index).await?;
reader.read_to_end_checked(&mut content, &file).await?;
let mut new_path = PathBuf::new();
let components = file_path.components().skip(1);
for component in components {
new_path.push(component);
}
if new_path.file_name().is_some() {
let permit = state.io_semaphore.acquire().await?;
write(&profile.join(new_path), &content, &permit)
.await?;
}
}
}
Ok::<(), crate::Error>(())
}
.await
};
extract_overrides("overrides".to_string()).await?;
extract_overrides("client_overrides".to_string()).await?;
super::profile::sync(&profile).await?;
Ok(profile)
} else {
Err(crate::Error::from(crate::ErrorKind::InputError(
"No pack manifest found in mrpack".to_string(),
)))
}
}

View File

@@ -12,32 +12,12 @@ use std::{
};
use tokio::{process::Command, sync::RwLock};
/// Add a profile to the in-memory state
#[tracing::instrument]
pub async fn add(profile: Profile) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
profiles.insert(profile)?;
Ok(())
}
/// Add a path as a profile in-memory
#[tracing::instrument]
pub async fn add_path(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
profiles.insert_from(path).await?;
Ok(())
}
/// Remove a profile
#[tracing::instrument]
pub async fn remove(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
profiles.remove(path)?;
profiles.remove(path).await?;
Ok(())
}
@@ -48,29 +28,7 @@ pub async fn get(path: &Path) -> crate::Result<Option<Profile>> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
profiles.0.get(path).map_or(Ok(None), |prof| match prof {
Some(prof) => Ok(Some(prof.clone())),
None => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(),
)
.as_error()),
})
}
/// Check if a profile is already managed by Theseus
#[tracing::instrument]
pub async fn is_managed(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles.0.contains_key(profile))
}
/// Check if a profile is loaded
#[tracing::instrument]
pub async fn is_loaded(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles.0.get(profile).and_then(Option::as_ref).is_some())
Ok(profiles.0.get(path).cloned())
}
/// Edit a profile using a given asynchronous closure
@@ -85,11 +43,7 @@ where
let mut profiles = state.profiles.write().await;
match profiles.0.get_mut(path) {
Some(&mut Some(ref mut profile)) => action(profile).await,
Some(&mut None) => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(),
)
.as_error()),
Some(ref mut profile) => action(profile).await,
None => Err(crate::ErrorKind::UnmanagedProfileError(
path.display().to_string(),
)
@@ -99,13 +53,45 @@ where
/// Get a copy of the profile set
#[tracing::instrument]
pub async fn list(
) -> crate::Result<std::collections::HashMap<PathBuf, Option<Profile>>> {
pub async fn list() -> crate::Result<std::collections::HashMap<PathBuf, Profile>>
{
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles.0.clone())
}
/// Query + sync profile's projects with the UI from the FS
#[tracing::instrument]
pub async fn sync(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let paths = profile.get_profile_project_paths()?;
let projects = crate::state::infer_data_from_files(
paths,
state.directories.caches_dir(),
&state.io_semaphore,
)
.await?;
{
let mut profiles = state.profiles.write().await;
if let Some(profile) = profiles.0.get_mut(path) {
profile.projects = projects;
}
}
State::sync().await?;
Ok(())
} else {
Err(
crate::ErrorKind::UnmanagedProfileError(path.display().to_string())
.as_error(),
)
}
}
/// Run Minecraft using a profile
/// Returns Arc pointer to RwLock to Child
#[tracing::instrument(skip_all)]
@@ -113,7 +99,7 @@ pub async fn run(
path: &Path,
credentials: &crate::auth::Credentials,
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
let state = State::get().await.unwrap();
let state = State::get().await?;
let settings = state.settings.read().await;
let profile = get(path).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
@@ -141,19 +127,21 @@ pub async fn run(
for hook in pre_launch_hooks.iter() {
// TODO: hook parameters
let mut cmd = hook.split(' ');
let result = Command::new(cmd.next().unwrap())
.args(&cmd.collect::<Vec<&str>>())
.current_dir(path)
.spawn()?
.wait()
.await?;
if let Some(command) = cmd.next() {
let result = Command::new(command)
.args(&cmd.collect::<Vec<&str>>())
.current_dir(path)
.spawn()?
.wait()
.await?;
if !result.success() {
return Err(crate::ErrorKind::LauncherError(format!(
"Non-zero exit code for pre-launch hook: {}",
result.code().unwrap_or(-1)
))
.as_error());
if !result.success() {
return Err(crate::ErrorKind::LauncherError(format!(
"Non-zero exit code for pre-launch hook: {}",
result.code().unwrap_or(-1)
))
.as_error());
}
}
}

View File

@@ -1,5 +1,5 @@
//! Theseus profile management interface
use crate::{prelude::ModLoader, profile};
use crate::prelude::ModLoader;
pub use crate::{
state::{JavaSettings, Profile},
State,
@@ -22,8 +22,9 @@ pub async fn profile_create_empty() -> crate::Result<PathBuf> {
String::from(DEFAULT_NAME), // the name/path of the profile
String::from("1.19.2"), // the game version of the profile
ModLoader::Vanilla, // the modloader to use
String::from("stable"), // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
None, // the icon for the profile
None, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
None, // the icon for the profile
None,
)
.await
}
@@ -32,17 +33,17 @@ pub async fn profile_create_empty() -> crate::Result<PathBuf> {
// Returns filepath at which it can be accessed in the State
#[tracing::instrument]
pub async fn profile_create(
name: String, // the name of the profile, and relative path
game_version: String, // the game version of the profile
modloader: ModLoader, // the modloader to use
loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Option<PathBuf>, // the icon for the profile
name: String, // the name of the profile, and relative path
game_version: String, // the game version of the profile
modloader: ModLoader, // the modloader to use
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader. defaults to latest
icon: Option<PathBuf>, // the icon for the profile
linked_project_id: Option<String>, // the linked project ID (mainly for modpacks)- used for updating
) -> crate::Result<PathBuf> {
let state = State::get().await?;
let uuid = Uuid::new_v4();
let path = state.directories.profiles_dir().join(uuid.to_string());
if path.exists() {
if !path.is_dir() {
return Err(ProfileCreationError::NotFolder.into());
@@ -64,6 +65,7 @@ pub async fn profile_create(
} else {
fs::create_dir_all(&path).await?;
}
println!(
"Creating profile at path {}",
&canonicalize(&path)?.display()
@@ -71,12 +73,12 @@ pub async fn profile_create(
let loader = modloader;
let loader = if loader != ModLoader::Vanilla {
let version = loader_version;
let version = loader_version.unwrap_or_else(|| "latest".to_string());
let filter = |it: &LoaderVersion| match version.as_str() {
"latest" => true,
"stable" => it.stable,
id => it.id == *id,
id => it.id == *id || format!("{}-{}", game_version, id) == it.id,
};
let loader_data = match loader {
@@ -93,7 +95,12 @@ pub async fn profile_create(
let loaders = &loader_data
.game_versions
.iter()
.find(|it| it.id == game_version)
.find(|it| {
it.id.replace(
daedalus::modded::DUMMY_REPLACE_STRING,
&game_version,
) == game_version
})
.ok_or_else(|| {
ProfileCreationError::ModloaderUnsupported(
loader.to_string(),
@@ -130,13 +137,19 @@ pub async fn profile_create(
let path = canonicalize(&path)?;
let mut profile = Profile::new(name, game_version, path.clone()).await?;
if let Some(ref icon) = icon {
profile.with_icon(icon).await?;
profile.set_icon(icon).await?;
}
if let Some((loader_version, loader)) = loader {
profile.with_loader(loader, Some(loader_version));
profile.metadata.loader = loader;
profile.metadata.loader_version = Some(loader_version);
}
profile.metadata.linked_project_id = linked_project_id;
{
let mut profiles = state.profiles.write().await;
profiles.insert(profile)?;
}
profile::add(profile).await?;
State::sync().await?;
Ok(path)