diff options
| author | 2026-02-28 09:03:19 +0800 | |
|---|---|---|
| committer | 2026-02-28 09:03:19 +0800 | |
| commit | cc53b1cf260e1c67939e50608ef18764da616d55 (patch) | |
| tree | 119109c62331d4d26612e2df7726cee82d1871f5 /src-tauri/src/core | |
| parent | ee37d044e473217daadd9ce26c7e2e2ad39a0490 (diff) | |
| parent | 81a62402ef6f8900ff092366121a9b7a4263ba52 (diff) | |
| download | DropOut-cc53b1cf260e1c67939e50608ef18764da616d55.tar.gz DropOut-cc53b1cf260e1c67939e50608ef18764da616d55.zip | |
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'src-tauri/src/core')
22 files changed, 2327 insertions, 1162 deletions
diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs index 8998206..df202cd 100644 --- a/src-tauri/src/core/account_storage.rs +++ b/src-tauri/src/core/account_storage.rs @@ -2,23 +2,27 @@ use crate::core::auth::{Account, MicrosoftAccount, OfflineAccount}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use ts_rs::TS; /// Stored account data for persistence -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[ts(export, export_to = "account.ts")] pub struct AccountStore { pub accounts: Vec<StoredAccount>, pub active_account_id: Option<String>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type")] +#[ts(export, export_to = "account.ts")] pub enum StoredAccount { Offline(OfflineAccount), Microsoft(StoredMicrosoftAccount), } /// Microsoft account with refresh token for persistence -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "account.ts")] pub struct StoredMicrosoftAccount { pub username: String, pub uuid: String, @@ -64,6 +68,8 @@ impl StoredAccount { } } +#[derive(Debug, Clone, TS)] +#[ts(export, export_to = "account.ts")] pub struct AccountStorage { file_path: PathBuf, } diff --git a/src-tauri/src/core/assistant.rs b/src-tauri/src/core/assistant.rs index 9a8f7bf..5663007 100644 --- a/src-tauri/src/core/assistant.rs +++ b/src-tauri/src/core/assistant.rs @@ -4,8 +4,11 @@ use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use tauri::{Emitter, Window}; +use ts_rs::TS; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "assistant.ts")] pub struct Message { pub role: String, pub content: String, @@ -51,7 +54,9 @@ pub struct OllamaTagsResponse { } // Simplified model info for frontend -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "assistant.ts")] pub struct ModelInfo { pub id: String, pub name: String, @@ -102,7 +107,9 @@ pub struct OpenAIModelsResponse { } // Streaming response structures -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "assistant.ts")] pub struct GenerationStats { pub total_duration: u64, pub load_duration: u64, @@ -112,7 +119,9 @@ pub struct GenerationStats { pub eval_duration: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "assistant.ts")] pub struct StreamChunk { pub content: String, pub done: bool, @@ -223,7 +232,10 @@ impl GameAssistant { // Add language instruction if not auto if config.response_language != "auto" { - system_content = format!("{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", system_content, config.response_language); + system_content = format!( + "{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", + system_content, config.response_language + ); } // Add log context if available @@ -435,7 +447,10 @@ impl GameAssistant { let mut system_content = config.system_prompt.clone(); if config.response_language != "auto" { - system_content = format!("{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", system_content, config.response_language); + system_content = format!( + "{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", + system_content, config.response_language + ); } if !context.is_empty() { diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index d5e6c17..03752fd 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::sync::Mutex; +use ts_rs::TS; use uuid::Uuid; // Helper to create a client with a custom User-Agent @@ -11,8 +12,10 @@ fn get_client() -> reqwest::Client { .unwrap_or_else(|_| reqwest::Client::new()) } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +#[ts(export, tag = "type", export_to = "auth.ts")] pub enum Account { Offline(OfflineAccount), Microsoft(MicrosoftAccount), @@ -41,13 +44,17 @@ impl Account { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "auth.ts")] pub struct OfflineAccount { pub username: String, pub uuid: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "auth.ts")] pub struct MicrosoftAccount { pub username: String, pub uuid: String, @@ -73,11 +80,12 @@ pub fn generate_offline_uuid(username: &str) -> String { Uuid::new_v3(&namespace, username.as_bytes()).to_string() } -// const CLIENT_ID: &str = "fe165602-5410-4441-92f7-326e10a7cb82"; -const CLIENT_ID: &str = "c36a9fb6-4f2a-41ff-90bd-ae7cc92031eb"; // ATLauncher's Client ID +const CLIENT_ID: &str = "fe165602-5410-4441-92f7-326e10a7cb82"; const SCOPE: &str = "XboxLive.SignIn XboxLive.offline_access"; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(rename_all(serialize = "camelCase"))] +#[ts(export, export_to = "auth.ts", rename_all = "camelCase")] pub struct DeviceCodeResponse { pub user_code: String, pub device_code: String, @@ -87,7 +95,9 @@ pub struct DeviceCodeResponse { pub message: Option<String>, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(rename_all(serialize = "camelCase"))] +#[ts(export, export_to = "auth.ts")] pub struct TokenResponse { pub access_token: String, pub refresh_token: Option<String>, @@ -209,7 +219,9 @@ pub struct MinecraftAuthResponse { pub expires_in: u64, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "auth.ts")] pub struct MinecraftProfile { pub id: String, pub name: String, diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs index e4b9381..d1f306a 100644 --- a/src-tauri/src/core/config.rs +++ b/src-tauri/src/core/config.rs @@ -3,8 +3,11 @@ use std::fs; use std::path::PathBuf; use std::sync::Mutex; use tauri::{AppHandle, Manager}; +use ts_rs::TS; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "config.ts")] #[serde(default)] pub struct AssistantConfig { pub enabled: bool, @@ -43,7 +46,9 @@ impl Default for AssistantConfig { } /// Feature-gated arguments configuration -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "config.ts")] #[serde(default)] pub struct FeatureFlags { /// Demo user: enables demo-related arguments when rules require it @@ -70,7 +75,9 @@ impl Default for FeatureFlags { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "config.ts")] #[serde(default)] pub struct LauncherConfig { pub min_memory: u32, // in MB diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index 26f6ebd..a6b11c5 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -2,13 +2,16 @@ use futures::StreamExt; use serde::{Deserialize, Serialize}; use sha1::Digest as Sha1Digest; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use tauri::{AppHandle, Emitter, Manager, Window}; use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use tokio::sync::Semaphore; +use ts_rs::TS; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct DownloadTask { pub url: String, pub path: PathBuf, @@ -19,7 +22,9 @@ pub struct DownloadTask { } /// Metadata for resumable downloads stored in .part.meta file -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct DownloadMetadata { pub url: String, pub file_name: String, @@ -31,7 +36,9 @@ pub struct DownloadMetadata { } /// A download segment for multi-segment parallel downloading -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct DownloadSegment { pub start: u64, pub end: u64, @@ -40,7 +47,9 @@ pub struct DownloadSegment { } /// Progress event for Java download -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct JavaDownloadProgress { pub file_name: String, pub downloaded_bytes: u64, @@ -52,7 +61,9 @@ pub struct JavaDownloadProgress { } /// Pending download task for queue persistence -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct PendingJavaDownload { pub major_version: u32, pub image_type: String, @@ -65,7 +76,9 @@ pub struct PendingJavaDownload { } /// Download queue for persistence -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct DownloadQueue { pub pending_downloads: Vec<PendingJavaDownload>, } @@ -419,7 +432,9 @@ fn create_new_metadata( } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "downloader.ts")] pub struct ProgressEvent { pub file: String, pub downloaded: u64, diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs index 32790c7..a6ef236 100644 --- a/src-tauri/src/core/fabric.rs +++ b/src-tauri/src/core/fabric.rs @@ -8,11 +8,14 @@ use serde::{Deserialize, Serialize}; use std::error::Error; use std::path::PathBuf; +use ts_rs::TS; const FABRIC_META_URL: &str = "https://meta.fabricmc.net/v2"; /// Represents a Fabric loader version from the Meta API. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricLoaderVersion { pub separator: String, pub build: i32, @@ -22,7 +25,9 @@ pub struct FabricLoaderVersion { } /// Represents a Fabric intermediary mapping version. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricIntermediaryVersion { pub maven: String, pub version: String, @@ -30,7 +35,9 @@ pub struct FabricIntermediaryVersion { } /// Represents a combined loader + intermediary version entry. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricLoaderEntry { pub loader: FabricLoaderVersion, pub intermediary: FabricIntermediaryVersion, @@ -39,7 +46,9 @@ pub struct FabricLoaderEntry { } /// Launcher metadata from Fabric Meta API. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricLauncherMeta { pub version: i32, pub libraries: FabricLibraries, @@ -48,7 +57,9 @@ pub struct FabricLauncherMeta { } /// Libraries required by Fabric loader. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricLibraries { pub client: Vec<FabricLibrary>, pub common: Vec<FabricLibrary>, @@ -56,7 +67,9 @@ pub struct FabricLibraries { } /// A single Fabric library dependency. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricLibrary { pub name: String, pub url: Option<String>, @@ -64,7 +77,9 @@ pub struct FabricLibrary { /// Main class configuration for Fabric. /// Can be either a struct with client/server fields or a simple string. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts", untagged)] #[serde(untagged)] pub enum FabricMainClass { Structured { client: String, server: String }, @@ -89,14 +104,18 @@ impl FabricMainClass { } /// Represents a Minecraft version supported by Fabric. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct FabricGameVersion { pub version: String, pub stable: bool, } /// Information about an installed Fabric version. -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "fabric.ts")] pub struct InstalledFabricVersion { pub id: String, pub minecraft_version: String, @@ -108,8 +127,8 @@ pub struct InstalledFabricVersion { /// /// # Returns /// A list of game versions that have Fabric intermediary mappings available. -pub async fn fetch_supported_game_versions( -) -> Result<Vec<FabricGameVersion>, Box<dyn Error + Send + Sync>> { +pub async fn fetch_supported_game_versions() +-> Result<Vec<FabricGameVersion>, Box<dyn Error + Send + Sync>> { let url = format!("{}/versions/game", FABRIC_META_URL); let resp = reqwest::get(&url) .await? @@ -122,8 +141,8 @@ pub async fn fetch_supported_game_versions( /// /// # Returns /// A list of all Fabric loader versions, ordered by build number (newest first). -pub async fn fetch_loader_versions( -) -> Result<Vec<FabricLoaderVersion>, Box<dyn Error + Send + Sync>> { +pub async fn fetch_loader_versions() +-> Result<Vec<FabricLoaderVersion>, Box<dyn Error + Send + Sync>> { let url = format!("{}/versions/loader", FABRIC_META_URL); let resp = reqwest::get(&url) .await? diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs index 65bf413..4452f8e 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -12,6 +12,7 @@ use std::error::Error; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use std::path::PathBuf; +use ts_rs::TS; const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; @@ -19,7 +20,9 @@ const FORGE_MAVEN_URL: &str = "https://maven.minecraftforge.net/"; const FORGE_FILES_URL: &str = "https://files.minecraftforge.net/"; /// Represents a Forge version entry. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "forge.ts")] pub struct ForgeVersion { pub version: String, pub minecraft_version: String, @@ -36,11 +39,14 @@ struct ForgePromotions { } /// Information about an installed Forge version. -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "forge.ts")] pub struct InstalledForgeVersion { pub id: String, pub minecraft_version: String, pub forge_version: String, + #[ts(type = "string")] pub path: PathBuf, } diff --git a/src-tauri/src/core/game_version.rs b/src-tauri/src/core/game_version.rs index c62e232..82196bd 100644 --- a/src-tauri/src/core/game_version.rs +++ b/src-tauri/src/core/game_version.rs @@ -1,8 +1,10 @@ use serde::{Deserialize, Serialize}; +use ts_rs::TS; /// Represents a Minecraft version JSON, supporting both vanilla and modded (Fabric/Forge) formats. /// Modded versions use `inheritsFrom` to reference a parent vanilla version. -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct GameVersion { pub id: String, /// Optional for mod loaders that inherit from vanilla @@ -28,13 +30,15 @@ pub struct GameVersion { pub version_type: Option<String>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct Downloads { pub client: DownloadArtifact, pub server: Option<DownloadArtifact>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct DownloadArtifact { pub sha1: Option<String>, pub size: Option<u64>, @@ -42,7 +46,8 @@ pub struct DownloadArtifact { pub path: Option<String>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct AssetIndex { pub id: String, pub sha1: String, @@ -52,43 +57,54 @@ pub struct AssetIndex { pub total_size: Option<u64>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct Library { pub downloads: Option<LibraryDownloads>, pub name: String, pub rules: Option<Vec<Rule>>, + #[ts(type = "Record<string, unknown>")] pub natives: Option<serde_json::Value>, /// Maven repository URL for mod loader libraries pub url: Option<String>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct Rule { pub action: String, // "allow" or "disallow" pub os: Option<OsRule>, + #[ts(type = "Record<string, unknown>")] pub features: Option<serde_json::Value>, // Feature-based rules (e.g., is_demo_user, has_quick_plays_support) } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct OsRule { pub name: Option<String>, // "linux", "osx", "windows" pub version: Option<String>, // Regex pub arch: Option<String>, // "x86" } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct LibraryDownloads { pub artifact: Option<DownloadArtifact>, + #[ts(type = "Record<string, unknown>")] pub classifiers: Option<serde_json::Value>, // Complex, simplifying for now } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct Arguments { + #[ts(type = "Record<string, unknown>")] pub game: Option<serde_json::Value>, + #[ts(type = "Record<string, unknown>")] pub jvm: Option<serde_json::Value>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, TS)] +#[ts(export, export_to = "game-version.ts")] pub struct JavaVersion { pub component: String, #[serde(rename = "majorVersion")] diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs index 573273e..0237270 100644 --- a/src-tauri/src/core/instance.rs +++ b/src-tauri/src/core/instance.rs @@ -11,9 +11,12 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; +use ts_rs::TS; /// Represents a game instance/profile -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "instance.ts")] pub struct Instance { pub id: String, // 唯一标识符(UUID) pub name: String, // 显示名称 @@ -28,17 +31,22 @@ pub struct Instance { pub jvm_args_override: Option<String>, // JVM参数覆盖(可选) #[serde(default)] pub memory_override: Option<MemoryOverride>, // 内存设置覆盖(可选) + pub java_path_override: Option<String>, // 实例级Java路径覆盖(可选) } /// Memory settings override for an instance -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "instance.ts")] pub struct MemoryOverride { pub min: u32, // MB pub max: u32, // MB } /// Configuration for all instances -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "instance.ts")] pub struct InstanceConfig { pub instances: Vec<Instance>, pub active_instance_id: Option<String>, // 当前活动的实例ID @@ -111,6 +119,7 @@ impl InstanceState { mod_loader_version: None, jvm_args_override: None, memory_override: None, + java_path_override: None, }; let mut config = self.instances.lock().unwrap(); @@ -267,6 +276,7 @@ impl InstanceState { last_played: None, jvm_args_override: source_instance.jvm_args_override.clone(), memory_override: source_instance.memory_override.clone(), + java_path_override: source_instance.java_path_override.clone(), }; self.update_instance(new_instance.clone())?; diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs deleted file mode 100644 index 2e3c8a7..0000000 --- a/src-tauri/src/core/java.rs +++ /dev/null @@ -1,1103 +0,0 @@ -use serde::{Deserialize, Serialize}; -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; -use std::path::PathBuf; -use std::process::Command; -use tauri::AppHandle; -use tauri::Emitter; -use tauri::Manager; - -use crate::core::downloader::{self, DownloadQueue, JavaDownloadProgress, PendingJavaDownload}; -use crate::utils::zip; - -const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; -const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; // 24 hours - -/// Helper to strip UNC prefix on Windows (\\?\) -fn strip_unc_prefix(path: PathBuf) -> PathBuf { - #[cfg(target_os = "windows")] - { - let s = path.to_string_lossy().to_string(); - if s.starts_with(r"\\?\") { - return PathBuf::from(&s[4..]); - } - } - path -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JavaInstallation { - pub path: String, - pub version: String, - pub is_64bit: bool, -} - -/// Java image type: JRE or JDK -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ImageType { - Jre, - Jdk, -} - -impl Default for ImageType { - fn default() -> Self { - Self::Jre - } -} - -impl std::fmt::Display for ImageType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Jre => write!(f, "jre"), - Self::Jdk => write!(f, "jdk"), - } - } -} - -/// Java release information for UI display -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JavaReleaseInfo { - pub major_version: u32, - pub image_type: String, - pub version: String, - pub release_name: String, - pub release_date: Option<String>, - pub file_size: u64, - pub checksum: Option<String>, - pub download_url: String, - pub is_lts: bool, - pub is_available: bool, - pub architecture: String, -} - -/// Java catalog containing all available versions -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct JavaCatalog { - pub releases: Vec<JavaReleaseInfo>, - pub available_major_versions: Vec<u32>, - pub lts_versions: Vec<u32>, - pub cached_at: u64, -} - -/// Adoptium `/v3/assets/latest/{version}/hotspot` API response structures -#[derive(Debug, Clone, Deserialize)] -pub struct AdoptiumAsset { - pub binary: AdoptiumBinary, - pub release_name: String, - pub version: AdoptiumVersionData, -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AdoptiumBinary { - pub os: String, - pub architecture: String, - pub image_type: String, - pub package: AdoptiumPackage, - #[serde(default)] - pub updated_at: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AdoptiumPackage { - pub name: String, - pub link: String, - pub size: u64, - pub checksum: Option<String>, // SHA256 -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AdoptiumVersionData { - pub major: u32, - pub minor: u32, - pub security: u32, - pub semver: String, - pub openjdk_version: String, -} - -/// Adoptium available releases response -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AvailableReleases { - pub available_releases: Vec<u32>, - pub available_lts_releases: Vec<u32>, - pub most_recent_lts: Option<u32>, - pub most_recent_feature_release: Option<u32>, -} - -/// Java download information from Adoptium -#[derive(Debug, Clone, Serialize)] -pub struct JavaDownloadInfo { - pub version: String, - pub release_name: String, - pub download_url: String, - pub file_name: String, - pub file_size: u64, - pub checksum: Option<String>, - pub image_type: String, -} - -/// Get the Adoptium OS name for the current platform -pub fn get_adoptium_os() -> &'static str { - #[cfg(target_os = "linux")] - { - // Check if Alpine Linux (musl libc) - if std::path::Path::new("/etc/alpine-release").exists() { - return "alpine-linux"; - } - "linux" - } - #[cfg(target_os = "macos")] - { - "mac" - } - #[cfg(target_os = "windows")] - { - "windows" - } - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - { - "linux" // fallback - } -} - -/// Get the Adoptium Architecture name for the current architecture -pub fn get_adoptium_arch() -> &'static str { - #[cfg(target_arch = "x86_64")] - { - "x64" - } - #[cfg(target_arch = "aarch64")] - { - "aarch64" - } - #[cfg(target_arch = "x86")] - { - "x86" - } - #[cfg(target_arch = "arm")] - { - "arm" - } - #[cfg(not(any( - target_arch = "x86_64", - target_arch = "aarch64", - target_arch = "x86", - target_arch = "arm" - )))] - { - "x64" // fallback - } -} - -/// Get the default Java installation directory for DropOut -pub fn get_java_install_dir(app_handle: &AppHandle) -> PathBuf { - app_handle.path().app_data_dir().unwrap().join("java") -} - -/// Get the cache file path for Java catalog -fn get_catalog_cache_path(app_handle: &AppHandle) -> PathBuf { - app_handle - .path() - .app_data_dir() - .unwrap() - .join("java_catalog_cache.json") -} - -/// Load cached Java catalog if not expired -pub fn load_cached_catalog(app_handle: &AppHandle) -> Option<JavaCatalog> { - let cache_path = get_catalog_cache_path(app_handle); - if !cache_path.exists() { - return None; - } - - let content = std::fs::read_to_string(&cache_path).ok()?; - let catalog: JavaCatalog = serde_json::from_str(&content).ok()?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - if now - catalog.cached_at < CACHE_DURATION_SECS { - Some(catalog) - } else { - None - } -} - -/// Save Java catalog to cache -pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Result<(), String> { - let cache_path = get_catalog_cache_path(app_handle); - let content = serde_json::to_string_pretty(catalog).map_err(|e| e.to_string())?; - std::fs::write(&cache_path, content).map_err(|e| e.to_string())?; - Ok(()) -} - -/// Clear Java catalog cache -#[allow(dead_code)] -pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> { - let cache_path = get_catalog_cache_path(app_handle); - if cache_path.exists() { - std::fs::remove_file(&cache_path).map_err(|e| e.to_string())?; - } - Ok(()) -} - -/// Fetch complete Java catalog from Adoptium API with platform availability check -pub async fn fetch_java_catalog( - app_handle: &AppHandle, - force_refresh: bool, -) -> Result<JavaCatalog, String> { - // Check cache first unless force refresh - if !force_refresh { - if let Some(cached) = load_cached_catalog(app_handle) { - return Ok(cached); - } - } - - let os = get_adoptium_os(); - let arch = get_adoptium_arch(); - let client = reqwest::Client::new(); - - // 1. Fetch available releases - let releases_url = format!("{}/info/available_releases", ADOPTIUM_API_BASE); - let available: AvailableReleases = client - .get(&releases_url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| format!("Failed to fetch available releases: {}", e))? - .json() - .await - .map_err(|e| format!("Failed to parse available releases: {}", e))?; - - let mut releases = Vec::new(); - - // 2. Fetch details for each major version - for major_version in &available.available_releases { - for image_type in &["jre", "jdk"] { - let url = format!( - "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", - ADOPTIUM_API_BASE, major_version, os, arch, image_type - ); - - match client - .get(&url) - .header("Accept", "application/json") - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - if let Ok(assets) = response.json::<Vec<AdoptiumAsset>>().await { - if let Some(asset) = assets.into_iter().next() { - let release_date = asset.binary.updated_at.clone(); - releases.push(JavaReleaseInfo { - major_version: *major_version, - image_type: image_type.to_string(), - version: asset.version.semver.clone(), - release_name: asset.release_name.clone(), - release_date, - file_size: asset.binary.package.size, - checksum: asset.binary.package.checksum, - download_url: asset.binary.package.link, - is_lts: available - .available_lts_releases - .contains(major_version), - is_available: true, - architecture: asset.binary.architecture.clone(), - }); - } - } - } else { - // Platform not available for this version/type - releases.push(JavaReleaseInfo { - major_version: *major_version, - image_type: image_type.to_string(), - version: format!("{}.x", major_version), - release_name: format!("jdk-{}", major_version), - release_date: None, - file_size: 0, - checksum: None, - download_url: String::new(), - is_lts: available.available_lts_releases.contains(major_version), - is_available: false, - architecture: arch.to_string(), - }); - } - } - Err(_) => { - // Network error, mark as unavailable - releases.push(JavaReleaseInfo { - major_version: *major_version, - image_type: image_type.to_string(), - version: format!("{}.x", major_version), - release_name: format!("jdk-{}", major_version), - release_date: None, - file_size: 0, - checksum: None, - download_url: String::new(), - is_lts: available.available_lts_releases.contains(major_version), - is_available: false, - architecture: arch.to_string(), - }); - } - } - } - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let catalog = JavaCatalog { - releases, - available_major_versions: available.available_releases, - lts_versions: available.available_lts_releases, - cached_at: now, - }; - - // Save to cache - let _ = save_catalog_cache(app_handle, &catalog); - - Ok(catalog) -} - -/// Get Adoptium API download info for a specific Java version and image type -/// -/// # Arguments -/// * `major_version` - Java major version (e.g., 8, 11, 17) -/// * `image_type` - JRE or JDK -/// -/// # Returns -/// * `Ok(JavaDownloadInfo)` - Download information -/// * `Err(String)` - Error message -pub async fn fetch_java_release( - major_version: u32, - image_type: ImageType, -) -> Result<JavaDownloadInfo, String> { - let os = get_adoptium_os(); - let arch = get_adoptium_arch(); - - let url = format!( - "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", - ADOPTIUM_API_BASE, major_version, os, arch, image_type - ); - - let client = reqwest::Client::new(); - let response = client - .get(&url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| format!("Network request failed: {}", e))?; - - if !response.status().is_success() { - return Err(format!( - "Adoptium API returned error: {} - The version/platform might be unavailable", - response.status() - )); - } - - let assets: Vec<AdoptiumAsset> = response - .json() - .await - .map_err(|e| format!("Failed to parse API response: {}", e))?; - - let asset = assets - .into_iter() - .next() - .ok_or_else(|| format!("Java {} {} download not found", major_version, image_type))?; - - Ok(JavaDownloadInfo { - version: asset.version.semver.clone(), - release_name: asset.release_name, - download_url: asset.binary.package.link, - file_name: asset.binary.package.name, - file_size: asset.binary.package.size, - checksum: asset.binary.package.checksum, - image_type: asset.binary.image_type, - }) -} - -/// Fetch available Java versions from Adoptium API -pub async fn fetch_available_versions() -> Result<Vec<u32>, String> { - let url = format!("{}/info/available_releases", ADOPTIUM_API_BASE); - - let response = reqwest::get(url) - .await - .map_err(|e| format!("Network request failed: {}", e))?; - - #[derive(Deserialize)] - struct AvailableReleases { - available_releases: Vec<u32>, - } - - let releases: AvailableReleases = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; - - Ok(releases.available_releases) -} - -/// Download and install Java with resume support and progress events -/// -/// # Arguments -/// * `app_handle` - Tauri app handle for accessing app directories -/// * `major_version` - Java major version (e.g., 8, 11, 17) -/// * `image_type` - JRE or JDK -/// * `custom_path` - Optional custom installation path -/// -/// # Returns -/// * `Ok(JavaInstallation)` - Information about the successfully installed Java -pub async fn download_and_install_java( - app_handle: &AppHandle, - major_version: u32, - image_type: ImageType, - custom_path: Option<PathBuf>, -) -> Result<JavaInstallation, String> { - // 1. Fetch download information - let info = fetch_java_release(major_version, image_type).await?; - let file_name = info.file_name.clone(); - - // 2. Prepare installation directory - let install_base = custom_path.unwrap_or_else(|| get_java_install_dir(app_handle)); - let version_dir = install_base.join(format!("temurin-{}-{}", major_version, image_type)); - - std::fs::create_dir_all(&install_base) - .map_err(|e| format!("Failed to create installation directory: {}", e))?; - - // 3. Add to download queue for persistence - let mut queue = DownloadQueue::load(app_handle); - queue.add(PendingJavaDownload { - major_version, - image_type: image_type.to_string(), - download_url: info.download_url.clone(), - file_name: info.file_name.clone(), - file_size: info.file_size, - checksum: info.checksum.clone(), - install_path: install_base.to_string_lossy().to_string(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - }); - queue.save(app_handle)?; - - // 4. Download the archive with resume support - let archive_path = install_base.join(&info.file_name); - - // Check if we need to download - let need_download = if archive_path.exists() { - if let Some(expected_checksum) = &info.checksum { - let data = std::fs::read(&archive_path) - .map_err(|e| format!("Failed to read downloaded file: {}", e))?; - !downloader::verify_checksum(&data, Some(expected_checksum), None) - } else { - false - } - } else { - true - }; - - if need_download { - // Use resumable download - downloader::download_with_resume( - app_handle, - &info.download_url, - &archive_path, - info.checksum.as_deref(), - info.file_size, - ) - .await?; - } - - // 5. Emit extracting status - let _ = app_handle.emit( - "java-download-progress", - JavaDownloadProgress { - file_name: file_name.clone(), - downloaded_bytes: info.file_size, - total_bytes: info.file_size, - speed_bytes_per_sec: 0, - eta_seconds: 0, - status: "Extracting".to_string(), - percentage: 100.0, - }, - ); - - // 6. Extract - // If the target directory exists, remove it first - if version_dir.exists() { - std::fs::remove_dir_all(&version_dir) - .map_err(|e| format!("Failed to remove old version directory: {}", e))?; - } - - std::fs::create_dir_all(&version_dir) - .map_err(|e| format!("Failed to create version directory: {}", e))?; - - let top_level_dir = if info.file_name.ends_with(".tar.gz") || info.file_name.ends_with(".tgz") { - zip::extract_tar_gz(&archive_path, &version_dir)? - } else if info.file_name.ends_with(".zip") { - zip::extract_zip(&archive_path, &version_dir)?; - // Find the top-level directory inside the extracted folder - find_top_level_dir(&version_dir)? - } else { - return Err(format!("Unsupported archive format: {}", info.file_name)); - }; - - // 7. Clean up downloaded archive - let _ = std::fs::remove_file(&archive_path); - - // 8. Locate java executable - // macOS has a different structure: jdk-xxx/Contents/Home/bin/java - // Linux/Windows: jdk-xxx/bin/java - let java_home = version_dir.join(&top_level_dir); - let java_bin = if cfg!(target_os = "macos") { - java_home - .join("Contents") - .join("Home") - .join("bin") - .join("java") - } else if cfg!(windows) { - java_home.join("bin").join("java.exe") - } else { - java_home.join("bin").join("java") - }; - - if !java_bin.exists() { - return Err(format!( - "Installation completed but Java executable not found: {}", - java_bin.display() - )); - } - - // Resolve symlinks and strip UNC prefix to ensure clean path - let java_bin = std::fs::canonicalize(&java_bin).map_err(|e| e.to_string())?; - let java_bin = strip_unc_prefix(java_bin); - - // 9. Verify installation - let installation = check_java_installation(&java_bin) - .ok_or_else(|| "Failed to verify Java installation".to_string())?; - - // 10. Remove from download queue - queue.remove(major_version, &image_type.to_string()); - queue.save(app_handle)?; - - // 11. Emit completed status - let _ = app_handle.emit( - "java-download-progress", - JavaDownloadProgress { - file_name, - downloaded_bytes: info.file_size, - total_bytes: info.file_size, - speed_bytes_per_sec: 0, - eta_seconds: 0, - status: "Completed".to_string(), - percentage: 100.0, - }, - ); - - Ok(installation) -} - -/// Find the top-level directory inside the extracted folder -fn find_top_level_dir(extract_dir: &PathBuf) -> Result<String, String> { - let entries: Vec<_> = std::fs::read_dir(extract_dir) - .map_err(|e| format!("Failed to read directory: {}", e))? - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .collect(); - - if entries.len() == 1 { - Ok(entries[0].file_name().to_string_lossy().to_string()) - } else { - // No single top-level directory, return empty string - Ok(String::new()) - } -} - -/// Detect Java installations on the system -pub fn detect_java_installations() -> Vec<JavaInstallation> { - let mut installations = Vec::new(); - let candidates = get_java_candidates(); - - for candidate in candidates { - if let Some(java) = check_java_installation(&candidate) { - // Avoid duplicates - if !installations - .iter() - .any(|j: &JavaInstallation| j.path == java.path) - { - installations.push(java); - } - } - } - - // Sort by version (newer first) - installations.sort_by(|a, b| { - let v_a = parse_java_version(&a.version); - let v_b = parse_java_version(&b.version); - v_b.cmp(&v_a) - }); - - installations -} - -/// Get list of candidate Java paths to check -fn get_java_candidates() -> Vec<PathBuf> { - let mut candidates = Vec::new(); - - // Check PATH first - let mut cmd = Command::new(if cfg!(windows) { "where" } else { "which" }); - cmd.arg("java"); - #[cfg(target_os = "windows")] - cmd.creation_flags(0x08000000); - - if let Ok(output) = cmd.output() { - if output.status.success() { - let paths = String::from_utf8_lossy(&output.stdout); - for line in paths.lines() { - let path = PathBuf::from(line.trim()); - if path.exists() { - // Resolve symlinks (important for Windows javapath wrapper) - let resolved = std::fs::canonicalize(&path).unwrap_or(path); - // Strip UNC prefix if present to keep paths clean - let final_path = strip_unc_prefix(resolved); - candidates.push(final_path); - } - } - } - } - - #[cfg(target_os = "linux")] - { - // Common Linux Java paths - let linux_paths = [ - "/usr/lib/jvm", - "/usr/java", - "/opt/java", - "/opt/jdk", - "/opt/openjdk", - ]; - - for base in &linux_paths { - if let Ok(entries) = std::fs::read_dir(base) { - for entry in entries.flatten() { - let java_path = entry.path().join("bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - - // Flatpak / Snap locations - let home = std::env::var("HOME").unwrap_or_default(); - let snap_java = PathBuf::from(&home).join(".sdkman/candidates/java"); - if snap_java.exists() { - if let Ok(entries) = std::fs::read_dir(&snap_java) { - for entry in entries.flatten() { - let java_path = entry.path().join("bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - } - - #[cfg(target_os = "macos")] - { - // macOS Java paths - let mac_paths = [ - "/Library/Java/JavaVirtualMachines", - "/System/Library/Java/JavaVirtualMachines", - "/usr/local/opt/openjdk/bin/java", - "/opt/homebrew/opt/openjdk/bin/java", - ]; - - for path in &mac_paths { - let p = PathBuf::from(path); - if p.is_dir() { - if let Ok(entries) = std::fs::read_dir(&p) { - for entry in entries.flatten() { - let java_path = entry.path().join("Contents/Home/bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } else if p.exists() { - candidates.push(p); - } - } - - // Homebrew ARM64 - let homebrew_arm = PathBuf::from("/opt/homebrew/Cellar/openjdk"); - if homebrew_arm.exists() { - if let Ok(entries) = std::fs::read_dir(&homebrew_arm) { - for entry in entries.flatten() { - let java_path = entry - .path() - .join("libexec/openjdk.jdk/Contents/Home/bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - } - - #[cfg(target_os = "windows")] - { - // Windows Java paths - let program_files = - std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); - let program_files_x86 = std::env::var("ProgramFiles(x86)") - .unwrap_or_else(|_| "C:\\Program Files (x86)".to_string()); - let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); - - let win_paths = [ - format!("{}\\Java", program_files), - format!("{}\\Java", program_files_x86), - format!("{}\\Eclipse Adoptium", program_files), - format!("{}\\AdoptOpenJDK", program_files), - format!("{}\\Microsoft\\jdk", program_files), - format!("{}\\Zulu", program_files), - format!("{}\\Amazon Corretto", program_files), - format!("{}\\BellSoft\\LibericaJDK", program_files), - format!("{}\\Programs\\Eclipse Adoptium", local_app_data), - ]; - - for base in &win_paths { - let base_path = PathBuf::from(base); - if base_path.exists() { - if let Ok(entries) = std::fs::read_dir(&base_path) { - for entry in entries.flatten() { - let java_path = entry.path().join("bin\\java.exe"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - } - - // Also check JAVA_HOME - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let java_path = PathBuf::from(&java_home).join("bin\\java.exe"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - - // JAVA_HOME environment variable (cross-platform) - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; - let java_path = PathBuf::from(&java_home).join("bin").join(bin_name); - if java_path.exists() { - candidates.push(java_path); - } - } - - candidates -} - -/// Check a specific Java installation and get its version info -fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> { - let mut cmd = Command::new(path); - cmd.arg("-version"); - #[cfg(target_os = "windows")] - cmd.creation_flags(0x08000000); - - let output = cmd.output().ok()?; - - // Java outputs version info to stderr - let version_output = String::from_utf8_lossy(&output.stderr); - - // Parse version string (e.g., "openjdk version \"17.0.1\"" or "java version \"1.8.0_301\"") - let version = parse_version_string(&version_output)?; - let is_64bit = version_output.contains("64-Bit"); - - Some(JavaInstallation { - path: path.to_string_lossy().to_string(), - version, - is_64bit, - }) -} - -/// Parse version string from java -version output -fn parse_version_string(output: &str) -> Option<String> { - for line in output.lines() { - if line.contains("version") { - // Find the quoted version string - if let Some(start) = line.find('"') { - if let Some(end) = line[start + 1..].find('"') { - return Some(line[start + 1..start + 1 + end].to_string()); - } - } - } - } - None -} - -/// Parse version for comparison (returns major version number) -fn parse_java_version(version: &str) -> u32 { - // 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 - parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0) - } else { - // New format: 17.0.1 -> major is 17 - first.parse().unwrap_or(0) - } - } else { - 0 - } -} - -/// Get the best Java for a specific Minecraft version -pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaInstallation> { - let installations = detect_java_installations(); - - if let Some(required) = required_major_version { - // Find exact match or higher - installations.into_iter().find(|java| { - let major = parse_java_version(&java.version); - major >= required as u32 - }) - } else { - // Return newest - installations.into_iter().next() - } -} - -/// Get compatible Java for a specific Minecraft version with upper bound -/// For older Minecraft versions (1.13.x and below), we need Java 8 specifically -/// as newer Java versions have compatibility issues with old Forge versions -pub fn get_compatible_java( - app_handle: &AppHandle, - required_major_version: Option<u64>, - max_major_version: Option<u32>, -) -> Option<JavaInstallation> { - let installations = detect_all_java_installations(app_handle); - - if let Some(max_version) = max_major_version { - // Find Java version within the acceptable range - installations.into_iter().find(|java| { - let major = parse_java_version(&java.version); - let meets_min = if let Some(required) = required_major_version { - major >= required as u32 - } else { - true - }; - meets_min && major <= max_version - }) - } else if let Some(required) = required_major_version { - // Find exact match or higher (no upper bound) - installations.into_iter().find(|java| { - let major = parse_java_version(&java.version); - major >= required as u32 - }) - } else { - // Return newest - installations.into_iter().next() - } -} - -/// Check if a Java installation is compatible with the required version range -pub fn is_java_compatible( - java_path: &str, - required_major_version: Option<u64>, - max_major_version: Option<u32>, -) -> bool { - let java_path_buf = PathBuf::from(java_path); - if let Some(java) = check_java_installation(&java_path_buf) { - let major = parse_java_version(&java.version); - let meets_min = if let Some(required) = required_major_version { - major >= required as u32 - } else { - true - }; - let meets_max = if let Some(max_version) = max_major_version { - major <= max_version - } else { - true - }; - meets_min && meets_max - } else { - false - } -} - -/// Detect all installed Java versions (including system installations and DropOut downloads) -pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstallation> { - let mut installations = detect_java_installations(); - - // Add DropOut downloaded Java versions - let dropout_java_dir = get_java_install_dir(app_handle); - if dropout_java_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&dropout_java_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - // Find the java executable in this directory - let java_bin = find_java_executable(&path); - if let Some(java_path) = java_bin { - if let Some(java) = check_java_installation(&java_path) { - if !installations.iter().any(|j| j.path == java.path) { - installations.push(java); - } - } - } - } - } - } - } - - // Sort by version - installations.sort_by(|a, b| { - let v_a = parse_java_version(&a.version); - let v_b = parse_java_version(&b.version); - v_b.cmp(&v_a) - }); - - installations -} - -/// Find the java executable in a directory using a limited-depth search -fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { - let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; - - // Directly look in the bin directory - let direct_bin = dir.join("bin").join(bin_name); - if direct_bin.exists() { - let resolved = std::fs::canonicalize(&direct_bin).unwrap_or(direct_bin); - return Some(strip_unc_prefix(resolved)); - } - - // macOS: Contents/Home/bin/java - #[cfg(target_os = "macos")] - { - let macos_bin = dir.join("Contents").join("Home").join("bin").join(bin_name); - if macos_bin.exists() { - return Some(macos_bin); - } - } - - // Look in subdirectories (handle nested directories after Adoptium extraction) - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - // Try direct bin path - let nested_bin = path.join("bin").join(bin_name); - if nested_bin.exists() { - let resolved = std::fs::canonicalize(&nested_bin).unwrap_or(nested_bin); - return Some(strip_unc_prefix(resolved)); - } - - // macOS: nested/Contents/Home/bin/java - #[cfg(target_os = "macos")] - { - let macos_nested = path - .join("Contents") - .join("Home") - .join("bin") - .join(bin_name); - if macos_nested.exists() { - return Some(macos_nested); - } - } - } - } - } - - None -} - -/// Resume pending Java downloads from queue -pub async fn resume_pending_downloads( - app_handle: &AppHandle, -) -> Result<Vec<JavaInstallation>, String> { - let queue = DownloadQueue::load(app_handle); - let mut installed = Vec::new(); - - for pending in queue.pending_downloads.iter() { - let image_type = if pending.image_type == "jdk" { - ImageType::Jdk - } else { - ImageType::Jre - }; - - // Try to resume the download - match download_and_install_java( - app_handle, - pending.major_version, - image_type, - Some(PathBuf::from(&pending.install_path)), - ) - .await - { - Ok(installation) => { - installed.push(installation); - } - Err(e) => { - eprintln!( - "Failed to resume Java {} {} download: {}", - pending.major_version, pending.image_type, e - ); - } - } - } - - Ok(installed) -} - -/// Cancel current Java download -pub fn cancel_current_download() { - downloader::cancel_java_download(); -} - -/// Get pending downloads from queue -pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload> { - let queue = DownloadQueue::load(app_handle); - queue.pending_downloads -} - -/// Clear a specific pending download -#[allow(dead_code)] -pub fn clear_pending_download( - app_handle: &AppHandle, - major_version: u32, - image_type: &str, -) -> Result<(), String> { - let mut queue = DownloadQueue::load(app_handle); - queue.remove(major_version, image_type); - queue.save(app_handle) -} diff --git a/src-tauri/src/core/java/detection.rs b/src-tauri/src/core/java/detection.rs new file mode 100644 index 0000000..08dcebb --- /dev/null +++ b/src-tauri/src/core/java/detection.rs @@ -0,0 +1,311 @@ +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +use crate::core::java::strip_unc_prefix; + +const WHICH_TIMEOUT: Duration = Duration::from_secs(2); + +/// Scans a directory for Java installations, filtering out symlinks +/// +/// # Arguments +/// * `base_dir` - Base directory to scan (e.g., mise or SDKMAN java dir) +/// * `should_skip` - Predicate to determine if an entry should be skipped +/// +/// # Returns +/// First valid Java installation found, or `None` +fn scan_java_dir<F>(base_dir: &Path, should_skip: F) -> Option<PathBuf> +where + F: Fn(&std::fs::DirEntry) -> bool, +{ + std::fs::read_dir(base_dir) + .ok()? + .flatten() + .filter(|entry| { + let path = entry.path(); + // Only consider real directories, not symlinks + path.is_dir() && !path.is_symlink() && !should_skip(entry) + }) + .find_map(|entry| { + let java_path = entry.path().join("bin/java"); + if java_path.exists() && java_path.is_file() { + Some(java_path) + } else { + None + } + }) +} + +/// Finds Java installation from SDKMAN! if available +/// +/// Scans the SDKMAN! candidates directory and returns the first valid Java installation found. +/// Skips the 'current' symlink to avoid duplicates. +/// +/// Path: `~/.sdkman/candidates/java/` +/// +/// # Returns +/// `Some(PathBuf)` pointing to `bin/java` if found, `None` otherwise +pub fn find_sdkman_java() -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + let sdkman_base = PathBuf::from(&home).join(".sdkman/candidates/java/"); + + if !sdkman_base.exists() { + return None; + } + + scan_java_dir(&sdkman_base, |entry| entry.file_name() == "current") +} + +/// Finds Java installation from mise if available +/// +/// Scans the mise Java installation directory and returns the first valid installation found. +/// Skips version alias symlinks (e.g., `21`, `21.0`, `latest`, `lts`) to avoid duplicates. +/// +/// Path: `~/.local/share/mise/installs/java/` +/// +/// # Returns +/// `Some(PathBuf)` pointing to `bin/java` if found, `None` otherwise +pub fn find_mise_java() -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + let mise_base = PathBuf::from(&home).join(".local/share/mise/installs/java/"); + + if !mise_base.exists() { + return None; + } + + scan_java_dir(&mise_base, |_| false) // mise: no additional filtering needed +} + +/// Runs `which` (Unix) or `where` (Windows) command to find Java in PATH with timeout +/// +/// This function spawns a subprocess to locate the `java` executable in the system PATH. +/// It enforces a 2-second timeout to prevent hanging if the command takes too long. +/// +/// # Returns +/// `Some(String)` containing the output (paths separated by newlines) if successful, +/// `None` if the command fails, times out, or returns non-zero exit code +/// +/// # Platform-specific behavior +/// - Unix/Linux/macOS: Uses `which java` +/// - Windows: Uses `where java` and hides the console window +/// +/// # Timeout Behavior +/// If the command does not complete within 2 seconds, the process is killed +/// and `None` is returned. This prevents the launcher from hanging on systems +/// where `which`/`where` may be slow or unresponsive. +fn run_which_command_with_timeout() -> Option<String> { + let mut cmd = Command::new(if cfg!(windows) { "where" } else { "which" }); + cmd.arg("java"); + // Hide console window on Windows + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + cmd.stdout(Stdio::piped()); + + let mut child = cmd.spawn().ok()?; + let start = std::time::Instant::now(); + + loop { + // Check if timeout has been exceeded + if start.elapsed() > WHICH_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + + match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + let mut output = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut output); + } + return Some(output); + } else { + let _ = child.wait(); + return None; + } + } + Ok(None) => { + // Command still running, sleep briefly before checking again + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } +} + +/// Detects all available Java installations on the system +/// +/// This function searches for Java installations in multiple locations: +/// - **All platforms**: `JAVA_HOME` environment variable, `java` in PATH +/// - **Linux**: `/usr/lib/jvm`, `/usr/java`, `/opt/java`, `/opt/jdk`, `/opt/openjdk`, SDKMAN! +/// - **macOS**: `/Library/Java/JavaVirtualMachines`, `/System/Library/Java/JavaVirtualMachines`, +/// Homebrew paths (`/usr/local/opt/openjdk`, `/opt/homebrew/opt/openjdk`), SDKMAN! +/// - **Windows**: `Program Files`, `Program Files (x86)`, `LOCALAPPDATA` for various JDK distributions +/// +/// # Returns +/// A vector of `PathBuf` pointing to Java executables found on the system. +/// Note: Paths may include symlinks and duplicates; callers should canonicalize and deduplicate as needed. +/// +/// # Examples +/// ```ignore +/// let candidates = get_java_candidates(); +/// for java_path in candidates { +/// println!("Found Java at: {}", java_path.display()); +/// } +/// ``` +pub fn get_java_candidates() -> Vec<PathBuf> { + let mut candidates = Vec::new(); + + // Try to find Java in PATH using 'which' or 'where' command with timeout + // CAUTION: linux 'which' may return symlinks, so we need to canonicalize later + if let Some(paths_str) = run_which_command_with_timeout() { + for line in paths_str.lines() { + let path = PathBuf::from(line.trim()); + if path.exists() { + let resolved = std::fs::canonicalize(&path).unwrap_or(path); + let final_path = strip_unc_prefix(resolved); + candidates.push(final_path); + } + } + } + + #[cfg(target_os = "linux")] + { + let linux_paths = [ + "/usr/lib/jvm", + "/usr/java", + "/opt/java", + "/opt/jdk", + "/opt/openjdk", + ]; + + for base in &linux_paths { + if let Ok(entries) = std::fs::read_dir(base) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + + // Check common SDKMAN! java candidates + if let Some(sdkman_java) = find_sdkman_java() { + candidates.push(sdkman_java); + } + + // Check common mise java candidates + if let Some(mise_java) = find_mise_java() { + candidates.push(mise_java); + } + } + + #[cfg(target_os = "macos")] + { + let mac_paths = [ + "/Library/Java/JavaVirtualMachines", + "/System/Library/Java/JavaVirtualMachines", + "/usr/local/opt/openjdk/bin/java", + "/opt/homebrew/opt/openjdk/bin/java", + ]; + + for path in &mac_paths { + let p = PathBuf::from(path); + if p.is_dir() { + if let Ok(entries) = std::fs::read_dir(&p) { + for entry in entries.flatten() { + let java_path = entry.path().join("Contents/Home/bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } else if p.exists() { + candidates.push(p); + } + } + + // Check common Homebrew java candidates for aarch64 macs + let homebrew_arm = PathBuf::from("/opt/homebrew/Cellar/openjdk"); + if homebrew_arm.exists() { + if let Ok(entries) = std::fs::read_dir(&homebrew_arm) { + for entry in entries.flatten() { + let java_path = entry + .path() + .join("libexec/openjdk.jdk/Contents/Home/bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + + // Check common SDKMAN! java candidates + if let Some(sdkman_java) = find_sdkman_java() { + candidates.push(sdkman_java); + } + + // Check common mise java candidates + if let Some(mise_java) = find_mise_java() { + candidates.push(mise_java); + } + } + + #[cfg(target_os = "windows")] + { + let program_files = + std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); + let program_files_x86 = std::env::var("ProgramFiles(x86)") + .unwrap_or_else(|_| "C:\\Program Files (x86)".to_string()); + let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); + + // Common installation paths for various JDK distributions + let mut win_paths = vec![]; + for base in &[&program_files, &program_files_x86, &local_app_data] { + win_paths.push(format!("{}\\Java", base)); + win_paths.push(format!("{}\\Eclipse Adoptium", base)); + win_paths.push(format!("{}\\AdoptOpenJDK", base)); + win_paths.push(format!("{}\\Microsoft\\jdk", base)); + win_paths.push(format!("{}\\Zulu", base)); + win_paths.push(format!("{}\\Amazon Corretto", base)); + win_paths.push(format!("{}\\BellSoft\\LibericaJDK", base)); + win_paths.push(format!("{}\\Programs\\Eclipse Adoptium", base)); + } + + for base in &win_paths { + let base_path = PathBuf::from(base); + if base_path.exists() { + if let Ok(entries) = std::fs::read_dir(&base_path) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin\\java.exe"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + } + } + + // Check JAVA_HOME environment variable + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; + let java_path = PathBuf::from(&java_home).join("bin").join(bin_name); + if java_path.exists() { + candidates.push(java_path); + } + } + + candidates +} diff --git a/src-tauri/src/core/java/error.rs b/src-tauri/src/core/java/error.rs new file mode 100644 index 0000000..bf78d3b --- /dev/null +++ b/src-tauri/src/core/java/error.rs @@ -0,0 +1,95 @@ +use std::fmt; + +/// Unified error type for Java component operations +/// +/// This enum represents all possible errors that can occur in the Java component, +/// providing a consistent error handling interface across all modules. +#[derive(Debug, Clone)] +pub enum JavaError { + // Java installation not found at the specified path + NotFound, + // Invalid Java version format or unable to parse version + InvalidVersion(String), + // Java installation verification failed (e.g., -version command failed) + VerificationFailed(String), + // Network error during API calls or downloads + NetworkError(String), + // File I/O error (reading, writing, or accessing files) + IoError(String), + // Timeout occurred during operation + Timeout(String), + // Serialization/deserialization error + SerializationError(String), + // Invalid configuration or parameters + InvalidConfig(String), + // Download or installation failed + DownloadFailed(String), + // Extraction or decompression failed + ExtractionFailed(String), + // Checksum verification failed + ChecksumMismatch(String), + // Other unspecified errors + Other(String), +} + +impl fmt::Display for JavaError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JavaError::NotFound => write!(f, "Java installation not found"), + JavaError::InvalidVersion(msg) => write!(f, "Invalid Java version: {}", msg), + JavaError::VerificationFailed(msg) => write!(f, "Java verification failed: {}", msg), + JavaError::NetworkError(msg) => write!(f, "Network error: {}", msg), + JavaError::IoError(msg) => write!(f, "I/O error: {}", msg), + JavaError::Timeout(msg) => write!(f, "Operation timeout: {}", msg), + JavaError::SerializationError(msg) => write!(f, "Serialization error: {}", msg), + JavaError::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg), + JavaError::DownloadFailed(msg) => write!(f, "Download failed: {}", msg), + JavaError::ExtractionFailed(msg) => write!(f, "Extraction failed: {}", msg), + JavaError::ChecksumMismatch(msg) => write!(f, "Checksum mismatch: {}", msg), + JavaError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for JavaError {} + +/// Convert JavaError to String for Tauri command results +impl From<JavaError> for String { + fn from(err: JavaError) -> Self { + err.to_string() + } +} + +/// Convert std::io::Error to JavaError +impl From<std::io::Error> for JavaError { + fn from(err: std::io::Error) -> Self { + JavaError::IoError(err.to_string()) + } +} + +/// Convert serde_json::Error to JavaError +impl From<serde_json::Error> for JavaError { + fn from(err: serde_json::Error) -> Self { + JavaError::SerializationError(err.to_string()) + } +} + +/// Convert reqwest::Error to JavaError +impl From<reqwest::Error> for JavaError { + fn from(err: reqwest::Error) -> Self { + if err.is_timeout() { + JavaError::Timeout(err.to_string()) + } else if err.is_connect() || err.is_request() { + JavaError::NetworkError(err.to_string()) + } else { + JavaError::NetworkError(err.to_string()) + } + } +} + +/// Convert String to JavaError +impl From<String> for JavaError { + fn from(err: String) -> Self { + JavaError::Other(err) + } +} diff --git a/src-tauri/src/core/java/mod.rs b/src-tauri/src/core/java/mod.rs new file mode 100644 index 0000000..091ad0a --- /dev/null +++ b/src-tauri/src/core/java/mod.rs @@ -0,0 +1,538 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter, Manager}; + +pub mod detection; +pub mod error; +pub mod persistence; +pub mod priority; +pub mod provider; +pub mod providers; +pub mod validation; + +pub use error::JavaError; +use ts_rs::TS; + +/// Remove the UNC prefix (\\?\) from Windows paths +pub fn strip_unc_prefix(path: PathBuf) -> PathBuf { + #[cfg(target_os = "windows")] + { + let s = path.to_string_lossy().to_string(); + if s.starts_with(r"\\?\\") { + return PathBuf::from(&s[4..]); + } + } + path +} + +use crate::core::downloader::{DownloadQueue, JavaDownloadProgress, PendingJavaDownload}; +use crate::utils::zip; +use provider::JavaProvider; +use providers::AdoptiumProvider; + +const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "java/core.ts")] +pub struct JavaInstallation { + pub path: String, + pub version: String, + pub arch: String, + pub vendor: String, + pub source: String, + pub is_64bit: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageType { + Jre, + Jdk, +} + +impl Default for ImageType { + fn default() -> Self { + Self::Jre + } +} + +impl std::fmt::Display for ImageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Jre => write!(f, "jre"), + Self::Jdk => write!(f, "jdk"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "java/core.ts")] +#[serde(rename_all = "camelCase")] +pub struct JavaReleaseInfo { + pub major_version: u32, + pub image_type: String, + pub version: String, + pub release_name: String, + pub release_date: Option<String>, + pub file_size: u64, + pub checksum: Option<String>, + pub download_url: String, + pub is_lts: bool, + pub is_available: bool, + pub architecture: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[ts(export, export_to = "java/core.ts")] +#[serde(rename_all = "camelCase")] +pub struct JavaCatalog { + pub releases: Vec<JavaReleaseInfo>, + pub available_major_versions: Vec<u32>, + pub lts_versions: Vec<u32>, + pub cached_at: u64, +} + +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export, export_to = "java/core.ts")] +pub struct JavaDownloadInfo { + pub version: String, // e.g., "17.0.2+8" + pub release_name: String, // e.g., "jdk-17.0.2+8" + pub download_url: String, // Direct download URL + pub file_name: String, // e.g., "OpenJDK17U-jre_x64_linux_hotspot_17.0.2_8.tar.gz" + pub file_size: u64, // in bytes + pub checksum: Option<String>, // SHA256 checksum + pub image_type: String, // "jre" or "jdk" +} + +pub fn get_java_install_dir(app_handle: &AppHandle) -> PathBuf { + app_handle.path().app_data_dir().unwrap().join("java") +} + +fn get_catalog_cache_path(app_handle: &AppHandle) -> PathBuf { + app_handle + .path() + .app_data_dir() + .unwrap() + .join("java_catalog_cache.json") +} + +pub fn load_cached_catalog(app_handle: &AppHandle) -> Option<JavaCatalog> { + let cache_path = get_catalog_cache_path(app_handle); + if !cache_path.exists() { + return None; + } + + // Read cache file + let content = std::fs::read_to_string(&cache_path).ok()?; + let catalog: JavaCatalog = serde_json::from_str(&content).ok()?; + + // Get current time in seconds since UNIX_EPOCH + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Check if cache is still valid + if now - catalog.cached_at < CACHE_DURATION_SECS { + Some(catalog) + } else { + None + } +} + +pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Result<(), String> { + let cache_path = get_catalog_cache_path(app_handle); + let content = serde_json::to_string_pretty(catalog).map_err(|e| e.to_string())?; + std::fs::write(&cache_path, content).map_err(|e| e.to_string())?; + Ok(()) +} + +#[allow(dead_code)] +pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> { + let cache_path = get_catalog_cache_path(app_handle); + if cache_path.exists() { + std::fs::remove_file(&cache_path).map_err(|e| e.to_string())?; + } + Ok(()) +} + +pub async fn fetch_java_catalog( + app_handle: &AppHandle, + force_refresh: bool, +) -> Result<JavaCatalog, String> { + let provider = AdoptiumProvider::new(); + provider + .fetch_catalog(app_handle, force_refresh) + .await + .map_err(|e| e.to_string()) +} + +pub async fn fetch_java_release( + major_version: u32, + image_type: ImageType, +) -> Result<JavaDownloadInfo, String> { + let provider = AdoptiumProvider::new(); + provider + .fetch_release(major_version, image_type) + .await + .map_err(|e| e.to_string()) +} + +pub async fn fetch_available_versions() -> Result<Vec<u32>, String> { + let provider = AdoptiumProvider::new(); + provider + .available_versions() + .await + .map_err(|e| e.to_string()) +} + +pub async fn download_and_install_java( + app_handle: &AppHandle, + major_version: u32, + image_type: ImageType, + custom_path: Option<PathBuf>, +) -> Result<JavaInstallation, String> { + let provider = AdoptiumProvider::new(); + let info = provider.fetch_release(major_version, image_type).await?; + let file_name = info.file_name.clone(); + + let install_base = custom_path.unwrap_or_else(|| get_java_install_dir(app_handle)); + let version_dir = install_base.join(format!( + "{}-{}-{}", + provider.install_prefix(), + major_version, + image_type + )); + + std::fs::create_dir_all(&install_base) + .map_err(|e| format!("Failed to create installation directory: {}", e))?; + + let mut queue = DownloadQueue::load(app_handle); + queue.add(PendingJavaDownload { + major_version, + image_type: image_type.to_string(), + download_url: info.download_url.clone(), + file_name: info.file_name.clone(), + file_size: info.file_size, + checksum: info.checksum.clone(), + install_path: install_base.to_string_lossy().to_string(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }); + queue.save(app_handle)?; + + let archive_path = install_base.join(&info.file_name); + + let need_download = if archive_path.exists() { + if let Some(expected_checksum) = &info.checksum { + let data = std::fs::read(&archive_path) + .map_err(|e| format!("Failed to read downloaded file: {}", e))?; + !crate::core::downloader::verify_checksum(&data, Some(expected_checksum), None) + } else { + false + } + } else { + true + }; + + if need_download { + crate::core::downloader::download_with_resume( + app_handle, + &info.download_url, + &archive_path, + info.checksum.as_deref(), + info.file_size, + ) + .await?; + } + + let _ = app_handle.emit( + "java-download-progress", + JavaDownloadProgress { + file_name: file_name.clone(), + downloaded_bytes: info.file_size, + total_bytes: info.file_size, + speed_bytes_per_sec: 0, + eta_seconds: 0, + status: "Extracting".to_string(), + percentage: 100.0, + }, + ); + + if version_dir.exists() { + std::fs::remove_dir_all(&version_dir) + .map_err(|e| format!("Failed to remove old version directory: {}", e))?; + } + + std::fs::create_dir_all(&version_dir) + .map_err(|e| format!("Failed to create version directory: {}", e))?; + + let top_level_dir = if info.file_name.ends_with(".tar.gz") || info.file_name.ends_with(".tgz") { + zip::extract_tar_gz(&archive_path, &version_dir)? + } else if info.file_name.ends_with(".zip") { + zip::extract_zip(&archive_path, &version_dir)?; + find_top_level_dir(&version_dir)? + } else { + return Err(format!("Unsupported archive format: {}", info.file_name)); + }; + + let _ = std::fs::remove_file(&archive_path); + + let java_home = version_dir.join(&top_level_dir); + let java_bin = if cfg!(target_os = "macos") { + java_home + .join("Contents") + .join("Home") + .join("bin") + .join("java") + } else if cfg!(windows) { + java_home.join("bin").join("java.exe") + } else { + java_home.join("bin").join("java") + }; + + if !java_bin.exists() { + return Err(format!( + "Installation completed but Java executable not found: {}", + java_bin.display() + )); + } + + let java_bin = std::fs::canonicalize(&java_bin).map_err(|e| e.to_string())?; + let java_bin = strip_unc_prefix(java_bin); + + let installation = validation::check_java_installation(&java_bin) + .await + .ok_or_else(|| "Failed to verify Java installation".to_string())?; + + queue.remove(major_version, &image_type.to_string()); + queue.save(app_handle)?; + + let _ = app_handle.emit( + "java-download-progress", + JavaDownloadProgress { + file_name, + downloaded_bytes: info.file_size, + total_bytes: info.file_size, + speed_bytes_per_sec: 0, + eta_seconds: 0, + status: "Completed".to_string(), + percentage: 100.0, + }, + ); + + Ok(installation) +} + +fn find_top_level_dir(extract_dir: &PathBuf) -> Result<String, String> { + let entries: Vec<_> = std::fs::read_dir(extract_dir) + .map_err(|e| format!("Failed to read directory: {}", e))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); + + if entries.len() == 1 { + Ok(entries[0].file_name().to_string_lossy().to_string()) + } else { + Ok(String::new()) + } +} + +pub async fn detect_java_installations() -> Vec<JavaInstallation> { + let mut installations = Vec::new(); + let candidates = detection::get_java_candidates(); + + for candidate in candidates { + if let Some(java) = validation::check_java_installation(&candidate).await { + if !installations + .iter() + .any(|j: &JavaInstallation| j.path == java.path) + { + installations.push(java); + } + } + } + + installations.sort_by(|a, b| { + let v_a = validation::parse_java_version(&a.version); + let v_b = validation::parse_java_version(&b.version); + v_b.cmp(&v_a) + }); + + installations +} + +pub async fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaInstallation> { + let installations = detect_java_installations().await; + + if let Some(required) = required_major_version { + installations.into_iter().find(|java| { + let major = validation::parse_java_version(&java.version); + major >= required as u32 + }) + } else { + installations.into_iter().next() + } +} + +pub async fn get_compatible_java( + app_handle: &AppHandle, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> Option<JavaInstallation> { + let installations = detect_all_java_installations(app_handle).await; + + installations.into_iter().find(|java| { + let major = validation::parse_java_version(&java.version); + validation::is_version_compatible(major, required_major_version, max_major_version) + }) +} + +pub async fn is_java_compatible( + java_path: &str, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> bool { + let java_path_buf = PathBuf::from(java_path); + if let Some(java) = validation::check_java_installation(&java_path_buf).await { + let major = validation::parse_java_version(&java.version); + validation::is_version_compatible(major, required_major_version, max_major_version) + } else { + false + } +} + +pub async fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstallation> { + let mut installations = detect_java_installations().await; + + let dropout_java_dir = get_java_install_dir(app_handle); + if dropout_java_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&dropout_java_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let java_bin = find_java_executable(&path); + if let Some(java_path) = java_bin { + if let Some(java) = validation::check_java_installation(&java_path).await { + if !installations.iter().any(|j| j.path == java.path) { + installations.push(java); + } + } + } + } + } + } + } + + installations.sort_by(|a, b| { + let v_a = validation::parse_java_version(&a.version); + let v_b = validation::parse_java_version(&b.version); + v_b.cmp(&v_a) + }); + + installations +} + +fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { + let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; + + let direct_bin = dir.join("bin").join(bin_name); + if direct_bin.exists() { + let resolved = std::fs::canonicalize(&direct_bin).unwrap_or(direct_bin); + return Some(strip_unc_prefix(resolved)); + } + + #[cfg(target_os = "macos")] + { + let macos_bin = dir.join("Contents").join("Home").join("bin").join(bin_name); + if macos_bin.exists() { + return Some(macos_bin); + } + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let nested_bin = path.join("bin").join(bin_name); + if nested_bin.exists() { + let resolved = std::fs::canonicalize(&nested_bin).unwrap_or(nested_bin); + return Some(strip_unc_prefix(resolved)); + } + + #[cfg(target_os = "macos")] + { + let macos_nested = path + .join("Contents") + .join("Home") + .join("bin") + .join(bin_name); + if macos_nested.exists() { + return Some(macos_nested); + } + } + } + } + } + + None +} + +pub async fn resume_pending_downloads( + app_handle: &AppHandle, +) -> Result<Vec<JavaInstallation>, String> { + let queue = DownloadQueue::load(app_handle); + let mut installed = Vec::new(); + + for pending in queue.pending_downloads.iter() { + let image_type = if pending.image_type == "jdk" { + ImageType::Jdk + } else { + ImageType::Jre + }; + + match download_and_install_java( + app_handle, + pending.major_version, + image_type, + Some(PathBuf::from(&pending.install_path)), + ) + .await + { + Ok(installation) => { + installed.push(installation); + } + Err(e) => { + eprintln!( + "Failed to resume Java {} {} download: {}", + pending.major_version, pending.image_type, e + ); + } + } + } + + Ok(installed) +} + +pub fn cancel_current_download() { + crate::core::downloader::cancel_java_download(); +} + +pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload> { + let queue = DownloadQueue::load(app_handle); + queue.pending_downloads +} + +#[allow(dead_code)] +pub fn clear_pending_download( + app_handle: &AppHandle, + major_version: u32, + image_type: &str, +) -> Result<(), String> { + let mut queue = DownloadQueue::load(app_handle); + queue.remove(major_version, image_type); + queue.save(app_handle) +} diff --git a/src-tauri/src/core/java/persistence.rs b/src-tauri/src/core/java/persistence.rs new file mode 100644 index 0000000..a6727d7 --- /dev/null +++ b/src-tauri/src/core/java/persistence.rs @@ -0,0 +1,115 @@ +use crate::core::java::error::JavaError; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "java/persistence.ts")] +pub struct JavaConfig { + pub user_defined_paths: Vec<String>, + pub preferred_java_path: Option<String>, + pub last_detection_time: u64, +} + +impl Default for JavaConfig { + fn default() -> Self { + Self { + user_defined_paths: Vec::new(), + preferred_java_path: None, + last_detection_time: 0, + } + } +} + +fn get_java_config_path(app_handle: &AppHandle) -> PathBuf { + app_handle + .path() + .app_data_dir() + .unwrap() + .join("java_config.json") +} + +pub fn load_java_config(app_handle: &AppHandle) -> JavaConfig { + let config_path = get_java_config_path(app_handle); + if !config_path.exists() { + return JavaConfig::default(); + } + + match std::fs::read_to_string(&config_path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(config) => config, + Err(err) => { + // Log the error but don't panic - return default config + log::warn!( + "Failed to parse Java config at {}: {}. Using default configuration.", + config_path.display(), + err + ); + JavaConfig::default() + } + }, + Err(err) => { + log::warn!( + "Failed to read Java config at {}: {}. Using default configuration.", + config_path.display(), + err + ); + JavaConfig::default() + } + } +} + +pub fn save_java_config(app_handle: &AppHandle, config: &JavaConfig) -> Result<(), JavaError> { + let config_path = get_java_config_path(app_handle); + let content = serde_json::to_string_pretty(config)?; + + std::fs::create_dir_all(config_path.parent().ok_or_else(|| { + JavaError::InvalidConfig("Java config path has no parent directory".to_string()) + })?)?; + + std::fs::write(&config_path, content)?; + Ok(()) +} + +#[allow(dead_code)] +pub fn add_user_defined_path(app_handle: &AppHandle, path: String) -> Result<(), JavaError> { + let mut config = load_java_config(app_handle); + if !config.user_defined_paths.contains(&path) { + config.user_defined_paths.push(path); + } + save_java_config(app_handle, &config) +} + +#[allow(dead_code)] +pub fn remove_user_defined_path(app_handle: &AppHandle, path: &str) -> Result<(), JavaError> { + let mut config = load_java_config(app_handle); + config.user_defined_paths.retain(|p| p != path); + save_java_config(app_handle, &config) +} + +#[allow(dead_code)] +pub fn set_preferred_java_path( + app_handle: &AppHandle, + path: Option<String>, +) -> Result<(), JavaError> { + let mut config = load_java_config(app_handle); + config.preferred_java_path = path; + save_java_config(app_handle, &config) +} + +#[allow(dead_code)] +pub fn get_preferred_java_path(app_handle: &AppHandle) -> Option<String> { + let config = load_java_config(app_handle); + config.preferred_java_path +} + +#[allow(dead_code)] +pub fn update_last_detection_time(app_handle: &AppHandle) -> Result<(), JavaError> { + let mut config = load_java_config(app_handle); + config.last_detection_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| JavaError::Other(format!("System time error: {}", e)))? + .as_secs(); + save_java_config(app_handle, &config) +} diff --git a/src-tauri/src/core/java/priority.rs b/src-tauri/src/core/java/priority.rs new file mode 100644 index 0000000..f991eb7 --- /dev/null +++ b/src-tauri/src/core/java/priority.rs @@ -0,0 +1,59 @@ +use tauri::AppHandle; + +use crate::core::java::JavaInstallation; +use crate::core::java::persistence; +use crate::core::java::validation; + +pub async fn resolve_java_for_launch( + app_handle: &AppHandle, + instance_java_override: Option<&str>, + global_java_path: Option<&str>, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> Option<JavaInstallation> { + if let Some(override_path) = instance_java_override { + if !override_path.is_empty() { + let path_buf = std::path::PathBuf::from(override_path); + if let Some(java) = validation::check_java_installation(&path_buf).await { + if is_version_compatible(&java, required_major_version, max_major_version) { + return Some(java); + } + } + } + } + + if let Some(global_path) = global_java_path { + if !global_path.is_empty() { + let path_buf = std::path::PathBuf::from(global_path); + if let Some(java) = validation::check_java_installation(&path_buf).await { + if is_version_compatible(&java, required_major_version, max_major_version) { + return Some(java); + } + } + } + } + + let preferred = persistence::get_preferred_java_path(app_handle); + if let Some(pref_path) = preferred { + let path_buf = std::path::PathBuf::from(&pref_path); + if let Some(java) = validation::check_java_installation(&path_buf).await { + if is_version_compatible(&java, required_major_version, max_major_version) { + return Some(java); + } + } + } + + let installations = super::detect_all_java_installations(app_handle).await; + installations + .into_iter() + .find(|java| is_version_compatible(java, required_major_version, max_major_version)) +} + +fn is_version_compatible( + java: &JavaInstallation, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> bool { + let major = validation::parse_java_version(&java.version); + validation::is_version_compatible(major, required_major_version, max_major_version) +} diff --git a/src-tauri/src/core/java/provider.rs b/src-tauri/src/core/java/provider.rs new file mode 100644 index 0000000..8aa0a0d --- /dev/null +++ b/src-tauri/src/core/java/provider.rs @@ -0,0 +1,58 @@ +use crate::core::java::{ImageType, JavaCatalog, JavaDownloadInfo, JavaError}; +use tauri::AppHandle; + +/// Trait for Java distribution providers (e.g., Adoptium, Corretto) +/// +/// Implementations handle fetching Java catalogs and release information +/// from different distribution providers. +pub trait JavaProvider: Send + Sync { + /// Fetch the Java catalog (all available versions for this provider) + /// + /// # Arguments + /// * `app_handle` - The Tauri app handle for cache access + /// * `force_refresh` - If true, bypass cache and fetch fresh data + /// + /// # Returns + /// * `Ok(JavaCatalog)` with available versions + /// * `Err(JavaError)` if fetch or parsing fails + async fn fetch_catalog( + &self, + app_handle: &AppHandle, + force_refresh: bool, + ) -> Result<JavaCatalog, JavaError>; + + /// Fetch a specific Java release + /// + /// # Arguments + /// * `major_version` - The major version number (e.g., 17, 21) + /// * `image_type` - Whether to fetch JRE or JDK + /// + /// # Returns + /// * `Ok(JavaDownloadInfo)` with download details + /// * `Err(JavaError)` if fetch or parsing fails + async fn fetch_release( + &self, + major_version: u32, + image_type: ImageType, + ) -> Result<JavaDownloadInfo, JavaError>; + + /// Get list of available major versions + /// + /// # Returns + /// * `Ok(Vec<u32>)` with available major versions + /// * `Err(JavaError)` if fetch fails + async fn available_versions(&self) -> Result<Vec<u32>, JavaError>; + + /// Get provider name (e.g., "adoptium", "corretto") + #[allow(dead_code)] + fn provider_name(&self) -> &'static str; + + /// Get OS name for this provider's API + fn os_name(&self) -> &'static str; + + /// Get architecture name for this provider's API + fn arch_name(&self) -> &'static str; + + /// Get installation directory prefix (e.g., "temurin", "corretto") + fn install_prefix(&self) -> &'static str; +} diff --git a/src-tauri/src/core/java/providers/adoptium.rs b/src-tauri/src/core/java/providers/adoptium.rs new file mode 100644 index 0000000..1765a99 --- /dev/null +++ b/src-tauri/src/core/java/providers/adoptium.rs @@ -0,0 +1,340 @@ +use crate::core::java::error::JavaError; +use crate::core::java::provider::JavaProvider; +use crate::core::java::save_catalog_cache; +use crate::core::java::{ImageType, JavaCatalog, JavaDownloadInfo, JavaReleaseInfo}; +use serde::Deserialize; +use tauri::AppHandle; +use ts_rs::TS; + +const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; + +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export, export_to = "java/providers/adoptium.ts")] +pub struct AdoptiumAsset { + pub binary: AdoptiumBinary, + pub release_name: String, + pub version: AdoptiumVersionData, +} + +#[derive(Debug, Clone, Deserialize, TS)] +#[allow(dead_code)] +#[ts(export, export_to = "java/providers/adoptium.ts")] +pub struct AdoptiumBinary { + pub os: String, + pub architecture: String, + pub image_type: String, + pub package: AdoptiumPackage, + #[serde(default)] + pub updated_at: Option<String>, +} + +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export, export_to = "java/providers/adoptium.ts")] +pub struct AdoptiumPackage { + pub name: String, + pub link: String, + pub size: u64, + pub checksum: Option<String>, +} + +#[derive(Debug, Clone, Deserialize, TS)] +#[allow(dead_code)] +#[ts(export, export_to = "java/providers/adoptium.ts")] +pub struct AdoptiumVersionData { + pub major: u32, + pub minor: u32, + pub security: u32, + pub semver: String, + pub openjdk_version: String, +} + +#[derive(Debug, Clone, Deserialize, TS)] +#[allow(dead_code)] +#[ts(export, export_to = "java/providers/adoptium.ts")] +pub struct AvailableReleases { + pub available_releases: Vec<u32>, + pub available_lts_releases: Vec<u32>, + pub most_recent_lts: Option<u32>, + pub most_recent_feature_release: Option<u32>, +} + +pub struct AdoptiumProvider; + +impl AdoptiumProvider { + pub fn new() -> Self { + Self + } +} + +impl Default for AdoptiumProvider { + fn default() -> Self { + Self::new() + } +} + +impl JavaProvider for AdoptiumProvider { + async fn fetch_catalog( + &self, + app_handle: &AppHandle, + force_refresh: bool, + ) -> Result<JavaCatalog, JavaError> { + if !force_refresh { + if let Some(cached) = crate::core::java::load_cached_catalog(app_handle) { + return Ok(cached); + } + } + + let os = self.os_name(); + let arch = self.arch_name(); + let client = reqwest::Client::new(); + + let releases_url = format!("{}/info/available_releases", ADOPTIUM_API_BASE); + let available: AvailableReleases = client + .get(&releases_url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| { + JavaError::NetworkError(format!("Failed to fetch available releases: {}", e)) + })? + .json::<AvailableReleases>() + .await + .map_err(|e| { + JavaError::SerializationError(format!("Failed to parse available releases: {}", e)) + })?; + + // Parallelize HTTP requests for better performance + let mut fetch_tasks = Vec::new(); + + for major_version in &available.available_releases { + for image_type in &["jre", "jdk"] { + let major_version = *major_version; + let image_type = image_type.to_string(); + let url = format!( + "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", + ADOPTIUM_API_BASE, major_version, os, arch, image_type + ); + let client = client.clone(); + let is_lts = available.available_lts_releases.contains(&major_version); + let arch = arch.to_string(); + + let task = tokio::spawn(async move { + match client + .get(&url) + .header("Accept", "application/json") + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + if let Ok(assets) = response.json::<Vec<AdoptiumAsset>>().await { + if let Some(asset) = assets.into_iter().next() { + let release_date = asset.binary.updated_at.clone(); + return Some(JavaReleaseInfo { + major_version, + image_type, + version: asset.version.semver.clone(), + release_name: asset.release_name.clone(), + release_date, + file_size: asset.binary.package.size, + checksum: asset.binary.package.checksum, + download_url: asset.binary.package.link, + is_lts, + is_available: true, + architecture: asset.binary.architecture.clone(), + }); + } + } + } + // Fallback for unsuccessful response + Some(JavaReleaseInfo { + major_version, + image_type, + version: format!("{}.x", major_version), + release_name: format!("jdk-{}", major_version), + release_date: None, + file_size: 0, + checksum: None, + download_url: String::new(), + is_lts, + is_available: false, + architecture: arch, + }) + } + Err(_) => Some(JavaReleaseInfo { + major_version, + image_type, + version: format!("{}.x", major_version), + release_name: format!("jdk-{}", major_version), + release_date: None, + file_size: 0, + checksum: None, + download_url: String::new(), + is_lts, + is_available: false, + architecture: arch, + }), + } + }); + fetch_tasks.push(task); + } + } + + // Collect all results concurrently + let mut releases = Vec::new(); + for task in fetch_tasks { + match task.await { + Ok(Some(release)) => { + releases.push(release); + } + Ok(None) => { + // Task completed but returned None, should not happen in current implementation + } + Err(e) => { + return Err(JavaError::NetworkError(format!( + "Failed to join Adoptium catalog fetch task: {}", + e + ))); + } + } + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let catalog = JavaCatalog { + releases, + available_major_versions: available.available_releases, + lts_versions: available.available_lts_releases, + cached_at: now, + }; + + let _ = save_catalog_cache(app_handle, &catalog); + + Ok(catalog) + } + + async fn fetch_release( + &self, + major_version: u32, + image_type: ImageType, + ) -> Result<JavaDownloadInfo, JavaError> { + let os = self.os_name(); + let arch = self.arch_name(); + + let url = format!( + "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", + ADOPTIUM_API_BASE, major_version, os, arch, image_type + ); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| JavaError::NetworkError(format!("Network request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(JavaError::NetworkError(format!( + "Adoptium API returned error: {} - The version/platform might be unavailable", + response.status() + ))); + } + + let assets: Vec<AdoptiumAsset> = + response.json::<Vec<AdoptiumAsset>>().await.map_err(|e| { + JavaError::SerializationError(format!("Failed to parse API response: {}", e)) + })?; + + let asset = assets + .into_iter() + .next() + .ok_or_else(|| JavaError::NotFound)?; + + Ok(JavaDownloadInfo { + version: asset.version.semver.clone(), + release_name: asset.release_name, + download_url: asset.binary.package.link, + file_name: asset.binary.package.name, + file_size: asset.binary.package.size, + checksum: asset.binary.package.checksum, + image_type: asset.binary.image_type, + }) + } + + async fn available_versions(&self) -> Result<Vec<u32>, JavaError> { + let url = format!("{}/info/available_releases", ADOPTIUM_API_BASE); + + let response = reqwest::get(url) + .await + .map_err(|e| JavaError::NetworkError(format!("Network request failed: {}", e)))?; + + let releases: AvailableReleases = + response.json::<AvailableReleases>().await.map_err(|e| { + JavaError::SerializationError(format!("Failed to parse response: {}", e)) + })?; + + Ok(releases.available_releases) + } + + fn provider_name(&self) -> &'static str { + "adoptium" + } + + fn os_name(&self) -> &'static str { + #[cfg(target_os = "linux")] + { + if std::path::Path::new("/etc/alpine-release").exists() { + return "alpine-linux"; + } + "linux" + } + #[cfg(target_os = "macos")] + { + "mac" + } + #[cfg(target_os = "windows")] + { + "windows" + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + "linux" + } + } + + fn arch_name(&self) -> &'static str { + #[cfg(target_arch = "x86_64")] + { + "x64" + } + #[cfg(target_arch = "aarch64")] + { + "aarch64" + } + #[cfg(target_arch = "x86")] + { + "x86" + } + #[cfg(target_arch = "arm")] + { + "arm" + } + #[cfg(not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "x86", + target_arch = "arm" + )))] + { + "x64" + } + } + + fn install_prefix(&self) -> &'static str { + "temurin" + } +} diff --git a/src-tauri/src/core/java/providers/mod.rs b/src-tauri/src/core/java/providers/mod.rs new file mode 100644 index 0000000..16eb5c7 --- /dev/null +++ b/src-tauri/src/core/java/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod adoptium; + +pub use adoptium::AdoptiumProvider; diff --git a/src-tauri/src/core/java/validation.rs b/src-tauri/src/core/java/validation.rs new file mode 100644 index 0000000..b56ad59 --- /dev/null +++ b/src-tauri/src/core/java/validation.rs @@ -0,0 +1,146 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +use crate::core::java::JavaInstallation; + +pub async fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> { + let path = path.clone(); + tokio::task::spawn_blocking(move || check_java_installation_blocking(&path)) + .await + .ok()? +} + +fn check_java_installation_blocking(path: &PathBuf) -> Option<JavaInstallation> { + let mut cmd = Command::new(path); + cmd.arg("-version"); + + // Hide console window + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + + let output = cmd.output().ok()?; + + let version_output = String::from_utf8_lossy(&output.stderr); + + let version = parse_version_string(&version_output)?; + let arch = extract_architecture(&version_output); + let vendor = extract_vendor(&version_output); + let is_64bit = version_output.to_lowercase().contains("64-bit") || arch == "aarch64"; + + Some(JavaInstallation { + path: path.to_string_lossy().to_string(), + version, + arch, + vendor, + source: "system".to_string(), + is_64bit, + }) +} + +pub fn parse_version_string(output: &str) -> Option<String> { + for line in output.lines() { + if line.contains("version") { + if let Some(start) = line.find('"') { + if let Some(end) = line[start + 1..].find('"') { + return Some(line[start + 1..start + 1 + end].to_string()); + } + } + } + } + None +} + +pub fn parse_java_version(version: &str) -> u32 { + let parts: Vec<&str> = version.split('.').collect(); + if let Some(first) = parts.first() { + // Handle both legacy (1.x) and modern (x) versioning + if *first == "1" { + // Legacy versioning + parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0) + } else { + // Modern versioning + first.parse().unwrap_or(0) + } + } else { + 0 + } +} + +pub fn extract_architecture(version_output: &str) -> String { + if version_output.contains("64-Bit") { + "x64".to_string() + } else if version_output.contains("32-Bit") { + "x86".to_string() + } else if version_output.contains("aarch64") || version_output.contains("ARM64") { + "aarch64".to_string() + } else { + "x64".to_string() + } +} + +pub fn extract_vendor(version_output: &str) -> String { + let lower = version_output.to_lowercase(); + + let vendor_name: HashMap<&str, &str> = [ + // Eclipse/Adoptium + ("temurin", "Temurin (Eclipse)"), + ("adoptium", "Eclipse Adoptium"), + // Amazon + ("corretto", "Corretto (Amazon)"), + ("amzn", "Corretto (Amazon)"), + // Alibaba + ("dragonwell", "Dragonwell (Alibaba)"), + ("albba", "Dragonwell (Alibaba)"), + // GraalVM + ("graalvm", "GraalVM"), + // Oracle + ("oracle", "Java SE Development Kit (Oracle)"), + // Tencent + ("kona", "Kona (Tencent)"), + // BellSoft + ("liberica", "Liberica (Bellsoft)"), + ("mandrel", "Mandrel (Red Hat)"), + // Microsoft + ("microsoft", "OpenJDK (Microsoft)"), + // SAP + ("sapmachine", "SapMachine (SAP)"), + // IBM + ("semeru", "Semeru (IBM)"), + ("sem", "Semeru (IBM)"), + // Azul + ("zulu", "Zulu (Azul Systems)"), + // Trava + ("trava", "Trava (Trava)"), + // Huawei + ("bisheng", "BiSheng (Huawei)"), + // Generic OpenJDK + ("openjdk", "OpenJDK"), + ] + .iter() + .cloned() + .collect(); + + for (key, name) in vendor_name { + if lower.contains(key) { + return name.to_string(); + } + } + + "Unknown".to_string() +} + +pub fn is_version_compatible( + major: u32, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> bool { + let meets_min = required_major_version + .map(|r| major >= r as u32) + .unwrap_or(true); + let meets_max = max_major_version.map(|m| major <= m).unwrap_or(true); + meets_min && meets_max +} diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs index e792071..d40d958 100644 --- a/src-tauri/src/core/manifest.rs +++ b/src-tauri/src/core/manifest.rs @@ -3,20 +3,27 @@ use std::error::Error; use std::path::PathBuf; use crate::core::game_version::GameVersion; +use ts_rs::TS; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "manifest.ts")] pub struct VersionManifest { pub latest: Latest, pub versions: Vec<Version>, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "manifest.ts")] pub struct Latest { pub release: String, pub snapshot: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "manifest.ts")] pub struct Version { pub id: String, #[serde(rename = "type")] diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index dcbd47a..4b92e0a 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -10,5 +10,6 @@ pub mod instance; pub mod java; pub mod manifest; pub mod maven; +pub mod modpack; pub mod rules; pub mod version_merge; diff --git a/src-tauri/src/core/modpack.rs b/src-tauri/src/core/modpack.rs new file mode 100644 index 0000000..5ac9493 --- /dev/null +++ b/src-tauri/src/core/modpack.rs @@ -0,0 +1,489 @@ +//! Modpack parsing and extraction module. +//! +//! Supported formats: +//! - Modrinth (.mrpack / zip with `modrinth.index.json`) +//! - CurseForge (zip with `manifest.json`, manifestType = "minecraftModpack") +//! - MultiMC / PrismLauncher (zip with `instance.cfg`) +//! +//! ## Usage +//! +//! ```ignore +//! // 1. Parse modpack → get metadata + file list + override prefixes +//! let pack = modpack::import(&path).await?; +//! +//! // 2. These can run in parallel for Modrinth/CurseForge: +//! // a) Extract override files (configs, resource packs, etc.) +//! modpack::extract_overrides(&path, &game_dir, &pack.override_prefixes, |cur, total, name| { +//! println!("Extracting ({cur}/{total}) {name}"); +//! })?; +//! // b) Install Minecraft version — use pack.info.minecraft_version (e.g. "1.20.1") +//! // → Fetch version manifest, download client jar, assets, libraries. +//! // c) Install mod loader — use pack.info.mod_loader + mod_loader_version +//! // → Download loader installer/profile, patch version JSON. +//! +//! // 3. Download mod files (use pack.files) +//! // Each ModpackFile has url, path (relative to game_dir), sha1, size. +//! // Partial failure is acceptable — missing mods can be retried on next launch. +//! ``` + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::path::Path; + +type Archive = zip::ZipArchive<fs::File>; + +// ── Public types ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModpackInfo { + pub name: String, + pub minecraft_version: Option<String>, + pub mod_loader: Option<String>, + pub mod_loader_version: Option<String>, + pub modpack_type: String, + #[serde(default)] + pub instance_id: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModpackFile { + pub url: String, + pub path: String, + pub size: Option<u64>, + pub sha1: Option<String>, +} + +/// Unified parse result from any modpack format. +pub struct ParsedModpack { + pub info: ModpackInfo, + pub files: Vec<ModpackFile>, + pub override_prefixes: Vec<String>, +} + +// ── Public API ──────────────────────────────────────────────────────────── + +/// Parse a modpack zip and return metadata only (no network, no side effects). +pub fn detect(path: &Path) -> Result<ModpackInfo, String> { + Ok(parse(path)?.info) +} + +/// Parse a modpack zip, resolve download URLs, and return everything needed +/// to complete the installation. +pub async fn import(path: &Path) -> Result<ParsedModpack, String> { + let mut result = parse(path)?; + if result.info.modpack_type == "curseforge" { + result.files = resolve_curseforge_files(&result.files).await?; + } + Ok(result) +} + +/// Extract override files from the modpack zip into the game directory. +pub fn extract_overrides( + path: &Path, + game_dir: &Path, + override_prefixes: &[String], + on_progress: impl Fn(usize, usize, &str), +) -> Result<(), String> { + let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?; + + // Collect which prefixes actually exist + let all_names: Vec<String> = (0..archive.len()) + .filter_map(|i| Some(archive.by_index_raw(i).ok()?.name().to_string())) + .collect(); + let prefixes: Vec<&str> = override_prefixes + .iter() + .filter(|pfx| all_names.iter().any(|n| n.starts_with(pfx.as_str()))) + .map(|s| s.as_str()) + .collect(); + + let strip = |name: &str| -> Option<String> { + prefixes.iter().find_map(|pfx| { + let rel = name.strip_prefix(*pfx)?; + (!rel.is_empty()).then(|| rel.to_string()) + }) + }; + + let total = all_names.iter().filter(|n| strip(n).is_some()).count(); + let mut current = 0; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; + let name = entry.name().to_string(); + let Some(relative) = strip(&name) else { + continue; + }; + + let outpath = game_dir.join(&relative); + if !outpath.starts_with(game_dir) { + continue; + } // path traversal guard + + if entry.is_dir() { + fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; + } else { + if let Some(p) = outpath.parent() { + fs::create_dir_all(p).map_err(|e| e.to_string())?; + } + let mut f = fs::File::create(&outpath).map_err(|e| e.to_string())?; + std::io::copy(&mut entry, &mut f).map_err(|e| e.to_string())?; + } + current += 1; + on_progress(current, total, &relative); + } + Ok(()) +} + +// ── Core parse dispatch ─────────────────────────────────────────────────── + +type ParserFn = fn(&mut Archive) -> Result<ParsedModpack, String>; + +const PARSERS: &[ParserFn] = &[parse_modrinth, parse_curseforge, parse_multimc]; + +fn parse(path: &Path) -> Result<ParsedModpack, String> { + let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?; + + for parser in PARSERS { + if let Ok(result) = parser(&mut archive) { + return Ok(result); + } + } + Ok(ParsedModpack { + info: ModpackInfo { + name: path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Imported Modpack") + .to_string(), + minecraft_version: None, + mod_loader: None, + mod_loader_version: None, + modpack_type: "unknown".into(), + instance_id: None, + }, + files: vec![], + override_prefixes: vec![], + }) +} + +// ── Format parsers ──────────────────────────────────────────────────────── + +fn parse_modrinth(archive: &mut Archive) -> Result<ParsedModpack, String> { + let json = read_json(archive, "modrinth.index.json")?; + let (mod_loader, mod_loader_version) = parse_modrinth_loader(&json["dependencies"]); + + let files = json["files"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|f| { + if f["env"]["client"].as_str() == Some("unsupported") { + return None; + } + let path = f["path"].as_str()?; + if path.contains("..") { + return None; + } + Some(ModpackFile { + path: path.to_string(), + url: f["downloads"].as_array()?.first()?.as_str()?.to_string(), + size: f["fileSize"].as_u64(), + sha1: f["hashes"]["sha1"].as_str().map(String::from), + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ParsedModpack { + info: ModpackInfo { + name: json["name"].as_str().unwrap_or("Modrinth Modpack").into(), + minecraft_version: json["dependencies"]["minecraft"].as_str().map(Into::into), + mod_loader, + mod_loader_version, + modpack_type: "modrinth".into(), + instance_id: None, + }, + files, + override_prefixes: vec!["client-overrides/".into(), "overrides/".into()], + }) +} + +fn parse_curseforge(archive: &mut Archive) -> Result<ParsedModpack, String> { + let json = read_json(archive, "manifest.json")?; + if json["manifestType"].as_str() != Some("minecraftModpack") { + return Err("not curseforge".into()); + } + + let (loader, loader_ver) = json["minecraft"]["modLoaders"] + .as_array() + .and_then(|arr| { + arr.iter() + .find(|ml| ml["primary"].as_bool() == Some(true)) + .or_else(|| arr.first()) + }) + .and_then(|ml| { + let (l, v) = ml["id"].as_str()?.split_once('-')?; + Some((Some(l.to_string()), Some(v.to_string()))) + }) + .unwrap_or((None, None)); + + let files = json["files"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|f| { + Some(ModpackFile { + url: format!( + "curseforge://{}:{}", + f["projectID"].as_u64()?, + f["fileID"].as_u64()? + ), + path: String::new(), + size: None, + sha1: None, + }) + }) + .collect() + }) + .unwrap_or_default(); + + let overrides = json["overrides"].as_str().unwrap_or("overrides"); + + Ok(ParsedModpack { + info: ModpackInfo { + name: json["name"].as_str().unwrap_or("CurseForge Modpack").into(), + minecraft_version: json["minecraft"]["version"].as_str().map(Into::into), + mod_loader: loader, + mod_loader_version: loader_ver, + modpack_type: "curseforge".into(), + instance_id: None, + }, + files, + override_prefixes: vec![format!("{overrides}/")], + }) +} + +fn parse_multimc(archive: &mut Archive) -> Result<ParsedModpack, String> { + let root = find_multimc_root(archive).ok_or("not multimc")?; + let cfg = read_entry(archive, &format!("{root}instance.cfg")).ok_or("not multimc")?; + + let name = cfg_value(&cfg, "name").unwrap_or_else(|| "MultiMC Modpack".into()); + + let (mc, loader, loader_ver) = read_json(archive, &format!("{root}mmc-pack.json")) + .map(|j| parse_mmc_components(&j)) + .unwrap_or_default(); + let mc = mc.or_else(|| cfg_value(&cfg, "IntendedVersion")); + + Ok(ParsedModpack { + info: ModpackInfo { + name, + minecraft_version: mc, + mod_loader: loader, + mod_loader_version: loader_ver, + modpack_type: "multimc".into(), + instance_id: None, + }, + files: vec![], + override_prefixes: vec![format!("{root}.minecraft/"), format!("{root}minecraft/")], + }) +} + +// ── CurseForge API resolution ───────────────────────────────────────────── + +const CURSEFORGE_API_KEY: &str = env!("CURSEFORGE_API_KEY"); + +async fn resolve_curseforge_files(files: &[ModpackFile]) -> Result<Vec<ModpackFile>, String> { + let file_ids: Vec<u64> = files + .iter() + .filter_map(|f| { + f.url + .strip_prefix("curseforge://")? + .split(':') + .nth(1)? + .parse() + .ok() + }) + .collect(); + if file_ids.is_empty() { + return Ok(vec![]); + } + + let client = reqwest::Client::new(); + + // 1. Batch-resolve file metadata + let body = cf_post( + &client, + "/v1/mods/files", + &serde_json::json!({ "fileIds": file_ids }), + ) + .await?; + let file_arr = body["data"].as_array().cloned().unwrap_or_default(); + + // 2. Batch-resolve mod classIds for directory placement + let mod_ids: Vec<u64> = file_arr + .iter() + .filter_map(|f| f["modId"].as_u64()) + .collect::<std::collections::HashSet<_>>() + .into_iter() + .collect(); + let class_map = cf_class_ids(&client, &mod_ids).await; + + // 3. Build results + Ok(file_arr + .iter() + .filter_map(|f| { + let name = f["fileName"].as_str()?; + let id = f["id"].as_u64()?; + let url = f["downloadUrl"] + .as_str() + .map(String::from) + .unwrap_or_else(|| { + format!( + "https://edge.forgecdn.net/files/{}/{}/{name}", + id / 1000, + id % 1000 + ) + }); + let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) { + Some(12) => "resourcepacks", + Some(6552) => "shaderpacks", + _ => "mods", + }; + Some(ModpackFile { + url, + path: format!("{dir}/{name}"), + size: f["fileLength"].as_u64(), + sha1: None, + }) + }) + .collect()) +} + +async fn cf_post( + client: &reqwest::Client, + endpoint: &str, + body: &serde_json::Value, +) -> Result<serde_json::Value, String> { + let resp = client + .post(format!("https://api.curseforge.com{endpoint}")) + .header("x-api-key", CURSEFORGE_API_KEY) + .json(body) + .send() + .await + .map_err(|e| format!("CurseForge API error: {e}"))?; + if !resp.status().is_success() { + return Err(format!("CurseForge API returned {}", resp.status())); + } + resp.json().await.map_err(|e| e.to_string()) +} + +async fn cf_class_ids(client: &reqwest::Client, mod_ids: &[u64]) -> HashMap<u64, u64> { + if mod_ids.is_empty() { + return Default::default(); + } + let Ok(body) = cf_post( + client, + "/v1/mods", + &serde_json::json!({ "modIds": mod_ids }), + ) + .await + else { + return Default::default(); + }; + body["data"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| Some((m["id"].as_u64()?, m["classId"].as_u64()?))) + .collect() + }) + .unwrap_or_default() +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +fn read_entry(archive: &mut Archive, name: &str) -> Option<String> { + let mut buf = String::new(); + archive.by_name(name).ok()?.read_to_string(&mut buf).ok()?; + Some(buf) +} + +fn read_json(archive: &mut Archive, name: &str) -> Result<serde_json::Value, String> { + let content = read_entry(archive, name).ok_or_else(|| format!("{name} not found"))?; + serde_json::from_str(&content).map_err(|e| e.to_string()) +} + +fn cfg_value(content: &str, key: &str) -> Option<String> { + let prefix = format!("{key}="); + content + .lines() + .find_map(|l| Some(l.strip_prefix(&prefix)?.trim().to_string())) +} + +fn find_multimc_root(archive: &mut Archive) -> Option<String> { + for i in 0..archive.len() { + let name = archive.by_index_raw(i).ok()?.name().to_string(); + if name == "instance.cfg" { + return Some(String::new()); + } + if name.ends_with("/instance.cfg") && name.matches('/').count() == 1 { + return Some(name.strip_suffix("instance.cfg")?.to_string()); + } + } + None +} + +fn parse_modrinth_loader(deps: &serde_json::Value) -> (Option<String>, Option<String>) { + const LOADERS: &[(&str, &str)] = &[ + ("fabric-loader", "fabric"), + ("forge", "forge"), + ("quilt-loader", "quilt"), + ("neoforge", "neoforge"), + ("neo-forge", "neoforge"), + ]; + LOADERS + .iter() + .find_map(|(key, name)| { + let v = deps[*key].as_str()?; + Some((Some((*name).into()), Some(v.into()))) + }) + .unwrap_or((None, None)) +} + +fn parse_mmc_components( + json: &serde_json::Value, +) -> (Option<String>, Option<String>, Option<String>) { + let (mut mc, mut loader, mut loader_ver) = (None, None, None); + for c in json["components"].as_array().into_iter().flatten() { + let ver = c["version"].as_str().map(String::from); + match c["uid"].as_str().unwrap_or("") { + "net.minecraft" => mc = ver, + "net.minecraftforge" => { + loader = Some("forge".into()); + loader_ver = ver; + } + "net.neoforged" => { + loader = Some("neoforge".into()); + loader_ver = ver; + } + "net.fabricmc.fabric-loader" => { + loader = Some("fabric".into()); + loader_ver = ver; + } + "org.quiltmc.quilt-loader" => { + loader = Some("quilt".into()); + loader_ver = ver; + } + "com.mumfrey.liteloader" => { + loader = Some("liteloader".into()); + loader_ver = ver; + } + _ => {} + } + } + (mc, loader, loader_ver) +} |