diff options
| author | 2026-01-15 05:29:58 +0100 | |
|---|---|---|
| committer | 2026-01-15 05:29:58 +0100 | |
| commit | 43a3e9c285f3d5d04fef025041a06609a0d1c218 (patch) | |
| tree | c18970f3e926e079d5857219031bbaf8d37a6901 | |
| parent | 1b3c84b0c78ea438c8f446054af196c620d30602 (diff) | |
| download | DropOut-43a3e9c285f3d5d04fef025041a06609a0d1c218.tar.gz DropOut-43a3e9c285f3d5d04fef025041a06609a0d1c218.zip | |
feat(java): Implement Java catalog management and download features
- Added commands to fetch and refresh the Java catalog, cancel downloads, and manage pending downloads.
- Enhanced the Java download modal in the UI to support version selection, download progress, and pending downloads.
- Introduced new types for Java catalog, download progress, and pending downloads.
- Updated settings store to handle Java catalog state, download progress, and pending downloads.
- Improved user experience with loading states, error handling, and status notifications for Java installations.
| -rw-r--r-- | src-tauri/Cargo.toml | 2 | ||||
| -rw-r--r-- | src-tauri/src/core/downloader.rs | 400 | ||||
| -rw-r--r-- | src-tauri/src/core/java.rs | 369 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 52 | ||||
| -rw-r--r-- | ui/src/App.svelte | 23 | ||||
| -rw-r--r-- | ui/src/components/SettingsView.svelte | 375 | ||||
| -rw-r--r-- | ui/src/stores/settings.svelte.ts | 294 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 44 |
8 files changed, 1429 insertions, 130 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bc831fb..27a0e0c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ toml = "0.5" log = "0.4" env_logger = "0.9" tokio = { version = "1.49.0", features = ["full"] } -reqwest = { version = "0.13.1", features = ["json", "blocking"] } +reqwest = { version = "0.13.1", features = ["json", "blocking", "stream"] } serde_json = "1.0.149" tauri = { version = "2.9", features = [] } tauri-plugin-shell = "2.3" diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs index d33c44d..bf6334f 100644 --- a/src-tauri/src/core/downloader.rs +++ b/src-tauri/src/core/downloader.rs @@ -2,10 +2,10 @@ use futures::StreamExt; use serde::{Deserialize, Serialize}; use sha1::Digest as Sha1Digest; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; -use tauri::{Emitter, Window}; -use tokio::io::AsyncWriteExt; +use tauri::{AppHandle, Emitter, Manager, Window}; +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use tokio::sync::Semaphore; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -18,6 +18,400 @@ pub struct DownloadTask { pub sha256: Option<String>, } +/// Metadata for resumable downloads stored in .part.meta file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadMetadata { + pub url: String, + pub file_name: String, + pub total_size: u64, + pub downloaded_bytes: u64, + pub checksum: Option<String>, + pub timestamp: u64, + pub segments: Vec<DownloadSegment>, +} + +/// A download segment for multi-segment parallel downloading +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadSegment { + pub start: u64, + pub end: u64, + pub downloaded: u64, + pub completed: bool, +} + +/// Progress event for Java download +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JavaDownloadProgress { + pub file_name: String, + pub downloaded_bytes: u64, + pub total_bytes: u64, + pub speed_bytes_per_sec: u64, + pub eta_seconds: u64, + pub status: String, // "Downloading", "Extracting", "Verifying", "Completed", "Paused", "Error" + pub percentage: f32, +} + +/// Pending download task for queue persistence +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingJavaDownload { + pub major_version: u32, + pub image_type: String, + pub download_url: String, + pub file_name: String, + pub file_size: u64, + pub checksum: Option<String>, + pub install_path: String, + pub created_at: u64, +} + +/// Download queue for persistence +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DownloadQueue { + pub pending_downloads: Vec<PendingJavaDownload>, +} + +impl DownloadQueue { + /// Load download queue from file + pub fn load(app_handle: &AppHandle) -> Self { + let queue_path = app_handle + .path() + .app_data_dir() + .unwrap() + .join("download_queue.json"); + if queue_path.exists() { + if let Ok(content) = std::fs::read_to_string(&queue_path) { + if let Ok(queue) = serde_json::from_str(&content) { + return queue; + } + } + } + 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"); + 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())?; + Ok(()) + } + + /// Add a pending download + pub fn add(&mut self, download: PendingJavaDownload) { + // Remove existing download for same version/type + self.pending_downloads.retain(|d| { + !(d.major_version == download.major_version && d.image_type == download.image_type) + }); + self.pending_downloads.push(download); + } + + /// Remove a completed or cancelled download + pub fn remove(&mut self, major_version: u32, image_type: &str) { + self.pending_downloads.retain(|d| { + !(d.major_version == major_version && d.image_type == image_type) + }); + } +} + +/// Global cancel flag for Java downloads +pub static JAVA_DOWNLOAD_CANCELLED: AtomicBool = AtomicBool::new(false); + +/// Reset the cancel flag +pub fn reset_java_download_cancel() { + JAVA_DOWNLOAD_CANCELLED.store(false, Ordering::SeqCst); +} + +/// Cancel the current Java download +pub fn cancel_java_download() { + JAVA_DOWNLOAD_CANCELLED.store(true, Ordering::SeqCst); +} + +/// Check if download is cancelled +pub fn is_java_download_cancelled() -> bool { + JAVA_DOWNLOAD_CANCELLED.load(Ordering::SeqCst) +} + +/// Determine optimal segment count based on file size +fn get_segment_count(file_size: u64) -> usize { + if file_size < 20 * 1024 * 1024 { + 1 // < 20MB: single segment + } else if file_size < 100 * 1024 * 1024 { + 4 // 20-100MB: 4 segments + } else { + 8 // > 100MB: 8 segments + } +} + +/// Download a large file with resume support and progress events +pub async fn download_with_resume( + app_handle: &AppHandle, + url: &str, + dest_path: &PathBuf, + checksum: Option<&str>, + total_size: u64, +) -> Result<(), String> { + reset_java_download_cancel(); + + let part_path = dest_path.with_extension( + dest_path + .extension() + .map(|e| format!("{}.part", e.to_string_lossy())) + .unwrap_or_else(|| "part".to_string()), + ); + let meta_path = PathBuf::from(format!("{}.meta", part_path.display())); + let file_name = dest_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // Load or create metadata + let mut metadata = if meta_path.exists() { + let content = tokio::fs::read_to_string(&meta_path) + .await + .map_err(|e| e.to_string())?; + serde_json::from_str(&content).unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum)) + } else { + create_new_metadata(url, &file_name, total_size, checksum) + }; + + // Create parent directory + if let Some(parent) = dest_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + // Open or create part file + let file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .read(true) + .open(&part_path) + .await + .map_err(|e| format!("Failed to open part file: {}", e))?; + + let file = Arc::new(tokio::sync::Mutex::new(file)); + let client = reqwest::Client::new(); + let progress = Arc::new(AtomicU64::new(metadata.downloaded_bytes)); + let start_time = std::time::Instant::now(); + let last_progress_bytes = Arc::new(AtomicU64::new(metadata.downloaded_bytes)); + + // Download segments concurrently + let segment_count = metadata.segments.len(); + let semaphore = Arc::new(Semaphore::new(segment_count.min(8))); + let mut handles = Vec::new(); + + for (idx, segment) in metadata.segments.iter().enumerate() { + if segment.completed { + continue; + } + + let client = client.clone(); + let url = url.to_string(); + let file = file.clone(); + let progress = progress.clone(); + let semaphore = semaphore.clone(); + let segment_start = segment.start + segment.downloaded; + let segment_end = segment.end; + let app_handle = app_handle.clone(); + let file_name = file_name.clone(); + let total_size = total_size; + let last_progress_bytes = last_progress_bytes.clone(); + let start_time = start_time.clone(); + + let handle = tokio::spawn(async move { + let _permit = semaphore.acquire().await.unwrap(); + + if is_java_download_cancelled() { + return Err("Download cancelled".to_string()); + } + + // Send Range request + let range = format!("bytes={}-{}", segment_start, segment_end); + let response = client + .get(&url) + .header("Range", &range) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + return Err(format!("Server returned error: {}", response.status())); + } + + let mut stream = response.bytes_stream(); + let mut current_pos = segment_start; + + while let Some(chunk_result) = stream.next().await { + if is_java_download_cancelled() { + return Err("Download cancelled".to_string()); + } + + let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; + let chunk_len = chunk.len() as u64; + + // Write to file at correct position + { + let mut file_guard = file.lock().await; + file_guard + .seek(std::io::SeekFrom::Start(current_pos)) + .await + .map_err(|e| format!("Seek error: {}", e))?; + file_guard + .write_all(&chunk) + .await + .map_err(|e| format!("Write error: {}", e))?; + } + + current_pos += chunk_len; + let total_downloaded = progress.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len; + + // Emit progress event (throttled) + let last_bytes = last_progress_bytes.load(Ordering::Relaxed); + if total_downloaded - last_bytes > 100 * 1024 || total_downloaded >= total_size { + last_progress_bytes.store(total_downloaded, Ordering::Relaxed); + + let elapsed = start_time.elapsed().as_secs_f64(); + let speed = if elapsed > 0.0 { + (total_downloaded as f64 / elapsed) as u64 + } else { + 0 + }; + let remaining = total_size.saturating_sub(total_downloaded); + let eta = if speed > 0 { remaining / speed } else { 0 }; + let percentage = (total_downloaded as f32 / total_size as f32) * 100.0; + + let _ = app_handle.emit( + "java-download-progress", + JavaDownloadProgress { + file_name: file_name.clone(), + downloaded_bytes: total_downloaded, + total_bytes: total_size, + speed_bytes_per_sec: speed, + eta_seconds: eta, + status: "Downloading".to_string(), + percentage, + }, + ); + } + } + + Ok::<usize, String>(idx) + }); + + handles.push(handle); + } + + // Wait for all segments + let mut all_success = true; + for handle in handles { + match handle.await { + Ok(Ok(idx)) => { + metadata.segments[idx].completed = true; + } + Ok(Err(e)) => { + all_success = false; + if e.contains("cancelled") { + // Save progress for resume + metadata.downloaded_bytes = progress.load(Ordering::Relaxed); + let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; + tokio::fs::write(&meta_path, meta_content).await.ok(); + return Err(e); + } + } + Err(e) => { + all_success = false; + eprintln!("Segment task panicked: {}", e); + } + } + } + + if !all_success { + // Save progress + metadata.downloaded_bytes = progress.load(Ordering::Relaxed); + let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; + tokio::fs::write(&meta_path, meta_content).await.ok(); + return Err("Some segments failed".to_string()); + } + + // Verify checksum if provided + if let Some(expected) = checksum { + let _ = app_handle.emit( + "java-download-progress", + JavaDownloadProgress { + file_name: file_name.clone(), + downloaded_bytes: total_size, + total_bytes: total_size, + speed_bytes_per_sec: 0, + eta_seconds: 0, + status: "Verifying".to_string(), + percentage: 100.0, + }, + ); + + let data = tokio::fs::read(&part_path) + .await + .map_err(|e| format!("Failed to read file for verification: {}", e))?; + + if !verify_checksum(&data, Some(expected), None) { + // Checksum failed, delete files and retry + tokio::fs::remove_file(&part_path).await.ok(); + tokio::fs::remove_file(&meta_path).await.ok(); + return Err("Checksum verification failed".to_string()); + } + } + + // Rename part file to final destination + tokio::fs::rename(&part_path, dest_path) + .await + .map_err(|e| format!("Failed to rename file: {}", e))?; + + // Clean up metadata file + tokio::fs::remove_file(&meta_path).await.ok(); + + Ok(()) +} + +/// Create new download metadata with segments +fn create_new_metadata(url: &str, file_name: &str, total_size: u64, checksum: Option<&str>) -> DownloadMetadata { + let segment_count = get_segment_count(total_size); + let segment_size = total_size / segment_count as u64; + let mut segments = Vec::new(); + + for i in 0..segment_count { + let start = i as u64 * segment_size; + let end = if i == segment_count - 1 { + total_size - 1 + } else { + (i as u64 + 1) * segment_size - 1 + }; + segments.push(DownloadSegment { + start, + end, + downloaded: 0, + completed: false, + }); + } + + DownloadMetadata { + url: url.to_string(), + file_name: file_name.to_string(), + total_size, + downloaded_bytes: 0, + checksum: checksum.map(|s| s.to_string()), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + segments, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgressEvent { pub file: String, diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index a8fdeea..8341138 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -2,11 +2,15 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::process::Command; use tauri::AppHandle; +use tauri::Emitter; use tauri::Manager; -use crate::core::downloader; +use crate::core::downloader::{self, JavaDownloadProgress, DownloadQueue, PendingJavaDownload}; use crate::utils::zip; +const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; +const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; // 24 hours + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JavaInstallation { pub path: String, @@ -37,6 +41,42 @@ impl std::fmt::Display for ImageType { } } +/// Java release information for UI display +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JavaReleaseInfo { + pub major_version: u32, + pub image_type: String, + pub version: String, + pub release_name: String, + pub release_date: Option<String>, + pub file_size: u64, + pub checksum: Option<String>, + pub download_url: String, + pub is_lts: bool, + pub is_available: bool, + pub architecture: String, +} + +/// Java catalog containing all available versions +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JavaCatalog { + pub releases: Vec<JavaReleaseInfo>, + pub available_major_versions: Vec<u32>, + pub lts_versions: Vec<u32>, + pub cached_at: u64, +} + +impl Default for JavaCatalog { + fn default() -> Self { + Self { + releases: Vec::new(), + available_major_versions: Vec::new(), + lts_versions: Vec::new(), + cached_at: 0, + } + } +} + /// Adoptium `/v3/assets/latest/{version}/hotspot` API response structures #[derive(Debug, Clone, Deserialize)] pub struct AdoptiumAsset { @@ -51,6 +91,8 @@ pub struct AdoptiumBinary { pub architecture: String, pub image_type: String, pub package: AdoptiumPackage, + #[serde(default)] + pub updated_at: Option<String>, } #[derive(Debug, Clone, Deserialize)] @@ -70,6 +112,15 @@ pub struct AdoptiumVersionData { pub openjdk_version: String, } +/// Adoptium available releases response +#[derive(Debug, Clone, Deserialize)] +pub struct AvailableReleases { + pub available_releases: Vec<u32>, + pub available_lts_releases: Vec<u32>, + pub most_recent_lts: Option<u32>, + pub most_recent_feature_release: Option<u32>, +} + /// Java download information from Adoptium #[derive(Debug, Clone, Serialize)] pub struct JavaDownloadInfo { @@ -140,6 +191,170 @@ pub fn get_java_install_dir(app_handle: &AppHandle) -> PathBuf { app_handle.path().app_data_dir().unwrap().join("java") } +/// Get the cache file path for Java catalog +fn get_catalog_cache_path(app_handle: &AppHandle) -> PathBuf { + app_handle + .path() + .app_data_dir() + .unwrap() + .join("java_catalog_cache.json") +} + +/// Load cached Java catalog if not expired +pub fn load_cached_catalog(app_handle: &AppHandle) -> Option<JavaCatalog> { + let cache_path = get_catalog_cache_path(app_handle); + if !cache_path.exists() { + return None; + } + + let content = std::fs::read_to_string(&cache_path).ok()?; + let catalog: JavaCatalog = serde_json::from_str(&content).ok()?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + if now - catalog.cached_at < CACHE_DURATION_SECS { + Some(catalog) + } else { + None + } +} + +/// Save Java catalog to cache +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(()) +} + +/// Clear Java catalog cache +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(()) +} + +/// Fetch complete Java catalog from Adoptium API with platform availability check +pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) -> Result<JavaCatalog, String> { + // Check cache first unless force refresh + if !force_refresh { + if let Some(cached) = load_cached_catalog(app_handle) { + return Ok(cached); + } + } + + let os = get_adoptium_os(); + let arch = get_adoptium_arch(); + let client = reqwest::Client::new(); + + // 1. Fetch available releases + 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| format!("Failed to fetch available releases: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse available releases: {}", e))?; + + let mut releases = Vec::new(); + + // 2. Fetch details for each major version + for major_version in &available.available_releases { + for image_type in &["jre", "jdk"] { + let url = format!( + "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", + ADOPTIUM_API_BASE, major_version, os, arch, image_type + ); + + match client + .get(&url) + .header("Accept", "application/json") + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + if let Ok(assets) = response.json::<Vec<AdoptiumAsset>>().await { + if let Some(asset) = assets.into_iter().next() { + let release_date = asset.binary.updated_at.clone(); + releases.push(JavaReleaseInfo { + major_version: *major_version, + image_type: image_type.to_string(), + 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: available.available_lts_releases.contains(major_version), + is_available: true, + architecture: asset.binary.architecture.clone(), + }); + } + } + } else { + // Platform not available for this version/type + releases.push(JavaReleaseInfo { + major_version: *major_version, + image_type: image_type.to_string(), + 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: available.available_lts_releases.contains(major_version), + is_available: false, + architecture: arch.to_string(), + }); + } + } + Err(_) => { + // Network error, mark as unavailable + releases.push(JavaReleaseInfo { + major_version: *major_version, + image_type: image_type.to_string(), + 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: available.available_lts_releases.contains(major_version), + is_available: false, + architecture: arch.to_string(), + }); + } + } + } + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let catalog = JavaCatalog { + releases, + available_major_versions: available.available_releases, + lts_versions: available.available_lts_releases, + cached_at: now, + }; + + // Save to cache + let _ = save_catalog_cache(app_handle, &catalog); + + Ok(catalog) +} + /// Get Adoptium API download info for a specific Java version and image type /// /// # Arguments @@ -157,8 +372,8 @@ pub async fn fetch_java_release( let arch = get_adoptium_arch(); let url = format!( - "https://api.adoptium.net/v3/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", - major_version, os, arch, image_type + "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}", + ADOPTIUM_API_BASE, major_version, os, arch, image_type ); let client = reqwest::Client::new(); @@ -199,7 +414,7 @@ pub async fn fetch_java_release( /// Fetch available Java versions from Adoptium API pub async fn fetch_available_versions() -> Result<Vec<u32>, String> { - let url = "https://api.adoptium.net/v3/info/available_releases"; + let url = format!("{}/info/available_releases", ADOPTIUM_API_BASE); let response = reqwest::get(url) .await @@ -218,7 +433,7 @@ pub async fn fetch_available_versions() -> Result<Vec<u32>, String> { Ok(releases.available_releases) } -/// Download and install Java +/// Download and install Java with resume support and progress events /// /// # Arguments /// * `app_handle` - Tauri app handle for accessing app directories @@ -236,6 +451,7 @@ pub async fn download_and_install_java( ) -> Result<JavaInstallation, String> { // 1. Fetch download information let info = fetch_java_release(major_version, image_type).await?; + let file_name = info.file_name.clone(); // 2. Prepare installation directory let install_base = custom_path.unwrap_or_else(|| get_java_install_dir(app_handle)); @@ -244,7 +460,24 @@ pub async fn download_and_install_java( std::fs::create_dir_all(&install_base) .map_err(|e| format!("Failed to create installation directory: {}", e))?; - // 3. Download the archive + // 3. Add to download queue for persistence + 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)?; + + // 4. Download the archive with resume support let archive_path = install_base.join(&info.file_name); // Check if we need to download @@ -261,30 +494,32 @@ pub async fn download_and_install_java( }; if need_download { - let client = reqwest::Client::new(); - let response = client - .get(&info.download_url) - .send() - .await - .map_err(|e| format!("Download failed: {}", e))?; - - let bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read download content: {}", e))?; - - // Verify downloaded file checksum - if let Some(expected) = &info.checksum { - if !downloader::verify_checksum(&bytes, Some(expected), None) { - return Err("Downloaded file verification failed, the file may be corrupted".to_string()); - } - } - - std::fs::write(&archive_path, &bytes) - .map_err(|e| format!("Failed to save downloaded file: {}", e))?; + // Use resumable download + downloader::download_with_resume( + app_handle, + &info.download_url, + &archive_path, + info.checksum.as_deref(), + info.file_size, + ) + .await?; } - // 4. Extract + // 5. Emit extracting status + 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, + }, + ); + + // 6. Extract // If the target directory exists, remove it first if version_dir.exists() { std::fs::remove_dir_all(&version_dir) @@ -304,10 +539,10 @@ pub async fn download_and_install_java( return Err(format!("Unsupported archive format: {}", info.file_name)); }; - // 5. Clean up downloaded archive + // 7. Clean up downloaded archive let _ = std::fs::remove_file(&archive_path); - // 6. Locate java executable + // 8. Locate java executable // macOS has a different structure: jdk-xxx/Contents/Home/bin/java // Linux/Windows: jdk-xxx/bin/java let java_home = version_dir.join(&top_level_dir); @@ -326,10 +561,28 @@ pub async fn download_and_install_java( )); } - // 7. Verify installation + // 9. Verify installation let installation = check_java_installation(&java_bin) .ok_or_else(|| "Failed to verify Java installation".to_string())?; + // 10. Remove from download queue + queue.remove(major_version, &image_type.to_string()); + queue.save(app_handle)?; + + // 11. Emit completed status + 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) } @@ -676,3 +929,57 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> { None } + +/// Resume pending Java downloads from queue +pub async fn resume_pending_downloads(app_handle: &AppHandle) -> Result<Vec<JavaInstallation>, 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 { + ImageType::Jre + }; + + // Try to resume the download + match download_and_install_java( + app_handle, + pending.major_version, + image_type, + Some(PathBuf::from(&pending.install_path)), + ) + .await + { + Ok(installation) => { + installed.push(installation); + } + Err(e) => { + eprintln!( + "Failed to resume Java {} {} download: {}", + pending.major_version, pending.image_type, e + ); + } + } + } + + Ok(installed) +} + +/// Cancel current Java download +pub fn cancel_current_download() { + downloader::cancel_java_download(); +} + +/// Get pending downloads from queue +pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload> { + let queue = DownloadQueue::load(app_handle); + queue.pending_downloads +} + +/// Clear a specific pending download +pub fn clear_pending_download(app_handle: &AppHandle, major_version: u32, image_type: &str) -> Result<(), String> { + let mut queue = DownloadQueue::load(app_handle); + queue.remove(major_version, image_type); + queue.save(app_handle) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a2b8c6c..53589d8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -895,6 +895,45 @@ async fn fetch_available_java_versions() -> Result<Vec<u32>, String> { core::java::fetch_available_versions().await } +/// Fetch Java catalog with platform availability (uses cache) +#[tauri::command] +async fn fetch_java_catalog( + app_handle: tauri::AppHandle, +) -> Result<core::java::JavaCatalog, String> { + core::java::fetch_java_catalog(&app_handle, false).await +} + +/// Refresh Java catalog (bypass cache) +#[tauri::command] +async fn refresh_java_catalog( + app_handle: tauri::AppHandle, +) -> Result<core::java::JavaCatalog, String> { + core::java::fetch_java_catalog(&app_handle, true).await +} + +/// Cancel current Java download +#[tauri::command] +async fn cancel_java_download() -> Result<(), String> { + core::java::cancel_current_download(); + Ok(()) +} + +/// Get pending Java downloads +#[tauri::command] +async fn get_pending_java_downloads( + app_handle: tauri::AppHandle, +) -> Result<Vec<core::downloader::PendingJavaDownload>, String> { + Ok(core::java::get_pending_downloads(&app_handle)) +} + +/// Resume pending Java downloads +#[tauri::command] +async fn resume_java_downloads( + app_handle: tauri::AppHandle, +) -> Result<Vec<core::java::JavaInstallation>, String> { + core::java::resume_pending_downloads(&app_handle).await +} + /// Get Minecraft versions supported by Fabric #[tauri::command] async fn get_fabric_game_versions() -> Result<Vec<core::fabric::FabricGameVersion>, String> { @@ -1067,6 +1106,13 @@ fn main() { println!("[Startup] Loaded saved account"); } + // Check for pending Java downloads and notify frontend + let pending = core::java::get_pending_downloads(&app.app_handle()); + if !pending.is_empty() { + println!("[Startup] Found {} pending Java download(s)", pending.len()); + let _ = app.emit("pending-java-downloads", pending.len()); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -1080,11 +1126,17 @@ fn main() { start_microsoft_login, complete_microsoft_login, refresh_account, + // Java commands detect_java, get_recommended_java, fetch_adoptium_java, download_adoptium_java, fetch_available_java_versions, + fetch_java_catalog, + refresh_java_catalog, + cancel_java_download, + get_pending_java_downloads, + resume_java_downloads, // Fabric commands get_fabric_game_versions, get_fabric_loader_versions, diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 968f6c5..f32a42f 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,25 +1,23 @@ <script lang="ts"> import { getVersion } from "@tauri-apps/api/app"; - import { onMount, onDestroy } from "svelte"; import { convertFileSrc } from "@tauri-apps/api/core"; + import { onDestroy, onMount } from "svelte"; import DownloadMonitor from "./lib/DownloadMonitor.svelte"; import GameConsole from "./lib/GameConsole.svelte"; - - // Components - import Sidebar from "./components/Sidebar.svelte"; - import HomeView from "./components/HomeView.svelte"; - import VersionsView from "./components/VersionsView.svelte"; - import SettingsView from "./components/SettingsView.svelte"; +// Components import BottomBar from "./components/BottomBar.svelte"; + import HomeView from "./components/HomeView.svelte"; import LoginModal from "./components/LoginModal.svelte"; - import StatusToast from "./components/StatusToast.svelte"; import ParticleBackground from "./components/ParticleBackground.svelte"; - - // Stores - import { uiState } from "./stores/ui.svelte"; + import SettingsView from "./components/SettingsView.svelte"; + import Sidebar from "./components/Sidebar.svelte"; + import StatusToast from "./components/StatusToast.svelte"; + import VersionsView from "./components/VersionsView.svelte"; +// Stores import { authState } from "./stores/auth.svelte"; - import { settingsState } from "./stores/settings.svelte"; import { gameState } from "./stores/game.svelte"; + import { settingsState } from "./stores/settings.svelte"; + import { uiState } from "./stores/ui.svelte"; let mouseX = $state(0); let mouseY = $state(0); @@ -32,6 +30,7 @@ onMount(async () => { authState.checkAccount(); await settingsState.loadSettings(); + await settingsState.detectJava(); gameState.loadVersions(); getVersion().then((v) => (uiState.appVersion = v)); window.addEventListener("mousemove", handleMouseMove); diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte index d409784..42b1056 100644 --- a/ui/src/components/SettingsView.svelte +++ b/ui/src/components/SettingsView.svelte @@ -1,7 +1,7 @@ <script lang="ts"> - import { settingsState } from "../stores/settings.svelte"; - import { open } from "@tauri-apps/plugin-dialog"; import { convertFileSrc } from "@tauri-apps/api/core"; + import { open } from "@tauri-apps/plugin-dialog"; + import { settingsState } from "../stores/settings.svelte"; async function selectBackground() { try { @@ -51,9 +51,9 @@ <!-- Preview --> <div class="w-40 h-24 rounded-xl overflow-hidden dark:bg-black/50 bg-gray-200 border dark:border-white/10 border-black/10 relative group shadow-lg"> {#if settingsState.settings.custom_background_path} - <img - src={convertFileSrc(settingsState.settings.custom_background_path)} - alt="Background Preview" + <img + src={convertFileSrc(settingsState.settings.custom_background_path)} + alt="Background Preview" class="w-full h-full object-cover" /> {:else} @@ -307,68 +307,329 @@ <!-- Java Download Modal --> {#if settingsState.showJavaDownloadModal} <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70"> - <div class="bg-zinc-900 rounded-2xl border border-white/10 p-8 max-w-md w-full mx-4 shadow-2xl"> - <h3 class="text-2xl font-bold mb-6 text-white">Download Java</h3> - - <div class="space-y-6"> - <!-- Java Version Selection --> - <div> - <label class="block text-sm font-medium text-white/70 mb-2">Java Version</label> - <select - bind:value={settingsState.selectedJavaVersion} - class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none" + <div class="bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl w-[900px] max-w-[95vw] h-[600px] max-h-[90vh] flex flex-col overflow-hidden"> + <!-- Header --> + <div class="flex items-center justify-between p-5 border-b border-white/10"> + <h3 class="text-xl font-bold text-white">Download Java</h3> + <button + aria-label="Close dialog" + onclick={() => settingsState.closeJavaDownloadModal()} + disabled={settingsState.isDownloadingJava} + class="text-white/40 hover:text-white/80 disabled:opacity-50 transition-colors p-1" + > + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> + </svg> + </button> + </div> + + <!-- Main Content Area --> + <div class="flex flex-1 overflow-hidden"> + <!-- Left Sidebar: Sources --> + <div class="w-40 border-r border-white/10 p-3 flex flex-col gap-1"> + <span class="text-[10px] font-bold uppercase tracking-widest text-white/30 px-2 mb-2">Sources</span> + + <button + disabled + class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50" > - {#each settingsState.availableJavaVersions as version} - <option value={version}>Java {version}</option> - {/each} - </select> + <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">M</div> + Mojang + </button> + + <button + class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm bg-indigo-500/20 border border-indigo-500/40 text-white" + > + <div class="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center text-[10px] font-bold">A</div> + Adoptium + </button> + + <button + disabled + class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50" + > + <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">Z</div> + Azul Zulu + </button> </div> - <!-- Image Type Selection --> - <div> - <label class="block text-sm font-medium text-white/70 mb-2">Image Type</label> - <div class="grid grid-cols-2 gap-3"> - <button - onclick={() => settingsState.selectedImageType = "jre"} - class="px-4 py-3 rounded-xl border transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500/20 border-indigo-500/50 text-white' : 'bg-black/20 border-white/10 text-white/60 hover:bg-white/5'}" - > - JRE - </button> + <!-- Center: Version Selection --> + <div class="flex-1 flex flex-col overflow-hidden"> + <!-- Toolbar --> + <div class="flex items-center gap-3 p-4 border-b border-white/5"> + <!-- Search --> + <div class="relative flex-1 max-w-xs"> + <input + type="text" + bind:value={settingsState.searchQuery} + placeholder="Search versions..." + class="w-full bg-black/30 text-white text-sm px-4 py-2 pl-9 rounded-lg border border-white/10 focus:border-indigo-500/50 outline-none" + /> + <svg class="absolute left-3 top-2.5 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> + </svg> + </div> + + <!-- Recommended Filter --> + <label class="flex items-center gap-2 text-sm text-white/60 cursor-pointer select-none"> + <input + type="checkbox" + bind:checked={settingsState.showOnlyRecommended} + class="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/30" + /> + LTS Only + </label> + + <!-- Image Type Toggle --> + <div class="flex items-center bg-black/30 rounded-lg p-0.5 border border-white/10"> + <button + onclick={() => settingsState.selectedImageType = "jre"} + class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}" + > + JRE + </button> + <button + onclick={() => settingsState.selectedImageType = "jdk"} + class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}" + > + JDK + </button> + </div> + </div> + + <!-- Loading State --> + {#if settingsState.isLoadingCatalog} + <div class="flex-1 flex items-center justify-center text-white/50"> + <div class="flex flex-col items-center gap-3"> + <div class="w-8 h-8 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div> + <span class="text-sm">Loading Java versions...</span> + </div> + </div> + {:else if settingsState.catalogError} + <div class="flex-1 flex items-center justify-center text-red-400"> + <div class="flex flex-col items-center gap-3 text-center px-8"> + <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> + </svg> + <span class="text-sm">{settingsState.catalogError}</span> + <button + onclick={() => settingsState.refreshCatalog()} + class="mt-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white transition-colors" + > + Retry + </button> + </div> + </div> + {:else} + <!-- Version List --> + <div class="flex-1 overflow-auto p-4"> + <div class="space-y-2"> + {#each settingsState.availableMajorVersions as version} + {@const isLts = settingsState.javaCatalog?.lts_versions.includes(version)} + {@const isSelected = settingsState.selectedMajorVersion === version} + {@const releaseInfo = settingsState.javaCatalog?.releases.find(r => r.major_version === version && r.image_type === settingsState.selectedImageType)} + {@const isAvailable = releaseInfo?.is_available ?? false} + {@const installStatus = releaseInfo ? settingsState.getInstallStatus(releaseInfo) : 'download'} + + <button + onclick={() => settingsState.selectMajorVersion(version)} + disabled={!isAvailable} + class="w-full flex items-center gap-4 p-3 rounded-xl border transition-all text-left + {isSelected + ? 'bg-indigo-500/20 border-indigo-500/50 ring-2 ring-indigo-500/30' + : isAvailable + ? 'bg-black/20 border-white/10 hover:bg-white/5 hover:border-white/20' + : 'bg-black/10 border-white/5 opacity-40 cursor-not-allowed'}" + > + <!-- Version Number --> + <div class="w-14 text-center"> + <span class="text-xl font-bold {isSelected ? 'text-white' : 'text-white/80'}">{version}</span> + </div> + + <!-- Version Details --> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-2"> + <span class="text-sm text-white/70 font-mono truncate">{releaseInfo?.version ?? '--'}</span> + {#if isLts} + <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded uppercase shrink-0">LTS</span> + {/if} + </div> + {#if releaseInfo} + <div class="text-[10px] text-white/40 truncate mt-0.5"> + {releaseInfo.release_name} • {settingsState.formatBytes(releaseInfo.file_size)} + </div> + {/if} + </div> + + <!-- Install Status Badge --> + <div class="shrink-0"> + {#if installStatus === 'installed'} + <span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-[10px] font-bold rounded uppercase">Installed</span> + {:else if isAvailable} + <span class="px-2 py-1 bg-white/10 text-white/50 text-[10px] font-medium rounded">Download</span> + {:else} + <span class="px-2 py-1 bg-red-500/10 text-red-400/60 text-[10px] font-medium rounded">N/A</span> + {/if} + </div> + </button> + {/each} + </div> + </div> + {/if} + </div> + + <!-- Right Sidebar: Details --> + <div class="w-64 border-l border-white/10 flex flex-col"> + <div class="p-4 border-b border-white/5"> + <span class="text-[10px] font-bold uppercase tracking-widest text-white/30">Details</span> + </div> + + {#if settingsState.selectedRelease} + <div class="flex-1 p-4 space-y-4 overflow-auto"> + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Version</div> + <div class="text-sm text-white font-mono">{settingsState.selectedRelease.version}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Name</div> + <div class="text-sm text-white">{settingsState.selectedRelease.release_name}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Date</div> + <div class="text-sm text-white">{settingsState.formatDate(settingsState.selectedRelease.release_date)}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Size</div> + <div class="text-sm text-white">{settingsState.formatBytes(settingsState.selectedRelease.file_size)}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</div> + <div class="flex items-center gap-2"> + <span class="text-sm text-white uppercase">{settingsState.selectedRelease.image_type}</span> + {#if settingsState.selectedRelease.is_lts} + <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded">LTS</span> + {/if} + </div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Architecture</div> + <div class="text-sm text-white">{settingsState.selectedRelease.architecture}</div> + </div> + + {#if !settingsState.selectedRelease.is_available} + <div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg"> + <div class="text-xs text-red-400">Not available for your platform</div> + </div> + {/if} + </div> + {:else} + <div class="flex-1 flex items-center justify-center text-white/30 text-sm p-4 text-center"> + Select a Java version to view details + </div> + {/if} + </div> + </div> + + <!-- Download Progress (MC Style) --> + {#if settingsState.isDownloadingJava && settingsState.downloadProgress} + <div class="border-t border-white/10 p-4 bg-zinc-900/90"> + <div class="flex items-center justify-between mb-2"> + <h3 class="text-white font-bold text-sm">Downloading Java</h3> + <span class="text-xs text-zinc-400">{settingsState.downloadProgress.status}</span> + </div> + + <!-- Progress Bar --> + <div class="mb-2"> + <div class="flex justify-between text-[10px] text-zinc-400 mb-1"> + <span>{settingsState.downloadProgress.file_name}</span> + <span>{Math.round(settingsState.downloadProgress.percentage)}%</span> + </div> + <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden"> + <div + class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200" + style="width: {settingsState.downloadProgress.percentage}%" + ></div> + </div> + </div> + + <!-- Speed & Stats --> + <div class="flex justify-between text-[10px] text-zinc-500 font-mono"> + <span> + {settingsState.formatBytes(settingsState.downloadProgress.speed_bytes_per_sec)}/s · + ETA: {settingsState.formatTime(settingsState.downloadProgress.eta_seconds)} + </span> + <span> + {settingsState.formatBytes(settingsState.downloadProgress.downloaded_bytes)} / + {settingsState.formatBytes(settingsState.downloadProgress.total_bytes)} + </span> + </div> + </div> + {/if} + + <!-- Pending Downloads Alert --> + {#if settingsState.pendingDownloads.length > 0 && !settingsState.isDownloadingJava} + <div class="border-t border-amber-500/30 p-4 bg-amber-500/10"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> + </svg> + <span class="text-sm text-amber-200"> + {settingsState.pendingDownloads.length} pending download(s) can be resumed + </span> + </div> <button - onclick={() => settingsState.selectedImageType = "jdk"} - class="px-4 py-3 rounded-xl border transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500/20 border-indigo-500/50 text-white' : 'bg-black/20 border-white/10 text-white/60 hover:bg-white/5'}" + onclick={() => settingsState.resumeDownloads()} + class="px-4 py-2 bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 rounded-lg text-sm font-medium transition-colors" > - JDK + Resume All </button> </div> - <p class="text-xs text-white/40 mt-2"> - JRE: Runtime only (smaller). JDK: Includes development tools. - </p> </div> + {/if} - <!-- Status --> - {#if settingsState.javaDownloadStatus} - <div class="bg-black/40 border border-white/10 rounded-xl p-4 text-sm text-white/80"> - {settingsState.javaDownloadStatus} - </div> - {/if} + <!-- Footer Actions --> + <div class="flex items-center justify-between p-4 border-t border-white/10 bg-black/20"> + <button + onclick={() => settingsState.refreshCatalog()} + disabled={settingsState.isLoadingCatalog || settingsState.isDownloadingJava} + class="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 disabled:opacity-50 text-white/70 rounded-lg text-sm transition-colors" + > + <svg class="w-4 h-4 {settingsState.isLoadingCatalog ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> + </svg> + Refresh + </button> - <!-- Actions --> - <div class="flex gap-3 pt-2"> - <button - onclick={() => settingsState.closeJavaDownloadModal()} - disabled={settingsState.isDownloadingJava} - class="flex-1 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-4 py-3 rounded-xl border border-white/5 transition-colors font-medium" - > - Cancel - </button> - <button - onclick={() => settingsState.downloadJava()} - disabled={settingsState.isDownloadingJava} - class="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-3 rounded-xl transition-colors font-medium" - > - {settingsState.isDownloadingJava ? "Downloading..." : "Download & Install"} - </button> + <div class="flex gap-3"> + {#if settingsState.isDownloadingJava} + <button + onclick={() => settingsState.cancelDownload()} + class="px-5 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-colors" + > + Cancel Download + </button> + {:else} + {@const isInstalled = settingsState.selectedRelease ? settingsState.getInstallStatus(settingsState.selectedRelease) === 'installed' : false} + <button + onclick={() => settingsState.closeJavaDownloadModal()} + class="px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-sm font-medium transition-colors" + > + Close + </button> + <button + onclick={() => settingsState.downloadJava()} + disabled={!settingsState.selectedRelease?.is_available || settingsState.isLoadingCatalog || isInstalled} + class="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors" + > + {isInstalled ? 'Already Installed' : 'Download & Install'} + </button> + {/if} </div> </div> </div> diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts index 5b87af2..cad466d 100644 --- a/ui/src/stores/settings.svelte.ts +++ b/ui/src/stores/settings.svelte.ts @@ -1,5 +1,14 @@ import { invoke } from "@tauri-apps/api/core"; -import type { JavaInstallation, LauncherConfig } from "../types"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { + JavaCatalog, + JavaDownloadProgress, + JavaDownloadSource, + JavaInstallation, + JavaReleaseInfo, + LauncherConfig, + PendingJavaDownload, +} from "../types"; import { uiState } from "./ui.svelte"; export class SettingsState { @@ -17,14 +26,101 @@ export class SettingsState { }); javaInstallations = $state<JavaInstallation[]>([]); isDetectingJava = $state(false); - - // Java download state + + // Java download modal state showJavaDownloadModal = $state(false); - availableJavaVersions = $state<number[]>([]); - selectedJavaVersion = $state(21); + selectedDownloadSource = $state<JavaDownloadSource>("adoptium"); + + // Java catalog state + javaCatalog = $state<JavaCatalog | null>(null); + isLoadingCatalog = $state(false); + catalogError = $state(""); + + // Version selection state + selectedMajorVersion = $state<number | null>(null); selectedImageType = $state<"jre" | "jdk">("jre"); + showOnlyRecommended = $state(true); + searchQuery = $state(""); + + // Download progress state isDownloadingJava = $state(false); + downloadProgress = $state<JavaDownloadProgress | null>(null); javaDownloadStatus = $state(""); + + // Pending downloads + pendingDownloads = $state<PendingJavaDownload[]>([]); + + // Event listener cleanup + private progressUnlisten: UnlistenFn | null = null; + + // Computed: filtered releases based on selection + get filteredReleases(): JavaReleaseInfo[] { + if (!this.javaCatalog) return []; + + let releases = this.javaCatalog.releases; + + // Filter by major version if selected + if (this.selectedMajorVersion !== null) { + releases = releases.filter(r => r.major_version === this.selectedMajorVersion); + } + + // Filter by image type + releases = releases.filter(r => r.image_type === this.selectedImageType); + + // Filter by recommended (LTS) versions + if (this.showOnlyRecommended) { + releases = releases.filter(r => r.is_lts); + } + + // Filter by search query + if (this.searchQuery.trim()) { + const query = this.searchQuery.toLowerCase(); + releases = releases.filter( + r => + r.release_name.toLowerCase().includes(query) || + r.version.toLowerCase().includes(query) || + r.major_version.toString().includes(query) + ); + } + + return releases; + } + + // Computed: available major versions for display + get availableMajorVersions(): number[] { + if (!this.javaCatalog) return []; + let versions = [...this.javaCatalog.available_major_versions]; + + // Filter by LTS if showOnlyRecommended is enabled + if (this.showOnlyRecommended) { + versions = versions.filter(v => this.javaCatalog!.lts_versions.includes(v)); + } + + // Sort descending (newest first) + return versions.sort((a, b) => b - a); + } + + // Get installation status for a release: 'installed' | 'download' + getInstallStatus(release: JavaReleaseInfo): 'installed' | 'download' { + // Find installed Java that matches the major version and image type (by path pattern) + const matchingInstallations = this.javaInstallations.filter(inst => { + // Check if this is a DropOut-managed Java (path contains temurin-XX-jre/jdk pattern) + const pathLower = inst.path.toLowerCase(); + const pattern = `temurin-${release.major_version}-${release.image_type}`; + return pathLower.includes(pattern); + }); + + // If any matching installation exists, it's installed + return matchingInstallations.length > 0 ? 'installed' : 'download'; + } + + // Computed: selected release details + get selectedRelease(): JavaReleaseInfo | null { + if (!this.javaCatalog || this.selectedMajorVersion === null) return null; + return this.javaCatalog.releases.find( + r => r.major_version === this.selectedMajorVersion && r.image_type === this.selectedImageType + ) || null; + } async loadSettings() { try { @@ -32,8 +128,8 @@ export class SettingsState { this.settings = result; // Force dark mode if (this.settings.theme !== "dark") { - this.settings.theme = "dark"; - this.saveSettings(); + this.settings.theme = "dark"; + this.saveSettings(); } } catch (e) { console.error("Failed to load settings:", e); @@ -74,55 +170,201 @@ export class SettingsState { async openJavaDownloadModal() { this.showJavaDownloadModal = true; this.javaDownloadStatus = ""; + this.catalogError = ""; + this.downloadProgress = null; + + // Setup progress event listener + await this.setupProgressListener(); + + // Load catalog + await this.loadJavaCatalog(false); + + // Check for pending downloads + await this.loadPendingDownloads(); + } + + async closeJavaDownloadModal() { + if (!this.isDownloadingJava) { + this.showJavaDownloadModal = false; + // Cleanup listener + if (this.progressUnlisten) { + this.progressUnlisten(); + this.progressUnlisten = null; + } + } + } + + private async setupProgressListener() { + if (this.progressUnlisten) { + this.progressUnlisten(); + } + + this.progressUnlisten = await listen<JavaDownloadProgress>( + "java-download-progress", + (event) => { + this.downloadProgress = event.payload; + this.javaDownloadStatus = event.payload.status; + + if (event.payload.status === "Completed") { + this.isDownloadingJava = false; + setTimeout(async () => { + await this.detectJava(); + uiState.setStatus(`Java installed successfully!`); + }, 500); + } else if (event.payload.status === "Error") { + this.isDownloadingJava = false; + } + } + ); + } + + async loadJavaCatalog(forceRefresh: boolean) { + this.isLoadingCatalog = true; + this.catalogError = ""; + try { - this.availableJavaVersions = await invoke("fetch_available_java_versions"); - // Default selection logic - if (this.availableJavaVersions.includes(21)) { - this.selectedJavaVersion = 21; - } else if (this.availableJavaVersions.includes(17)) { - this.selectedJavaVersion = 17; - } else if (this.availableJavaVersions.length > 0) { - this.selectedJavaVersion = this.availableJavaVersions[this.availableJavaVersions.length - 1]; + const command = forceRefresh ? "refresh_java_catalog" : "fetch_java_catalog"; + this.javaCatalog = await invoke<JavaCatalog>(command); + + // Auto-select first LTS version + if (this.selectedMajorVersion === null && this.javaCatalog.lts_versions.length > 0) { + // Select most recent LTS (21 or highest) + const ltsVersions = [...this.javaCatalog.lts_versions].sort((a, b) => b - a); + this.selectedMajorVersion = ltsVersions[0]; } } catch (e) { - console.error("Failed to fetch available Java versions:", e); - this.javaDownloadStatus = "Error fetching Java versions: " + e; + console.error("Failed to load Java catalog:", e); + this.catalogError = `Failed to load Java catalog: ${e}`; + } finally { + this.isLoadingCatalog = false; } } - closeJavaDownloadModal() { - if (!this.isDownloadingJava) { - this.showJavaDownloadModal = false; + async refreshCatalog() { + await this.loadJavaCatalog(true); + uiState.setStatus("Java catalog refreshed"); + } + + async loadPendingDownloads() { + try { + this.pendingDownloads = await invoke<PendingJavaDownload[]>("get_pending_java_downloads"); + } catch (e) { + console.error("Failed to load pending downloads:", e); } } + selectMajorVersion(version: number) { + this.selectedMajorVersion = version; + } + async downloadJava() { + if (!this.selectedRelease || !this.selectedRelease.is_available) { + uiState.setStatus("Selected Java version is not available for this platform"); + return; + } + this.isDownloadingJava = true; - this.javaDownloadStatus = `Downloading Java ${this.selectedJavaVersion} ${this.selectedImageType.toUpperCase()}...`; + this.javaDownloadStatus = "Starting download..."; + this.downloadProgress = null; try { const result: JavaInstallation = await invoke("download_adoptium_java", { - majorVersion: this.selectedJavaVersion, + majorVersion: this.selectedMajorVersion, imageType: this.selectedImageType, customPath: null, }); - this.javaDownloadStatus = `Java ${this.selectedJavaVersion} installed at ${result.path}`; this.settings.java_path = result.path; - await this.detectJava(); setTimeout(() => { this.showJavaDownloadModal = false; - uiState.setStatus(`Java ${this.selectedJavaVersion} is ready to use!`); + uiState.setStatus(`Java ${this.selectedMajorVersion} is ready to use!`); }, 1500); } catch (e) { console.error("Failed to download Java:", e); - this.javaDownloadStatus = "Download failed: " + e; + this.javaDownloadStatus = `Download failed: ${e}`; } finally { this.isDownloadingJava = false; } } + + async cancelDownload() { + try { + await invoke("cancel_java_download"); + this.isDownloadingJava = false; + this.javaDownloadStatus = "Download cancelled"; + this.downloadProgress = null; + await this.loadPendingDownloads(); + } catch (e) { + console.error("Failed to cancel download:", e); + } + } + + async resumeDownloads() { + if (this.pendingDownloads.length === 0) return; + + this.isDownloadingJava = true; + this.javaDownloadStatus = "Resuming download..."; + + try { + const installed = await invoke<JavaInstallation[]>("resume_java_downloads"); + if (installed.length > 0) { + this.settings.java_path = installed[0].path; + await this.detectJava(); + uiState.setStatus(`Resumed and installed ${installed.length} Java version(s)`); + } + await this.loadPendingDownloads(); + } catch (e) { + console.error("Failed to resume downloads:", e); + this.javaDownloadStatus = `Resume failed: ${e}`; + } finally { + this.isDownloadingJava = false; + } + } + + // Format bytes to human readable + formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; + } + + // Format seconds to human readable + formatTime(seconds: number): string { + if (seconds === 0 || !isFinite(seconds)) return "--"; + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${mins}m ${secs}s`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return `${hours}h ${mins}m`; + } + + // Format date string + formatDate(dateStr: string | null): string { + if (!dateStr) return "--"; + try { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + }); + } catch { + return "--"; + } + } + + // Legacy compatibility + get availableJavaVersions(): number[] { + return this.availableMajorVersions; + } } export const settingsState = new SettingsState(); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index f92cbf2..163dc92 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -53,6 +53,50 @@ export interface JavaDownloadInfo { image_type: string; } +export interface JavaReleaseInfo { + major_version: number; + image_type: string; + version: string; + release_name: string; + release_date: string | null; + file_size: number; + checksum: string | null; + download_url: string; + is_lts: boolean; + is_available: boolean; + architecture: string; +} + +export interface JavaCatalog { + releases: JavaReleaseInfo[]; + available_major_versions: number[]; + lts_versions: number[]; + cached_at: number; +} + +export interface JavaDownloadProgress { + file_name: string; + downloaded_bytes: number; + total_bytes: number; + speed_bytes_per_sec: number; + eta_seconds: number; + status: string; + percentage: number; +} + +export interface PendingJavaDownload { + major_version: number; + image_type: string; + download_url: string; + file_name: string; + file_size: number; + checksum: string | null; + install_path: string; + created_at: number; +} + +export type JavaDownloadSource = "adoptium" | "mojang" | "azul"; + // ==================== Fabric Types ==================== export interface FabricGameVersion { |