diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0c25c51c..c7277fbc 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -5,6 +5,7 @@ contact_links: url: https://polyfrost.org/discord - name: Polyfrost Documentation about: Useful documentation about Polyfrost, OneConfig, and other Polyfrost projects. - url: https://docs.modrinth.com + url: https://docs.polyfrost.org - name: Polyfrost Docs^2 about: Useful documentation about how you can contribute to Polyfrost projects. + url: https://contributing.polyfrost.org diff --git a/crates/core/src/api/cluster/mod.rs b/crates/core/src/api/cluster/mod.rs index 6e8e1e84..7644c49e 100644 --- a/crates/core/src/api/cluster/mod.rs +++ b/crates/core/src/api/cluster/mod.rs @@ -1,6 +1,8 @@ //! **OneLauncher Cluster** //! -//! API for our managed Minecraft instances, Clusters. +//! API for creating our managed Minecraft instances, Clusters. + +// TODO: (pauline) fully implement the cluster::self APIs. use crate::proxy::send::send_cluster; @@ -201,7 +203,7 @@ pub async fn get_by_uuid(uuid: uuid::Uuid, clear: Option) -> crate::Result #[tracing::instrument] pub async fn get_full_path(path: &ClusterPath) -> crate::Result { let _ = get(path, Some(true)).await?.ok_or_else(|| { - anyhow::anyhow!("failed to get the full path of profile at path {}", path) + anyhow::anyhow!("failed to get the full path of cluster at path {}", path) })?; let full_path = io::canonicalize(path.full_path().await?)?; @@ -255,7 +257,7 @@ where Ok(()) } - None => Err(anyhow::anyhow!("unmanaged profile edited at {}", path.to_string()).into()), + None => Err(anyhow::anyhow!("unmanaged cluster edited at {}", path.to_string()).into()), } } diff --git a/crates/core/src/api/cluster/update.rs b/crates/core/src/api/cluster/update.rs index 8e60c619..5eadc291 100644 --- a/crates/core/src/api/cluster/update.rs +++ b/crates/core/src/api/cluster/update.rs @@ -1 +1,3 @@ //! Utilities for updating Clusters + +// TODO: requires `store/package.rs` to be implemented. diff --git a/crates/core/src/api/package/from.rs b/crates/core/src/api/package/from.rs index 4e51c6d3..1de7c8a5 100644 --- a/crates/core/src/api/package/from.rs +++ b/crates/core/src/api/package/from.rs @@ -1,20 +1,404 @@ //! Install modpacks from different sources. -use crate::data::{Loader, PackageData}; +use crate::data::{Loader, ManagedPackage, ManagedVersion, PackageData}; +use crate::prelude::ClusterPath; +use crate::proxy::send::{init_ingress, send_ingress}; +use crate::proxy::IngressId; +use crate::store::{ClusterStage, PackageSide}; +use crate::utils::http::{fetch, fetch_advanced, fetch_json, write_icon}; +use crate::utils::io; +use crate::{IngressType, InnerPathLinux, State}; +use reqwest::Method; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; +pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/"; +pub const CURSEFORGE_API_URL: &str = "https://api.cursefor"; + +#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PackFormat { + pub game: String, + pub format_version: i32, + pub version_id: String, + pub name: String, + pub summary: Option, + pub files: Vec, + pub dependencies: HashMap, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PackFile { + pub path: InnerPathLinux, + pub hashes: HashMap, + pub env: Option>, + pub downloads: Vec, + pub file_size: u32, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "camelCase", from = "String")] +pub enum PackFileHash { + Sha1, + Sha512, + Unknown(String), +} + +impl From 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")] +pub enum EnvType { + Client, + Server, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)] +pub enum PackDependency { + #[serde(rename = "forge")] + Forge, + + #[serde(rename = "neoforge")] + #[serde(alias = "neo-forge")] + NeoForge, + + #[serde(rename = "fabric-loader")] + FabricLoader, + + #[serde(rename = "quilt-loader")] + QuiltLoader, + + #[serde(rename = "minecraft")] + Minecraft, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum CreatePackLocation { + /// Create a pack from a Modrinth modpack. + FromModrinth { + package_id: String, + version_id: String, + title: String, + icon_url: Option, + }, + /// Create a pack from a file (importing, importing an mrpack or zip-style modpack) + FromFile { path: PathBuf }, +} + /// Modpack associated [`Cluster`] wrapper. #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreatePackCluster { + /// The name of the cluster, and relative path. pub name: String, + /// The game version (MC) of the cluster. pub mc_version: String, + /// The mod [`Loader`] to use (if applicable). pub mod_loader: Loader, + /// The mod [`Loader`] version to use, set to `latest` or `stable` or the ID of the loader. + /// The default value is `latest` for the latest version of the mod loader. pub loader_version: Option, + /// The icon of the cluster. pub icon: Option, + /// The URL of an icon for a cluster. (ONLY USE THIS FOR A TEMPORARY CLUSTER) pub icon_url: Option, + /// The linked package data (mainly used for modpacks and updating files). pub package_data: Option, + /// Whether or not the cluster should skip being installed. pub skip: Option, + /// Whether or not the cluster should skip file watching. pub skip_watch: Option, } + +impl Default for CreatePackCluster { + fn default() -> Self { + CreatePackCluster { + name: "Untitled".to_string(), + mc_version: "1.20.4".to_string(), + mod_loader: Loader::Vanilla, + loader_version: None, + icon: None, + icon_url: None, + package_data: None, + skip: Some(true), + skip_watch: Some(false), + } + } +} + +#[derive(Clone)] +pub struct CreatePack { + pub file: bytes::Bytes, + pub description: CreatePackDescription, +} + +#[derive(Clone, Debug)] +pub struct CreatePackDescription { + pub icon: Option, + pub override_title: Option, + pub package_id: Option, + pub version_id: Option, + pub existing_ingress: Option, + pub cluster_path: ClusterPath, +} + +pub fn get_cluster_from_pack(location: CreatePackLocation) -> CreatePackCluster { + match location { + CreatePackLocation::FromModrinth { + package_id, + version_id, + title, + icon_url, + } => CreatePackCluster { + name: title, + icon_url, + package_data: Some(PackageData { + package_id: Some(package_id), + version_id: Some(version_id), + locked: Some(true), + }), + ..Default::default() + }, + CreatePackLocation::FromFile { path } => { + let file_name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + CreatePackCluster { + name: file_name, + ..Default::default() + } + } + } +} + +#[tracing::instrument] +#[onelauncher_debug::debugger] +pub async fn generate_pack_from_version_id( + package_id: String, + version_id: String, + title: String, + icon_url: Option, + cluster_path: ClusterPath, + existing_ingress: Option, +) -> crate::Result { + let state = State::get().await?; + + let ingress = if let Some(ing) = existing_ingress { + send_ingress(&ing, 0.0, Some("downloading pack file")).await?; + ing + } else { + init_ingress( + IngressType::DownloadPack { + cluster_path: cluster_path.full_path().await?, + package_name: title, + icon: icon_url, + package_version: version_id.clone(), + }, + 100.0, + "downloading pack file", + ) + .await? + }; + + send_ingress(&ingress, 0.0, Some("fetching version")).await?; + let version: ManagedVersion = fetch_json( + Method::GET, + &format!("{}version/{}", MODRINTH_API_URL, version_id), + None, + None, + &state.fetch_semaphore, + ) + .await?; + send_ingress(&ingress, 10.0, None).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::AnyhowError(anyhow::anyhow!("specified version has no files")) + })?; + + let file = fetch_advanced( + Method::GET, + &url, + hash.map(|x| &**x), + None, + None, + Some((&ingress, 70.0)), + &state.fetch_semaphore, + ) + .await?; + send_ingress(&ingress, 0.0, Some("fetching project metadata")).await?; + + let pkg: ManagedPackage = fetch_json( + Method::GET, + &format!("{}project/{}", MODRINTH_API_URL, version.package_id), + None, + None, + &state.fetch_semaphore, + ) + .await?; + + send_ingress(&ingress, 10.0, Some("Retrieving icon")).await?; + let icon = if let Some(icon_url) = pkg.icon_url { + let state = State::get().await?; + let icon_bytes = fetch(&icon_url, None, &state.fetch_semaphore).await?; + let filename = icon_url.rsplit('/').next(); + + if let Some(filename) = filename { + Some( + write_icon( + filename, + &state.directories.caches_dir().await, + icon_bytes, + &state.io_semaphore, + ) + .await?, + ) + } else { + None + } + } else { + None + }; + send_ingress(&ingress, 10.0, None).await?; + + Ok(CreatePack { + file, + description: CreatePackDescription { + icon, + override_title: None, + package_id: Some(package_id), + version_id: Some(version_id), + existing_ingress: Some(ingress), + cluster_path, + }, + }) +} + +#[tracing::instrument] +#[onelauncher_debug::debugger] +pub async fn generate_pack_from_file( + path: PathBuf, + cluster_path: ClusterPath, +) -> crate::Result { + let file = io::read(&path).await?; + Ok(CreatePack { + file: bytes::Bytes::from(file), + description: CreatePackDescription { + icon: None, + override_title: None, + package_id: None, + version_id: None, + existing_ingress: None, + cluster_path, + }, + }) +} + +/// Sets generated cluster attributes to the pack attributes (using `cluster::edit`). +/// This includes the pack name, icon, game version, loader version, and loader. +#[onelauncher_debug::debugger] +pub async fn set_cluster_information( + cluster_path: ClusterPath, + description: &CreatePackDescription, + backup_name: &str, + deps: &HashMap, + ignore_lock: bool, +) -> crate::Result<()> { + let mut game_version: Option<&String> = None; + let mut mod_loader = None; + let mut loader_version = None; + + for (key, value) in deps { + match key { + PackDependency::Forge => { + mod_loader = Some(Loader::Forge); + loader_version = Some(value); + } + PackDependency::NeoForge => { + mod_loader = Some(Loader::NeoForge); + loader_version = Some(value); + } + PackDependency::FabricLoader => { + mod_loader = Some(Loader::Fabric); + loader_version = Some(value); + } + PackDependency::QuiltLoader => { + mod_loader = Some(Loader::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(anyhow::anyhow!("pack did not specify Minecraft version").into()); + }; + + let mod_loader = mod_loader.unwrap_or(Loader::Vanilla); + let loader_version = if mod_loader != Loader::Vanilla { + crate::cluster::create::get_loader_version( + game_version.clone(), + mod_loader, + loader_version.cloned(), + ) + .await? + } else { + None + }; + + crate::api::cluster::edit(&cluster_path, |cl| { + cl.meta.name = description + .override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + cl.stage = ClusterStage::PackDownloading; + let package_id = description.package_id.clone(); + let version_id = description.version_id.clone(); + + cl.meta.package_data = if package_id.is_some() && version_id.is_some() { + Some(PackageData { + package_id, + version_id, + locked: if !ignore_lock { + Some(true) + } else { + cl.meta.package_data.as_ref().and_then(|x| x.locked) + }, + }) + } else { + None + }; + + cl.meta.icon.clone_from(&description.icon); + cl.meta.mc_version.clone_from(game_version); + cl.meta.loader_version.clone_from(&loader_version); + cl.meta.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + + Ok(()) +} diff --git a/crates/core/src/api/package/import/atlauncher.rs b/crates/core/src/api/package/import/atlauncher.rs index 8b137891..d4393906 100644 --- a/crates/core/src/api/package/import/atlauncher.rs +++ b/crates/core/src/api/package/import/atlauncher.rs @@ -1 +1,237 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use crate::package::from::CreatePackDescription; +use crate::package::import::{self, copy_minecraft}; +use crate::package::{self}; +use crate::prelude::{Cluster, ClusterPath, Loader}; +use crate::store::{ClusterStage, PackageData, State}; +use crate::utils::io; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ATInstance { + pub id: String, // minecraft version id ie: 1.12.1, not a name + pub launcher: ATLauncher, + pub java_version: ATJavaVersion, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ATLauncher { + pub name: String, + pub pack: String, + pub version: String, + pub loader_version: ATLauncherLoaderVersion, + + pub managed_project: Option, + pub managed_version: Option, + pub managed_manifest: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ATJavaVersion { + pub major_version: u8, + pub component: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ATLauncherLoaderVersion { + pub r#type: String, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ATLauncherManagedProject { + pub id: String, + pub slug: String, + pub project_type: String, + pub team: String, + pub client_side: Option, + pub server_side: Option, + pub categories: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ATLauncherManagedVersion { + pub id: String, + pub project_id: String, + pub name: String, + pub version_number: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ATLauncherManagedVersionFile { + pub hashes: HashMap, + pub url: String, + pub filename: String, + pub primary: bool, + pub size: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ATLauncherManagedVersionDependency { + pub project_id: Option, + pub version_id: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ATLauncherMod { + pub name: String, + pub version: String, + pub file: String, + + pub managed_project: Option, + pub managed_version: Option, +} + +pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool { + let instance: String = io::read_to_string(&instance_folder.join("instance.json")) + .await + .unwrap_or("".to_string()); + let instance: Result = + serde_json::from_str::(&instance); + if let Err(e) = instance { + tracing::warn!( + "failed to parse instance.json at {}: {}", + instance_folder.display(), + e + ); + false + } else { + true + } +} + +#[tracing::instrument] +#[onelauncher_debug::debugger] +pub async fn import_atlauncher( + // The path to the base ATLauncher folder. + atlauncher_base_path: PathBuf, + // The instance folder in atlauncher_base_path + instance_folder: String, + // The path to the importing Cluster + cluster_path: ClusterPath, +) -> crate::Result<()> { + let atlauncher_instance_path = atlauncher_base_path + .join("instances") + .join(instance_folder.clone()); + let atinstance: String = + io::read_to_string(&atlauncher_instance_path.join("instance.json")).await?; + let atinstance: ATInstance = serde_json::from_str::(&atinstance)?; + + // Icon path should be {instance_folder}/instance.png if it exists, + // Another possibility is ATLauncher/configs/images/{safe_pack_name}.png (safe pack name is alphanumeric lowercase) + let icon_path_primary = atlauncher_instance_path.join("instance.png"); + let safe_pack_name = atinstance + .launcher + .pack + .replace(|c: char| !c.is_alphanumeric(), "") + .to_lowercase(); + let icon_path_secondary = atlauncher_base_path + .join("configs") + .join("images") + .join(safe_pack_name + ".png"); + let icon = match (icon_path_primary.exists(), icon_path_secondary.exists()) { + (true, _) => import::cache_icon(icon_path_primary).await?, + (_, true) => import::cache_icon(icon_path_secondary).await?, + _ => None, + }; + + // Create description from instance.cfg + let description = CreatePackDescription { + icon, + override_title: Some(atinstance.launcher.name.clone()), + package_id: None, + version_id: None, + existing_ingress: None, + cluster_path: cluster_path.clone(), + }; + + let backup_name = format!("ATLauncher-{}", instance_folder); + let minecraft_folder = atlauncher_instance_path; + + import_atlauncher_unmanaged( + cluster_path, + minecraft_folder, + backup_name, + description, + atinstance, + ) + .await?; + + Ok(()) +} + +async fn import_atlauncher_unmanaged( + cluster_path: ClusterPath, + minecraft_folder: PathBuf, + backup_name: String, + description: CreatePackDescription, + atinstance: ATInstance, +) -> crate::Result<()> { + let mod_loader = format!( + "\"{}\"", + atinstance.launcher.loader_version.r#type.to_lowercase() + ); + let mod_loader: Loader = serde_json::from_str::(&mod_loader)?; + + let game_version = atinstance.id; + let loader_version = if mod_loader != Loader::Vanilla { + crate::cluster::create::get_loader_version( + game_version.clone(), + mod_loader, + Some(atinstance.launcher.loader_version.version.clone()), + ) + .await? + } else { + None + }; + + crate::api::cluster::edit(&cluster_path, |cl| { + cl.meta.name = description + .override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + cl.stage = ClusterStage::PackDownloading; + cl.meta.package_data = Some(PackageData { + package_id: description.package_id.clone(), + version_id: description.version_id.clone(), + locked: Some(description.package_id.is_some() && description.version_id.is_some()), + }); + cl.meta.icon.clone_from(&description.icon); + cl.meta.mc_version.clone_from(&game_version); + cl.meta.loader_version.clone_from(&loader_version); + cl.meta.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + + // moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc) + let state = State::get().await?; + let ingress = copy_minecraft( + cluster_path.clone(), + minecraft_folder, + &state.io_semaphore, + None, + ) + .await?; + + if let Some(cluster_val) = crate::api::cluster::get(&cluster_path, None).await? { + crate::game::install_minecraft(&cluster_val, Some(ingress), false).await?; + { + let state = State::get().await?; + let mut watcher = state.watcher.write().await; + Cluster::watch(&cluster_val.get_full_path().await?, &mut watcher).await?; + } + State::sync().await?; + } + Ok(()) +} diff --git a/crates/core/src/api/package/import/curseforge.rs b/crates/core/src/api/package/import/curseforge.rs index 8b137891..f6962762 100644 --- a/crates/core/src/api/package/import/curseforge.rs +++ b/crates/core/src/api/package/import/curseforge.rs @@ -1 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use super::{cache_icon, copy_minecraft}; +use crate::prelude::{Cluster, ClusterPath, Loader}; +use crate::store::{ClusterStage, State}; +use crate::utils::http::{fetch, write_icon}; +use crate::utils::io; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftInstance { + pub name: Option, + pub base_mod_loader: Option, + pub profile_image_path: Option, + pub installed_modpack: Option, + /// Minecraft game version. Non-prioritized, use this if Vanilla + pub game_version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftInstanceModLoader { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InstalledModpack { + pub thumbnail_url: Option, +} + +pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool { + let minecraftinstance: String = + io::read_to_string(&instance_folder.join("minecraftinstance.json")) + .await + .unwrap_or("".to_string()); + let minecraftinstance: Result = + serde_json::from_str::(&minecraftinstance); + minecraftinstance.is_ok() +} + +pub async fn import_curseforge( + // The path to the CurseForge instance + curseforge_instance_folder: PathBuf, + // The path to the cluster + cluster_path: ClusterPath, +) -> crate::Result<()> { + let minecraft_instance: String = + io::read_to_string(&curseforge_instance_folder.join("minecraftinstance.json")).await?; + let minecraft_instance: MinecraftInstance = + serde_json::from_str::(&minecraft_instance)?; + let override_title: Option = minecraft_instance.name.clone(); + let backup_name = format!( + "Curseforge-{}", + curseforge_instance_folder + .file_name() + .map(|a| a.to_string_lossy().to_string()) + .unwrap_or("Unknown".to_string()) + ); + + let state = State::get().await?; + let mut icon = None; + + if let Some(icon_path) = minecraft_instance.profile_image_path.clone() { + icon = cache_icon(icon_path).await?; + } else if let Some(InstalledModpack { + thumbnail_url: Some(thumbnail_url), + }) = minecraft_instance.installed_modpack.clone() + { + let icon_bytes = fetch(&thumbnail_url, None, &state.fetch_semaphore).await?; + let filename = thumbnail_url.rsplit('/').last(); + if let Some(filename) = filename { + icon = Some( + write_icon( + filename, + &state.directories.caches_dir().await, + icon_bytes, + &state.io_semaphore, + ) + .await?, + ); + } + } + + if let Some(instance_mod_loader) = minecraft_instance.base_mod_loader { + let game_version = minecraft_instance.game_version; + let mut mod_loader = None; + let mut loader_version = None; + + match instance_mod_loader.name.split('-').collect::>()[..] { + ["forge", version] => { + mod_loader = Some(Loader::Forge); + loader_version = Some(version.to_string()); + } + ["fabric", version, _game_version] => { + mod_loader = Some(Loader::Fabric); + loader_version = Some(version.to_string()); + } + _ => {} + } + + let mod_loader = mod_loader.unwrap_or(Loader::Vanilla); + let loader_version = if mod_loader != Loader::Vanilla { + crate::cluster::create::get_loader_version( + game_version.clone(), + mod_loader, + loader_version, + ) + .await? + } else { + None + }; + + crate::api::cluster::edit(&cluster_path, |cl| { + cl.meta.name = override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + cl.stage = ClusterStage::PackDownloading; + cl.meta.icon.clone_from(&icon); + cl.meta.mc_version.clone_from(&game_version); + cl.meta.loader_version.clone_from(&loader_version); + cl.meta.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + } else { + crate::api::cluster::edit(&cluster_path, |cl| { + cl.meta.name = override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + cl.meta.icon.clone_from(&icon); + cl.meta + .mc_version + .clone_from(&minecraft_instance.game_version); + cl.meta.loader_version = None; + cl.meta.loader = Loader::Vanilla; + + async { Ok(()) } + }) + .await?; + } + + let state = State::get().await?; + let ingress = copy_minecraft( + cluster_path.clone(), + curseforge_instance_folder, + &state.io_semaphore, + None, + ) + .await?; + + if let Some(cluster_val) = crate::api::cluster::get(&cluster_path, None).await? { + crate::game::install_minecraft(&cluster_val, Some(ingress), false).await?; + { + let state = State::get().await?; + let mut watcher = state.watcher.write().await; + Cluster::watch(&cluster_val.get_full_path().await?, &mut watcher).await?; + } + State::sync().await?; + } + + Ok(()) +} diff --git a/crates/core/src/api/package/import/gdlauncher.rs b/crates/core/src/api/package/import/gdlauncher.rs index 8b137891..b1088d20 100644 --- a/crates/core/src/api/package/import/gdlauncher.rs +++ b/crates/core/src/api/package/import/gdlauncher.rs @@ -1 +1,107 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use super::{cache_icon, copy_minecraft}; +use crate::prelude::{Cluster, ClusterPath, Loader}; +use crate::store::{ClusterStage, State}; +use crate::utils::io; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GDLauncherConfig { + pub background: Option, + pub loader: GDLauncherLoader, + // pub mods: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GDLauncherLoader { + pub loader_type: Loader, + pub loader_version: Option, + pub mc_version: String, + pub source: Option, + pub source_name: Option, +} + +pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool { + let config: String = io::read_to_string(&instance_folder.join("config.json")) + .await + .unwrap_or("".to_string()); + let config: Result = + serde_json::from_str::(&config); + config.is_ok() +} + +pub async fn import_gdlauncher( + gdlauncher_instance_folder: PathBuf, + cluster_path: ClusterPath, +) -> crate::Result<()> { + let config: String = + io::read_to_string(&gdlauncher_instance_folder.join("config.json")).await?; + let config: GDLauncherConfig = serde_json::from_str::(&config)?; + let override_title: Option = config.loader.source_name.clone(); + let backup_name = format!( + "GDLauncher-{}", + gdlauncher_instance_folder + .file_name() + .map(|a| a.to_string_lossy().to_string()) + .unwrap_or("Unknown".to_string()) + ); + + let icon = config + .background + .clone() + .map(|b| gdlauncher_instance_folder.join(b)); + let icon = if let Some(icon) = icon { + cache_icon(icon).await? + } else { + None + }; + + let game_version = config.loader.mc_version; + let mod_loader = config.loader.loader_type; + let loader_version = config.loader.loader_version; + + let loader_version = if mod_loader != Loader::Vanilla { + crate::cluster::create::get_loader_version(game_version.clone(), mod_loader, loader_version) + .await? + } else { + None + }; + + crate::api::cluster::edit(&cluster_path, |cl| { + cl.meta.name = override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + cl.stage = ClusterStage::PackDownloading; + cl.meta.icon.clone_from(&icon); + cl.meta.mc_version.clone_from(&game_version); + cl.meta.loader_version.clone_from(&loader_version); + cl.meta.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + + let state = State::get().await?; + let ingress = copy_minecraft( + cluster_path.clone(), + gdlauncher_instance_folder, + &state.io_semaphore, + None, + ) + .await?; + + if let Some(cluster_val) = crate::api::cluster::get(&cluster_path, None).await? { + crate::game::install_minecraft(&cluster_val, Some(ingress), false).await?; + { + let state = State::get().await?; + let mut watcher = state.watcher.write().await; + Cluster::watch(&cluster_val.get_full_path().await?, &mut watcher).await?; + } + State::sync().await?; + } + + Ok(()) +} diff --git a/crates/core/src/api/package/import/mod.rs b/crates/core/src/api/package/import/mod.rs index c0342ae4..8150cf59 100644 --- a/crates/core/src/api/package/import/mod.rs +++ b/crates/core/src/api/package/import/mod.rs @@ -4,6 +4,7 @@ use crate::prelude::ClusterPath; use crate::proxy::send::{init_or_edit_ingress, send_ingress}; use crate::proxy::IngressId; +use crate::store::Clusters; use crate::utils::http::{self, IoSemaphore}; use crate::utils::io::{self, IOError}; use serde::{Deserialize, Serialize}; @@ -52,10 +53,10 @@ impl fmt::Display for ImportType { ImportType::ATLauncher => write!(f, "ATLauncher"), ImportType::Curseforge => write!(f, "Curseforge"), ImportType::GDLauncher => write!(f, "GDLauncher"), - ImportType::Modrinth => write!(f, "Modrinth"), + ImportType::Modrinth => write!(f, "Modrinth App"), ImportType::PrismLauncher => write!(f, "PrismLauncher"), ImportType::TLauncher => write!(f, "TLauncher"), - ImportType::FTBLauncher => write!(f, "FTB"), + ImportType::FTBLauncher => write!(f, "Feed The Beast"), ImportType::Technic => write!(f, "Technic"), ImportType::Unknown => write!(f, "Unknown"), } @@ -63,22 +64,21 @@ impl fmt::Display for ImportType { } pub async fn import_instances(import: ImportType, path: PathBuf) -> crate::Result> { - // TODO(pauline)(instance_import): Fix MultiMC based launcher import let instances_path = match import { ImportType::GDLauncher | ImportType::ATLauncher => "instances".to_string(), ImportType::Curseforge => "Instances".to_string(), - // ImportType::MultiMC => multibased::get_instances_path(path.clone().join("multimc.cfg")) - // .await - // .unwrap_or_else(|| "instances".to_string()), - // ImportType::PrismLauncher => { - // multibased::get_instances_path(path.clone().join("prismlauncher.cfg")) - // .await - // .unwrap_or_else(|| "instances".to_string()) - // } + ImportType::MultiMC => multibased::get_instances_subpath(path.clone().join("multimc.cfg")) + .await + .unwrap_or_else(|| "instances".to_string()), + ImportType::PrismLauncher => { + multibased::get_instances_subpath(path.clone().join("prismlauncher.cfg")) + .await + .unwrap_or_else(|| "instances".to_string()) + } ImportType::Modrinth => "profiles".to_string(), ImportType::TLauncher => "instances".to_string(), - ImportType::Technic => "instances".to_string(), ImportType::FTBLauncher => "instances".to_string(), + ImportType::Technic => "instances".to_string(), _ => return Err(anyhow::anyhow!("launcher type unknown, cant import").into()), }; @@ -115,80 +115,54 @@ pub async fn import_instance( ) -> crate::Result<()> { tracing::debug!("importing instance from {instance_path}"); - // TODO(pauline)(instance_import): Finish this - // let result = match import { - // ImportType::MultiMC | ImportType::PrismLauncher => { - // multibased::import(path, instance_path, cluster_path.clone()).await - // } - // ImportType::ATLauncher => { - // atlauncher::import(path, instance_path, cluster_path.clone()).await - // } - // ImportType::GDLauncher => { - // gdlauncher::import( - // path.join("instances").join(instance_path), - // cluster_path.clone(), - // ) - // .await - // } - // ImportType::Curseforge => { - // curseforge::import( - // path.join("Instances").join(instance_path), - // cluster_path.clone(), - // ) - // .await - // } - // ImportType::Modrinth => { - // modrinth::import( - // path.join("profiles").join(instance_path), - // cluster_path.clone(), - // ) - // .await - // } - // ImportType::TLauncher => { - // tlauncher::import( - // path.join("instances").join(instance_path), - // cluster_path.clone(), - // ) - // .await - // } - // ImportType::Technic => { - // technic::import( - // path.join("instances").join(instance_path), - // cluster_path.clone(), - // ) - // .await - // } - // ImportType::FTBLauncher => { - // ftb::import( - // path.join("instances").join(instance_path), - // cluster_path.clone(), - // ) - // .await - // } - // ImportType::Unknown => { - // return Err(anyhow::anyhow!("unknown launcher type").into()); - // } - // }; + let result = match import { + ImportType::MultiMC | ImportType::PrismLauncher => { + multibased::import_mmc(path, instance_path, cluster_path.clone()).await + } + ImportType::ATLauncher => { + atlauncher::import_atlauncher(path, instance_path, cluster_path.clone()).await + } + ImportType::GDLauncher => { + gdlauncher::import_gdlauncher( + path.join("instances").join(instance_path), + cluster_path.clone(), + ) + .await + } + ImportType::Curseforge => { + curseforge::import_curseforge( + path.join("Instances").join(instance_path), + cluster_path.clone(), + ) + .await + } + // ImportType::Modrinth => modrinth::import_modrinth(path.join("profiles").join(instance_path), cluster_path.clone()).await, + // ImportType::TLauncher => tlauncher::import_tlauncher(path.join("instances").join(instance_path), cluster_path.clone()).await, + // ImportType::Technic => technic::import_technic(path.join("instances").join(instance_path), cluster_path.clone()).await, + // ImportType::FTBLauncher => ftb::import_ftb(path.join("instances").join(instance_path), cluster_path.clone()).await, + ImportType::Unknown => Err(anyhow::anyhow!("unknown launcher type").into()), + _ => todo!(), + }; - // match result { - // Ok(_) => {} - // Err(e) => { - // tracing::warn!("failed to import modpack: {:?}", e); - // let _ = crate::api::cluster::remove(&cluster_path).await; - // return Err(e); - // } - // } + match result { + Ok(_) => {} + Err(e) => { + tracing::warn!("failed to import modpack: {:?}", e); + let _ = crate::api::cluster::remove(&cluster_path).await; + return Err(e); + } + } - // tokio::task::spawn(Clusters::update_versions()); + tokio::task::spawn(Clusters::update_versions()); - // tracing::debug!("completed import of instance."); + tracing::debug!("completed import of instance."); Ok(()) } /// returns the default path for a given [`ImportType`]. pub fn default_launcher_path(r#type: ImportType) -> Option { let path = match r#type { - ImportType::MultiMC => None, + ImportType::MultiMC => None, // MultiMC data is in it's application directory ImportType::PrismLauncher => Some(dirs::data_dir()?.join("PrismLauncher")), ImportType::ATLauncher => Some(dirs::data_dir()?.join("ATLauncher")), ImportType::GDLauncher => Some(dirs::data_dir()?.join("gdlauncher_next")), @@ -208,26 +182,26 @@ pub fn default_launcher_path(r#type: ImportType) -> Option { } } -/// checks if a [`PathBuf`] is a valid instance for a given [`ImportType`] +/// Checks if a [`PathBuf`] is a valid instance for a given [`ImportType`] #[tracing::instrument] #[onelauncher_debug::debugger] pub async fn is_valid_instance(instance_path: PathBuf, r#type: ImportType) -> bool { - false - // TODO(pauline)(instance_import): Finish this - // match r#type { - // ImportType::MultiMC | ImportType::PrismLauncher => multibased::is_valid(insance_path).await, - // ImportType::ATLauncher => atlauncher::is_valid(instance_path).await, - // ImportType::GDLauncher => gdlauncher::is_valid(instance_path).await, - // ImportType::Curseforge => curseforge::is_valid(instance_path).await, - // ImportType::Modrinth => modrinth::is_valid(instance_path).await, - // ImportType::TLauncher => tlauncher::is_valid(instance_path).await, - // ImportType::FTBLauncher => ftb::is_valid(instance_path).await, - // ImportType::Technic => technic::is_valid(instance_path).await, - // ImportType::Unknown => false, - // } + match r#type { + ImportType::MultiMC | ImportType::PrismLauncher => { + multibased::is_valid_multibased(instance_path).await + } + ImportType::ATLauncher => atlauncher::is_valid_atlauncher(instance_path).await, + ImportType::GDLauncher => gdlauncher::is_valid_gdlauncher(instance_path).await, + ImportType::Curseforge => curseforge::is_valid_curseforge(instance_path).await, + // ImportType::Modrinth => modrinth::is_valid_modrinth(instance_path).await, + // ImportType::TLauncher => tlauncher::is_valid_tlauncher(instance_path).await, + // ImportType::FTBLauncher => ftb::is_valid_ftb(instance_path).await, + // ImportType::Technic => technic::is_valid_technic(instance_path).await, + ImportType::Unknown => false, + _ => todo!(), + } } -/// caches an image file #[tracing::instrument] #[onelauncher_debug::debugger] pub async fn cache_icon(icon_path: PathBuf) -> crate::Result> { @@ -251,7 +225,7 @@ pub async fn copy_minecraft( io_semaphore: &IoSemaphore, old_ingress: Option, ) -> crate::Result { - let _cluster_full = cluster_path.full_path().await?; + let cluster_path_full = cluster_path.full_path().await?; let subfiles = sub(&minecraft_path).await?; let total_subfiles = subfiles.len() as u64; let ingress = init_or_edit_ingress( @@ -269,7 +243,7 @@ pub async fn copy_minecraft( let child = sub .strip_prefix(&minecraft_path) .map_err(|_| anyhow::anyhow!("invalid .minecraft file {}", &sub.display()))?; - let child = cluster_path.0.join(child); + let child = cluster_path_full.join(child); tokio::time::sleep(std::time::Duration::from_millis(1)).await; http::copy(&sub, &child, io_semaphore).await?; send_ingress(&ingress, 1.0, None).await?; diff --git a/crates/core/src/api/package/import/modrinth.rs b/crates/core/src/api/package/import/modrinth.rs index 8b137891..278bfa68 100644 --- a/crates/core/src/api/package/import/modrinth.rs +++ b/crates/core/src/api/package/import/modrinth.rs @@ -1 +1,24 @@ +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use crate::prelude::ClusterPath; +use crate::utils::io; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ModrinthProfile {} + +pub async fn is_valid_modrinth(instance_folder: PathBuf) -> bool { + let config: String = io::read_to_string(&instance_folder.join("profile.json")) + .await + .unwrap_or("".to_string()); + let config: Result = + serde_json::from_str::(&config); + config.is_ok() +} + +pub async fn import_modrinth( + modrinth_instance_folder: PathBuf, + cluster_path: ClusterPath, +) -> crate::Result<()> { + Ok(()) +} diff --git a/crates/core/src/api/package/import/multibased.rs b/crates/core/src/api/package/import/multibased.rs index 8b137891..4f6a8c65 100644 --- a/crates/core/src/api/package/import/multibased.rs +++ b/crates/core/src/api/package/import/multibased.rs @@ -1 +1,341 @@ +use serde::{de, Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use crate::package::from::{self, CreatePackDescription, PackDependency}; +use crate::package::import::{self, copy_minecraft}; +use crate::prelude::{Cluster, ClusterPath, State}; +use crate::utils::io; + +// instance.cfg: https://github.com/PrismLauncher/PrismLauncher/blob/develop/launcher/minecraft/MinecraftInstance.cpp +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +#[serde(untagged)] +enum MMCInstanceEnum { + General(MMCInstanceGeneral), + Instance(MMCInstance), +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct MMCInstanceGeneral { + pub general: MMCInstance, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct MMCInstance { + pub java_path: Option, + pub jvm_args: Option, + + #[serde(default)] + #[serde(deserialize_with = "deserialize_optional_bool")] + pub managed_pack: Option, + + #[serde(rename = "ManagedPackID")] + pub managed_pack_id: Option, + pub managed_pack_type: Option, + #[serde(rename = "ManagedPackVersionID")] + pub managed_pack_version_id: Option, + pub managed_pack_version_name: Option, + + #[serde(rename = "iconKey")] + pub icon_key: Option, + #[serde(rename = "name")] + pub name: Option, +} + +/// Deserializes INI String Boolean values into Rust Boolean values for [`serde-ini`]. +fn deserialize_optional_bool<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let s = Option::::deserialize(deserializer)?; + match s { + Some(string) => match string.as_str() { + "true" => Ok(Some(true)), + "false" => Ok(Some(false)), + _ => Err(de::Error::custom("expected 'true' or 'false'")), + }, + None => Ok(None), + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum MMCManagedPackType { + Modrinth, + Flame, + ATLauncher, + #[serde(other)] + Unknown, +} + +// mmc-pack.json +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MMCPack { + components: Vec, + format_version: u32, +} + +// https://github.com/PrismLauncher/PrismLauncher/blob/develop/launcher/minecraft/Component.h +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MMCComponent { + pub uid: String, + + #[serde(default)] + pub version: Option, + #[serde(default)] + pub dependency_only: bool, + + #[serde(default)] + pub important: bool, + #[serde(default)] + pub disabled: bool, + + pub cached_name: Option, + pub cached_version: Option, + + #[serde(default)] + pub cached_requires: Vec, + #[serde(default)] + pub cached_conflicts: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MMCComponentRequirement { + pub uid: String, + pub equals_version: Option, + pub suggests: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +#[serde(untagged)] +enum MMCLauncherEnum { + General(MMCLauncherGeneral), + Instance(MMCLauncher), +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct MMCLauncherGeneral { + pub general: MMCLauncher, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct MMCLauncher { + instance_dir: String, +} + +#[tracing::instrument] +pub async fn is_valid_multibased(instance_folder: PathBuf) -> bool { + let instance_cfg = instance_folder.join("instance.cfg"); + let mmc_pack = instance_folder.join("mmc-pack.json"); + + let mmc_pack = match io::read_to_string(&mmc_pack).await { + Ok(mmc_pack) => mmc_pack, + Err(_) => return false, + }; + + load_instance_cfg(&instance_cfg).await.is_ok() + && serde_json::from_str::(&mmc_pack).is_ok() +} + +#[tracing::instrument] +pub async fn get_instances_subpath(config: PathBuf) -> Option { + let launcher = io::read_to_string(&config).await.ok()?; + let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?; + match launcher { + MMCLauncherEnum::General(p) => Some(p.general.instance_dir), + MMCLauncherEnum::Instance(p) => Some(p.instance_dir), + } +} + +async fn load_instance_cfg(file_path: &Path) -> crate::Result { + let instance_cfg: String = io::read_to_string(file_path).await?; + let instance_cfg_enum: MMCInstanceEnum = serde_ini::from_str::(&instance_cfg)?; + match instance_cfg_enum { + MMCInstanceEnum::General(instance_cfg) => Ok(instance_cfg.general), + MMCInstanceEnum::Instance(instance_cfg) => Ok(instance_cfg), + } +} + +#[tracing::instrument] +#[onelauncher_debug::debugger] +pub async fn import_mmc( + mmc_base_path: PathBuf, + instance_folder: String, + cluster_path: ClusterPath, +) -> crate::Result<()> { + let mmc_instance_path = mmc_base_path + .join("instances") + .join(instance_folder.clone()); + let mmc_pack = io::read_to_string(&mmc_instance_path.join("mmc-pack.json")).await?; + let mmc_pack: MMCPack = serde_json::from_str::(&mmc_pack)?; + let instance_cfg = load_instance_cfg(&mmc_instance_path.join("instance.cfg")).await?; + + let icon = if let Some(icon_key) = instance_cfg.icon_key { + let icon_path = mmc_base_path.join("icons").join(icon_key); + import::cache_icon(icon_path).await? + } else { + None + }; + + let description = CreatePackDescription { + icon, + override_title: instance_cfg.name, + package_id: instance_cfg.managed_pack_id, + version_id: instance_cfg.managed_pack_version_id, + existing_ingress: None, + cluster_path: cluster_path.clone(), + }; + + let backup_name = "Imported Modpack".to_string(); + + if instance_cfg.managed_pack.unwrap_or(false) { + match instance_cfg.managed_pack_type { + Some(MMCManagedPackType::Modrinth) => { + let backup_name = "Imported Modrinth Modpack".to_string(); + let minecraft_folder = mmc_base_path + .join("instances") + .join(instance_folder) + .join(".minecraft"); + import_mmc_unmanaged( + cluster_path, + minecraft_folder, + backup_name, + description, + mmc_pack, + ) + .await?; + } + Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => { + // Same as unmanaged, but with 'minecraft' folder instead of '.minecraft' + let minecraft_folder = mmc_base_path + .join("instances") + .join(instance_folder) + .join("minecraft"); + import_mmc_unmanaged( + cluster_path, + minecraft_folder, + backup_name, + description, + mmc_pack, + ) + .await?; + } + Some(_) => { + let backup_name = "ImportedModpack".to_string(); + let minecraft_folder = mmc_base_path + .join("instances") + .join(instance_folder) + .join(".minecraft"); + import_mmc_unmanaged( + cluster_path, + minecraft_folder, + backup_name, + description, + mmc_pack, + ) + .await?; + } + _ => { + return Err(anyhow::anyhow!( + "Instance is managed, but managed pack type not specified in instance.cfg" + ) + .into()) + } + } + } else { + let backup_name = "Imported Modpack".to_string(); + let minecraft_folder = mmc_base_path + .join("instances") + .join(instance_folder) + .join(".minecraft"); + import_mmc_unmanaged( + cluster_path, + minecraft_folder, + backup_name, + description, + mmc_pack, + ) + .await?; + } + + Ok(()) +} + +async fn import_mmc_unmanaged( + cluster_path: ClusterPath, + minecraft_folder: PathBuf, + backup_name: String, + description: CreatePackDescription, + mmc_pack: MMCPack, +) -> crate::Result<()> { + let dependencies = mmc_pack + .components + .iter() + .filter_map(|component| { + if component.uid.starts_with("net.fabricmc.fabric-loader") { + return Some(( + PackDependency::FabricLoader, + component.version.clone().unwrap_or_default(), + )); + } + if component.uid.starts_with("net.minecraftforge") { + return Some(( + PackDependency::Forge, + component.version.clone().unwrap_or_default(), + )); + } + if component.uid.starts_with("org.quiltmc.quilt-loader") { + return Some(( + PackDependency::QuiltLoader, + component.version.clone().unwrap_or_default(), + )); + } + if component.uid.starts_with("net.minecraft") { + return Some(( + PackDependency::Minecraft, + component.version.clone().unwrap_or_default(), + )); + } + + None + }) + .collect(); + + from::set_cluster_information( + cluster_path.clone(), + &description, + &backup_name, + &dependencies, + false, + ) + .await?; + + let state = State::get().await?; + let ingress = copy_minecraft( + cluster_path.clone(), + minecraft_folder, + &state.io_semaphore, + None, + ) + .await?; + + if let Some(cluster_val) = crate::api::cluster::get(&cluster_path, None).await? { + crate::game::install_minecraft(&cluster_val, Some(ingress), false).await?; + { + let state = State::get().await?; + let mut watcher = state.watcher.write().await; + Cluster::watch(&cluster_val.get_full_path().await?, &mut watcher).await?; + } + State::sync().await?; + } + + Ok(()) +} diff --git a/crates/core/src/api/package/mrpack.rs b/crates/core/src/api/package/mrpack.rs index 4ad96dfe..bdf24df2 100644 --- a/crates/core/src/api/package/mrpack.rs +++ b/crates/core/src/api/package/mrpack.rs @@ -1 +1,348 @@ //! Import and Export `.mrpack` mod packs. + +use crate::package::from::{set_cluster_information, EnvType, PackFile, PackFileHash}; +use crate::prelude::{ClusterPath, ManagedVersion}; +use crate::proxy::ingress_try_for_each; +use crate::proxy::send::{init_or_edit_ingress, send_ingress}; +use crate::store::{ClusterStage, Clusters, PackageMetadata, PackageSide}; +use crate::utils::http::{fetch_from_mirrors, fetch_json, write}; +use crate::utils::io; +use crate::{cluster, IngressType, State}; +use async_zip::base::read::seek::ZipFileReader; +use reqwest::Method; +use serde_json::json; + +use std::collections::HashMap; +use std::io::Cursor; +use std::path::{Component, PathBuf}; + +use super::from::{ + generate_pack_from_file, generate_pack_from_version_id, CreatePack, CreatePackLocation, + PackFormat, MODRINTH_API_URL, +}; + +/// Install a pack +/// Wrapper around [`install_pack_files`] that generates a pack creation description, and +/// attempts to install the pack files. If it fails, it will remove the cluster (fail safely) +/// Install a modpack from a mrpack file (a modrinth .zip format) +#[onelauncher_debug::debugger] +pub async fn install_zipped_mrpack( + location: CreatePackLocation, + cluster_path: ClusterPath, +) -> crate::Result { + let create_pack: CreatePack = match location { + CreatePackLocation::FromModrinth { + package_id, + version_id, + title, + icon_url, + } => { + generate_pack_from_version_id( + package_id, + version_id, + title, + icon_url, + cluster_path.clone(), + None, + ) + .await? + } + CreatePackLocation::FromFile { path } => { + generate_pack_from_file(path, cluster_path.clone()).await? + } + }; + + let result = install_zipped_mrpack_files(create_pack, false).await; + tokio::task::spawn(Clusters::update_versions()); + + match result { + Ok(cluster) => Ok(cluster), + Err(err) => { + let _ = crate::api::cluster::remove(&cluster_path).await; + + Err(err) + } + } +} + +/// Install all pack files from a description. Does not remove the cluster if it fails +#[onelauncher_debug::debugger] +pub async fn install_zipped_mrpack_files( + create_pack: CreatePack, + ignore_lock: bool, +) -> crate::Result { + let state = &State::get().await?; + let file = create_pack.file; + let description = create_pack.description.clone(); // make a copy for cluster::edit + let icon = create_pack.description.icon; + let package_id = create_pack.description.package_id; + let version_id = create_pack.description.version_id; + let existing_ingress = create_pack.description.existing_ingress; + let cluster_path = create_pack.description.cluster_path; + let icon_exists = icon.is_some(); + let reader: Cursor<&bytes::Bytes> = Cursor::new(&file); + + let mut zip_reader = ZipFileReader::with_tokio(reader).await?; + + let zip_index_option = zip_reader + .file() + .entries() + .iter() + .position(|f| f.filename().as_str().unwrap_or_default() == "modrinth.index.json"); + if let Some(zip_index) = zip_index_option { + let mut manifest = String::new(); + let mut reader = zip_reader.reader_with_entry(zip_index).await?; + reader.read_to_string_checked(&mut manifest).await?; + let pack: PackFormat = serde_json::from_str(&manifest)?; + + if &*pack.game != "minecraft" { + return Err(anyhow::anyhow!("pack doesn't support minecraft").into()); + } + + set_cluster_information( + cluster_path.clone(), + &description, + &pack.name, + &pack.dependencies, + ignore_lock, + ) + .await?; + + let cluster_path = cluster_path.clone(); + let ingress = init_or_edit_ingress( + existing_ingress, + IngressType::DownloadPackage { + cluster_path: cluster_path.full_path().await?.clone(), + package_name: pack.name.clone(), + icon, + package_id, + package_version: version_id, + }, + 100.0, + "downloading modpack", + ) + .await?; + + let num_files = pack.files.len(); + use futures::StreamExt; + ingress_try_for_each( + futures::stream::iter(pack.files.into_iter()).map(Ok::), + None, + Some(&ingress), + 70.0, + num_files, + None, + |pack| { + let cluster_path = cluster_path.clone(); + async move { + // TODO: prompt user for optional files in a modpack + if let Some(env) = pack.env { + if env + .get(&EnvType::Client) + .map(|x| x == &PackageSide::Unsupported) + .unwrap_or(false) + { + return Ok(()); + } + } + + let file = fetch_from_mirrors( + &pack.downloads.iter().map(|x| &**x).collect::>(), + pack.hashes.get(&PackFileHash::Sha1).map(|x| &**x), + &state.fetch_semaphore, + ) + .await?; + + let package_path = pack.path.to_string(); + let path = std::path::Path::new(&package_path).components().next(); + if let Some(path) = path { + match path { + Component::CurDir | Component::Normal(_) => { + let path = cluster_path.full_path().await?.join(&package_path); + write(&path, &file, &state.io_semaphore).await?; + } + _ => {} + }; + } + Ok(()) + } + }, + ) + .await?; + + send_ingress(&ingress, 0.0, Some("extracting overrides")).await?; + + let mut total_len = 0; + + for index in 0..zip_reader.file().entries().len() { + let file = zip_reader.file().entries().get(index).unwrap(); + let filename = file.filename().as_str().unwrap_or_default(); + + if (filename.starts_with("overrides") || filename.starts_with("client-overrides")) + && !filename.ends_with('/') + { + total_len += 1; + } + } + + for index in 0..zip_reader.file().entries().len() { + let file = zip_reader.file().entries().get(index).unwrap(); + + let filename = file.filename().as_str().unwrap_or_default(); + + let file_path = PathBuf::from(filename); + if (filename.starts_with("overrides") || filename.starts_with("client-overrides")) + && !filename.ends_with('/') + { + let mut content = Vec::new(); + let mut reader = zip_reader.reader_with_entry(index).await?; + reader.read_to_end_checked(&mut content).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() { + write( + &cluster_path.full_path().await?.join(new_path), + &content, + &state.io_semaphore, + ) + .await?; + } + + send_ingress( + &ingress, + 30.0 / total_len as f64, + Some(&format!("extracting override {}/{}", index, total_len)), + ) + .await?; + } + } + + let potential_icon = cluster_path.full_path().await?.join("icon.png"); + if !icon_exists && potential_icon.exists() { + cluster::edit_icon(&cluster_path, Some(&potential_icon)).await?; + } + + if let Some(cluster) = cluster::get(&cluster_path, None).await? { + crate::game::install_minecraft(&cluster, Some(ingress), false).await?; + + State::sync().await?; + } + + Ok::(cluster_path.clone()) + } else { + Err(anyhow::anyhow!("No pack manifest found in mrpack").into()) + } +} + +#[tracing::instrument(skip(mrpack_file))] +#[onelauncher_debug::debugger] +pub async fn remove_all_related_files( + cluster_path: ClusterPath, + mrpack_file: bytes::Bytes, +) -> crate::Result<()> { + let reader: Cursor<&bytes::Bytes> = Cursor::new(&mrpack_file); + + let mut zip_reader = ZipFileReader::with_tokio(reader).await?; + + let zip_index_option = zip_reader + .file() + .entries() + .iter() + .position(|f| f.filename().as_str().unwrap_or_default() == "modrinth.index.json"); + if let Some(zip_index) = zip_index_option { + let mut manifest = String::new(); + + let mut reader = zip_reader.reader_with_entry(zip_index).await?; + reader.read_to_string_checked(&mut manifest).await?; + + let pack: PackFormat = serde_json::from_str(&manifest)?; + + if &*pack.game != "minecraft" { + return Err(anyhow::anyhow!("package doesn't support Minecraft").into()); + } + + crate::api::cluster::edit(&cluster_path, |cl| { + cl.stage = ClusterStage::PackDownloading; + async { Ok(()) } + }) + .await?; + + let state = State::get().await?; + let all_hashes = pack + .files + .iter() + .filter_map(|f| Some(f.hashes.get(&PackFileHash::Sha512)?.clone())) + .collect::>(); + + let files_url = format!("{}version_files", MODRINTH_API_URL); + + let hash_packages = fetch_json::>( + Method::POST, + &files_url, + None, + Some(json!({ + "hashes": all_hashes, + "algorithm": "sha512", + })), + &state.fetch_semaphore, + ) + .await?; + let to_remove = hash_packages + .into_values() + .map(|p| p.package_id) + .collect::>(); + let cluster = cluster::get(&cluster_path, None).await?.ok_or_else(|| { + crate::ErrorKind::AnyhowError(anyhow::anyhow!( + "{} is an unmanaged cluster!", + cluster_path.to_string() + )) + })?; + for (package_id, package) in &cluster.packages { + if let PackageMetadata::Managed { package, .. } = &package.meta { + if to_remove.contains(&package.id) { + let path = cluster.get_full_path().await?.join(package_id.0.clone()); + if path.exists() { + io::remove_file(&path).await?; + } + } + } + } + + for file in pack.files { + let path: PathBuf = cluster_path.full_path().await?.join(file.path.to_string()); + if path.exists() { + io::remove_file(&path).await?; + } + } + + for index in 0..zip_reader.file().entries().len() { + let file = zip_reader.file().entries().get(index).unwrap(); + let filename = file.filename().as_str().unwrap_or_default(); + let file_path = PathBuf::from(filename); + if (filename.starts_with("overrides") || filename.starts_with("client-overrides")) + && !filename.ends_with('/') + { + let mut new_path = PathBuf::new(); + let components = file_path.components().skip(1); + + for component in components { + new_path.push(component); + } + + let existing_file = cluster_path.full_path().await?.join(&new_path); + if existing_file.exists() { + io::remove_file(&existing_file).await?; + } + } + } + Ok(()) + } else { + Err(anyhow::anyhow!("No pack manifest found in mrpack").into()) + } +} diff --git a/crates/core/src/api/proxy/mod.rs b/crates/core/src/api/proxy/mod.rs index 200428d0..1f2d0def 100644 --- a/crates/core/src/api/proxy/mod.rs +++ b/crates/core/src/api/proxy/mod.rs @@ -176,6 +176,12 @@ pub enum IngressType { package_id: Option, package_version: Option, }, + DownloadPack { + cluster_path: PathBuf, + package_name: String, + icon: Option, + package_version: String, + }, } #[derive(Serialize, Clone)] diff --git a/crates/core/src/store/minecraft.rs b/crates/core/src/store/minecraft.rs index 94fd7410..fe016206 100644 --- a/crates/core/src/store/minecraft.rs +++ b/crates/core/src/store/minecraft.rs @@ -158,7 +158,7 @@ impl MinecraftState { redirect_uri: redirect_uri.value.msa_oauth_redirect, }) } else { - Err(crate::ErrorKind::from(err).into()) + Err(err.into()) } } } diff --git a/crates/core/src/store/oneconfig.rs b/crates/core/src/store/oneconfig.rs index 48bff411..4e2ffc7f 100644 --- a/crates/core/src/store/oneconfig.rs +++ b/crates/core/src/store/oneconfig.rs @@ -30,4 +30,10 @@ impl OneConfig { .await?; Ok(Self { socket }) } + + pub async fn close(&mut self) -> crate::Result<()> { + self.socket.close(None).await?; + + Ok(()) + } } diff --git a/crates/core/src/utils/io.rs b/crates/core/src/utils/io.rs index 8e8515e3..febcc11e 100644 --- a/crates/core/src/utils/io.rs +++ b/crates/core/src/utils/io.rs @@ -193,8 +193,8 @@ pub fn extract_archive(archive: &PathBuf, dest: &PathBuf) -> crate::Result<()> { }; match ext { - "zip" => extract_zip(archive, dest).map_err(|err| crate::ErrorKind::IOError(err).into()), - "gz" => extract_tar_gz(archive, dest).map_err(|err| crate::ErrorKind::IOError(err).into()), + "zip" => Ok(extract_zip(archive, dest)?), + "gz" => Ok(extract_tar_gz(archive, dest)?), _ => Err(anyhow!("unsupported file extension {:?}", ext).into()), } }