From 079ee0a6611499db68d1eb4894fab64739d5d2e7 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:16:05 +0800 Subject: fix(instance): copy directory BEFORE creating metadata in duplicate_instance Prevent race condition in duplicate_instance by copying the source game directory BEFORE creating and saving the new instance metadata. This ensures that if the copy fails, no orphaned metadata is created. Also copy the icon_path from source instance to maintain visual consistency. --- src-tauri/src/core/instance.rs | 43 +++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) (limited to 'src-tauri/src/core/instance.rs') diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs index 90ec34e..738dbd8 100644 --- a/src-tauri/src/core/instance.rs +++ b/src-tauri/src/core/instance.rs @@ -218,21 +218,42 @@ impl InstanceState { .get_instance(id) .ok_or_else(|| format!("Instance {} not found", id))?; - // Create new instance - let mut new_instance = self.create_instance(new_name, app_handle)?; - - // Copy instance properties - new_instance.version_id = source_instance.version_id.clone(); - new_instance.mod_loader = source_instance.mod_loader.clone(); - new_instance.mod_loader_version = source_instance.mod_loader_version.clone(); - new_instance.notes = source_instance.notes.clone(); - - // Copy directory contents + // Prepare new instance metadata (but don't save yet) + let new_id = uuid::Uuid::new_v4().to_string(); + let instances_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("instances"); + let new_game_dir = instances_dir.join(&new_id); + + // Copy directory FIRST - if this fails, don't create metadata if source_instance.game_dir.exists() { - copy_dir_all(&source_instance.game_dir, &new_instance.game_dir) + copy_dir_all(&source_instance.game_dir, &new_game_dir) .map_err(|e| format!("Failed to copy instance directory: {}", e))?; + } else { + // If source dir doesn't exist, create new empty game dir + std::fs::create_dir_all(&new_game_dir) + .map_err(|e| format!("Failed to create instance directory: {}", e))?; } + // NOW create metadata and save + let new_instance = Instance { + id: new_id, + name: new_name, + game_dir: new_game_dir, + version_id: source_instance.version_id.clone(), + mod_loader: source_instance.mod_loader.clone(), + mod_loader_version: source_instance.mod_loader_version.clone(), + notes: source_instance.notes.clone(), + icon_path: source_instance.icon_path.clone(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + last_played: None, + }; + self.update_instance(new_instance.clone())?; Ok(new_instance) -- cgit v1.2.3-70-g09d2 From 17e8dd78ca5b7aae9baa4f86d38fa755c8af21c5 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 13:43:12 +0800 Subject: feat(migration): implement shared cache migration with SHA1 dedup - Add migrate_to_shared_caches() with hard link preference - SHA1-based deduplication across all instances - Copy fallback for cross-filesystem scenarios - Auto-enable use_shared_caches after successful migration - UI shows statistics: moved files, hardlinks/copies, MB saved --- src-tauri/src/core/instance.rs | 224 ++++++++++++++++++++++++++++++++++ src-tauri/src/main.rs | 52 +++++++- ui/src/components/SettingsView.svelte | 31 ++++- 3 files changed, 303 insertions(+), 4 deletions(-) (limited to 'src-tauri/src/core/instance.rs') diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs index 738dbd8..183e1cc 100644 --- a/src-tauri/src/core/instance.rs +++ b/src-tauri/src/core/instance.rs @@ -6,6 +6,7 @@ //! - Support for instance switching and isolation use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -344,3 +345,226 @@ pub fn migrate_legacy_data( Ok(()) } + +/// Migrate instance caches to shared global caches +/// +/// This function deduplicates versions, libraries, and assets from all instances +/// into a global shared cache. It prefers hard links (instant, zero-copy) and +/// falls back to copying if hard links are not supported. +/// +/// # Arguments +/// * `app_handle` - Tauri app handle +/// * `instance_state` - Instance state management +/// +/// # Returns +/// * `Ok((moved_count, hardlink_count, copy_count, saved_bytes))` on success +/// * `Err(String)` on failure +pub fn migrate_to_shared_caches( + app_handle: &AppHandle, + instance_state: &InstanceState, +) -> Result<(usize, usize, usize, u64), String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + + // Global shared cache directories + let global_versions = app_dir.join("versions"); + let global_libraries = app_dir.join("libraries"); + let global_assets = app_dir.join("assets"); + + // Create global cache directories + std::fs::create_dir_all(&global_versions).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&global_libraries).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&global_assets).map_err(|e| e.to_string())?; + + let mut total_moved = 0; + let mut hardlink_count = 0; + let mut copy_count = 0; + let mut saved_bytes = 0u64; + + // Get all instances + let instances = instance_state.list_instances(); + + for instance in instances { + let instance_versions = instance.game_dir.join("versions"); + let instance_libraries = instance.game_dir.join("libraries"); + let instance_assets = instance.game_dir.join("assets"); + + // Migrate versions + if instance_versions.exists() { + let (moved, hardlinks, copies, bytes) = + deduplicate_directory(&instance_versions, &global_versions)?; + total_moved += moved; + hardlink_count += hardlinks; + copy_count += copies; + saved_bytes += bytes; + } + + // Migrate libraries + if instance_libraries.exists() { + let (moved, hardlinks, copies, bytes) = + deduplicate_directory(&instance_libraries, &global_libraries)?; + total_moved += moved; + hardlink_count += hardlinks; + copy_count += copies; + saved_bytes += bytes; + } + + // Migrate assets + if instance_assets.exists() { + let (moved, hardlinks, copies, bytes) = + deduplicate_directory(&instance_assets, &global_assets)?; + total_moved += moved; + hardlink_count += hardlinks; + copy_count += copies; + saved_bytes += bytes; + } + } + + Ok((total_moved, hardlink_count, copy_count, saved_bytes)) +} + +/// Deduplicate a directory tree into a global cache +/// +/// Recursively processes all files, checking SHA1 hashes for deduplication. +/// Returns (total_moved, hardlink_count, copy_count, saved_bytes) +fn deduplicate_directory( + source_dir: &Path, + dest_dir: &Path, +) -> Result<(usize, usize, usize, u64), String> { + let mut moved = 0; + let mut hardlinks = 0; + let mut copies = 0; + let mut saved_bytes = 0u64; + + // Build a hash map of existing files in dest (hash -> path) + let mut dest_hashes: HashMap = HashMap::new(); + if dest_dir.exists() { + index_directory_hashes(dest_dir, dest_dir, &mut dest_hashes)?; + } + + // Process source directory + process_directory_for_migration( + source_dir, + source_dir, + dest_dir, + &dest_hashes, + &mut moved, + &mut hardlinks, + &mut copies, + &mut saved_bytes, + )?; + + Ok((moved, hardlinks, copies, saved_bytes)) +} + +/// Index all files in a directory by their SHA1 hash +fn index_directory_hashes( + dir: &Path, + base: &Path, + hashes: &mut HashMap, +) -> Result<(), String> { + if !dir.is_dir() { + return Ok(()); + } + + for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + + if path.is_dir() { + index_directory_hashes(&path, base, hashes)?; + } else if path.is_file() { + let hash = compute_file_sha1(&path)?; + hashes.insert(hash, path); + } + } + + Ok(()) +} + +/// Process directory for migration (recursive) +fn process_directory_for_migration( + current: &Path, + source_base: &Path, + dest_base: &Path, + dest_hashes: &HashMap, + moved: &mut usize, + hardlinks: &mut usize, + copies: &mut usize, + saved_bytes: &mut u64, +) -> Result<(), String> { + if !current.is_dir() { + return Ok(()); + } + + for entry in std::fs::read_dir(current).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let source_path = entry.path(); + + // Compute relative path + let rel_path = source_path + .strip_prefix(source_base) + .map_err(|e| e.to_string())?; + let dest_path = dest_base.join(rel_path); + + if source_path.is_dir() { + // Recurse into subdirectory + process_directory_for_migration( + &source_path, + source_base, + dest_base, + dest_hashes, + moved, + hardlinks, + copies, + saved_bytes, + )?; + } else if source_path.is_file() { + let file_size = std::fs::metadata(&source_path) + .map(|m| m.len()) + .unwrap_or(0); + + // Compute file hash + let source_hash = compute_file_sha1(&source_path)?; + + // Check if file already exists in dest with same hash + if let Some(_existing) = dest_hashes.get(&source_hash) { + // File exists, delete source (already deduplicated) + std::fs::remove_file(&source_path).map_err(|e| e.to_string())?; + *saved_bytes += file_size; + *moved += 1; + } else { + // File doesn't exist, move it + // Create parent directory in dest + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + // Try hard link first + if std::fs::hard_link(&source_path, &dest_path).is_ok() { + // Hard link succeeded, remove source + std::fs::remove_file(&source_path).map_err(|e| e.to_string())?; + *hardlinks += 1; + *moved += 1; + } else { + // Hard link failed (different filesystem?), copy instead + std::fs::copy(&source_path, &dest_path).map_err(|e| e.to_string())?; + std::fs::remove_file(&source_path).map_err(|e| e.to_string())?; + *copies += 1; + *moved += 1; + } + } + } + } + + Ok(()) +} + +/// Compute SHA1 hash of a file +fn compute_file_sha1(path: &Path) -> Result { + use sha1::{Digest, Sha1}; + + let data = std::fs::read(path).map_err(|e| e.to_string())?; + let mut hasher = Sha1::new(); + hasher.update(&data); + Ok(hex::encode(hasher.finalize())) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6a230c9..a506713 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2373,6 +2373,55 @@ async fn assistant_chat_stream( .await } +/// Migrate instance caches to shared global caches +#[derive(Serialize)] +struct MigrationResult { + moved_files: usize, + hardlinks: usize, + copies: usize, + saved_bytes: u64, + saved_mb: f64, +} + +#[tauri::command] +async fn migrate_shared_caches( + window: Window, + instance_state: State<'_, core::instance::InstanceState>, + config_state: State<'_, core::config::ConfigState>, +) -> Result { + emit_log!(window, "Starting migration to shared caches...".to_string()); + + let app_handle = window.app_handle(); + let (moved, hardlinks, copies, saved_bytes) = + core::instance::migrate_to_shared_caches(app_handle, &instance_state)?; + + let saved_mb = saved_bytes as f64 / (1024.0 * 1024.0); + + emit_log!( + window, + format!( + "Migration complete: {} files moved ({} hardlinks, {} copies), {:.2} MB saved", + moved, hardlinks, copies, saved_mb + ) + ); + + // Automatically enable shared caches config + let mut config = config_state.config.lock().unwrap().clone(); + config.use_shared_caches = true; + drop(config); + *config_state.config.lock().unwrap() = config_state.config.lock().unwrap().clone(); + config_state.config.lock().unwrap().use_shared_caches = true; + config_state.save()?; + + Ok(MigrationResult { + moved_files: moved, + hardlinks, + copies, + saved_bytes, + saved_mb, + }) +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_fs::init()) @@ -2479,7 +2528,8 @@ fn main() { get_instance, set_active_instance, get_active_instance, - duplicate_instance + duplicate_instance, + migrate_shared_caches ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte index 0e89e25..0020506 100644 --- a/ui/src/components/SettingsView.svelte +++ b/ui/src/components/SettingsView.svelte @@ -124,12 +124,31 @@ settingsState.saveSettings(); } + let migrating = $state(false); async function runMigrationToSharedCaches() { + if (migrating) return; + migrating = true; try { - await (await import("@tauri-apps/api/core")).invoke("migrate_shared_caches"); - settingsState.loadSettings(); + const { invoke } = await import("@tauri-apps/api/core"); + const result = await invoke<{ + moved_files: number; + hardlinks: number; + copies: number; + saved_mb: number; + }>("migrate_shared_caches"); + + // Reload settings to reflect changes + await settingsState.loadSettings(); + + // Show success message + const msg = `Migration complete! ${result.moved_files} files (${result.hardlinks} hardlinks, ${result.copies} copies), ${result.saved_mb.toFixed(2)} MB saved.`; + console.log(msg); + alert(msg); } catch (e) { console.error("Migration failed:", e); + alert(`Migration failed: ${e}`); + } finally { + migrating = false; } } @@ -444,7 +463,13 @@

Run Migration

Hard-link or copy existing per-instance caches into the shared cache.

- + -- cgit v1.2.3-70-g09d2 From 6fdb730c323bcb1b052a2f9b13034603cbaf1e4d Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 14:27:45 +0800 Subject: feat(backend): enhance instance management for editor support - Sync instance.version_id after start_game, install_fabric, install_forge - Add jvm_args_override and memory_override to Instance struct - Add file management commands: list_instance_directory, delete_instance_file, open_file_explorer - Support per-instance settings overrides (Java args, memory) --- src-tauri/src/core/instance.rs | 14 +++++ src-tauri/src/main.rs | 127 +++++++++++++++++++++++++++++++++++++++-- ui/src/types/index.ts | 7 +++ 3 files changed, 142 insertions(+), 6 deletions(-) (limited to 'src-tauri/src/core/instance.rs') diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs index 183e1cc..573273e 100644 --- a/src-tauri/src/core/instance.rs +++ b/src-tauri/src/core/instance.rs @@ -25,6 +25,16 @@ pub struct Instance { pub notes: Option, // 备注(可选) pub mod_loader: Option, // 模组加载器类型:"fabric", "forge", "vanilla" pub mod_loader_version: Option, // 模组加载器版本 + pub jvm_args_override: Option, // JVM参数覆盖(可选) + #[serde(default)] + pub memory_override: Option, // 内存设置覆盖(可选) +} + +/// Memory settings override for an instance +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryOverride { + pub min: u32, // MB + pub max: u32, // MB } /// Configuration for all instances @@ -99,6 +109,8 @@ impl InstanceState { notes: None, mod_loader: Some("vanilla".to_string()), mod_loader_version: None, + jvm_args_override: None, + memory_override: None, }; let mut config = self.instances.lock().unwrap(); @@ -253,6 +265,8 @@ impl InstanceState { .unwrap() .as_secs() as i64, last_played: None, + jvm_args_override: source_instance.jvm_args_override.clone(), + memory_override: source_instance.memory_override.clone(), }; self.update_instance(new_instance.clone())?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a506713..35e2ef5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::sync::Mutex; use tauri::{Emitter, Manager, State, Window}; // Added Emitter @@ -854,6 +854,12 @@ async fn start_game( } }); + // Update instance's version_id to track last launched version + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.version_id = Some(version_id.clone()); + let _ = instance_state.update_instance(instance); + } + Ok(format!("Launched Minecraft {} successfully!", version_id)) } @@ -1693,10 +1699,11 @@ async fn install_fabric( format!("Fabric installed successfully: {}", result.id) ); - // Update Instance's mod_loader metadata + // Update Instance's mod_loader metadata and version_id if let Some(mut instance) = instance_state.get_instance(&instance_id) { instance.mod_loader = Some("fabric".to_string()); - instance.mod_loader_version = Some(loader_version); + instance.mod_loader_version = Some(loader_version.clone()); + instance.version_id = Some(result.id.clone()); instance_state.update_instance(instance)?; } @@ -2107,10 +2114,11 @@ async fn install_forge( format!("Forge installed successfully: {}", result.id) ); - // Update Instance's mod_loader metadata + // Update Instance's mod_loader metadata and version_id if let Some(mut instance) = instance_state.get_instance(&instance_id) { instance.mod_loader = Some("forge".to_string()); - instance.mod_loader_version = Some(forge_version); + instance.mod_loader_version = Some(forge_version.clone()); + instance.version_id = Some(result.id.clone()); instance_state.update_instance(instance)?; } @@ -2422,6 +2430,110 @@ async fn migrate_shared_caches( }) } +/// File information for instance file browser +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FileInfo { + name: String, + path: String, + is_directory: bool, + size: u64, + modified: i64, +} + +/// List files in an instance subdirectory (mods, resourcepacks, shaderpacks, saves, screenshots) +#[tauri::command] +async fn list_instance_directory( + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + folder: String, // "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots" +) -> Result, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + let target_dir = game_dir.join(&folder); + if !target_dir.exists() { + tokio::fs::create_dir_all(&target_dir) + .await + .map_err(|e| e.to_string())?; + } + + let mut files = Vec::new(); + let mut entries = tokio::fs::read_dir(&target_dir) + .await + .map_err(|e| e.to_string())?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? { + let metadata = entry.metadata().await.map_err(|e| e.to_string())?; + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + files.push(FileInfo { + name: entry.file_name().to_string_lossy().to_string(), + path: entry.path().to_string_lossy().to_string(), + is_directory: metadata.is_dir(), + size: metadata.len(), + modified, + }); + } + + // Sort: directories first, then by name + files.sort_by(|a, b| { + b.is_directory + .cmp(&a.is_directory) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + Ok(files) +} + +/// Delete a file in an instance directory +#[tauri::command] +async fn delete_instance_file(path: String) -> Result<(), String> { + let path_buf = std::path::PathBuf::from(&path); + if path_buf.is_dir() { + tokio::fs::remove_dir_all(&path_buf) + .await + .map_err(|e| e.to_string())?; + } else { + tokio::fs::remove_file(&path_buf) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Open instance directory in system file explorer +#[tauri::command] +async fn open_file_explorer(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + } + Ok(()) +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_fs::init()) @@ -2529,7 +2641,10 @@ fn main() { set_active_instance, get_active_instance, duplicate_instance, - migrate_shared_caches + migrate_shared_caches, + list_instance_directory, + delete_instance_file, + open_file_explorer ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 858ee43..6632d58 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -214,4 +214,11 @@ export interface Instance { notes?: string; mod_loader?: string; mod_loader_version?: string; + jvm_args_override?: string; + memory_override?: MemoryOverride; +} + +export interface MemoryOverride { + min: number; // MB + max: number; // MB } -- cgit v1.2.3-70-g09d2