aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri
diff options
context:
space:
mode:
Diffstat (limited to 'src-tauri')
-rw-r--r--src-tauri/Cargo.toml8
-rw-r--r--src-tauri/icons/icon.svg2
-rw-r--r--src-tauri/src/core/account_storage.rs2
-rw-r--r--src-tauri/src/core/assistant.rs694
-rw-r--r--src-tauri/src/core/auth.rs7
-rw-r--r--src-tauri/src/core/config.rs40
-rw-r--r--src-tauri/src/core/downloader.rs33
-rw-r--r--src-tauri/src/core/fabric.rs18
-rw-r--r--src-tauri/src/core/forge.rs186
-rw-r--r--src-tauri/src/core/instance.rs325
-rw-r--r--src-tauri/src/core/java.rs158
-rw-r--r--src-tauri/src/core/manifest.rs16
-rw-r--r--src-tauri/src/core/maven.rs5
-rw-r--r--src-tauri/src/core/mod.rs2
-rw-r--r--src-tauri/src/core/version_merge.rs2
-rw-r--r--src-tauri/src/main.rs765
-rw-r--r--src-tauri/src/utils/mod.rs3
-rw-r--r--src-tauri/src/utils/path.rs247
-rw-r--r--src-tauri/tauri.conf.json4
19 files changed, 2302 insertions, 215 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 97529a1..ecd0beb 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dropout"
-version = "0.1.23"
+version = "0.1.26"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
@@ -27,8 +27,11 @@ flate2 = "1.0"
tar = "0.4"
dirs = "5.0"
serde_urlencoded = "0.7.1"
-tauri-plugin-dialog = "2.5.0"
+tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5"
+bytes = "1.11.0"
+chrono = "0.4"
+regex = "1.12.2"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
@@ -45,4 +48,3 @@ section = "games"
assets = [
["target/release/dropout", "usr/bin/", "755"],
]
-
diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg
index d8b0ed7..0baf00f 100644
--- a/src-tauri/icons/icon.svg
+++ b/src-tauri/icons/icon.svg
@@ -47,4 +47,4 @@
<!-- Layer 3: Output - x=412 -->
<circle cx="412" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
-</svg> \ No newline at end of file
+</svg>
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);
+ }
+ }
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 060a871..dd84fd4 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "dropout",
- "version": "0.1.23",
+ "version": "0.1.26",
"identifier": "com.dropout.launcher",
"build": {
"beforeDevCommand": "pnpm -C ../ui dev",
@@ -20,7 +20,7 @@
}
],
"security": {
- "csp": null,
+ "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;",
"capabilities": ["default"]
}
},