diff --git a/.vscode/settings.json b/.vscode/settings.json index 526b84890..2d7302c8f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -56,4 +56,5 @@ "rust-analyzer.linkedProjects": [ "./theseus/Cargo.toml" ], + "rust-analyzer.showUnlinkedFileNotification": false, } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 606ed0435..91c2ce933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,6 +3528,12 @@ dependencies = [ "winreg 0.10.1", ] +[[package]] +name = "result" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194d8e591e405d1eecf28819740abed6d719d1a2db87fc0bcdedee9a26d55560" + [[package]] name = "rfd" version = "0.10.0" @@ -3827,6 +3833,17 @@ dependencies = [ "syn 2.0.22", ] +[[package]] +name = "serde_ini" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb236687e2bb073a7521c021949be944641e671b8505a94069ca37b656c81139" +dependencies = [ + "result", + "serde", + "void", +] + [[package]] name = "serde_json" version = "1.0.99" @@ -4599,6 +4616,7 @@ dependencies = [ "regex", "reqwest", "serde", + "serde_ini", "serde_json", "sha1 0.6.1", "sha2 0.9.9", @@ -5237,6 +5255,12 @@ version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dcc60c0624df774c82a0ef104151231d37da4962957d691c011c852b2473314" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index 08b043119..a839d531d 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -12,6 +12,7 @@ theseus_macros = { path = "../theseus_macros" } bytes = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_ini = "0.2.0" toml = "0.7.3" sha1 = { version = "0.6.1", features = ["std"]} sha2 = "0.9.9" diff --git a/theseus/src/api/pack/import/atlauncher.rs b/theseus/src/api/pack/import/atlauncher.rs new file mode 100644 index 000000000..be2508477 --- /dev/null +++ b/theseus/src/api/pack/import/atlauncher.rs @@ -0,0 +1,244 @@ +use std::{collections::HashMap, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use tokio::fs; + +use crate::{ + event::LoadingBarId, + pack::{ + self, + import::{self, copy_dotminecraft}, + install_from::CreatePackDescription, + }, + prelude::{ModLoader, ProfilePathId}, + state::{LinkedData, ProfileInstallStage}, + util::io, + State, +}; + +#[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, // ie: 1.6 + pub loader_version: ATLauncherLoaderVersion, + + pub modrinth_project: Option, + pub modrinth_version: Option, + pub modrinth_manifest: Option, + + pub mods: Vec, +} + +#[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 ATLauncherModrinthProject { + pub id: String, + pub slug: String, + pub project_type: String, + pub team: String, + pub title: String, + pub description: String, + pub body: String, + pub client_side: Option, + pub server_side: Option, + pub categories: Vec, + pub icon_url: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ATLauncherModrinthVersion { + pub id: String, + pub project_id: String, + pub name: String, + pub version_number: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ATLauncherModrinthVersionFile { + 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 ATLauncherModrinthVersionDependency { + 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 modrinth_project: Option, + pub modrinth_version: Option, +} + +// Check if folder has a instance.json that parses +pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool { + let instance: String = + fs::read_to_string(&instance_folder.join("instance.json")) + .await + .unwrap_or("".to_string()); + let instance: Result = + serde_json::from_str::(&instance); + instance.is_ok() +} + +#[tracing::instrument] +#[theseus_macros::debug_pin] +pub async fn import_atlauncher( + atlauncher_base_path: PathBuf, // path to base atlauncher folder + instance_folder: String, // instance folder in atlauncher_base_path + profile_path: ProfilePathId, // path to profile +) -> crate::Result<()> { + let atlauncher_instance_path = atlauncher_base_path + .join("instances") + .join(instance_folder.clone()); + + // Load instance.json + 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, + // Second 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::recache_icon(icon_path_primary).await?, + (_, true) => import::recache_icon(icon_path_secondary).await?, + _ => None, + }; + + // Create description from instance.cfg + let description = CreatePackDescription { + icon, + override_title: Some(atinstance.launcher.name.clone()), + project_id: None, + version_id: None, + existing_loading_bar: None, + profile_path: profile_path.clone(), + }; + + let backup_name = format!("ATLauncher-{}", instance_folder); + let minecraft_folder = atlauncher_instance_path; + + import_atlauncher_unmanaged( + profile_path, + minecraft_folder, + backup_name, + description, + atinstance, + None, + ) + .await?; + Ok(()) +} + +async fn import_atlauncher_unmanaged( + profile_path: ProfilePathId, + minecraft_folder: PathBuf, + backup_name: String, + description: CreatePackDescription, + atinstance: ATInstance, + existing_loading_bar: Option, +) -> crate::Result<()> { + let mod_loader = format!( + "\"{}\"", + atinstance.launcher.loader_version.r#type.to_lowercase() + ); + let mod_loader: ModLoader = serde_json::from_str::(&mod_loader) + .map_err(|_| { + crate::ErrorKind::InputError(format!( + "Could not parse mod loader type: {}", + mod_loader + )) + })?; + + let game_version = atinstance.id; + + let loader_version = if mod_loader != ModLoader::Vanilla { + crate::profile_create::get_loader_version_from_loader( + game_version.clone(), + mod_loader, + Some(atinstance.launcher.loader_version.version.clone()), + ) + .await? + } else { + None + }; + + // Set profile data to created default profile + crate::api::profile::edit(&profile_path, |prof| { + prof.metadata.name = description + .override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + prof.install_stage = ProfileInstallStage::PackInstalling; + prof.metadata.linked_data = Some(LinkedData { + project_id: description.project_id.clone(), + version_id: description.version_id.clone(), + }); + prof.metadata.icon = description.icon.clone(); + prof.metadata.game_version = game_version.clone(); + prof.metadata.loader_version = loader_version.clone(); + prof.metadata.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + + // Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc) + copy_dotminecraft(profile_path.clone(), minecraft_folder).await?; + + if let Some(profile_val) = + crate::api::profile::get(&profile_path, None).await? + { + crate::launcher::install_minecraft(&profile_val, existing_loading_bar) + .await?; + + State::sync().await?; + } + Ok(()) +} diff --git a/theseus/src/api/pack/import/curseforge.rs b/theseus/src/api/pack/import/curseforge.rs new file mode 100644 index 000000000..27603e010 --- /dev/null +++ b/theseus/src/api/pack/import/curseforge.rs @@ -0,0 +1,161 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tokio::fs; + +use crate::{ + prelude::{ModLoader, ProfilePathId}, + state::ProfileInstallStage, + util::io, + State, +}; + +use super::copy_dotminecraft; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlameManifest { + pub manifest_version: u8, + pub name: String, + pub minecraft: FlameMinecraft, +} +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlameMinecraft { + pub version: String, + pub mod_loaders: Vec, +} +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlameModLoader { + pub id: String, + pub primary: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftInstance { + pub name: Option, + pub game_version: String, // Minecraft game version. Non-prioritized, use this if Vanilla +} + +// Check if folder has a minecraftinstance.json that parses +pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool { + let minecraftinstance: String = + fs::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( + curseforge_instance_folder: PathBuf, // instance's folder + profile_path: ProfilePathId, // path to profile +) -> crate::Result<()> { + // TODO: recache curseforge instance icon + let icon: Option = None; + + // Load minecraftinstance.json + 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()) + ); + + // Curseforge vanilla profile may not have a manifest.json, so we allow it to not exist + if curseforge_instance_folder.join("manifest.json").exists() { + // Load manifest.json + let cf_manifest: String = io::read_to_string( + &curseforge_instance_folder.join("manifest.json"), + ) + .await?; + + let cf_manifest: FlameManifest = + serde_json::from_str::(&cf_manifest)?; + + let game_version = cf_manifest.minecraft.version; + + // CF allows Forge, Fabric, and Vanilla + let mut mod_loader = None; + let mut loader_version = None; + for loader in cf_manifest.minecraft.mod_loaders { + match loader.id.split_once('-') { + Some(("forge", version)) => { + mod_loader = Some(ModLoader::Forge); + loader_version = Some(version.to_string()); + } + Some(("fabric", version)) => { + mod_loader = Some(ModLoader::Fabric); + loader_version = Some(version.to_string()); + } + _ => {} + } + } + let mod_loader = mod_loader.unwrap_or(ModLoader::Vanilla); + + let loader_version = if mod_loader != ModLoader::Vanilla { + crate::profile_create::get_loader_version_from_loader( + game_version.clone(), + mod_loader, + loader_version, + ) + .await? + } else { + None + }; + + // Set profile data to created default profile + crate::api::profile::edit(&profile_path, |prof| { + prof.metadata.name = override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + prof.install_stage = ProfileInstallStage::PackInstalling; + prof.metadata.icon = icon.clone(); + prof.metadata.game_version = game_version.clone(); + prof.metadata.loader_version = loader_version.clone(); + prof.metadata.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + } else { + // If no manifest is found, it's a vanilla profile + crate::api::profile::edit(&profile_path, |prof| { + prof.metadata.name = override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + prof.metadata.icon = icon.clone(); + prof.metadata.game_version = + minecraft_instance.game_version.clone(); + prof.metadata.loader_version = None; + prof.metadata.loader = ModLoader::Vanilla; + + async { Ok(()) } + }) + .await?; + } + + // Copy in contained folders as overrides + copy_dotminecraft(profile_path.clone(), curseforge_instance_folder).await?; + + if let Some(profile_val) = + crate::api::profile::get(&profile_path, None).await? + { + crate::launcher::install_minecraft(&profile_val, None).await?; + + State::sync().await?; + } + + Ok(()) +} diff --git a/theseus/src/api/pack/import/gdlauncher.rs b/theseus/src/api/pack/import/gdlauncher.rs new file mode 100644 index 000000000..6bf0f07cf --- /dev/null +++ b/theseus/src/api/pack/import/gdlauncher.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tokio::fs; + +use crate::{ + prelude::{ModLoader, ProfilePathId}, + state::ProfileInstallStage, + util::io, + State, +}; + +use super::{copy_dotminecraft, recache_icon}; + +#[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: ModLoader, + pub loader_version: Option, + pub mc_version: String, + pub source: Option, + pub source_name: Option, +} + +// Check if folder has a config.json that parses +pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool { + let config: String = + fs::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, // instance's folder + profile_path: ProfilePathId, // path to profile +) -> crate::Result<()> { + // Load config.json + 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()) + ); + + // Re-cache icon + let icon = config + .background + .clone() + .map(|b| gdlauncher_instance_folder.join(b)); + let icon = if let Some(icon) = icon { + recache_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 != ModLoader::Vanilla { + crate::profile_create::get_loader_version_from_loader( + game_version.clone(), + mod_loader, + loader_version, + ) + .await? + } else { + None + }; + + // Set profile data to created default profile + crate::api::profile::edit(&profile_path, |prof| { + prof.metadata.name = override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + prof.install_stage = ProfileInstallStage::PackInstalling; + prof.metadata.icon = icon.clone(); + prof.metadata.game_version = game_version.clone(); + prof.metadata.loader_version = loader_version.clone(); + prof.metadata.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + + // Copy in contained folders as overrides + copy_dotminecraft(profile_path.clone(), gdlauncher_instance_folder).await?; + + if let Some(profile_val) = + crate::api::profile::get(&profile_path, None).await? + { + crate::launcher::install_minecraft(&profile_val, None).await?; + + State::sync().await?; + } + + Ok(()) +} diff --git a/theseus/src/api/pack/import/mmc.rs b/theseus/src/api/pack/import/mmc.rs new file mode 100644 index 000000000..6cc87db61 --- /dev/null +++ b/theseus/src/api/pack/import/mmc.rs @@ -0,0 +1,292 @@ +use std::path::{Path, PathBuf}; + +use serde::{de, Deserialize, Serialize}; +use tokio::fs; + +use crate::{ + pack::{ + import::{self, copy_dotminecraft}, + install_from::{self, CreatePackDescription, PackDependency}, + }, + prelude::ProfilePathId, + util::io, + State, +}; + +// 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, + + pub managed_pack_id: Option, + pub managed_pack_type: Option, + 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, +} + +// serde_ini reads 'true' and 'false' as strings, so we need to convert them to booleans +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, +} + +// Checks if if its a folder, and the folder contains instance.cfg and mmc-pack.json, and they both parse +#[tracing::instrument] +pub async fn is_valid_mmc(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 fs::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() +} + +// Loading the INI (instance.cfg) file +async fn load_instance_cfg(file_path: &Path) -> crate::Result { + let instance_cfg = 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] +#[theseus_macros::debug_pin] +pub async fn import_mmc( + mmc_base_path: PathBuf, // path to base mmc folder + instance_folder: String, // instance folder in mmc_base_path + profile_path: ProfilePathId, // path to profile +) -> 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?; + + // Re-cache icon + let icon = if let Some(icon_key) = instance_cfg.icon_key { + let icon_path = mmc_base_path.join("icons").join(icon_key); + import::recache_icon(icon_path).await? + } else { + None + }; + + // Create description from instance.cfg + let description = CreatePackDescription { + icon, + override_title: instance_cfg.name, + project_id: instance_cfg.managed_pack_id, + version_id: instance_cfg.managed_pack_version_id, + existing_loading_bar: None, + profile_path: profile_path.clone(), + }; + + // Managed pack + let backup_name = "Imported Modpack".to_string(); + + if instance_cfg.managed_pack.unwrap_or(false) { + match instance_cfg.managed_pack_type { + Some(MMCManagedPackType::Modrinth) => { + // Modrinth Managed Pack + // Kept separate as we may in the future want to add special handling for modrinth managed packs + let backup_name = "Imported Modrinth Modpack".to_string(); + let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft"); + import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?; + } + Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => { + // For flame/atlauncher managed packs + // Treat 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(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?; + }, + Some(_) => { + // For managed packs that aren't modrinth, flame, atlauncher + // Treat as unmanaged + let backup_name = "ImportedModpack".to_string(); + let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft"); + import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?; + }, + _ => return Err(crate::ErrorKind::InputError({ + "Instance is managed, but managed pack type not specified in instance.cfg".to_string() + }).into()) + } + } else { + // Direclty import unmanaged pack + let backup_name = "Imported Modpack".to_string(); + let minecraft_folder = mmc_base_path + .join("instances") + .join(instance_folder) + .join(".minecraft"); + import_mmc_unmanaged( + profile_path, + minecraft_folder, + backup_name, + description, + mmc_pack, + ) + .await?; + } + Ok(()) +} + +async fn import_mmc_unmanaged( + profile_path: ProfilePathId, + minecraft_folder: PathBuf, + backup_name: String, + description: CreatePackDescription, + mmc_pack: MMCPack, +) -> crate::Result<()> { + // Pack dependencies stored in mmc-pack.json, we convert to .mrpack pack dependencies + 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(); + + // Sets profile information to be that loaded from mmc-pack.json and instance.cfg + install_from::set_profile_information( + profile_path.clone(), + &description, + &backup_name, + &dependencies, + ) + .await?; + + // Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc) + copy_dotminecraft(profile_path.clone(), minecraft_folder).await?; + + if let Some(profile_val) = + crate::api::profile::get(&profile_path, None).await? + { + crate::launcher::install_minecraft(&profile_val, None).await?; + + State::sync().await?; + } + Ok(()) +} diff --git a/theseus/src/api/pack/import/mod.rs b/theseus/src/api/pack/import/mod.rs new file mode 100644 index 000000000..0559f157e --- /dev/null +++ b/theseus/src/api/pack/import/mod.rs @@ -0,0 +1,268 @@ +use std::path::{Path, PathBuf}; + +use io::IOError; +use serde::{Deserialize, Serialize}; + +use crate::{ + prelude::ProfilePathId, + util::{fetch, io}, +}; + +pub mod atlauncher; +pub mod curseforge; +pub mod gdlauncher; +pub mod mmc; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ImportLauncherType { + MultiMC, + PrismLauncher, + ATLauncher, + GDLauncher, + Curseforge, + #[serde(other)] + Unknown, +} + +// Return a list of importable instances from a launcher type and base path, by iterating through the folder and checking +pub async fn get_importable_instances( + launcher_type: ImportLauncherType, + base_path: PathBuf, +) -> crate::Result> { + // Some launchers have a different folder structure for instances + let instances_folder = match launcher_type { + ImportLauncherType::GDLauncher + | ImportLauncherType::MultiMC + | ImportLauncherType::PrismLauncher + | ImportLauncherType::ATLauncher => base_path.join("instances"), + ImportLauncherType::Curseforge => base_path.join("Instances"), + ImportLauncherType::Unknown => { + return Err(crate::ErrorKind::InputError( + "Launcher type Unknown".to_string(), + ) + .into()) + } + }; + let mut instances = Vec::new(); + let mut dir = io::read_dir(&instances_folder).await?; + while let Some(entry) = dir + .next_entry() + .await + .map_err(|e| IOError::with_path(e, &instances_folder))? + { + let path = entry.path(); + if path.is_dir() { + // Check instance is valid of this launcher type + if is_valid_importable_instance(path.clone(), launcher_type).await { + let name = path.file_name(); + if let Some(name) = name { + instances.push(name.to_string_lossy().to_string()); + } + } + } + } + Ok(instances) +} + +// Import an instance from a launcher type and base path +#[theseus_macros::debug_pin] +#[tracing::instrument] +pub async fn import_instance( + profile_path: ProfilePathId, + launcher_type: ImportLauncherType, + base_path: PathBuf, + instance_folder: String, +) -> crate::Result<()> { + tracing::debug!("Importing instance from {instance_folder}"); + match launcher_type { + ImportLauncherType::MultiMC | ImportLauncherType::PrismLauncher => { + mmc::import_mmc( + base_path, // path to base mmc folder + instance_folder, // instance folder in mmc_base_path + profile_path, // path to profile + ) + .await?; + } + ImportLauncherType::ATLauncher => { + atlauncher::import_atlauncher( + base_path, // path to atlauncher folder + instance_folder, // instance folder in atlauncher + profile_path, // path to profile + ) + .await?; + } + ImportLauncherType::GDLauncher => { + gdlauncher::import_gdlauncher( + base_path.join("instances").join(instance_folder), // path to gdlauncher folder + profile_path, // path to profile + ) + .await?; + } + ImportLauncherType::Curseforge => { + curseforge::import_curseforge( + base_path.join("Instances").join(instance_folder), // path to curseforge folder + profile_path, // path to profile + ) + .await?; + } + ImportLauncherType::Unknown => { + return Err(crate::ErrorKind::InputError( + "Launcher type Unknown".to_string(), + ) + .into()); + } + } + tracing::debug!("Completed import."); + Ok(()) +} + +/// Returns the default path for the given launcher type +/// None if it can't be found or doesn't exist +pub fn get_default_launcher_path( + r#type: ImportLauncherType, +) -> Option { + let path = match r#type { + ImportLauncherType::MultiMC => None, // multimc data is *in* app dir + ImportLauncherType::PrismLauncher => { + Some(dirs::data_dir()?.join("PrismLauncher")) + } + ImportLauncherType::ATLauncher => { + Some(dirs::data_dir()?.join("ATLauncher")) + } + ImportLauncherType::GDLauncher => { + Some(dirs::data_dir()?.join("gdlauncher_next")) + } + ImportLauncherType::Curseforge => { + Some(dirs::home_dir()?.join("curseforge").join("minecraft")) + } + ImportLauncherType::Unknown => None, + }; + let path = path?; + if path.exists() { + Some(path) + } else { + None + } +} + +/// Checks if this PathBuf is a valid instance for the given launcher type +#[theseus_macros::debug_pin] +#[tracing::instrument] +pub async fn is_valid_importable_instance( + instance_path: PathBuf, + r#type: ImportLauncherType, +) -> bool { + match r#type { + ImportLauncherType::MultiMC | ImportLauncherType::PrismLauncher => { + mmc::is_valid_mmc(instance_path).await + } + ImportLauncherType::ATLauncher => { + atlauncher::is_valid_atlauncher(instance_path).await + } + ImportLauncherType::GDLauncher => { + gdlauncher::is_valid_gdlauncher(instance_path).await + } + ImportLauncherType::Curseforge => { + curseforge::is_valid_curseforge(instance_path).await + } + ImportLauncherType::Unknown => false, + } +} + +/// Caches an image file in the filesystem into the cache directory, and returns the path to the cached file. +#[theseus_macros::debug_pin] +#[tracing::instrument] +pub async fn recache_icon( + icon_path: PathBuf, +) -> crate::Result> { + let state = crate::State::get().await?; + + let bytes = tokio::fs::read(&icon_path).await; + if let Ok(bytes) = bytes { + let bytes = bytes::Bytes::from(bytes); + let cache_dir = &state.directories.caches_dir(); + let semaphore = &state.io_semaphore; + Ok(Some( + fetch::write_cached_icon( + &icon_path.to_string_lossy(), + cache_dir, + bytes, + semaphore, + ) + .await?, + )) + } else { + // could not find icon (for instance, prism default icon, etc) + Ok(None) + } +} + +async fn copy_dotminecraft( + profile_path: ProfilePathId, + dotminecraft: PathBuf, +) -> crate::Result<()> { + // Get full path to profile + let profile_path = profile_path.get_full_path().await?; + + // std fs copy every file in dotminecraft to profile_path + let mut dir = io::read_dir(&dotminecraft).await?; + while let Some(entry) = dir + .next_entry() + .await + .map_err(|e| IOError::with_path(e, &dotminecraft))? + { + let path = entry.path(); + copy_dir_to( + &path, + &profile_path.join(path.file_name().ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Invalid file: {}", + &path.display() + )) + })?), + ) + .await?; + } + Ok(()) +} + +/// Recursively fs::copy every file in src to dest +/// uses async recursion +#[theseus_macros::debug_pin] +#[async_recursion::async_recursion] +#[tracing::instrument] +async fn copy_dir_to(src: &Path, dst: &Path) -> crate::Result<()> { + if !src.is_dir() { + io::copy(src, dst).await?; + return Ok(()); + } + + // Create the destination directory + io::create_dir_all(&dst).await?; + + // Iterate over the directory + let mut dir = io::read_dir(&src).await?; + while let Some(child) = dir + .next_entry() + .await + .map_err(|e| IOError::with_path(e, src))? + { + let src_child = child.path(); + let dst_child = dst.join(src_child.file_name().ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Invalid file: {}", + &src_child.display() + )) + })?); + + if src_child.is_dir() { + // Recurse into sub-directory + copy_dir_to(&src_child, &dst_child).await?; + } else { + // Copy file + io::copy(&src_child, &dst_child).await?; + } + } + + Ok(()) +} diff --git a/theseus/src/api/pack/install_from.rs b/theseus/src/api/pack/install_from.rs index 2e92dc163..bfb31d71e 100644 --- a/theseus/src/api/pack/install_from.rs +++ b/theseus/src/api/pack/install_from.rs @@ -2,8 +2,9 @@ use crate::config::MODRINTH_API_URL; use crate::data::ModLoader; use crate::event::emit::{emit_loading, init_loading}; use crate::event::{LoadingBarId, LoadingBarType}; +use crate::prelude::ProfilePathId; use crate::state::{ - LinkedData, ModrinthProject, ModrinthVersion, ProfilePathId, SideType, + LinkedData, ModrinthProject, ModrinthVersion, ProfileInstallStage, SideType, }; use crate::util::fetch::{ fetch, fetch_advanced, fetch_json, write_cached_icon, @@ -64,7 +65,7 @@ pub enum EnvType { Server, } -#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq, Debug)] #[serde(rename_all = "kebab-case")] pub enum PackDependency { Forge, @@ -76,12 +77,14 @@ pub enum PackDependency { #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase", tag = "type")] pub enum CreatePackLocation { + // Create a pack from a modrinth version ID (such as a modpack) FromVersionId { project_id: String, version_id: String, title: String, icon_url: Option, }, + // Create a pack from a file (such as an .mrpack for installing from a file, or a folder name for importing) FromFile { path: PathBuf, }, @@ -100,9 +103,29 @@ pub struct CreatePackProfile { pub skip_install_profile: Option, } -#[derive(Debug)] -pub struct CreatePackDescription { +// default +impl Default for CreatePackProfile { + fn default() -> Self { + CreatePackProfile { + name: "Untitled".to_string(), + game_version: "1.19.4".to_string(), + modloader: ModLoader::Vanilla, + loader_version: None, + icon: None, + icon_url: None, + linked_data: None, + skip_install_profile: Some(true), + } + } +} + +pub struct CreatePack { pub file: bytes::Bytes, + pub description: CreatePackDescription, +} + +#[derive(Clone, Debug)] +pub struct CreatePackDescription { pub icon: Option, pub override_title: Option, pub project_id: Option, @@ -122,16 +145,12 @@ pub fn get_profile_from_pack( icon_url, } => CreatePackProfile { name: title, - game_version: "1.19.4".to_string(), - modloader: ModLoader::Vanilla, - loader_version: None, - icon: None, icon_url, linked_data: Some(LinkedData { project_id: Some(project_id), version_id: Some(version_id), }), - skip_install_profile: Some(true), + ..Default::default() }, CreatePackLocation::FromFile { path } => { let file_name = path @@ -142,13 +161,7 @@ pub fn get_profile_from_pack( CreatePackProfile { name: file_name, - game_version: "1.19.4".to_string(), - modloader: ModLoader::Vanilla, - loader_version: None, - icon: None, - icon_url: None, - linked_data: None, - skip_install_profile: Some(true), + ..Default::default() } } } @@ -162,7 +175,7 @@ pub async fn generate_pack_from_version_id( title: String, icon_url: Option, profile_path: ProfilePathId, -) -> crate::Result { +) -> crate::Result { let state = State::get().await?; let loading_bar = init_loading( @@ -249,14 +262,16 @@ pub async fn generate_pack_from_version_id( }; emit_loading(&loading_bar, 10.0, None).await?; - Ok(CreatePackDescription { + Ok(CreatePack { file, - icon, - override_title: None, - project_id: Some(project_id), - version_id: Some(version_id), - existing_loading_bar: Some(loading_bar), - profile_path, + description: CreatePackDescription { + icon, + override_title: None, + project_id: Some(project_id), + version_id: Some(version_id), + existing_loading_bar: Some(loading_bar), + profile_path, + }, }) } @@ -265,15 +280,90 @@ pub async fn generate_pack_from_version_id( pub async fn generate_pack_from_file( path: PathBuf, profile_path: ProfilePathId, -) -> crate::Result { +) -> crate::Result { let file = io::read(&path).await?; - Ok(CreatePackDescription { + Ok(CreatePack { file: bytes::Bytes::from(file), - icon: None, - override_title: None, - project_id: None, - version_id: None, - existing_loading_bar: None, - profile_path, + description: CreatePackDescription { + icon: None, + override_title: None, + project_id: None, + version_id: None, + existing_loading_bar: None, + profile_path, + }, }) } + +/// Sets generated profile attributes to the pack ones (using profile::edit) +/// This includes the pack name, icon, game version, loader version, and loader +#[theseus_macros::debug_pin] +pub async fn set_profile_information( + profile_path: ProfilePathId, + description: &CreatePackDescription, + backup_name: &str, + dependencies: &HashMap, +) -> crate::Result<()> { + let mut game_version: Option<&String> = None; + let mut mod_loader = None; + let mut loader_version = None; + + for (key, value) in dependencies { + match key { + PackDependency::Forge => { + mod_loader = Some(ModLoader::Forge); + loader_version = Some(value); + } + PackDependency::FabricLoader => { + mod_loader = Some(ModLoader::Fabric); + loader_version = Some(value); + } + PackDependency::QuiltLoader => { + mod_loader = Some(ModLoader::Quilt); + loader_version = Some(value); + } + PackDependency::Minecraft => game_version = Some(value), + } + } + + let game_version = if let Some(game_version) = game_version { + game_version + } else { + return Err(crate::ErrorKind::InputError( + "Pack did not specify Minecraft version".to_string(), + ) + .into()); + }; + + let mod_loader = mod_loader.unwrap_or(ModLoader::Vanilla); + let loader_version = if mod_loader != ModLoader::Vanilla { + crate::profile_create::get_loader_version_from_loader( + game_version.clone(), + mod_loader, + loader_version.cloned(), + ) + .await? + } else { + None + }; + // Sets values in profile + crate::api::profile::edit(&profile_path, |prof| { + prof.metadata.name = description + .override_title + .clone() + .unwrap_or_else(|| backup_name.to_string()); + prof.install_stage = ProfileInstallStage::PackInstalling; + prof.metadata.linked_data = Some(LinkedData { + project_id: description.project_id.clone(), + version_id: description.version_id.clone(), + }); + prof.metadata.icon = description.icon.clone(); + prof.metadata.game_version = game_version.clone(); + prof.metadata.loader_version = loader_version.clone(); + prof.metadata.loader = mod_loader; + + async { Ok(()) } + }) + .await?; + Ok(()) +} diff --git a/theseus/src/api/pack/install.rs b/theseus/src/api/pack/install_mrpack.rs similarity index 68% rename from theseus/src/api/pack/install.rs rename to theseus/src/api/pack/install_mrpack.rs index 47088190c..6ebb1e00f 100644 --- a/theseus/src/api/pack/install.rs +++ b/theseus/src/api/pack/install_mrpack.rs @@ -1,10 +1,12 @@ -use crate::data::ModLoader; use crate::event::emit::{ emit_loading, init_or_edit_loading, loading_try_for_each_concurrent, }; use crate::event::LoadingBarType; -use crate::pack::install_from::{EnvType, PackFile, PackFileHash}; -use crate::state::{LinkedData, ProfileInstallStage, ProfilePathId, SideType}; +use crate::pack::install_from::{ + set_profile_information, EnvType, PackFile, PackFileHash, +}; +use crate::prelude::ProfilePathId; +use crate::state::SideType; use crate::util::fetch::{fetch_mirrors, write}; use crate::State; use async_zip::tokio::read::seek::ZipFileReader; @@ -13,17 +15,18 @@ use std::io::Cursor; use std::path::{Component, PathBuf}; use super::install_from::{ - generate_pack_from_file, generate_pack_from_version_id, - CreatePackDescription, CreatePackLocation, PackDependency, PackFormat, + generate_pack_from_file, generate_pack_from_version_id, CreatePack, + CreatePackLocation, PackFormat, }; +/// Install a modpack from a mrpack file (a modrinth .zip format) #[theseus_macros::debug_pin] -pub async fn install_pack( +pub async fn install_zipped_mrpack( location: CreatePackLocation, - profile_path: ProfilePathId, + profile: ProfilePathId, ) -> crate::Result { // Get file from description - let description: CreatePackDescription = match location { + let create_pack: CreatePack = match location { CreatePackLocation::FromVersionId { project_id, version_id, @@ -31,26 +34,22 @@ pub async fn install_pack( icon_url, } => { generate_pack_from_version_id( - project_id, - version_id, - title, - icon_url, - profile_path, + project_id, version_id, title, icon_url, profile, ) .await? } CreatePackLocation::FromFile { path } => { - generate_pack_from_file(path, profile_path).await? + generate_pack_from_file(path, profile).await? } }; - let file = description.file; - let icon = description.icon; - let override_title = description.override_title; - let project_id = description.project_id; - let version_id = description.version_id; - let existing_loading_bar = description.existing_loading_bar; - let profile_path = description.profile_path; + let file = create_pack.file; + let description = create_pack.description.clone(); // make a copy for profile edit function + let icon = create_pack.description.icon; + let project_id = create_pack.description.project_id; + let version_id = create_pack.description.version_id; + let existing_loading_bar = create_pack.description.existing_loading_bar; + let profile = create_pack.description.profile_path; let state = &State::get().await?; @@ -92,69 +91,22 @@ pub async fn install_pack( .into()); } - let mut game_version = None; - let mut mod_loader = None; - let mut loader_version = None; - for (key, value) in &pack.dependencies { - match key { - PackDependency::Forge => { - mod_loader = Some(ModLoader::Forge); - loader_version = Some(value); - } - PackDependency::FabricLoader => { - mod_loader = Some(ModLoader::Fabric); - loader_version = Some(value); - } - PackDependency::QuiltLoader => { - mod_loader = Some(ModLoader::Quilt); - loader_version = Some(value); - } - PackDependency::Minecraft => game_version = Some(value), - } - } - - let game_version = if let Some(game_version) = game_version { - game_version - } else { - return Err(crate::ErrorKind::InputError( - "Pack did not specify Minecraft version".to_string(), - ) - .into()); - }; - - let loader_version = - crate::profile_create::get_loader_version_from_loader( - game_version.clone(), - mod_loader.unwrap_or(ModLoader::Vanilla), - loader_version.cloned(), - ) - .await?; - crate::api::profile::edit(&profile_path, |prof| { - prof.metadata.name = - override_title.clone().unwrap_or_else(|| pack.name.clone()); - prof.install_stage = ProfileInstallStage::PackInstalling; - prof.metadata.linked_data = Some(LinkedData { - project_id: project_id.clone(), - version_id: version_id.clone(), - }); - prof.metadata.icon = icon.clone(); - prof.metadata.game_version = game_version.clone(); - prof.metadata.loader_version = loader_version.clone(); - prof.metadata.loader = mod_loader.unwrap_or(ModLoader::Vanilla); - - async { Ok(()) } - }) + // Sets generated profile attributes to the pack ones (using profile::edit) + set_profile_information( + profile.clone(), + &description, + &pack.name, + &pack.dependencies, + ) .await?; - let profile_path = profile_path.clone(); + let profile_full_path = profile.get_full_path().await?; + let profile = profile.clone(); let result = async { let loading_bar = init_or_edit_loading( existing_loading_bar, LoadingBarType::PackDownload { - profile_path: profile_path - .get_full_path() - .await? - .clone(), + profile_path: profile_full_path.clone(), pack_name: pack.name.clone(), icon, pack_id: project_id, @@ -176,7 +128,7 @@ pub async fn install_pack( num_files, None, |project| { - let profile_path = profile_path.clone(); + let profile_full_path = profile_full_path.clone(); async move { //TODO: Future update: prompt user for optional files in a modpack if let Some(env) = project.env { @@ -210,9 +162,7 @@ pub async fn install_pack( match path { Component::CurDir | Component::Normal(_) => { - let path = profile_path - .get_full_path() - .await? + let path = profile_full_path .join(project.path); write( &path, @@ -275,10 +225,7 @@ pub async fn install_pack( if new_path.file_name().is_some() { write( - &profile_path - .get_full_path() - .await? - .join(new_path), + &profile_full_path.join(new_path), &content, &state.io_semaphore, ) @@ -298,7 +245,7 @@ pub async fn install_pack( } if let Some(profile_val) = - crate::api::profile::get(&profile_path, None).await? + crate::api::profile::get(&profile, None).await? { crate::launcher::install_minecraft( &profile_val, @@ -309,14 +256,14 @@ pub async fn install_pack( State::sync().await?; } - Ok::(profile_path.clone()) + Ok::(profile.clone()) } .await; match result { Ok(profile) => Ok(profile), Err(err) => { - let _ = crate::api::profile::remove(&profile_path).await; + let _ = crate::api::profile::remove(&profile).await; Err(err) } @@ -332,7 +279,7 @@ pub async fn install_pack( match result { Ok(profile) => Ok(profile), Err(err) => { - let _ = crate::api::profile::remove(&profile_path).await; + let _ = crate::api::profile::remove(&profile).await; Err(err) } diff --git a/theseus/src/api/pack/mod.rs b/theseus/src/api/pack/mod.rs index 9a7c1d341..be9ec8b53 100644 --- a/theseus/src/api/pack/mod.rs +++ b/theseus/src/api/pack/mod.rs @@ -1,2 +1,3 @@ -pub mod install; +pub mod import; pub mod install_from; +pub mod install_mrpack; diff --git a/theseus/src/api/profile_create.rs b/theseus/src/api/profile_create.rs index 9a6747d8a..3e259cca2 100644 --- a/theseus/src/api/profile_create.rs +++ b/theseus/src/api/profile_create.rs @@ -1,5 +1,7 @@ //! Theseus profile management interface -use crate::state::{LinkedData, ProfilePathId}; +use crate::pack::install_from::CreatePackProfile; +use crate::prelude::ProfilePathId; +use crate::state::LinkedData; use crate::util::io::{self, canonicalize}; use crate::{ event::{emit::emit_profile, ProfilePayloadType}, @@ -128,6 +130,22 @@ pub async fn profile_create( } } +pub async fn profile_create_from_creator( + profile: CreatePackProfile, +) -> crate::Result { + profile_create( + profile.name, + profile.game_version, + profile.modloader, + profile.loader_version, + profile.icon, + profile.icon_url, + profile.linked_data, + profile.skip_install_profile, + ) + .await +} + #[tracing::instrument] #[theseus_macros::debug_pin] pub(crate) async fn get_loader_version_from_loader( diff --git a/theseus/src/error.rs b/theseus/src/error.rs index fb2485beb..ea44b4ce7 100644 --- a/theseus/src/error.rs +++ b/theseus/src/error.rs @@ -7,6 +7,9 @@ pub enum ErrorKind { #[error("Filesystem error: {0}")] FSError(String), + #[error("Serialization error (INI): {0}")] + INIError(#[from] serde_ini::de::Error), + #[error("Serialization error (JSON): {0}")] JSONError(#[from] serde_json::Error), diff --git a/theseus/src/event/mod.rs b/theseus/src/event/mod.rs index 2d5f5370a..f2706f7a3 100644 --- a/theseus/src/event/mod.rs +++ b/theseus/src/event/mod.rs @@ -93,7 +93,7 @@ pub struct LoadingBar { pub cli_progress_bar: indicatif::ProgressBar, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, Clone)] pub struct LoadingBarId(Uuid); // When Loading bar id is dropped, we should remove it from the hashmap diff --git a/theseus/src/util/io.rs b/theseus/src/util/io.rs index 11162e94a..b915372a4 100644 --- a/theseus/src/util/io.rs +++ b/theseus/src/util/io.rs @@ -135,6 +135,21 @@ pub async fn rename( }) } +// copy +pub async fn copy( + from: impl AsRef, + to: impl AsRef, +) -> Result { + let from = from.as_ref(); + let to = to.as_ref(); + tokio::fs::copy(from, to) + .await + .map_err(|e| IOError::IOPathError { + source: e, + path: from.to_string_lossy().to_string(), + }) +} + // remove file pub async fn remove_file( path: impl AsRef, diff --git a/theseus_gui/src-tauri/src/api/import.rs b/theseus_gui/src-tauri/src/api/import.rs new file mode 100644 index 000000000..f30e77d0a --- /dev/null +++ b/theseus_gui/src-tauri/src/api/import.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; + +use crate::api::Result; +use theseus::pack::import::ImportLauncherType; + +use theseus::pack::import; +use theseus::prelude::ProfilePathId; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("import") + .invoke_handler(tauri::generate_handler![ + import_get_importable_instances, + import_import_instance, + import_is_valid_importable_instance, + import_get_default_launcher_path, + ]) + .build() +} + +/// Gets a list of importable instances from a launcher type and base path +/// eg: get_importable_instances(ImportLauncherType::MultiMC, PathBuf::from("C:/MultiMC")) +/// returns ["Instance 1", "Instance 2"] +#[tauri::command] +pub async fn import_get_importable_instances( + launcher_type: ImportLauncherType, + base_path: PathBuf, +) -> Result> { + Ok(import::get_importable_instances(launcher_type, base_path).await?) +} + +/// Import an instance from a launcher type and base path +/// eg: import_instance(ImportLauncherType::MultiMC, PathBuf::from("C:/MultiMC"), "Instance 1") +#[tauri::command] +pub async fn import_import_instance( + profile_path: ProfilePathId, + launcher_type: ImportLauncherType, + base_path: PathBuf, + instance_folder: String, +) -> Result<()> { + import::import_instance( + profile_path, + launcher_type, + base_path, + instance_folder, + ) + .await?; + Ok(()) +} + +/// Checks if this instance is valid for importing, given a certain launcher type +/// eg: is_valid_importable_instance(PathBuf::from("C:/MultiMC/Instance 1"), ImportLauncherType::MultiMC) +#[tauri::command] +pub async fn import_is_valid_importable_instance( + instance_folder: PathBuf, + launcher_type: ImportLauncherType, +) -> Result { + Ok( + import::is_valid_importable_instance(instance_folder, launcher_type) + .await, + ) +} + +/// Returns the default path for the given launcher type +/// None if it can't be found or doesn't exist +#[tauri::command] +pub async fn import_get_default_launcher_path( + launcher_type: ImportLauncherType, +) -> Result> { + Ok(import::get_default_launcher_path(launcher_type)) +} diff --git a/theseus_gui/src-tauri/src/api/mod.rs b/theseus_gui/src-tauri/src/api/mod.rs index 31ff2a90a..2bfb0c966 100644 --- a/theseus_gui/src-tauri/src/api/mod.rs +++ b/theseus_gui/src-tauri/src/api/mod.rs @@ -3,6 +3,7 @@ use serde::{Serialize, Serializer}; use thiserror::Error; pub mod auth; +pub mod import; pub mod jre; pub mod logs; pub mod metadata; diff --git a/theseus_gui/src-tauri/src/api/pack.rs b/theseus_gui/src-tauri/src/api/pack.rs index eb6e4212b..3d04a1527 100644 --- a/theseus_gui/src-tauri/src/api/pack.rs +++ b/theseus_gui/src-tauri/src/api/pack.rs @@ -2,8 +2,8 @@ use crate::api::Result; use theseus::{ pack::{ - install::install_pack, install_from::{CreatePackLocation, CreatePackProfile}, + install_mrpack::install_zipped_mrpack, }, prelude::*, }; @@ -22,7 +22,7 @@ pub async fn pack_install( location: CreatePackLocation, profile: ProfilePathId, ) -> Result { - Ok(install_pack(location, profile).await?) + Ok(install_zipped_mrpack(location, profile).await?) } #[tauri::command] diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index 99d90b9c1..d04d50dc4 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -118,6 +118,7 @@ fn main() { } let builder = builder .plugin(api::auth::init()) + .plugin(api::import::init()) .plugin(api::logs::init()) .plugin(api::jre::init()) .plugin(api::metadata::init()) diff --git a/theseus_gui/src/helpers/import.js b/theseus_gui/src/helpers/import.js new file mode 100644 index 000000000..c1e91d5c0 --- /dev/null +++ b/theseus_gui/src/helpers/import.js @@ -0,0 +1,61 @@ +/** + * All theseus API calls return serialized values (both return values and errors); + * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, + * and deserialized into a usable JS object. + */ +import { invoke } from '@tauri-apps/api/tauri' +import { create } from './profile' + +/* + API for importing instances from other launchers + launcherType can be one of the following: + - MultiMC + - GDLauncher + - ATLauncher + - Curseforge + - PrismLauncher + - Unknown (shouldn't be used, but is used internally if the launcher type isn't recognized) + + For each launcher type, we can get a guess of the default path for the launcher, and a list of importable instances + For most launchers, this will be the application's data directory, with two exceptions: + - MultiMC: this goes to the app directory (wherever the app is) + - Curseforge: this goes to the 'minecraft' subdirectory of the data directory, as Curseforge has multiple games + +*/ + +/// Gets a list of importable instances from a launcher type and base path +/// eg: get_importable_instances("MultiMC", "C:/MultiMC") +/// returns ["Instance 1", "Instance 2"] +export async function get_importable_instances(launcherType, basePath) { + return await invoke('plugin:import|import_get_importable_instances', { launcherType, basePath }) +} + +/// Import an instance from a launcher type and base path +/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1") +export async function import_instance(launcherType, basePath, instanceFolder) { + // create a basic, empty instance (most properties will be filled in by the import process) + const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null) + + return await invoke('plugin:import|import_import_instance', { + profilePath, + launcherType, + basePath, + instanceFolder, + }) +} + +/// Checks if this instance is valid for importing, given a certain launcher type +/// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC") +export async function is_valid_importable_instance(instanceFolder, launcherType) { + return await invoke('plugin:import|import_is_valid_importable_instance', { + instanceFolder, + launcherType, + }) +} + +/// Gets the default path for the given launcher type +/// null if it can't be found or doesn't exist +/// eg: get_default_launcher_path("MultiMC") +export async function get_default_launcher_path(launcherType) { + return await invoke('plugin:import|import_get_default_launcher_path', { launcherType }) +}