aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-01-19 11:06:38 +0800
committerGitHub <noreply@github.com>2026-01-19 11:06:38 +0800
commitf5560d7e8abe4a41c5f959cb6eb888f6aef6ca65 (patch)
treef3675bdb552a79ddb4601ccf2f5ddd81eb47c9fb /src-tauri
parentee767338d6db510ef15d6b8cc11f6fb9a6215a43 (diff)
parentbdff2175a8470accdab030b3931406495c56074d (diff)
downloadDropOut-f5560d7e8abe4a41c5f959cb6eb888f6aef6ca65.tar.gz
DropOut-f5560d7e8abe4a41c5f959cb6eb888f6aef6ca65.zip
Merge branch 'main' into chore/migrate-repository
Diffstat (limited to 'src-tauri')
-rw-r--r--src-tauri/CHANGELOG.md7
-rw-r--r--src-tauri/Cargo.toml10
-rw-r--r--src-tauri/src/core/auth.rs5
-rw-r--r--src-tauri/src/core/config.rs36
-rw-r--r--src-tauri/src/core/downloader.rs22
-rw-r--r--src-tauri/src/core/instance.rs279
-rw-r--r--src-tauri/src/core/java.rs20
-rw-r--r--src-tauri/src/core/manifest.rs37
-rw-r--r--src-tauri/src/core/rules.rs88
-rw-r--r--src-tauri/src/main.rs512
-rw-r--r--src-tauri/tauri.conf.json80
11 files changed, 930 insertions, 166 deletions
diff --git a/src-tauri/CHANGELOG.md b/src-tauri/CHANGELOG.md
new file mode 100644
index 0000000..4b2d22b
--- /dev/null
+++ b/src-tauri/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Changelog
+
+## v0.2.0-alpha.1
+
+### New Features
+
+- [`e7ac28c`](https://github.com/HydroRoll-Team/DropOut/commit/e7ac28c6b8467a8fca0a3b61ba498e4742d3a718): Prepare for alpha mode pre-release. ([#62](https://github.com/HydroRoll-Team/DropOut/pull/62) by @fu050409)
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index e1b2ab3..6fde40f 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,11 +1,12 @@
[package]
name = "dropout"
-version = "0.1.26"
+version = "0.2.0-alpha.1"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
license = "MIT"
repository = "https://github.com/HydroRoll-Team/DropOut"
+publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
@@ -31,16 +32,11 @@ tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5"
bytes = "1.11.0"
chrono = "0.4"
+regex = "1.12.2"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
-[profile.dev]
-opt-level = 0
-
-[profile.release]
-opt-level = 3
-
[package.metadata.deb]
depends = "libgtk-3-0"
section = "games"
diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs
index ac5904c..d5e6c17 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)]
@@ -136,7 +136,6 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenRespons
}
/// Check if a Microsoft account token is expired or about to expire
-#[allow(dead_code)]
pub fn is_token_expired(expires_at: i64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
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<String>,
+ /// Quick Play singleplayer flag
+ pub quick_play_singleplayer: bool,
+ /// Quick Play multiplayer server address (optional)
+ pub quick_play_multiplayer_server: Option<String>,
+}
+
+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<String>,
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/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,
}
diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs
index 90ec34e..573273e 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;
@@ -24,6 +25,16 @@ pub struct Instance {
pub notes: Option<String>, // 备注(可选)
pub mod_loader: Option<String>, // 模组加载器类型:"fabric", "forge", "vanilla"
pub mod_loader_version: Option<String>, // 模组加载器版本
+ pub jvm_args_override: Option<String>, // JVM参数覆盖(可选)
+ #[serde(default)]
+ pub memory_override: Option<MemoryOverride>, // 内存设置覆盖(可选)
+}
+
+/// 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
@@ -98,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();
@@ -218,21 +231,44 @@ 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)?;
+ // 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 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
+ // 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,
+ jvm_args_override: source_instance.jvm_args_override.clone(),
+ memory_override: source_instance.memory_override.clone(),
+ };
+
self.update_instance(new_instance.clone())?;
Ok(new_instance)
@@ -323,3 +359,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<String, PathBuf> = 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<String, PathBuf>,
+) -> 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<String, PathBuf>,
+ 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<String, String> {
+ 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/core/java.rs b/src-tauri/src/core/java.rs
index 0c7769b..2e3c8a7 100644
--- a/src-tauri/src/core/java.rs
+++ b/src-tauri/src/core/java.rs
@@ -850,8 +850,24 @@ fn parse_version_string(output: &str) -> Option<String> {
/// 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
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<String, Box<dyn Error + Send + Sync>> {
+ let mut current_id = version_id.to_string();
+
+ // Keep following the inheritance chain
+ loop {
+ let version = match load_local_version(game_dir, &current_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
diff --git a/src-tauri/src/core/rules.rs b/src-tauri/src/core/rules.rs
index 71abda5..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<Vec<Rule>>) -> bool {
+pub fn is_library_allowed(rules: &Option<Vec<Rule>>, features: Option<&FeatureFlags>) -> bool {
// If no rules, it's allowed by default
let Some(rules) = rules else {
return true;
@@ -39,36 +40,97 @@ pub fn is_library_allowed(rules: &Option<Vec<Rule>>) -> 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 {
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;
+ // 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 8d73cc2..e4d0a2e 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
@@ -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();
@@ -267,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));
@@ -280,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 {
@@ -307,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<String> = 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::<core::game_version::DownloadArtifact>(
- 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<core::game_version::DownloadArtifact> = None;
+ for key in candidates {
+ if let Some(native_artifact_value) = classifiers.get(&key) {
+ if let Ok(artifact) =
+ serde_json::from_value::<core::game_version::DownloadArtifact>(
+ 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)
@@ -363,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");
@@ -494,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 {
@@ -527,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,
+ );
}
}
@@ -559,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 {
@@ -593,7 +667,10 @@ async fn start_game(
if let Ok(rules) = serde_json::from_value::<Vec<core::game_version::Rule>>(
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.
}
@@ -777,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))
}
@@ -786,6 +869,7 @@ fn parse_jvm_arguments(
args: &mut Vec<String>,
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());
@@ -811,7 +895,7 @@ fn parse_jvm_arguments(
if let Ok(rules) =
serde_json::from_value::<Vec<core::game_version::Rule>>(rules_val.clone())
{
- core::rules::is_library_allowed(&Some(rules))
+ core::rules::is_library_allowed(&Some(rules), Some(feature_flags))
} else {
false
}
@@ -850,12 +934,14 @@ fn parse_jvm_arguments(
}
#[tauri::command]
-async fn get_versions(window: Window) -> Result<Vec<core::manifest::Version>, String> {
- let app_handle = window.app_handle();
- let game_dir = app_handle
- .path()
- .app_data_dir()
- .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+async fn get_versions(
+ _window: Window,
+ instance_state: State<'_, core::instance::InstanceState>,
+ instance_id: String,
+) -> Result<Vec<core::manifest::Version>, String> {
+ let game_dir = instance_state
+ .get_instance_game_dir(&instance_id)
+ .ok_or_else(|| format!("Instance {} not found", instance_id))?;
match core::manifest::fetch_version_manifest().await {
Ok(manifest) => {
@@ -1018,7 +1104,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));
@@ -1030,10 +1126,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
@@ -1054,34 +1160,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<String> = 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::<core::game_version::DownloadArtifact>(
- 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<core::game_version::DownloadArtifact> = None;
+ for key in candidates {
+ if let Some(native_artifact_value) = classifiers.get(&key) {
+ if let Ok(artifact) =
+ serde_json::from_value::<core::game_version::DownloadArtifact>(
+ 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)
@@ -1103,7 +1226,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");
@@ -1566,6 +1699,14 @@ async fn install_fabric(
format!("Fabric installed successfully: {}", result.id)
);
+ // 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.clone());
+ instance.version_id = Some(result.id.clone());
+ instance_state.update_instance(instance)?;
+ }
+
// Emit event to notify frontend
let _ = window.emit("fabric-installed", &result.id);
@@ -1640,6 +1781,31 @@ async fn delete_version(
.await
.map_err(|e| format!("Failed to delete version: {}", e))?;
+ // Clean up Instance state if necessary
+ if let Some(mut instance) = instance_state.get_instance(&instance_id) {
+ let mut updated = false;
+
+ // If deleted version is the current selected version
+ if instance.version_id.as_ref() == Some(&version_id) {
+ instance.version_id = None;
+ updated = true;
+ }
+
+ // If deleted version is a modded version, clear mod_loader
+ if (version_id.starts_with("fabric-loader-")
+ && instance.mod_loader == Some("fabric".to_string()))
+ || (version_id.contains("-forge-") && instance.mod_loader == Some("forge".to_string()))
+ {
+ instance.mod_loader = None;
+ instance.mod_loader_version = None;
+ updated = true;
+ }
+
+ if updated {
+ instance_state.update_instance(instance)?;
+ }
+ }
+
// Emit event to notify frontend
let _ = window.emit("version-deleted", &version_id);
@@ -1917,16 +2083,45 @@ async fn install_forge(
"Forge installer completed, creating version profile...".to_string()
);
- // Now create the version JSON
- let result = core::forge::install_forge(&game_dir, &game_version, &forge_version)
- .await
- .map_err(|e| e.to_string())?;
+ // Check if the version JSON already exists
+ let version_id = core::forge::generate_version_id(&game_version, &forge_version);
+ let json_path = game_dir
+ .join("versions")
+ .join(&version_id)
+ .join(format!("{}.json", version_id));
+
+ let result = if json_path.exists() {
+ // Version JSON was created by the installer, load it
+ emit_log!(
+ window,
+ "Using version profile created by Forge installer".to_string()
+ );
+ core::forge::InstalledForgeVersion {
+ id: version_id,
+ minecraft_version: game_version.clone(),
+ forge_version: forge_version.clone(),
+ path: json_path,
+ }
+ } else {
+ // Installer didn't create JSON, create it manually
+ core::forge::install_forge(&game_dir, &game_version, &forge_version)
+ .await
+ .map_err(|e| e.to_string())?
+ };
emit_log!(
window,
format!("Forge installed successfully: {}", result.id)
);
+ // 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.clone());
+ instance.version_id = Some(result.id.clone());
+ instance_state.update_instance(instance)?;
+ }
+
// Emit event to notify frontend
let _ = window.emit("forge-installed", &result.id);
@@ -2186,6 +2381,159 @@ 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<MigrationResult, String> {
+ 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,
+ })
+}
+
+/// 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<Vec<FileInfo>, 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())
@@ -2292,7 +2640,11 @@ fn main() {
get_instance,
set_active_instance,
get_active_instance,
- duplicate_instance
+ duplicate_instance,
+ 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/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index dd84fd4..2f0ace7 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,43 +1,43 @@
{
- "productName": "dropout",
- "version": "0.1.26",
- "identifier": "com.dropout.launcher",
- "build": {
- "beforeDevCommand": "pnpm -C ../ui dev",
- "beforeBuildCommand": "pnpm -C ../ui build",
- "devUrl": "http://localhost:5173",
- "frontendDist": "../ui/dist"
- },
- "app": {
- "windows": [
- {
- "title": "Minecraft DropOut Launcher",
- "width": 1024,
- "height": 768,
- "minWidth": 905,
- "minHeight": 575,
- "resizable": true
- }
- ],
- "security": {
- "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;",
- "capabilities": ["default"]
- }
- },
- "bundle": {
- "active": true,
- "targets": "all",
- "icon": [
- "icons/32x32.png",
- "icons/128x128.png",
- "icons/128x128@2x.png",
- "icons/icon.icns",
- "icons/icon.ico"
- ],
- "linux": {
- "appimage": {
- "bundleMediaFramework": false
- }
- }
+ "productName": "dropout",
+ "version": "0.2.0-alpha.1",
+ "identifier": "com.dropout.launcher",
+ "build": {
+ "beforeDevCommand": "pnpm --filter @dropout/ui dev",
+ "beforeBuildCommand": "pnpm --filter @dropout/ui build",
+ "devUrl": "http://localhost:5173",
+ "frontendDist": "../ui/dist"
+ },
+ "app": {
+ "windows": [
+ {
+ "title": "Minecraft DropOut Launcher",
+ "width": 1024,
+ "height": 768,
+ "minWidth": 905,
+ "minHeight": 575,
+ "resizable": true
+ }
+ ],
+ "security": {
+ "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;",
+ "capabilities": ["default"]
}
+ },
+ "bundle": {
+ "active": true,
+ "targets": "all",
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ],
+ "linux": {
+ "appimage": {
+ "bundleMediaFramework": false
+ }
+ }
+ }
}