diff options
| author | 2026-01-19 11:06:38 +0800 | |
|---|---|---|
| committer | 2026-01-19 11:06:38 +0800 | |
| commit | f5560d7e8abe4a41c5f959cb6eb888f6aef6ca65 (patch) | |
| tree | f3675bdb552a79ddb4601ccf2f5ddd81eb47c9fb /src-tauri | |
| parent | ee767338d6db510ef15d6b8cc11f6fb9a6215a43 (diff) | |
| parent | bdff2175a8470accdab030b3931406495c56074d (diff) | |
| download | DropOut-f5560d7e8abe4a41c5f959cb6eb888f6aef6ca65.tar.gz DropOut-f5560d7e8abe4a41c5f959cb6eb888f6aef6ca65.zip | |
Merge branch 'main' into chore/migrate-repository
Diffstat (limited to 'src-tauri')
| -rw-r--r-- | src-tauri/CHANGELOG.md | 7 | ||||
| -rw-r--r-- | src-tauri/Cargo.toml | 10 | ||||
| -rw-r--r-- | src-tauri/src/core/auth.rs | 5 | ||||
| -rw-r--r-- | src-tauri/src/core/config.rs | 36 | ||||
| -rw-r--r-- | src-tauri/src/core/downloader.rs | 22 | ||||
| -rw-r--r-- | src-tauri/src/core/instance.rs | 279 | ||||
| -rw-r--r-- | src-tauri/src/core/java.rs | 20 | ||||
| -rw-r--r-- | src-tauri/src/core/manifest.rs | 37 | ||||
| -rw-r--r-- | src-tauri/src/core/rules.rs | 88 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 512 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 80 |
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, ¤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 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 + } + } + } } |