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/3] 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/3] 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/3] 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`