diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c0014c1fc..c6ca45f88 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,7 @@ version = "0.8.0" dependencies = [ "async-speed-limit", "async-trait", + "async_zip", "axum", "base64 0.22.1", "cafebabe", @@ -73,6 +74,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "walkdir", "winapi", "winreg 0.55.0", "zip", @@ -275,6 +277,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-executor" version = "1.13.2" @@ -394,6 +409,21 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async_zip" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +dependencies = [ + "async-compression", + "crc32fast", + "futures-lite", + "pin-project", + "thiserror 2.0.17", + "tokio", + "tokio-util", +] + [[package]] name = "atk" version = "0.18.2" @@ -928,6 +958,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5968fe261..2528e0de5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -91,6 +91,8 @@ axum = "0.8.6" tower-http = { version = "0.6.6", features = ["cors"] } sha2 = "0.10.9" async-trait = "0.1.89" +walkdir = "2.5.0" +async_zip = { version = "0.0.18", features = ["tokio", "tokio-fs", "deflate"] } [target."cfg(windows)".dependencies] winreg = "0.55.0" diff --git a/src-tauri/src/instance/commands.rs b/src-tauri/src/instance/commands.rs index 17e048427..fbec67291 100644 --- a/src-tauri/src/instance/commands.rs +++ b/src-tauri/src/instance/commands.rs @@ -12,6 +12,11 @@ use crate::instance::helpers::misc::{ get_instance_game_config, get_instance_subdir_path_by_id, get_instance_subdir_paths, refresh_and_update_instances, unify_instance_name, }; +use crate::instance::helpers::modpack::export::{ + collect_modrinth_files, generate_modrinth_manifest, generate_multimc_instance_cfg, + generate_multimc_manifest, list_files, validate_export_options, ExportFormat, + ExportModpackOptions, +}; use crate::instance::helpers::modpack::misc::{ extract_overrides, get_download_params, ModpackMetaInfo, }; @@ -29,8 +34,8 @@ use crate::instance::helpers::server::{ use crate::instance::helpers::world::{load_level_data_from_nbt, load_world_info_from_dir}; use crate::instance::models::misc::{ Instance, InstanceError, InstanceSubdirType, InstanceSummary, LocalModInfo, ModLoader, - ModLoaderStatus, ModLoaderType, OptiFine, ResourcePackInfo, SchematicInfo, ScreenshotInfo, - ShaderPackInfo, + ModLoaderStatus, ModLoaderType, ModpackFileList, OptiFine, ResourcePackInfo, SchematicInfo, + ScreenshotInfo, ShaderPackInfo, }; use crate::instance::models::world::base::WorldInfo; use crate::instance::models::world::level::LevelData; @@ -47,8 +52,8 @@ use crate::tasks::commands::schedule_progressive_task_group; use crate::tasks::download::DownloadParam; use crate::tasks::PTaskParam; use crate::utils::fs::{ - copy_whole_dir, create_url_shortcut, generate_unique_filename, get_files_with_regex, - get_subdirectories, + copy_whole_dir, create_modpack_zip, create_url_shortcut, generate_unique_filename, + get_files_with_regex, get_subdirectories, }; use crate::utils::image::ImageWrapper; use lazy_static::lazy_static; @@ -1323,3 +1328,97 @@ pub fn add_custom_instance_icon( Ok(()) } + +#[tauri::command] +pub async fn list_modpack_files( + app: AppHandle, + instance_id: String, +) -> SJMCLResult { + let instance = { + let binding = app.state::>>(); + let state = binding.lock()?; + state + .get(&instance_id) + .ok_or(InstanceError::InstanceNotFoundByID)? + .clone() + }; + tokio::task::spawn_blocking(move || list_files(&instance)).await? +} + +#[tauri::command] +pub async fn export_modpack( + app: AppHandle, + instance_id: String, + save_path: String, + options: ExportModpackOptions, + files: Vec, +) -> SJMCLResult<()> { + let instance = { + let binding = app.state::>>(); + let state = binding.lock()?; + state + .get(&instance_id) + .ok_or(InstanceError::InstanceNotFoundByID)? + .clone() + }; + validate_export_options(&instance, &options)?; + + let base_path = instance.version_path.clone(); + + let mut selected_files = Vec::new(); + for rel in files { + let full = base_path.join(&rel); + if tokio::fs::try_exists(&full).await.unwrap_or(false) { + selected_files.push((rel, full)); + } + } + + if selected_files.is_empty() { + return Err(InstanceError::ModpackManifestParseError.into()); + } + + let no_create_remote_files = options.no_create_remote_files.unwrap_or(false); + let skip_curseforge = options.skip_curseforge_remote_files.unwrap_or(false); + + let (overrides_prefix, overrides_files, extra_files) = match options.format { + ExportFormat::Modrinth => { + let mut manifest = generate_modrinth_manifest(&instance, &options)?; + let (modrinth_files, override_files) = collect_modrinth_files( + &app, + &selected_files, + no_create_remote_files, + skip_curseforge, + ) + .await?; + + manifest.files = modrinth_files; + let json = serde_json::to_string_pretty(&manifest) + .map_err(|_| InstanceError::ModpackManifestParseError)?; + ( + "overrides".to_string(), + override_files, + vec![("modrinth.index.json".to_string(), json)], + ) + } + ExportFormat::MultiMC => { + let manifest = generate_multimc_manifest(&instance, &options)?; + let json = serde_json::to_string_pretty(&manifest) + .map_err(|_| InstanceError::ModpackManifestParseError)?; + let extras = vec![ + ("mmc-pack.json".to_string(), json), + ( + "instance.cfg".to_string(), + generate_multimc_instance_cfg(&instance, &options), + ), + (".packignore".to_string(), String::new()), + ]; + (".minecraft".to_string(), selected_files, extras) + } + }; + + create_modpack_zip(&save_path, &overrides_prefix, overrides_files, extra_files) + .await + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + + Ok(()) +} diff --git a/src-tauri/src/instance/helpers/modpack/export.rs b/src-tauri/src/instance/helpers/modpack/export.rs new file mode 100644 index 000000000..b3ebc296b --- /dev/null +++ b/src-tauri/src/instance/helpers/modpack/export.rs @@ -0,0 +1,533 @@ +use crate::error::SJMCLResult; +use crate::instance::helpers::modpack::modrinth::{ + ModrinthFile, ModrinthFileEnv, ModrinthFileHashes, ModrinthManifest, +}; +use crate::instance::helpers::modpack::multimc::{MultiMcComponent, MultiMcManifest}; +use crate::instance::models::misc::{Instance, InstanceError, ModLoaderType, ModpackFileList}; +use crate::resource::helpers::{ + curseforge::fetch_remote_resource_by_local_curseforge, + modrinth::fetch_remote_resource_by_local_modrinth, +}; +use regex::{Regex, RegexSet}; +use serde::{Deserialize, Serialize}; +use sha1::Digest; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock}; +use tauri::AppHandle; +use tokio::sync::Semaphore; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum ExportFormat { + Modrinth, + MultiMC, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportModpackOptions { + pub format: ExportFormat, + pub name: String, + pub version: String, + pub author: Option, + pub description: Option, + pub pack_with_launcher: Option, // TODO: unused currently + pub min_memory: Option, // for MultiMC + pub no_create_remote_files: Option, // for Modrinth + pub skip_curseforge_remote_files: Option, // for Modrinth +} + +/// Validate export options before proceeding +pub fn validate_export_options( + instance: &Instance, + options: &ExportModpackOptions, +) -> SJMCLResult<()> { + // Check required fields + if options.name.trim().is_empty() { + return Err(InstanceError::InvalidNameError.into()); + } + + if options.version.trim().is_empty() { + return Err(InstanceError::ModpackManifestParseError.into()); + } + + if !instance.version_path.exists() { + return Err(InstanceError::FileNotFoundError.into()); + } + + Ok(()) +} + +// ============================================================================ +// Candidate File Listing +// ============================================================================ + +static REGEX_BLACKLIST: LazyLock = LazyLock::new(|| { + RegexSet::new([ + r".*\.log$", + r".*-natives$", + r"natives-.*$", + r"^\._.*", + // Backup files + r".*\.dat_old$", + r".*\.old$", + // BakaXL + r".*\.BakaCoreInfo$", + ]) + .expect("Invalid regex patterns") +}); + +static BLACKLIST: LazyLock> = LazyLock::new(|| { + HashSet::from([ + ".DS_Store", + "desktop.ini", + "Thumbs.db", + // Minecraft + "usernamecache.json", + "usercache.json", + "jars", + "logs", + "versions", + "assets", + "libraries", + "crash-reports", + "NVIDIA", + "AMD", + "screenshots", + "natives", + "native", + "$native", + "$natives", + "server-resource-packs", + "command_history.txt", + // Old Minecraft Launcher + "launcher_profiles.json", + "launcher.pack.lzma", + // New Minecraft Launcher + "launcher_accounts.json", + "launcher_cef_log.txt", + "launcher_log.txt", + "launcher_msa_credentials.bin", + "launcher_settings.json", + "launcher_ui_state.json", + "realms_persistence.json", + "webcache2", + "treatment_tags.json", + // Plain Craft Launcher + "clientId.txt", + "PCL.ini", + // HMCL + "backup", + "pack.json", + "launcher.jar", + "cache", + "modpack.cfg", + "log4j2.xml", + "hmclversion.cfg", + // SJMCL + "install_profile.json", + "sjmclcfg.json", + // Curse + "manifest.json", + "minecraftinstance.json", + ".curseclient", + // Modrinth + "modrinth.index.json", + // Fabric/OptiFine + ".fabric", + ".mixin.out", + ".optifine", + // Downloads and Essential + "downloads", + "essential", + // Mods + "asm", + "backups", + "TCNodeTracker", + "CustomDISkins", + "data", + "CustomSkinLoader/caches", + // Debug files + "debug", + // ReplayMod + ".replay_cache", + "replay_recordings", + "replay_videos", + // Iris + "irisUpdateInfo.json", + // ModernFix + "modernfix", + // Mod translations + "modtranslations", + // Schematics mod + "schematics", + // JourneyMap + "journeymap/data", + // Sinytra Connector + "mods/.connector", + ]) +}); + +// ugly, consider GlobSet if this grows too large +static SUGGESTED_BLACKLIST: LazyLock> = LazyLock::new(|| { + HashSet::from([ + // BetterFonts + "fonts", + // Minecraft + "saves", + "servers.dat", + "options.txt", + // BuildCraft + "blueprints", + // OptiFine + "optionsof.txt", + // JourneyMap + "journeymap", + "optionsshaders.txt", + // VoxelMods + "mods/VoxelMods", + ]) +}); + +static SUGGESTED_REGEX_BLACKLIST: LazyLock = LazyLock::new(|| { + RegexSet::new([ + r"^fonts/.*$", + r"^saves/.*$", + r"^blueprints/.*$", + r"^journeymap/.*$", + r"^mods/VoxelMods/.*$", + ]) + .expect("Invalid regex") +}); + +/// List all files that can be offered to the user in the modpack export tree UI. +pub fn list_files(instance: &Instance) -> SJMCLResult { + let root = &instance.version_path; + if !root.exists() { + return Err(InstanceError::FileNotFoundError.into()); + } + + let name = &instance.name; + let mut files = Vec::new(); + let mut unchecked_files = Vec::new(); + + let walker = WalkDir::new(root).into_iter(); + let iter = walker.filter_entry(|entry| { + let path = entry.path(); + + let rel_path = match path.strip_prefix(root) { + Ok(rel) => rel, + Err(_) => return false, + }; + let rel_str = rel_path.to_string_lossy().replace('\\', "/"); + + if BLACKLIST.contains(rel_str.as_str()) { + return false; + } + if REGEX_BLACKLIST.is_match(rel_str.as_str()) { + return false; + } + true + }); + + for entry in iter { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + if entry.file_type().is_file() { + let path = entry.path(); + // Ignore name.jar / name.json + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + if stem == name { + continue; + } + } + + if let Ok(rel_path) = path.strip_prefix(root) { + let rel_str = rel_path.to_string_lossy().replace('\\', "/"); + + if SUGGESTED_BLACKLIST.contains(rel_str.as_str()) + || SUGGESTED_REGEX_BLACKLIST.is_match(&rel_str) + { + unchecked_files.push(rel_str.clone()); + } + + files.push(rel_str); + } + } + } + + files.sort_unstable(); + unchecked_files.sort_unstable(); + Ok(ModpackFileList { + all: files, + unchecked: unchecked_files, + }) +} + +static FORGE_VERSION_REGEX: LazyLock = LazyLock::new(|| { + // Forge: "1.16.5-forge-36.2.39" (Including NeoForge 1.20.1) + Regex::new(r"([\d.]+)-forge-([\d.]+)").expect("Invalid regex") +}); + +static NEOFORGE_VERSION_REGEX: LazyLock = LazyLock::new(|| { + // NeoForge: "neoforge-21.4.121" (Not processed for "21.10.0-beta" or "0.25w14craftmine.3-beta") + Regex::new(r"(neoforge-)?([a-zA-Z0-9.-]+)(-beta)?").expect("Invalid regex") +}); + +fn normalize_mod_loader_version(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return raw.to_string(); + } + + if let Some(caps) = FORGE_VERSION_REGEX.captures(trimmed) { + if let Some(matched) = caps.get(2) { + return matched.as_str().to_string(); + } + } + + if let Some(caps) = NEOFORGE_VERSION_REGEX.captures(trimmed) { + if let Some(matched) = caps.get(2) { + return matched.as_str().to_string(); + } + } + + raw.to_string() +} + +// ============================================================================ +// Export Manifest Generators +// ============================================================================ + +/// Generate a Modrinth format manifest for the instance +pub fn generate_modrinth_manifest( + instance: &Instance, + options: &ExportModpackOptions, +) -> SJMCLResult { + let mut dependencies = HashMap::new(); + + // Add Minecraft version + dependencies.insert("minecraft".to_string(), instance.version.clone()); + + // Add mod loader if present + if instance.mod_loader.loader_type != ModLoaderType::Unknown { + let version = normalize_mod_loader_version(&instance.mod_loader.version); + let loader_key = match instance.mod_loader.loader_type { + ModLoaderType::Forge | ModLoaderType::LegacyForge => "forge", + ModLoaderType::Fabric => "fabric-loader", + ModLoaderType::NeoForge => "neoforge", + ModLoaderType::Quilt => "quilt-loader", + _ => "", + }; + + if !loader_key.is_empty() { + dependencies.insert(loader_key.to_string(), version); + } + } + + Ok(ModrinthManifest { + version_id: options.version.clone(), + name: options.name.clone(), + summary: options.description.clone(), + files: Vec::new(), + dependencies, + ..Default::default() + }) +} + +/// Generate a MultiMC format manifest for the instance +pub fn generate_multimc_manifest( + instance: &Instance, + _options: &ExportModpackOptions, +) -> SJMCLResult { + let mut components = Vec::new(); + + // Add Minecraft component (always important) + components.push(MultiMcComponent { + uid: "net.minecraft".to_string(), + version: Some(instance.version.clone()), + important: Some(true), + dependency_only: Some(false), + cached_name: None, + cached_requires: None, + cached_version: None, + cached_volatile: None, + }); + + // Add mod loader component if present + if instance.mod_loader.loader_type != ModLoaderType::Unknown { + let version = normalize_mod_loader_version(&instance.mod_loader.version); + let uid = match instance.mod_loader.loader_type { + ModLoaderType::Forge | ModLoaderType::LegacyForge => "net.minecraftforge", + ModLoaderType::NeoForge => "net.neoforged", + ModLoaderType::Fabric => "net.fabricmc.fabric-loader", + ModLoaderType::Quilt => "org.quiltmc.quilt-loader", + _ => "", + }; + + if !uid.is_empty() { + components.push(MultiMcComponent { + uid: uid.to_string(), + version: Some(version), + important: Some(false), + dependency_only: Some(false), + cached_name: None, + cached_requires: None, + cached_version: None, + cached_volatile: None, + }); + } + } + + Ok(MultiMcManifest { + format_version: 1, + components, + cfg: HashMap::new(), + base_path: String::new(), + }) +} + +/// Generate instance.cfg file content for MultiMC +pub fn generate_multimc_instance_cfg( + _instance: &Instance, + options: &ExportModpackOptions, +) -> String { + let mut content = format!( + "# Auto generated by SJMC Launcher\nInstanceType=OneSix\nname={}\n", + options.name + ); + if let Some(min_memory) = options.min_memory { + content.push_str("OverrideMemory=true\n"); + content.push_str(&format!("MinMemAlloc={}\n", min_memory)); + } + content +} + +// ============================================================================ +// Remote File Collectors +// ============================================================================ + +async fn build_modrinth_remote_file( + app: &AppHandle, + rel: &str, + full: &Path, + skip_curseforge: bool, +) -> SJMCLResult> { + // If disabled, mark it as optional in env + let is_disabled = rel.ends_with(".disabled"); + let manifest_path = if is_disabled { + rel.trim_end_matches(".disabled").to_string() + } else { + rel.to_string() + }; + + let mut downloads = Vec::new(); + + if let Ok(remote) = + fetch_remote_resource_by_local_modrinth(app, full.to_string_lossy().as_ref()).await + { + downloads.push(remote.download_url); + } + + if !skip_curseforge { + if let Ok(remote) = + fetch_remote_resource_by_local_curseforge(app, full.to_string_lossy().as_ref()).await + { + downloads.push(remote.download_url); + } + } + + if downloads.is_empty() { + return Ok(None); + } + + let file_content = tokio::fs::read(full).await?; + let mut sha1_hasher = sha1::Sha1::new(); + let mut sha512_hasher = sha2::Sha512::new(); + sha1_hasher.update(&file_content); + sha512_hasher.update(&file_content); + let sha1 = hex::encode(sha1_hasher.finalize()); + let sha512 = hex::encode(sha512_hasher.finalize()); + let file_size = file_content.len() as u64; + let env = if is_disabled { + Some(ModrinthFileEnv { + client: "optional".to_string(), + server: "unsupported".to_string(), + }) + } else { + None + }; + + Ok(Some(ModrinthFile { + path: manifest_path, + hashes: ModrinthFileHashes { sha1, sha512 }, + env, + downloads, + file_size, + })) +} + +pub async fn collect_modrinth_files( + app: &AppHandle, + selected_files: &[(String, PathBuf)], + no_create_remote_files: bool, + skip_curseforge: bool, +) -> SJMCLResult<(Vec, Vec<(String, PathBuf)>)> { + let is_remote_candidate = |rel: &str| { + rel.starts_with("mods/") || rel.starts_with("resourcepacks/") || rel.starts_with("shaderpacks/") + }; + + let mut tasks = Vec::new(); + let semaphore = Arc::new(Semaphore::new( + std::thread::available_parallelism().unwrap().into(), + )); + + for (rel, full) in selected_files { + let rel = rel.clone(); + let full = full.clone(); + let app = app.clone(); + let permit = semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| InstanceError::SemaphoreAcquireFailed)?; + + let task = tokio::spawn(async move { + let result = if is_remote_candidate(&rel) && !no_create_remote_files { + build_modrinth_remote_file(&app, &rel, &full, skip_curseforge) + .await + .ok() + .flatten() + } else { + None + }; + + drop(permit); + (rel, full, result) + }); + + tasks.push(task); + } + + let mut modrinth_files = Vec::new(); + let mut override_files = Vec::new(); + + for task in tasks { + if let Ok(res) = task.await { + match res { + (_, _, Some(modrinth_file)) => { + modrinth_files.push(modrinth_file); + } + (rel, full, None) => { + override_files.push((rel.to_string(), full)); + } + } + } + } + + Ok((modrinth_files, override_files)) +} diff --git a/src-tauri/src/instance/helpers/modpack/mod.rs b/src-tauri/src/instance/helpers/modpack/mod.rs index 79a6020fc..a31dd1b7d 100644 --- a/src-tauri/src/instance/helpers/modpack/mod.rs +++ b/src-tauri/src/instance/helpers/modpack/mod.rs @@ -1,4 +1,5 @@ pub mod curseforge; +pub mod export; pub mod misc; pub mod modrinth; pub mod multimc; diff --git a/src-tauri/src/instance/helpers/modpack/modrinth.rs b/src-tauri/src/instance/helpers/modpack/modrinth.rs index 0c29e15e7..251bc04f5 100644 --- a/src-tauri/src/instance/helpers/modpack/modrinth.rs +++ b/src-tauri/src/instance/helpers/modpack/modrinth.rs @@ -1,3 +1,4 @@ +use smart_default::SmartDefault; use std::collections::HashMap; use std::fs::File; use std::io::Read; @@ -5,6 +6,7 @@ use std::path::Path; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use serialize_skip_none_derive::serialize_skip_none; use tauri::AppHandle; use zip::ZipArchive; @@ -16,15 +18,16 @@ use crate::tasks::download::DownloadParam; use crate::tasks::PTaskParam; structstruck::strike! { +#[strikethrough[serialize_skip_none]] #[strikethrough[derive(Deserialize, Serialize, Debug, Clone)]] #[strikethrough[serde(rename_all = "camelCase")]] pub struct ModrinthFile { pub path: String, - pub hashes: struct { + pub hashes: struct ModrinthFileHashes { pub sha1: String, pub sha512: String, }, - pub env: Option, @@ -33,9 +36,14 @@ pub struct ModrinthFile { } } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[serialize_skip_none] +#[derive(Deserialize, Serialize, Debug, Clone, SmartDefault)] #[serde(rename_all = "camelCase")] pub struct ModrinthManifest { + #[default = 1] + pub format_version: u64, + #[default = "minecraft"] + pub game: String, pub version_id: String, pub name: String, pub summary: Option, diff --git a/src-tauri/src/instance/models/misc.rs b/src-tauri/src/instance/models/misc.rs index 6ab48a0ec..434857dd0 100644 --- a/src-tauri/src/instance/models/misc.rs +++ b/src-tauri/src/instance/models/misc.rs @@ -284,3 +284,10 @@ pub struct OptiFine { } impl std::error::Error for InstanceError {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModpackFileList { + pub all: Vec, + pub unchecked: Vec, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c026083c1..5c1a05202 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -121,6 +121,8 @@ pub async fn run() { instance::commands::change_mod_loader, instance::commands::retrieve_modpack_meta_info, instance::commands::add_custom_instance_icon, + instance::commands::list_modpack_files, + instance::commands::export_modpack, launch::commands::select_suitable_jre, launch::commands::validate_game_files, launch::commands::validate_selected_player, diff --git a/src-tauri/src/resource/helpers/curseforge/mod.rs b/src-tauri/src/resource/helpers/curseforge/mod.rs index aa8fed72a..388f3a962 100644 --- a/src-tauri/src/resource/helpers/curseforge/mod.rs +++ b/src-tauri/src/resource/helpers/curseforge/mod.rs @@ -19,7 +19,6 @@ use murmur2::murmur2; use serde_json::json; use sha1::{Digest, Sha1}; use std::collections::HashMap; -use std::path::Path; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; @@ -148,12 +147,9 @@ pub async fn fetch_remote_resource_by_local_curseforge( app: &AppHandle, file_path: &str, ) -> SJMCLResult { - let file_path = Path::new(file_path); - if !file_path.exists() { - return Err(ResourceError::ParseError.into()); - } - - let file_content = std::fs::read(file_path).map_err(|_| ResourceError::ParseError)?; + let file_content = tokio::fs::read(file_path) + .await + .map_err(|_| ResourceError::ParseError)?; // Calculate SHA1 hash of the local file for verification let mut hasher = Sha1::new(); diff --git a/src-tauri/src/resource/helpers/modrinth/mod.rs b/src-tauri/src/resource/helpers/modrinth/mod.rs index 23db47245..1a5bec72a 100644 --- a/src-tauri/src/resource/helpers/modrinth/mod.rs +++ b/src-tauri/src/resource/helpers/modrinth/mod.rs @@ -16,7 +16,6 @@ use misc::{ }; use sha1::{Digest, Sha1}; use std::collections::HashMap; -use std::fs; use std::path::PathBuf; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; @@ -128,7 +127,9 @@ pub async fn fetch_remote_resource_by_local_modrinth( app: &AppHandle, file_path: &str, ) -> SJMCLResult { - let file_content = fs::read(file_path).map_err(|_| ResourceError::ParseError)?; + let file_content = tokio::fs::read(file_path) + .await + .map_err(|_| ResourceError::ParseError)?; let mut hasher = Sha1::new(); hasher.update(&file_content); diff --git a/src-tauri/src/utils/fs.rs b/src-tauri/src/utils/fs.rs index 2eecd2f36..3b6c57b1d 100644 --- a/src-tauri/src/utils/fs.rs +++ b/src-tauri/src/utils/fs.rs @@ -1,5 +1,8 @@ use crate::error::{SJMCLError, SJMCLResult}; use crate::IS_PORTABLE; +use async_zip::tokio::write::ZipFileWriter; +use async_zip::Compression; +use async_zip::ZipEntryBuilder; use regex::Regex; use sha1::{Digest, Sha1}; use sha2::Sha256; @@ -9,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::{fs, io}; use tauri::path::BaseDirectory; use tauri::{AppHandle, Manager}; +use tokio_util::compat::TokioAsyncReadCompatExt; use zip::write::{ExtendedFileOptions, FileOptions}; use zip::{CompressionMethod, ZipWriter}; @@ -467,6 +471,31 @@ pub fn create_zip_from_dirs(paths: Vec, zip_file_path: PathBuf) -> SJMC Ok(zip_file_path.to_string_lossy().to_string()) } +pub async fn create_modpack_zip( + save_path: &str, + overrides_prefix: &str, + overrides_files: Vec<(String, PathBuf)>, + extra_files: Vec<(String, String)>, +) -> SJMCLResult<()> { + let output = tokio::fs::File::create(save_path).await?; + let mut writer = ZipFileWriter::with_tokio(output); + for (name, content) in extra_files { + let entry = ZipEntryBuilder::new(name.into(), Compression::Deflate); + writer.write_entry_whole(entry, content.as_bytes()).await?; + } + + for (rel, full) in overrides_files { + let file = tokio::fs::File::open(&full).await?.compat(); + let entry_path = format!("{}/{}", overrides_prefix, rel); + let entry = ZipEntryBuilder::new(entry_path.into(), Compression::Deflate); + let mut entry_writer = writer.write_entry_stream(entry).await?; + futures::io::copy(file, &mut entry_writer).await?; + entry_writer.close().await?; + } + writer.close().await?; + Ok(()) +} + /// Enum to define the permission operation #[derive(Debug)] pub enum PermissionOperation { diff --git a/src/components/modals/export-modpack-modal.tsx b/src/components/modals/export-modpack-modal.tsx new file mode 100644 index 000000000..6b88cf192 --- /dev/null +++ b/src/components/modals/export-modpack-modal.tsx @@ -0,0 +1,605 @@ +import { + Box, + Button, + Center, + Checkbox, + HStack, + IconButton, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalProps, + Radio, + RadioGroup, + Stack, + Switch, + Text, + VStack, +} from "@chakra-ui/react"; +import { save } from "@tauri-apps/plugin-dialog"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import { BeatLoader } from "react-spinners"; +import Editable from "@/components/common/editable"; +import Empty from "@/components/common/empty"; +import { + OptionItemGroup, + OptionItemGroupProps, +} from "@/components/common/option-item"; +import { useLauncherConfig } from "@/contexts/config"; +import { useToast } from "@/contexts/toast"; +import { ModpackFileList } from "@/models/instance/misc"; +import { InstanceService } from "@/services/instance"; +import { isFileNameSanitized, sanitizeFileName } from "@/utils/string"; + +interface ExportModpackModalProps extends Omit { + instanceId: string; + instanceName: string; +} + +interface ExportModpackOptions { + format: "Modrinth" | "MultiMC"; + name: string; + version: string; + author?: string; + description?: string; + packWithLauncher?: boolean; + minMemory?: number; + noCreateRemoteFiles?: boolean; + skipCurseForgeRemoteFiles?: boolean; +} + +interface FileTreeNode { + name: string; + path: string; + isFile: boolean; + children: FileTreeNode[]; +} + +const buildFileTree = (paths: string[]): FileTreeNode[] => { + type InternalNode = { + name: string; + path: string; + isFile: boolean; + children: Map; + }; + + const root = new Map(); + for (const rawPath of paths) { + const parts = rawPath.split("/").filter(Boolean); + let current = root; + let currentPath = ""; + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + let node = current.get(part); + if (!node) { + node = { + name: part, + path: currentPath, + isFile: false, + children: new Map(), + }; + current.set(part, node); + } + if (index === parts.length - 1) { + node.isFile = true; + } + current = node.children; + }); + } + + const convert = (map: Map): FileTreeNode[] => + Array.from(map.values()) + .sort((a, b) => { + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; + return a.name.localeCompare(b.name); + }) + .map((node) => ({ + name: node.name, + path: node.path, + isFile: node.isFile, + children: convert(node.children), + })); + + return convert(root); +}; + +const collectLeafPaths = (node: FileTreeNode): string[] => { + if (node.isFile || node.children.length === 0) { + return [node.path]; + } + return node.children.flatMap((child) => collectLeafPaths(child)); +}; + +const ExportModpackModal: React.FC = ({ + instanceId, + instanceName, + ...modalProps +}) => { + const { t } = useTranslation(); + const { config } = useLauncherConfig(); + const toast = useToast(); + const primaryColor = config.appearance.theme.primaryColor; + + const [exportFormat, setExportFormat] = useState<"Modrinth" | "MultiMC">( + "Modrinth" + ); + const [modpackName, setModpackName] = useState(instanceName); + const [modpackVersion, setModpackVersion] = useState("1.0.0"); + const [author, setAuthor] = useState(""); + const [description, setDescription] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [fileList, setFileList] = useState(null); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [isFileListLoading, setIsFileListLoading] = useState(false); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [packWithLauncher, setPackWithLauncher] = useState(false); + const [minMemoryInput, setMinMemoryInput] = useState(""); + const [noCreateRemoteFiles, setNoCreateRemoteFiles] = useState(false); + const [skipCurseForgeRemoteFiles, setSkipCurseForgeRemoteFiles] = + useState(false); + + const checkNameError = useCallback((value: string): number => { + if (value.trim() === "") return 1; + if (!isFileNameSanitized(value)) return 2; + if (value.length > 255) return 3; + return 0; + }, []); + + const checkVersionError = useCallback((value: string): number => { + if (value.trim() === "") return 1; + return 0; + }, []); + + useEffect(() => { + if (!modalProps.isOpen) return; + let isActive = true; + setIsFileListLoading(true); + + InstanceService.listModpackFiles(instanceId) + .then((response) => { + if (!isActive) return; + if (response.status === "success") { + setFileList(response.data); + const nextSelected = new Set(response.data.all); + response.data.unchecked.forEach((path) => nextSelected.delete(path)); + setSelectedFiles(nextSelected); + } else { + toast({ + title: t("ExportModpackModal.error.title"), + description: response.details || response.message, + status: "error", + }); + } + }) + .catch((error) => { + if (!isActive) return; + toast({ + title: t("ExportModpackModal.error.title"), + description: String(error), + status: "error", + }); + }) + .finally(() => { + if (!isActive) return; + setIsFileListLoading(false); + }); + + return () => { + isActive = false; + }; + }, [instanceId, modalProps.isOpen, t, toast]); + + const fileTree = useMemo( + () => (fileList ? buildFileTree(fileList.all) : []), + [fileList] + ); + + const selectedFileList = useMemo(() => { + if (!fileList) return []; + return fileList.all.filter((path) => selectedFiles.has(path)); + }, [fileList, selectedFiles]); + + const isMultiMC = exportFormat === "MultiMC"; + const isModrinth = exportFormat === "Modrinth"; + + const handleExport = useCallback(async () => { + // Validate inputs + if (checkNameError(modpackName) !== 0) { + toast({ + title: t("ExportModpackModal.error.invalidName"), + status: "warning", + }); + return; + } + + if (checkVersionError(modpackVersion) !== 0) { + toast({ + title: t("ExportModpackModal.error.invalidVersion"), + status: "warning", + }); + return; + } + + // Open save dialog + const fileExtension = exportFormat === "Modrinth" ? "mrpack" : "zip"; + const savePath = await save({ + defaultPath: `${sanitizeFileName(modpackName)}-${modpackVersion}.${fileExtension}`, + filters: [ + { + name: t("General.dialog.filterName.modpack"), + extensions: [fileExtension], + }, + ], + }); + + if (!savePath) { + return; // User cancelled + } + + setIsLoading(true); + + try { + const parsedMinMemory = minMemoryInput.trim() + ? Number.parseInt(minMemoryInput.trim(), 10) + : undefined; + + const options: ExportModpackOptions = { + format: exportFormat, + name: modpackName, + version: modpackVersion, + author: author || undefined, + description: description || undefined, + packWithLauncher: packWithLauncher || undefined, + minMemory: Number.isNaN(parsedMinMemory || NaN) + ? undefined + : parsedMinMemory, + noCreateRemoteFiles: noCreateRemoteFiles || undefined, + skipCurseForgeRemoteFiles: skipCurseForgeRemoteFiles || undefined, + }; + + const response = await InstanceService.exportModpack( + instanceId, + savePath, + options, + selectedFileList + ); + + if (response.status === "success") { + toast({ + title: t("ExportModpackModal.success.title"), + description: t("ExportModpackModal.success.description"), + status: "success", + }); + modalProps.onClose?.(); + } else { + toast({ + title: t("ExportModpackModal.error.title"), + description: response.details || response.message, + status: "error", + }); + } + } catch (error) { + toast({ + title: t("ExportModpackModal.error.title"), + description: String(error), + status: "error", + }); + } finally { + setIsLoading(false); + } + }, [ + checkNameError, + modpackName, + checkVersionError, + modpackVersion, + exportFormat, + t, + toast, + minMemoryInput, + author, + description, + packWithLauncher, + noCreateRemoteFiles, + skipCurseForgeRemoteFiles, + instanceId, + selectedFileList, + modalProps, + ]); + + const FileTreeItem: React.FC<{ node: FileTreeNode; depth?: number }> = ({ + node, + depth = 0, + }) => { + const expanded = expandedPaths.has(node.path); + const leafPaths = useMemo(() => collectLeafPaths(node), [node]); + const selectedCount = useMemo( + () => leafPaths.filter((path) => selectedFiles.has(path)).length, + [leafPaths] + ); + const isChecked = + leafPaths.length > 0 && selectedCount === leafPaths.length; + const isIndeterminate = + selectedCount > 0 && selectedCount < leafPaths.length; + + const handleToggle = (checked: boolean) => { + setSelectedFiles((prev) => { + const next = new Set(prev); + leafPaths.forEach((path) => { + if (checked) { + next.add(path); + } else { + next.delete(path); + } + }); + return next; + }); + }; + + return ( + + + {!node.isFile ? ( + : } + size="xs" + variant="ghost" + onClick={() => + setExpandedPaths((prev) => { + const next = new Set(prev); + if (expanded) { + next.delete(node.path); + } else { + next.add(node.path); + } + return next; + }) + } + /> + ) : ( + + )} + handleToggle(e.target.checked)} + colorScheme={primaryColor} + size="sm" + > + + {node.name} + + + + {!node.isFile && expanded && ( + + {node.children.map((child) => ( + + ))} + + )} + + ); + }; + + const optionGroups: OptionItemGroupProps[] = [ + { + title: t("ExportModpackModal.label.exportFormat"), + items: [ + { + title: "", + children: ( + setExportFormat(val as "Modrinth" | "MultiMC")} + w="100%" + > + + + Modrinth + + + MultiMC + + + + ), + }, + ], + }, + { + title: t("ExportModpackModal.label.basicInfo"), + items: [ + { + title: t("ExportModpackModal.label.modpackName"), + children: ( + + ), + }, + { + title: t("ExportModpackModal.label.modpackVersion"), + children: ( + + ), + }, + { + title: t("ExportModpackModal.label.author"), + children: ( + + ), + }, + { + title: t("ExportModpackModal.label.description"), + children: ( + + ), + }, + ], + }, + { + title: t("ExportModpackModal.label.includedFiles"), + items: [ + { + title: "", + children: ( + + {isFileListLoading ? ( +
+ +
+ ) : fileTree.length > 0 ? ( + + {fileTree.map((node) => ( + + ))} + + ) : ( + + )} +
+ ), + }, + ], + }, + { + title: t("ExportModpackModal.label.exportOptions"), + items: [ + { + title: t("ExportModpackModal.label.packWithLauncher"), + children: ( + setPackWithLauncher(e.target.checked)} + /> + ), + }, + ...(isMultiMC + ? [ + { + title: t("ExportModpackModal.label.minMemory"), + children: ( + + ), + }, + ] + : []), + ...(isModrinth + ? [ + { + title: t("ExportModpackModal.label.noCreateRemoteFiles"), + children: ( + setNoCreateRemoteFiles(e.target.checked)} + /> + ), + }, + { + title: t("ExportModpackModal.label.skipCurseForgeRemoteFiles"), + children: ( + + setSkipCurseForgeRemoteFiles(e.target.checked) + } + /> + ), + }, + ] + : []), + ].filter(Boolean) as OptionItemGroupProps["items"], + }, + ]; + + return ( + + + + {t("ExportModpackModal.header.title")} + + + {optionGroups.map((group, index) => ( + + ))} + + + + + + + + + + ); +}; + +export default ExportModpackModal; diff --git a/src/components/special/shared-modals-provider.tsx b/src/components/special/shared-modals-provider.tsx index d9f306a50..f29ea2578 100644 --- a/src/components/special/shared-modals-provider.tsx +++ b/src/components/special/shared-modals-provider.tsx @@ -5,6 +5,7 @@ import CopyOrMoveModal from "@/components/modals/copy-or-move-modal"; import DeleteInstanceDialog from "@/components/modals/delete-instance-alert-dialog"; import DownloadModpackModal from "@/components/modals/download-modpack-modal"; import DownloadResourceModal from "@/components/modals/download-resource-modal"; +import ExportModpackModal from "@/components/modals/export-modpack-modal"; import GenericConfirmDialog from "@/components/modals/generic-confirm-dialog"; import ImportModpackModal from "@/components/modals/import-modpack-modal"; import LaunchProcessModal from "@/components/modals/launch-process-modal"; @@ -37,6 +38,7 @@ const SharedModals: React.FC<{ children: React.ReactNode }> = ({ "download-modpack": DownloadModpackModal, "download-resource": DownloadResourceModal, "download-specific-resource": DownloadSpecificResourceModal, + "export-modpack": ExportModpackModal, "generic-confirm": GenericConfirmDialog, "import-modpack": ImportModpackModal, launch: LaunchProcessModal, diff --git a/src/layouts/instance-details-layout.tsx b/src/layouts/instance-details-layout.tsx index 468555ec2..fe7138ccc 100644 --- a/src/layouts/instance-details-layout.tsx +++ b/src/layouts/instance-details-layout.tsx @@ -116,7 +116,14 @@ const InstanceDetailsLayoutContent: React.FC<{ children: React.ReactNode }> = ({ icon: LuPackagePlus, label: t("InstanceDetailsLayout.secMenu.exportModPack"), danger: false, - onClick: () => {}, + onClick: () => { + if (summary) { + openSharedModal("export-modpack", { + instanceId: summary.id, + instanceName: summary.name, + }); + } + }, }, { icon: "delete", diff --git a/src/locales/en.json b/src/locales/en.json index 7e4f7b7ec..e7c7ee0ab 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -907,6 +907,7 @@ "notice": "Notice", "open": "Open", "openFolder": "Open Folder", + "optional": "Optional", "previous": "Previous", "refresh": "Refresh", "revealFile": "Reveal in {{opener}}", @@ -1204,6 +1205,43 @@ "launch": "Launch Instance" } }, + "ExportModpackModal": { + "header": { + "title": "Export Modpack" + }, + "label": { + "exportFormat": "Export Format", + "basicInfo": "Basic Information", + "modpackName": "Modpack Name", + "modpackVersion": "Modpack Version", + "author": "Author", + "description": "Description", + "includedFiles": "Included Files", + "exportOptions": "Export Options", + "packWithLauncher": "Pack With Launcher", + "minMemory": "Minimum Memory (MB)", + "noCreateRemoteFiles": "Disable Remote Files", + "skipCurseForgeRemoteFiles": "Skip CurseForge Remote Files" + }, + "button": { + "export": "Export" + }, + "error": { + "title": "Export Failed", + "invalidName": "Please enter a valid modpack name", + "invalidVersion": "Please enter a valid version number", + "noCategorySelected": "Please select at least one category to include" + }, + "errorMessage": { + "1": "Name cannot be empty", + "2": "Name contains invalid characters", + "3": "Name is too long (max 255 characters)" + }, + "success": { + "title": "Export Successful", + "description": "Modpack has been exported successfully" + } + }, "InstanceIconSelector": { "customize": "Customize Icon" }, diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index f2fc3b999..56a7d1a2b 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -907,6 +907,7 @@ "notice": "提示", "open": "打开", "openFolder": "打开文件夹", + "optional": "可选", "previous": "上一步", "refresh": "刷新", "revealFile": "在{{opener}}中显示", @@ -1204,6 +1205,43 @@ "launch": "启动实例" } }, + "ExportModpackModal": { + "header": { + "title": "导出整合包" + }, + "label": { + "exportFormat": "导出格式", + "basicInfo": "基本信息", + "modpackName": "整合包名称", + "modpackVersion": "整合包版本", + "author": "作者", + "description": "描述", + "includedFiles": "包含文件", + "exportOptions": "导出选项", + "packWithLauncher": "打包启动器", + "minMemory": "最低内存 (MB)", + "noCreateRemoteFiles": "禁用远程文件", + "skipCurseForgeRemoteFiles": "跳过 CurseForge 远程文件" + }, + "button": { + "export": "导出" + }, + "error": { + "title": "导出失败", + "invalidName": "请输入有效的整合包名称", + "invalidVersion": "请输入有效的版本号", + "noCategorySelected": "请至少选择一个类别" + }, + "errorMessage": { + "1": "名称不能为空", + "2": "名称包含无效字符", + "3": "名称过长(最多 255 个字符)" + }, + "success": { + "title": "导出成功", + "description": "整合包已成功导出" + } + }, "InstanceIconSelector": { "customize": "自定义图像" }, diff --git a/src/models/instance/misc.ts b/src/models/instance/misc.ts index a157cf079..5b5354ae1 100644 --- a/src/models/instance/misc.ts +++ b/src/models/instance/misc.ts @@ -49,6 +49,11 @@ export interface ModpackMetaInfo { modLoader?: ModLoader; } +export interface ModpackFileList { + all: string[]; + unchecked: string[]; +} + export interface GameServerInfo { iconSrc: string; ip: string; diff --git a/src/services/instance.ts b/src/services/instance.ts index 17544316f..7311b94d3 100644 --- a/src/services/instance.ts +++ b/src/services/instance.ts @@ -5,6 +5,7 @@ import { GameServerInfo, InstanceSummary, LocalModInfo, + ModpackFileList, ModpackMetaInfo, ResourcePackInfo, SchematicInfo, @@ -455,4 +456,41 @@ export class InstanceService { sourceSrc, }); } + + /** + * Export the instance as a modpack. + * @param {string} instanceId - The ID of the instance to export. + * @param {string} savePath - The destination path for the exported modpack. + * @param {any} options - Export configuration options. + * @param {string[]} files - The selected files to include in the export. + * @returns {Promise>} + */ + @responseHandler("instance") + static async exportModpack( + instanceId: string, + savePath: string, + options: any, + files: string[] + ): Promise> { + return await invoke("export_modpack", { + instanceId, + savePath, + options, + files, + }); + } + + /** + * Retrieve exportable file list for modpack export. + * @param {string} instanceId - The ID of the instance. + * @returns {Promise>} + */ + @responseHandler("instance") + static async listModpackFiles( + instanceId: string + ): Promise> { + return await invoke("list_modpack_files", { + instanceId, + }); + } }