From 891300636da422cf04b2e0b089d25012a2ad63dd Mon Sep 17 00:00:00 2001 From: "Begonia, HE" <163421589+BegoniaHe@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:39:18 +0100 Subject: [PATCH 1/4] refactor(java): add java catalog cache and reorganize providers add src-tauri/src/core/java/cache.rs for Java catalog caching reorganize provider interfaces and move detection/install changes update persistence, adoptium provider and validation Reviewed-by: Raptor mini (Preview) --- .git_commit_msg.txt | 7 + src-tauri/src/core/java/cache.rs | 77 +++++ src-tauri/src/core/java/detection.rs | 311 ------------------ src-tauri/src/core/java/detection/common.rs | 82 +++++ src-tauri/src/core/java/detection/linux.rs | 36 ++ src-tauri/src/core/java/detection/macos.rs | 54 +++ src-tauri/src/core/java/detection/mod.rs | 34 ++ src-tauri/src/core/java/detection/unix.rs | 44 +++ src-tauri/src/core/java/detection/windows.rs | 39 +++ src-tauri/src/core/java/install.rs | 179 ++++++++++ src-tauri/src/core/java/mod.rs | 299 +++-------------- src-tauri/src/core/java/persistence.rs | 58 ++-- src-tauri/src/core/java/providers/adoptium.rs | 14 +- src-tauri/src/core/java/validation.rs | 8 +- src-tauri/src/main.rs | 28 +- 15 files changed, 674 insertions(+), 596 deletions(-) create mode 100644 .git_commit_msg.txt create mode 100644 src-tauri/src/core/java/cache.rs delete mode 100644 src-tauri/src/core/java/detection.rs create mode 100644 src-tauri/src/core/java/detection/common.rs create mode 100644 src-tauri/src/core/java/detection/linux.rs create mode 100644 src-tauri/src/core/java/detection/macos.rs create mode 100644 src-tauri/src/core/java/detection/mod.rs create mode 100644 src-tauri/src/core/java/detection/unix.rs create mode 100644 src-tauri/src/core/java/detection/windows.rs create mode 100644 src-tauri/src/core/java/install.rs diff --git a/.git_commit_msg.txt b/.git_commit_msg.txt new file mode 100644 index 0000000..c42c11e --- /dev/null +++ b/.git_commit_msg.txt @@ -0,0 +1,7 @@ +refactor(java): add java catalog cache and reorganize providers + +add src-tauri/src/core/java/cache.rs for Java catalog caching +reorganize provider interfaces and move detection/install changes +update persistence, adoptium provider and validation + +Reviewed-by: Raptor mini (Preview) diff --git a/src-tauri/src/core/java/cache.rs b/src-tauri/src/core/java/cache.rs new file mode 100644 index 0000000..6f8c6fd --- /dev/null +++ b/src-tauri/src/core/java/cache.rs @@ -0,0 +1,77 @@ +use crate::core::java::error::JavaError; +use crate::core::java::JavaCatalog; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; + +fn get_catalog_cache_path(app_handle: &AppHandle) -> PathBuf { + app_handle + .path() + .app_data_dir() + .unwrap() + .join("java_catalog_cache.json") +} + +fn write_file_atomic(path: &PathBuf, content: &str) -> Result<(), JavaError> { + let parent = path.parent().ok_or_else(|| { + JavaError::InvalidConfig("Java cache path has no parent directory".to_string()) + })?; + std::fs::create_dir_all(parent)?; + + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, content)?; + + if path.exists() { + let _ = std::fs::remove_file(path); + } + + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +pub fn load_cached_catalog_result( + app_handle: &AppHandle, +) -> Result, JavaError> { + let cache_path = get_catalog_cache_path(app_handle); + if !cache_path.exists() { + return Ok(None); + } + + let content = std::fs::read_to_string(&cache_path)?; + let catalog: JavaCatalog = serde_json::from_str(&content)?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| JavaError::Other(format!("System time error: {}", e)))? + .as_secs(); + + if now - catalog.cached_at < CACHE_DURATION_SECS { + Ok(Some(catalog)) + } else { + Ok(None) + } +} + +#[allow(dead_code)] +pub fn load_cached_catalog(app_handle: &AppHandle) -> Option { + match load_cached_catalog_result(app_handle) { + Ok(value) => value, + Err(_) => None, + } +} + +pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Result<(), JavaError> { + let cache_path = get_catalog_cache_path(app_handle); + let content = serde_json::to_string_pretty(catalog)?; + write_file_atomic(&cache_path, &content) +} + +#[allow(dead_code)] +pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), JavaError> { + let cache_path = get_catalog_cache_path(app_handle); + if cache_path.exists() { + std::fs::remove_file(&cache_path)?; + } + Ok(()) +} diff --git a/src-tauri/src/core/java/detection.rs b/src-tauri/src/core/java/detection.rs deleted file mode 100644 index 08dcebb..0000000 --- a/src-tauri/src/core/java/detection.rs +++ /dev/null @@ -1,311 +0,0 @@ -use std::io::Read; -use std::path::Path; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::time::Duration; - -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; - -use crate::core::java::strip_unc_prefix; - -const WHICH_TIMEOUT: Duration = Duration::from_secs(2); - -/// Scans a directory for Java installations, filtering out symlinks -/// -/// # Arguments -/// * `base_dir` - Base directory to scan (e.g., mise or SDKMAN java dir) -/// * `should_skip` - Predicate to determine if an entry should be skipped -/// -/// # Returns -/// First valid Java installation found, or `None` -fn scan_java_dir(base_dir: &Path, should_skip: F) -> Option -where - F: Fn(&std::fs::DirEntry) -> bool, -{ - std::fs::read_dir(base_dir) - .ok()? - .flatten() - .filter(|entry| { - let path = entry.path(); - // Only consider real directories, not symlinks - path.is_dir() && !path.is_symlink() && !should_skip(entry) - }) - .find_map(|entry| { - let java_path = entry.path().join("bin/java"); - if java_path.exists() && java_path.is_file() { - Some(java_path) - } else { - None - } - }) -} - -/// Finds Java installation from SDKMAN! if available -/// -/// Scans the SDKMAN! candidates directory and returns the first valid Java installation found. -/// Skips the 'current' symlink to avoid duplicates. -/// -/// Path: `~/.sdkman/candidates/java/` -/// -/// # Returns -/// `Some(PathBuf)` pointing to `bin/java` if found, `None` otherwise -pub fn find_sdkman_java() -> Option { - let home = std::env::var("HOME").ok()?; - let sdkman_base = PathBuf::from(&home).join(".sdkman/candidates/java/"); - - if !sdkman_base.exists() { - return None; - } - - scan_java_dir(&sdkman_base, |entry| entry.file_name() == "current") -} - -/// Finds Java installation from mise if available -/// -/// Scans the mise Java installation directory and returns the first valid installation found. -/// Skips version alias symlinks (e.g., `21`, `21.0`, `latest`, `lts`) to avoid duplicates. -/// -/// Path: `~/.local/share/mise/installs/java/` -/// -/// # Returns -/// `Some(PathBuf)` pointing to `bin/java` if found, `None` otherwise -pub fn find_mise_java() -> Option { - let home = std::env::var("HOME").ok()?; - let mise_base = PathBuf::from(&home).join(".local/share/mise/installs/java/"); - - if !mise_base.exists() { - return None; - } - - scan_java_dir(&mise_base, |_| false) // mise: no additional filtering needed -} - -/// Runs `which` (Unix) or `where` (Windows) command to find Java in PATH with timeout -/// -/// This function spawns a subprocess to locate the `java` executable in the system PATH. -/// It enforces a 2-second timeout to prevent hanging if the command takes too long. -/// -/// # Returns -/// `Some(String)` containing the output (paths separated by newlines) if successful, -/// `None` if the command fails, times out, or returns non-zero exit code -/// -/// # Platform-specific behavior -/// - Unix/Linux/macOS: Uses `which java` -/// - Windows: Uses `where java` and hides the console window -/// -/// # Timeout Behavior -/// If the command does not complete within 2 seconds, the process is killed -/// and `None` is returned. This prevents the launcher from hanging on systems -/// where `which`/`where` may be slow or unresponsive. -fn run_which_command_with_timeout() -> Option { - let mut cmd = Command::new(if cfg!(windows) { "where" } else { "which" }); - cmd.arg("java"); - // Hide console window on Windows - #[cfg(target_os = "windows")] - cmd.creation_flags(0x08000000); - cmd.stdout(Stdio::piped()); - - let mut child = cmd.spawn().ok()?; - let start = std::time::Instant::now(); - - loop { - // Check if timeout has been exceeded - if start.elapsed() > WHICH_TIMEOUT { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - - match child.try_wait() { - Ok(Some(status)) => { - if status.success() { - let mut output = String::new(); - if let Some(mut stdout) = child.stdout.take() { - let _ = stdout.read_to_string(&mut output); - } - return Some(output); - } else { - let _ = child.wait(); - return None; - } - } - Ok(None) => { - // Command still running, sleep briefly before checking again - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - } -} - -/// Detects all available Java installations on the system -/// -/// This function searches for Java installations in multiple locations: -/// - **All platforms**: `JAVA_HOME` environment variable, `java` in PATH -/// - **Linux**: `/usr/lib/jvm`, `/usr/java`, `/opt/java`, `/opt/jdk`, `/opt/openjdk`, SDKMAN! -/// - **macOS**: `/Library/Java/JavaVirtualMachines`, `/System/Library/Java/JavaVirtualMachines`, -/// Homebrew paths (`/usr/local/opt/openjdk`, `/opt/homebrew/opt/openjdk`), SDKMAN! -/// - **Windows**: `Program Files`, `Program Files (x86)`, `LOCALAPPDATA` for various JDK distributions -/// -/// # Returns -/// A vector of `PathBuf` pointing to Java executables found on the system. -/// Note: Paths may include symlinks and duplicates; callers should canonicalize and deduplicate as needed. -/// -/// # Examples -/// ```ignore -/// let candidates = get_java_candidates(); -/// for java_path in candidates { -/// println!("Found Java at: {}", java_path.display()); -/// } -/// ``` -pub fn get_java_candidates() -> Vec { - let mut candidates = Vec::new(); - - // Try to find Java in PATH using 'which' or 'where' command with timeout - // CAUTION: linux 'which' may return symlinks, so we need to canonicalize later - if let Some(paths_str) = run_which_command_with_timeout() { - for line in paths_str.lines() { - let path = PathBuf::from(line.trim()); - if path.exists() { - let resolved = std::fs::canonicalize(&path).unwrap_or(path); - let final_path = strip_unc_prefix(resolved); - candidates.push(final_path); - } - } - } - - #[cfg(target_os = "linux")] - { - let linux_paths = [ - "/usr/lib/jvm", - "/usr/java", - "/opt/java", - "/opt/jdk", - "/opt/openjdk", - ]; - - for base in &linux_paths { - if let Ok(entries) = std::fs::read_dir(base) { - for entry in entries.flatten() { - let java_path = entry.path().join("bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - - // Check common SDKMAN! java candidates - if let Some(sdkman_java) = find_sdkman_java() { - candidates.push(sdkman_java); - } - - // Check common mise java candidates - if let Some(mise_java) = find_mise_java() { - candidates.push(mise_java); - } - } - - #[cfg(target_os = "macos")] - { - let mac_paths = [ - "/Library/Java/JavaVirtualMachines", - "/System/Library/Java/JavaVirtualMachines", - "/usr/local/opt/openjdk/bin/java", - "/opt/homebrew/opt/openjdk/bin/java", - ]; - - for path in &mac_paths { - let p = PathBuf::from(path); - if p.is_dir() { - if let Ok(entries) = std::fs::read_dir(&p) { - for entry in entries.flatten() { - let java_path = entry.path().join("Contents/Home/bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } else if p.exists() { - candidates.push(p); - } - } - - // Check common Homebrew java candidates for aarch64 macs - let homebrew_arm = PathBuf::from("/opt/homebrew/Cellar/openjdk"); - if homebrew_arm.exists() { - if let Ok(entries) = std::fs::read_dir(&homebrew_arm) { - for entry in entries.flatten() { - let java_path = entry - .path() - .join("libexec/openjdk.jdk/Contents/Home/bin/java"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - - // Check common SDKMAN! java candidates - if let Some(sdkman_java) = find_sdkman_java() { - candidates.push(sdkman_java); - } - - // Check common mise java candidates - if let Some(mise_java) = find_mise_java() { - candidates.push(mise_java); - } - } - - #[cfg(target_os = "windows")] - { - let program_files = - std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); - let program_files_x86 = std::env::var("ProgramFiles(x86)") - .unwrap_or_else(|_| "C:\\Program Files (x86)".to_string()); - let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); - - // Common installation paths for various JDK distributions - let mut win_paths = vec![]; - for base in &[&program_files, &program_files_x86, &local_app_data] { - win_paths.push(format!("{}\\Java", base)); - win_paths.push(format!("{}\\Eclipse Adoptium", base)); - win_paths.push(format!("{}\\AdoptOpenJDK", base)); - win_paths.push(format!("{}\\Microsoft\\jdk", base)); - win_paths.push(format!("{}\\Zulu", base)); - win_paths.push(format!("{}\\Amazon Corretto", base)); - win_paths.push(format!("{}\\BellSoft\\LibericaJDK", base)); - win_paths.push(format!("{}\\Programs\\Eclipse Adoptium", base)); - } - - for base in &win_paths { - let base_path = PathBuf::from(base); - if base_path.exists() { - if let Ok(entries) = std::fs::read_dir(&base_path) { - for entry in entries.flatten() { - let java_path = entry.path().join("bin\\java.exe"); - if java_path.exists() { - candidates.push(java_path); - } - } - } - } - } - } - - // Check JAVA_HOME environment variable - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; - let java_path = PathBuf::from(&java_home).join("bin").join(bin_name); - if java_path.exists() { - candidates.push(java_path); - } - } - - candidates -} diff --git a/src-tauri/src/core/java/detection/common.rs b/src-tauri/src/core/java/detection/common.rs new file mode 100644 index 0000000..266c0bf --- /dev/null +++ b/src-tauri/src/core/java/detection/common.rs @@ -0,0 +1,82 @@ +use std::io::Read; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +use crate::core::java::strip_unc_prefix; + +// We set a timeout for the which/where command to prevent hanging if it encounters issues. +const WHICH_TIMEOUT: Duration = Duration::from_secs(2); + +fn run_which_command_with_timeout() -> Option { + let mut cmd = Command::new(if cfg!(windows) { "where" } else { "which" }); + cmd.arg("java"); + #[cfg(target_os = "windows")] + // hide the console window on Windows to avoid flashing a command prompt. + cmd.creation_flags(0x08000000); + cmd.stdout(Stdio::piped()); + + let mut child = cmd.spawn().ok()?; + let start = std::time::Instant::now(); + + loop { + if start.elapsed() > WHICH_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + + match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + let mut output = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut output); + } + return Some(output); + } + let _ = child.wait(); + return None; + } + Ok(None) => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } +} + +pub fn path_candidates() -> Vec { + let mut candidates = Vec::new(); + + if let Some(paths_str) = run_which_command_with_timeout() { + for line in paths_str.lines() { + let path = PathBuf::from(line.trim()); + if path.exists() { + let resolved = std::fs::canonicalize(&path).unwrap_or(path); + let final_path = strip_unc_prefix(resolved); + candidates.push(final_path); + } + } + } + + candidates +} + +pub fn java_home_candidate() -> Option { + let java_home = std::env::var("JAVA_HOME").ok()?; + let bin_name = if cfg!(windows) { "java.exe" } else { "java" }; + let java_path = PathBuf::from(&java_home).join("bin").join(bin_name); + if java_path.exists() { + Some(java_path) + } else { + None + } +} diff --git a/src-tauri/src/core/java/detection/linux.rs b/src-tauri/src/core/java/detection/linux.rs new file mode 100644 index 0000000..84f6c64 --- /dev/null +++ b/src-tauri/src/core/java/detection/linux.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use super::unix::{find_mise_java, find_sdkman_java}; + +pub fn linux_candidates() -> Vec { + let mut candidates = Vec::new(); + + let linux_paths = [ + "/usr/lib/jvm", + "/usr/java", + "/opt/java", + "/opt/jdk", + "/opt/openjdk", + ]; + + for base in &linux_paths { + if let Ok(entries) = std::fs::read_dir(base) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + + if let Some(sdkman_java) = find_sdkman_java() { + candidates.push(sdkman_java); + } + + if let Some(mise_java) = find_mise_java() { + candidates.push(mise_java); + } + + candidates +} diff --git a/src-tauri/src/core/java/detection/macos.rs b/src-tauri/src/core/java/detection/macos.rs new file mode 100644 index 0000000..5c2ed2a --- /dev/null +++ b/src-tauri/src/core/java/detection/macos.rs @@ -0,0 +1,54 @@ +use std::path::PathBuf; + +use super::unix::{find_mise_java, find_sdkman_java}; + +pub fn macos_candidates() -> Vec { + let mut candidates = Vec::new(); + + let mac_paths = [ + "/Library/Java/JavaVirtualMachines", + "/System/Library/Java/JavaVirtualMachines", + "/usr/local/opt/openjdk/bin/java", + "/opt/homebrew/opt/openjdk/bin/java", + ]; + + for path in &mac_paths { + let p = PathBuf::from(path); + if p.is_dir() { + if let Ok(entries) = std::fs::read_dir(&p) { + for entry in entries.flatten() { + let java_path = entry.path().join("Contents/Home/bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } else if p.exists() { + candidates.push(p); + } + } + + let homebrew_arm = PathBuf::from("/opt/homebrew/Cellar/openjdk"); + if homebrew_arm.exists() { + if let Ok(entries) = std::fs::read_dir(&homebrew_arm) { + for entry in entries.flatten() { + let java_path = entry + .path() + .join("libexec/openjdk.jdk/Contents/Home/bin/java"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + + if let Some(sdkman_java) = find_sdkman_java() { + candidates.push(sdkman_java); + } + + if let Some(mise_java) = find_mise_java() { + candidates.push(mise_java); + } + + candidates +} diff --git a/src-tauri/src/core/java/detection/mod.rs b/src-tauri/src/core/java/detection/mod.rs new file mode 100644 index 0000000..2c2e692 --- /dev/null +++ b/src-tauri/src/core/java/detection/mod.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +mod common; +mod linux; +mod macos; +mod unix; +mod windows; + +pub fn get_java_candidates() -> Vec { + let mut candidates = Vec::new(); + + candidates.extend(common::path_candidates()); + + #[cfg(target_os = "linux")] + { + candidates.extend(linux::linux_candidates()); + } + + #[cfg(target_os = "macos")] + { + candidates.extend(macos::macos_candidates()); + } + + #[cfg(target_os = "windows")] + { + candidates.extend(windows::windows_candidates()); + } + + if let Some(java_home) = common::java_home_candidate() { + candidates.push(java_home); + } + + candidates +} diff --git a/src-tauri/src/core/java/detection/unix.rs b/src-tauri/src/core/java/detection/unix.rs new file mode 100644 index 0000000..f06b0d8 --- /dev/null +++ b/src-tauri/src/core/java/detection/unix.rs @@ -0,0 +1,44 @@ +use std::path::{Path, PathBuf}; + +fn scan_java_dir(base_dir: &Path, should_skip: F) -> Option +where + F: Fn(&std::fs::DirEntry) -> bool, +{ + std::fs::read_dir(base_dir) + .ok()? + .flatten() + .filter(|entry| { + let path = entry.path(); + path.is_dir() && !path.is_symlink() && !should_skip(entry) + }) + .find_map(|entry| { + let java_path = entry.path().join("bin/java"); + if java_path.exists() && java_path.is_file() { + Some(java_path) + } else { + None + } + }) +} + +pub fn find_sdkman_java() -> Option { + let home = std::env::var("HOME").ok()?; + let sdkman_base = PathBuf::from(&home).join(".sdkman/candidates/java/"); + + if !sdkman_base.exists() { + return None; + } + + scan_java_dir(&sdkman_base, |entry| entry.file_name() == "current") +} + +pub fn find_mise_java() -> Option { + let home = std::env::var("HOME").ok()?; + let mise_base = PathBuf::from(&home).join(".local/share/mise/installs/java/"); + + if !mise_base.exists() { + return None; + } + + scan_java_dir(&mise_base, |_| false) +} diff --git a/src-tauri/src/core/java/detection/windows.rs b/src-tauri/src/core/java/detection/windows.rs new file mode 100644 index 0000000..4f664ff --- /dev/null +++ b/src-tauri/src/core/java/detection/windows.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +pub fn windows_candidates() -> Vec { + let mut candidates = Vec::new(); + + let program_files = + std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); + let program_files_x86 = std::env::var("ProgramFiles(x86)") + .unwrap_or_else(|_| "C:\\Program Files (x86)".to_string()); + let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); + + let mut win_paths = vec![]; + for base in &[&program_files, &program_files_x86, &local_app_data] { + win_paths.push(format!("{}\\Java", base)); + win_paths.push(format!("{}\\Eclipse Adoptium", base)); + win_paths.push(format!("{}\\AdoptOpenJDK", base)); + win_paths.push(format!("{}\\Microsoft\\jdk", base)); + win_paths.push(format!("{}\\Zulu", base)); + win_paths.push(format!("{}\\Amazon Corretto", base)); + win_paths.push(format!("{}\\BellSoft\\LibericaJDK", base)); + win_paths.push(format!("{}\\Programs\\Eclipse Adoptium", base)); + } + + for base in &win_paths { + let base_path = PathBuf::from(base); + if base_path.exists() { + if let Ok(entries) = std::fs::read_dir(&base_path) { + for entry in entries.flatten() { + let java_path = entry.path().join("bin\\java.exe"); + if java_path.exists() { + candidates.push(java_path); + } + } + } + } + } + + candidates +} diff --git a/src-tauri/src/core/java/install.rs b/src-tauri/src/core/java/install.rs new file mode 100644 index 0000000..afc6d13 --- /dev/null +++ b/src-tauri/src/core/java/install.rs @@ -0,0 +1,179 @@ +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Emitter}; + +use crate::core::downloader::{DownloadQueue, JavaDownloadProgress, PendingJavaDownload}; +use crate::utils::zip; + +use super::{ + fetch_java_release_with, get_java_install_dir, strip_unc_prefix, validation, ImageType, + JavaInstallation, JavaProvider, +}; + +pub async fn download_and_install_java_with_provider( + provider: &P, + app_handle: &AppHandle, + major_version: u32, + image_type: ImageType, + custom_path: Option, +) -> Result { + let info = fetch_java_release_with(provider, major_version, image_type) + .await + .map_err(|e| e.to_string())?; + let file_name = info.file_name.clone(); + + let install_base = custom_path.unwrap_or_else(|| get_java_install_dir(app_handle)); + let version_dir = install_base.join(format!( + "{}-{}-{}", + provider.install_prefix(), + major_version, + image_type + )); + + std::fs::create_dir_all(&install_base) + .map_err(|e| format!("Failed to create installation directory: {}", e))?; + + let mut queue = DownloadQueue::load(app_handle); + queue.add(PendingJavaDownload { + major_version, + image_type: image_type.to_string(), + download_url: info.download_url.clone(), + file_name: info.file_name.clone(), + file_size: info.file_size, + checksum: info.checksum.clone(), + install_path: install_base.to_string_lossy().to_string(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }); + queue.save(app_handle)?; + + let archive_path = install_base.join(&info.file_name); + + let need_download = if archive_path.exists() { + if let Some(expected_checksum) = &info.checksum { + let data = std::fs::read(&archive_path) + .map_err(|e| format!("Failed to read downloaded file: {}", e))?; + !crate::core::downloader::verify_checksum(&data, Some(expected_checksum), None) + } else { + false + } + } else { + true + }; + + if need_download { + crate::core::downloader::download_with_resume( + app_handle, + &info.download_url, + &archive_path, + info.checksum.as_deref(), + info.file_size, + ) + .await?; + } + + let _ = app_handle.emit( + "java-download-progress", + JavaDownloadProgress { + file_name: file_name.clone(), + downloaded_bytes: info.file_size, + total_bytes: info.file_size, + speed_bytes_per_sec: 0, + eta_seconds: 0, + status: "Extracting".to_string(), + percentage: 100.0, + }, + ); + + if version_dir.exists() { + std::fs::remove_dir_all(&version_dir) + .map_err(|e| format!("Failed to remove old version directory: {}", e))?; + } + + std::fs::create_dir_all(&version_dir) + .map_err(|e| format!("Failed to create version directory: {}", e))?; + + let top_level_dir = if info.file_name.ends_with(".tar.gz") || info.file_name.ends_with(".tgz") { + zip::extract_tar_gz(&archive_path, &version_dir)? + } else if info.file_name.ends_with(".zip") { + zip::extract_zip(&archive_path, &version_dir)?; + find_top_level_dir(&version_dir)? + } else { + return Err(format!("Unsupported archive format: {}", info.file_name)); + }; + + let _ = std::fs::remove_file(&archive_path); + + let java_home = version_dir.join(&top_level_dir); + let java_bin = resolve_java_executable(&java_home); + + if !java_bin.exists() { + return Err(format!( + "Installation completed but Java executable not found: {}", + java_bin.display() + )); + } + + let java_bin = std::fs::canonicalize(&java_bin).map_err(|e| e.to_string())?; + let java_bin = strip_unc_prefix(java_bin); + + let installation = validation::check_java_installation(&java_bin) + .await + .ok_or_else(|| "Failed to verify Java installation".to_string())?; + + queue.remove(major_version, &image_type.to_string()); + queue.save(app_handle)?; + + let _ = app_handle.emit( + "java-download-progress", + JavaDownloadProgress { + file_name, + downloaded_bytes: info.file_size, + total_bytes: info.file_size, + speed_bytes_per_sec: 0, + eta_seconds: 0, + status: "Completed".to_string(), + percentage: 100.0, + }, + ); + + Ok(installation) +} + +fn find_top_level_dir(extract_dir: &PathBuf) -> Result { + let entries: Vec<_> = std::fs::read_dir(extract_dir) + .map_err(|e| format!("Failed to read directory: {}", e))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); + + if entries.len() == 1 { + Ok(entries[0].file_name().to_string_lossy().to_string()) + } else { + let names: Vec = entries + .iter() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + Err(format!( + "Expected exactly one top-level directory, found {}: {:?}", + names.len(), + names + )) + } +} + +fn resolve_java_executable(java_home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + java_home + .join("Contents") + .join("Home") + .join("bin") + .join("java") + } else if cfg!(windows) { + java_home.join("bin").join("java.exe") + } else { + java_home.join("bin").join("java") + } +} diff --git a/src-tauri/src/core/java/mod.rs b/src-tauri/src/core/java/mod.rs index 2215872..b7c3457 100644 --- a/src-tauri/src/core/java/mod.rs +++ b/src-tauri/src/core/java/mod.rs @@ -1,15 +1,18 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; +pub mod cache; pub mod detection; pub mod error; +pub mod install; pub mod persistence; pub mod priority; pub mod provider; pub mod providers; pub mod validation; +pub use cache::{load_cached_catalog_result, save_catalog_cache}; pub use error::JavaError; use ts_rs::TS; @@ -25,12 +28,30 @@ pub fn strip_unc_prefix(path: PathBuf) -> PathBuf { path } -use crate::core::downloader::{DownloadQueue, JavaDownloadProgress, PendingJavaDownload}; -use crate::utils::zip; +use crate::core::downloader::{DownloadQueue, PendingJavaDownload}; use provider::JavaProvider; -use providers::AdoptiumProvider; -const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; +pub async fn fetch_java_catalog_with( + provider: &P, + app_handle: &AppHandle, + force_refresh: bool, +) -> Result { + provider.fetch_catalog(app_handle, force_refresh).await +} + +pub async fn fetch_java_release_with( + provider: &P, + major_version: u32, + image_type: ImageType, +) -> Result { + provider.fetch_release(major_version, image_type).await +} + +pub async fn fetch_available_versions_with( + provider: &P, +) -> Result, JavaError> { + provider.available_versions().await +} #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts( @@ -46,8 +67,12 @@ pub struct JavaInstallation { pub is_64bit: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] +#[ts( + export, + export_to = "../../packages/ui-new/src/types/bindings/java/index.ts" +)] pub enum ImageType { Jre, Jdk, @@ -68,6 +93,16 @@ impl std::fmt::Display for ImageType { } } +impl ImageType { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "jre" => Some(Self::Jre), + "jdk" => Some(Self::Jdk), + _ => None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts( export, @@ -75,7 +110,7 @@ impl std::fmt::Display for ImageType { )] pub struct JavaReleaseInfo { pub major_version: u32, - pub image_type: String, + pub image_type: ImageType, pub version: String, pub release_name: String, pub release_date: Option, @@ -111,245 +146,13 @@ pub struct JavaDownloadInfo { pub file_name: String, // e.g., "OpenJDK17U-jre_x64_linux_hotspot_17.0.2_8.tar.gz" pub file_size: u64, // in bytes pub checksum: Option, // SHA256 checksum - pub image_type: String, // "jre" or "jdk" + pub image_type: ImageType, } pub fn get_java_install_dir(app_handle: &AppHandle) -> PathBuf { app_handle.path().app_data_dir().unwrap().join("java") } -fn get_catalog_cache_path(app_handle: &AppHandle) -> PathBuf { - app_handle - .path() - .app_data_dir() - .unwrap() - .join("java_catalog_cache.json") -} - -pub fn load_cached_catalog(app_handle: &AppHandle) -> Option { - let cache_path = get_catalog_cache_path(app_handle); - if !cache_path.exists() { - return None; - } - - // Read cache file - let content = std::fs::read_to_string(&cache_path).ok()?; - let catalog: JavaCatalog = serde_json::from_str(&content).ok()?; - - // Get current time in seconds since UNIX_EPOCH - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Check if cache is still valid - if now - catalog.cached_at < CACHE_DURATION_SECS { - Some(catalog) - } else { - None - } -} - -pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Result<(), String> { - let cache_path = get_catalog_cache_path(app_handle); - let content = serde_json::to_string_pretty(catalog).map_err(|e| e.to_string())?; - std::fs::write(&cache_path, content).map_err(|e| e.to_string())?; - Ok(()) -} - -#[allow(dead_code)] -pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> { - let cache_path = get_catalog_cache_path(app_handle); - if cache_path.exists() { - std::fs::remove_file(&cache_path).map_err(|e| e.to_string())?; - } - Ok(()) -} - -pub async fn fetch_java_catalog( - app_handle: &AppHandle, - force_refresh: bool, -) -> Result { - let provider = AdoptiumProvider::new(); - provider - .fetch_catalog(app_handle, force_refresh) - .await - .map_err(|e| e.to_string()) -} - -pub async fn fetch_java_release( - major_version: u32, - image_type: ImageType, -) -> Result { - let provider = AdoptiumProvider::new(); - provider - .fetch_release(major_version, image_type) - .await - .map_err(|e| e.to_string()) -} - -pub async fn fetch_available_versions() -> Result, String> { - let provider = AdoptiumProvider::new(); - provider - .available_versions() - .await - .map_err(|e| e.to_string()) -} - -pub async fn download_and_install_java( - app_handle: &AppHandle, - major_version: u32, - image_type: ImageType, - custom_path: Option, -) -> Result { - let provider = AdoptiumProvider::new(); - let info = provider.fetch_release(major_version, image_type).await?; - let file_name = info.file_name.clone(); - - let install_base = custom_path.unwrap_or_else(|| get_java_install_dir(app_handle)); - let version_dir = install_base.join(format!( - "{}-{}-{}", - provider.install_prefix(), - major_version, - image_type - )); - - std::fs::create_dir_all(&install_base) - .map_err(|e| format!("Failed to create installation directory: {}", e))?; - - let mut queue = DownloadQueue::load(app_handle); - queue.add(PendingJavaDownload { - major_version, - image_type: image_type.to_string(), - download_url: info.download_url.clone(), - file_name: info.file_name.clone(), - file_size: info.file_size, - checksum: info.checksum.clone(), - install_path: install_base.to_string_lossy().to_string(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - }); - queue.save(app_handle)?; - - let archive_path = install_base.join(&info.file_name); - - let need_download = if archive_path.exists() { - if let Some(expected_checksum) = &info.checksum { - let data = std::fs::read(&archive_path) - .map_err(|e| format!("Failed to read downloaded file: {}", e))?; - !crate::core::downloader::verify_checksum(&data, Some(expected_checksum), None) - } else { - false - } - } else { - true - }; - - if need_download { - crate::core::downloader::download_with_resume( - app_handle, - &info.download_url, - &archive_path, - info.checksum.as_deref(), - info.file_size, - ) - .await?; - } - - let _ = app_handle.emit( - "java-download-progress", - JavaDownloadProgress { - file_name: file_name.clone(), - downloaded_bytes: info.file_size, - total_bytes: info.file_size, - speed_bytes_per_sec: 0, - eta_seconds: 0, - status: "Extracting".to_string(), - percentage: 100.0, - }, - ); - - if version_dir.exists() { - std::fs::remove_dir_all(&version_dir) - .map_err(|e| format!("Failed to remove old version directory: {}", e))?; - } - - std::fs::create_dir_all(&version_dir) - .map_err(|e| format!("Failed to create version directory: {}", e))?; - - let top_level_dir = if info.file_name.ends_with(".tar.gz") || info.file_name.ends_with(".tgz") { - zip::extract_tar_gz(&archive_path, &version_dir)? - } else if info.file_name.ends_with(".zip") { - zip::extract_zip(&archive_path, &version_dir)?; - find_top_level_dir(&version_dir)? - } else { - return Err(format!("Unsupported archive format: {}", info.file_name)); - }; - - let _ = std::fs::remove_file(&archive_path); - - let java_home = version_dir.join(&top_level_dir); - let java_bin = if cfg!(target_os = "macos") { - java_home - .join("Contents") - .join("Home") - .join("bin") - .join("java") - } else if cfg!(windows) { - java_home.join("bin").join("java.exe") - } else { - java_home.join("bin").join("java") - }; - - if !java_bin.exists() { - return Err(format!( - "Installation completed but Java executable not found: {}", - java_bin.display() - )); - } - - let java_bin = std::fs::canonicalize(&java_bin).map_err(|e| e.to_string())?; - let java_bin = strip_unc_prefix(java_bin); - - let installation = validation::check_java_installation(&java_bin) - .await - .ok_or_else(|| "Failed to verify Java installation".to_string())?; - - queue.remove(major_version, &image_type.to_string()); - queue.save(app_handle)?; - - let _ = app_handle.emit( - "java-download-progress", - JavaDownloadProgress { - file_name, - downloaded_bytes: info.file_size, - total_bytes: info.file_size, - speed_bytes_per_sec: 0, - eta_seconds: 0, - status: "Completed".to_string(), - percentage: 100.0, - }, - ); - - Ok(installation) -} - -fn find_top_level_dir(extract_dir: &PathBuf) -> Result { - let entries: Vec<_> = std::fs::read_dir(extract_dir) - .map_err(|e| format!("Failed to read directory: {}", e))? - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .collect(); - - if entries.len() == 1 { - Ok(entries[0].file_name().to_string_lossy().to_string()) - } else { - Ok(String::new()) - } -} - pub async fn detect_java_installations() -> Vec { let mut installations = Vec::new(); let candidates = detection::get_java_candidates(); @@ -490,20 +293,24 @@ fn find_java_executable(dir: &PathBuf) -> Option { None } -pub async fn resume_pending_downloads( +pub async fn resume_pending_downloads_with( + provider: &P, app_handle: &AppHandle, ) -> Result, String> { let queue = DownloadQueue::load(app_handle); let mut installed = Vec::new(); for pending in queue.pending_downloads.iter() { - let image_type = if pending.image_type == "jdk" { - ImageType::Jdk - } else { + let image_type = ImageType::parse(&pending.image_type).unwrap_or_else(|| { + eprintln!( + "Unknown image type '{}' in pending download, defaulting to jre", + pending.image_type + ); ImageType::Jre - }; + }); - match download_and_install_java( + match install::download_and_install_java_with_provider( + provider, app_handle, pending.major_version, image_type, diff --git a/src-tauri/src/core/java/persistence.rs b/src-tauri/src/core/java/persistence.rs index 6720696..a8fdc40 100644 --- a/src-tauri/src/core/java/persistence.rs +++ b/src-tauri/src/core/java/persistence.rs @@ -33,28 +33,31 @@ fn get_java_config_path(app_handle: &AppHandle) -> PathBuf { .join("java_config.json") } -pub fn load_java_config(app_handle: &AppHandle) -> JavaConfig { - let config_path = get_java_config_path(app_handle); - if !config_path.exists() { - return JavaConfig::default(); +fn write_file_atomic(path: &PathBuf, content: &str) -> Result<(), JavaError> { + let parent = path.parent().ok_or_else(|| { + JavaError::InvalidConfig("Java config path has no parent directory".to_string()) + })?; + + std::fs::create_dir_all(parent)?; + + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, content)?; + + if path.exists() { + let _ = std::fs::remove_file(path); } - match std::fs::read_to_string(&config_path) { - Ok(content) => match serde_json::from_str(&content) { - Ok(config) => config, - Err(err) => { - // Log the error but don't panic - return default config - log::warn!( - "Failed to parse Java config at {}: {}. Using default configuration.", - config_path.display(), - err - ); - JavaConfig::default() - } - }, + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +pub fn load_java_config(app_handle: &AppHandle) -> JavaConfig { + match load_java_config_result(app_handle) { + Ok(config) => config, Err(err) => { + let config_path = get_java_config_path(app_handle); log::warn!( - "Failed to read Java config at {}: {}. Using default configuration.", + "Failed to load Java config at {}: {}. Using default configuration.", config_path.display(), err ); @@ -63,16 +66,21 @@ pub fn load_java_config(app_handle: &AppHandle) -> JavaConfig { } } -pub fn save_java_config(app_handle: &AppHandle, config: &JavaConfig) -> Result<(), JavaError> { +pub fn load_java_config_result(app_handle: &AppHandle) -> Result { let config_path = get_java_config_path(app_handle); - let content = serde_json::to_string_pretty(config)?; + if !config_path.exists() { + return Ok(JavaConfig::default()); + } - std::fs::create_dir_all(config_path.parent().ok_or_else(|| { - JavaError::InvalidConfig("Java config path has no parent directory".to_string()) - })?)?; + let content = std::fs::read_to_string(&config_path)?; + let config: JavaConfig = serde_json::from_str(&content)?; + Ok(config) +} - std::fs::write(&config_path, content)?; - Ok(()) +pub fn save_java_config(app_handle: &AppHandle, config: &JavaConfig) -> Result<(), JavaError> { + let config_path = get_java_config_path(app_handle); + let content = serde_json::to_string_pretty(config)?; + write_file_atomic(&config_path, &content) } #[allow(dead_code)] diff --git a/src-tauri/src/core/java/providers/adoptium.rs b/src-tauri/src/core/java/providers/adoptium.rs index 20fb2d5..fb86500 100644 --- a/src-tauri/src/core/java/providers/adoptium.rs +++ b/src-tauri/src/core/java/providers/adoptium.rs @@ -94,8 +94,12 @@ impl JavaProvider for AdoptiumProvider { force_refresh: bool, ) -> Result { if !force_refresh { - if let Some(cached) = crate::core::java::load_cached_catalog(app_handle) { - return Ok(cached); + match crate::core::java::load_cached_catalog_result(app_handle) { + Ok(Some(cached)) => return Ok(cached), + Ok(None) => {} + Err(err) => { + log::warn!("Failed to load Java catalog cache: {}", err); + } } } @@ -122,9 +126,9 @@ impl JavaProvider for AdoptiumProvider { let mut fetch_tasks = Vec::new(); for major_version in &available.available_releases { - for image_type in &["jre", "jdk"] { + for image_type in [ImageType::Jre, ImageType::Jdk] { let major_version = *major_version; - let image_type = image_type.to_string(); + let image_type = image_type; let url = format!( "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", ADOPTIUM_API_BASE, major_version, os, arch, image_type @@ -276,7 +280,7 @@ impl JavaProvider for AdoptiumProvider { file_name: asset.binary.package.name, file_size: asset.binary.package.size, checksum: asset.binary.package.checksum, - image_type: asset.binary.image_type, + image_type, }) } diff --git a/src-tauri/src/core/java/validation.rs b/src-tauri/src/core/java/validation.rs index b56ad59..b83d5a7 100644 --- a/src-tauri/src/core/java/validation.rs +++ b/src-tauri/src/core/java/validation.rs @@ -24,7 +24,13 @@ fn check_java_installation_blocking(path: &PathBuf) -> Option let output = cmd.output().ok()?; - let version_output = String::from_utf8_lossy(&output.stderr); + let stderr_output = String::from_utf8_lossy(&output.stderr); + let stdout_output = String::from_utf8_lossy(&output.stdout); + let version_output = if stderr_output.trim().is_empty() { + stdout_output + } else { + stderr_output + }; let version = parse_version_string(&version_output)?; let arch = extract_architecture(&version_output); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b74c746..7fcf6dd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1549,7 +1549,8 @@ async fn fetch_adoptium_java( "jdk" => core::java::ImageType::Jdk, _ => core::java::ImageType::Jre, }; - core::java::fetch_java_release(major_version, img_type) + let provider = core::java::providers::AdoptiumProvider::new(); + core::java::fetch_java_release_with(&provider, major_version, img_type) .await .map_err(|e| e.to_string()) } @@ -1567,15 +1568,23 @@ async fn download_adoptium_java( _ => core::java::ImageType::Jre, }; let path = custom_path.map(std::path::PathBuf::from); - core::java::download_and_install_java(&app_handle, major_version, img_type, path) - .await - .map_err(|e| e.to_string()) + let provider = core::java::providers::AdoptiumProvider::new(); + core::java::install::download_and_install_java_with_provider( + &provider, + &app_handle, + major_version, + img_type, + path, + ) + .await + .map_err(|e| e.to_string()) } /// Get available Adoptium Java versions #[tauri::command] async fn fetch_available_java_versions() -> Result, String> { - core::java::fetch_available_versions() + let provider = core::java::providers::AdoptiumProvider::new(); + core::java::fetch_available_versions_with(&provider) .await .map_err(|e| e.to_string()) } @@ -1585,7 +1594,8 @@ async fn fetch_available_java_versions() -> Result, String> { async fn fetch_java_catalog( app_handle: tauri::AppHandle, ) -> Result { - core::java::fetch_java_catalog(&app_handle, false) + let provider = core::java::providers::AdoptiumProvider::new(); + core::java::fetch_java_catalog_with(&provider, &app_handle, false) .await .map_err(|e| e.to_string()) } @@ -1595,7 +1605,8 @@ async fn fetch_java_catalog( async fn refresh_java_catalog( app_handle: tauri::AppHandle, ) -> Result { - core::java::fetch_java_catalog(&app_handle, true) + let provider = core::java::providers::AdoptiumProvider::new(); + core::java::fetch_java_catalog_with(&provider, &app_handle, true) .await .map_err(|e| e.to_string()) } @@ -1620,7 +1631,8 @@ async fn get_pending_java_downloads( async fn resume_java_downloads( app_handle: tauri::AppHandle, ) -> Result, String> { - core::java::resume_pending_downloads(&app_handle).await + let provider = core::java::providers::AdoptiumProvider::new(); + core::java::resume_pending_downloads_with(&provider, &app_handle).await } /// Get Minecraft versions supported by Fabric From a5f5babe204e805e43b318ecb84577c4a1107573 Mon Sep 17 00:00:00 2001 From: "Begonia, HE" <163421589+BegoniaHe@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:59:11 +0100 Subject: [PATCH 2/4] chore: delete .git_commit_msg.txt --- .git_commit_msg.txt | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .git_commit_msg.txt diff --git a/.git_commit_msg.txt b/.git_commit_msg.txt deleted file mode 100644 index c42c11e..0000000 --- a/.git_commit_msg.txt +++ /dev/null @@ -1,7 +0,0 @@ -refactor(java): add java catalog cache and reorganize providers - -add src-tauri/src/core/java/cache.rs for Java catalog caching -reorganize provider interfaces and move detection/install changes -update persistence, adoptium provider and validation - -Reviewed-by: Raptor mini (Preview) From 05935ca85247df3599141621e6c9a8f84cbe9707 Mon Sep 17 00:00:00 2001 From: begonia Date: Fri, 6 Feb 2026 03:20:37 +0100 Subject: [PATCH 3/4] docs: update copilot instructions - Update .github/copilot-instructions.md Reviewed-by: GitHub Copilot CLI --- .github/copilot-instructions.md | 172 +++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 57 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6b577d4..99d576d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,14 +3,15 @@ ## Architecture Overview **DropOut** is a Tauri v2 desktop application combining: + - **Backend (Rust)**: Game launching, asset management, authentication, mod loader installation -- **Frontend (Svelte 5)**: Reactive UI with Tailwind CSS 4 and particle effects +- **Frontend (React + Svelte)**: Active React UI in `packages/ui-new`, legacy Svelte 5 UI in `packages/ui` - **Communication**: Tauri commands (invoke) and events (emit/listen) - **Pre-commit Hooks**: Python-based tooling for JSON/TOML validation (managed via `pyproject.toml`) **Key Data Flow**: Frontend invokes Rust commands → Rust processes/downloads → Rust emits progress events → Frontend updates UI via listeners -**Version Management**: Uses `_version.py` for single source of truth, synced to `Cargo.toml` (0.1.24) and `tauri.conf.json` +**Version Management**: `Cargo.toml` is the source of truth; `scripts/bump-tauri.ts` syncs `src-tauri/tauri.conf.json` ## Project Structure @@ -33,59 +34,80 @@ src-tauri/ # Rust backend version_merge.rs # Parent version inheritance merging utils/ zip.rs # Native library extraction -ui/ # Svelte 5 frontend - src/ - App.svelte # Main app component, enforces dark mode - stores/ # Svelte 5 runes state management ($state, $effect) - auth.svelte.ts # Authentication state with device code polling - game.svelte.ts # Game state (running, logs) - settings.svelte.ts # Settings + Java detection - ui.svelte.ts # UI state (toasts, modals, active view) - components/ # UI components (HomeView, VersionsView, SettingsView, etc.) - lib/ # Reusable components (DownloadMonitor, GameConsole) +packages/ + ui-new/ # React frontend used by Tauri + src/ + main.tsx # React Router setup (hash routing) + pages/ # Route views (Home, Versions, Settings, ...) + stores/ # Zustand stores + components/ # UI components + ui/ # Legacy Svelte 5 frontend + src/ + App.svelte # Main app component, enforces dark mode + stores/ # Svelte 5 runes state management ($state, $effect) + auth.svelte.ts # Authentication state with device code polling + game.svelte.ts # Game state (running, logs) + settings.svelte.ts # Settings + Java detection + ui.svelte.ts # UI state (toasts, modals, active view) + components/ # UI components (HomeView, VersionsView, SettingsView, etc.) + lib/ # Reusable components (DownloadMonitor, GameConsole) ``` ## Critical Development Workflows ### Development Mode + ```bash -cargo tauri dev # Starts frontend dev server (Vite on :5173) + Tauri window +cargo tauri dev # Runs ui-new dev server (Vite on :5173) + Tauri window ``` -- Frontend uses **Rolldown-based Vite fork** (`npm:rolldown-vite@7.2.5`) with hot reload + +- `src-tauri/tauri.conf.json` runs `pnpm --filter @dropout/ui-new dev` +- Frontend uses **Rolldown-based Vite fork** (`npm:rolldown-vite@^7`) with hot reload - Backend recompiles on Rust file changes - Console shows both Rust stdout and frontend Vite logs -- **Vite Config**: Uses `usePolling: true` for watch compatibility with Tauri - **HMR**: WebSocket on `ws://localhost:5173` ### Pre-commit Checks + - Uses **pre-commit** with Python (configured in `pyproject.toml`) - Hooks: JSON/TOML/YAML validation, Ruff for Python files - Run manually: `pre-commit run --all-files` - **IMPORTANT**: All Python tooling for CI/validation lives here, NOT for app logic ### Building + ```bash -cd ui && pnpm install # Install frontend dependencies (requires pnpm 9, Node 22) -cargo tauri build # Produces platform bundles in src-tauri/target/release/bundle/ +pnpm install # Install workspace deps (requires pnpm 10, Node 22) +cargo tauri build # Produces bundles in src-tauri/target/release/bundle/ +pnpm --filter @dropout/ui-new build # Frontend-only build ``` ### Frontend Workflows + ```bash -cd ui -pnpm check # Svelte type checking + TypeScript validation -pnpm lint # OxLint for code quality -pnpm format # OxFmt for formatting (--check for CI) +# React UI (active) +pnpm --filter @dropout/ui-new lint # Biome check +pnpm --filter @dropout/ui-new build + +# Svelte UI (legacy) +pnpm --filter @dropout/ui check # Svelte + TS checks +pnpm --filter @dropout/ui lint # OxLint +pnpm --filter @dropout/ui format # OxFmt (--check for CI) ``` ### Testing + - CI workflow: [`.github/workflows/test.yml`](.github/workflows/test.yml) tests on Ubuntu, Arch (Wayland), Windows, macOS -- Local: `cargo test` (no comprehensive test suite exists yet) +- Local: `cargo test` (run from `src-tauri/`) +- Single test: `cargo test ` (unit) or `cargo test --test ` - **Test workflow behavior**: Push/PR = Linux build only, `workflow_dispatch` = full multi-platform builds ## Project-Specific Patterns & Conventions ### Tauri Command Pattern + Commands in [`main.rs`](../src-tauri/src/main.rs) follow this structure: + ```rust #[tauri::command] async fn command_name( @@ -98,35 +120,43 @@ async fn command_name( Ok(result) } ``` + **Register in `main()`:** + ```rust tauri::Builder::default() .invoke_handler(tauri::generate_handler![command_name, ...]) ``` ### Event Communication + **Rust → Frontend (Progress/Logs):** + ```rust // In Rust window.emit("launcher-log", "Downloading assets...")?; window.emit("download-progress", progress_struct)?; ``` + ```typescript -// In Frontend (Svelte) +// In Frontend (React/Svelte) import { listen } from "@tauri-apps/api/event"; const unlisten = await listen("launcher-log", (event) => { - console.log(event.payload); + console.log(event.payload); }); ``` **Frontend → Rust (Commands):** + ```typescript import { invoke } from "@tauri-apps/api/core"; const result = await invoke("start_game", { versionId: "1.20.4" }); ``` ### State Management (Rust) + Global state via Tauri's managed state: + ```rust pub struct ConfigState { pub config: Mutex, @@ -138,14 +168,16 @@ pub struct ConfigState { config_state: State<'_, ConfigState> ``` -### State Management (Svelte 5) -Uses **Svelte 5 runes** (not stores): +### State Management (Svelte 5, legacy UI) + +Uses **Svelte 5 runes** (not stores) in `packages/ui`: + ```typescript // stores/auth.svelte.ts export class AuthState { currentAccount = $state(null); // Reactive state isLoginModalOpen = $state(false); - + $effect(() => { // Side effects // Runs when dependencies change }); @@ -153,64 +185,83 @@ export class AuthState { // Export singleton export const authState = new AuthState(); ``` + **CRITICAL**: Stores are TypeScript classes with `$state` runes, not Svelte 4's `writable()`. Each store file exports a singleton instance. -**Store Pattern**: -- File: `stores/*.svelte.ts` (note `.svelte.ts` extension) +**Store Pattern**: + +- File: `packages/ui/src/stores/*.svelte.ts` (note `.svelte.ts` extension) - Class-based with reactive `$state` properties - Methods for actions (async operations with `invoke()`) - Derived values with `get` accessors - Side effects with `$effect()` (auto-tracks dependencies) +### State Management (React UI) + +`packages/ui-new` uses Zustand stores in `src/stores` with `create(...)` and hook exports (e.g., `useUIStore`). + ### Version Inheritance System + Modded versions (Fabric/Forge) use `inheritsFrom` field: + - [`version_merge.rs`](../src-tauri/src/core/version_merge.rs): Merges parent vanilla JSON with mod loader JSON - [`manifest.rs`](../src-tauri/src/core/manifest.rs): `load_version()` recursively resolves inheritance - Libraries, assets, arguments are merged from parent + modded version ### Microsoft Authentication Flow + Uses **Device Code Flow** (no redirect needed): + 1. Frontend calls `start_microsoft_login()` → gets device code + URL 2. User visits URL in browser, enters code -3. Frontend polls `complete_microsoft_login()` with device code -4. Rust exchanges code → MS token → Xbox Live → XSTS → Minecraft token -5. Stores MS refresh token for auto-refresh (see [`auth.rs`](../src-tauri/src/core/auth.rs)) +3. Frontend calls `complete_microsoft_login()` with device code +4. Rust exchanges code → MS token → Xbox Live → XSTS → Minecraft token → profile +5. Emits `auth-progress` during the flow and stores MS refresh token -**Client ID**: Uses ATLauncher's public client ID (`c36a9fb6-4f2a-41ff-90bd-ae7cc92031eb`) +**Client ID**: `CLIENT_ID` in [`auth.rs`](../src-tauri/src/core/auth.rs) is `fe165602-5410-4441-92f7-326e10a7cb82`. ### Download System + [`downloader.rs`](../src-tauri/src/core/downloader.rs) features: + - **Concurrent downloads** with semaphore (configurable threads) - **Resumable downloads**: `.part` + `.part.meta` files track progress - **Multi-segment downloads**: Large files split into segments downloaded in parallel - **Checksum verification**: SHA1/SHA256 validation -- **Progress events**: Emits `download-progress` with file name, bytes, ETA +- **Progress events**: Emits `download-progress` with file/status, bytes, and totals (plus `download-start`) - **Queue persistence**: Java downloads saved to `download_queue.json` for resumption ### Java Management -[`java.rs`](../src-tauri/src/core/java.rs): -- **Auto-detection**: Scans `/usr/lib/jvm`, `/Library/Java`, `JAVA_HOME`, `PATH` -- **Adoptium API**: Fetches available JDK/JRE versions for current OS/arch -- **Catalog caching**: `java_catalog.json` cached for 24 hours -- **Installation**: Downloads, extracts to `app_data_dir/java/` -- **Cancellation**: Global `AtomicBool` flag for download cancellation + +`src-tauri/src/core/java/` module: + +- **Auto-detection**: PATH/JAVA_HOME plus OS-specific directories (e.g., `/usr/lib/jvm`, `/Library/Java/JavaVirtualMachines`, `Program Files\\Java`) +- **Catalog caching**: `java_catalog_cache.json` cached for 24 hours +- **Installation**: Downloads with queue persistence, extracts to `app_data_dir/java/--` +- **Download progress event**: `java-download-progress` ### Error Handling + - Commands return `Result` (String for JS-friendly errors) - Use `.map_err(|e| e.to_string())` to convert errors - Emit detailed error logs: `emit_log!(window, format!("Error: {}", e))` ### File Paths -- **Game directory**: `app_handle.path().app_data_dir()` (~/.local/share/com.dropout.launcher on Linux) -- **Versions**: `game_dir/versions//.json` -- **Libraries**: `game_dir/libraries/` -- **Assets**: `game_dir/assets/objects//` -- **Config**: `game_dir/config.json` -- **Accounts**: `game_dir/accounts.json` + +- **App data root**: `app_handle.path().app_data_dir()` (platform-specific) +- **Instance metadata**: `app_data_dir/instances.json` +- **Instance game dir**: `app_data_dir/instances//` + - Versions: `versions//.json` + - Libraries: `libraries/` + - Assets: `assets/objects//` +- **Shared caches** (when `use_shared_caches`): `app_data_dir/{versions,libraries,assets}` +- **Config**: `app_data_dir/config.json` +- **Accounts**: `app_data_dir/accounts.json` ## Integration Points ### External APIs + - **Mojang**: `https://piston-meta.mojang.com/mc/game/version_manifest_v2.json` - **Fabric Meta**: `https://meta.fabricmc.net/v2/` - **Forge Maven**: `https://maven.minecraftforge.net/` @@ -218,6 +269,7 @@ Uses **Device Code Flow** (no redirect needed): - **GitHub Releases**: `https://api.github.com/repos/HsiangNianian/DropOut/releases` ### Native Dependencies + - **Linux**: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev` (see [test.yml](../.github/workflows/test.yml)) - **macOS**: System WebKit via Tauri - **Windows**: WebView2 runtime (bundled) @@ -225,40 +277,45 @@ Uses **Device Code Flow** (no redirect needed): ## Common Tasks ### Adding a New Tauri Command + 1. Define function in [`main.rs`](../src-tauri/src/main.rs) with `#[tauri::command]` 2. Add to `.invoke_handler(tauri::generate_handler![..., new_command])` 3. Call from frontend: `invoke("new_command", { args })` ### Adding a New UI View -1. Create component in `ui/src/components/NewView.svelte` -2. Import in [`App.svelte`](../ui/src/App.svelte) -3. Add navigation in [`Sidebar.svelte`](../ui/src/components/Sidebar.svelte) -4. Update `uiState.activeView` in [`ui.svelte.ts`](../ui/src/stores/ui.svelte.ts) + +- **React UI (active)**: add a page in `packages/ui-new/src/pages` and register the route in [`main.tsx`](../packages/ui-new/src/main.tsx). +- **Svelte UI (legacy)**: create component in `packages/ui/src/components`, import in [`App.svelte`](../packages/ui/src/App.svelte), update `uiState.activeView` in [`ui.svelte.ts`](../packages/ui/src/stores/ui.svelte.ts). ### Emitting Progress Events + Use `emit_log!` macro for launcher logs: + ```rust emit_log!(window, format!("Downloading {}", filename)); ``` + For custom events: + ```rust window.emit("custom-event", payload)?; ``` ### Handling Placeholders in Arguments + Game arguments may contain `${variable}` placeholders. Use the `has_unresolved_placeholder()` helper to skip malformed arguments (see [`main.rs:57-67`](../src-tauri/src/main.rs#L57-L67)). ## Important Notes -- **Dark mode enforced**: [`App.svelte`](../ui/src/App.svelte) force-adds `dark` class regardless of system preference +- **Dark mode enforced (legacy UI)**: [`App.svelte`](../packages/ui/src/App.svelte) force-adds `dark` class regardless of system preference - **Svelte 5 syntax**: Use `$state`, `$derived`, `$effect` (not `writable` stores) -- **No CREATE_NO_WINDOW on non-Windows**: Use `#[cfg(target_os = "windows")]` for Windows-specific code +- **Java launch on Windows**: Uses `CREATE_NO_WINDOW` behind `#[cfg(target_os = "windows")]` - **Version IDs**: Fabric uses `fabric-loader--`, Forge uses `-forge-` -- **Mod loader libraries**: Don't have `downloads.artifact`, use Maven resolution via [`maven.rs`](../src-tauri/src/core/maven.rs) +- **Library resolution**: When `downloads.artifact` is missing, resolve via Maven coordinates in [`maven.rs`](../src-tauri/src/core/maven.rs) - **Native extraction**: Extract to `versions//natives/`, exclude META-INF - **Classpath order**: Libraries → Client JAR (see [`main.rs:437-453`](../src-tauri/src/main.rs#L437-L453)) -- **Version management**: Single source in `_version.py`, synced to Cargo.toml and tauri.conf.json -- **Frontend dependencies**: Must use pnpm 9 + Node 22 (uses Rolldown-based Vite fork) +- **Version management**: `Cargo.toml` version is synced to `tauri.conf.json` via `pnpm bump-tauri` +- **Frontend dependencies**: Use Node 22 + pnpm 10 (Rolldown-based Vite fork) - **Store files**: Must have `.svelte.ts` extension, not `.ts` ## Debugging Tips @@ -272,15 +329,16 @@ Game arguments may contain `${variable}` placeholders. Use the `has_unresolved_p ## Version Compatibility - **Rust**: Edition 2021, requires Tauri v2 dependencies -- **Node.js**: 22+ with pnpm 9+ for frontend (uses Rolldown-based Vite fork `npm:rolldown-vite@7.2.5`) +- **Node.js**: 22+ with pnpm 10+ for frontend (uses Rolldown-based Vite fork `npm:rolldown-vite@^7`) - **Tauri**: v2.9+ - **Svelte**: v5.46+ (runes mode) -- **Java**: Supports detection of Java 8-23+, recommends Java 17+ for modern Minecraft +- **Java**: Required versions come from `javaVersion` in version JSON; Java 8 is enforced as a max for old versions - **Python**: 3.10+ for pre-commit hooks (validation only, not app logic) ## Commit Conventions Follow instructions in [`.github/instructions/commit.instructions.md`](.github/instructions/commit.instructions.md): + - **Format**: `[scope]: ` (lowercase, imperative, no period) - **AI commits**: MUST include `Reviewed-by: [MODEL_NAME]` - **Common types**: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `chore` From 1685fb59c0e841ecf2c8b4d1244246ec5a820a65 Mon Sep 17 00:00:00 2001 From: "Begonia, HE" <163421589+BegoniaHe@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:53:15 +0100 Subject: [PATCH 4/4] refactor(java): enhance download queue management and error handling --- src-tauri/src/core/downloader.rs | 108 +++++- src-tauri/src/core/java/error.rs | 135 +++++++ src-tauri/src/core/java/install.rs | 34 +- src-tauri/src/core/java/mod.rs | 55 ++- src-tauri/src/core/java/providers/adoptium.rs | 338 ++++++++++++++---- src-tauri/src/main.rs | 2 +- src-tauri/src/utils/zip.rs | 36 +- 7 files changed, 599 insertions(+), 109 deletions(-) diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index d4fc782..e57f746 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -4,11 +4,14 @@ use sha1::Digest as Sha1Digest; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; +use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, Window}; use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use tokio::sync::Semaphore; use ts_rs::TS; +static DOWNLOAD_QUEUE_FILE_LOCK: Mutex<()> = Mutex::new(()); + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts( @@ -102,15 +105,17 @@ pub struct DownloadQueue { } impl DownloadQueue { - /// Load download queue from file - pub fn load(app_handle: &AppHandle) -> Self { - let queue_path = app_handle + fn queue_path(app_handle: &AppHandle) -> Result { + app_handle .path() .app_data_dir() - .unwrap() - .join("download_queue.json"); + .map(|dir| dir.join("download_queue.json")) + .map_err(|e| e.to_string()) + } + + fn load_from_path(queue_path: &PathBuf) -> Self { if queue_path.exists() { - if let Ok(content) = std::fs::read_to_string(&queue_path) { + if let Ok(content) = std::fs::read_to_string(queue_path) { if let Ok(queue) = serde_json::from_str(&content) { return queue; } @@ -119,18 +124,93 @@ impl DownloadQueue { Self::default() } - /// Save download queue to file - pub fn save(&self, app_handle: &AppHandle) -> Result<(), String> { - let queue_path = app_handle - .path() - .app_data_dir() - .unwrap() - .join("download_queue.json"); + fn save_atomic_to_path(&self, queue_path: &PathBuf) -> Result<(), String> { + let parent = queue_path + .parent() + .ok_or_else(|| "Download queue path has no parent directory".to_string())?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let content = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; - std::fs::write(&queue_path, content).map_err(|e| e.to_string())?; + let tmp_path = queue_path.with_extension("json.tmp"); + std::fs::write(&tmp_path, content).map_err(|e| e.to_string())?; + + if queue_path.exists() { + let _ = std::fs::remove_file(queue_path); + } + + std::fs::rename(&tmp_path, queue_path).map_err(|e| e.to_string())?; Ok(()) } + pub fn update(app_handle: &AppHandle, mutator: F) -> Result + where + F: FnOnce(&mut Self) -> T, + { + let _guard = DOWNLOAD_QUEUE_FILE_LOCK + .lock() + .map_err(|_| "Download queue lock poisoned".to_string())?; + let queue_path = Self::queue_path(app_handle)?; + let mut queue = Self::load_from_path(&queue_path); + let result = mutator(&mut queue); + queue.save_atomic_to_path(&queue_path)?; + Ok(result) + } + + pub fn list_pending(app_handle: &AppHandle) -> Vec { + let _guard = match DOWNLOAD_QUEUE_FILE_LOCK.lock() { + Ok(guard) => guard, + Err(_) => return Vec::new(), + }; + + let queue_path = match Self::queue_path(app_handle) { + Ok(path) => path, + Err(_) => return Vec::new(), + }; + + Self::load_from_path(&queue_path).pending_downloads + } + + pub fn add_pending( + app_handle: &AppHandle, + download: PendingJavaDownload, + ) -> Result<(), String> { + Self::update(app_handle, |queue| queue.add(download)).map(|_| ()) + } + + pub fn remove_pending( + app_handle: &AppHandle, + major_version: u32, + image_type: &str, + ) -> Result<(), String> { + Self::update(app_handle, |queue| queue.remove(major_version, image_type)).map(|_| ()) + } + + /// Load download queue from file + #[allow(dead_code)] + pub fn load(app_handle: &AppHandle) -> Self { + let _guard = match DOWNLOAD_QUEUE_FILE_LOCK.lock() { + Ok(guard) => guard, + Err(_) => return Self::default(), + }; + + let queue_path = match Self::queue_path(app_handle) { + Ok(path) => path, + Err(_) => return Self::default(), + }; + + Self::load_from_path(&queue_path) + } + + /// Save download queue to file + #[allow(dead_code)] + pub fn save(&self, app_handle: &AppHandle) -> Result<(), String> { + let _guard = DOWNLOAD_QUEUE_FILE_LOCK + .lock() + .map_err(|_| "Download queue lock poisoned".to_string())?; + let queue_path = Self::queue_path(app_handle)?; + self.save_atomic_to_path(&queue_path) + } + /// Add a pending download pub fn add(&mut self, download: PendingJavaDownload) { // Remove existing download for same version/type diff --git a/src-tauri/src/core/java/error.rs b/src-tauri/src/core/java/error.rs index bf78d3b..d0066e0 100644 --- a/src-tauri/src/core/java/error.rs +++ b/src-tauri/src/core/java/error.rs @@ -1,4 +1,6 @@ +use serde::{Deserialize, Serialize}; use std::fmt; +use ts_rs::TS; /// Unified error type for Java component operations /// @@ -32,6 +34,65 @@ pub enum JavaError { Other(String), } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts( + export, + export_to = "../../packages/ui-new/src/types/bindings/java/index.ts" +)] +pub enum ResumeJavaDownloadFailureReason { + Network, + Cancelled, + ChecksumMismatch, + ExtractionFailed, + VerificationFailed, + Filesystem, + InvalidArchive, + Unknown, +} + +impl ResumeJavaDownloadFailureReason { + /// Classify a raw error message into a stable reason enum for metrics/alert aggregation. + pub fn from_error_message(error: &str) -> Self { + let lower = error.to_ascii_lowercase(); + + if lower.contains("cancel") { + return Self::Cancelled; + } + if lower.contains("checksum") { + return Self::ChecksumMismatch; + } + if lower.contains("extract") || lower.contains("unsupported archive") { + return Self::ExtractionFailed; + } + if lower.contains("verify") || lower.contains("java executable not found") { + return Self::VerificationFailed; + } + if lower.contains("request") + || lower.contains("network") + || lower.contains("timed out") + || lower.contains("http status") + { + return Self::Network; + } + if lower.contains("archive") || lower.contains("top-level directory") { + return Self::InvalidArchive; + } + if lower.contains("create") + || lower.contains("write") + || lower.contains("read") + || lower.contains("rename") + || lower.contains("permission") + || lower.contains("directory") + || lower.contains("path") + { + return Self::Filesystem; + } + + Self::Unknown + } +} + impl fmt::Display for JavaError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -93,3 +154,77 @@ impl From for JavaError { JavaError::Other(err) } } + +#[cfg(test)] +mod tests { + use super::ResumeJavaDownloadFailureReason; + + #[test] + fn classify_resume_failure_reason_table_driven() { + let cases = [ + ( + "Download cancelled by user", + ResumeJavaDownloadFailureReason::Cancelled, + ), + ( + "Checksum verification failed", + ResumeJavaDownloadFailureReason::ChecksumMismatch, + ), + ( + "Failed to extract file: invalid archive", + ResumeJavaDownloadFailureReason::ExtractionFailed, + ), + ( + "Failed to verify Java installation", + ResumeJavaDownloadFailureReason::VerificationFailed, + ), + ( + "Request failed: timed out", + ResumeJavaDownloadFailureReason::Network, + ), + ("HTTP status 503", ResumeJavaDownloadFailureReason::Network), + ( + "Failed to create directory", + ResumeJavaDownloadFailureReason::Filesystem, + ), + ( + "Top-level directory not found in archive", + ResumeJavaDownloadFailureReason::InvalidArchive, + ), + ( + "completely unknown issue", + ResumeJavaDownloadFailureReason::Unknown, + ), + ]; + + for (message, expected) in cases { + let actual = ResumeJavaDownloadFailureReason::from_error_message(message); + assert_eq!( + std::mem::discriminant(&actual), + std::mem::discriminant(&expected), + "message '{}' expected {:?} but got {:?}", + message, + expected, + actual + ); + } + } + + #[test] + fn classify_resume_failure_reason_priority_rules() { + let cancelled_over_network = + ResumeJavaDownloadFailureReason::from_error_message("request cancelled due to timeout"); + assert_eq!( + std::mem::discriminant(&cancelled_over_network), + std::mem::discriminant(&ResumeJavaDownloadFailureReason::Cancelled) + ); + + let checksum_over_filesystem = ResumeJavaDownloadFailureReason::from_error_message( + "failed to read file: checksum mismatch", + ); + assert_eq!( + std::mem::discriminant(&checksum_over_filesystem), + std::mem::discriminant(&ResumeJavaDownloadFailureReason::ChecksumMismatch) + ); + } +} diff --git a/src-tauri/src/core/java/install.rs b/src-tauri/src/core/java/install.rs index afc6d13..6337388 100644 --- a/src-tauri/src/core/java/install.rs +++ b/src-tauri/src/core/java/install.rs @@ -33,21 +33,22 @@ pub async fn download_and_install_java_with_provider( std::fs::create_dir_all(&install_base) .map_err(|e| format!("Failed to create installation directory: {}", e))?; - let mut queue = DownloadQueue::load(app_handle); - queue.add(PendingJavaDownload { - major_version, - image_type: image_type.to_string(), - download_url: info.download_url.clone(), - file_name: info.file_name.clone(), - file_size: info.file_size, - checksum: info.checksum.clone(), - install_path: install_base.to_string_lossy().to_string(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - }); - queue.save(app_handle)?; + DownloadQueue::add_pending( + app_handle, + PendingJavaDownload { + major_version, + image_type: image_type.to_string(), + download_url: info.download_url.clone(), + file_name: info.file_name.clone(), + file_size: info.file_size, + checksum: info.checksum.clone(), + install_path: install_base.to_string_lossy().to_string(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }, + )?; let archive_path = install_base.join(&info.file_name); @@ -123,8 +124,7 @@ pub async fn download_and_install_java_with_provider( .await .ok_or_else(|| "Failed to verify Java installation".to_string())?; - queue.remove(major_version, &image_type.to_string()); - queue.save(app_handle)?; + DownloadQueue::remove_pending(app_handle, major_version, &image_type.to_string())?; let _ = app_handle.emit( "java-download-progress", diff --git a/src-tauri/src/core/java/mod.rs b/src-tauri/src/core/java/mod.rs index b7c3457..7675af3 100644 --- a/src-tauri/src/core/java/mod.rs +++ b/src-tauri/src/core/java/mod.rs @@ -13,7 +13,7 @@ pub mod providers; pub mod validation; pub use cache::{load_cached_catalog_result, save_catalog_cache}; -pub use error::JavaError; +pub use error::{JavaError, ResumeJavaDownloadFailureReason}; use ts_rs::TS; /// Remove the UNC prefix (\\?\) from Windows paths @@ -67,6 +67,30 @@ pub struct JavaInstallation { pub is_64bit: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts( + export, + export_to = "../../packages/ui-new/src/types/bindings/java/index.ts" +)] +pub struct ResumeJavaDownloadFailure { + pub major_version: u32, + pub image_type: String, + pub install_path: String, + pub reason: ResumeJavaDownloadFailureReason, + pub error: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts( + export, + export_to = "../../packages/ui-new/src/types/bindings/java/index.ts" +)] +pub struct ResumeJavaDownloadsResult { + pub successful_installations: Vec, + pub failed_downloads: Vec, + pub total_pending: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts( @@ -296,11 +320,13 @@ fn find_java_executable(dir: &PathBuf) -> Option { pub async fn resume_pending_downloads_with( provider: &P, app_handle: &AppHandle, -) -> Result, String> { - let queue = DownloadQueue::load(app_handle); +) -> Result { + let pending_downloads = DownloadQueue::list_pending(app_handle); let mut installed = Vec::new(); + let mut failed = Vec::new(); + let total_pending = pending_downloads.len(); - for pending in queue.pending_downloads.iter() { + for pending in pending_downloads.iter() { let image_type = ImageType::parse(&pending.image_type).unwrap_or_else(|| { eprintln!( "Unknown image type '{}' in pending download, defaulting to jre", @@ -326,11 +352,23 @@ pub async fn resume_pending_downloads_with( "Failed to resume Java {} {} download: {}", pending.major_version, pending.image_type, e ); + + failed.push(ResumeJavaDownloadFailure { + major_version: pending.major_version, + image_type: pending.image_type.clone(), + install_path: pending.install_path.clone(), + reason: ResumeJavaDownloadFailureReason::from_error_message(&e), + error: e, + }); } } } - Ok(installed) + Ok(ResumeJavaDownloadsResult { + successful_installations: installed, + failed_downloads: failed, + total_pending, + }) } pub fn cancel_current_download() { @@ -338,8 +376,7 @@ pub fn cancel_current_download() { } pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec { - let queue = DownloadQueue::load(app_handle); - queue.pending_downloads + DownloadQueue::list_pending(app_handle) } #[allow(dead_code)] @@ -348,7 +385,5 @@ pub fn clear_pending_download( 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) + DownloadQueue::remove_pending(app_handle, major_version, image_type) } diff --git a/src-tauri/src/core/java/providers/adoptium.rs b/src-tauri/src/core/java/providers/adoptium.rs index fb86500..cfdf8d1 100644 --- a/src-tauri/src/core/java/providers/adoptium.rs +++ b/src-tauri/src/core/java/providers/adoptium.rs @@ -3,10 +3,22 @@ use crate::core::java::provider::JavaProvider; use crate::core::java::save_catalog_cache; use crate::core::java::{ImageType, JavaCatalog, JavaDownloadInfo, JavaReleaseInfo}; use serde::Deserialize; +use std::sync::Arc; use tauri::AppHandle; +use tokio::sync::Semaphore; +use tokio::time::{sleep, Duration}; use ts_rs::TS; const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; +const CATALOG_MAX_CONCURRENT_REQUESTS: usize = 6; +#[cfg(not(test))] +const CATALOG_REQUEST_RETRIES: u32 = 3; +#[cfg(test)] +const CATALOG_REQUEST_RETRIES: u32 = 2; // TODO: Consider making this configurable +#[cfg(not(test))] +const CATALOG_RETRY_BASE_DELAY_MS: u64 = 300; +#[cfg(test)] +const CATALOG_RETRY_BASE_DELAY_MS: u64 = 10; // TODO: Consider making this configurable #[derive(Debug, Clone, Deserialize, TS)] #[ts( @@ -87,6 +99,91 @@ impl Default for AdoptiumProvider { } } +async fn fetch_available_releases_with_retry( + client: &reqwest::Client, + releases_url: &str, +) -> Result { + for attempt in 0..=CATALOG_REQUEST_RETRIES { + let response = client + .get(releases_url) + .header("Accept", "application/json") + .send() + .await; + + match response { + Ok(resp) => { + if resp.status().is_success() { + return resp.json::().await.map_err(|e| { + JavaError::SerializationError(format!( + "Failed to parse available releases: {}", + e + )) + }); + } + + if attempt == CATALOG_REQUEST_RETRIES { + return Err(JavaError::NetworkError(format!( + "Failed to fetch available releases after retries, status: {}", + resp.status() + ))); + } + } + Err(err) => { + if attempt == CATALOG_REQUEST_RETRIES { + return Err(JavaError::NetworkError(format!( + "Failed to fetch available releases: {}", + err + ))); + } + } + } + + let backoff = CATALOG_RETRY_BASE_DELAY_MS * (1_u64 << attempt); + sleep(Duration::from_millis(backoff)).await; + } + + Err(JavaError::NetworkError( + "Failed to fetch available releases after retries".to_string(), + )) +} + +async fn fetch_adoptium_assets_with_retry( + client: &reqwest::Client, + url: &str, +) -> Result, String> { + let mut last_error = "unknown error".to_string(); + + for attempt in 0..=CATALOG_REQUEST_RETRIES { + match client + .get(url) + .header("Accept", "application/json") + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + return response + .json::>() + .await + .map_err(|e| format!("Failed to parse assets response: {}", e)); + } + + last_error = format!("HTTP status {}", response.status()); + } + Err(err) => { + last_error = err.to_string(); + } + } + + if attempt < CATALOG_REQUEST_RETRIES { + let backoff = CATALOG_RETRY_BASE_DELAY_MS * (1_u64 << attempt); + sleep(Duration::from_millis(backoff)).await; + } + } + + Err(last_error) +} + impl JavaProvider for AdoptiumProvider { async fn fetch_catalog( &self, @@ -108,22 +205,11 @@ impl JavaProvider for AdoptiumProvider { let client = reqwest::Client::new(); let releases_url = format!("{}/info/available_releases", ADOPTIUM_API_BASE); - let available: AvailableReleases = client - .get(&releases_url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| { - JavaError::NetworkError(format!("Failed to fetch available releases: {}", e)) - })? - .json::() - .await - .map_err(|e| { - JavaError::SerializationError(format!("Failed to parse available releases: {}", e)) - })?; + let available = fetch_available_releases_with_retry(&client, &releases_url).await?; - // Parallelize HTTP requests for better performance + // Bounded parallel requests with retry to avoid request spikes. let mut fetch_tasks = Vec::new(); + let semaphore = Arc::new(Semaphore::new(CATALOG_MAX_CONCURRENT_REQUESTS)); for major_version in &available.available_releases { for image_type in [ImageType::Jre, ImageType::Jdk] { @@ -136,64 +222,50 @@ impl JavaProvider for AdoptiumProvider { let client = client.clone(); let is_lts = available.available_lts_releases.contains(&major_version); let arch = arch.to_string(); + let semaphore = semaphore.clone(); let task = tokio::spawn(async move { - match client - .get(&url) - .header("Accept", "application/json") - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - if let Ok(assets) = response.json::>().await { - if let Some(asset) = assets.into_iter().next() { - let release_date = asset.binary.updated_at.clone(); - return Some(JavaReleaseInfo { - major_version, - image_type, - version: asset.version.semver.clone(), - release_name: asset.release_name.clone(), - release_date, - file_size: asset.binary.package.size, - checksum: asset.binary.package.checksum, - download_url: asset.binary.package.link, - is_lts, - is_available: true, - architecture: asset.binary.architecture.clone(), - }); - } - } - } - // Fallback for unsuccessful response - Some(JavaReleaseInfo { + let _permit = semaphore.acquire_owned().await.ok(); + + if let Ok(assets) = fetch_adoptium_assets_with_retry(&client, &url).await { + if let Some(asset) = assets.into_iter().next() { + let release_date = asset.binary.updated_at.clone(); + return Some(JavaReleaseInfo { major_version, image_type, - version: format!("{}.x", major_version), - release_name: format!("jdk-{}", major_version), - release_date: None, - file_size: 0, - checksum: None, - download_url: String::new(), + version: asset.version.semver.clone(), + release_name: asset.release_name.clone(), + release_date, + file_size: asset.binary.package.size, + checksum: asset.binary.package.checksum, + download_url: asset.binary.package.link, is_lts, - is_available: false, - architecture: arch, - }) + is_available: true, + architecture: asset.binary.architecture.clone(), + }); } - Err(_) => Some(JavaReleaseInfo { + } else { + log::warn!( + "Adoptium catalog fetch failed after retries (major={}, image_type={})", major_version, - image_type, - version: format!("{}.x", major_version), - release_name: format!("jdk-{}", major_version), - release_date: None, - file_size: 0, - checksum: None, - download_url: String::new(), - is_lts, - is_available: false, - architecture: arch, - }), + image_type + ); } + + // Fallback for failed/unavailable response + Some(JavaReleaseInfo { + major_version, + image_type, + version: format!("{}.x", major_version), + release_name: format!("jdk-{}", major_version), + release_date: None, + file_size: 0, + checksum: None, + download_url: String::new(), + is_lts, + is_available: false, + architecture: arch, + }) }); fetch_tasks.push(task); } @@ -357,3 +429,141 @@ impl JavaProvider for AdoptiumProvider { "temurin" } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + use std::time::Duration as StdDuration; + + #[derive(Clone)] + struct MockResponse { + status_line: &'static str, + body: &'static str, + delay_ms: u64, + } + + fn spawn_mock_server( + responses: Vec, + ) -> (String, Arc, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server failed"); + let addr = listener.local_addr().expect("read local addr failed"); + let hits = Arc::new(AtomicUsize::new(0)); + let hits_clone = Arc::clone(&hits); + + let handle = thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().expect("accept failed"); + hits_clone.fetch_add(1, Ordering::SeqCst); + + let _ = stream.set_read_timeout(Some(StdDuration::from_millis(100))); + let mut buf = [0_u8; 2048]; + let _ = stream.read(&mut buf); + + if response.delay_ms > 0 { + thread::sleep(StdDuration::from_millis(response.delay_ms)); + } + + let body_bytes = response.body.as_bytes(); + let head = format!( + "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response.status_line, + body_bytes.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body_bytes); + let _ = stream.flush(); + } + }); + + (format!("http://{}", addr), hits, handle) + } + + #[tokio::test] + async fn fetch_available_releases_retries_on_timeout() { + let attempts = CATALOG_REQUEST_RETRIES as usize + 1; + let responses = vec![ + MockResponse { + status_line: "200 OK", + body: "{\"available_releases\":[21],\"available_lts_releases\":[21],\"most_recent_lts\":21,\"most_recent_feature_release\":21}", + delay_ms: 120, + }; + attempts + ]; + let (base_url, hits, handle) = spawn_mock_server(responses); + + let client = reqwest::Client::builder() + .timeout(StdDuration::from_millis(30)) + .build() + .expect("build client failed"); + + let result = + fetch_available_releases_with_retry(&client, &format!("{}/releases", base_url)).await; + let _ = handle.join(); + + assert!(matches!(result, Err(JavaError::NetworkError(_)))); + assert_eq!(hits.load(Ordering::SeqCst), attempts); + } + + #[tokio::test] + async fn fetch_available_releases_retries_on_http_5xx() { + let attempts = CATALOG_REQUEST_RETRIES as usize + 1; + let responses = vec![ + MockResponse { + status_line: "503 Service Unavailable", + body: "{}", + delay_ms: 0, + }; + attempts + ]; + let (base_url, hits, handle) = spawn_mock_server(responses); + let client = reqwest::Client::new(); + + let result = + fetch_available_releases_with_retry(&client, &format!("{}/releases", base_url)).await; + let _ = handle.join(); + + assert!(matches!(result, Err(JavaError::NetworkError(_)))); + assert_eq!(hits.load(Ordering::SeqCst), attempts); + } + + #[tokio::test] + async fn fetch_available_releases_parse_failure_returns_serialization_error() { + let responses = vec![MockResponse { + status_line: "200 OK", + body: "{this is invalid json", + delay_ms: 0, + }]; + let (base_url, hits, handle) = spawn_mock_server(responses); + let client = reqwest::Client::new(); + + let result = + fetch_available_releases_with_retry(&client, &format!("{}/releases", base_url)).await; + let _ = handle.join(); + + assert!(matches!(result, Err(JavaError::SerializationError(_)))); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_assets_parse_failure_returns_error() { + let responses = vec![MockResponse { + status_line: "200 OK", + body: "[invalid", + delay_ms: 0, + }]; + let (base_url, hits, handle) = spawn_mock_server(responses); + let client = reqwest::Client::new(); + + let result = + fetch_adoptium_assets_with_retry(&client, &format!("{}/assets", base_url)).await; + let _ = handle.join(); + + assert!(result.is_err()); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7fcf6dd..005f7a9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1630,7 +1630,7 @@ async fn get_pending_java_downloads( #[tauri::command] async fn resume_java_downloads( app_handle: tauri::AppHandle, -) -> Result, String> { +) -> Result { let provider = core::java::providers::AdoptiumProvider::new(); core::java::resume_pending_downloads_with(&provider, &app_handle).await } diff --git a/src-tauri/src/utils/zip.rs b/src-tauri/src/utils/zip.rs index dfe1214..e164bd5 100644 --- a/src-tauri/src/utils/zip.rs +++ b/src-tauri/src/utils/zip.rs @@ -1,8 +1,33 @@ use flate2::read::GzDecoder; use std::fs; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use tar::Archive; +fn sanitize_relative_path(entry_path: &Path) -> Result { + let mut safe_path = PathBuf::new(); + + for component in entry_path.components() { + match component { + Component::Normal(part) => safe_path.push(part), + Component::CurDir => {} + Component::ParentDir => { + return Err(format!( + "Unsafe archive entry path detected (parent traversal): {}", + entry_path.display() + )); + } + Component::RootDir | Component::Prefix(_) => { + return Err(format!( + "Unsafe archive entry path detected (absolute path): {}", + entry_path.display() + )); + } + } + } + + Ok(safe_path) +} + pub fn extract_zip(zip_path: &Path, extract_to: &Path) -> Result<(), String> { let file = fs::File::open(zip_path) .map_err(|e| format!("Failed to open zip {}: {}", zip_path.display(), e))?; @@ -69,9 +94,14 @@ pub fn extract_tar_gz(archive_path: &Path, extract_to: &Path) -> Result Result