From 723738e5308b0195ad715e0fa49f19db754753c6 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:13:49 +0800 Subject: fix(auth): prevent infinite recursion in get_client() The fallback in the reqwest client builder was calling get_client() recursively, which would cause a stack overflow if Client::builder() failed. Now uses reqwest::Client::new() as the fallback. Also fixed User-Agent to be platform-agnostic. Reviewed-by: Claude Opus 4.5 --- src-tauri/src/core/auth.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index ac5904c..e1e1588 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -6,9 +6,9 @@ use uuid::Uuid; // This is critical because Microsoft's WAF often blocks requests without a valid UA fn get_client() -> reqwest::Client { reqwest::Client::builder() - .user_agent("DropOut/1.0 (Linux)") + .user_agent("DropOut/1.0") .build() - .unwrap_or_else(|_| get_client()) + .unwrap_or_else(|_| reqwest::Client::new()) } #[derive(Debug, Clone, Serialize, Deserialize)] -- cgit v1.2.3-70-g09d2 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') 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 1021c921c5690ceb800c03140de0723f3338e121 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:16:55 +0800 Subject: fix(auth): add token expiry check in start_game Check if the Microsoft account token is expired before attempting to launch the game. If expired, attempt to refresh using the refresh_token. If refresh fails, return an error instructing the user to login again. Also removed #[allow(dead_code)] from is_token_expired since it's now actively used. --- src-tauri/src/core/auth.rs | 1 - src-tauri/src/main.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index e1e1588..d5e6c17 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -136,7 +136,6 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4086066..853c93e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -82,13 +82,42 @@ async fn start_game( // Check for active account emit_log!(window, "Checking for active account...".to_string()); - let account = auth_state + let mut account = auth_state .active_account .lock() .unwrap() .clone() .ok_or("No active account found. Please login first.")?; + // Check if Microsoft account token is expired and refresh if needed + if let core::auth::Account::Microsoft(ms_account) = &account { + if core::auth::is_token_expired(ms_account.expires_at) { + emit_log!(window, "Token expired, refreshing...".to_string()); + match core::auth::refresh_full_auth( + &ms_account + .refresh_token + .clone() + .ok_or("No refresh token available")?, + ) + .await + { + Ok((refreshed_account, _new_ms_refresh)) => { + let refreshed_account = core::auth::Account::Microsoft(refreshed_account); + *auth_state.active_account.lock().unwrap() = Some(refreshed_account.clone()); + account = refreshed_account; + emit_log!(window, "Token refreshed successfully".to_string()); + } + Err(e) => { + emit_log!(window, format!("Token refresh failed: {}", e)); + return Err(format!( + "Your login session has expired. Please login again: {}", + e + )); + } + } + } + } + emit_log!(window, format!("Account found: {}", account.username())); let config = config_state.config.lock().unwrap().clone(); -- cgit v1.2.3-70-g09d2 From e7d683d79bec482a13c821f8c1da3c8c1d719d1b Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:17:56 +0800 Subject: fix(downloader): use proper atomic ordering for thread-safe progress tracking Replace Ordering::Relaxed with appropriate synchronization: - Ordering::AcqRel for fetch_add operations that modify shared state - Ordering::Acquire for loads that depend on other thread's writes - Ordering::Release for stores that other threads may read This ensures visibility of downloaded bytes and completed files across concurrent download tasks without data races. --- src-tauri/src/core/downloader.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index 9c6b7f0..26f6ebd 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -270,12 +270,12 @@ pub async fn download_with_resume( } current_pos += chunk_len; - let total_downloaded = progress.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len; + let total_downloaded = progress.fetch_add(chunk_len, Ordering::AcqRel) + chunk_len; // Emit progress event (throttled) - let last_bytes = last_progress_bytes.load(Ordering::Relaxed); + let last_bytes = last_progress_bytes.load(Ordering::Acquire); if total_downloaded - last_bytes > 100 * 1024 || total_downloaded >= total_size { - last_progress_bytes.store(total_downloaded, Ordering::Relaxed); + last_progress_bytes.store(total_downloaded, Ordering::Release); let elapsed = start_time.elapsed().as_secs_f64(); let speed = if elapsed > 0.0 { @@ -319,7 +319,7 @@ pub async fn download_with_resume( all_success = false; if e.contains("cancelled") { // Save progress for resume - metadata.downloaded_bytes = progress.load(Ordering::Relaxed); + metadata.downloaded_bytes = progress.load(Ordering::Acquire); let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; tokio::fs::write(&meta_path, meta_content).await.ok(); @@ -335,7 +335,7 @@ pub async fn download_with_resume( if !all_success { // Save progress - metadata.downloaded_bytes = progress.load(Ordering::Relaxed); + metadata.downloaded_bytes = progress.load(Ordering::Acquire); let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; tokio::fs::write(&meta_path, meta_content).await.ok(); return Err("Some segments failed".to_string()); @@ -482,19 +482,19 @@ impl GlobalProgress { /// Get current progress snapshot without modification fn snapshot(&self) -> ProgressSnapshot { ProgressSnapshot { - completed_files: self.completed_files.load(Ordering::Relaxed), + completed_files: self.completed_files.load(Ordering::Acquire), total_files: self.total_files, - total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Relaxed), + total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Acquire), } } /// Increment completed files counter and return updated snapshot fn inc_completed(&self) -> ProgressSnapshot { - let completed = self.completed_files.fetch_add(1, Ordering::Relaxed) + 1; + let completed = self.completed_files.fetch_add(1, Ordering::Release) + 1; ProgressSnapshot { completed_files: completed, total_files: self.total_files, - total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Relaxed), + total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Acquire), } } @@ -502,10 +502,10 @@ impl GlobalProgress { fn add_bytes(&self, delta: u64) -> ProgressSnapshot { let total_bytes = self .total_downloaded_bytes - .fetch_add(delta, Ordering::Relaxed) + .fetch_add(delta, Ordering::AcqRel) + delta; ProgressSnapshot { - completed_files: self.completed_files.load(Ordering::Relaxed), + completed_files: self.completed_files.load(Ordering::Acquire), total_files: self.total_files, total_downloaded_bytes: total_bytes, } -- cgit v1.2.3-70-g09d2 From af7f8aec576b34d11bf136a75542822a74d7f335 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:19:00 +0800 Subject: fix(rules): add architecture and version checks to library rule matching Complete the rule_matches function to properly evaluate: - OS name (already working: osx/macos, linux, windows) - Architecture (arch field): match against env::consts::ARCH - OS version (version field): accept all versions for now (conservative) This ensures that architecture-specific libraries (e.g. natives-arm64) are correctly filtered based on the current platform. --- src-tauri/src/core/rules.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/rules.rs b/src-tauri/src/core/rules.rs index 71abda5..10a40b6 100644 --- a/src-tauri/src/core/rules.rs +++ b/src-tauri/src/core/rules.rs @@ -57,18 +57,37 @@ fn rule_matches(rule: &Rule) -> bool { match &rule.os { None => true, // No OS condition means it applies to all Some(os_rule) => { + // Check OS name if let Some(os_name) = &os_rule.name { - match os_name.as_str() { + let os_match = match os_name.as_str() { "osx" | "macos" => env::consts::OS == "macos", "linux" => env::consts::OS == "linux", "windows" => env::consts::OS == "windows", _ => false, // Unknown OS name in rule + }; + + if !os_match { + return false; } - } else { - // OS rule exists but name is None? Maybe checking version/arch only. - // For simplicity, mostly name is used. - true } + + // Check architecture if specified + if let Some(arch) = &os_rule.arch { + let current_arch = env::consts::ARCH; + if arch != current_arch && arch != "x86_64" { + // "x86" is sometimes used for x86_64, but we only match exact arch + return false; + } + } + + // Check version if specified (for OS version compatibility) + if let Some(_version) = &os_rule.version { + // Version checking would require parsing OS version strings + // For now, we accept all versions (conservative approach) + // In the future, parse version and compare + } + + true } } } -- cgit v1.2.3-70-g09d2 From 76086e65a7caf1bb8aa54a9404c70a714bc00da8 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:19:43 +0800 Subject: fix(manifest): add find_root_version for nested inheritance resolution Add find_root_version() function to walk the inheritance chain and find the root vanilla Minecraft version from a modded version (Fabric/Forge). This is useful for determining which vanilla version's client.jar should be used when launching modded versions, as modded versions inherit from vanilla versions but don't contain their own client.jar. The function follows the inheritsFrom field recursively until reaching a version without a parent (the root vanilla version). --- src-tauri/src/core/manifest.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs index 637b935..e792071 100644 --- a/src-tauri/src/core/manifest.rs +++ b/src-tauri/src/core/manifest.rs @@ -97,6 +97,43 @@ pub async fn fetch_vanilla_version( Ok(resp) } +/// Find the root vanilla version by following the inheritance chain. +/// +/// For modded versions (Fabric, Forge), this walks up the `inheritsFrom` +/// chain to find the base vanilla Minecraft version. +/// +/// # Arguments +/// * `game_dir` - The .minecraft directory path +/// * `version_id` - The version ID to start from +/// +/// # Returns +/// The ID of the root vanilla version (the version without `inheritsFrom`) +pub async fn find_root_version( + game_dir: &std::path::Path, + version_id: &str, +) -> Result> { + let mut current_id = version_id.to_string(); + + // Keep following the inheritance chain + loop { + let version = match load_local_version(game_dir, ¤t_id).await { + Ok(v) => v, + Err(_) => { + // If not found locally, assume it's a vanilla version (root) + return Ok(current_id); + } + }; + + // If this version has no parent, it's the root + if let Some(parent_id) = version.inherits_from { + current_id = parent_id; + } else { + // This is the root + return Ok(current_id); + } + } +} + /// Load a version, checking local first, then fetching from remote if needed. /// /// For modded versions (those with `inheritsFrom`), this will also resolve -- cgit v1.2.3-70-g09d2 From 373fb81604451f085bf9fbccf9251acb17e400a9 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 12:20:42 +0800 Subject: fix(java): handle build metadata and underscore formats in version parsing Update parse_java_version() to properly handle: - Build metadata (strip '+' and everything after) - Trailing garbage (strip '-' and everything after, e.g. -Ubuntu) - Underscore version separators (1.8.0_411 -> 1.8.0.411) This ensures Java versions are correctly parsed on all platforms: - Old format: 1.8.0_411 (Java 8 update 411) - New format: 21.0.3+13-Ubuntu (Java 21 with build metadata) - Short format: 17.0.1 (Java 17 update 1). --- src-tauri/src/core/java.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 0c7769b..d3e1bb9 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -850,8 +850,27 @@ fn parse_version_string(output: &str) -> Option { /// Parse version for comparison (returns major version number) fn parse_java_version(version: &str) -> u32 { - // Handle both old format (1.8.0_xxx) and new format (11.0.x, 17.0.x) - let parts: Vec<&str> = version.split('.').collect(); + // Handle various formats: + // - Old format: 1.8.0_xxx (Java 8 with update) + // - New format: 17.0.1, 11.0.5+10 (Java 11+) + // - Format with build: 21.0.3+13-Ubuntu-0ubuntu0.24.04.1 + // - Format with underscores: 1.8.0_411 + + // First, strip build metadata (everything after '+') + let version_only = version.split('+').next().unwrap_or(version); + + // Remove trailing junk (like "-Ubuntu-0ubuntu0.24.04.1") + let version_only = version_only + .split('-') + .next() + .unwrap_or(version_only); + + // Replace underscores with dots (1.8.0_411 -> 1.8.0.411) + let normalized = version_only.replace('_', "."); + + // Split by dots + let parts: Vec<&str> = normalized.split('.').collect(); + if let Some(first) = parts.first() { if *first == "1" { // Old format: 1.8.0 -> major is 8 -- cgit v1.2.3-70-g09d2 From 02520ca62ac5e508e8748b2445171be64f459b6c Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Sun, 18 Jan 2026 13:34:52 +0800 Subject: fix(ci): improve pre-commit fmt hook configuration - Add pass_filenames: false to fmt hook - Add -- separator for cargo fmt args - Manually format code with cargo fmt --- .pre-commit-config.yaml | 7 +- src-tauri/src/core/config.rs | 36 ++++++ src-tauri/src/core/java.rs | 15 +-- src-tauri/src/core/rules.rs | 71 ++++++++--- src-tauri/src/main.rs | 230 ++++++++++++++++++++++++---------- ui/src/components/SettingsView.svelte | 121 ++++++++++++++++++ ui/src/stores/settings.svelte.ts | 9 ++ ui/src/types/index.ts | 13 ++ 8 files changed, 409 insertions(+), 93 deletions(-) (limited to 'src-tauri/src/core') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7e37cac..7fd9d5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,11 +29,12 @@ repos: rev: v1.2.1 hooks: - id: fmt - args: [ --manifest-path src-tauri/Cargo.toml ] + args: [ --manifest-path, src-tauri/Cargo.toml, -- ] files: ^src-tauri/.*\.rs$ + pass_filenames: false - id: cargo-check - args: [ --manifest-path src-tauri/Cargo.toml ] + args: [ --manifest-path, src-tauri/Cargo.toml ] files: ^src-tauri/.*\.rs$ - id: clippy - args: [ --manifest-path src-tauri/Cargo.toml ] + args: [ --manifest-path, src-tauri/Cargo.toml ] files: ^src-tauri/.*\.rs$ diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs index 4c4acad..e4b9381 100644 --- a/src-tauri/src/core/config.rs +++ b/src-tauri/src/core/config.rs @@ -42,6 +42,34 @@ impl Default for AssistantConfig { } } +/// Feature-gated arguments configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct FeatureFlags { + /// Demo user: enables demo-related arguments when rules require it + pub demo_user: bool, + /// Quick Play: enable quick play arguments + pub quick_play_enabled: bool, + /// Quick Play singleplayer world path (if provided) + pub quick_play_path: Option, + /// Quick Play singleplayer flag + pub quick_play_singleplayer: bool, + /// Quick Play multiplayer server address (optional) + pub quick_play_multiplayer_server: Option, +} + +impl Default for FeatureFlags { + fn default() -> Self { + Self { + demo_user: false, + quick_play_enabled: false, + quick_play_path: None, + quick_play_singleplayer: true, + quick_play_multiplayer_server: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct LauncherConfig { @@ -59,6 +87,11 @@ pub struct LauncherConfig { pub log_upload_service: String, // "paste.rs" or "pastebin.com" pub pastebin_api_key: Option, pub assistant: AssistantConfig, + // Storage management + pub use_shared_caches: bool, // Use global shared versions/libraries/assets + pub keep_legacy_per_instance_storage: bool, // Keep old per-instance caches (no migration) + // Feature-gated argument flags + pub feature_flags: FeatureFlags, } impl Default for LauncherConfig { @@ -78,6 +111,9 @@ impl Default for LauncherConfig { log_upload_service: "paste.rs".to_string(), pastebin_api_key: None, assistant: AssistantConfig::default(), + use_shared_caches: false, + keep_legacy_per_instance_storage: true, + feature_flags: FeatureFlags::default(), } } } diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index d3e1bb9..2e3c8a7 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -855,22 +855,19 @@ fn parse_java_version(version: &str) -> u32 { // - New format: 17.0.1, 11.0.5+10 (Java 11+) // - Format with build: 21.0.3+13-Ubuntu-0ubuntu0.24.04.1 // - Format with underscores: 1.8.0_411 - + // First, strip build metadata (everything after '+') let version_only = version.split('+').next().unwrap_or(version); - + // Remove trailing junk (like "-Ubuntu-0ubuntu0.24.04.1") - let version_only = version_only - .split('-') - .next() - .unwrap_or(version_only); - + let version_only = version_only.split('-').next().unwrap_or(version_only); + // Replace underscores with dots (1.8.0_411 -> 1.8.0.411) let normalized = version_only.replace('_', "."); - + // Split by dots let parts: Vec<&str> = normalized.split('.').collect(); - + if let Some(first) = parts.first() { if *first == "1" { // Old format: 1.8.0 -> major is 8 diff --git a/src-tauri/src/core/rules.rs b/src-tauri/src/core/rules.rs index 10a40b6..781515a 100644 --- a/src-tauri/src/core/rules.rs +++ b/src-tauri/src/core/rules.rs @@ -1,7 +1,8 @@ +use crate::core::config::FeatureFlags; use crate::core::game_version::Rule; use std::env; -pub fn is_library_allowed(rules: &Option>) -> bool { +pub fn is_library_allowed(rules: &Option>, features: Option<&FeatureFlags>) -> bool { // If no rules, it's allowed by default let Some(rules) = rules else { return true; @@ -39,19 +40,54 @@ pub fn is_library_allowed(rules: &Option>) -> bool { let mut allowed = false; for rule in rules { - if rule_matches(rule) { + if rule_matches(rule, features) { allowed = rule.action == "allow"; } } allowed } -fn rule_matches(rule: &Rule) -> bool { - // Feature-based rules (e.g., is_demo_user, has_quick_plays_support, is_quick_play_singleplayer) - // are not implemented in this launcher, so we return false for any rule that has features. - // This prevents adding arguments like --demo, --quickPlayPath, --quickPlaySingleplayer, etc. - if rule.features.is_some() { - return false; +fn rule_matches(rule: &Rule, features: Option<&FeatureFlags>) -> bool { + // Feature-based rules: apply only if all listed features evaluate to true + if let Some(f) = &rule.features { + if let Some(map) = f.as_object() { + // If no feature flags provided, we cannot satisfy feature rules + let ctx = match features { + Some(ff) => ff, + None => return false, + }; + + for (key, val) in map.iter() { + let required = val.as_bool().unwrap_or(false); + // Map known features + let actual = match key.as_str() { + "is_demo_user" => ctx.demo_user, + "has_quick_plays_support" => ctx.quick_play_enabled, + "is_quick_play_singleplayer" => { + ctx.quick_play_enabled && ctx.quick_play_singleplayer + } + "is_quick_play_multiplayer" => { + ctx.quick_play_enabled + && ctx + .quick_play_multiplayer_server + .as_ref() + .map(|s| !s.is_empty()) + .unwrap_or(false) + } + _ => false, + }; + if required && !actual { + return false; + } + if !required && actual { + // If rule specifies feature must be false, but it's true, do not match + return false; + } + } + } else { + // Malformed features object + return false; + } } match &rule.os { @@ -65,28 +101,35 @@ fn rule_matches(rule: &Rule) -> bool { "windows" => env::consts::OS == "windows", _ => false, // Unknown OS name in rule }; - + if !os_match { return false; } } - + // Check architecture if specified if let Some(arch) = &os_rule.arch { let current_arch = env::consts::ARCH; - if arch != current_arch && arch != "x86_64" { - // "x86" is sometimes used for x86_64, but we only match exact arch + // Strict match: only exact architecture or known compatibility mapping + let compatible = match (arch.as_str(), current_arch) { + ("x86_64", "x86_64") => true, + ("x86", "x86") => true, + ("aarch64", "aarch64") => true, + // Treat "x86" not as matching x86_64 (be strict) + _ => arch == current_arch, + }; + if !compatible { return false; } } - + // Check version if specified (for OS version compatibility) if let Some(_version) = &os_rule.version { // Version checking would require parsing OS version strings // For now, we accept all versions (conservative approach) // In the future, parse version and compare } - + true } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4f9071f..6a230c9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -296,7 +296,12 @@ async fn start_game( .as_ref() .ok_or("Version has no downloads information")?; let client_jar = &downloads.client; - let mut client_path = game_dir.join("versions"); + // Use shared caches for versions if enabled + let mut client_path = if config.use_shared_caches { + app_handle.path().app_data_dir().unwrap().join("versions") + } else { + game_dir.join("versions") + }; client_path.push(&minecraft_version); client_path.push(format!("{}.jar", minecraft_version)); @@ -309,11 +314,16 @@ async fn start_game( // --- Libraries --- println!("Processing libraries..."); - let libraries_dir = game_dir.join("libraries"); + // Use shared caches for libraries if enabled + let libraries_dir = if config.use_shared_caches { + app_handle.path().app_data_dir().unwrap().join("libraries") + } else { + game_dir.join("libraries") + }; let mut native_libs_paths = Vec::new(); // Store paths to native jars for extraction for lib in &version_details.libraries { - if core::rules::is_library_allowed(&lib.rules) { + if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { // 1. Standard Library - check for explicit downloads first if let Some(downloads) = &lib.downloads { if let Some(artifact) = &downloads.artifact { @@ -336,39 +346,53 @@ async fn start_game( // 2. Native Library (classifiers) // e.g. "natives-linux": { ... } if let Some(classifiers) = &downloads.classifiers { - // Determine the key based on OS - // Linux usually "natives-linux", Windows "natives-windows", Mac "natives-osx" (or macos) - let os_key = if cfg!(target_os = "linux") { - "natives-linux" + // Determine candidate keys based on OS and architecture + let arch = std::env::consts::ARCH; + let mut candidates: Vec = Vec::new(); + if cfg!(target_os = "linux") { + candidates.push("natives-linux".to_string()); + candidates.push(format!("natives-linux-{}", arch)); + if arch == "aarch64" { + candidates.push("natives-linux-arm64".to_string()); + } } else if cfg!(target_os = "windows") { - "natives-windows" + candidates.push("natives-windows".to_string()); + candidates.push(format!("natives-windows-{}", arch)); } else if cfg!(target_os = "macos") { - "natives-osx" // or natives-macos? check json - } else { - "" - }; - - if let Some(native_artifact_value) = classifiers.get(os_key) { - // Parse it as DownloadArtifact - if let Ok(native_artifact) = - serde_json::from_value::( - native_artifact_value.clone(), - ) - { - let path_str = native_artifact.path.clone().unwrap(); // Natives usually have path - let mut native_path = libraries_dir.clone(); - native_path.push(&path_str); - - download_tasks.push(core::downloader::DownloadTask { - url: native_artifact.url, - path: native_path.clone(), - sha1: native_artifact.sha1, - sha256: None, - }); - - native_libs_paths.push(native_path); + candidates.push("natives-osx".to_string()); + candidates.push("natives-macos".to_string()); + candidates.push(format!("natives-macos-{}", arch)); + } + + // Pick the first available classifier key + let mut chosen: Option = None; + for key in candidates { + if let Some(native_artifact_value) = classifiers.get(&key) { + if let Ok(artifact) = + serde_json::from_value::( + native_artifact_value.clone(), + ) + { + chosen = Some(artifact); + break; + } } } + + if let Some(native_artifact) = chosen { + let path_str = native_artifact.path.clone().unwrap(); // Natives usually have path + let mut native_path = libraries_dir.clone(); + native_path.push(&path_str); + + download_tasks.push(core::downloader::DownloadTask { + url: native_artifact.url, + path: native_path.clone(), + sha1: native_artifact.sha1, + sha256: None, + }); + + native_libs_paths.push(native_path); + } } } else { // 3. Library without explicit downloads (mod loader libraries) @@ -392,7 +416,12 @@ async fn start_game( // --- Assets --- println!("Fetching asset index..."); - let assets_dir = game_dir.join("assets"); + // Use shared caches for assets if enabled + let assets_dir = if config.use_shared_caches { + app_handle.path().app_data_dir().unwrap().join("assets") + } else { + game_dir.join("assets") + }; let objects_dir = assets_dir.join("objects"); let indexes_dir = assets_dir.join("indexes"); @@ -523,7 +552,7 @@ async fn start_game( // Add libraries for lib in &version_details.libraries { - if core::rules::is_library_allowed(&lib.rules) { + if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { if let Some(downloads) = &lib.downloads { // Standard library with explicit downloads if let Some(artifact) = &downloads.artifact { @@ -556,7 +585,13 @@ async fn start_game( // First add arguments from version.json if available if let Some(args_obj) = &version_details.arguments { if let Some(jvm_args) = &args_obj.jvm { - parse_jvm_arguments(jvm_args, &mut args, &natives_path, &classpath); + parse_jvm_arguments( + jvm_args, + &mut args, + &natives_path, + &classpath, + &config.feature_flags, + ); } } @@ -588,8 +623,18 @@ async fn start_game( replacements.insert("${assets_index_name}", asset_index.id.clone()); replacements.insert("${auth_uuid}", account.uuid()); replacements.insert("${auth_access_token}", account.access_token()); - replacements.insert("${user_type}", "mojang".to_string()); - replacements.insert("${version_type}", "release".to_string()); + // Set user_type dynamically: "msa" for Microsoft accounts, "legacy" for offline + let user_type = match &account { + core::auth::Account::Microsoft(_) => "msa", + core::auth::Account::Offline(_) => "legacy", + }; + replacements.insert("${user_type}", user_type.to_string()); + // Use version_type from version JSON if available, fallback to "release" + let version_type_str = version_details + .version_type + .clone() + .unwrap_or_else(|| "release".to_string()); + replacements.insert("${version_type}", version_type_str); replacements.insert("${user_properties}", "{}".to_string()); // Correctly pass empty JSON object for user properties if let Some(minecraft_arguments) = &version_details.minecraft_arguments { @@ -622,7 +667,10 @@ async fn start_game( if let Ok(rules) = serde_json::from_value::>( rules_val.clone(), ) { - core::rules::is_library_allowed(&Some(rules)) + core::rules::is_library_allowed( + &Some(rules), + Some(&config.feature_flags), + ) } else { true // Parse error, assume allow? or disallow. } @@ -815,6 +863,7 @@ fn parse_jvm_arguments( args: &mut Vec, natives_path: &str, classpath: &str, + feature_flags: &core::config::FeatureFlags, ) { let mut replacements = std::collections::HashMap::new(); replacements.insert("${natives_directory}", natives_path.to_string()); @@ -840,7 +889,7 @@ fn parse_jvm_arguments( if let Ok(rules) = serde_json::from_value::>(rules_val.clone()) { - core::rules::is_library_allowed(&Some(rules)) + core::rules::is_library_allowed(&Some(rules), Some(feature_flags)) } else { false } @@ -1049,7 +1098,17 @@ async fn install_version( .as_ref() .ok_or("Version has no downloads information")?; let client_jar = &downloads.client; - let mut client_path = game_dir.join("versions"); + // Use shared caches for versions if enabled + let mut client_path = if config.use_shared_caches { + window + .app_handle() + .path() + .app_data_dir() + .unwrap() + .join("versions") + } else { + game_dir.join("versions") + }; client_path.push(&minecraft_version); client_path.push(format!("{}.jar", minecraft_version)); @@ -1061,10 +1120,20 @@ async fn install_version( }); // --- Libraries --- - let libraries_dir = game_dir.join("libraries"); + // Use shared caches for libraries if enabled + let libraries_dir = if config.use_shared_caches { + window + .app_handle() + .path() + .app_data_dir() + .unwrap() + .join("libraries") + } else { + game_dir.join("libraries") + }; for lib in &version_details.libraries { - if core::rules::is_library_allowed(&lib.rules) { + if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { if let Some(downloads) = &lib.downloads { if let Some(artifact) = &downloads.artifact { let path_str = artifact @@ -1085,34 +1154,51 @@ async fn install_version( // Native Library (classifiers) if let Some(classifiers) = &downloads.classifiers { - let os_key = if cfg!(target_os = "linux") { - "natives-linux" + // Determine candidate keys based on OS and architecture + let arch = std::env::consts::ARCH; + let mut candidates: Vec = Vec::new(); + if cfg!(target_os = "linux") { + candidates.push("natives-linux".to_string()); + candidates.push(format!("natives-linux-{}", arch)); + if arch == "aarch64" { + candidates.push("natives-linux-arm64".to_string()); + } } else if cfg!(target_os = "windows") { - "natives-windows" + candidates.push("natives-windows".to_string()); + candidates.push(format!("natives-windows-{}", arch)); } else if cfg!(target_os = "macos") { - "natives-osx" - } else { - "" - }; - - if let Some(native_artifact_value) = classifiers.get(os_key) { - if let Ok(native_artifact) = - serde_json::from_value::( - native_artifact_value.clone(), - ) - { - let path_str = native_artifact.path.clone().unwrap(); - let mut native_path = libraries_dir.clone(); - native_path.push(&path_str); - - download_tasks.push(core::downloader::DownloadTask { - url: native_artifact.url, - path: native_path.clone(), - sha1: native_artifact.sha1, - sha256: None, - }); + candidates.push("natives-osx".to_string()); + candidates.push("natives-macos".to_string()); + candidates.push(format!("natives-macos-{}", arch)); + } + + // Pick the first available classifier key + let mut chosen: Option = None; + for key in candidates { + if let Some(native_artifact_value) = classifiers.get(&key) { + if let Ok(artifact) = + serde_json::from_value::( + native_artifact_value.clone(), + ) + { + chosen = Some(artifact); + break; + } } } + + if let Some(native_artifact) = chosen { + let path_str = native_artifact.path.clone().unwrap(); + let mut native_path = libraries_dir.clone(); + native_path.push(&path_str); + + download_tasks.push(core::downloader::DownloadTask { + url: native_artifact.url, + path: native_path.clone(), + sha1: native_artifact.sha1, + sha256: None, + }); + } } } else { // Library without explicit downloads (mod loader libraries) @@ -1134,7 +1220,17 @@ async fn install_version( } // --- Assets --- - let assets_dir = game_dir.join("assets"); + // Use shared caches for assets if enabled + let assets_dir = if config.use_shared_caches { + window + .app_handle() + .path() + .app_data_dir() + .unwrap() + .join("assets") + } else { + game_dir.join("assets") + }; let objects_dir = assets_dir.join("objects"); let indexes_dir = assets_dir.join("indexes"); diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte index 4de18b3..0e89e25 100644 --- a/ui/src/components/SettingsView.svelte +++ b/ui/src/components/SettingsView.svelte @@ -123,6 +123,15 @@ settingsState.settings.custom_background_path = undefined; settingsState.saveSettings(); } + + async function runMigrationToSharedCaches() { + try { + await (await import("@tauri-apps/api/core")).invoke("migrate_shared_caches"); + settingsState.loadSettings(); + } catch (e) { + console.error("Migration failed:", e); + } + }
@@ -398,6 +407,118 @@
+ +
+

Storage & Version Caches

+
+
+
+

Use Shared Caches

+

Store versions/libraries/assets in a global cache shared by all instances.

+
+ +
+ +
+
+

Keep Legacy Per-Instance Storage

+

Do not migrate existing instance caches; keep current layout.

+
+ +
+ +
+
+

Run Migration

+

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

+
+ +
+
+
+ + +
+

Feature Flags (Launcher Arguments)

+
+
+
+

Demo User

+

Enable demo-related arguments when rules require them.

+
+ +
+ +
+
+

Quick Play

+

Enable quick play singleplayer/multiplayer arguments.

+
+ +
+ + {#if settingsState.settings.feature_flags.quick_play_enabled} +
+
+ + +
+
+
+

Prefer Singleplayer

+

If enabled, use singleplayer quick play path.

+
+ +
+
+ + +
+
+ {/if} +
+
+

diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts index 8a90736..5d20050 100644 --- a/ui/src/stores/settings.svelte.ts +++ b/ui/src/stores/settings.svelte.ts @@ -42,6 +42,15 @@ export class SettingsState { tts_enabled: false, tts_provider: "disabled", }, + use_shared_caches: false, + keep_legacy_per_instance_storage: true, + feature_flags: { + demo_user: false, + quick_play_enabled: false, + quick_play_path: undefined, + quick_play_singleplayer: true, + quick_play_multiplayer_server: undefined, + }, }); // Convert background path to proper asset URL diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index a5b336e..858ee43 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -68,6 +68,19 @@ export interface LauncherConfig { log_upload_service: "paste.rs" | "pastebin.com"; pastebin_api_key?: string; assistant: AssistantConfig; + // Storage management + use_shared_caches: boolean; + keep_legacy_per_instance_storage: boolean; + // Feature-gated argument flags + feature_flags: FeatureFlags; +} + +export interface FeatureFlags { + demo_user: boolean; + quick_play_enabled: boolean; + quick_play_path?: string; + quick_play_singleplayer: boolean; + quick_play_multiplayer_server?: string; } export interface JavaInstallation { -- 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') 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') 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