diff options
| author | 2026-01-18 12:24:29 +0800 | |
|---|---|---|
| committer | 2026-01-18 12:24:29 +0800 | |
| commit | fd00ac6878b2cee9337b9e92d0c990ecdce9a346 (patch) | |
| tree | bb5540f763dc0061877c9d9ac53747d79193eecc /src-tauri/src | |
| parent | ad36e0ce82770f9b3509ddb1cf96bc3422969806 (diff) | |
| parent | 6d82ab2275130f3bafdb7ec664297eb700321526 (diff) | |
| download | DropOut-fd00ac6878b2cee9337b9e92d0c990ecdce9a346.tar.gz DropOut-fd00ac6878b2cee9337b9e92d0c990ecdce9a346.zip | |
Merge pull request #58 from HsiangNianian/main
Diffstat (limited to 'src-tauri/src')
| -rw-r--r-- | src-tauri/src/core/account_storage.rs | 2 | ||||
| -rw-r--r-- | src-tauri/src/core/assistant.rs | 694 | ||||
| -rw-r--r-- | src-tauri/src/core/auth.rs | 7 | ||||
| -rw-r--r-- | src-tauri/src/core/config.rs | 40 | ||||
| -rw-r--r-- | src-tauri/src/core/downloader.rs | 33 | ||||
| -rw-r--r-- | src-tauri/src/core/fabric.rs | 18 | ||||
| -rw-r--r-- | src-tauri/src/core/forge.rs | 186 | ||||
| -rw-r--r-- | src-tauri/src/core/instance.rs | 325 | ||||
| -rw-r--r-- | src-tauri/src/core/java.rs | 158 | ||||
| -rw-r--r-- | src-tauri/src/core/manifest.rs | 16 | ||||
| -rw-r--r-- | src-tauri/src/core/maven.rs | 5 | ||||
| -rw-r--r-- | src-tauri/src/core/mod.rs | 2 | ||||
| -rw-r--r-- | src-tauri/src/core/version_merge.rs | 2 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 765 | ||||
| -rw-r--r-- | src-tauri/src/utils/mod.rs | 3 | ||||
| -rw-r--r-- | src-tauri/src/utils/path.rs | 247 |
16 files changed, 2294 insertions, 209 deletions
diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs index 569df7b..8998206 100644 --- a/src-tauri/src/core/account_storage.rs +++ b/src-tauri/src/core/account_storage.rs @@ -138,6 +138,7 @@ impl AccountStorage { } } + #[allow(dead_code)] pub fn set_active_account(&self, uuid: &str) -> Result<(), String> { let mut store = self.load(); if store.accounts.iter().any(|a| a.id() == uuid) { @@ -148,6 +149,7 @@ impl AccountStorage { } } + #[allow(dead_code)] pub fn get_all_accounts(&self) -> Vec<StoredAccount> { self.load().accounts } diff --git a/src-tauri/src/core/assistant.rs b/src-tauri/src/core/assistant.rs new file mode 100644 index 0000000..9a8f7bf --- /dev/null +++ b/src-tauri/src/core/assistant.rs @@ -0,0 +1,694 @@ +use super::config::AssistantConfig; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use tauri::{Emitter, Window}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct OllamaChatRequest { + pub model: String, + pub messages: Vec<Message>, + pub stream: bool, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OllamaChatResponse { + pub model: String, + pub created_at: String, + pub message: Message, + pub done: bool, +} + +// Ollama model list response structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OllamaModelDetails { + pub format: Option<String>, + pub family: Option<String>, + pub parameter_size: Option<String>, + pub quantization_level: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OllamaModel { + pub name: String, + pub modified_at: Option<String>, + pub size: Option<u64>, + pub digest: Option<String>, + pub details: Option<OllamaModelDetails>, +} + +#[derive(Debug, Deserialize)] +pub struct OllamaTagsResponse { + pub models: Vec<OllamaModel>, +} + +// Simplified model info for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub size: Option<String>, + pub details: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct OpenAIChatRequest { + pub model: String, + pub messages: Vec<Message>, + pub stream: bool, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIChoice { + pub index: i32, + pub message: Message, + pub finish_reason: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIChatResponse { + pub id: String, + pub object: String, + pub created: i64, + pub model: String, + pub choices: Vec<OpenAIChoice>, +} + +// OpenAI models list response +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIModelData { + pub id: String, + pub object: String, + pub created: Option<i64>, + pub owned_by: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIModelsResponse { + pub object: String, + pub data: Vec<OpenAIModelData>, +} + +// Streaming response structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerationStats { + pub total_duration: u64, + pub load_duration: u64, + pub prompt_eval_count: u64, + pub prompt_eval_duration: u64, + pub eval_count: u64, + pub eval_duration: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamChunk { + pub content: String, + pub done: bool, + pub stats: Option<GenerationStats>, +} + +// Ollama streaming response (each line is a JSON object) +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OllamaStreamResponse { + pub model: Option<String>, + pub created_at: Option<String>, + pub message: Option<Message>, + pub done: bool, + pub total_duration: Option<u64>, + pub load_duration: Option<u64>, + pub prompt_eval_count: Option<u64>, + pub prompt_eval_duration: Option<u64>, + pub eval_count: Option<u64>, + pub eval_duration: Option<u64>, +} + +// OpenAI streaming response +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamDelta { + pub role: Option<String>, + pub content: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamChoice { + pub index: i32, + pub delta: OpenAIStreamDelta, + pub finish_reason: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamResponse { + pub id: Option<String>, + pub object: Option<String>, + pub created: Option<i64>, + pub model: Option<String>, + pub choices: Vec<OpenAIStreamChoice>, +} + +#[derive(Clone)] +pub struct GameAssistant { + client: reqwest::Client, + pub log_buffer: VecDeque<String>, + pub max_log_lines: usize, +} + +impl GameAssistant { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + log_buffer: VecDeque::new(), + max_log_lines: 100, + } + } + + pub fn add_log(&mut self, line: String) { + if self.log_buffer.len() >= self.max_log_lines { + self.log_buffer.pop_front(); + } + self.log_buffer.push_back(line); + } + + pub fn get_log_context(&self) -> String { + self.log_buffer + .iter() + .cloned() + .collect::<Vec<_>>() + .join("\n") + } + + pub async fn check_health(&self, config: &AssistantConfig) -> bool { + if config.llm_provider == "ollama" { + match self + .client + .get(format!("{}/api/tags", config.ollama_endpoint)) + .send() + .await + { + Ok(res) => res.status().is_success(), + Err(_) => false, + } + } else if config.llm_provider == "openai" { + // For OpenAI, just check if API key is set + config.openai_api_key.is_some() && !config.openai_api_key.as_ref().unwrap().is_empty() + } else { + false + } + } + + pub async fn chat( + &self, + mut messages: Vec<Message>, + config: &AssistantConfig, + ) -> Result<Message, String> { + // Inject system prompt and log context + if !messages.iter().any(|m| m.role == "system") { + let context = self.get_log_context(); + let mut system_content = config.system_prompt.clone(); + + // 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); + } + + // Add log context if available + if !context.is_empty() { + system_content = format!( + "{}\n\nRecent game logs:\n```\n{}\n```", + system_content, context + ); + } + + messages.insert( + 0, + Message { + role: "system".to_string(), + content: system_content, + }, + ); + } + + if config.llm_provider == "ollama" { + self.chat_ollama(messages, config).await + } else if config.llm_provider == "openai" { + self.chat_openai(messages, config).await + } else { + Err(format!("Unknown LLM provider: {}", config.llm_provider)) + } + } + + async fn chat_ollama( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + ) -> Result<Message, String> { + let request = OllamaChatRequest { + model: config.ollama_model.clone(), + messages, + stream: false, + }; + + let response = self + .client + .post(format!("{}/api/chat", config.ollama_endpoint)) + .json(&request) + .send() + .await + .map_err(|e| format!("Ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama API returned error: {}", response.status())); + } + + let chat_response: OllamaChatResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + + Ok(chat_response.message) + } + + async fn chat_openai( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + ) -> Result<Message, String> { + let api_key = config + .openai_api_key + .as_ref() + .ok_or("OpenAI API key not configured")?; + + let request = OpenAIChatRequest { + model: config.openai_model.clone(), + messages, + stream: false, + }; + + let response = self + .client + .post(format!("{}/chat/completions", config.openai_endpoint)) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("OpenAI request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error ({}): {}", status, error_text)); + } + + let chat_response: OpenAIChatResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse OpenAI response: {}", e))?; + + chat_response + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| "No response from OpenAI".to_string()) + } + + pub async fn list_ollama_models(&self, endpoint: &str) -> Result<Vec<ModelInfo>, String> { + let response = self + .client + .get(format!("{}/api/tags", endpoint)) + .send() + .await + .map_err(|e| format!("Failed to connect to Ollama: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama API error: {}", response.status())); + } + + let tags_response: OllamaTagsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + + let models: Vec<ModelInfo> = tags_response + .models + .into_iter() + .map(|m| { + let size_str = m.size.map(format_size); + let details_str = m.details.map(|d| { + let mut parts = Vec::new(); + if let Some(family) = d.family { + parts.push(family); + } + if let Some(params) = d.parameter_size { + parts.push(params); + } + if let Some(quant) = d.quantization_level { + parts.push(quant); + } + parts.join(" / ") + }); + + ModelInfo { + id: m.name.clone(), + name: m.name, + size: size_str, + details: details_str, + } + }) + .collect(); + + Ok(models) + } + + pub async fn list_openai_models( + &self, + config: &AssistantConfig, + ) -> Result<Vec<ModelInfo>, String> { + let api_key = config + .openai_api_key + .as_ref() + .ok_or("OpenAI API key not configured")?; + + let response = self + .client + .get(format!("{}/models", config.openai_endpoint)) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + .map_err(|e| format!("Failed to connect to OpenAI: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error ({}): {}", status, error_text)); + } + + let models_response: OpenAIModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse OpenAI response: {}", e))?; + + // Filter to only show chat models (gpt-*) + let models: Vec<ModelInfo> = models_response + .data + .into_iter() + .filter(|m| { + m.id.starts_with("gpt-") || m.id.starts_with("o1") || m.id.contains("turbo") + }) + .map(|m| ModelInfo { + id: m.id.clone(), + name: m.id, + size: None, + details: m.owned_by, + }) + .collect(); + + Ok(models) + } + + // Streaming chat methods + pub async fn chat_stream( + &self, + mut messages: Vec<Message>, + config: &AssistantConfig, + window: &Window, + ) -> Result<String, String> { + // Inject system prompt and log context + if !messages.iter().any(|m| m.role == "system") { + let context = self.get_log_context(); + 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); + } + + if !context.is_empty() { + system_content = format!( + "{}\n\nRecent game logs:\n```\n{}\n```", + system_content, context + ); + } + + messages.insert( + 0, + Message { + role: "system".to_string(), + content: system_content, + }, + ); + } + + if config.llm_provider == "ollama" { + self.chat_stream_ollama(messages, config, window).await + } else if config.llm_provider == "openai" { + self.chat_stream_openai(messages, config, window).await + } else { + Err(format!("Unknown LLM provider: {}", config.llm_provider)) + } + } + + async fn chat_stream_ollama( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + window: &Window, + ) -> Result<String, String> { + let request = OllamaChatRequest { + model: config.ollama_model.clone(), + messages, + stream: true, + }; + + let response = self + .client + .post(format!("{}/api/chat", config.ollama_endpoint)) + .json(&request) + .send() + .await + .map_err(|e| format!("Ollama request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama API returned error: {}", response.status())); + } + + let mut full_content = String::new(); + let mut stream = response.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + let text = String::from_utf8_lossy(&chunk); + // Ollama returns newline-delimited JSON + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(stream_response) = + serde_json::from_str::<OllamaStreamResponse>(line) + { + if let Some(msg) = stream_response.message { + full_content.push_str(&msg.content); + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: msg.content, + done: stream_response.done, + stats: None, + }, + ); + } + if stream_response.done { + let stats = if let ( + Some(total), + Some(load), + Some(prompt_cnt), + Some(prompt_dur), + Some(eval_cnt), + Some(eval_dur), + ) = ( + stream_response.total_duration, + stream_response.load_duration, + stream_response.prompt_eval_count, + stream_response.prompt_eval_duration, + stream_response.eval_count, + stream_response.eval_duration, + ) { + Some(GenerationStats { + total_duration: total, + load_duration: load, + prompt_eval_count: prompt_cnt, + prompt_eval_duration: prompt_dur, + eval_count: eval_cnt, + eval_duration: eval_dur, + }) + } else { + None + }; + + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: String::new(), + done: true, + stats, + }, + ); + } + } + } + } + Err(e) => { + return Err(format!("Stream error: {}", e)); + } + } + } + + Ok(full_content) + } + + async fn chat_stream_openai( + &self, + messages: Vec<Message>, + config: &AssistantConfig, + window: &Window, + ) -> Result<String, String> { + let api_key = config + .openai_api_key + .as_ref() + .ok_or("OpenAI API key not configured")?; + + let request = OpenAIChatRequest { + model: config.openai_model.clone(), + messages, + stream: true, + }; + + let response = self + .client + .post(format!("{}/chat/completions", config.openai_endpoint)) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("OpenAI request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API error ({}): {}", status, error_text)); + } + + let mut full_content = String::new(); + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete lines + while let Some(pos) = buffer.find('\n') { + let line = buffer[..pos].to_string(); + buffer = buffer[pos + 1..].to_string(); + + let line = line.trim(); + if line.is_empty() || line == "data: [DONE]" { + if line == "data: [DONE]" { + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: String::new(), + done: true, + stats: None, + }, + ); + } + continue; + } + + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(stream_response) = + serde_json::from_str::<OpenAIStreamResponse>(data) + { + if let Some(choice) = stream_response.choices.first() { + if let Some(content) = &choice.delta.content { + full_content.push_str(content); + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: content.clone(), + done: false, + stats: None, + }, + ); + } + if choice.finish_reason.is_some() { + let _ = window.emit( + "assistant-stream", + StreamChunk { + content: String::new(), + done: true, + stats: None, + }, + ); + } + } + } + } + } + } + Err(e) => { + return Err(format!("Stream error: {}", e)); + } + } + } + + Ok(full_content) + } +} + +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +pub struct AssistantState { + pub assistant: Arc<Mutex<GameAssistant>>, +} + +impl AssistantState { + pub fn new() -> Self { + Self { + assistant: Arc::new(Mutex::new(GameAssistant::new())), + } + } +} diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index 5f01a58..ac5904c 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -136,6 +136,7 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenRespons } /// Check if a Microsoft account token is expired or about to expire +#[allow(dead_code)] pub fn is_token_expired(expires_at: i64) -> bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -430,17 +431,21 @@ pub async fn fetch_profile(mc_access_token: &str) -> Result<MinecraftProfile, St // 7. Check Game Ownership #[derive(Debug, Serialize, Deserialize)] +#[allow(dead_code)] pub struct Entitlement { pub name: String, } #[derive(Debug, Serialize, Deserialize)] +#[allow(dead_code)] pub struct EntitlementsResponse { pub items: Vec<Entitlement>, pub signature: Option<String>, - pub keyId: Option<String>, + #[serde(rename = "keyId")] + pub key_id: Option<String>, } +#[allow(dead_code)] pub async fn check_ownership(mc_access_token: &str) -> Result<bool, String> { let client = get_client(); let url = "https://api.minecraftservices.com/entitlements/mcstore"; diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs index 43c8145..4c4acad 100644 --- a/src-tauri/src/core/config.rs +++ b/src-tauri/src/core/config.rs @@ -6,6 +6,44 @@ use tauri::{AppHandle, Manager}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] +pub struct AssistantConfig { + pub enabled: bool, + pub llm_provider: String, // "ollama" or "openai" + // Ollama settings + pub ollama_endpoint: String, + pub ollama_model: String, + // OpenAI settings + pub openai_api_key: Option<String>, + pub openai_endpoint: String, + pub openai_model: String, + // Common settings + pub system_prompt: String, + pub response_language: String, + // TTS settings + pub tts_enabled: bool, + pub tts_provider: String, // "disabled", "piper", "edge" +} + +impl Default for AssistantConfig { + fn default() -> Self { + Self { + enabled: true, + llm_provider: "ollama".to_string(), + ollama_endpoint: "http://localhost:11434".to_string(), + ollama_model: "llama3".to_string(), + openai_api_key: None, + openai_endpoint: "https://api.openai.com/v1".to_string(), + openai_model: "gpt-3.5-turbo".to_string(), + system_prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.".to_string(), + response_language: "auto".to_string(), + tts_enabled: false, + tts_provider: "disabled".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct LauncherConfig { pub min_memory: u32, // in MB pub max_memory: u32, // in MB @@ -20,6 +58,7 @@ pub struct LauncherConfig { pub theme: String, pub log_upload_service: String, // "paste.rs" or "pastebin.com" pub pastebin_api_key: Option<String>, + pub assistant: AssistantConfig, } impl Default for LauncherConfig { @@ -38,6 +77,7 @@ impl Default for LauncherConfig { theme: "dark".to_string(), log_upload_service: "paste.rs".to_string(), pastebin_api_key: None, + assistant: AssistantConfig::default(), } } } diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index bf6334f..9c6b7f0 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -111,9 +111,8 @@ impl DownloadQueue { /// Remove a completed or cancelled download pub fn remove(&mut self, major_version: u32, image_type: &str) { - self.pending_downloads.retain(|d| { - !(d.major_version == major_version && d.image_type == image_type) - }); + self.pending_downloads + .retain(|d| !(d.major_version == major_version && d.image_type == image_type)); } } @@ -174,7 +173,8 @@ pub async fn download_with_resume( let content = tokio::fs::read_to_string(&meta_path) .await .map_err(|e| e.to_string())?; - serde_json::from_str(&content).unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum)) + serde_json::from_str(&content) + .unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum)) } else { create_new_metadata(url, &file_name, total_size, checksum) }; @@ -191,6 +191,7 @@ pub async fn download_with_resume( .create(true) .write(true) .read(true) + .truncate(false) .open(&part_path) .await .map_err(|e| format!("Failed to open part file: {}", e))?; @@ -220,9 +221,7 @@ pub async fn download_with_resume( let segment_end = segment.end; let app_handle = app_handle.clone(); let file_name = file_name.clone(); - let total_size = total_size; let last_progress_bytes = last_progress_bytes.clone(); - let start_time = start_time.clone(); let handle = tokio::spawn(async move { let _permit = semaphore.acquire().await.unwrap(); @@ -240,7 +239,9 @@ pub async fn download_with_resume( .await .map_err(|e| format!("Request failed: {}", e))?; - if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + if !response.status().is_success() + && response.status() != reqwest::StatusCode::PARTIAL_CONTENT + { return Err(format!("Server returned error: {}", response.status())); } @@ -319,7 +320,8 @@ pub async fn download_with_resume( if e.contains("cancelled") { // Save progress for resume metadata.downloaded_bytes = progress.load(Ordering::Relaxed); - let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; + let meta_content = + serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; tokio::fs::write(&meta_path, meta_content).await.ok(); return Err(e); } @@ -357,7 +359,7 @@ pub async fn download_with_resume( let data = tokio::fs::read(&part_path) .await .map_err(|e| format!("Failed to read file for verification: {}", e))?; - + if !verify_checksum(&data, Some(expected), None) { // Checksum failed, delete files and retry tokio::fs::remove_file(&part_path).await.ok(); @@ -378,7 +380,12 @@ pub async fn download_with_resume( } /// Create new download metadata with segments -fn create_new_metadata(url: &str, file_name: &str, total_size: u64, checksum: Option<&str>) -> DownloadMetadata { +fn create_new_metadata( + url: &str, + file_name: &str, + total_size: u64, + checksum: Option<&str>, +) -> DownloadMetadata { let segment_count = get_segment_count(total_size); let segment_size = total_size / segment_count as u64; let mut segments = Vec::new(); @@ -559,11 +566,7 @@ pub async fn download_files( if task.sha256.is_some() || task.sha1.is_some() { if let Ok(data) = tokio::fs::read(&task.path).await { - if verify_checksum( - &data, - task.sha256.as_deref(), - task.sha1.as_deref(), - ) { + if verify_checksum(&data, task.sha256.as_deref(), task.sha1.as_deref()) { // Already valid, skip download let skipped_size = tokio::fs::metadata(&task.path) .await diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs index 3e4d50d..32790c7 100644 --- a/src-tauri/src/core/fabric.rs +++ b/src-tauri/src/core/fabric.rs @@ -67,13 +67,11 @@ pub struct FabricLibrary { #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum FabricMainClass { - Structured { - client: String, - server: String, - }, + Structured { client: String, server: String }, Simple(String), } +#[allow(dead_code)] impl FabricMainClass { pub fn client(&self) -> &str { match self { @@ -81,7 +79,7 @@ impl FabricMainClass { FabricMainClass::Simple(s) => s, } } - + pub fn server(&self) -> &str { match self { FabricMainClass::Structured { server, .. } => server, @@ -200,7 +198,7 @@ pub fn generate_version_id(game_version: &str, loader_version: &str) -> String { /// # Returns /// Information about the installed version. pub async fn install_fabric( - game_dir: &PathBuf, + game_dir: &std::path::Path, game_version: &str, loader_version: &str, ) -> Result<InstalledFabricVersion, Box<dyn Error + Send + Sync>> { @@ -240,7 +238,11 @@ pub async fn install_fabric( /// /// # Returns /// `true` if the version JSON exists, `false` otherwise. -pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_version: &str) -> bool { +pub fn is_fabric_installed( + game_dir: &std::path::Path, + game_version: &str, + loader_version: &str, +) -> bool { let version_id = generate_version_id(game_version, loader_version); let json_path = game_dir .join("versions") @@ -257,7 +259,7 @@ pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_versio /// # Returns /// A list of installed Fabric version IDs. pub async fn list_installed_fabric_versions( - game_dir: &PathBuf, + game_dir: &std::path::Path, ) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> { let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs index e69b296..65bf413 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -9,11 +9,14 @@ use serde::{Deserialize, Serialize}; use std::error::Error; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::PathBuf; const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; 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)] @@ -43,6 +46,7 @@ pub struct InstalledForgeVersion { /// Forge installer manifest structure (from version.json inside installer JAR) #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct ForgeInstallerManifest { id: Option<String>, #[serde(rename = "inheritsFrom")] @@ -177,36 +181,102 @@ pub fn generate_version_id(game_version: &str, forge_version: &str) -> String { format!("{}-forge-{}", game_version, forge_version) } +/// Try to download the Forge installer from multiple possible URL formats. +/// This is necessary because older Forge versions use different URL patterns. +async fn try_download_forge_installer( + game_version: &str, + forge_version: &str, +) -> Result<bytes::Bytes, Box<dyn Error + Send + Sync>> { + let forge_full = format!("{}-{}", game_version, forge_version); + // For older versions (like 1.7.10), the URL needs an additional -{game_version} suffix + let forge_full_with_suffix = format!("{}-{}", forge_full, game_version); + + // Try different URL formats for different Forge versions + // Order matters: try most common formats first, then fallback to alternatives + let url_patterns = vec![ + // Standard Maven format (for modern versions): forge/{game_version}-{forge_version}/forge-{game_version}-{forge_version}-installer.jar + format!( + "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_MAVEN_URL, forge_full, forge_full + ), + // Old version format with suffix (for versions like 1.7.10): forge/{game_version}-{forge_version}-{game_version}/forge-{game_version}-{forge_version}-{game_version}-installer.jar + // This is the correct format for 1.7.10 and similar old versions + format!( + "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_MAVEN_URL, forge_full_with_suffix, forge_full_with_suffix + ), + // Files.minecraftforge.net format with suffix (for old versions like 1.7.10) + format!( + "{}maven/net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_FILES_URL, forge_full_with_suffix, forge_full_with_suffix + ), + // Files.minecraftforge.net standard format (for older versions) + format!( + "{}maven/net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_FILES_URL, forge_full, forge_full + ), + // Alternative Maven format + format!( + "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", + FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version + ), + // Alternative files format + format!( + "{}maven/net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", + FORGE_FILES_URL, game_version, forge_version, game_version, forge_version + ), + ]; + + let mut last_error = None; + for url in url_patterns { + println!("Trying Forge installer URL: {}", url); + match reqwest::get(&url).await { + Ok(response) => { + if response.status().is_success() { + match response.bytes().await { + Ok(bytes) => { + println!("Successfully downloaded Forge installer from: {}", url); + return Ok(bytes); + } + Err(e) => { + last_error = Some(format!("Failed to read response body: {}", e)); + continue; + } + } + } else { + last_error = Some(format!("HTTP {}: {}", response.status(), url)); + continue; + } + } + Err(e) => { + last_error = Some(format!("Request failed: {}", e)); + continue; + } + } + } + + Err(format!( + "Failed to download Forge installer from any URL. Last error: {}", + last_error.unwrap_or_else(|| "Unknown error".to_string()) + ) + .into()) +} + /// Fetch the Forge installer manifest to get the library list async fn fetch_forge_installer_manifest( game_version: &str, forge_version: &str, ) -> Result<ForgeInstallerManifest, Box<dyn Error + Send + Sync>> { - let forge_full = format!("{}-{}", game_version, forge_version); - - // Download the installer JAR to extract version.json - let installer_url = format!( - "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", - FORGE_MAVEN_URL, forge_full, forge_full - ); - - println!("Fetching Forge installer from: {}", installer_url); - - let response = reqwest::get(&installer_url).await?; - if !response.status().is_success() { - return Err(format!("Failed to download Forge installer: {}", response.status()).into()); - } - - let bytes = response.bytes().await?; - + let bytes = try_download_forge_installer(game_version, forge_version).await?; + // Extract version.json from the JAR (which is a ZIP file) let cursor = std::io::Cursor::new(bytes.as_ref()); let mut archive = zip::ZipArchive::new(cursor)?; - + // Look for version.json in the archive let version_json = archive.by_name("version.json")?; let manifest: ForgeInstallerManifest = serde_json::from_reader(version_json)?; - + Ok(manifest) } @@ -224,7 +294,7 @@ async fn fetch_forge_installer_manifest( /// # Returns /// Information about the installed version. pub async fn install_forge( - game_dir: &PathBuf, + game_dir: &std::path::Path, game_version: &str, forge_version: &str, ) -> Result<InstalledForgeVersion, Box<dyn Error + Send + Sync>> { @@ -234,7 +304,8 @@ pub async fn install_forge( let manifest = fetch_forge_installer_manifest(game_version, forge_version).await?; // Create version JSON from the manifest - let version_json = create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?; + let version_json = + create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?; // Create the version directory let version_dir = game_dir.join("versions").join(&version_id); @@ -270,47 +341,38 @@ pub async fn run_forge_installer( forge_version: &str, java_path: &PathBuf, ) -> Result<(), Box<dyn Error + Send + Sync>> { - // Download the installer JAR - let installer_url = format!( - "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", - FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version - ); - let installer_path = game_dir.join("forge-installer.jar"); - - // Download installer - let client = reqwest::Client::new(); - let response = client.get(&installer_url).send().await?; - - if !response.status().is_success() { - return Err(format!("Failed to download Forge installer: {}", response.status()).into()); - } - - let bytes = response.bytes().await?; + + // Download installer using the same multi-URL approach + let bytes = try_download_forge_installer(game_version, forge_version).await?; tokio::fs::write(&installer_path, &bytes).await?; - + // Run the installer in headless mode // The installer accepts --installClient <path> to install to a specific directory - let output = tokio::process::Command::new(java_path) - .arg("-jar") + let mut cmd = tokio::process::Command::new(java_path); + cmd.arg("-jar") .arg(&installer_path) .arg("--installClient") - .arg(game_dir) - .output() - .await?; - + .arg(game_dir); + + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + + let output = cmd.output().await?; + // Clean up installer let _ = tokio::fs::remove_file(&installer_path).await; - + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); return Err(format!( "Forge installer failed:\nstdout: {}\nstderr: {}", stdout, stderr - ).into()); + ) + .into()); } - + Ok(()) } @@ -332,13 +394,14 @@ fn create_forge_version_json_from_manifest( }); // Convert libraries to JSON format, preserving download info - let lib_entries: Vec<serde_json::Value> = manifest.libraries + let lib_entries: Vec<serde_json::Value> = manifest + .libraries .iter() .map(|lib| { let mut entry = serde_json::json!({ "name": lib.name }); - + // Add URL if present if let Some(url) = &lib.url { entry["url"] = serde_json::Value::String(url.clone()); @@ -346,19 +409,22 @@ fn create_forge_version_json_from_manifest( // Default to Forge Maven for Forge libraries entry["url"] = serde_json::Value::String(FORGE_MAVEN_URL.to_string()); } - + // Add downloads if present if let Some(downloads) = &lib.downloads { if let Some(artifact) = &downloads.artifact { let mut artifact_json = serde_json::Map::new(); if let Some(path) = &artifact.path { - artifact_json.insert("path".to_string(), serde_json::Value::String(path.clone())); + artifact_json + .insert("path".to_string(), serde_json::Value::String(path.clone())); } if let Some(url) = &artifact.url { - artifact_json.insert("url".to_string(), serde_json::Value::String(url.clone())); + artifact_json + .insert("url".to_string(), serde_json::Value::String(url.clone())); } if let Some(sha1) = &artifact.sha1 { - artifact_json.insert("sha1".to_string(), serde_json::Value::String(sha1.clone())); + artifact_json + .insert("sha1".to_string(), serde_json::Value::String(sha1.clone())); } if !artifact_json.is_empty() { entry["downloads"] = serde_json::json!({ @@ -367,7 +433,7 @@ fn create_forge_version_json_from_manifest( } } } - + entry }) .collect(); @@ -377,7 +443,7 @@ fn create_forge_version_json_from_manifest( "game": [], "jvm": [] }); - + if let Some(args) = &manifest.arguments { if let Some(game_args) = &args.game { arguments["game"] = serde_json::Value::Array(game_args.clone()); @@ -461,7 +527,12 @@ fn is_modern_forge(game_version: &str) -> bool { /// /// # Returns /// `true` if the version JSON exists, `false` otherwise. -pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: &str) -> bool { +#[allow(dead_code)] +pub fn is_forge_installed( + game_dir: &std::path::Path, + game_version: &str, + forge_version: &str, +) -> bool { let version_id = generate_version_id(game_version, forge_version); let json_path = game_dir .join("versions") @@ -477,8 +548,9 @@ pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: /// /// # Returns /// A list of installed Forge version IDs. +#[allow(dead_code)] pub async fn list_installed_forge_versions( - game_dir: &PathBuf, + game_dir: &std::path::Path, ) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> { let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs new file mode 100644 index 0000000..90ec34e --- /dev/null +++ b/src-tauri/src/core/instance.rs @@ -0,0 +1,325 @@ +//! Instance/Profile management module. +//! +//! This module provides functionality to: +//! - Create and manage multiple isolated game instances +//! - Each instance has its own versions, libraries, assets, mods, and saves +//! - Support for instance switching and isolation + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// Represents a game instance/profile +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instance { + pub id: String, // 唯一标识符(UUID) + pub name: String, // 显示名称 + pub game_dir: PathBuf, // 游戏目录路径 + pub version_id: Option<String>, // 当前选择的版本ID + pub created_at: i64, // 创建时间戳 + pub last_played: Option<i64>, // 最后游玩时间 + pub icon_path: Option<String>, // 图标路径(可选) + pub notes: Option<String>, // 备注(可选) + pub mod_loader: Option<String>, // 模组加载器类型:"fabric", "forge", "vanilla" + pub mod_loader_version: Option<String>, // 模组加载器版本 +} + +/// Configuration for all instances +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct InstanceConfig { + pub instances: Vec<Instance>, + pub active_instance_id: Option<String>, // 当前活动的实例ID +} + +/// State management for instances +pub struct InstanceState { + pub instances: Mutex<InstanceConfig>, + pub file_path: PathBuf, +} + +impl InstanceState { + /// Create a new InstanceState + pub fn new(app_handle: &AppHandle) -> Self { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let file_path = app_dir.join("instances.json"); + + let config = if file_path.exists() { + let content = fs::read_to_string(&file_path).unwrap_or_default(); + serde_json::from_str(&content).unwrap_or_else(|_| InstanceConfig::default()) + } else { + InstanceConfig::default() + }; + + Self { + instances: Mutex::new(config), + file_path, + } + } + + /// Save the instance configuration to disk + pub fn save(&self) -> Result<(), String> { + let config = self.instances.lock().unwrap(); + let content = serde_json::to_string_pretty(&*config).map_err(|e| e.to_string())?; + fs::create_dir_all(self.file_path.parent().unwrap()).map_err(|e| e.to_string())?; + fs::write(&self.file_path, content).map_err(|e| e.to_string())?; + Ok(()) + } + + /// Create a new instance + pub fn create_instance( + &self, + name: String, + app_handle: &AppHandle, + ) -> Result<Instance, String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_dir = app_dir.join("instances").join(&instance_id); + let game_dir = instance_dir.clone(); + + // Create instance directory structure + fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("versions")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("libraries")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("assets")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("mods")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("config")).map_err(|e| e.to_string())?; + fs::create_dir_all(instance_dir.join("saves")).map_err(|e| e.to_string())?; + + let instance = Instance { + id: instance_id.clone(), + name, + game_dir, + version_id: None, + created_at: chrono::Utc::now().timestamp(), + last_played: None, + icon_path: None, + notes: None, + mod_loader: Some("vanilla".to_string()), + mod_loader_version: None, + }; + + let mut config = self.instances.lock().unwrap(); + config.instances.push(instance.clone()); + + // If this is the first instance, set it as active + if config.active_instance_id.is_none() { + config.active_instance_id = Some(instance_id); + } + + drop(config); + self.save()?; + + Ok(instance) + } + + /// Delete an instance + pub fn delete_instance(&self, id: &str) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + // Find the instance + let instance_index = config + .instances + .iter() + .position(|i| i.id == id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + let instance = config.instances[instance_index].clone(); + + // Remove from list + config.instances.remove(instance_index); + + // If this was the active instance, clear or set another as active + if config.active_instance_id.as_ref() == Some(&id.to_string()) { + config.active_instance_id = config.instances.first().map(|i| i.id.clone()); + } + + drop(config); + self.save()?; + + // Delete the instance directory + if instance.game_dir.exists() { + fs::remove_dir_all(&instance.game_dir) + .map_err(|e| format!("Failed to delete instance directory: {}", e))?; + } + + Ok(()) + } + + /// Update an instance + pub fn update_instance(&self, instance: Instance) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + let index = config + .instances + .iter() + .position(|i| i.id == instance.id) + .ok_or_else(|| format!("Instance {} not found", instance.id))?; + + config.instances[index] = instance; + drop(config); + self.save()?; + + Ok(()) + } + + /// Get an instance by ID + pub fn get_instance(&self, id: &str) -> Option<Instance> { + let config = self.instances.lock().unwrap(); + config.instances.iter().find(|i| i.id == id).cloned() + } + + /// List all instances + pub fn list_instances(&self) -> Vec<Instance> { + let config = self.instances.lock().unwrap(); + config.instances.clone() + } + + /// Set the active instance + pub fn set_active_instance(&self, id: &str) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + + // Verify the instance exists + if !config.instances.iter().any(|i| i.id == id) { + return Err(format!("Instance {} not found", id)); + } + + config.active_instance_id = Some(id.to_string()); + drop(config); + self.save()?; + + Ok(()) + } + + /// Get the active instance + pub fn get_active_instance(&self) -> Option<Instance> { + let config = self.instances.lock().unwrap(); + config + .active_instance_id + .as_ref() + .and_then(|id| config.instances.iter().find(|i| i.id == *id)) + .cloned() + } + + /// Get the game directory for an instance + pub fn get_instance_game_dir(&self, id: &str) -> Option<PathBuf> { + self.get_instance(id).map(|i| i.game_dir) + } + + /// Duplicate an instance + pub fn duplicate_instance( + &self, + id: &str, + new_name: String, + app_handle: &AppHandle, + ) -> Result<Instance, String> { + let source_instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + // Create new instance + let mut new_instance = self.create_instance(new_name, app_handle)?; + + // Copy instance properties + new_instance.version_id = source_instance.version_id.clone(); + new_instance.mod_loader = source_instance.mod_loader.clone(); + new_instance.mod_loader_version = source_instance.mod_loader_version.clone(); + new_instance.notes = source_instance.notes.clone(); + + // Copy directory contents + if source_instance.game_dir.exists() { + copy_dir_all(&source_instance.game_dir, &new_instance.game_dir) + .map_err(|e| format!("Failed to copy instance directory: {}", e))?; + } + + self.update_instance(new_instance.clone())?; + + Ok(new_instance) + } +} + +/// Copy a directory recursively +fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), std::io::Error> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?; + } else { + fs::copy(entry.path(), dst.join(entry.file_name()))?; + } + } + Ok(()) +} + +/// Migrate legacy data to instance system +pub fn migrate_legacy_data( + app_handle: &AppHandle, + instance_state: &InstanceState, +) -> Result<(), String> { + let app_dir = app_handle.path().app_data_dir().unwrap(); + let old_versions_dir = app_dir.join("versions"); + let old_libraries_dir = app_dir.join("libraries"); + let old_assets_dir = app_dir.join("assets"); + + // Check if legacy data exists + let has_legacy_data = + old_versions_dir.exists() || old_libraries_dir.exists() || old_assets_dir.exists(); + + if !has_legacy_data { + return Ok(()); // No legacy data to migrate + } + + // Check if instances already exist + let config = instance_state.instances.lock().unwrap(); + if !config.instances.is_empty() { + drop(config); + return Ok(()); // Already have instances, skip migration + } + drop(config); + + // Create default instance + let default_instance = instance_state + .create_instance("Default".to_string(), app_handle) + .map_err(|e| format!("Failed to create default instance: {}", e))?; + + let new_versions_dir = default_instance.game_dir.join("versions"); + let new_libraries_dir = default_instance.game_dir.join("libraries"); + let new_assets_dir = default_instance.game_dir.join("assets"); + + // Move legacy data + if old_versions_dir.exists() { + if new_versions_dir.exists() { + // Merge directories + copy_dir_all(&old_versions_dir, &new_versions_dir) + .map_err(|e| format!("Failed to migrate versions: {}", e))?; + } else { + fs::rename(&old_versions_dir, &new_versions_dir) + .map_err(|e| format!("Failed to migrate versions: {}", e))?; + } + } + + if old_libraries_dir.exists() { + if new_libraries_dir.exists() { + copy_dir_all(&old_libraries_dir, &new_libraries_dir) + .map_err(|e| format!("Failed to migrate libraries: {}", e))?; + } else { + fs::rename(&old_libraries_dir, &new_libraries_dir) + .map_err(|e| format!("Failed to migrate libraries: {}", e))?; + } + } + + if old_assets_dir.exists() { + if new_assets_dir.exists() { + copy_dir_all(&old_assets_dir, &new_assets_dir) + .map_err(|e| format!("Failed to migrate assets: {}", e))?; + } else { + fs::rename(&old_assets_dir, &new_assets_dir) + .map_err(|e| format!("Failed to migrate assets: {}", e))?; + } + } + + Ok(()) +} diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 8341138..0c7769b 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -1,16 +1,30 @@ 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, JavaDownloadProgress, DownloadQueue, PendingJavaDownload}; +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, @@ -58,7 +72,7 @@ pub struct JavaReleaseInfo { } /// Java catalog containing all available versions -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct JavaCatalog { pub releases: Vec<JavaReleaseInfo>, pub available_major_versions: Vec<u32>, @@ -66,17 +80,6 @@ pub struct JavaCatalog { pub cached_at: u64, } -impl Default for JavaCatalog { - fn default() -> Self { - Self { - releases: Vec::new(), - available_major_versions: Vec::new(), - lts_versions: Vec::new(), - cached_at: 0, - } - } -} - /// Adoptium `/v3/assets/latest/{version}/hotspot` API response structures #[derive(Debug, Clone, Deserialize)] pub struct AdoptiumAsset { @@ -86,6 +89,7 @@ pub struct AdoptiumAsset { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AdoptiumBinary { pub os: String, pub architecture: String, @@ -104,6 +108,7 @@ pub struct AdoptiumPackage { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AdoptiumVersionData { pub major: u32, pub minor: u32, @@ -114,6 +119,7 @@ pub struct AdoptiumVersionData { /// 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>, @@ -231,6 +237,7 @@ pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Resu } /// 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() { @@ -240,7 +247,10 @@ pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> { } /// 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> { +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) { @@ -294,7 +304,9 @@ pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) -> 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_lts: available + .available_lts_releases + .contains(major_version), is_available: true, architecture: asset.binary.architecture.clone(), }); @@ -547,7 +559,11 @@ pub async fn download_and_install_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") + java_home + .join("Contents") + .join("Home") + .join("bin") + .join("java") } else if cfg!(windows) { java_home.join("bin").join("java.exe") } else { @@ -561,6 +577,10 @@ pub async fn download_and_install_java( )); } + // 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())?; @@ -634,16 +654,22 @@ fn get_java_candidates() -> Vec<PathBuf> { let mut candidates = Vec::new(); // Check PATH first - if let Ok(output) = Command::new(if cfg!(windows) { "where" } else { "which" }) - .arg("java") - .output() - { + 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() { - candidates.push(path); + // 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); } } } @@ -786,7 +812,12 @@ fn get_java_candidates() -> Vec<PathBuf> { /// Check a specific Java installation and get its version info fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> { - let output = Command::new(path).arg("-version").output().ok()?; + 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); @@ -850,6 +881,64 @@ pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaI } } +/// 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(); @@ -885,14 +974,15 @@ pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstalla installations } -//// Find the java executable in a directory using a limited-depth search +/// 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() { - return Some(direct_bin); + let resolved = std::fs::canonicalize(&direct_bin).unwrap_or(direct_bin); + return Some(strip_unc_prefix(resolved)); } // macOS: Contents/Home/bin/java @@ -912,13 +1002,18 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { // Try direct bin path let nested_bin = path.join("bin").join(bin_name); if nested_bin.exists() { - return Some(nested_bin); + 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); + let macos_nested = path + .join("Contents") + .join("Home") + .join("bin") + .join(bin_name); if macos_nested.exists() { return Some(macos_nested); } @@ -931,7 +1026,9 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { } /// Resume pending Java downloads from queue -pub async fn resume_pending_downloads(app_handle: &AppHandle) -> Result<Vec<JavaInstallation>, String> { +pub async fn resume_pending_downloads( + app_handle: &AppHandle, +) -> Result<Vec<JavaInstallation>, String> { let queue = DownloadQueue::load(app_handle); let mut installed = Vec::new(); @@ -978,7 +1075,12 @@ pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload> } /// Clear a specific pending download -pub fn clear_pending_download(app_handle: &AppHandle, major_version: u32, image_type: &str) -> Result<(), String> { +#[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/manifest.rs b/src-tauri/src/core/manifest.rs index bae87c9..637b935 100644 --- a/src-tauri/src/core/manifest.rs +++ b/src-tauri/src/core/manifest.rs @@ -25,6 +25,13 @@ pub struct Version { pub time: String, #[serde(rename = "releaseTime")] pub release_time: String, + /// Java version requirement (major version number) + /// This is populated from the version JSON file if the version is installed locally + #[serde(skip_serializing_if = "Option::is_none")] + pub java_version: Option<u64>, + /// Whether this version is installed locally + #[serde(rename = "isInstalled", skip_serializing_if = "Option::is_none")] + pub is_installed: Option<bool>, } pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + Send + Sync>> { @@ -45,7 +52,7 @@ pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + /// # Returns /// The parsed `GameVersion` if found, or an error if not found. pub async fn load_local_version( - game_dir: &PathBuf, + game_dir: &std::path::Path, version_id: &str, ) -> Result<GameVersion, Box<dyn Error + Send + Sync>> { let json_path = game_dir @@ -102,7 +109,7 @@ pub async fn fetch_vanilla_version( /// # Returns /// A fully resolved `GameVersion` ready for launching. pub async fn load_version( - game_dir: &PathBuf, + game_dir: &std::path::Path, version_id: &str, ) -> Result<GameVersion, Box<dyn Error + Send + Sync>> { // Try loading from local first @@ -138,7 +145,7 @@ pub async fn load_version( /// # Returns /// The path where the JSON was saved. pub async fn save_local_version( - game_dir: &PathBuf, + game_dir: &std::path::Path, version: &GameVersion, ) -> Result<PathBuf, Box<dyn Error + Send + Sync>> { let version_dir = game_dir.join("versions").join(&version.id); @@ -158,8 +165,9 @@ pub async fn save_local_version( /// /// # Returns /// A list of version IDs found in the versions directory. +#[allow(dead_code)] pub async fn list_local_versions( - game_dir: &PathBuf, + game_dir: &std::path::Path, ) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> { let versions_dir = game_dir.join("versions"); let mut versions = Vec::new(); diff --git a/src-tauri/src/core/maven.rs b/src-tauri/src/core/maven.rs index 8c89768..760e68b 100644 --- a/src-tauri/src/core/maven.rs +++ b/src-tauri/src/core/maven.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; /// Known Maven repository URLs for mod loaders +#[allow(dead_code)] pub const MAVEN_CENTRAL: &str = "https://repo1.maven.org/maven2/"; pub const FABRIC_MAVEN: &str = "https://maven.fabricmc.net/"; pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net/"; @@ -114,7 +115,7 @@ impl MavenCoordinate { /// /// # Returns /// The full path where the library should be stored - pub fn to_local_path(&self, libraries_dir: &PathBuf) -> PathBuf { + pub fn to_local_path(&self, libraries_dir: &std::path::Path) -> PathBuf { let rel_path = self.to_path(); libraries_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR)) } @@ -183,7 +184,7 @@ pub fn resolve_library_url( /// /// # Returns /// The path where the library should be stored -pub fn get_library_path(name: &str, libraries_dir: &PathBuf) -> Option<PathBuf> { +pub fn get_library_path(name: &str, libraries_dir: &std::path::Path) -> Option<PathBuf> { let coord = MavenCoordinate::parse(name)?; Some(coord.to_local_path(libraries_dir)) } diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 3c09a76..dcbd47a 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,10 +1,12 @@ pub mod account_storage; +pub mod assistant; pub mod auth; pub mod config; pub mod downloader; pub mod fabric; pub mod forge; pub mod game_version; +pub mod instance; pub mod java; pub mod manifest; pub mod maven; diff --git a/src-tauri/src/core/version_merge.rs b/src-tauri/src/core/version_merge.rs index fe6b3cd..098d271 100644 --- a/src-tauri/src/core/version_merge.rs +++ b/src-tauri/src/core/version_merge.rs @@ -101,6 +101,7 @@ fn merge_json_arrays( /// /// # Returns /// `true` if the version has an `inheritsFrom` field that needs resolution. +#[allow(dead_code)] pub fn needs_inheritance_resolution(version: &GameVersion) -> bool { version.inherits_from.is_some() } @@ -116,6 +117,7 @@ pub fn needs_inheritance_resolution(version: &GameVersion) -> bool { /// /// # Returns /// A fully merged `GameVersion` with all inheritance resolved. +#[allow(dead_code)] pub async fn resolve_inheritance<F, Fut>( version: GameVersion, version_loader: F, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3671166..2871b03 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,12 +1,12 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use serde::Serialize; use std::process::Stdio; use std::sync::Mutex; use tauri::{Emitter, Manager, State, Window}; // Added Emitter use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; -use serde::Serialize; // Added Serialize +use tokio::process::Command; // Added Serialize #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -67,11 +67,17 @@ async fn start_game( window: Window, auth_state: State<'_, core::auth::AccountState>, config_state: State<'_, core::config::ConfigState>, + assistant_state: State<'_, core::assistant::AssistantState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<String, String> { emit_log!( window, - format!("Starting game launch for version: {}", version_id) + format!( + "Starting game launch for version: {} in instance: {}", + version_id, instance_id + ) ); // Check for active account @@ -83,14 +89,7 @@ async fn start_game( .clone() .ok_or("No active account found. Please login first.")?; - let account_type = match &account { - core::auth::Account::Offline(_) => "Offline", - core::auth::Account::Microsoft(_) => "Microsoft", - }; - emit_log!( - window, - format!("Account found: {} ({})", account.username(), account_type) - ); + emit_log!(window, format!("Account found: {}", account.username())); let config = config_state.config.lock().unwrap().clone(); emit_log!(window, format!("Java path: {}", config.java_path)); @@ -99,14 +98,10 @@ async fn start_game( format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory) ); - // Get App Data Directory (e.g., ~/.local/share/com.dropout.launcher or similar) - // The identifier is set in tauri.conf.json. - // If not accessible, use a specific logic. - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + // Get game directory from instance + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -123,10 +118,11 @@ async fn start_game( // First, load the local version to get the original inheritsFrom value // (before merge clears it) - let original_inherits_from = match core::manifest::load_local_version(&game_dir, &version_id).await { - Ok(local_version) => local_version.inherits_from.clone(), - Err(_) => None, - }; + let original_inherits_from = + match core::manifest::load_local_version(&game_dir, &version_id).await { + Ok(local_version) => local_version.inherits_from.clone(), + Err(_) => None, + }; let version_details = core::manifest::load_version(&game_dir, &version_id) .await @@ -142,8 +138,123 @@ async fn start_game( // Determine the actual minecraft version for client.jar // (for modded versions, this is the parent vanilla version) - let minecraft_version = original_inherits_from - .unwrap_or_else(|| version_id.clone()); + let minecraft_version = original_inherits_from.unwrap_or_else(|| version_id.clone()); + + // Get required Java version from version file's javaVersion field + // The version file (after merging with parent) should contain the correct javaVersion + let required_java_major = version_details + .java_version + .as_ref() + .map(|jv| jv.major_version); + + // For older Minecraft versions (1.13.x and below), if javaVersion specifies Java 8, + // we should only allow Java 8 (not higher) due to compatibility issues with old Forge + // For newer versions, javaVersion.majorVersion is the minimum required version + let max_java_major = if let Some(required) = required_java_major { + // If version file specifies Java 8, enforce it as maximum (old versions need exactly Java 8) + // For Java 9+, allow that version or higher + if required <= 8 { + Some(8) + } else { + None // No upper bound for Java 9+ + } + } else { + // If version file doesn't specify javaVersion, this shouldn't happen for modern versions + // But if it does, we can't determine compatibility - log a warning + emit_log!( + window, + "Warning: Version file does not specify javaVersion. Using system default Java." + .to_string() + ); + None + }; + + // Check if configured Java is compatible + let app_handle = window.app_handle(); + let mut java_path_to_use = config.java_path.clone(); + if !java_path_to_use.is_empty() && java_path_to_use != "java" { + let is_compatible = + core::java::is_java_compatible(&java_path_to_use, required_java_major, max_java_major); + + if !is_compatible { + emit_log!( + window, + format!( + "Configured Java version may not be compatible. Looking for compatible Java..." + ) + ); + + // Try to find a compatible Java version + if let Some(compatible_java) = + core::java::get_compatible_java(app_handle, required_java_major, max_java_major) + { + emit_log!( + window, + format!( + "Found compatible Java {} at: {}", + compatible_java.version, compatible_java.path + ) + ); + java_path_to_use = compatible_java.path; + } else { + let version_constraint = if let Some(max) = max_java_major { + if let Some(min) = required_java_major { + if min == max as u64 { + format!("Java {}", min) + } else { + format!("Java {} to {}", min, max) + } + } else { + format!("Java {} (or lower)", max) + } + } else if let Some(min) = required_java_major { + format!("Java {} or higher", min) + } else { + "any Java version".to_string() + }; + + return Err(format!( + "No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.", + version_constraint + )); + } + } + } else { + // No Java configured, try to find a compatible one + if let Some(compatible_java) = + core::java::get_compatible_java(app_handle, required_java_major, max_java_major) + { + emit_log!( + window, + format!( + "Using Java {} at: {}", + compatible_java.version, compatible_java.path + ) + ); + java_path_to_use = compatible_java.path; + } else { + let version_constraint = if let Some(max) = max_java_major { + if let Some(min) = required_java_major { + if min == max as u64 { + format!("Java {}", min) + } else { + format!("Java {} to {}", min, max) + } + } else { + format!("Java {} (or lower)", max) + } + } else if let Some(min) = required_java_major { + format!("Java {} or higher", min) + } else { + "any Java version".to_string() + }; + + return Err(format!( + "No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.", + version_constraint + )); + } + } // 2. Prepare download tasks emit_log!(window, "Preparing download tasks...".to_string()); @@ -527,17 +638,67 @@ async fn start_game( window, format!("Preparing to launch game with {} arguments...", args.len()) ); - // Debug: Log arguments (only first few to avoid spam) - if args.len() > 10 { - emit_log!(window, format!("First 10 args: {:?}", &args[..10])); - } + + // Format Java command with sensitive information masked + let masked_args: Vec<String> = args + .iter() + .enumerate() + .map(|(i, arg)| { + // Check if previous argument was a sensitive flag + if i > 0 { + let prev_arg = &args[i - 1]; + if prev_arg == "--accessToken" || prev_arg == "--uuid" { + return "***".to_string(); + } + } + + // Mask sensitive argument values + if arg == "--accessToken" || arg == "--uuid" { + arg.clone() + } else if arg.starts_with("token:") { + // Mask token: prefix tokens (Session ID format) + "token:***".to_string() + } else if arg.len() > 100 + && arg.contains('.') + && !arg.contains('/') + && !arg.contains('\\') + && !arg.contains(':') + { + // Likely a JWT token (very long string with dots, no paths) + "***".to_string() + } else if arg.len() == 36 + && arg.contains('-') + && arg.chars().all(|c| c.is_ascii_hexdigit() || c == '-') + { + // Likely a UUID (36 chars with dashes) + "***".to_string() + } else { + arg.clone() + } + }) + .collect(); + + // Format as actual Java command (properly quote arguments with spaces) + let masked_args_str: Vec<String> = masked_args + .iter() + .map(|arg| { + if arg.contains(' ') { + format!("\"{}\"", arg) + } else { + arg.clone() + } + }) + .collect(); + + let java_command = format!("{} {}", java_path_to_use, masked_args_str.join(" ")); + emit_log!(window, format!("Java Command: {}", java_command)); // Spawn the process emit_log!( window, - format!("Starting Java process: {}", config.java_path) + format!("Starting Java process: {}", java_path_to_use) ); - let mut command = Command::new(&config.java_path); + let mut command = Command::new(&java_path_to_use); command.args(&args); command.current_dir(&game_dir); // Run in game directory command.stdout(Stdio::piped()); @@ -557,7 +718,7 @@ async fn start_game( // Spawn and handle output let mut child = command .spawn() - .map_err(|e| format!("Failed to launch java: {}", e))?; + .map_err(|e| format!("Failed to launch Java at '{}': {}\nPlease check your Java installation and path configuration in Settings.", java_path_to_use, e))?; emit_log!(window, "Java process started successfully".to_string()); @@ -577,9 +738,11 @@ async fn start_game( ); let window_rx = window.clone(); + let assistant_arc = assistant_state.assistant.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { + assistant_arc.lock().unwrap().add_log(line.clone()); let _ = window_rx.emit("game-stdout", line); } // Emit log when stdout stream ends (game closing) @@ -587,10 +750,12 @@ async fn start_game( }); let window_rx_err = window.clone(); + let assistant_arc_err = assistant_state.assistant.clone(); let window_exit = window.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { + assistant_arc_err.lock().unwrap().add_log(line.clone()); let _ = window_rx_err.emit("game-stderr", line); } // Emit log when stderr stream ends @@ -685,29 +850,73 @@ fn parse_jvm_arguments( } #[tauri::command] -async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { +async fn get_versions(window: Window) -> Result<Vec<core::manifest::Version>, String> { + let app_handle = window.app_handle(); + let game_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + match core::manifest::fetch_version_manifest().await { - Ok(manifest) => Ok(manifest.versions), + Ok(manifest) => { + let mut versions = manifest.versions; + + // For each version, try to load Java version info and check installation status + for version in &mut versions { + // Check if version is installed + let version_dir = game_dir.join("versions").join(&version.id); + let json_path = version_dir.join(format!("{}.json", version.id)); + let client_jar_path = version_dir.join(format!("{}.jar", version.id)); + + // Version is installed if both JSON and client jar exist + let is_installed = json_path.exists() && client_jar_path.exists(); + version.is_installed = Some(is_installed); + + // If installed, try to load the version JSON to get javaVersion + if is_installed { + if let Ok(game_version) = + core::manifest::load_local_version(&game_dir, &version.id).await + { + if let Some(java_ver) = game_version.java_version { + version.java_version = Some(java_ver.major_version); + } + } + } + } + + Ok(versions) + } Err(e) => Err(e.to_string()), } } /// Check if a version is installed (has client.jar) #[tauri::command] -async fn check_version_installed(window: Window, version_id: String) -> Result<bool, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; +async fn check_version_installed( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<bool, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // For modded versions, check the parent vanilla version let minecraft_version = if version_id.starts_with("fabric-loader-") { // Format: fabric-loader-X.X.X-1.20.4 - version_id.split('-').last().unwrap_or(&version_id).to_string() + version_id + .split('-') + .next_back() + .unwrap_or(&version_id) + .to_string() } else if version_id.contains("-forge-") { // Format: 1.20.4-forge-49.0.38 - version_id.split("-forge-").next().unwrap_or(&version_id).to_string() + version_id + .split("-forge-") + .next() + .unwrap_or(&version_id) + .to_string() } else { version_id.clone() }; @@ -725,19 +934,24 @@ async fn check_version_installed(window: Window, version_id: String) -> Result<b async fn install_version( window: Window, config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, version_id: String, ) -> Result<(), String> { emit_log!( window, - format!("Starting installation for version: {}", version_id) + format!( + "Starting installation for version: {} in instance: {}", + version_id, instance_id + ) ); let config = config_state.config.lock().unwrap().clone(); - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + // Get game directory from instance + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -753,21 +967,24 @@ async fn install_version( ); // First, try to fetch the vanilla version from Mojang and save it locally - let version_details = match core::manifest::load_local_version(&game_dir, &version_id).await { + let _version_details = match core::manifest::load_local_version(&game_dir, &version_id).await { Ok(v) => v, Err(_) => { // Not found locally, fetch from Mojang - emit_log!(window, format!("Fetching version {} from Mojang...", version_id)); + emit_log!( + window, + format!("Fetching version {} from Mojang...", version_id) + ); let fetched = core::manifest::fetch_vanilla_version(&version_id) .await .map_err(|e| e.to_string())?; - + // Save the version JSON locally emit_log!(window, format!("Saving version JSON...")); core::manifest::save_local_version(&game_dir, &fetched) .await .map_err(|e| e.to_string())?; - + fetched } }; @@ -983,6 +1200,9 @@ async fn install_version( format!("Installation of {} completed successfully!", version_id) ); + // Emit event to notify frontend that version installation is complete + let _ = window.emit("version-installed", &version_id); + Ok(()) } @@ -1060,6 +1280,38 @@ async fn save_settings( } #[tauri::command] +async fn get_config_path(state: State<'_, core::config::ConfigState>) -> Result<String, String> { + Ok(state.file_path.to_string_lossy().to_string()) +} + +#[tauri::command] +async fn read_raw_config(state: State<'_, core::config::ConfigState>) -> Result<String, String> { + tokio::fs::read_to_string(&state.file_path) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn save_raw_config( + state: State<'_, core::config::ConfigState>, + content: String, +) -> Result<(), String> { + // Validate JSON + let new_config: core::config::LauncherConfig = + serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {}", e))?; + + // Save to file + tokio::fs::write(&state.file_path, &content) + .await + .map_err(|e| e.to_string())?; + + // Update in-memory state + *state.config.lock().unwrap() = new_config; + + Ok(()) +} + +#[tauri::command] async fn start_microsoft_login() -> Result<core::auth::DeviceCodeResponse, String> { core::auth::start_device_flow().await } @@ -1170,7 +1422,9 @@ async fn refresh_account( /// Detect Java installations on the system #[tauri::command] -async fn detect_java(app_handle: tauri::AppHandle) -> Result<Vec<core::java::JavaInstallation>, String> { +async fn detect_java( + app_handle: tauri::AppHandle, +) -> Result<Vec<core::java::JavaInstallation>, String> { Ok(core::java::detect_all_java_installations(&app_handle)) } @@ -1286,22 +1540,22 @@ async fn get_fabric_loaders_for_version( #[tauri::command] async fn install_fabric( window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, loader_version: String, ) -> Result<core::fabric::InstalledFabricVersion, String> { emit_log!( window, format!( - "Installing Fabric {} for Minecraft {}...", - loader_version, game_version + "Installing Fabric {} for Minecraft {} in instance {}...", + loader_version, game_version, instance_id ) ); - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) .await @@ -1312,23 +1566,172 @@ async fn install_fabric( format!("Fabric installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("fabric-installed", &result.id); + Ok(result) } /// List installed Fabric versions #[tauri::command] -async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; +async fn list_installed_fabric_versions( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<Vec<String>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; core::fabric::list_installed_fabric_versions(&game_dir) .await .map_err(|e| e.to_string()) } +/// Get Java version requirement for a specific version +#[tauri::command] +async fn get_version_java_version( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<Option<u64>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + // Try to load the version JSON to get javaVersion + match core::manifest::load_version(&game_dir, &version_id).await { + Ok(game_version) => Ok(game_version.java_version.map(|jv| jv.major_version)), + Err(_) => Ok(None), // Version not found or can't be loaded + } +} + +/// Version metadata for display in the UI +#[derive(serde::Serialize)] +struct VersionMetadata { + id: String, + #[serde(rename = "javaVersion")] + java_version: Option<u64>, + #[serde(rename = "isInstalled")] + is_installed: bool, +} + +/// Delete a version (remove version directory) +#[tauri::command] +async fn delete_version( + window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<(), String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + let version_dir = game_dir.join("versions").join(&version_id); + + if !version_dir.exists() { + return Err(format!("Version {} not found", version_id)); + } + + // Remove the entire version directory + tokio::fs::remove_dir_all(&version_dir) + .await + .map_err(|e| format!("Failed to delete version: {}", e))?; + + // Emit event to notify frontend + let _ = window.emit("version-deleted", &version_id); + + Ok(()) +} + +/// Get detailed metadata for a specific version +#[tauri::command] +async fn get_version_metadata( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result<VersionMetadata, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + // Initialize metadata + let mut metadata = VersionMetadata { + id: version_id.clone(), + java_version: None, + is_installed: false, + }; + + // Check if version is in manifest and get Java version if available + if let Ok(manifest) = core::manifest::fetch_version_manifest().await { + if let Some(version_entry) = manifest.versions.iter().find(|v| v.id == version_id) { + // Note: version_entry.java_version is only set if version is installed locally + // For uninstalled versions, we'll fetch from remote below + if let Some(java_ver) = version_entry.java_version { + metadata.java_version = Some(java_ver); + } + } + } + + // Check if version is installed (both JSON and client jar must exist) + let version_dir = game_dir.join("versions").join(&version_id); + let json_path = version_dir.join(format!("{}.json", version_id)); + + // For modded versions, check the parent vanilla version's client jar + let client_jar_path = if version_id.starts_with("fabric-loader-") { + // Format: fabric-loader-X.X.X-1.20.4 + let minecraft_version = version_id + .split('-') + .next_back() + .unwrap_or(&version_id) + .to_string(); + game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)) + } else if version_id.contains("-forge-") { + // Format: 1.20.4-forge-49.0.38 + let minecraft_version = version_id + .split("-forge-") + .next() + .unwrap_or(&version_id) + .to_string(); + game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)) + } else { + version_dir.join(format!("{}.jar", version_id)) + }; + + metadata.is_installed = json_path.exists() && client_jar_path.exists(); + + // Try to get Java version - from local if installed, or from remote if not + if metadata.is_installed { + // If installed, load from local version JSON + if let Ok(game_version) = core::manifest::load_version(&game_dir, &version_id).await { + if let Some(java_ver) = game_version.java_version { + metadata.java_version = Some(java_ver.major_version); + } + } + } else if metadata.java_version.is_none() { + // If not installed and we don't have Java version yet, try to fetch from remote + // This is for vanilla versions that are not installed + if !version_id.starts_with("fabric-loader-") && !version_id.contains("-forge-") { + if let Ok(game_version) = core::manifest::fetch_vanilla_version(&version_id).await { + if let Some(java_ver) = game_version.java_version { + metadata.java_version = Some(java_ver.major_version); + } + } + } + } + + Ok(metadata) +} + /// Installed version info #[derive(serde::Serialize)] struct InstalledVersion { @@ -1340,12 +1743,14 @@ struct InstalledVersion { /// List all installed versions from the data directory /// Simply lists all folders in the versions directory without validation #[tauri::command] -async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; +async fn list_installed_versions( + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<Vec<InstalledVersion>, String> { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); @@ -1425,15 +1830,15 @@ async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion> /// Check if Fabric is installed for a specific version #[tauri::command] async fn is_fabric_installed( - window: Window, + _window: Window, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, loader_version: String, ) -> Result<bool, String> { - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; Ok(core::fabric::is_fabric_installed( &game_dir, @@ -1465,37 +1870,40 @@ async fn get_forge_versions_for_game( async fn install_forge( window: Window, config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, game_version: String, forge_version: String, ) -> Result<core::forge::InstalledForgeVersion, String> { emit_log!( window, format!( - "Installing Forge {} for Minecraft {}...", - forge_version, game_version + "Installing Forge {} for Minecraft {} in instance {}...", + forge_version, game_version, instance_id ) ); - let app_handle = window.app_handle(); - let game_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; // Get Java path from config or detect let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { config.java_path.clone() } else { // Try to find a suitable Java installation - let javas = core::java::detect_all_java_installations(&app_handle); + let javas = core::java::detect_all_java_installations(app_handle); if let Some(java) = javas.first() { java.path.clone() } else { - return Err("No Java installation found. Please configure Java in settings.".to_string()); + return Err( + "No Java installation found. Please configure Java in settings.".to_string(), + ); } }; - let java_path = std::path::PathBuf::from(&java_path_str); + let java_path = utils::path::normalize_java_path(&java_path_str)?; emit_log!(window, "Running Forge installer...".to_string()); @@ -1504,7 +1912,10 @@ async fn install_forge( .await .map_err(|e| format!("Forge installer failed: {}", e))?; - emit_log!(window, "Forge installer completed, creating version profile...".to_string()); + emit_log!( + window, + "Forge installer completed, creating version profile...".to_string() + ); // Now create the version JSON let result = core::forge::install_forge(&game_dir, &game_version, &forge_version) @@ -1516,6 +1927,9 @@ async fn install_forge( format!("Forge installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("forge-installed", &result.id); + Ok(result) } @@ -1551,7 +1965,7 @@ async fn get_github_releases() -> Result<Vec<GithubRelease>, String> { r["name"].as_str(), r["published_at"].as_str(), r["body"].as_str(), - r["html_url"].as_str() + r["html_url"].as_str(), ) { result.push(GithubRelease { tag_name: tag.to_string(), @@ -1593,8 +2007,7 @@ async fn upload_to_pastebin( match service.as_str() { "pastebin.com" => { - let api_key = api_key - .ok_or("Pastebin API Key not configured in settings")?; + let api_key = api_key.ok_or("Pastebin API Key not configured in settings")?; let res = client .post("https://pastebin.com/api/api_post.php") @@ -1640,6 +2053,139 @@ async fn upload_to_pastebin( } } +#[tauri::command] +async fn assistant_check_health( + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, +) -> Result<bool, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + Ok(assistant.check_health(&config.assistant).await) +} + +#[tauri::command] +async fn assistant_chat( + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, + messages: Vec<core::assistant::Message>, +) -> Result<core::assistant::Message, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + assistant.chat(messages, &config.assistant).await +} + +#[tauri::command] +async fn list_ollama_models( + assistant_state: State<'_, core::assistant::AssistantState>, + endpoint: String, +) -> Result<Vec<core::assistant::ModelInfo>, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + assistant.list_ollama_models(&endpoint).await +} + +#[tauri::command] +async fn list_openai_models( + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, +) -> Result<Vec<core::assistant::ModelInfo>, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + assistant.list_openai_models(&config.assistant).await +} + +// ==================== Instance Management Commands ==================== + +/// Create a new instance +#[tauri::command] +async fn create_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + name: String, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.create_instance(name, app_handle) +} + +/// Delete an instance +#[tauri::command] +async fn delete_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<(), String> { + state.delete_instance(&instance_id) +} + +/// Update an instance +#[tauri::command] +async fn update_instance( + state: State<'_, core::instance::InstanceState>, + instance: core::instance::Instance, +) -> Result<(), String> { + state.update_instance(instance) +} + +/// Get all instances +#[tauri::command] +async fn list_instances( + state: State<'_, core::instance::InstanceState>, +) -> Result<Vec<core::instance::Instance>, String> { + Ok(state.list_instances()) +} + +/// Get a single instance by ID +#[tauri::command] +async fn get_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<core::instance::Instance, String> { + state + .get_instance(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id)) +} + +/// Set the active instance +#[tauri::command] +async fn set_active_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, +) -> Result<(), String> { + state.set_active_instance(&instance_id) +} + +/// Get the active instance +#[tauri::command] +async fn get_active_instance( + state: State<'_, core::instance::InstanceState>, +) -> Result<Option<core::instance::Instance>, String> { + Ok(state.get_active_instance()) +} + +/// Duplicate an instance +#[tauri::command] +async fn duplicate_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + instance_id: String, + new_name: String, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.duplicate_instance(&instance_id, new_name, app_handle) +} + +#[tauri::command] +async fn assistant_chat_stream( + window: tauri::Window, + assistant_state: State<'_, core::assistant::AssistantState>, + config_state: State<'_, core::config::ConfigState>, + messages: Vec<core::assistant::Message>, +) -> Result<String, String> { + let assistant = assistant_state.assistant.lock().unwrap().clone(); + let config = config_state.config.lock().unwrap().clone(); + assistant + .chat_stream(messages, &config.assistant, &window) + .await +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_fs::init()) @@ -1647,10 +2193,21 @@ fn main() { .plugin(tauri_plugin_shell::init()) .manage(core::auth::AccountState::new()) .manage(MsRefreshTokenState::new()) + .manage(core::assistant::AssistantState::new()) .setup(|app| { let config_state = core::config::ConfigState::new(app.handle()); app.manage(config_state); + // Initialize instance state + let instance_state = core::instance::InstanceState::new(app.handle()); + + // Migrate legacy data if needed + if let Err(e) = core::instance::migrate_legacy_data(app.handle(), &instance_state) { + eprintln!("[Startup] Warning: Failed to migrate legacy data: {}", e); + } + + app.manage(instance_state); + // Load saved account on startup let app_dir = app.path().app_data_dir().unwrap(); let storage = core::account_storage::AccountStorage::new(app_dir); @@ -1670,7 +2227,7 @@ fn main() { } // Check for pending Java downloads and notify frontend - let pending = core::java::get_pending_downloads(&app.app_handle()); + let pending = core::java::get_pending_downloads(app.app_handle()); if !pending.is_empty() { println!("[Startup] Found {} pending Java download(s)", pending.len()); let _ = app.emit("pending-java-downloads", pending.len()); @@ -1684,11 +2241,17 @@ fn main() { check_version_installed, install_version, list_installed_versions, + get_version_java_version, + get_version_metadata, + delete_version, login_offline, get_active_account, logout, get_settings, save_settings, + get_config_path, + read_raw_config, + save_raw_config, start_microsoft_login, complete_microsoft_login, refresh_account, @@ -1715,7 +2278,21 @@ fn main() { get_forge_versions_for_game, install_forge, get_github_releases, - upload_to_pastebin + upload_to_pastebin, + assistant_check_health, + assistant_chat, + assistant_chat_stream, + list_ollama_models, + list_openai_models, + // Instance management commands + create_instance, + delete_instance, + update_instance, + list_instances, + get_instance, + set_active_instance, + get_active_instance, + duplicate_instance ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index c0aed36..c9ac368 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,6 +1,8 @@ +pub mod path; pub mod zip; // File system related utility functions +#[allow(dead_code)] pub mod file_utils { use std::fs; use std::io::{self, Write}; @@ -16,6 +18,7 @@ pub mod file_utils { } // Configuration parsing utilities +#[allow(dead_code)] pub mod config_parser { use std::collections::HashMap; diff --git a/src-tauri/src/utils/path.rs b/src-tauri/src/utils/path.rs new file mode 100644 index 0000000..ab14c12 --- /dev/null +++ b/src-tauri/src/utils/path.rs @@ -0,0 +1,247 @@ +/// Path utilities for cross-platform compatibility +use std::path::PathBuf; + +/// Helper to strip UNC prefix on Windows (\\?\) +/// This is needed because std::fs::canonicalize adds UNC prefix on Windows +#[cfg(target_os = "windows")] +fn strip_unc_prefix(path: PathBuf) -> PathBuf { + let s = path.to_string_lossy().to_string(); + if s.starts_with(r"\\?\") { + return PathBuf::from(&s[4..]); + } + path +} + +#[cfg(not(target_os = "windows"))] +fn strip_unc_prefix(path: PathBuf) -> PathBuf { + path +} + +/// Normalize a Java executable path for the current platform. +/// +/// This function handles platform-specific requirements and validates that +/// the resulting path points to an executable Java binary. +/// +/// On Windows: +/// - Adds .exe extension if missing +/// - Attempts to locate java.exe in PATH if only "java" is provided +/// - Resolves symlinks and strips UNC prefix +/// - Validates that the path exists +/// +/// On Unix: +/// - Attempts to locate java in PATH using `which` if only "java" is provided +/// - Resolves symlinks to get canonical path +/// - Validates that the path exists +/// +/// # Arguments +/// * `java_path` - The Java executable path to normalize (can be relative, absolute, or "java") +/// +/// # Returns +/// * `Ok(PathBuf)` - Canonicalized, validated path to Java executable +/// * `Err(String)` - Error if the path cannot be found or validated +#[cfg(target_os = "windows")] +pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { + let mut path = PathBuf::from(java_path); + + // If path doesn't exist and doesn't end with .exe, try adding .exe + if !path.exists() && path.extension().is_none() { + path.set_extension("exe"); + } + + // If still not found and it's just "java.exe" (not an absolute path), try to find it in PATH + // Only search PATH for relative paths or just "java", not for absolute paths that don't exist + if !path.exists() + && !path.is_absolute() + && path.file_name() == Some(std::ffi::OsStr::new("java.exe")) + { + // Try to locate java.exe in PATH + if let Ok(output) = std::process::Command::new("where").arg("java").output() { + if output.status.success() { + let paths = String::from_utf8_lossy(&output.stdout); + if let Some(first_path) = paths.lines().next() { + path = PathBuf::from(first_path.trim()); + } + } + } + + // If still not found after PATH search, return specific error + if !path.exists() { + return Err( + "Java not found in PATH. Please install Java or configure the full path in Settings." + .to_string(), + ); + } + } + + // Verify the path exists before canonicalization + if !path.exists() { + return Err(format!( + "Java executable not found at: {}\nPlease configure a valid Java path in Settings.", + path.display() + )); + } + + // Canonicalize and strip UNC prefix for clean path + let canonical = std::fs::canonicalize(&path) + .map_err(|e| format!("Failed to resolve Java path '{}': {}", path.display(), e))?; + + Ok(strip_unc_prefix(canonical)) +} + +#[cfg(not(target_os = "windows"))] +pub fn normalize_java_path(java_path: &str) -> Result<PathBuf, String> { + let mut path = PathBuf::from(java_path); + + // If path doesn't exist and it's just "java", try to find java in PATH + if !path.exists() && java_path == "java" { + if let Ok(output) = std::process::Command::new("which").arg("java").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout); + if let Some(first_path) = path_str.lines().next() { + path = PathBuf::from(first_path.trim()); + } + } + } + + // If still not found after PATH search, return specific error + if !path.exists() { + return Err( + "Java not found in PATH. Please install Java or configure the full path in Settings." + .to_string(), + ); + } + } + + // Verify the path exists before canonicalization + if !path.exists() { + return Err(format!( + "Java executable not found at: {}\nPlease configure a valid Java path in Settings.", + path.display() + )); + } + + // Canonicalize to resolve symlinks and get absolute path + let canonical = std::fs::canonicalize(&path) + .map_err(|e| format!("Failed to resolve Java path '{}': {}", path.display(), e))?; + + Ok(strip_unc_prefix(canonical)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + + #[test] + #[cfg(target_os = "windows")] + fn test_normalize_nonexistent_path_windows() { + // Non-existent path should return error + let result = normalize_java_path("C:\\NonExistent\\Path\\java.exe"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn test_normalize_nonexistent_path_unix() { + // Non-existent path should return error + let result = normalize_java_path("/nonexistent/path/java"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[test] + #[cfg(target_os = "windows")] + fn test_normalize_adds_exe_extension() { + // This test assumes java is not in the current directory + let result = normalize_java_path("nonexistent_java"); + // Should fail since the file doesn't exist + assert!(result.is_err()); + } + + #[test] + fn test_normalize_existing_path_returns_canonical() { + // Test with a path that should exist on most systems + #[cfg(target_os = "windows")] + let test_path = "C:\\Windows\\System32\\cmd.exe"; + #[cfg(not(target_os = "windows"))] + let test_path = "/bin/sh"; + + if std::path::Path::new(test_path).exists() { + let result = normalize_java_path(test_path); + assert!(result.is_ok()); + let normalized = result.unwrap(); + // Should be absolute path after canonicalization + assert!(normalized.is_absolute()); + // Should not contain UNC prefix on Windows + #[cfg(target_os = "windows")] + assert!(!normalized.to_string_lossy().starts_with(r"\\?\")); + } + } + + #[test] + fn test_normalize_java_not_in_path() { + // When "java" is provided but not in PATH, should return error + // This test may pass if java IS in PATH, so we check error message format + let result = normalize_java_path("java"); + if result.is_err() { + let err = result.unwrap_err(); + assert!( + err.contains("not found in PATH") || err.contains("not found at"), + "Expected PATH error, got: {}", + err + ); + } + // If Ok, java was found in PATH - test passes + } + + #[test] + fn test_normalize_with_temp_file() { + // Create a temporary file to test with an actual existing path + let temp_dir = std::env::temp_dir(); + + #[cfg(target_os = "windows")] + let temp_file = temp_dir.join("test_java_normalize.exe"); + #[cfg(not(target_os = "windows"))] + let temp_file = temp_dir.join("test_java_normalize"); + + // Create the file + if let Ok(mut file) = fs::File::create(&temp_file) { + let _ = file.write_all(b"#!/bin/sh\necho test"); + drop(file); + + // Test normalization + let result = normalize_java_path(temp_file.to_str().unwrap()); + + // Clean up + let _ = fs::remove_file(&temp_file); + + // Verify result + assert!(result.is_ok(), "Failed to normalize temp file path"); + let normalized = result.unwrap(); + assert!(normalized.is_absolute()); + } + } + + #[test] + fn test_strip_unc_prefix() { + #[cfg(target_os = "windows")] + { + let unc_path = PathBuf::from(r"\\?\C:\Windows\System32\cmd.exe"); + let stripped = strip_unc_prefix(unc_path); + assert_eq!(stripped.to_string_lossy(), r"C:\Windows\System32\cmd.exe"); + + let normal_path = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + let unchanged = strip_unc_prefix(normal_path.clone()); + assert_eq!(unchanged, normal_path); + } + + #[cfg(not(target_os = "windows"))] + { + let path = PathBuf::from("/usr/bin/java"); + let unchanged = strip_unc_prefix(path.clone()); + assert_eq!(unchanged, path); + } + } +} |