diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ba4e65..9856b9f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ tauri-plugin-prevent-default = "4.0.4" openssl = { version = "0.10.75", features = ["v110", "vendored"] } base64 = "0.22.1" tauri-plugin-http = "2.5.7" +dashmap = "6.1.0" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2.4.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a1e4ec..a4e88db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,12 @@ use base64::{Engine as _, engine::general_purpose}; +use dashmap::DashMap; use futures_util::stream::StreamExt; use openssl::pkey::PKey; use openssl::sign::Verifier; use sha2::{Digest, Sha512}; use std::fs; use std::path::Path; +use std::sync::OnceLock; use std::time::Instant; use std::{ fs::{File, create_dir_all}, @@ -26,6 +28,16 @@ use zip::result::ZipError; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +static CANCEL_MAP: OnceLock> = OnceLock::new(); + +fn cancel_map() -> &'static DashMap { + CANCEL_MAP.get_or_init(DashMap::new) +} + +fn is_cancelled(name: &str) -> bool { + cancel_map().get(name).map(|v| *v).unwrap_or(false) +} + #[allow(unused)] fn is_running_by_path(path: &Path) -> bool { let sys = System::new_all(); @@ -141,53 +153,25 @@ async fn download( hash: String, ) -> String { println!("[download] Starting download for '{}' from '{}'", name, url); + let _ = app.emit("download-start", format!("{}", name)); let window = app.get_webview_window("main").expect("main window missing"); - let client = reqwest::Client::new(); - let resp = match client.get(&url).send().await { - Ok(r) => { - println!("[download] HTTP request succeeded, status: {}", r.status()); - r - } - Err(e) => { - println!("[download] ERROR: HTTP request failed: {:?}", e); - return "-1".to_string(); - } - }; - - let total_size = resp.content_length().unwrap_or(0); - println!( - "[download] Content-Length: {} bytes ({:.2} MB)", - total_size, - total_size as f64 / 1_048_576.0 - ); - let _ = app.emit("download-size", format!("{}:{}", &name, &total_size)); - - let mut downloaded: u64 = 0; - let mut stream = resp.bytes_stream(); - let downloads_path = match app.path().app_local_data_dir() { - Ok(p) => { - let path = p.join("downloads"); - println!("[download] Downloads path: {:?}", path); - path - } + Ok(p) => p.join("downloads"), Err(e) => { println!( "[download] ERROR: Failed to resolve app_local_data_dir: {}", e ); + let _ = &app.emit("download-stop", format!("{}", name)); return "-1".to_string(); } }; let game_path = match app.path().app_local_data_dir() { - Ok(p) => { - let path = p.join("game"); - println!("[download] Game path: {:?}", path); - path - } + Ok(p) => p.join("game"), Err(e) => { println!("[download] ERROR: Failed to resolve game path: {}", e); + let _ = &app.emit("download-stop", format!("{}", name)); return "-1".to_string(); } }; @@ -197,43 +181,132 @@ async fn download( println!("[download] Part file: {:?}", download_part_path); println!("[download] Zip file: {:?}", download_zip_path); - if download_part_path.exists() { - println!("[download] Stale .part file found, removing..."); - let _ = tokio::fs::remove_file(&download_part_path).await; - } - - println!( - "[download] Creating downloads dir (if needed): {:?}", - downloads_path - ); let _ = tokio::fs::create_dir_all(&downloads_path).await; - if let Ok(true) = tokio::fs::try_exists(&game_path.join(name.clone())).await { - println!( - "[download] Existing game dir found, removing: {:?}", - game_path.join(&name) - ); - let _ = tokio::fs::remove_dir_all(&game_path.join(name.clone())).await; + let resume_from: u64 = match tokio::fs::metadata(&download_part_path).await { + Ok(meta) => { + let size = meta.len(); + println!( + "[download] Found existing .part file ({} bytes), will attempt to resume", + size + ); + size + } + Err(_) => 0, + }; + + let client = reqwest::Client::new(); + let mut request = client.get(&url); + if resume_from > 0 { + request = request.header("Range", format!("bytes={}-", resume_from)); + println!("[download] Requesting range: bytes={}-", resume_from); } - println!("[download] Creating game dir: {:?}", game_path.join(&name)); - let _ = tokio::fs::create_dir_all(&game_path.join(&name)).await; - - let mut file = match tokio::fs::File::create(download_part_path.clone()).await { - Ok(f) => { - println!("[download] Created .part file successfully"); - f + let resp = match request.send().await { + Ok(r) => { + println!("[download] HTTP request succeeded, status: {}", r.status()); + r } Err(e) => { - println!("[download] ERROR: Failed to create .part file: {}", e); + println!("[download] ERROR: HTTP request failed: {:?}", e); + let _ = &app.emit("download-stop", format!("{}", name)); return "-1".to_string(); } }; + let status = resp.status(); + + let actual_resume_from = if resume_from > 0 && status == reqwest::StatusCode::OK { + println!( + "[download] Server does not support range requests (got 200), restarting from scratch" + ); + let _ = tokio::fs::remove_file(&download_part_path).await; + 0 + } else if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT { + println!("[download] Server accepted range request (206 Partial Content)"); + resume_from + } else if resume_from > 0 { + println!( + "[download] Unexpected status {} while resuming, restarting", + status + ); + let _ = tokio::fs::remove_file(&download_part_path).await; + 0 + } else { + 0 + }; + + let remaining_size = resp.content_length().unwrap_or(0); + let total_size = actual_resume_from + remaining_size; + println!( + "[download] Resume offset: {} bytes | Remaining: {} bytes | Total: {} bytes ({:.2} MB)", + actual_resume_from, + remaining_size, + total_size, + total_size as f64 / 1_048_576.0 + ); + let _ = app.emit("download-size", format!("{}:{}", &name, &total_size)); + + let mut file = if actual_resume_from > 0 { + match tokio::fs::OpenOptions::new() + .append(true) + .open(&download_part_path) + .await + { + Ok(f) => { + println!("[download] Opened .part file in append mode for resume"); + f + } + Err(e) => { + println!( + "[download] ERROR: Failed to open .part file for append: {}", + e + ); + let _ = &app.emit("download-stop", format!("{}", name)); + return "-1".to_string(); + } + } + } else { + if let Ok(true) = tokio::fs::try_exists(&game_path.join(&name)).await { + println!( + "[download] Existing game dir found, removing: {:?}", + game_path.join(&name) + ); + let _ = tokio::fs::remove_dir_all(&game_path.join(&name)).await; + } + let _ = tokio::fs::create_dir_all(&game_path.join(&name)).await; + + match tokio::fs::File::create(&download_part_path).await { + Ok(f) => { + println!("[download] Created new .part file"); + f + } + Err(e) => { + println!("[download] ERROR: Failed to create .part file: {}", e); + let _ = &app.emit("download-stop", format!("{}", name)); + return "-1".to_string(); + } + } + }; + + let mut downloaded: u64 = actual_resume_from; + let mut stream = resp.bytes_stream(); + let start = Instant::now(); let mut chunk_count: u64 = 0; while let Ok(Some(chunk_result)) = timeout(Duration::from_secs(5), stream.next()).await { + if is_cancelled(&name) { + println!("[download] Cancelled '{}' during chunk loop", name); + cancel_map().remove(&name); + let _ = window.set_progress_bar(ProgressBarState { + status: Some(ProgressBarStatus::None), + progress: Some(0), + }); + let _ = app.emit("download-cancelled", format!("{}", name)); + return "0".to_string(); + } + let chunk = match chunk_result { Ok(c) => c, Err(e) => { @@ -241,6 +314,7 @@ async fn download( "[download] ERROR: Failed to read chunk #{}: {}", chunk_count, e ); + let _ = &app.emit("download-stop", format!("{}", name)); return "-1".to_string(); } }; @@ -252,6 +326,7 @@ async fn download( "[download] ERROR: Failed to write chunk #{} to disk: {}", chunk_count, e ); + let _ = &app.emit("download-stop", format!("{}", name)); return "-1".to_string(); } @@ -263,10 +338,15 @@ async fn download( 0.0 }; + let session_bytes = downloaded - actual_resume_from; let elapsed_secs = start.elapsed().as_secs_f64(); - let speed = (downloaded as f64) / elapsed_secs; - let eta_secs = if total_size > downloaded { - ((total_size - downloaded) as f64) / speed + let speed = if elapsed_secs > 0.0 { + session_bytes as f64 / elapsed_secs + } else { + 0.0 + }; + let eta_secs = if total_size > downloaded && speed > 0.0 { + (total_size - downloaded) as f64 / speed } else { 0.0 }; @@ -311,7 +391,7 @@ async fn download( } println!( - "[download] Download complete: {} bytes in {:.2}s", + "[download] Download complete: {} bytes total in {:.2}s (this session)", downloaded, start.elapsed().as_secs_f64() ); @@ -319,7 +399,7 @@ async fn download( let _ = app.emit("download-hash-checking", format!("{}", &name)); let download_hash = { - let mut file = match tokio::fs::File::open(download_part_path.clone()).await { + let mut file = match tokio::fs::File::open(&download_part_path).await { Ok(f) => f, Err(e) => { println!( @@ -331,25 +411,23 @@ async fn download( }; let mut hasher = Sha512::new(); let mut bytes_hashed: u64 = 0; - { - let mut buffer = [0; 8192]; - loop { - let bytes_read = match file.read(&mut buffer).await { - Ok(n) => n, - Err(e) => { - println!( - "[download] ERROR: Failed to read file during hashing: {}", - e - ); - return "-1".to_string(); - } - }; - if bytes_read == 0 { - break; + let mut buffer = [0u8; 8192]; + loop { + let bytes_read = match file.read(&mut buffer).await { + Ok(n) => n, + Err(e) => { + println!( + "[download] ERROR: Failed to read file during hashing: {}", + e + ); + return "-1".to_string(); } - hasher.update(&buffer[..bytes_read]); - bytes_hashed += bytes_read as u64; + }; + if bytes_read == 0 { + break; } + hasher.update(&buffer[..bytes_read]); + bytes_hashed += bytes_read as u64; } drop(file); println!("[download] Hashed {} bytes", bytes_hashed); @@ -360,8 +438,8 @@ async fn download( println!("[download] Computed hash: {}", download_hash); if hash != download_hash { - println!("[download] ERROR: Hash mismatch! File may be corrupt or tampered with."); - let _ = tokio::fs::remove_file(download_part_path.clone()).await; + println!("[download] ERROR: Hash mismatch! Deleting corrupt .part file."); + let _ = tokio::fs::remove_file(&download_part_path).await; let _ = window.set_progress_bar(ProgressBarState { status: Some(ProgressBarStatus::None), progress: Some(0), @@ -370,9 +448,8 @@ async fn download( } println!("[download] Hash verified OK"); - println!("[download] Renaming .part -> .zip: {:?}", download_zip_path); - if let Err(e) = tokio::fs::rename(download_part_path.clone(), download_zip_path.clone()).await { + if let Err(e) = tokio::fs::rename(&download_part_path, &download_zip_path).await { println!("[download] ERROR: Failed to rename .part to .zip: {}", e); let _ = window.set_progress_bar(ProgressBarState { status: Some(ProgressBarStatus::None), @@ -381,9 +458,11 @@ async fn download( return "-1".to_string(); } + let _ = tokio::fs::create_dir_all(&game_path.join(&name)).await; + println!("[download] Unzipping to: {:?}", game_path.join(&name)); let unzip_res = unzip_to_dir( - app, + app.clone(), download_zip_path.clone(), game_path.join(&name), name.clone(), @@ -391,7 +470,7 @@ async fn download( .await; println!("[download] Removing zip file: {:?}", download_zip_path); - let _ = tokio::fs::remove_file(download_zip_path.clone()).await; + let _ = tokio::fs::remove_file(&download_zip_path).await; if unzip_res == "-1" { println!("[download] ERROR: Unzip failed for '{}'", name); @@ -410,7 +489,7 @@ async fn download( status: Some(ProgressBarStatus::None), progress: Some(0), }); - return "1".to_string(); + "1".to_string() } #[allow(unused_variables)] @@ -508,6 +587,12 @@ fn verify_signature(body: String, signature: String, public_key: String) -> bool .unwrap_or(false) } +#[tauri::command] +async fn cancel_download(name: String) { + println!("[cancel_download] Cancellation requested for '{}'", name); + cancel_map().insert(name.clone(), true); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[allow(unused_variables)] @@ -546,7 +631,8 @@ pub fn run() { download, launch_game, folder_size, - verify_signature + verify_signature, + cancel_download ]) .setup(|app| { #[cfg(target_os = "windows")] diff --git a/src/app/Globals.css b/src/app/Globals.css index 8b31761..0ec90c2 100644 --- a/src/app/Globals.css +++ b/src/app/Globals.css @@ -14,6 +14,7 @@ body { --col6: rgb(96, 96, 96); --col7: rgb(112, 112, 112); --col8: rgb(128, 128, 128); + --col9: rgb(144, 144, 144); } .red-theme { @@ -26,6 +27,7 @@ body { --col6: rgb(120, 96, 96); --col7: rgb(136, 112, 112); --col8: rgb(152, 128, 128); + --col9: rgb(168, 144, 144); } .blue-theme { @@ -38,6 +40,7 @@ body { --col6: rgb(96, 96, 120); --col7: rgb(112, 112, 136); --col8: rgb(128, 128, 152); + --col9: rgb(144, 144, 168); } .purple-theme { @@ -50,6 +53,7 @@ body { --col6: rgb(108, 96, 120); --col7: rgb(124, 112, 136); --col8: rgb(140, 128, 152); + --col9: rgb(156, 144, 168); } .button { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c687c64..7aa3d5f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -228,7 +228,7 @@ export default function RootLayout ({ const i = prev.findIndex(d => d.version === displayName) if (i === -1) return prev const copy = [...prev] - copy[i] = { ...copy[i], hash_checking: true } + copy[i] = { ...copy[i], hash_checking: true, downloading: false } return copy }) }) @@ -266,6 +266,58 @@ export default function RootLayout ({ }) }) ) + + unlisteners.push( + await listen('download-start', event => { + const displayName = event.payload + setDownloadProgress(prev => { + const i = prev.findIndex(d => d.version === displayName) + if (i === -1) return prev + const copy = [...prev] + copy[i] = { + ...copy[i], + downloading: true, + paused: false, + failed: false + } + return copy + }) + }) + ) + + unlisteners.push( + await listen('download-stop', event => { + const displayName = event.payload + setDownloadProgress(prev => { + const i = prev.findIndex(d => d.version === displayName) + if (i === -1) return prev + const copy = [...prev] + copy[i] = { ...copy[i], downloading: false } + return copy + }) + }) + ) + + unlisteners.push( + await listen('download-cancelled', event => { + const displayName = event.payload + setDownloadProgress(prev => { + const i = prev.findIndex(d => d.version === displayName) + if (i === -1) return prev + if (prev[i].canceled) { + return prev.filter((_, idx) => idx !== i) + } + const copy = [...prev] + copy[i] = { + ...copy[i], + downloading: false, + paused: true, + failed: false + } + return copy + }) + }) + ) } setupListeners() @@ -349,6 +401,9 @@ export default function RootLayout ({ 0, 0, false, + false, + false, + false, true, false, 0, @@ -356,7 +411,10 @@ export default function RootLayout ({ 0, false, 0, - 0 + 0, + null, + null, + null ) ) @@ -424,6 +482,19 @@ export default function RootLayout ({ return } + setDownloadProgress(prev => + prev.map(d => + d.version === versionId + ? { + ...d, + url: data.data.url, + executable: info.executable, + hash: data.data.hash + } + : d + ) + ) + const res = await invoke('download', { url: data.data.url, name: info.id, @@ -447,7 +518,7 @@ export default function RootLayout ({ writeVersionsConfig(updated) return updated }) - } else { + } else if (res !== '0') { setDownloadProgress(prev => prev.map(d => d.version === versionId diff --git a/src/componets/popups/Downloads.tsx b/src/componets/popups/Downloads.tsx index fdcd0b0..b1e09c7 100644 --- a/src/componets/popups/Downloads.tsx +++ b/src/componets/popups/Downloads.tsx @@ -2,6 +2,9 @@ import { useGlobal } from '@/app/GlobalProvider' import prettyBytes from 'pretty-bytes' import ProgressBar from '../ProgressBar' import { formatEtaSmart } from '@/lib/Util' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCancel, faPause, faPlay } from '@fortawesome/free-solid-svg-icons' +import { invoke } from '@tauri-apps/api/core' export default function DownloadsPopup () { const { @@ -19,7 +22,62 @@ export default function DownloadsPopup () { {downloadProgress.map((v, i) => { const queuePosition = downloadQueue.indexOf(v.version) return ( -
+
+
+ + + +

{getVersionInfo(v.version)?.displayName}

diff --git a/src/types/DownloadProgress.ts b/src/types/DownloadProgress.ts index bd75042..4da999e 100644 --- a/src/types/DownloadProgress.ts +++ b/src/types/DownloadProgress.ts @@ -4,6 +4,9 @@ export class DownloadProgress { public progress: number, public progressBytes: number, public failed: boolean, + public downloading: boolean, + public paused: boolean, + public canceled: boolean, public queued: boolean, public hash_checking: boolean, public speed: number, @@ -11,6 +14,9 @@ export class DownloadProgress { public etaSecs: number, public unzipping: boolean, public unzipTotal: number, - public unzipped: number + public unzipped: number, + public url: string | null, + public executable: string | null, + public hash: string | null ) {} }