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

@@ -10,7 +10,7 @@ use daedalus::{
use futures::prelude::*;
use std::collections::LinkedList;
const METADATA_URL: &str = "https://meta.modrinth.com/gamedata";
const METADATA_URL: &str = "https://meta.modrinth.com";
const METADATA_DB_FIELD: &[u8] = b"metadata";
const RETRY_ATTEMPTS: i32 = 3;

View File

@@ -77,17 +77,17 @@ impl State {
let settings =
Settings::init(&directories.settings_file()).await?;
// Launcher data
let (metadata, profiles) = tokio::try_join! {
Metadata::init(&database),
Profiles::init(&database, &directories),
}?;
let users = Users::init(&database)?;
// Loose initializations
let io_semaphore =
Semaphore::new(settings.max_concurrent_downloads);
// Launcher data
let (metadata, profiles) = tokio::try_join! {
Metadata::init(&database),
Profiles::init(&database, &directories, &io_semaphore),
}?;
let users = Users::init(&database)?;
let children = Children::new();
let auth_flow = AuthTask::new();
@@ -133,8 +133,7 @@ impl State {
reader.sync(&state.directories.settings_file()).await?;
Ok::<_, crate::Error>(())
})
.await
.unwrap()
.await?
};
let sync_profiles = async {
@@ -148,15 +147,16 @@ impl State {
profiles.sync(&mut batch).await?;
Ok::<_, crate::Error>(())
})
.await
.unwrap()
.await?
};
tokio::try_join!(sync_settings, sync_profiles)?;
state
.database
.apply_batch(Arc::try_unwrap(batch).unwrap().into_inner())?;
state.database.apply_batch(
Arc::try_unwrap(batch)
.expect("Error saving state by acquiring Arc")
.into_inner(),
)?;
state.database.flush_async().await?;
Ok(())

View File

@@ -11,11 +11,12 @@ use std::{
path::{Path, PathBuf},
};
use tokio::fs;
use tokio::sync::Semaphore;
const PROFILE_JSON_PATH: &str = "profile.json";
const PROFILE_SUBTREE: &[u8] = b"profiles";
pub(crate) struct Profiles(pub HashMap<PathBuf, Option<Profile>>);
pub(crate) struct Profiles(pub HashMap<PathBuf, Profile>);
// TODO: possibly add defaults to some of these values
pub const CURRENT_FORMAT_VERSION: u32 = 1;
@@ -30,7 +31,6 @@ pub struct Profile {
#[serde(skip)]
pub path: PathBuf,
pub metadata: ProfileMetadata,
pub projects: HashMap<PathBuf, Project>,
#[serde(skip_serializing_if = "Option::is_none")]
pub java: Option<JavaSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -39,6 +39,7 @@ pub struct Profile {
pub resolution: Option<WindowSize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<Hooks>,
pub projects: HashMap<PathBuf, Project>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -52,6 +53,7 @@ pub struct ProfileMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub loader_version: Option<LoaderVersion>,
pub format_version: u32,
pub linked_project_id: Option<String>,
}
// TODO: Quilt?
@@ -64,6 +66,7 @@ pub enum ModLoader {
Vanilla,
Forge,
Fabric,
Quilt,
}
impl std::fmt::Display for ModLoader {
@@ -72,6 +75,7 @@ impl std::fmt::Display for ModLoader {
Self::Vanilla => "Vanilla",
Self::Forge => "Forge",
Self::Fabric => "Fabric",
Self::Quilt => "Quilt",
})
}
}
@@ -107,6 +111,7 @@ impl Profile {
loader: ModLoader::Vanilla,
loader_version: None,
format_version: CURRENT_FORMAT_VERSION,
linked_project_id: None,
},
projects: HashMap::new(),
java: None,
@@ -116,16 +121,8 @@ impl Profile {
})
}
// TODO: deduplicate these builder methods
// They are flat like this in order to allow builder-style usage
#[tracing::instrument]
pub fn with_name(&mut self, name: String) -> &mut Self {
self.metadata.name = name;
self
}
#[tracing::instrument]
pub async fn with_icon<'a>(
pub async fn set_icon<'a>(
&'a mut self,
icon: &'a Path,
) -> crate::Result<&'a mut Self> {
@@ -149,54 +146,24 @@ impl Profile {
}
}
#[tracing::instrument]
pub fn with_game_version(&mut self, version: String) -> &mut Self {
self.metadata.game_version = version;
self
}
pub fn get_profile_project_paths(&self) -> crate::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut read_paths = |path: &str| {
let new_path = self.path.join(path);
if new_path.exists() {
for path in std::fs::read_dir(self.path.join(path))? {
files.push(path?.path());
}
}
Ok::<(), crate::Error>(())
};
#[tracing::instrument]
pub fn with_loader(
&mut self,
loader: ModLoader,
version: Option<LoaderVersion>,
) -> &mut Self {
self.metadata.loader = loader;
self.metadata.loader_version = version;
self
}
read_paths("mods")?;
read_paths("shaders")?;
read_paths("resourcepacks")?;
read_paths("datapacks")?;
#[tracing::instrument]
pub fn with_java_settings(
&mut self,
settings: Option<JavaSettings>,
) -> &mut Self {
self.java = settings;
self
}
#[tracing::instrument]
pub fn with_memory(
&mut self,
settings: Option<MemorySettings>,
) -> &mut Self {
self.memory = settings;
self
}
#[tracing::instrument]
pub fn with_resolution(
&mut self,
resolution: Option<WindowSize>,
) -> &mut Self {
self.resolution = resolution;
self
}
#[tracing::instrument]
pub fn with_hooks(&mut self, hooks: Option<Hooks>) -> &mut Self {
self.hooks = hooks;
self
Ok(files)
}
}
@@ -205,6 +172,7 @@ impl Profiles {
pub async fn init(
db: &sled::Db,
dirs: &DirectoryInfo,
io_sempahore: &Semaphore,
) -> crate::Result<Self> {
let profile_db = db.get(PROFILE_SUBTREE)?.map_or(
Ok(Default::default()),
@@ -229,38 +197,34 @@ impl Profiles {
};
(path, prof)
})
.collect::<HashMap<PathBuf, Option<Profile>>>()
.filter_map(|(key, opt_value)| async move {
opt_value.map(|value| (key, value))
})
.collect::<HashMap<PathBuf, Profile>>()
.await;
// project path, parent profile path
let mut files: HashMap<PathBuf, PathBuf> = HashMap::new();
{
for (profile_path, _profile_opt) in profiles.iter() {
let mut read_paths = |path: &str| {
let new_path = profile_path.join(path);
if new_path.exists() {
for path in std::fs::read_dir(profile_path.join(path))?
{
files.insert(path?.path(), profile_path.clone());
}
}
Ok::<(), crate::Error>(())
};
read_paths("mods")?;
read_paths("shaders")?;
read_paths("resourcepacks")?;
read_paths("datapacks")?;
for (profile_path, profile) in profiles.iter() {
let paths = profile.get_profile_project_paths()?;
for path in paths {
files.insert(path, profile_path.clone());
}
}
}
let inferred = super::projects::infer_data_from_files(
files.keys().cloned().collect(),
dirs.caches_dir(),
io_sempahore,
)
.await?;
for (key, value) in inferred {
if let Some(profile_path) = files.get(&key) {
if let Some(Some(profile)) = profiles.get_mut(profile_path) {
if let Some(profile) = profiles.get_mut(profile_path) {
profile.projects.insert(key, value);
}
}
@@ -278,7 +242,7 @@ impl Profiles {
crate::ErrorKind::UTFError(profile.path.clone()).as_error(),
)?
.into(),
Some(profile),
profile,
);
Ok(self)
}
@@ -292,9 +256,15 @@ impl Profiles {
}
#[tracing::instrument(skip(self))]
pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> {
let path = PathBuf::from(&canonicalize(path)?.to_str().unwrap());
pub async fn remove(&mut self, path: &Path) -> crate::Result<&Self> {
let path =
PathBuf::from(&canonicalize(path)?.to_string_lossy().to_string());
self.0.remove(&path);
if path.exists() {
fs::remove_dir_all(path).await?;
}
Ok(self)
}
@@ -308,8 +278,8 @@ impl Profiles {
.try_for_each_concurrent(None, |(path, profile)| async move {
let json = serde_json::to_vec_pretty(&profile)?;
let json_path =
Path::new(path.to_str().unwrap()).join(PROFILE_JSON_PATH);
let json_path = Path::new(&path.to_string_lossy().to_string())
.join(PROFILE_JSON_PATH);
fs::write(json_path, json).await?;
Ok::<_, crate::Error>(())

View File

@@ -1,17 +1,17 @@
//! Project management + inference
use crate::config::{MODRINTH_API_URL, REQWEST_CLIENT};
use crate::util::fetch::write_cached_icon;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Digest;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use tokio::io::AsyncReadExt;
use zip::ZipArchive;
use tokio::sync::Semaphore;
// use zip::ZipArchive;
use async_zip::tokio::read::fs::ZipFileReader;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Project {
@@ -33,8 +33,8 @@ pub struct ModrinthProject {
pub published: DateTime<Utc>,
pub updated: DateTime<Utc>,
pub client_side: String,
pub server_side: String,
pub client_side: SideType,
pub server_side: SideType,
pub downloads: u32,
pub followers: u32,
@@ -46,7 +46,75 @@ pub struct ModrinthProject {
pub versions: Vec<String>,
pub icon_url: String,
pub icon_url: Option<String>,
}
/// A specific version of a project
#[derive(Serialize, Deserialize)]
pub struct ModrinthVersion {
pub id: String,
pub project_id: String,
pub author_id: String,
pub featured: bool,
pub name: String,
pub version_number: String,
pub changelog: String,
pub changelog_url: Option<String>,
pub date_published: DateTime<Utc>,
pub downloads: u32,
pub version_type: String,
pub files: Vec<ModrinthVersionFile>,
pub dependencies: Vec<Dependency>,
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ModrinthVersionFile {
pub hashes: HashMap<String, String>,
pub url: String,
pub filename: String,
pub primary: bool,
pub size: u32,
pub file_type: Option<FileType>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Dependency {
pub version_id: Option<String>,
pub project_id: Option<String>,
pub file_name: Option<String>,
pub dependency_type: DependencyType,
}
#[derive(Serialize, Deserialize, Copy, Clone)]
#[serde(rename_all = "lowercase")]
pub enum DependencyType {
Required,
Optional,
Incompatible,
Embedded,
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum SideType {
Required,
Optional,
Unsupported,
Unknown,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum FileType {
RequiredResourcePack,
OptionalResourcePack,
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -63,9 +131,57 @@ pub enum ProjectMetadata {
Unknown,
}
async fn read_icon_from_file(
icon_path: Option<String>,
cache_dir: &Path,
path: &PathBuf,
io_semaphore: &Semaphore,
) -> crate::Result<Option<PathBuf>> {
if let Some(icon_path) = icon_path {
// we have to repoen the zip twice here :(
let zip_file_reader = ZipFileReader::new(path).await;
if let Ok(zip_file_reader) = zip_file_reader {
// Get index of icon file and open it
let zip_index_option = zip_file_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == icon_path);
if let Some(index) = zip_index_option {
let entry = zip_file_reader
.file()
.entries()
.get(index)
.unwrap()
.entry();
let mut bytes = Vec::new();
if zip_file_reader
.entry(zip_index_option.unwrap())
.await?
.read_to_end_checked(&mut bytes, entry)
.await
.is_ok()
{
let bytes = bytes::Bytes::from(bytes);
let permit = io_semaphore.acquire().await?;
let path = write_cached_icon(
&icon_path, cache_dir, bytes, &permit,
)
.await?;
return Ok(Some(path));
}
};
}
}
Ok(None)
}
pub async fn infer_data_from_files(
paths: Vec<PathBuf>,
cache_dir: PathBuf,
io_semaphore: &Semaphore,
) -> crate::Result<HashMap<PathBuf, Project>> {
let mut file_path_hashes = HashMap::new();
@@ -139,51 +255,28 @@ pub async fn infer_data_from_files(
}
for (hash, path) in further_analyze_projects {
let file = File::open(path.clone())?;
// TODO: get rid of below unwrap
let mut zip = ZipArchive::new(file).unwrap();
let read_icon_from_file =
|icon_path: Option<String>| -> crate::Result<Option<PathBuf>> {
if let Some(icon_path) = icon_path {
// we have to repoen the zip twice here :(
let zip_file = File::open(path.clone())?;
if let Ok(mut zip) = ZipArchive::new(zip_file) {
if let Ok(mut file) = zip.by_name(&icon_path) {
let mut bytes = Vec::new();
if file.read_to_end(&mut bytes).is_ok() {
let extension = Path::new(&icon_path)
.extension()
.and_then(OsStr::to_str);
let hash = sha1::Sha1::from(&bytes).hexdigest();
let path = cache_dir.join("icons").join(
if let Some(ext) = extension {
format!("{hash}.{ext}")
} else {
hash
},
);
if !path.exists() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = File::create(path.clone())?;
file.write_all(&bytes)?;
}
return Ok(Some(path));
}
};
}
}
Ok(None)
};
if let Ok(mut file) = zip.by_name("META-INF/mods.toml") {
let zip_file_reader = if let Ok(zip_file_reader) =
ZipFileReader::new(path.clone()).await
{
zip_file_reader
} else {
return_projects.insert(
path.clone(),
Project {
sha512: hash,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Unknown,
},
);
continue;
};
let zip_index_option = zip_file_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == "META-INF/mods.toml");
if let Some(index) = zip_index_option {
let file = zip_file_reader.file().entries().get(index).unwrap();
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ForgeModInfo {
@@ -201,18 +294,30 @@ pub async fn infer_data_from_files(
}
let mut file_str = String::new();
if file.read_to_string(&mut file_str).is_ok() {
if zip_file_reader
.entry(index)
.await?
.read_to_string_checked(&mut file_str, file.entry())
.await
.is_ok()
{
if let Ok(pack) =
serde_json::from_str::<ForgeModInfo>(&file_str)
{
if let Some(pack) = pack.mods.first() {
let icon = read_icon_from_file(pack.logo_file.clone())?;
let icon = read_icon_from_file(
pack.logo_file.clone(),
&cache_dir,
&path,
io_semaphore,
)
.await?;
return_projects.insert(
path.clone(),
Project {
sha512: hash,
disabled: false,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Inferred {
title: Some(
pack.display_name
@@ -236,7 +341,13 @@ pub async fn infer_data_from_files(
}
}
if let Ok(mut file) = zip.by_name("mcmod.info") {
let zip_index_option = zip_file_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == "mcmod.info");
if let Some(index) = zip_index_option {
let file = zip_file_reader.file().entries().get(index).unwrap();
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ForgeMod {
@@ -249,15 +360,27 @@ pub async fn infer_data_from_files(
}
let mut file_str = String::new();
if file.read_to_string(&mut file_str).is_ok() {
if zip_file_reader
.entry(index)
.await?
.read_to_string_checked(&mut file_str, file.entry())
.await
.is_ok()
{
if let Ok(pack) = serde_json::from_str::<ForgeMod>(&file_str) {
let icon = read_icon_from_file(pack.logo_file)?;
let icon = read_icon_from_file(
pack.logo_file,
&cache_dir,
&path,
io_semaphore,
)
.await?;
return_projects.insert(
path.clone(),
Project {
sha512: hash,
disabled: false,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Inferred {
title: Some(if pack.name.is_empty() {
pack.modid
@@ -276,7 +399,13 @@ pub async fn infer_data_from_files(
}
}
if let Ok(mut file) = zip.by_name("fabric.mod.json") {
let zip_index_option = zip_file_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == "fabric.mod.json");
if let Some(index) = zip_index_option {
let file = zip_file_reader.file().entries().get(index).unwrap();
#[derive(Deserialize)]
#[serde(untagged)]
enum FabricAuthor {
@@ -295,15 +424,27 @@ pub async fn infer_data_from_files(
}
let mut file_str = String::new();
if file.read_to_string(&mut file_str).is_ok() {
if zip_file_reader
.entry(index)
.await?
.read_to_string_checked(&mut file_str, file.entry())
.await
.is_ok()
{
if let Ok(pack) = serde_json::from_str::<FabricMod>(&file_str) {
let icon = read_icon_from_file(pack.icon)?;
let icon = read_icon_from_file(
pack.icon,
&cache_dir,
&path,
io_semaphore,
)
.await?;
return_projects.insert(
path.clone(),
Project {
sha512: hash,
disabled: false,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Inferred {
title: Some(pack.name.unwrap_or(pack.id)),
description: pack.description,
@@ -325,7 +466,13 @@ pub async fn infer_data_from_files(
}
}
if let Ok(mut file) = zip.by_name("quilt.mod.json") {
let zip_index_option = zip_file_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == "quilt.mod.json");
if let Some(index) = zip_index_option {
let file = zip_file_reader.file().entries().get(index).unwrap();
#[derive(Deserialize)]
struct QuiltMetadata {
pub name: Option<String>,
@@ -341,17 +488,27 @@ pub async fn infer_data_from_files(
}
let mut file_str = String::new();
if file.read_to_string(&mut file_str).is_ok() {
if zip_file_reader
.entry(index)
.await?
.read_to_string_checked(&mut file_str, file.entry())
.await
.is_ok()
{
if let Ok(pack) = serde_json::from_str::<QuiltMod>(&file_str) {
let icon = read_icon_from_file(
pack.metadata.as_ref().and_then(|x| x.icon.clone()),
)?;
&cache_dir,
&path,
io_semaphore,
)
.await?;
return_projects.insert(
path.clone(),
Project {
sha512: hash,
disabled: false,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Inferred {
title: Some(
pack.metadata
@@ -383,23 +540,39 @@ pub async fn infer_data_from_files(
}
}
if let Ok(mut file) = zip.by_name("pack.mcmeta") {
let zip_index_option = zip_file_reader
.file()
.entries()
.iter()
.position(|f| f.entry().filename() == "pack.mcdata");
if let Some(index) = zip_index_option {
let file = zip_file_reader.file().entries().get(index).unwrap();
#[derive(Deserialize)]
struct Pack {
description: Option<String>,
}
let mut file_str = String::new();
if file.read_to_string(&mut file_str).is_ok() {
if zip_file_reader
.entry(index)
.await?
.read_to_string_checked(&mut file_str, file.entry())
.await
.is_ok()
{
if let Ok(pack) = serde_json::from_str::<Pack>(&file_str) {
let icon =
read_icon_from_file(Some("pack.png".to_string()))?;
let icon = read_icon_from_file(
Some("pack.png".to_string()),
&cache_dir,
&path,
io_semaphore,
)
.await?;
return_projects.insert(
path.clone(),
Project {
sha512: hash,
disabled: false,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Inferred {
title: None,
description: pack.description,
@@ -415,10 +588,10 @@ pub async fn infer_data_from_files(
}
return_projects.insert(
path,
path.clone(),
Project {
sha512: hash,
disabled: false,
disabled: path.ends_with(".disabled"),
metadata: ProjectMetadata::Unknown,
},
);