From 73ddf24b04bf94ee7fa76974e1af55eb94112b93 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Fri, 16 Jan 2026 14:18:04 +0800 Subject: feat: integrate AI assistant functionality and configuration management Implemented new commands for managing the AI assistant, including health checks, chat interactions, and model listings for both Ollama and OpenAI. Enhanced the configuration system to support raw JSON editing and added a dedicated AssistantConfig structure for better management of assistant settings. This update significantly improves the user experience by providing comprehensive control over AI interactions and configurations. --- src-tauri/src/core/account_storage.rs | 2 + src-tauri/src/core/assistant.rs | 694 ++++++++++++++++++++++++++++++++++ src-tauri/src/core/auth.rs | 7 +- src-tauri/src/core/config.rs | 40 ++ src-tauri/src/core/downloader.rs | 33 +- src-tauri/src/core/fabric.rs | 18 +- src-tauri/src/core/forge.rs | 69 ++-- src-tauri/src/core/java.rs | 53 ++- src-tauri/src/core/manifest.rs | 9 +- src-tauri/src/core/maven.rs | 5 +- src-tauri/src/core/mod.rs | 1 + src-tauri/src/core/version_merge.rs | 2 + 12 files changed, 855 insertions(+), 78 deletions(-) create mode 100644 src-tauri/src/core/assistant.rs (limited to 'src-tauri/src/core') 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 { 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, + 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, + pub family: Option, + pub parameter_size: Option, + pub quantization_level: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OllamaModel { + pub name: String, + pub modified_at: Option, + pub size: Option, + pub digest: Option, + pub details: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OllamaTagsResponse { + pub models: Vec, +} + +// Simplified model info for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub size: Option, + pub details: Option, +} + +#[derive(Debug, Serialize)] +pub struct OpenAIChatRequest { + pub model: String, + pub messages: Vec, + pub stream: bool, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIChoice { + pub index: i32, + pub message: Message, + pub finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIChatResponse { + pub id: String, + pub object: String, + pub created: i64, + pub model: String, + pub choices: Vec, +} + +// OpenAI models list response +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIModelData { + pub id: String, + pub object: String, + pub created: Option, + pub owned_by: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIModelsResponse { + pub object: String, + pub data: Vec, +} + +// 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, +} + +// Ollama streaming response (each line is a JSON object) +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OllamaStreamResponse { + pub model: Option, + pub created_at: Option, + pub message: Option, + pub done: bool, + pub total_duration: Option, + pub load_duration: Option, + pub prompt_eval_count: Option, + pub prompt_eval_duration: Option, + pub eval_count: Option, + pub eval_duration: Option, +} + +// OpenAI streaming response +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamDelta { + pub role: Option, + pub content: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamChoice { + pub index: i32, + pub delta: OpenAIStreamDelta, + pub finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct OpenAIStreamResponse { + pub id: Option, + pub object: Option, + pub created: Option, + pub model: Option, + pub choices: Vec, +} + +#[derive(Clone)] +pub struct GameAssistant { + client: reqwest::Client, + pub log_buffer: VecDeque, + 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::>() + .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, + config: &AssistantConfig, + ) -> Result { + // 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, + config: &AssistantConfig, + ) -> Result { + 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, + config: &AssistantConfig, + ) -> Result { + 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, 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 = 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, 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 = 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, + config: &AssistantConfig, + window: &Window, + ) -> Result { + // 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, + config: &AssistantConfig, + window: &Window, + ) -> Result { + 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::(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, + config: &AssistantConfig, + window: &Window, + ) -> Result { + 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::(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>, +} + +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 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, pub signature: Option, - pub keyId: Option, + #[serde(rename = "keyId")] + pub key_id: Option, } +#[allow(dead_code)] pub async fn check_ownership(mc_access_token: &str) -> Result { 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 @@ -4,6 +4,44 @@ use std::path::PathBuf; use std::sync::Mutex; 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, + 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 { @@ -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, + 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> { @@ -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, Box> { 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..c8bd6e4 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -43,6 +43,7 @@ pub struct InstalledForgeVersion { /// Forge installer manifest structure (from version.json inside installer JAR) #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct ForgeInstallerManifest { id: Option, #[serde(rename = "inheritsFrom")] @@ -183,30 +184,30 @@ async fn fetch_forge_installer_manifest( forge_version: &str, ) -> Result> { 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?; - + // 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 +225,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> { @@ -234,7 +235,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); @@ -275,20 +277,20 @@ pub async fn run_forge_installer( "{}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?; tokio::fs::write(&installer_path, &bytes).await?; - + // Run the installer in headless mode // The installer accepts --installClient to install to a specific directory let output = tokio::process::Command::new(java_path) @@ -298,19 +300,20 @@ pub async fn run_forge_installer( .arg(game_dir) .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 +335,14 @@ fn create_forge_version_json_from_manifest( }); // Convert libraries to JSON format, preserving download info - let lib_entries: Vec = manifest.libraries + let lib_entries: Vec = 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 +350,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 +374,7 @@ fn create_forge_version_json_from_manifest( } } } - + entry }) .collect(); @@ -377,7 +384,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 +468,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 +489,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, Box> { let versions_dir = game_dir.join("versions"); let mut installed = Vec::new(); diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 8341138..ac52da6 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -5,7 +5,7 @@ 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"; @@ -58,7 +58,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, pub available_major_versions: Vec, @@ -66,17 +66,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 +75,7 @@ pub struct AdoptiumAsset { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AdoptiumBinary { pub os: String, pub architecture: String, @@ -104,6 +94,7 @@ pub struct AdoptiumPackage { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AdoptiumVersionData { pub major: u32, pub minor: u32, @@ -114,6 +105,7 @@ pub struct AdoptiumVersionData { /// Adoptium available releases response #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct AvailableReleases { pub available_releases: Vec, pub available_lts_releases: Vec, @@ -231,6 +223,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 +233,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 { +pub async fn fetch_java_catalog( + app_handle: &AppHandle, + force_refresh: bool, +) -> Result { // Check cache first unless force refresh if !force_refresh { if let Some(cached) = load_cached_catalog(app_handle) { @@ -294,7 +290,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 +545,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 { @@ -885,7 +887,7 @@ pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec Option { let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; @@ -918,7 +920,11 @@ fn find_java_executable(dir: &PathBuf) -> Option { // 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 +937,9 @@ fn find_java_executable(dir: &PathBuf) -> Option { } /// Resume pending Java downloads from queue -pub async fn resume_pending_downloads(app_handle: &AppHandle) -> Result, String> { +pub async fn resume_pending_downloads( + app_handle: &AppHandle, +) -> Result, String> { let queue = DownloadQueue::load(app_handle); let mut installed = Vec::new(); @@ -978,7 +986,12 @@ pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec } /// 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..d92ae58 100644 --- a/src-tauri/src/core/manifest.rs +++ b/src-tauri/src/core/manifest.rs @@ -45,7 +45,7 @@ pub async fn fetch_version_manifest() -> Result Result> { let json_path = game_dir @@ -102,7 +102,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> { // Try loading from local first @@ -138,7 +138,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> { let version_dir = game_dir.join("versions").join(&version.id); @@ -158,8 +158,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, Box> { 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 { +pub fn get_library_path(name: &str, libraries_dir: &std::path::Path) -> Option { 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..7ad6ef9 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod account_storage; +pub mod assistant; pub mod auth; pub mod config; pub mod downloader; 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( version: GameVersion, version_loader: F, -- cgit v1.2.3-70-g09d2 From 9c366b8d5c3f68d19bb87ed354b42b48c99b66c5 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Fri, 16 Jan 2026 14:40:02 +0800 Subject: feat: improve Java command execution on Windows Enhanced the Java command execution by adding support for Windows-specific creation flags in both the installer and candidate checking functions. This change ensures better compatibility and performance when running Java commands on Windows systems. --- src-tauri/src/core/forge.rs | 15 ++++++++++----- src-tauri/src/core/java.rs | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs index c8bd6e4..0528b76 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -9,6 +9,8 @@ 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 = @@ -293,13 +295,16 @@ pub async fn run_forge_installer( // Run the installer in headless mode // The installer accepts --installClient 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; diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index ac52da6..7aa43b8 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -1,4 +1,6 @@ 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; @@ -636,10 +638,12 @@ fn get_java_candidates() -> Vec { 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() { @@ -788,7 +792,12 @@ fn get_java_candidates() -> Vec { /// Check a specific Java installation and get its version info fn check_java_installation(path: &PathBuf) -> Option { - 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); -- cgit v1.2.3-70-g09d2 From 1e0905613a7b7b98daf6dfecffa87fc0096a8e55 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Fri, 16 Jan 2026 14:49:05 +0800 Subject: feat: add UNC prefix stripping for Windows paths in Java handling Implemented a helper function to strip the UNC prefix from file paths on Windows, ensuring cleaner path handling. Updated the Java candidate retrieval process to resolve symlinks and apply the new prefix stripping function, enhancing compatibility and usability on Windows systems. --- src-tauri/src/core/java.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 7aa43b8..3f7a1e4 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -13,6 +13,18 @@ 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, @@ -649,7 +661,11 @@ fn get_java_candidates() -> Vec { 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); } } } -- cgit v1.2.3-70-g09d2 From 4d6ed7b93ed57a2f397e4f8060b2b183b7c86774 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Fri, 16 Jan 2026 14:49:50 +0800 Subject: feat: enhance Java path handling by resolving symlinks and stripping UNC prefixes Updated the Java installation and executable retrieval functions to resolve symlinks and strip UNC prefixes from paths. This improvement ensures cleaner and more reliable path handling on Windows systems, enhancing compatibility and usability. --- src-tauri/src/core/java.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 3f7a1e4..1d57d21 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -577,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())?; @@ -919,7 +923,8 @@ fn find_java_executable(dir: &PathBuf) -> Option { // 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 @@ -939,7 +944,8 @@ fn find_java_executable(dir: &PathBuf) -> Option { // 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 -- cgit v1.2.3-70-g09d2 From 1119f6c3cf421da2f2db92873efae8135c76b678 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Fri, 16 Jan 2026 18:38:47 +0800 Subject: feat: enhance Java version management for Minecraft versions Added functionality to determine and validate the required Java version for Minecraft versions, including checks for compatibility with older versions. Implemented event emissions for version installation and deletion, and updated the UI to reflect Java version requirements and installation status. Improved version metadata handling and added support for deleting versions. --- src-tauri/Cargo.toml | 1 + src-tauri/src/core/forge.rs | 116 ++++++--- src-tauri/src/core/java.rs | 58 +++++ src-tauri/src/core/manifest.rs | 7 + src-tauri/src/main.rs | 383 +++++++++++++++++++++++++++-- ui/src/components/BottomBar.svelte | 56 ++--- ui/src/components/ModLoaderSelector.svelte | 38 +-- ui/src/components/VersionsView.svelte | 325 +++++++++++++++++++++--- ui/src/stores/game.svelte.ts | 6 +- ui/src/types/index.ts | 2 + 10 files changed, 854 insertions(+), 138 deletions(-) (limited to 'src-tauri/src/core') diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9fe91b7..407da5a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ dirs = "5.0" serde_urlencoded = "0.7.1" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" +bytes = "1.11.0" [build-dependencies] tauri-build = { version = "2.0", features = [] } diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs index 0528b76..65bf413 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -16,6 +16,7 @@ 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)] @@ -180,27 +181,93 @@ pub fn generate_version_id(game_version: &str, forge_version: &str) -> String { format!("{}-forge-{}", game_version, forge_version) } -/// Fetch the Forge installer manifest to get the library list -async fn fetch_forge_installer_manifest( +/// 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> { +) -> Result> { 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()); + // 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; + } + } } - let bytes = response.bytes().await?; + 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> { + 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()); @@ -274,23 +341,10 @@ pub async fn run_forge_installer( forge_version: &str, java_path: &PathBuf, ) -> Result<(), Box> { - // 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 diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 1d57d21..0c7769b 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -881,6 +881,64 @@ pub fn get_recommended_java(required_major_version: Option) -> Option, + max_major_version: Option, +) -> Option { + 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, + max_major_version: Option, +) -> 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 { let mut installations = detect_java_installations(); diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs index d92ae58..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, + /// Whether this version is installed locally + #[serde(rename = "isInstalled", skip_serializing_if = "Option::is_none")] + pub is_installed: Option, } pub async fn fetch_version_manifest() -> Result> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 463bd5d..661309a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -139,6 +139,121 @@ async fn start_game( // (for modded versions, this is the parent vanilla version) 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 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()); let mut download_tasks = Vec::new(); @@ -521,33 +636,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!("Java Args: {:?}", &args)); - } - // Get Java path from config or detect - 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); - if let Some(java) = javas.first() { - java.path.clone() - } else { - return Err( - "No Java installation found. Please configure Java in settings.".to_string(), - ); - } - }; - let java_path = utils::path::normalize_java_path(&java_path_str)?; + // Format Java command with sensitive information masked + let masked_args: Vec = 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 = 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: {}", java_path.display()) + format!("Starting Java process: {}", java_path_to_use) ); - let mut command = Command::new(&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()); @@ -567,7 +716,7 @@ async fn start_game( // Spawn and handle output let mut child = command .spawn() - .map_err(|e| format!("Failed to launch Java at '{}': {}\nPlease check your Java installation and path configuration in Settings.", java_path.display(), 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()); @@ -699,9 +848,42 @@ fn parse_jvm_arguments( } #[tauri::command] -async fn get_versions() -> Result, String> { +async fn get_versions(window: Window) -> Result, 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()), } } @@ -1008,6 +1190,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(()) } @@ -1371,6 +1556,9 @@ async fn install_fabric( format!("Fabric installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("fabric-installed", &result.id); + Ok(result) } @@ -1388,6 +1576,147 @@ async fn list_installed_fabric_versions(window: Window) -> Result, S .map_err(|e| e.to_string()) } +/// Get Java version requirement for a specific version +#[tauri::command] +async fn get_version_java_version( + window: Window, + version_id: String, +) -> Result, 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))?; + + // 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, + #[serde(rename = "isInstalled")] + is_installed: bool, +} + +/// Delete a version (remove version directory) +#[tauri::command] +async fn delete_version(window: Window, version_id: String) -> Result<(), 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 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, + version_id: String, +) -> Result { + 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))?; + + // 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 { @@ -1580,6 +1909,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) } @@ -1802,6 +2134,9 @@ 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, diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index b7bbf71..8a6b7ff 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -4,7 +4,7 @@ import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; - import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte'; + import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; interface InstalledVersion { id: string; @@ -16,23 +16,31 @@ let installedVersions = $state([]); let isLoadingVersions = $state(true); let downloadCompleteUnlisten: UnlistenFn | null = null; + let versionDeletedUnlisten: UnlistenFn | null = null; // Load installed versions on mount $effect(() => { loadInstalledVersions(); - setupDownloadListener(); + setupEventListeners(); return () => { if (downloadCompleteUnlisten) { downloadCompleteUnlisten(); } + if (versionDeletedUnlisten) { + versionDeletedUnlisten(); + } }; }); - async function setupDownloadListener() { + async function setupEventListeners() { // Refresh list when a download completes downloadCompleteUnlisten = await listen("download-complete", () => { loadInstalledVersions(); }); + // Refresh list when a version is deleted + versionDeletedUnlisten = await listen("version-deleted", () => { + loadInstalledVersions(); + }); } async function loadInstalledVersions() { @@ -160,18 +168,7 @@
-
- - -
+ + {#if isLoadingVersions} + Loading... + {:else if installedVersions.length === 0} + No versions installed + {:else} + {gameState.selectedVersion || "Select version"} + {/if} + + + {#if isVersionDropdownOpen && installedVersions.length > 0}
-
+ {#if fabricLoaders.length === 0} +
+ No Fabric versions available for {selectedGameVersion} +
+ {:else} +
@@ -339,21 +344,22 @@
{/if}
-
- - +
+ + + {/if}
{:else if selectedLoader === "forge"} diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index 063c28d..2e8b028 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -1,5 +1,6 @@