diff --git a/.gitignore b/.gitignore index 7d6314ab..c8a133cf 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ next-env.d.ts *.sln *.sw? CLAUDE.md -.claude/ \ No newline at end of file +.claude/ +.zcode/ \ No newline at end of file diff --git a/src-tauri/src/instance/commands.rs b/src-tauri/src/instance/commands.rs index a3baf3b3..a3a00e0a 100644 --- a/src-tauri/src/instance/commands.rs +++ b/src-tauri/src/instance/commands.rs @@ -1246,6 +1246,47 @@ pub async fn scan_instance_files_for_export( Ok(entries) } +/// Scan an instance for the full pack export: the version-isolated instance +/// content plus (when version isolation is off) the shared game dir directories. +#[tauri::command] +pub async fn scan_instance_files_for_full_export( + app: AppHandle, + instance_id: String, +) -> XMCLResult> { + use crate::instance::helpers::modpack::export_fullzip::scan_instance_for_full_export; + + let (version_path, version_isolation) = { + let binding = app.state::>>(); + let state = binding.lock().unwrap(); + let instance = state + .get(&instance_id) + .ok_or(InstanceError::InstanceNotFoundByID)?; + ( + instance.version_path.clone(), + get_instance_game_config(&app, instance).version_isolation, + ) + }; + let game_dir = version_path + .parent() + .and_then(|p| p.parent()) + .ok_or(InstanceError::InstanceNotFoundByID)?; + + let entries = scan_instance_for_full_export(&version_path, game_dir, version_isolation) + .map_err(|_| InstanceError::FileNotFoundError)?; + + Ok(entries) +} + +/// Import a full pack (self-contained zip with instance + libraries + assets) +/// into the given game directory. Pure extraction, nothing is downloaded. +/// Returns the new instance id (`:`). +#[tauri::command] +pub async fn import_full_pack(directory: GameDirectory, pack_path: String) -> XMCLResult { + use crate::instance::helpers::modpack::export_fullzip::import_full_pack as do_import_full_pack; + + do_import_full_pack(&directory, &PathBuf::from(pack_path)) +} + #[tauri::command] pub async fn export_modpack( app: AppHandle, @@ -1263,8 +1304,8 @@ pub async fn export_modpack( let instance_path = get_instance_subdir_path_by_id(&app, &instance_id, &InstanceSubdirType::Root) .ok_or(InstanceError::InstanceNotFoundByID)?; - // Retrieve instance to get mc version and mod loader info - let (mc_version, loader_type, loader_version) = { + // Retrieve instance to get mc version, mod loader info and full pack details + let (mc_version, loader_type, loader_version, version_path, version_isolation) = { let binding = app.state::>>(); let state = binding.lock().unwrap(); let instance = state @@ -1274,6 +1315,8 @@ pub async fn export_modpack( instance.version.clone(), instance.mod_loader.loader_type.clone(), instance.mod_loader.version.clone(), + instance.version_path.clone(), + get_instance_game_config(&app, instance).version_isolation, ) }; @@ -1318,6 +1361,25 @@ pub async fn export_modpack( &out, )?; } + ExportFormat::Full => { + use crate::instance::helpers::modpack::export_fullzip::export_full_pack; + let game_dir = version_path + .parent() + .and_then(|p| p.parent()) + .ok_or(InstanceError::InstanceNotFoundByID)?; + export_full_pack( + &app, + &version_path, + game_dir, + &meta, + &mc_version, + &loader_type, + &loader_version, + version_isolation, + &selected_files, + &out, + )?; + } } Ok(()) diff --git a/src-tauri/src/instance/helpers/modpack/export_common.rs b/src-tauri/src/instance/helpers/modpack/export_common.rs index a44b21fd..1097a11b 100644 --- a/src-tauri/src/instance/helpers/modpack/export_common.rs +++ b/src-tauri/src/instance/helpers/modpack/export_common.rs @@ -22,6 +22,7 @@ pub struct ExportProgressPayload { pub enum ExportStage { Matching, Packing, + Copying, WritingManifest, Done, } diff --git a/src-tauri/src/instance/helpers/modpack/export_fullzip.rs b/src-tauri/src/instance/helpers/modpack/export_fullzip.rs new file mode 100644 index 00000000..eba82c76 --- /dev/null +++ b/src-tauri/src/instance/helpers/modpack/export_fullzip.rs @@ -0,0 +1,495 @@ +use crate::error::XMCLResult; +use crate::instance::helpers::modpack::export_common::{ + categorize, emit_progress, normalize_relative_path, scan_dir, ExportStage, +}; +use crate::instance::models::misc::{ + ExportFileEntry, ExportModpackMeta, InstanceError, ModLoader, ModLoaderType, +}; +use crate::launcher_config::models::GameDirectory; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +/// Manifest written at the zip root to identify an XMCL full pack and carry metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FullPackManifest { + pub format_version: u32, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub minecraft_version: String, + pub mod_loader: ModLoader, + pub file_count: usize, + pub total_size: u64, +} + +/// Instance sub-directories shared at the game dir root when version isolation is off. +const SHARED_GAME_DIRS: &[&str] = &[ + "mods", + "resourcepacks", + "shaderpacks", + "saves", + "schematics", + "screenshots", + "server-resource-packs", + "config", + "datapacks", +]; + +/// Root-level game files shared at the game dir root when version isolation is off. +const SHARED_GAME_FILES: &[&str] = &[ + "options.txt", + "optionsshaders.txt", + "optionsof.txt", + "servers.dat", + "realms_persistence.json", +]; + +/// Files that are mandatory for the imported instance to launch; always packed, +/// never shown in the file selection tree. +const CORE_INSTANCE_FILES: &[&str] = &["xmclcfg.json"]; + +/// Directories under the instance root that are rebuilt at launch and should not be packed. +const IGNORED_INSTANCE_DIR_PREFIXES: &[&str] = &["natives"]; + +/// Files with these extensions are already compressed; store them as-is to save CPU time. +fn should_compress(path: &str) -> bool { + let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + !matches!( + ext.as_str(), + "jar" + | "zip" + | "gz" + | "7z" + | "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "ogg" + | "mp3" + | "mp4" + | "webm" + | "bin" + | "nib" + | "class" + | "pack" + | "icns" + | "ico" + ) +} + +fn compression_options(path: &str) -> SimpleFileOptions { + if should_compress(path) { + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated) + } else { + SimpleFileOptions::default().compression_method(CompressionMethod::Stored) + } +} + +/// Check whether a path is inside `base`, and reject anything escaping it. +fn safe_join(base: &Path, rel: &str) -> Option { + let normalized = normalize_relative_path(rel)?; + Some(base.join(normalized.replace('/', std::path::MAIN_SEPARATOR_STR))) +} + +/// Recursively collect (relative_path, absolute_path) for every file under `base`. +fn walk_files(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> { + let read_dir = match fs::read_dir(dir) { + Ok(rd) => rd, + Err(_) => return Ok(()), + }; + let mut entries: Vec<_> = read_dir.flatten().collect(); + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + walk_files(base, &path, out)?; + continue; + } + let rel = match path.strip_prefix(base) { + Ok(r) => r.to_string_lossy().replace('\\', "/"), + Err(_) => continue, + }; + out.push((rel, path)); + } + Ok(()) +} + +fn is_core_file(name: &str, rel: &str) -> bool { + let first = rel.split('/').next().unwrap_or(rel); + if CORE_INSTANCE_FILES.contains(&first) { + return true; + } + // The client jar and client json are named after the instance and are required + // for the instance to be recognized and launched after import. + rel == format!("{}.jar", name) || rel == format!("{}.json", name) +} + +/// Scan an instance for the full pack export. +/// +/// The scan base is always the version path (`versions//`) so the produced +/// zip has a uniform, version-isolated layout. When version isolation is off, +/// shared directories at the game dir root (`mods/`, `config/`, ...) are merged +/// into the listing under their plain directory names. +pub fn scan_instance_for_full_export( + version_path: &Path, + game_dir: &Path, + version_isolation: bool, +) -> std::io::Result> { + let name = version_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let mut entries = scan_dir(version_path, version_path)?; + entries.retain(|entry| { + let first = entry.relative_path.split('/').next().unwrap_or(""); + let ignored_dir = IGNORED_INSTANCE_DIR_PREFIXES + .iter() + .any(|prefix| first.starts_with(prefix)); + !ignored_dir && !is_core_file(&name, &entry.relative_path) + }); + + if !version_isolation { + for dir in SHARED_GAME_DIRS { + let shared = game_dir.join(dir); + if !shared.is_dir() { + continue; + } + // Skip when the instance root already owns this directory (isolation is off + // for game content but the version dir may still hold some leftovers). + if version_path.join(dir).exists() { + continue; + } + let mut children = scan_dir(&shared, &shared)?; + for child in &mut children { + child.relative_path = format!("{}/{}", dir, child.relative_path); + } + entries.extend(children); + } + for file in SHARED_GAME_FILES { + let shared = game_dir.join(file); + if shared.is_file() { + let metadata = fs::metadata(&shared)?; + entries.push(ExportFileEntry { + relative_path: file.to_string(), + is_directory: false, + category: categorize(file, false), + file_size: metadata.len(), + }); + } + } + } + + Ok(entries) +} + +/// Map a selected relative path to its actual source file. +/// +/// The scan base is the version path, but when version isolation is off the +/// shared directories live at the game dir root, so try both locations. +fn selected_source_path( + version_path: &Path, + game_dir: &Path, + rel: &str, + version_isolation: bool, +) -> Option { + let in_version = safe_join(version_path, rel)?; + if in_version.is_file() { + return Some(in_version); + } + if !version_isolation { + let in_game_dir = safe_join(game_dir, rel)?; + if in_game_dir.is_file() { + return Some(in_game_dir); + } + } + None +} + +/// Export an instance as a self-contained full pack (.zip). +/// +/// Zip layout: +/// ```text +/// / +/// ├── xmcl-full-pack.json # identification manifest +/// ├── instance// # version-isolated instance content +/// ├── libraries/ # game dir shared libraries +/// └── assets/ # game dir shared assets (indexes + objects) +/// ``` +/// After import nothing needs to be downloaded: the game jar, libraries and +/// assets are all inside the pack, so launch validation passes offline. +pub fn export_full_pack( + app: &AppHandle, + version_path: &Path, + game_dir: &Path, + meta: &ExportModpackMeta, + mc_version: &str, + mod_loader_type: &ModLoaderType, + mod_loader_version: &str, + version_isolation: bool, + selected_files: &[String], + output_path: &Path, +) -> XMCLResult<()> { + let name = version_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let output_file = fs::File::create(output_path).map_err(|_| InstanceError::FileCreationFailed)?; + let mut zip = ZipWriter::new(output_file); + + // --- Collect instance content (user selection + mandatory core files) --- + let mut pack_files: Vec<(String, PathBuf)> = Vec::new(); + + // Mandatory files that make the instance recognizable and launchable. + for core in CORE_INSTANCE_FILES { + let abs = version_path.join(core); + if abs.is_file() { + pack_files.push((core.to_string(), abs)); + } + } + for core in [format!("{}.jar", name), format!("{}.json", name)] { + let abs = version_path.join(&core); + if abs.is_file() { + pack_files.push((core, abs)); + } + } + + // User selection, deduplicated against the core files. + let mut seen: std::collections::HashSet = + pack_files.iter().map(|(rel, _)| rel.clone()).collect(); + for rel in selected_files { + let Some(rel) = normalize_relative_path(rel) else { + continue; + }; + if !seen.insert(rel.clone()) { + continue; + } + if is_core_file(&name, &rel) { + continue; + } + if let Some(abs) = selected_source_path(version_path, game_dir, &rel, version_isolation) { + pack_files.push((rel, abs)); + } + } + + let total_size: u64 = pack_files + .iter() + .filter_map(|(_, abs)| fs::metadata(abs).ok().map(|m| m.len())) + .sum(); + + // --- Write identification manifest --- + let manifest = FullPackManifest { + format_version: 1, + name: name.clone(), + version: meta.version.clone(), + description: meta.description.clone(), + author: Some(meta.author.clone()), + minecraft_version: mc_version.to_string(), + mod_loader: ModLoader { + loader_type: mod_loader_type.clone(), + version: mod_loader_version.to_string(), + ..Default::default() + }, + file_count: pack_files.len(), + total_size, + }; + let manifest_json = serde_json::to_string_pretty(&manifest) + .map_err(|_| InstanceError::ModpackManifestParseError)?; + zip + .start_file( + "xmcl-full-pack.json", + compression_options("xmcl-full-pack.json"), + ) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + zip + .write_all(manifest_json.as_bytes()) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + + // --- Pack instance content --- + let total = pack_files.len(); + for (i, (rel, abs)) in pack_files.iter().enumerate() { + let file_name = rel.rsplit('/').next().unwrap_or(rel); + emit_progress(app, i + 1, total, file_name, ExportStage::Packing); + let zip_path = format!("instance/{}/{}", name, rel); + zip + .start_file(&zip_path, compression_options(&rel)) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + let data = fs::read(abs).map_err(|_| InstanceError::FileNotFoundError)?; + zip + .write_all(&data) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + } + + // --- Pack shared game libraries --- + let libs_dir = game_dir.join("libraries"); + let mut lib_files = Vec::new(); + if libs_dir.is_dir() { + walk_files(&libs_dir, &libs_dir, &mut lib_files)?; + } + let lib_total = lib_files.len(); + for (i, (rel, abs)) in lib_files.iter().enumerate() { + if i % 25 == 0 || i + 1 == lib_total { + emit_progress( + app, + i + 1, + lib_total, + rel.rsplit('/').next().unwrap_or(rel), + ExportStage::Copying, + ); + } + let zip_path = format!("libraries/{}", rel); + zip + .start_file(&zip_path, compression_options(rel)) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + let data = fs::read(abs).map_err(|_| InstanceError::FileNotFoundError)?; + zip + .write_all(&data) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + } + + // --- Pack shared game assets --- + let assets_dir = game_dir.join("assets"); + let mut asset_files = Vec::new(); + if assets_dir.is_dir() { + walk_files(&assets_dir, &assets_dir, &mut asset_files)?; + } + let asset_total = asset_files.len(); + for (i, (rel, abs)) in asset_files.iter().enumerate() { + if i % 25 == 0 || i + 1 == asset_total { + emit_progress( + app, + i + 1, + asset_total, + rel.rsplit('/').next().unwrap_or(rel), + ExportStage::Copying, + ); + } + let zip_path = format!("assets/{}", rel); + zip + .start_file(&zip_path, compression_options(rel)) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + let data = fs::read(abs).map_err(|_| InstanceError::FileNotFoundError)?; + zip + .write_all(&data) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + } + + zip + .finish() + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + + emit_progress(app, 0, 0, "", ExportStage::Done); + + Ok(()) +} + +/// Import a full pack into the given game directory. +/// +/// Pure extraction — nothing is downloaded. Returns the new instance id +/// (`:`). +pub fn import_full_pack(directory: &GameDirectory, pack_path: &Path) -> XMCLResult { + let file = fs::File::open(pack_path).map_err(|_| InstanceError::FileNotFoundError)?; + let mut archive = ZipArchive::new(file).map_err(|_| InstanceError::ModpackManifestParseError)?; + + // Read the identification manifest. + let mut manifest_str = String::new(); + archive + .by_name("xmcl-full-pack.json") + .map_err(|_| InstanceError::ModpackManifestParseError)? + .read_to_string(&mut manifest_str) + .map_err(|_| InstanceError::ModpackManifestParseError)?; + let manifest: FullPackManifest = + serde_json::from_str(&manifest_str).map_err(|_| InstanceError::ModpackManifestParseError)?; + if manifest.format_version != 1 { + return Err(InstanceError::ModpackManifestParseError.into()); + } + + let name = manifest.name.clone(); + let version_path = directory.dir.join("versions").join(&name); + if version_path.exists() { + return Err(InstanceError::ConflictNameError.into()); + } + + let instance_prefix = format!("instance/{}/", name); + let libraries_dir = directory.dir.join("libraries"); + let assets_dir = directory.dir.join("assets"); + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|_| InstanceError::ZipFileProcessFailed)?; + if entry.is_dir() { + continue; + } + let entry_name = entry.name().to_string(); + let target: Option = if let Some(rel) = entry_name.strip_prefix(&instance_prefix) { + safe_join(&version_path, rel) + } else if let Some(rel) = entry_name.strip_prefix("libraries/") { + safe_join(&libraries_dir, rel) + } else if let Some(rel) = entry_name.strip_prefix("assets/") { + safe_join(&assets_dir, rel) + } else { + None + }; + + let Some(target) = target else { continue }; + + // Shared layers are merged: skip files that already exist. + if target.exists() { + continue; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|_| InstanceError::FolderCreationFailed)?; + } + let mut out = fs::File::create(&target).map_err(|_| InstanceError::FileCreationFailed)?; + std::io::copy(&mut entry, &mut out).map_err(|_| InstanceError::ZipFileProcessFailed)?; + } + + // Rewrite the instance id so it matches the target game directory. + rewrite_instance_id(&version_path, &format!("{}:{}", directory.name, name))?; + + Ok(format!("{}:{}", directory.name, name)) +} + +/// Update the `id` field of `xmclcfg.json` to the target directory's composed id. +fn rewrite_instance_id(version_path: &Path, new_id: &str) -> XMCLResult<()> { + let cfg_path = version_path.join("xmclcfg.json"); + if !cfg_path.is_file() { + return Ok(()); + } + let raw = match fs::read_to_string(&cfg_path) { + Ok(r) => r, + Err(_) => return Ok(()), + }; + let mut value: serde_json::Value = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(_) => return Ok(()), + }; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "id".to_string(), + serde_json::Value::String(new_id.to_string()), + ); + } + let _ = fs::write( + &cfg_path, + serde_json::to_string_pretty(&value).unwrap_or(raw), + ); + Ok(()) +} diff --git a/src-tauri/src/instance/helpers/modpack/misc.rs b/src-tauri/src/instance/helpers/modpack/misc.rs index 1abd1a87..88a0235e 100644 --- a/src-tauri/src/instance/helpers/modpack/misc.rs +++ b/src-tauri/src/instance/helpers/modpack/misc.rs @@ -1,5 +1,6 @@ use crate::error::XMCLResult; use crate::instance::helpers::modpack::curseforge::CurseForgeManifest; +use crate::instance::helpers::modpack::export_fullzip::FullPackManifest; use crate::instance::helpers::modpack::modrinth::ModrinthManifest; use crate::instance::helpers::modpack::multimc::MultiMcManifest; use crate::instance::models::misc::{InstanceError, ModLoader}; @@ -7,6 +8,7 @@ use crate::resource::models::OtherResourceSource; use serde::{Deserialize, Serialize}; use std::fs; use std::fs::File; +use std::io::Read; use std::path::Path; use zip::ZipArchive; @@ -17,18 +19,48 @@ pub struct ModpackMetaInfo { pub version: String, pub description: Option, pub author: Option, - pub modpack_source: OtherResourceSource, + pub modpack_type: OtherResourceSource, pub client_version: String, pub mod_loader: ModLoader, } impl ModpackMetaInfo { + /// Try to parse the archive as an XMCL full pack (identified by `xmcl-full-pack.json`). + fn from_full_pack(file: &File) -> Option { + let mut archive = ZipArchive::new(file).ok()?; + let mut manifest_str = String::new(); + archive + .by_name("xmcl-full-pack.json") + .ok()? + .read_to_string(&mut manifest_str) + .ok()?; + let manifest: FullPackManifest = serde_json::from_str(&manifest_str).ok()?; + if manifest.format_version != 1 { + return None; + } + Some(ModpackMetaInfo { + modpack_type: OtherResourceSource::FullPack, + name: manifest.name, + version: manifest.version, + description: manifest.description, + author: manifest.author, + client_version: manifest.minecraft_version, + mod_loader: ModLoader { + loader_type: manifest.mod_loader.loader_type, + version: manifest.mod_loader.version, + ..Default::default() + }, + }) + } + pub async fn from_archive(file: &File) -> XMCLResult { - if let Ok(manifest) = CurseForgeManifest::from_archive(file) { + if let Some(info) = Self::from_full_pack(file) { + Ok(info) + } else if let Ok(manifest) = CurseForgeManifest::from_archive(file) { let client_version = manifest.get_client_version(); let (loader_type, version) = manifest.get_mod_loader_type_version(); Ok(ModpackMetaInfo { - modpack_source: OtherResourceSource::CurseForge, + modpack_type: OtherResourceSource::CurseForge, name: manifest.name, version: manifest.version, description: None, @@ -44,7 +76,7 @@ impl ModpackMetaInfo { let client_version = manifest.get_client_version()?; let (loader_type, version) = manifest.get_mod_loader_type_version()?; Ok(ModpackMetaInfo { - modpack_source: OtherResourceSource::Modrinth, + modpack_type: OtherResourceSource::Modrinth, name: manifest.name, version: manifest.version_id, description: manifest.summary, @@ -60,7 +92,7 @@ impl ModpackMetaInfo { let client_version = manifest.get_client_version()?; let (loader_type, version) = manifest.get_mod_loader_type_version()?; Ok(ModpackMetaInfo { - modpack_source: OtherResourceSource::Modrinth, + modpack_type: OtherResourceSource::Modrinth, name: manifest.cfg.get("name").cloned().unwrap_or_default(), version: String::new(), description: None, diff --git a/src-tauri/src/instance/helpers/modpack/mod.rs b/src-tauri/src/instance/helpers/modpack/mod.rs index b1153a37..1d2e68e3 100644 --- a/src-tauri/src/instance/helpers/modpack/mod.rs +++ b/src-tauri/src/instance/helpers/modpack/mod.rs @@ -1,6 +1,7 @@ pub mod curseforge; pub mod export_common; pub mod export_curseforge; +pub mod export_fullzip; pub mod export_modrinth; pub mod export_multimc; pub mod misc; diff --git a/src-tauri/src/instance/models/misc.rs b/src-tauri/src/instance/models/misc.rs index b0fd4902..72bd8a66 100644 --- a/src-tauri/src/instance/models/misc.rs +++ b/src-tauri/src/instance/models/misc.rs @@ -260,6 +260,7 @@ pub enum ExportFormat { Modrinth, CurseForge, MultiMC, + Full, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12c873c6..656af15e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -122,7 +122,9 @@ pub async fn run() { instance::commands::change_mod_loader, instance::commands::retrieve_modpack_meta_info, instance::commands::scan_instance_files_for_export, + instance::commands::scan_instance_files_for_full_export, instance::commands::export_modpack, + instance::commands::import_full_pack, launch::commands::select_suitable_jre, launch::commands::validate_game_files, launch::commands::validate_selected_player, diff --git a/src-tauri/src/resource/models.rs b/src-tauri/src/resource/models.rs index f7a96a67..c8f269d6 100644 --- a/src-tauri/src/resource/models.rs +++ b/src-tauri/src/resource/models.rs @@ -43,6 +43,7 @@ pub enum OtherResourceSource { Unknown, CurseForge, Modrinth, + FullPack, } impl FromStr for OtherResourceSource { @@ -52,6 +53,7 @@ impl FromStr for OtherResourceSource { match input.to_lowercase().as_str() { "curseforge" => Ok(OtherResourceSource::CurseForge), "modrinth" => Ok(OtherResourceSource::Modrinth), + "fullpack" => Ok(OtherResourceSource::FullPack), _ => Err(format!("Unknown resource download type: {}", input)), } } diff --git a/src/components/modals/export-modpack-modal.tsx b/src/components/modals/export-modpack-modal.tsx index c260dc11..d3fa4429 100644 --- a/src/components/modals/export-modpack-modal.tsx +++ b/src/components/modals/export-modpack-modal.tsx @@ -56,7 +56,12 @@ import { } from "@/models/instance/misc"; import { InstanceService } from "@/services/instance"; -type ExportStage = "matching" | "packing" | "writingManifest" | "done"; +type ExportStage = + | "matching" + | "packing" + | "copying" + | "writingManifest" + | "done"; interface ExportProgressPayload { current: number; @@ -92,6 +97,13 @@ const FORMAT_OPTIONS = [ ext: ".zip", descriptionKey: "ExportModpackModal.format.multimc.description", }, + { + format: ExportFormat.Full, + translationKey: "full", + icon: LuArchive, + ext: ".zip", + descriptionKey: "ExportModpackModal.format.full.description", + }, ]; const ExportModpackModal: React.FC = ({ @@ -144,7 +156,11 @@ const ExportModpackModal: React.FC = ({ useEffect(() => { if (activeStep !== 2 || fileEntries.length > 0) return; setIsLoadingFiles(true); - InstanceService.scanInstanceFilesForExport(instanceId) + const scanFiles = + selectedFormat === ExportFormat.Full + ? InstanceService.scanInstanceFilesForFullExport + : InstanceService.scanInstanceFilesForExport; + scanFiles(instanceId) .then((res) => { if (res.status === "success") { setFileEntries(res.data); @@ -158,7 +174,7 @@ const ExportModpackModal: React.FC = ({ } }) .finally(() => setIsLoadingFiles(false)); - }, [activeStep, instanceId, fileEntries.length, toast]); + }, [activeStep, instanceId, fileEntries.length, selectedFormat, toast]); const handleExport = useCallback(async () => { const ext = selectedFormat === ExportFormat.Modrinth ? "mrpack" : "zip"; @@ -251,7 +267,11 @@ const ExportModpackModal: React.FC = ({ ? `${primaryColor}.400` : "transparent" } - onClick={() => setSelectedFormat(format)} + onClick={() => { + setSelectedFormat(format); + // Files differ per format; force a rescan on next visit. + setFileEntries([]); + }} _hover={{ borderColor: `${primaryColor}.300` }} > @@ -519,6 +539,22 @@ const ExportModpackModal: React.FC = ({ }, ] : []), + ...(selectedFormat === ExportFormat.Full + ? [ + { + title: t( + "ExportModpackModal.options.fullPackIncludes" + ), + children: ( + + {t( + "ExportModpackModal.options.fullPackIncludesDesc" + )} + + ), + }, + ] + : []), ].filter(Boolean) as any } w="100%" diff --git a/src/components/modals/import-modpack-modal.tsx b/src/components/modals/import-modpack-modal.tsx index f0121ca9..8307e828 100644 --- a/src/components/modals/import-modpack-modal.tsx +++ b/src/components/modals/import-modpack-modal.tsx @@ -12,6 +12,7 @@ import { ModalOverlay, ModalProps, Radio, + Text, VStack, } from "@chakra-ui/react"; import { t } from "i18next"; @@ -27,7 +28,9 @@ import { import { InstanceIconSelectorPopover } from "@/components/instance-icon-selector"; import { modLoaderTypesToIcon } from "@/components/modals/create-instance-modal"; import { useLauncherConfig } from "@/contexts/config"; +import { useGlobalData } from "@/contexts/global-data"; import { useToast } from "@/contexts/toast"; +import { OtherResourceSource } from "@/enums/resource"; import { ModpackMetaInfo } from "@/models/instance/misc"; import { ModLoaderResourceInfo } from "@/models/resource"; import { InstanceService } from "@/services/instance"; @@ -44,6 +47,7 @@ const ImportModpackModal: React.FC = ({ ...modalProps }) => { const { config } = useLauncherConfig(); + const { getInstanceList } = useGlobalData(); const router = useRouter(); const toast = useToast(); const primaryColor = config.appearance.theme.primaryColor; @@ -66,6 +70,10 @@ const ImportModpackModal: React.FC = ({ return 0; }, []); + // Full packs are self-contained (instance + libraries + assets): the instance + // name is fixed by the pack content and nothing needs to be downloaded. + const isFullPack = modpack?.modpackType === OtherResourceSource.FullPack; + const modpackInfoGroup: OptionItemGroupProps[] = useMemo(() => { if (!modpack) return []; return [ @@ -74,7 +82,11 @@ const ImportModpackModal: React.FC = ({ items: [ { title: t("InstanceSettingsPage.name"), - children: ( + children: isFullPack ? ( + + {modpack.name} + + ) : ( = ({ }) ), }, - { - title: t("ImportModpackModal.label.modpackInfo"), - items: [ - { - title: t("ImportModpackModal.label.modpackName"), - children: modpack.name, - }, - { - title: t("ImportModpackModal.label.modpackVersion"), - children: modpack.version, - }, - { - title: t("ImportModpackModal.label.author"), - children: modpack.author || "-", - }, - { - title: t("ImportModpackModal.label.modLoader"), - children: `${modpack.modLoader.loaderType} ${modpack.modLoader.version}`, - }, - { - title: t("ImportModpackModal.label.gameVersion"), - children: modpack.clientVersion, - }, - ], - }, + ...(isFullPack + ? [ + { + title: t("ImportModpackModal.label.fullPackInfo"), + items: [ + { + title: t("ImportModpackModal.label.modpackVersion"), + children: modpack.version, + }, + { + title: t("ImportModpackModal.label.gameVersion"), + children: modpack.clientVersion, + }, + { + title: t("ImportModpackModal.label.fullPackIncludes"), + children: ( + + {t("ImportModpackModal.label.fullPackIncludesDesc")} + + ), + }, + ], + }, + ] + : [ + { + title: t("ImportModpackModal.label.modpackInfo"), + items: [ + { + title: t("ImportModpackModal.label.modpackName"), + children: modpack.name, + }, + { + title: t("ImportModpackModal.label.modpackVersion"), + children: modpack.version, + }, + { + title: t("ImportModpackModal.label.author"), + children: modpack.author || "-", + }, + { + title: t("ImportModpackModal.label.modLoader"), + children: `${modpack.modLoader.loaderType} ${modpack.modLoader.version}`, + }, + { + title: t("ImportModpackModal.label.gameVersion"), + children: modpack.clientVersion, + }, + ], + }, + ]), ]; }, [ modpack, @@ -170,14 +208,39 @@ const ImportModpackModal: React.FC = ({ gameDirectory, config.localGameDirectories, checkDirNameError, + isFullPack, setDescription, setGameDirectory, ]); const handleImportModpack = useCallback(async () => { - if (!modpack || checkDirNameError(name) !== 0 || !gameDirectory) return; + if (!modpack || !gameDirectory) return; + if (!isFullPack && checkDirNameError(name) !== 0) return; try { setIsBtnLoading(true); + + // Full packs: pure extraction, nothing to download. + if (isFullPack) { + const importResp = await InstanceService.importFullPack( + gameDirectory, + path + ); + if (importResp.status === "success") { + onClose(); + // Refresh the instance list so the imported instance shows up + // immediately, matching the refresh used by other instance mutations. + getInstanceList(true); + router.push("/instances/list"); + } else { + toast({ + title: importResp.message, + description: importResp.details, + status: "error", + }); + } + return; + } + // first get client resource info const versionResp = await ResourceService.fetchGameVersionSpecific( modpack.clientVersion @@ -226,7 +289,9 @@ const ImportModpackModal: React.FC = ({ checkDirNameError, description, gameDirectory, + getInstanceList, iconSrc, + isFullPack, onClose, modpack, name, diff --git a/src/enums/resource.ts b/src/enums/resource.ts index f031e07d..3ac90e71 100644 --- a/src/enums/resource.ts +++ b/src/enums/resource.ts @@ -10,6 +10,7 @@ export enum OtherResourceType { export enum OtherResourceSource { CurseForge = "CurseForge", Modrinth = "Modrinth", + FullPack = "FullPack", } export enum DependencyType { @@ -258,7 +259,7 @@ export const modpackTagList = { }, }; -export const sortByLists = { +export const sortByLists: Record = { CurseForge: [ "Popularity", "A-Z", diff --git a/src/locales/en.json b/src/locales/en.json index 68afa9f8..d3a6894e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1162,7 +1162,10 @@ "modpackVersion": "Modpack Version", "author": "Author", "modLoader": "Mod Loader", - "gameVersion": "Game Version" + "gameVersion": "Game Version", + "fullPackInfo": "Full Pack Info", + "fullPackIncludes": "Offline ready", + "fullPackIncludesDesc": "Includes the game libraries and assets. After import you can play offline directly without downloading anything." }, "button": { "import": "Import" @@ -1189,6 +1192,10 @@ "multimc": { "name": "MultiMC / Prism", "description": "Export as MultiMC .zip, compatible with Prism Launcher" + }, + "full": { + "name": "Full Pack (Offline)", + "description": "Self-contained zip with game libraries and assets; import and play without any downloads" } }, "meta": { @@ -1212,14 +1219,17 @@ "matching": "Matching remote resources", "packing": "Packing files", "writingManifest": "Writing manifest", - "done": "Done" + "done": "Done", + "copying": "Copying game files" } }, "options": { "sectionTitle": "Export Options", "noCreateRemoteFiles": "No remote matching (all files as local)", "skipCurseForgeRemoteFiles": "Skip CurseForge matching (Modrinth only)", - "minMemory": "Minimum Memory" + "minMemory": "Minimum Memory", + "fullPackIncludes": "Include game files", + "fullPackIncludesDesc": "The pack includes the game libraries and assets, so importing needs no downloads (the zip may be 2-4 GB)" } }, "FileTreeSelector": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 6fca8e4d..ac366ecd 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1044,6 +1044,9 @@ } } }, + "FileTreeSelector": { + "empty": "Aucun fichier trouvé" + }, "InstanceBasicSettings": { "selectDirectory": "Sélectionner le répertoire du jeu" }, @@ -2255,6 +2258,22 @@ "MODPACK_MANIFEST_PARSE_ERROR": "Modpack manifest file parse error" } } + }, + "scanInstanceFilesForExport": { + "error": { + "title": "Échec de l'analyse des fichiers de l'instance" + } + }, + "exportModpack": { + "success": "Intégration exportée avec succès", + "error": { + "title": "Échec de l'exportation de l'intégration", + "description": { + "FILE_CREATION_FAILED": "Échec de la création du fichier de sortie", + "INSTANCE_NOT_FOUND_BY_I_D": "Instance introuvable", + "ZIP_FILE_PROCESS_FAILED": "Échec de la création de l'archive zip" + } + } } }, "task": { @@ -2510,6 +2529,67 @@ "content": "After selecting your instance and player and adjusting the relevant settings, click the launch button and enjoy the game! 🥳" } }, + "ExportModpackModal": { + "header": { + "title": "Exporter un modpack" + }, + "stepper": { + "format": "Format", + "meta": "Informations", + "files": "Fichiers" + }, + "format": { + "modrinth": { + "name": "Modrinth", + "description": "Exporter en .mrpack, prend en charge la correspondance distante des mods pour réduire la taille du fichier" + }, + "curseforge": { + "name": "CurseForge", + "description": "Exporter en .zip CurseForge, prend en charge la correspondance des ID de mods distants" + }, + "multimc": { + "name": "MultiMC / Prism", + "description": "Exporter en .zip MultiMC, compatible avec Prism Launcher" + }, + "full": { + "name": "Pack complet (hors ligne)", + "description": "Zip autonome contenant les bibliothèques et ressources du jeu ; importez et jouez sans aucun téléchargement" + } + }, + "meta": { + "sectionTitle": "Informations du modpack", + "name": "Nom", + "author": "Auteur", + "version": "Version", + "description": "Description" + }, + "button": { + "export": "Exporter" + }, + "dialog": { + "filterName": "Modpack" + }, + "toast": { + "success": "Modpack exporté avec succès" + }, + "progress": { + "stage": { + "matching": "Correspondance des ressources distantes", + "packing": "Compression des fichiers", + "writingManifest": "Écriture du manifeste", + "done": "Terminé", + "copying": "Copie des fichiers du jeu" + } + }, + "options": { + "sectionTitle": "Options d'exportation", + "noCreateRemoteFiles": "Aucune correspondance distante (tous les fichiers en local)", + "skipCurseForgeRemoteFiles": "Ignorer la correspondance CurseForge (Modrinth uniquement)", + "minMemory": "Mémoire minimale", + "fullPackIncludes": "Inclure les fichiers du jeu", + "fullPackIncludesDesc": "Le pack contient les bibliothèques et ressources du jeu ; l'import ne nécessite aucun téléchargement (le zip peut faire 2 à 4 Go)" + } + }, "ImportModpackModal": { "header": { "title": "Import Modpack" @@ -2521,7 +2601,10 @@ "modpackVersion": "Modpack Version", "author": "Author", "modLoader": "Mod Loader", - "gameVersion": "Game Version" + "gameVersion": "Game Version", + "fullPackInfo": "Informations du pack complet", + "fullPackIncludes": "Prêt hors ligne", + "fullPackIncludesDesc": "Contient les bibliothèques et ressources du jeu. Après l'import, jouez directement hors ligne sans rien télécharger." }, "button": { "import": "Import" diff --git a/src/locales/ja.json b/src/locales/ja.json index 163fffbb..7b559311 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1028,6 +1028,9 @@ } } }, + "FileTreeSelector": { + "empty": "ファイルが見つかりません" + }, "InstanceBasicSettings": { "selectDirectory": "ゲームディレクトリ選択" }, @@ -2173,6 +2176,22 @@ "MODPACK_MANIFEST_PARSE_ERROR": "Modpack manifest file parse error" } } + }, + "scanInstanceFilesForExport": { + "error": { + "title": "インスタンスファイルのスキャンに失敗しました" + } + }, + "exportModpack": { + "success": "モッドパックのエクスポートに成功しました", + "error": { + "title": "モッドパックのエクスポートに失敗しました", + "description": { + "FILE_CREATION_FAILED": "出力ファイルの作成に失敗しました", + "INSTANCE_NOT_FOUND_BY_I_D": "インスタンスが見つかりません", + "ZIP_FILE_PROCESS_FAILED": "ZIP アーカイブの作成に失敗しました" + } + } } }, "launch": { @@ -2490,6 +2509,67 @@ "content": "After selecting your instance and player and adjusting the relevant settings, click the launch button and enjoy the game! 🥳" } }, + "ExportModpackModal": { + "header": { + "title": "モッドパックをエクスポート" + }, + "stepper": { + "format": "形式", + "meta": "情報", + "files": "ファイル" + }, + "format": { + "modrinth": { + "name": "Modrinth", + "description": ".mrpack 形式でエクスポート。リモートの MOD マッチングに対応し、ファイルサイズを削減" + }, + "curseforge": { + "name": "CurseForge", + "description": "CurseForge の .zip 形式でエクスポート。リモートの MOD ID マッチングに対応" + }, + "multimc": { + "name": "MultiMC / Prism", + "description": "MultiMC の .zip 形式でエクスポート。Prism Launcher と互換" + }, + "full": { + "name": "フルパック(オフライン)", + "description": "ゲームのライブラリとアセットを含む自己完結型 zip。インポート後はダウンロード不要でオフラインでプレイ可能" + } + }, + "meta": { + "sectionTitle": "モッドパック情報", + "name": "名前", + "author": "作者", + "version": "バージョン", + "description": "説明" + }, + "button": { + "export": "エクスポート" + }, + "dialog": { + "filterName": "モッドパック" + }, + "toast": { + "success": "モッドパックをエクスポートしました" + }, + "progress": { + "stage": { + "matching": "リモートリソースを照合中", + "packing": "ファイルをパッキング中", + "writingManifest": "マニフェストを書き込み中", + "done": "完了", + "copying": "ゲームファイルをコピー中" + } + }, + "options": { + "sectionTitle": "エクスポートオプション", + "noCreateRemoteFiles": "リモートマッチングなし(すべてのファイルをローカルとして扱う)", + "skipCurseForgeRemoteFiles": "CurseForge のマッチングをスキップ(Modrinth のみ)", + "minMemory": "最小メモリ", + "fullPackIncludes": "ゲーム本体を含める", + "fullPackIncludesDesc": "パックにはゲームのライブラリとアセットが含まれ、インポートにダウンロードは不要です(zip は 2〜4GB になる場合があります)" + } + }, "ImportModpackModal": { "header": { "title": "Import Modpack" @@ -2501,7 +2581,10 @@ "modpackVersion": "Modpack Version", "author": "Author", "modLoader": "Mod Loader", - "gameVersion": "Game Version" + "gameVersion": "Game Version", + "fullPackInfo": "フルパック情報", + "fullPackIncludes": "オフライン即時プレイ", + "fullPackIncludesDesc": "ゲームのライブラリとアセットが含まれています。インポート後は何もダウンロードせずにオフラインで直接プレイできます。" }, "button": { "import": "Import" diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index f470f0d9..9c03ef9c 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1162,7 +1162,10 @@ "modpackVersion": "整合包版本", "author": "作者", "modLoader": "模组加载器", - "gameVersion": "游戏版本" + "gameVersion": "游戏版本", + "fullPackInfo": "全量包信息", + "fullPackIncludes": "离线即玩", + "fullPackIncludesDesc": "已包含游戏依赖库与资源,导入后可直接离线游玩,无需下载任何内容" }, "button": { "import": "导入" @@ -1189,6 +1192,10 @@ "multimc": { "name": "MultiMC / Prism", "description": "导出为 MultiMC .zip 格式,兼容 Prism Launcher" + }, + "full": { + "name": "全量包(离线)", + "description": "自包含 zip,包含游戏依赖库与资源,导入后无需任何下载即可离线游玩" } }, "meta": { @@ -1212,14 +1219,17 @@ "matching": "正在匹配远程资源", "packing": "正在打包文件", "writingManifest": "正在写入清单", - "done": "完成" + "done": "完成", + "copying": "正在复制游戏文件" } }, "options": { "sectionTitle": "导出选项", "noCreateRemoteFiles": "不进行远程匹配(所有文件作为本地)", "skipCurseForgeRemoteFiles": "跳过 CurseForge 匹配(仅 Modrinth)", - "minMemory": "最小内存" + "minMemory": "最小内存", + "fullPackIncludes": "包含游戏本体", + "fullPackIncludesDesc": "包内将包含游戏依赖库与资源,导入无需下载(zip 可能达 2-4GB)" } }, "FileTreeSelector": { diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index 62697a44..e9306d1d 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -1139,6 +1139,67 @@ } } }, + "ExportModpackModal": { + "header": { + "title": "匯出整合包" + }, + "stepper": { + "format": "選擇格式", + "meta": "填寫資訊", + "files": "選擇檔案" + }, + "format": { + "modrinth": { + "name": "Modrinth", + "description": "匯出為 .mrpack 格式,支援遠端模組比對以減小檔案體積" + }, + "curseforge": { + "name": "CurseForge", + "description": "匯出為 CurseForge .zip 格式,支援遠端模組 ID 比對" + }, + "multimc": { + "name": "MultiMC / Prism", + "description": "匯出為 MultiMC .zip 格式,相容 Prism Launcher" + }, + "full": { + "name": "全量包(離線)", + "description": "自包含 zip,包含遊戲依賴庫與資源,匯入後無需任何下載即可離線遊玩" + } + }, + "meta": { + "sectionTitle": "整合包資訊", + "name": "名稱", + "author": "作者", + "version": "版本號", + "description": "描述" + }, + "button": { + "export": "匯出" + }, + "dialog": { + "filterName": "整合包" + }, + "toast": { + "success": "整合包匯出成功" + }, + "progress": { + "stage": { + "matching": "正在比對遠端資源", + "packing": "正在打包檔案", + "writingManifest": "正在寫入清單", + "done": "完成", + "copying": "正在複製遊戲檔案" + } + }, + "options": { + "sectionTitle": "匯出選項", + "noCreateRemoteFiles": "不進行遠端比對(所有檔案作為本機)", + "skipCurseForgeRemoteFiles": "跳過 CurseForge 比對(僅 Modrinth)", + "minMemory": "最小記憶體", + "fullPackIncludes": "包含遊戲本體", + "fullPackIncludesDesc": "包內將包含遊戲依賴庫與資源,匯入無需下載(zip 可能達 2-4GB)" + } + }, "ImportModpackModal": { "header": { "title": "匯入整合包" @@ -1150,12 +1211,18 @@ "modpackVersion": "整合包版本", "author": "作者", "modLoader": "模組載入器", - "gameVersion": "遊戲版本" + "gameVersion": "遊戲版本", + "fullPackInfo": "全量包資訊", + "fullPackIncludes": "離線即玩", + "fullPackIncludesDesc": "已包含遊戲依賴庫與資源,匯入後可直接離線遊玩,無需下載任何內容" }, "button": { "import": "匯入" } }, + "FileTreeSelector": { + "empty": "找不到檔案" + }, "InstanceBasicSettings": { "selectDirectory": "選擇遊戲目錄" }, @@ -2394,6 +2461,22 @@ "MODPACK_MANIFEST_PARSE_ERROR": "整合包清單檔案解析錯誤" } } + }, + "scanInstanceFilesForExport": { + "error": { + "title": "掃描例項檔案失敗" + } + }, + "exportModpack": { + "success": "整合包匯出成功", + "error": { + "title": "整合包匯出失敗", + "description": { + "FILE_CREATION_FAILED": "無法建立輸出檔案", + "INSTANCE_NOT_FOUND_BY_I_D": "找不到例項", + "ZIP_FILE_PROCESS_FAILED": "建立 zip 壓縮檔失敗" + } + } } }, "task": { diff --git a/src/models/instance/misc.ts b/src/models/instance/misc.ts index 672ba3b4..ff6f4780 100644 --- a/src/models/instance/misc.ts +++ b/src/models/instance/misc.ts @@ -94,6 +94,7 @@ export enum ExportFormat { Modrinth = "modrinth", CurseForge = "curseForge", MultiMC = "multiMC", + Full = "full", } export enum FileCategory { diff --git a/src/services/instance.ts b/src/services/instance.ts index 5ec42a1b..8bb5b940 100644 --- a/src/services/instance.ts +++ b/src/services/instance.ts @@ -444,6 +444,13 @@ export class InstanceService { return await invoke("scan_instance_files_for_export", { instanceId }); } + @responseHandler("instance") + static async scanInstanceFilesForFullExport( + instanceId: string + ): Promise> { + return await invoke("scan_instance_files_for_full_export", { instanceId }); + } + @responseHandler("instance") static async exportModpack( instanceId: string, @@ -460,4 +467,20 @@ export class InstanceService { outputPath, }); } + + /** + * Import a full pack (self-contained zip with instance + libraries + assets) + * into a game directory. Pure extraction, nothing is downloaded. + * @returns {Promise>} The new instance id. + */ + @responseHandler("instance") + static async importFullPack( + directory: GameDirectory, + packPath: string + ): Promise> { + return await invoke("import_full_pack", { + directory, + packPath, + }); + } }