From 75a4b73ee1377525086241f3de2b92df761c8743 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 4 Aug 2025 16:30:45 +1000 Subject: [PATCH] QoL Download Manager (#108) * feat: retry specific download errors * fix: potential fix for cmd window on Windows * feat: add disk space check for download * fix: update game fix formatting * fix: clippy --- src-tauri/Cargo.lock | 54 +++++++++++- src-tauri/Cargo.toml | 2 + .../download_manager/util/progress_object.rs | 1 + .../src/error/application_download_error.rs | 22 ++++- src-tauri/src/games/downloads/commands.rs | 44 +++++----- .../src/games/downloads/download_agent.rs | 84 +++++++++++++++---- src-tauri/src/process/compat.rs | 1 - src-tauri/src/process/mod.rs | 5 +- src-tauri/src/process/process_manager.rs | 4 +- src-tauri/src/process/utils.rs | 27 ++++++ 10 files changed, 196 insertions(+), 48 deletions(-) delete mode 100644 src-tauri/src/process/compat.rs create mode 100644 src-tauri/src/process/utils.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a94b80e..a00a867 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1301,6 +1301,7 @@ dependencies = [ "hex 0.4.3", "http 1.3.1", "http-serde 2.1.1", + "humansize", "known-folders", "log", "log4rs", @@ -1324,6 +1325,7 @@ dependencies = [ "sha1", "shared_child", "slice-deque", + "sysinfo", "tar", "tauri", "tauri-build", @@ -2310,6 +2312,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.2.0" @@ -2805,9 +2816,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" @@ -2819,6 +2830,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.3" @@ -3190,6 +3207,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3386,6 +3412,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.1" @@ -5317,6 +5353,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-configuration" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c32e7dc..823342e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -71,6 +71,8 @@ bitcode = "0.6.6" reqwest-websocket = "0.5.0" futures-lite = "2.6.0" page_size = "0.6.0" +sysinfo = "0.36.1" +humansize = "2.1.3" # tailscale = { path = "./tailscale" } [dependencies.dynfmt] diff --git a/src-tauri/src/download_manager/util/progress_object.rs b/src-tauri/src/download_manager/util/progress_object.rs index 9f67db5..2f0bb5d 100644 --- a/src-tauri/src/download_manager/util/progress_object.rs +++ b/src-tauri/src/download_manager/util/progress_object.rs @@ -26,6 +26,7 @@ pub struct ProgressObject { rolling: RollingProgressWindow<250>, } +#[derive(Clone)] pub struct ProgressHandle { progress: Arc, progress_object: Arc, diff --git a/src-tauri/src/error/application_download_error.rs b/src-tauri/src/error/application_download_error.rs index c1f3899..a6294ea 100644 --- a/src-tauri/src/error/application_download_error.rs +++ b/src-tauri/src/error/application_download_error.rs @@ -4,6 +4,7 @@ use std::{ }; use serde_with::SerializeDisplay; +use humansize::{format_size, BINARY}; use super::remote_access_error::RemoteAccessError; @@ -11,6 +12,7 @@ use super::remote_access_error::RemoteAccessError; #[derive(Debug, SerializeDisplay)] pub enum ApplicationDownloadError { Communication(RemoteAccessError), + DiskFull(u64, u64), Checksum, Lock, IoError(io::ErrorKind), @@ -20,11 +22,25 @@ pub enum ApplicationDownloadError { impl Display for ApplicationDownloadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { + ApplicationDownloadError::DiskFull(required, available) => write!( + f, + "Game requires {}, {} remaining left on disk.", + format_size(*required, BINARY), + format_size(*available, BINARY), + ), ApplicationDownloadError::Communication(error) => write!(f, "{error}"), - ApplicationDownloadError::Lock => write!(f, "failed to acquire lock. Something has gone very wrong internally. Please restart the application"), - ApplicationDownloadError::Checksum => write!(f, "checksum failed to validate for download"), + ApplicationDownloadError::Lock => write!( + f, + "failed to acquire lock. Something has gone very wrong internally. Please restart the application" + ), + ApplicationDownloadError::Checksum => { + write!(f, "checksum failed to validate for download") + } ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"), - ApplicationDownloadError::DownloadError => write!(f, "download failed. See Download Manager status for specific error"), + ApplicationDownloadError::DownloadError => write!( + f, + "download failed. See Download Manager status for specific error" + ), } } } diff --git a/src-tauri/src/games/downloads/commands.rs b/src-tauri/src/games/downloads/commands.rs index 7e48162..0fa6df9 100644 --- a/src-tauri/src/games/downloads/commands.rs +++ b/src-tauri/src/games/downloads/commands.rs @@ -5,10 +5,10 @@ use std::{ use crate::{ database::{db::borrow_db_checked, models::data::GameDownloadStatus}, - download_manager::{ - download_manager_frontend::DownloadManagerSignal, downloadable::Downloadable, - }, - error::download_manager_error::DownloadManagerError, + download_manager:: + downloadable::Downloadable + , + error::application_download_error::ApplicationDownloadError, AppState, }; @@ -20,26 +20,28 @@ pub fn download_game( game_version: String, install_dir: usize, state: tauri::State<'_, Mutex>, -) -> Result<(), DownloadManagerError> { +) -> Result<(), ApplicationDownloadError> { let sender = state.lock().unwrap().download_manager.get_sender(); - let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new_from_index( + let game_download_agent = GameDownloadAgent::new_from_index( game_id, game_version, install_dir, sender, - )) as Box); - Ok(state - .lock() - .unwrap() - .download_manager - .queue_download(game_download_agent)?) + )?; + let game_download_agent = Arc::new(Box::new(game_download_agent) as Box); + state + .lock() + .unwrap() + .download_manager + .queue_download(game_download_agent).unwrap(); + Ok(()) } #[tauri::command] pub fn resume_download( game_id: String, state: tauri::State<'_, Mutex>, -) -> Result<(), DownloadManagerError> { +) -> Result<(), ApplicationDownloadError> { let s = borrow_db_checked() .applications .game_statuses @@ -56,17 +58,21 @@ pub fn resume_download( install_dir, } => (version_name, install_dir), }; + let sender = state.lock().unwrap().download_manager.get_sender(); let parent_dir: PathBuf = install_dir.into(); + let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new( game_id, version_name.clone(), parent_dir.parent().unwrap().to_path_buf(), sender, - )) as Box); - Ok(state - .lock() - .unwrap() - .download_manager - .queue_download(game_download_agent)?) + )?) as Box); + + state + .lock() + .unwrap() + .download_manager + .queue_download(game_download_agent).unwrap(); + Ok(()) } diff --git a/src-tauri/src/games/downloads/download_agent.rs b/src-tauri/src/games/downloads/download_agent.rs index 3ace071..7946048 100644 --- a/src-tauri/src/games/downloads/download_agent.rs +++ b/src-tauri/src/games/downloads/download_agent.rs @@ -13,13 +13,12 @@ use crate::error::application_download_error::ApplicationDownloadError; use crate::error::remote_access_error::RemoteAccessError; use crate::games::downloads::manifest::{DropDownloadContext, DropManifest}; use crate::games::downloads::validate::validate_game_chunk; -use crate::games::library::{ - on_game_complete, push_game_update, set_partially_installed, -}; +use crate::games::library::{on_game_complete, push_game_update, set_partially_installed}; use crate::games::state::GameStatusManager; +use crate::process::utils::get_disk_available; use crate::remote::requests::make_request; use crate::remote::utils::DROP_CLIENT_SYNC; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use rayon::ThreadPoolBuilder; use std::collections::HashMap; use std::fs::{OpenOptions, create_dir_all}; @@ -35,6 +34,8 @@ use rustix::fs::{FallocateFlags, fallocate}; use super::download_logic::download_game_chunk; use super::drop_data::DropData; +static RETRY_COUNT: usize = 3; + pub struct GameDownloadAgent { pub id: String, pub version: String, @@ -54,7 +55,7 @@ impl GameDownloadAgent { version: String, target_download_dir: usize, sender: Sender, - ) -> Self { + ) -> Result { let db_lock = borrow_db_checked(); let base_dir = db_lock.applications.install_dirs[target_download_dir].clone(); drop(db_lock); @@ -66,7 +67,7 @@ impl GameDownloadAgent { version: String, base_dir: PathBuf, sender: Sender, - ) -> Self { + ) -> Result { // Don't run by default let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop); @@ -76,7 +77,7 @@ impl GameDownloadAgent { let stored_manifest = DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone()); - Self { + let result = Self { id, version, control_flag, @@ -87,7 +88,31 @@ impl GameDownloadAgent { sender, dropdata: stored_manifest, status: Mutex::new(DownloadStatus::Queued), + }; + + result.ensure_manifest_exists()?; + + let required_space = result + .manifest + .lock() + .unwrap() + .as_ref() + .unwrap() + .values() + .map(|e| e.lengths.iter().sum::()) + .sum::() + as u64; + + let available_space = get_disk_available(data_base_dir_path)? as u64; + + if required_space > available_space { + return Err(ApplicationDownloadError::DiskFull( + required_space, + available_space, + )); } + + Ok(result) } // Blocking @@ -315,15 +340,40 @@ impl GameDownloadAgent { }; scope.spawn(move |_| { - match download_game_chunk(context, &self.control_flag, progress_handle, request) - { - Ok(true) => { - completed_indexes.push(context.checksum.clone()); - } - Ok(false) => {} - Err(e) => { - error!("{e}"); - sender.send(DownloadManagerSignal::Error(e)).unwrap(); + // 3 attempts + for i in 0..RETRY_COUNT { + let loop_progress_handle = progress_handle.clone(); + match download_game_chunk( + context, + &self.control_flag, + loop_progress_handle, + request.try_clone().unwrap(), + ) { + Ok(true) => { + completed_indexes.push(context.checksum.clone()); + return; + } + Ok(false) => return, + Err(e) => { + warn!("game download agent error: {e}"); + + let retry = match &e { + ApplicationDownloadError::Communication( + _remote_access_error, + ) => true, + ApplicationDownloadError::Checksum => true, + ApplicationDownloadError::Lock => true, + ApplicationDownloadError::IoError(_error_kind) => false, + ApplicationDownloadError::DownloadError => false, + ApplicationDownloadError::DiskFull(_, _) => false, + }; + + if i == RETRY_COUNT - 1 || !retry { + warn!("retry logic failed, not re-attempting."); + sender.send(DownloadManagerSignal::Error(e)).unwrap(); + return; + } + } } } }); @@ -452,8 +502,6 @@ impl GameDownloadAgent { ); self.dropdata.write(); - - } } diff --git a/src-tauri/src/process/compat.rs b/src-tauri/src/process/compat.rs deleted file mode 100644 index 8b13789..0000000 --- a/src-tauri/src/process/compat.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src-tauri/src/process/mod.rs b/src-tauri/src/process/mod.rs index 02b74ba..fa1089e 100644 --- a/src-tauri/src/process/mod.rs +++ b/src-tauri/src/process/mod.rs @@ -1,6 +1,5 @@ pub mod commands; -#[cfg(target_os = "linux")] -pub mod compat; pub mod process_manager; pub mod process_handlers; -pub mod format; \ No newline at end of file +pub mod format; +pub mod utils; \ No newline at end of file diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index f3d72e9..d7dec3a 100644 --- a/src-tauri/src/process/process_manager.rs +++ b/src-tauri/src/process/process_manager.rs @@ -336,9 +336,9 @@ impl ProcessManager<'_> { use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] - let mut command = Command::new("cmd"); + let mut command = Command::new("start"); #[cfg(target_os = "windows")] - command.raw_arg(format!("/C \"{}\"", &launch_string)); + command.raw_arg(format!("/min cmd /C \"{}\"", &launch_string)); info!("launching (in {install_dir}): {launch_string}",); diff --git a/src-tauri/src/process/utils.rs b/src-tauri/src/process/utils.rs new file mode 100644 index 0000000..def2d9e --- /dev/null +++ b/src-tauri/src/process/utils.rs @@ -0,0 +1,27 @@ +use std::path::PathBuf; + +use futures_lite::io; +use sysinfo::{Disk, DiskRefreshKind, Disks}; + +use crate::error::application_download_error::ApplicationDownloadError; + +pub fn get_disk_available(mount_point: PathBuf) -> Result { + let disks = Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing().with_storage()); + + let mut disk_iter = disks.into_iter().collect::>(); + disk_iter.sort_by(|a, b| { + b.mount_point() + .to_string_lossy() + .len() + .cmp(&a.mount_point().to_string_lossy().len()) + }); + + for disk in disk_iter { + if mount_point.starts_with(disk.mount_point()) { + return Ok(disk.available_space()); + } + } + Err(ApplicationDownloadError::IoError(io::Error::other( + "could not find disk of path", + ).kind())) +}