From 33c865893f58db22fe651059bc8bc25bfaa316d6 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 7 Aug 2025 18:39:59 +0200 Subject: [PATCH] feat: mrpack support --- packages/core/examples/packages.rs | 53 +- packages/core/src/api/cluster/mod.rs | 12 + packages/core/src/api/packages/dao.rs | 42 +- packages/core/src/api/packages/data.rs | 10 + packages/core/src/api/packages/mod.rs | 216 +++++--- .../core/src/api/packages/modpack/data.rs | 22 + packages/core/src/api/packages/modpack/mod.rs | 112 ++++ .../core/src/api/packages/modpack/mrpack.rs | 343 ++++++++++++ .../core/src/api/packages/modpack/tests.rs | 88 +++ .../core/src/api/packages/provider/mod.rs | 2 +- .../core/src/api/packages/provider/tests.rs | 22 +- packages/core/src/api/tauri/commands.rs | 2 +- packages/core/src/utils/io.rs | 72 ++- packages/core/src/utils/mod.rs | 5 + packages/entity/src/domain/loader.rs | 18 +- packages/entity/src/domain/package.rs | 18 +- packages/entity/src/entities/clusters.rs | 7 +- .../src/m20220101_000001_create_table.rs | 500 ++++++++++-------- 18 files changed, 1173 insertions(+), 371 deletions(-) create mode 100644 packages/core/src/api/packages/modpack/data.rs create mode 100644 packages/core/src/api/packages/modpack/mod.rs create mode 100644 packages/core/src/api/packages/modpack/mrpack.rs create mode 100644 packages/core/src/api/packages/modpack/tests.rs diff --git a/packages/core/examples/packages.rs b/packages/core/examples/packages.rs index 64bc84ce..ead4da75 100644 --- a/packages/core/examples/packages.rs +++ b/packages/core/examples/packages.rs @@ -1,5 +1,13 @@ -use onelauncher_core::{api::{cluster, packages::{self, data::{SearchQuery, Sort}, download_package, provider::ProviderExt}, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::{CoreOptions, Dirs}}; -use onelauncher_entity::{loader::GameLoader, package::Provider}; +use onelauncher_core::api::cluster; +use onelauncher_core::api::packages::data::{SearchQuery, Sort}; +use onelauncher_core::api::packages::provider::ProviderExt; +use onelauncher_core::api::packages::{self, download_package}; +use onelauncher_core::api::proxy::ProxyDynamic; +use onelauncher_core::error::LauncherResult; +use onelauncher_core::initialize_core; +use onelauncher_core::store::{CoreOptions, Dirs}; +use onelauncher_entity::loader::GameLoader; +use onelauncher_entity::package::Provider; #[tokio::main] pub async fn main() -> LauncherResult<()> { @@ -20,29 +28,44 @@ pub async fn main() -> LauncherResult<()> { // Fetch our package and its versions let provider = Provider::Modrinth; - println!("search results: {:#?}", provider.search(&SearchQuery { - query: Some("chatting".to_string()), - offset: Some(0), - limit: Some(10), - sort: Some(Sort::Newest), - filters: None, - }).await?); + println!( + "search results: {:#?}", + provider + .search(&SearchQuery { + query: Some("chatting".to_string()), + offset: Some(0), + limit: Some(10), + sort: Some(Sort::Newest), + filters: None, + }) + .await? + ); let package = &provider.get("chatting").await?; - let versions = provider.get_versions(package.version_ids.as_slice()).await?; + let versions = provider + .get_versions(package.version_ids.as_slice()) + .await?; - let version = versions.iter().find(|v| v.mc_versions.contains(&"1.8.9".to_string())).expect("Version supporting 1.8.9 not found"); + let version = versions + .iter() + .find(|v| v.mc_versions.contains(&"1.8.9".to_string())) + .expect("Version supporting 1.8.9 not found"); // We can now download the package - let model = download_package(package, version, None).await?; + let model = download_package(package, version, Some(true), None).await?; // Link the package to the cluster packages::link_package(&model, &cluster, None).await?; // Check if the package is hard linked let cluster_dir = dirs.clusters_dir().join(cluster.folder_name.clone()); - let pkg_path = cluster_dir.join(model.package_type.folder_name()).join(model.file_name.as_str()); - assert!(tokio::fs::metadata(&pkg_path).await.is_ok(), "Package not found in cluster directory"); + let pkg_path = cluster_dir + .join(model.package_type.folder_name()) + .join(model.file_name.as_str()); + assert!( + tokio::fs::metadata(&pkg_path).await.is_ok(), + "Package not found in cluster directory" + ); Ok(()) -} \ No newline at end of file +} diff --git a/packages/core/src/api/cluster/mod.rs b/packages/core/src/api/cluster/mod.rs index f1c0c7b8..bdd64350 100644 --- a/packages/core/src/api/cluster/mod.rs +++ b/packages/core/src/api/cluster/mod.rs @@ -15,6 +15,7 @@ use crate::api::{java, setting_profiles}; use crate::error::LauncherResult; use crate::store::ingress::{IngressType, SubIngress}; use crate::store::{Dirs, State}; +use crate::utils::DatabaseModelExt; use crate::utils::io::{self, IOError}; pub mod content; @@ -30,6 +31,10 @@ use super::ingress::init_ingress; pub enum ClusterError { #[error("version '{0}' was not found")] InvalidVersion(String), + #[error("version '{0}' is not compatible with version '{1}'")] + MismatchedVersion(String, String), + #[error("loader '{0}' is not compatible with loader '{1}'")] + MismatchedLoader(GameLoader, GameLoader), #[error("failed to imply java version")] MissingJavaVersion, #[error("cluster is in downloading stage")] @@ -347,3 +352,10 @@ pub async fn update_playtime(id: ClusterId, duration: i64) -> LauncherResult LauncherResult { + Ok(Dirs::get_clusters_dir().await?.join(&self.folder_name)) + } +} diff --git a/packages/core/src/api/packages/dao.rs b/packages/core/src/api/packages/dao.rs index 1127c59b..2a884a74 100644 --- a/packages/core/src/api/packages/dao.rs +++ b/packages/core/src/api/packages/dao.rs @@ -1,7 +1,9 @@ use onelauncher_entity::loader::GameLoader; use onelauncher_entity::package::PackageType; use onelauncher_entity::{cluster_packages, clusters, packages}; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TryInsertResult, +}; use crate::error::{DaoError, LauncherResult}; use crate::store::State; @@ -12,14 +14,6 @@ pub type PackageId = String; /// Inserts a new package into the database. pub async fn insert_package(package: packages::ActiveModel) -> LauncherResult { - if package - .package_type - .try_as_ref() - .is_some_and(|p| p == &PackageType::ModPack) - { - return Err(PackageError::IsModPack.into()); - } - let state = State::get().await?; let db = &state.db; @@ -47,6 +41,36 @@ pub async fn link_package_to_cluster( Ok(()) } +/// Links multiple packages to a cluster in database. Returns the number of packages linked. +pub async fn link_many_packages_to_cluster( + packages: &[packages::Model], + cluster: &clusters::Model, +) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let cluster_packages = packages + .iter() + .map(|package| { + cluster_packages::Model { + cluster_id: cluster.id, + package_hash: package.hash.clone(), + } + .into_active_model() + }) + .collect::>(); + + let inserted = cluster_packages::Entity::insert_many(cluster_packages) + .on_conflict_do_nothing() + .exec_without_returning(db) + .await?; + + Ok(match inserted { + TryInsertResult::Inserted(rows) => rows, + _ => 0, + }) +} + /// Retrieves a clusters linked packages from the database. pub async fn get_linked_packages( cluster: &clusters::Model, diff --git a/packages/core/src/api/packages/data.rs b/packages/core/src/api/packages/data.rs index f2ed7436..1f8c14cd 100644 --- a/packages/core/src/api/packages/data.rs +++ b/packages/core/src/api/packages/data.rs @@ -77,6 +77,16 @@ pub struct SearchResult { pub gallery: Vec, } +#[onelauncher_macro::specta] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalPackage { + pub name: String, + pub url: String, + pub sha1: String, + pub size: usize, + pub package_type: PackageType, +} + #[onelauncher_macro::specta] #[serde_as] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/packages/core/src/api/packages/mod.rs b/packages/core/src/api/packages/mod.rs index 2eba3388..1fcc7bfe 100644 --- a/packages/core/src/api/packages/mod.rs +++ b/packages/core/src/api/packages/mod.rs @@ -1,6 +1,6 @@ use data::{ManagedPackage, ManagedVersion}; use onelauncher_entity::icon::Icon; -use onelauncher_entity::package::{PackageType, Provider}; +use onelauncher_entity::package::Provider; use onelauncher_entity::{clusters, packages}; use reqwest::Method; @@ -10,11 +10,12 @@ use crate::store::Dirs; use crate::store::ingress::SubIngress; use crate::utils::crypto::HashAlgorithm; use crate::utils::io::IOError; -use crate::utils::{http, icon, io}; +use crate::utils::{DatabaseModelExt, http, icon, io}; pub mod categories; pub mod dao; pub mod data; +pub mod modpack; pub mod provider; #[onelauncher_macro::error] @@ -22,8 +23,8 @@ pub mod provider; pub enum PackageError { #[error("no file marked for download")] NoPrimaryFile, - #[error("package is a modpack, it must be handled differently")] - IsModPack, + #[error("package is not a modpack")] + IsNotModPack, #[error(transparent)] Incompatible(#[from] IncompatiblePackageType), #[error("missing API key for provider '{0}'")] @@ -32,6 +33,8 @@ pub enum PackageError { UnsupportedBodyType(ManagedPackageBody), #[error("unsupported author type '{0}'")] UnsupportedAuthorType(PackageAuthor), + #[error("unsupported modpack format")] + UnsupportedModpackFormat, } #[onelauncher_macro::error] @@ -48,13 +51,9 @@ pub enum IncompatiblePackageType { pub async fn download_package( package: &ManagedPackage, version: &ManagedVersion, + force: Option, ingress: Option>, ) -> LauncherResult { - // Modpacks are downloaded and managed differently. - if package.package_type == PackageType::ModPack { - return Err(PackageError::IsModPack.into()); - } - tracing::debug!( "downloading package '{}' version '{}' from '{}'", package.name, @@ -70,39 +69,14 @@ pub async fn download_package( .find(|f| f.primary) .ok_or(PackageError::NoPrimaryFile)?; - if let Some(model) = dao::get_package_by_hash(primary_file.sha1.clone()).await? { + if !force.unwrap_or(false) + && let Some(model) = dao::get_package_by_hash(primary_file.sha1.clone()).await? + { tracing::debug!("package is already downloaded"); return Ok(model); } - let dir = Dirs::get_package_dir(&package.package_type, &package.provider, &package.id).await?; - - io::create_dir_all(&dir).await?; - - let dest = join_package_file(dir, &version.version_id, primary_file.file_name.as_str()); - - http::download_advanced( - Method::GET, - &primary_file.url, - dest, - None, - None, - Some((HashAlgorithm::Sha1, primary_file.sha1.as_str())), - ingress.as_ref(), - ) - .await?; - - let icon = if let Some(icon_url) = &package.icon_url { - if let Some(icon) = Icon::try_from_url(&url::Url::parse(icon_url)?) { - Some(icon::cache_icon(&icon).await?) - } else { - None - } - } else { - None - }; - - let model = packages::Model { + let mut model = packages::Model { hash: primary_file.sha1.clone(), display_name: package.name.clone(), display_version: version.display_version.clone(), @@ -110,16 +84,83 @@ pub async fn download_package( version_id: version.version_id.clone(), published_at: version.published, provider: package.provider.clone(), - icon, + icon: None, package_id: package.id.clone(), mc_loader: version.loaders.clone().into(), mc_versions: version.mc_versions.clone().into(), package_type: package.package_type.clone(), }; + http::download_advanced( + Method::GET, + &primary_file.url, + model.path().await?, + None, + None, + Some((HashAlgorithm::Sha1, primary_file.sha1.as_str())), + ingress.as_ref(), + ) + .await?; + + if let Some(icon_url) = &package.icon_url { + if let Some(icon) = Icon::try_from_url(&url::Url::parse(icon_url)?) { + model.icon = Some(icon::cache_icon(&icon).await?); + } + } + dao::insert_package(model.into()).await } +/// Downloads a package and conditionally returns the database entry if it's hash was already registered. +pub async fn download_external_package( + package: &data::ExternalPackage, + cluster: &clusters::Model, + force: Option, + skip_compatibility: Option, + ingress: Option>, +) -> LauncherResult> { + tracing::debug!( + "downloading external package '{}' from '{}'", + package.name, + package.url + ); + + // check if already downloaded + if !force.unwrap_or(false) + && let Some(model) = dao::get_package_by_hash(package.sha1.clone()).await? + { + tracing::debug!( + "external package is already downloaded as '{}'", + model.display_name + ); + + // considering that this function downloads packages to a cluster, + // we'll hard-link the already downloaded package to the cluster + // as to keep functionality consistent + link_package(&model, cluster, skip_compatibility).await?; + + return Ok(Some(model)); + } + + let dir = Dirs::get_clusters_dir() + .await? + .join(cluster.folder_name.clone()) + .join(package.package_type.folder_name()); + + http::download_advanced( + Method::GET, + &package.url, + dir, + None, + None, + Some((HashAlgorithm::Sha1, &package.sha1)), + ingress.as_ref(), + ) + .await?; + + Ok(None) +} + /// Links a package to a cluster on the file system and in database. /// * `skip_compatibility` - Checks whether the `mc_loader` and `mc_version` is compatible. Default is false #[tracing::instrument(level = "debug", skip_all)] @@ -128,10 +169,6 @@ pub async fn link_package( cluster: &clusters::Model, skip_compatibility: Option, ) -> LauncherResult<()> { - if package.package_type == PackageType::ModPack { - return Err(PackageError::IsModPack.into()); - } - tracing::debug!( "linking package '{}' version '{}' to cluster '{}'", package.display_name, @@ -168,18 +205,7 @@ pub async fn link_package( } } - let dirs = Dirs::get().await?; - let cluster_dir = dirs.clusters_dir().join(cluster.folder_name.clone()); - - tracing::trace!("hard linking package to cluster"); - let src_pkg = path_from_model(dirs, package); - let dest_dir = cluster_dir.join(package.package_type.folder_name()); - io::create_dir_all(&dest_dir).await?; - - let dest_pkg = dest_dir.join(package.file_name.as_str()); - tokio::fs::hard_link(src_pkg, dest_pkg) - .await - .map_err(IOError::from)?; + hard_link(package, cluster).await?; dao::link_package_to_cluster(package, cluster).await?; @@ -192,18 +218,74 @@ pub async fn link_package( Ok(()) } -fn path_from_model(dirs: &Dirs, model: &packages::Model) -> std::path::PathBuf { - join_package_file( - dirs.package_dir(&model.package_type, &model.provider, &model.package_id), - &model.version_id, - model.file_name.as_str(), - ) +#[tracing::instrument(level = "debug", skip_all)] +pub async fn link_many_packages_to_cluster( + packages: &[packages::Model], + cluster: &clusters::Model, + skip_compatibility: Option, +) -> LauncherResult { + tracing::debug!( + "linking {} packages to cluster '{}'", + packages.len(), + cluster.name + ); + + let compatible_packages = if skip_compatibility.unwrap_or(false) { + packages.to_vec() + } else { + packages + .iter() + .filter(|p| { + p.mc_loader + .iter() + .any(|v| cluster.mc_loader.compatible_with(v)) + && p.mc_versions.iter().any(|v| cluster.mc_version.contains(v)) + }) + .cloned() + .collect::>() + }; + + for package in compatible_packages.iter() { + hard_link(package, cluster).await?; + } + + dao::link_many_packages_to_cluster(packages, cluster).await?; + + Ok(0) } -fn join_package_file( - path: impl AsRef, - version_id: &str, - file_name: &str, -) -> std::path::PathBuf { - path.as_ref().join(version_id).join(file_name) +#[tracing::instrument(level = "debug", skip_all)] +async fn hard_link(package: &packages::Model, cluster: &clusters::Model) -> LauncherResult<()> { + let cluster_dir = Dirs::get_clusters_dir() + .await? + .join(cluster.folder_name.clone()); + + tracing::trace!( + "hard linking package '{}' to cluster '{}'", + package.display_name, + cluster.name + ); + let src_pkg = package.path().await?; + let dest_dir = cluster_dir.join(package.package_type.folder_name()); + io::create_dir_all(&dest_dir).await?; + + let dest_pkg = dest_dir.join(package.file_name.as_str()); + tokio::fs::hard_link(src_pkg, dest_pkg) + .await + .map_err(IOError::from)?; + + Ok(()) +} + +#[async_trait::async_trait] +impl DatabaseModelExt for packages::Model { + async fn path(&self) -> LauncherResult { + Ok(Dirs::get_packages_dir() + .await? + .join(&self.package_type.folder_name()) + .join(&self.provider.name()) + .join(&self.package_id) + .join(&self.version_id) + .join(&self.file_name)) + } } diff --git a/packages/core/src/api/packages/modpack/data.rs b/packages/core/src/api/packages/modpack/data.rs new file mode 100644 index 00000000..8f6567bb --- /dev/null +++ b/packages/core/src/api/packages/modpack/data.rs @@ -0,0 +1,22 @@ +use onelauncher_entity::loader::GameLoader; +use serde::{Deserialize, Serialize}; + +use crate::api::packages::data::{ExternalPackage, ManagedPackage, ManagedVersion}; + +#[onelauncher_macro::specta] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModpackManifest { + pub name: String, + pub version: String, + pub loader: GameLoader, + pub loader_version: String, + pub mc_version: String, + pub files: Vec, +} + +#[onelauncher_macro::specta] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModpackFile { + Managed((ManagedPackage, ManagedVersion)), + External(ExternalPackage), +} diff --git a/packages/core/src/api/packages/modpack/mod.rs b/packages/core/src/api/packages/modpack/mod.rs new file mode 100644 index 00000000..00b149bf --- /dev/null +++ b/packages/core/src/api/packages/modpack/mod.rs @@ -0,0 +1,112 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use onelauncher_entity::package::PackageType; +use onelauncher_entity::{clusters, packages}; +use sea_orm::ActiveValue::Set; + +use crate::api::packages::PackageError; +use crate::api::packages::modpack::mrpack::MrPackFormatImpl; +use crate::api::{self}; +use crate::error::LauncherResult; +use crate::store::ingress::SubIngress; + +pub mod data; +mod mrpack; + +#[cfg(test)] +mod tests; + +#[onelauncher_macro::specta] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModpackFormat { + CurseForge, + MrPack, +} + +#[async_trait::async_trait] +pub trait ModpackFormatExt { + async fn from_file( + path: PathBuf, + ) -> LauncherResult>> + where + Self: Sized; + + async fn from_bytes( + bytes: Arc>, + ) -> LauncherResult>> + where + Self: Sized; +} + +#[async_trait::async_trait] +pub trait InstallableModpackFormatExt: Send + Sync + std::any::Any { + async fn manifest(&self) -> LauncherResult<&data::ModpackManifest>; + async fn install_to( + &self, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()>; + + fn as_any(self: Box) -> Box; +} + +type LoaderFn = fn( + Arg, +) -> std::pin::Pin< + Box>>> + Send>, +>; + +impl ModpackFormat { + async fn from_file(path: PathBuf) -> LauncherResult> { + let pipeline: Vec> = vec![MrPackFormatImpl::from_file]; + + for stage in pipeline { + if let Some(format) = stage(path.clone()).await? { + return Ok(format); + } + } + + Err(PackageError::UnsupportedModpackFormat.into()) + } + + async fn from_bytes( + bytes: Arc>, + ) -> LauncherResult> { + let pipeline: Vec>>> = vec![MrPackFormatImpl::from_bytes]; + + for stage in pipeline { + if let Some(format) = stage(Arc::clone(&bytes)).await? { + return Ok(format); + } + } + + Err(PackageError::UnsupportedModpackFormat.into()) + } +} + +/// Installs a modpack to a cluster. +pub async fn install_managed_modpack( + package_model: &packages::Model, + modpack: &Box, + cluster: &mut clusters::Model, + skip_compatibility: Option, + ingress: Option>, +) -> LauncherResult<()> { + if package_model.package_type != PackageType::ModPack { + return Err(PackageError::IsNotModPack.into()); + } + + modpack + .install_to(cluster, skip_compatibility, ingress) + .await?; + + api::cluster::dao::update_cluster(cluster, async |mut c| { + c.linked_modpack_hash = Set(Some(package_model.hash.clone())); + Ok(c) + }) + .await?; + + Ok(()) +} diff --git a/packages/core/src/api/packages/modpack/mrpack.rs b/packages/core/src/api/packages/modpack/mrpack.rs new file mode 100644 index 00000000..4fdc9856 --- /dev/null +++ b/packages/core/src/api/packages/modpack/mrpack.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +use onelauncher_entity::clusters; +use onelauncher_entity::loader::GameLoader; +use onelauncher_entity::package::{PackageType, Provider}; +use serde::Deserialize; +use tokio::sync::OnceCell; + +use crate::api::cluster::ClusterError; +use crate::api::packages::data::{ExternalPackage, PackageSide}; +use crate::api::packages::modpack::data::{ModpackFile, ModpackManifest}; +use crate::api::packages::modpack::{InstallableModpackFormatExt, ModpackFormatExt}; +use crate::api::packages::provider::ProviderExt; +use crate::api::{self}; +use crate::error::LauncherResult; +use crate::store::ingress::SubIngress; +use crate::utils::DatabaseModelExt; +use crate::utils::io::{self, IOError}; + +pub struct MrPackFormatImpl { + archive: Option, + raw_manifest: MrPackManifest, + manifest: OnceCell, + mc_version: String, + loader: GameLoader, + loader_version: String, +} + +const MODRINTH_URL_PREFIX: &str = "https://cdn.modrinth.com/"; + +// TODO: move from_file fetching to manifest function + +#[async_trait::async_trait] +impl ModpackFormatExt for MrPackFormatImpl { + async fn from_file( + path: std::path::PathBuf, + ) -> LauncherResult>> + where + Self: Sized, + { + let zip_file = tokio::fs::File::open(path.clone()) + .await + .map_err(IOError::from)?; + let buf_reader = tokio::io::BufReader::new(zip_file); + + let manifest_file = io::try_read_zip_entry_bytes(buf_reader, "modrinth.index.json").await?; + + let Some(this) = Self::from_bytes(Arc::new(manifest_file)).await? else { + return Ok(None); + }; + + let mut this = this + .as_any() + .downcast::() + .expect("downcast failed for MrPackFormatImpl"); + + this.archive = Some(path); + Ok(Some(this)) + } + + async fn from_bytes( + bytes: Arc>, + ) -> LauncherResult>> + where + Self: Sized, + { + let serialized: MrPackManifest = match serde_json::from_slice(&bytes) { + Ok(manifest) => manifest, + Err(e) => { + tracing::debug!("failed to deserialize modpack as mrpack: {}", e); + return Ok(None); + } + }; + + let mut mc_version: Option = None; + let mut loader: Option = None; + let mut loader_version: Option = None; + + for (key, value) in serialized.dependencies.iter() { + if key == "minecraft" { + mc_version = Some(value.clone()); + } else { + loader = GameLoader::from_str(key).ok(); + loader_version = Some(value.clone()); + } + } + + if mc_version.is_none() { + tracing::error!("mrpack manifest does not contain a minecraft version"); + return Ok(None); + } + + if loader.is_none() { + tracing::error!("mrpack manifest does not contain a valid game loader"); + return Ok(None); + } + + if loader_version.is_none() { + tracing::error!("mrpack manifest does not contain a valid loader version"); + return Ok(None); + } + + Ok(Some(Box::new(Self { + archive: None, + raw_manifest: serialized, + manifest: OnceCell::new(), + mc_version: mc_version.unwrap(), + loader: loader.unwrap(), + loader_version: loader_version.unwrap(), + }))) + } +} + +#[async_trait::async_trait] +impl InstallableModpackFormatExt for MrPackFormatImpl { + fn as_any(self: Box) -> Box + where + Self: Sized, + { + self + } + + async fn manifest(&self) -> LauncherResult<&ModpackManifest> { + if let Some(manifest) = self.manifest.get() { + return Ok(manifest); + } + + let mut project_ids_to_fetch: Vec = Vec::new(); + let mut version_ids_to_fetch: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for file in &self.raw_manifest.files { + let name = file + .path + .split('/') + .last() + .unwrap_or(&file.path) + .to_string(); + + if let Some(url) = file + .downloads + .iter() + .find(|url| url.starts_with(MODRINTH_URL_PREFIX)) + { + // https://cdn.modrinth.com/data//versions// + let paths = url[MODRINTH_URL_PREFIX.len()..] + .split('/') + .collect::>(); + + if paths.len() >= 4 { + let project_id = paths[1]; + let version_id = paths[3]; + + project_ids_to_fetch.push(project_id.to_string()); + version_ids_to_fetch.push(version_id.to_string()) + } else { + tracing::error!("invalid modrinth file URL: '{}'", url); + } + } else { + let download_url = file + .downloads + .first() + .cloned() + .ok_or_else(|| { + tracing::warn!("mrpack file '{}' does not contain a download URL", name) + }) + .unwrap_or(String::new()); + + // the path usually contains the folder name such as "mods" or "resourcepacks" + // so we can use it to determine the package type + let package_type = file + .path + .split('/') + .next() + .and_then(|s| PackageType::try_from(s).ok()) + .unwrap_or(PackageType::Mod); + + files.push(ModpackFile::External(ExternalPackage { + name, + url: download_url, + sha1: file.hashes.sha1.clone(), + size: file.file_size, + package_type, + })); + } + } + + let managed_packages = Provider::Modrinth + .get_multiple(&project_ids_to_fetch) + .await?; + let managed_versions = Provider::Modrinth + .get_versions(&version_ids_to_fetch) + .await?; + + files.extend( + managed_packages + .into_iter() + .zip(managed_versions.into_iter()) + .map(|(package, version)| ModpackFile::Managed((package, version))), + ); + + let manifest = ModpackManifest { + name: self.raw_manifest.name.clone(), + version: self.raw_manifest.version_id.clone(), + mc_version: self.mc_version.clone(), + loader: self.loader.clone(), + loader_version: self.loader_version.clone(), + files, + }; + + self.manifest + .set(manifest) + .expect("failed to cache inner modpack manifest"); + Ok(self.manifest.get().unwrap()) + } + + async fn install_to( + &self, + cluster: &clusters::Model, + skip_compatibility: Option, + ingress: Option>, + ) -> LauncherResult<()> { + let manifest = self.manifest().await?; + + if cluster.mc_version != manifest.mc_version { + return Err(ClusterError::MismatchedVersion( + manifest.mc_version.clone(), + cluster.mc_version.clone(), + ) + .into()); + } + + if cluster.mc_loader != manifest.loader { + return Err(ClusterError::MismatchedLoader( + manifest.loader.clone(), + cluster.mc_loader.clone(), + ) + .into()); + } + + // TODO: Implement loader version checking + // if cluster.mc_loader_version.is_some_and(|v| v ) + + let mut errors = Vec::new(); + let mut packages_to_link = Vec::new(); + + // TODO: Ingress + for file in manifest.files.iter() { + match file { + ModpackFile::Managed((package, version)) => { + match api::packages::download_package(package, version, None, None).await { + Ok(model) => packages_to_link.push(model), + Err(e) => errors.push(e), + } + } + ModpackFile::External(package) => { + if let Err(e) = api::packages::download_external_package( + package, + cluster, + None, + skip_compatibility, + None, + ) + .await + { + errors.push(e); + } + } + } + } + + let linked = api::packages::link_many_packages_to_cluster( + &packages_to_link, + cluster, + skip_compatibility, + ) + .await?; + if linked < packages_to_link.len() as u64 { + tracing::warn!("not all packages could be linked to the cluster, some errors occurred"); + } + + if let Some(archive) = &self.archive { + tracing::debug!( + "extracting overrides from modpack archive: {}", + archive.display() + ); + let dest = cluster.path().await?; + + io::extract_zip_filtered( + archive, + dest, + Some(|entry: &async_zip::StoredZipEntry| { + entry + .filename() + .as_str() + .is_ok_and(|s| s.starts_with("overrides/")) + }), + Some(|name: &str| name.trim_start_matches("overrides/").to_string()), + ) + .await?; + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrPackManifest { + pub format_version: usize, + pub version_id: String, + pub name: String, + pub files: Vec, + pub dependencies: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrPackFile { + pub path: String, + pub hashes: MrPackFileHash, + #[serde(default)] + pub env: MrPackFileEnv, + pub downloads: Vec, + pub file_size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrPackFileHash { + pub sha1: String, + // pub sha512: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrPackFileEnv { + pub client: PackageSide, + pub server: PackageSide, +} diff --git a/packages/core/src/api/packages/modpack/tests.rs b/packages/core/src/api/packages/modpack/tests.rs new file mode 100644 index 00000000..f819bb43 --- /dev/null +++ b/packages/core/src/api/packages/modpack/tests.rs @@ -0,0 +1,88 @@ +use std::sync::LazyLock; + +use onelauncher_entity::package::{PackageType, Provider}; + +use crate::api::packages::DatabaseModelExt; +use crate::api::packages::modpack::ModpackFormat; +use crate::api::packages::provider::ProviderExt; +use crate::api::{self}; +use crate::error::{LauncherError, LauncherResult}; + +use super::super::provider::tests::init; + +const DATA: LazyLock)>> = LazyLock::new(|| { + vec![( + ModpackFormat::MrPack, + vec![Entry { + slug: "sop", + id: "BYfVnHa7", + version_id: "nciq4FNy", + provider: Provider::Modrinth, + }], + )] +}); + +struct Entry { + pub id: &'static str, + pub slug: &'static str, + pub version_id: &'static str, + pub provider: Provider, +} + +#[tokio::test] +pub async fn test_install_modpack() -> LauncherResult<()> { + let cf = init!(); + + for (format_kind, entries) in DATA.iter() { + if format_kind == &ModpackFormat::CurseForge && !cf { + continue; + } + + for entry in entries { + let provider = &entry.provider; + + let modpack_pkg = provider.get(entry.id).await?; + + assert_eq!(modpack_pkg.slug, entry.slug); + assert_eq!(modpack_pkg.id, entry.id); + assert_eq!(modpack_pkg.package_type, PackageType::ModPack); + + let modpack_version = provider + .get_versions(&[entry.version_id.to_string()]) + .await?; + let modpack_version = modpack_version.first().ok_or_else(|| { + LauncherError::from(anyhow::anyhow!("no version found for {}", entry.version_id)) + })?; + + assert_eq!(modpack_version.version_id, entry.version_id); + + let modpack_model = + api::packages::download_package(&modpack_pkg, &modpack_version, None, None).await?; + let mut cluster = api::cluster::create_cluster( + "Modpack Format Test", + modpack_version.mc_versions.first().unwrap(), + *modpack_version.loaders.first().unwrap(), + None, + None, + ) + .await?; + + let modpack_format = ModpackFormat::from_file(modpack_model.path().await?).await?; + api::packages::modpack::install_managed_modpack( + &modpack_model, + &modpack_format, + &mut cluster, + None, + None, + ) + .await?; + + assert_eq!( + cluster.linked_modpack_hash, + Some(modpack_model.hash.clone()) + ); + } + } + + Ok(()) +} diff --git a/packages/core/src/api/packages/provider/mod.rs b/packages/core/src/api/packages/provider/mod.rs index 1aad2d1e..825c8c8a 100644 --- a/packages/core/src/api/packages/provider/mod.rs +++ b/packages/core/src/api/packages/provider/mod.rs @@ -11,7 +11,7 @@ use crate::utils::pagination::Paginated; use super::data::{ManagedPackage, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery}; #[cfg(test)] -mod tests; +pub(crate) mod tests; mod curseforge; mod modrinth; diff --git a/packages/core/src/api/packages/provider/tests.rs b/packages/core/src/api/packages/provider/tests.rs index d080ac32..15d88b96 100644 --- a/packages/core/src/api/packages/provider/tests.rs +++ b/packages/core/src/api/packages/provider/tests.rs @@ -5,12 +5,12 @@ use onelauncher_entity::package::{PackageType, Provider}; use crate::api::packages; use crate::api::packages::provider::ProviderExt; use crate::error::LauncherResult; -use crate::store::{Core, CoreOptions, Dirs}; +use crate::utils::DatabaseModelExt; macro_rules! init { () => {{ - Core::initialize(CoreOptions::default()).await?; - if Core::get().curseforge_api_key.is_none() { + crate::store::Core::initialize(crate::store::CoreOptions::default()).await?; + if crate::store::Core::get().curseforge_api_key.is_none() { eprintln!("CURSEFORGE_API_KEY is not set, skipping tests that require it."); false } else { @@ -19,6 +19,8 @@ macro_rules! init { }}; } +pub(crate) use init; + struct Entry { slug: &'static str, id: &'static str, @@ -186,20 +188,10 @@ pub async fn test_download_packages() -> LauncherResult<()> { let versions = provider.get_versions(&[ver_id.clone()]).await?; let ver = versions.first().expect("No version found"); - let db_model = packages::download_package(&pkg, ver, None).await?; + let db_model = packages::download_package(&pkg, ver, None, None).await?; assert!(db_model.hash == ver.files.first().unwrap().sha1); - let dir = Dirs::get_package_dir( - &db_model.package_type, - &db_model.provider, - &db_model.package_id, - ) - .await?; - let dest = dir.join(packages::join_package_file( - &dir, - &db_model.version_id, - &db_model.file_name, - )); + let dest = db_model.path().await?; assert!(dest.exists(), "File does not exist: {dest:?}"); } diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 47bca235..62c17ee6 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -515,7 +515,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl { .next() .ok_or_else(|| anyhow::anyhow!("Version not found"))?; - let model = api::packages::download_package(&package, &version, None).await?; + let model = api::packages::download_package(&package, &version, None, None).await?; api::packages::link_package(&model, &cluster, skip_compatibility).await?; diff --git a/packages/core/src/utils/io.rs b/packages/core/src/utils/io.rs index bbad400e..f10e566c 100644 --- a/packages/core/src/utils/io.rs +++ b/packages/core/src/utils/io.rs @@ -9,8 +9,6 @@ use futures::{Stream, TryStreamExt, pin_mut}; use serde::Serialize; use serde::de::DeserializeOwned; use tempfile::TempDir; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio_util::compat::TokioAsyncWriteCompatExt; /// A wrapper around generic and unhelpful [`std::io::Error`] messages. @@ -19,6 +17,8 @@ use tokio_util::compat::TokioAsyncWriteCompatExt; pub enum IOError { #[error("invalid absolute path '{0}'")] InvalidAbsolutePath(PathBuf), + #[error("couldn't find file '{0}' in zip")] + FileNotFoundInZip(String), #[error("error acessing path: {source}, path: {path}")] IOErrorWrapper { @@ -126,11 +126,11 @@ pub async fn remove_dir_all(path: impl AsRef) -> Result<(), IOE pub async fn read_gz_to_string(path: impl AsRef) -> Result { let mut f = tokio::fs::File::open(path).await?; let mut buf = vec![]; - f.read_to_end(&mut buf).await?; + tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; let mut decoder = GzipDecoder::new(buf.as_slice()); let mut dst = String::new(); - decoder.read_to_string(&mut dst).await?; + tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; Ok(dst) } @@ -189,7 +189,7 @@ where f(&mut writer).await?; - writer.flush().await?; + tokio::io::AsyncWriteExt::flush(&mut writer).await?; Ok(()) } @@ -367,10 +367,25 @@ pub async fn unzip_bytes_filtered( Ok(()) } -/// Unzips a zip archive from a file -pub async fn unzip_file( +pub async fn extract_zip( zip_path: impl AsRef, dest_path: impl AsRef, +) -> Result<(), IOError> { + extract_zip_filtered( + zip_path, + dest_path, + None:: bool>, + None:: String>, + ) + .await +} + +/// Unzips a zip archive from a file +pub async fn extract_zip_filtered( + zip_path: impl AsRef, + dest_path: impl AsRef, + filter_entries: Option bool + Send + Sync>, + modify_entry_name: Option String>, ) -> Result<(), IOError> { let zip_path = zip_path.as_ref(); let dest_path = dest_path.as_ref(); @@ -381,7 +396,22 @@ pub async fn unzip_file( for index in 0..entries.len() { let entry = entries.get(index).expect("expected more zip entries"); - let path = dest_path.join(sanitize_path(entry.filename().as_str()?)); + if let Some(filter) = &filter_entries + && !filter(entry) + { + continue; + } + + let old_name = entry.filename().as_str()?; + let name: String = if let Some(modify) = &modify_entry_name { + modify(&old_name) + } else { + old_name.to_string() + }; + + let name = sanitize_path(name); + + let path = dest_path.join(name); let is_dir = entry.dir()?; if is_dir { @@ -402,6 +432,32 @@ pub async fn unzip_file( Ok(()) } +/// Returns a zip file entry's bytes without reading the entire file into memory. +pub async fn try_read_zip_entry_bytes(reader: R, file_name: &str) -> Result, IOError> +where + R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, +{ + use tokio_util::compat::TokioAsyncReadCompatExt; + let compat = reader.compat(); + + let mut zip_reader = async_zip::base::read::seek::ZipFileReader::new(compat).await?; + + let index = zip_reader + .file() + .entries() + .iter() + .position(|entry| entry.filename().as_str().is_ok_and(|n| n == file_name)) + .ok_or_else(|| IOError::FileNotFoundInZip(file_name.to_string()))?; + + let mut entry_reader = zip_reader.reader_without_entry(index).await?; + + let mut data: Vec = Vec::new(); + + futures::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + + Ok(data) +} + pub fn sanitize_path(path: impl AsRef) -> PathBuf { path.as_ref() .to_string_lossy() diff --git a/packages/core/src/utils/mod.rs b/packages/core/src/utils/mod.rs index 6ea42db3..628b1be5 100644 --- a/packages/core/src/utils/mod.rs +++ b/packages/core/src/utils/mod.rs @@ -4,3 +4,8 @@ pub mod icon; pub mod io; pub mod os_ext; pub mod pagination; + +#[async_trait::async_trait] +pub trait DatabaseModelExt { + async fn path(&self) -> crate::LauncherResult; +} diff --git a/packages/entity/src/domain/loader.rs b/packages/entity/src/domain/loader.rs index a2551aef..e6cf0bee 100644 --- a/packages/entity/src/domain/loader.rs +++ b/packages/entity/src/domain/loader.rs @@ -82,17 +82,17 @@ impl GameLoader { } } -impl TryFrom for GameLoader { - type Error = String; +impl FromStr for GameLoader { + type Err = String; - fn try_from(s: String) -> Result { + fn from_str(s: &str) -> Result { Ok( match s.to_lowercase().replace(['_', '.', ' '], "").as_str() { "vanilla" | "minecraft" => Self::Vanilla, "forge" => Self::Forge, "neoforge" | "neo" => Self::NeoForge, - "quilt" => Self::Quilt, - "fabric" => Self::Fabric, + "quilt" | "quilt-loader" => Self::Quilt, + "fabric" | "fabric-loader" => Self::Fabric, "legacyfabric" => Self::LegacyFabric, _ => return Err(format!("'{s}' is not a valid game loader")), }, @@ -100,14 +100,6 @@ impl TryFrom for GameLoader { } } -impl FromStr for GameLoader { - type Err = String; - - fn from_str(s: &str) -> Result { - Self::try_from(s.to_string()) - } -} - impl Display for GameLoader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { diff --git a/packages/entity/src/domain/package.rs b/packages/entity/src/domain/package.rs index 8545b6e6..d680e5d1 100644 --- a/packages/entity/src/domain/package.rs +++ b/packages/entity/src/domain/package.rs @@ -32,19 +32,25 @@ impl PackageType { } } -impl From for PackageType { - fn from(value: String) -> Self { +impl From<&str> for PackageType { + fn from(value: &str) -> Self { match value.to_lowercase().as_str() { - "mod" => Self::Mod, - "resourcepack" => Self::ResourcePack, - "shader" => Self::Shader, - "datapack" => Self::DataPack, + "mod" | "mods" => Self::Mod, + "resourcepack" | "resourcepacks" => Self::ResourcePack, + "shader" | "shaders" => Self::Shader, + "datapack" | "datapacks" => Self::DataPack, "modpack" => Self::ModPack, _ => Self::Mod, // default case } } } +impl From for PackageType { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + impl ToString for PackageType { fn to_string(&self) -> String { match self { diff --git a/packages/entity/src/entities/clusters.rs b/packages/entity/src/entities/clusters.rs index d8c22a5d..02317e3c 100644 --- a/packages/entity/src/entities/clusters.rs +++ b/packages/entity/src/entities/clusters.rs @@ -1,7 +1,9 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{cluster_stage::ClusterStage, icon::Icon, loader::GameLoader}; +use crate::cluster_stage::ClusterStage; +use crate::icon::Icon; +use crate::loader::GameLoader; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "clusters")] @@ -26,8 +28,7 @@ pub struct Model { #[sea_orm(nullable)] pub setting_profile_name: Option, #[sea_orm(nullable)] - pub linked_pack_id: Option, - pub linked_pack_version: Option, + pub linked_modpack_hash: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/migration/src/m20220101_000001_create_table.rs b/packages/migration/src/m20220101_000001_create_table.rs index 6d07b263..e618f42c 100644 --- a/packages/migration/src/m20220101_000001_create_table.rs +++ b/packages/migration/src/m20220101_000001_create_table.rs @@ -6,271 +6,305 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(ClusterGroups::Table) - .if_not_exists() - .col( - ColumnDef::new(ClusterGroups::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(ClusterGroups::Name).text().not_null()) - .to_owned(), - ) - .await?; + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ClusterGroups::Table) + .if_not_exists() + .col( + ColumnDef::new(ClusterGroups::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(ClusterGroups::Name).text().not_null()) + .to_owned(), + ) + .await?; - manager - .create_table( - Table::create() - .table(JavaVersions::Table) - .if_not_exists() - .col( - ColumnDef::new(JavaVersions::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(JavaVersions::MajorVersion).integer().not_null()) - .col(ColumnDef::new(JavaVersions::VendorName).text().not_null()) - .col(ColumnDef::new(JavaVersions::AbsolutePath).text().not_null()) - .col(ColumnDef::new(JavaVersions::FullVersion).text().not_null()) - .col(ColumnDef::new(JavaVersions::Arch).text().not_null()) - .to_owned(), - ) - .await?; + manager + .create_table( + Table::create() + .table(JavaVersions::Table) + .if_not_exists() + .col( + ColumnDef::new(JavaVersions::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(JavaVersions::MajorVersion) + .integer() + .not_null(), + ) + .col(ColumnDef::new(JavaVersions::VendorName).text().not_null()) + .col(ColumnDef::new(JavaVersions::AbsolutePath).text().not_null()) + .col(ColumnDef::new(JavaVersions::FullVersion).text().not_null()) + .col(ColumnDef::new(JavaVersions::Arch).text().not_null()) + .to_owned(), + ) + .await?; - manager - .create_index( - Index::create() - .name("java_versions_major_version_idx") - .table(JavaVersions::Table) - .col(JavaVersions::MajorVersion) - .to_owned(), - ) - .await?; + manager + .create_index( + Index::create() + .name("java_versions_major_version_idx") + .table(JavaVersions::Table) + .col(JavaVersions::MajorVersion) + .to_owned(), + ) + .await?; - manager - .create_table( - Table::create() - .table(Packages::Table) - .if_not_exists() - .col(ColumnDef::new(Packages::Hash).text().not_null().primary_key()) - .col(ColumnDef::new(Packages::FileName).text().not_null()) - .col(ColumnDef::new(Packages::VersionId).text().not_null()) - .col(ColumnDef::new(Packages::PublishedAt).date_time().not_null()) - .col(ColumnDef::new(Packages::DisplayName).text().not_null()) - .col(ColumnDef::new(Packages::DisplayVersion).text().not_null()) - .col(ColumnDef::new(Packages::PackageType).integer().not_null()) - .col(ColumnDef::new(Packages::Provider).integer().not_null()) - .col(ColumnDef::new(Packages::PackageId).text().not_null()) - .col(ColumnDef::new(Packages::McVersions).json().not_null()) - .col(ColumnDef::new(Packages::McLoader).integer().not_null()) - .col(ColumnDef::new(Packages::Icon).text().null()) - .to_owned(), - ) - .await?; + manager + .create_table( + Table::create() + .table(Packages::Table) + .if_not_exists() + .col( + ColumnDef::new(Packages::Hash) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Packages::FileName).text().not_null()) + .col(ColumnDef::new(Packages::VersionId).text().not_null()) + .col(ColumnDef::new(Packages::PublishedAt).date_time().not_null()) + .col(ColumnDef::new(Packages::DisplayName).text().not_null()) + .col(ColumnDef::new(Packages::DisplayVersion).text().not_null()) + .col(ColumnDef::new(Packages::PackageType).integer().not_null()) + .col(ColumnDef::new(Packages::Provider).integer().not_null()) + .col(ColumnDef::new(Packages::PackageId).text().not_null()) + .col(ColumnDef::new(Packages::McVersions).json().not_null()) + .col(ColumnDef::new(Packages::McLoader).integer().not_null()) + .col(ColumnDef::new(Packages::Icon).text().null()) + .to_owned(), + ) + .await?; - manager - .create_index( - Index::create() - .name("packages_mc_loader_mc_versions_idx") - .table(Packages::Table) - .col(Packages::PackageType) - .col(Packages::McLoader) - .to_owned(), - ) - .await?; + manager + .create_index( + Index::create() + .name("packages_mc_loader_mc_versions_idx") + .table(Packages::Table) + .col(Packages::PackageType) + .col(Packages::McLoader) + .to_owned(), + ) + .await?; - manager - .create_table( - Table::create() - .table(SettingProfiles::Table) - .if_not_exists() - .col(ColumnDef::new(SettingProfiles::Name).text().not_null().primary_key()) - .col(ColumnDef::new(SettingProfiles::JavaId).integer().null()) - .col(ColumnDef::new(SettingProfiles::Res).json().null()) - .col(ColumnDef::new(SettingProfiles::ForceFullscreen).boolean().null()) - .col(ColumnDef::new(SettingProfiles::MemMax).integer().null()) - .col(ColumnDef::new(SettingProfiles::LaunchArgs).text().null()) - .col(ColumnDef::new(SettingProfiles::LaunchEnv).text().null()) - .col(ColumnDef::new(SettingProfiles::HookPre).text().null()) - .col(ColumnDef::new(SettingProfiles::HookWrapper).text().null()) - .col(ColumnDef::new(SettingProfiles::HookPost).text().null()) - .col(ColumnDef::new(SettingProfiles::OsExtra).json().null()) - .foreign_key( - ForeignKey::create() - .from(SettingProfiles::Table, SettingProfiles::JavaId) - .to(JavaVersions::Table, JavaVersions::Id), - ) - .to_owned(), - ) - .await?; + manager + .create_table( + Table::create() + .table(SettingProfiles::Table) + .if_not_exists() + .col( + ColumnDef::new(SettingProfiles::Name) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(SettingProfiles::JavaId).integer().null()) + .col(ColumnDef::new(SettingProfiles::Res).json().null()) + .col( + ColumnDef::new(SettingProfiles::ForceFullscreen) + .boolean() + .null(), + ) + .col(ColumnDef::new(SettingProfiles::MemMax).integer().null()) + .col(ColumnDef::new(SettingProfiles::LaunchArgs).text().null()) + .col(ColumnDef::new(SettingProfiles::LaunchEnv).text().null()) + .col(ColumnDef::new(SettingProfiles::HookPre).text().null()) + .col(ColumnDef::new(SettingProfiles::HookWrapper).text().null()) + .col(ColumnDef::new(SettingProfiles::HookPost).text().null()) + .col(ColumnDef::new(SettingProfiles::OsExtra).json().null()) + .foreign_key( + ForeignKey::create() + .from(SettingProfiles::Table, SettingProfiles::JavaId) + .to(JavaVersions::Table, JavaVersions::Id), + ) + .to_owned(), + ) + .await?; - manager - .create_table( - Table::create() - .table(Clusters::Table) - .if_not_exists() - .col( - ColumnDef::new(Clusters::Id) - .integer() - .not_null() - .auto_increment() - .primary_key(), - ) - .col(ColumnDef::new(Clusters::FolderName).text().not_null()) - .col(ColumnDef::new(Clusters::Stage).integer().not_null().default(0)) - .col(ColumnDef::new(Clusters::CreatedAt).date_time().not_null().default(Utc::now())) - .col(ColumnDef::new(Clusters::GroupId).integer().null()) - .col(ColumnDef::new(Clusters::Name).text().not_null()) - .col(ColumnDef::new(Clusters::McVersion).text().not_null()) - .col(ColumnDef::new(Clusters::McLoader).integer().not_null()) - .col(ColumnDef::new(Clusters::McLoaderVersion).text().null()) - .col(ColumnDef::new(Clusters::LastPlayed).date_time().null()) - .col(ColumnDef::new(Clusters::OverallPlayed).integer().null()) - .col(ColumnDef::new(Clusters::IconUrl).text().null()) - .col(ColumnDef::new(Clusters::SettingProfileName).text().null()) - .col(ColumnDef::new(Clusters::LinkedPackId).text().null()) - .col(ColumnDef::new(Clusters::LinkedPackVersion).text().null()) - .foreign_key( - ForeignKey::create() - .from(Clusters::Table, Clusters::GroupId) - .to(ClusterGroups::Table, ClusterGroups::Id), - ) - .foreign_key( - ForeignKey::create() - .from(Clusters::Table, Clusters::SettingProfileName) - .to(SettingProfiles::Table, SettingProfiles::Name), - ) - .to_owned(), - ) - .await?; + manager + .create_table( + Table::create() + .table(Clusters::Table) + .if_not_exists() + .col( + ColumnDef::new(Clusters::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Clusters::FolderName).text().not_null()) + .col( + ColumnDef::new(Clusters::Stage) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Clusters::CreatedAt) + .date_time() + .not_null() + .default(Utc::now()), + ) + .col(ColumnDef::new(Clusters::GroupId).integer().null()) + .col(ColumnDef::new(Clusters::Name).text().not_null()) + .col(ColumnDef::new(Clusters::McVersion).text().not_null()) + .col(ColumnDef::new(Clusters::McLoader).integer().not_null()) + .col(ColumnDef::new(Clusters::McLoaderVersion).text().null()) + .col(ColumnDef::new(Clusters::LastPlayed).date_time().null()) + .col(ColumnDef::new(Clusters::OverallPlayed).integer().null()) + .col(ColumnDef::new(Clusters::IconUrl).text().null()) + .col(ColumnDef::new(Clusters::SettingProfileName).text().null()) + .col(ColumnDef::new(Clusters::LinkedModpackHash).text().null()) + .foreign_key( + ForeignKey::create() + .from(Clusters::Table, Clusters::GroupId) + .to(ClusterGroups::Table, ClusterGroups::Id), + ) + .foreign_key( + ForeignKey::create() + .from(Clusters::Table, Clusters::SettingProfileName) + .to(SettingProfiles::Table, SettingProfiles::Name), + ) + .to_owned(), + ) + .await?; - manager - .create_index( - Index::create() - .name("clusters_path_idx") - .table(Clusters::Table) - .col(Clusters::FolderName) - .to_owned(), - ) - .await?; + manager + .create_index( + Index::create() + .name("clusters_path_idx") + .table(Clusters::Table) + .col(Clusters::FolderName) + .to_owned(), + ) + .await?; - manager - .create_table( - Table::create() - .table(ClusterPackages::Table) - .if_not_exists() - .col(ColumnDef::new(ClusterPackages::ClusterId).integer().not_null()) - .col(ColumnDef::new(ClusterPackages::PackageHash).text().not_null()) - .primary_key( - Index::create() - .col(ClusterPackages::ClusterId) - .col(ClusterPackages::PackageHash) - .primary(), - ) - .foreign_key( - ForeignKey::create() - .from(ClusterPackages::Table, ClusterPackages::ClusterId) - .to(Clusters::Table, Clusters::Id), - ) - .foreign_key( - ForeignKey::create() - .from(ClusterPackages::Table, ClusterPackages::PackageHash) - .to(Packages::Table, Packages::Hash), - ) - .to_owned(), - ) - .await?; + manager + .create_table( + Table::create() + .table(ClusterPackages::Table) + .if_not_exists() + .col( + ColumnDef::new(ClusterPackages::ClusterId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(ClusterPackages::PackageHash) + .text() + .not_null(), + ) + .primary_key( + Index::create() + .col(ClusterPackages::ClusterId) + .col(ClusterPackages::PackageHash) + .primary(), + ) + .foreign_key( + ForeignKey::create() + .from(ClusterPackages::Table, ClusterPackages::ClusterId) + .to(Clusters::Table, Clusters::Id), + ) + .foreign_key( + ForeignKey::create() + .from(ClusterPackages::Table, ClusterPackages::PackageHash) + .to(Packages::Table, Packages::Hash), + ) + .to_owned(), + ) + .await?; Ok(()) - } + } - async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { - Err(DbErr::Migration("no rollback available".to_owned())) - } + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Err(DbErr::Migration("no rollback available".to_owned())) + } } #[derive(Iden)] enum ClusterGroups { - Table, - Id, - Name, + Table, + Id, + Name, } #[derive(Iden)] enum JavaVersions { - Table, - Id, - MajorVersion, - VendorName, - AbsolutePath, - FullVersion, - Arch, + Table, + Id, + MajorVersion, + VendorName, + AbsolutePath, + FullVersion, + Arch, } #[derive(Iden)] enum Packages { - Table, - Hash, - FileName, - VersionId, - PublishedAt, - DisplayName, - DisplayVersion, - PackageType, - Provider, - PackageId, - McVersions, - McLoader, - Icon, + Table, + Hash, + FileName, + VersionId, + PublishedAt, + DisplayName, + DisplayVersion, + PackageType, + Provider, + PackageId, + McVersions, + McLoader, + Icon, } #[derive(Iden)] enum SettingProfiles { - Table, - Name, - JavaId, - Res, - ForceFullscreen, - MemMax, - LaunchArgs, - LaunchEnv, - HookPre, - HookWrapper, - HookPost, - OsExtra, + Table, + Name, + JavaId, + Res, + ForceFullscreen, + MemMax, + LaunchArgs, + LaunchEnv, + HookPre, + HookWrapper, + HookPost, + OsExtra, } #[derive(Iden)] enum Clusters { - Table, - Id, - FolderName, - Stage, - CreatedAt, - GroupId, - Name, - McVersion, - McLoader, - McLoaderVersion, - LastPlayed, - OverallPlayed, - IconUrl, - SettingProfileName, - LinkedPackId, - LinkedPackVersion, + Table, + Id, + FolderName, + Stage, + CreatedAt, + GroupId, + Name, + McVersion, + McLoader, + McLoaderVersion, + LastPlayed, + OverallPlayed, + IconUrl, + SettingProfileName, + LinkedModpackHash, } #[derive(Iden)] enum ClusterPackages { - Table, - ClusterId, - PackageHash, -} \ No newline at end of file + Table, + ClusterId, + PackageHash, +}