diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1735f73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. Arch Linux, Windows] + - App Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7d0ff2c..0fc3abe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ stages: build-linux: stage: build - image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/rust:1.81.0-bookworm + image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/rustlang/rust:nightly script: - apt-get update -y - apt-get install yarnpkg libsoup-3.0-0 libsoup-3.0-dev libatk-adaptor libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev -y @@ -13,9 +13,10 @@ build-linux: - yarnpkg tauri build - cp src-tauri/target/release/bundle/deb/*.deb . - cp src-tauri/target/release/bundle/rpm/*.rpm . + - cp src-tauri/target/release/bundle/appimage/*.AppImage . artifacts: paths: - - "*.{deb,rpm}" + - "*.{deb,rpm,AppImage}" build-windows: stage: build diff --git a/README.md b/README.md index b0df333..903f4ca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,19 @@ Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI. +## Running +Before setting up the drop app, be sure that you have a server set up. +The instructions for this can be found on the [Drop Wiki](https://wiki.droposs.org/guides/quickstart.html) + +## Current features +Currently supported are the following features: +- Signin (with custom server) +- Database registering & recovery +- Dynamic library fetching from server +- Installing & uninstalling games +- Download progress monitoring +- Launching / playing games + ## Development Install dependencies with `yarn` @@ -10,7 +23,7 @@ Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use she To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`: -e.g. `RUST_LOG=debug yarn taudi dev` +e.g. `RUST_LOG=debug yarn tauri dev` ## Contributing Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines. \ No newline at end of file diff --git a/app.vue b/app.vue index f81c432..b9011c6 100644 --- a/app.vue +++ b/app.vue @@ -1,8 +1,8 @@ diff --git a/pages/auth/failed.vue b/pages/auth/failed.vue index db8a6e5..1eca3c0 100644 --- a/pages/auth/failed.vue +++ b/pages/auth/failed.vue @@ -26,7 +26,7 @@ import { XCircleIcon } from "@heroicons/vue/16/solid"; const route = useRoute(); -const message = route.query.error ?? "An unknown error occurred."; +const message = route.query.error ?? "An unknown error occurred"; definePageMeta({ layout: "mini", diff --git a/pages/index.vue b/pages/index.vue index 72433ae..52bc1df 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,4 +1,7 @@ + + \ No newline at end of file diff --git a/pages/settings/interface.vue b/pages/settings/interface.vue index 27e0f69..3df236b 100644 --- a/pages/settings/interface.vue +++ b/pages/settings/interface.vue @@ -1,3 +1,7 @@ \ No newline at end of file + + + diff --git a/plugins/global-error-handler.ts b/plugins/global-error-handler.ts index f9d7249..b7bcd45 100644 --- a/plugins/global-error-handler.ts +++ b/plugins/global-error-handler.ts @@ -1,7 +1,7 @@ export default defineNuxtPlugin((nuxtApp) => { // Also possible nuxtApp.hook("vue:error", (error, instance, info) => { - console.log(error); + console.error(error, info); const router = useRouter(); router.replace(`/error`); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ff38943..22b5365 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -255,12 +255,29 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic-instant-full" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6541700e074cda41b1c6f98c2cae6cde819967bf142078f069cad85387cdbe" + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -870,7 +887,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", ] [[package]] @@ -879,7 +905,18 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", ] [[package]] @@ -969,8 +1006,9 @@ dependencies = [ [[package]] name = "drop-app" -version = "0.1.0" +version = "0.2.0-beta-prerelease-1" dependencies = [ + "atomic-instant-full", "boxcar", "chrono", "directories", @@ -980,6 +1018,7 @@ dependencies = [ "log4rs", "md5", "openssl", + "parking_lot 0.12.3", "rayon", "reqwest", "rustbreak", @@ -987,14 +1026,18 @@ dependencies = [ "serde", "serde-binary", "serde_json", + "serde_with", "shared_child", + "slice-deque", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-os", "tauri-plugin-shell", "tauri-plugin-single-instance", + "throttle_my_fn", "tokio", "umu-wrapper-lib", "url", @@ -1047,7 +1090,7 @@ dependencies = [ "rustc_version", "toml 0.8.2", "vswhom", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -2308,7 +2351,7 @@ dependencies = [ "log", "log-mdc", "once_cell", - "parking_lot", + "parking_lot 0.12.3", "rand 0.8.5", "serde", "serde-value", @@ -2326,6 +2369,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2925,6 +2977,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -2932,7 +2995,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.10", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -2943,7 +3020,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.8", "smallvec", "windows-targets 0.52.6", ] @@ -3378,6 +3455,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -3824,9 +3910,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.11.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" dependencies = [ "base64 0.22.1", "chrono", @@ -3842,9 +3928,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.11.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" dependencies = [ "darling", "proc-macro2", @@ -3965,6 +4051,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slice-deque" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ef6ee280cdefba6d2d0b4b78a84a1c1a3f3a4cec98c2d4231c8bc225de0f25" +dependencies = [ + "libc", + "mach", + "winapi", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -3997,7 +4094,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.8", "wasm-bindgen", "web-sys", "windows-sys 0.59.0", @@ -4055,7 +4152,7 @@ checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" dependencies = [ "new_debug_unreachable", "once_cell", - "parking_lot", + "parking_lot 0.12.3", "phf_shared 0.10.0", "precomputed-hash", "serde", @@ -4208,7 +4305,7 @@ dependencies = [ "ndk-sys", "objc", "once_cell", - "parking_lot", + "parking_lot 0.12.3", "raw-window-handle", "scopeguard", "tao-macros", @@ -4245,7 +4342,7 @@ checksum = "e545de0a2dfe296fa67db208266cd397c5a55ae782da77973ef4c4fac90e9f2c" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 5.0.1", "dunce", "embed_plist", "futures-util", @@ -4295,7 +4392,7 @@ checksum = "7bd2a4bcfaf5fb9f4be72520eefcb61ae565038f8ccba2a497d8c28f463b8c01" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 5.0.1", "glob", "heck 0.5.0", "json-patch", @@ -4367,6 +4464,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c13f843e5e5df3eed270fc42b02923cc1a6b5c7e56b0f3ac1d858ab2c8b5fb" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.9", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.2.0" @@ -4655,6 +4766,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "throttle_my_fn" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482c185e5675626c9a130b3a8f362c322a239338c882f745a1d9a85838b987f0" +dependencies = [ + "parking_lot 0.11.2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "time" version = "0.3.37" @@ -4871,7 +4994,7 @@ checksum = "d48a05076dd272615d03033bf04f480199f7d1b66a8ac64d75c625fc4a70c06b" dependencies = [ "core-graphics", "crossbeam-channel", - "dirs", + "dirs 5.0.1", "libappindicator", "muda", "objc2", @@ -5777,6 +5900,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8bc070..f9f24c3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "drop-app" -version = "0.1.0" +version = "0.2.0-beta-prerelease-1" description = "The client application for the open-source, self-hosted game distribution platform Drop" authors = ["Drop OSS"] edition = "2021" @@ -26,7 +26,6 @@ tauri-build = { version = "2.0.0", features = [] } [dependencies] tauri-plugin-shell = "2.0.0" -serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" serde-binary = "0.5.0" rayon = "1.10.0" @@ -44,7 +43,13 @@ chrono = "0.4.38" tauri-plugin-os = "2" boxcar = "0.2.7" umu-wrapper-lib = "0.1.0" +tauri-plugin-autostart = "2.0.0" shared_child = "1.0.1" +serde_with = "3.12.0" +slice-deque = "0.3.0" +throttle_my_fn = "0.2.6" +parking_lot = "0.12.3" +atomic-instant-full = "0.1.0" [dependencies.tauri] version = "2.1.1" @@ -83,6 +88,10 @@ features = [] # You can also use "yaml_enc" or "bin_enc" version = "0.12" features = ["json", "blocking"] +[dependencies.serde] +version = "1" +features = ["derive", "rc"] + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/rust-toolchain.toml b/src-tauri/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/src-tauri/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs new file mode 100644 index 0000000..dec9148 --- /dev/null +++ b/src-tauri/src/autostart.rs @@ -0,0 +1,76 @@ +use crate::database::db::{borrow_db_checked, borrow_db_mut_checked, save_db}; +use log::debug; +use tauri::AppHandle; +use tauri_plugin_autostart::ManagerExt; + +pub fn toggle_autostart_logic(app: AppHandle, enabled: bool) -> Result<(), String> { + let manager = app.autolaunch(); + if enabled { + manager.enable().map_err(|e| e.to_string())?; + debug!("enabled autostart"); + } else { + manager.disable().map_err(|e| e.to_string())?; + debug!("eisabled autostart"); + } + + // Store the state in DB + let mut db_handle = borrow_db_mut_checked(); + db_handle.settings.autostart = enabled; + drop(db_handle); + save_db(); + + Ok(()) +} + +pub fn get_autostart_enabled_logic(app: AppHandle) -> Result { + // First check DB state + let db_handle = borrow_db_checked(); + let db_state = db_handle.settings.autostart; + drop(db_handle); + + // Get actual system state + let manager = app.autolaunch(); + let system_state = manager.is_enabled()?; + + // If they don't match, sync to DB state + if db_state != system_state { + if db_state { + manager.enable()?; + } else { + manager.disable()?; + } + } + + Ok(db_state) +} + +// New function to sync state on startup +pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> { + let db_handle = borrow_db_checked(); + let should_be_enabled = db_handle.settings.autostart; + drop(db_handle); + + let manager = app.autolaunch(); + let current_state = manager.is_enabled().map_err(|e| e.to_string())?; + + if current_state != should_be_enabled { + if should_be_enabled { + manager.enable().map_err(|e| e.to_string())?; + debug!("synced autostart: enabled"); + } else { + manager.disable().map_err(|e| e.to_string())?; + debug!("synced autostart: disabled"); + } + } + + Ok(()) +} +#[tauri::command] +pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> { + toggle_autostart_logic(app, enabled) +} + +#[tauri::command] +pub fn get_autostart_enabled(app: AppHandle) -> Result { + get_autostart_enabled_logic(app) +} diff --git a/src-tauri/src/cleanup.rs b/src-tauri/src/cleanup.rs index 925ea61..de0e4e6 100644 --- a/src-tauri/src/cleanup.rs +++ b/src-tauri/src/cleanup.rs @@ -1,15 +1,23 @@ - -use log::info; +use log::{debug, error}; use tauri::AppHandle; +use crate::AppState; #[tauri::command] -pub fn quit(app: tauri::AppHandle) { - cleanup_and_exit(&app); +pub fn quit(app: tauri::AppHandle, state: tauri::State<'_, std::sync::Mutex>>) { + cleanup_and_exit(&app, &state); } -pub fn cleanup_and_exit(app: &AppHandle) { - info!("exiting drop application..."); +pub fn cleanup_and_exit(app: &AppHandle, state: &tauri::State<'_, std::sync::Mutex>>) { + debug!("cleaning up and exiting application"); + let download_manager = state.lock().unwrap().download_manager.clone(); + match download_manager.ensure_terminated() { + Ok(res) => match res { + Ok(_) => debug!("download manager terminated correctly"), + Err(_) => error!("download manager failed to terminate correctly"), + }, + Err(e) => panic!("{:?}", e), + } app.exit(0); } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..b47a348 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,11 @@ +use crate::AppState; + +#[tauri::command] +pub fn fetch_state( + state: tauri::State<'_, std::sync::Mutex>>, +) -> Result { + let guard = state.lock().unwrap(); + let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?; + drop(guard); + Ok(cloned_state) +} diff --git a/src-tauri/src/database/commands.rs b/src-tauri/src/database/commands.rs new file mode 100644 index 0000000..18befb8 --- /dev/null +++ b/src-tauri/src/database/commands.rs @@ -0,0 +1,92 @@ +use std::{ + fs::create_dir_all, + io::{Error, ErrorKind}, + path::{Path, PathBuf}, +}; + +use serde_json::Value; + +use crate::{ + database::{db::borrow_db_mut_checked, settings::Settings}, + download_manager::internal_error::InternalError, +}; + +use super::{ + db::{borrow_db_checked, save_db, DATA_ROOT_DIR}, + debug::SystemData, +}; + +// Will, in future, return disk/remaining size +// Just returns the directories that have been set up +#[tauri::command] +pub fn fetch_download_dir_stats() -> Vec { + let lock = borrow_db_checked(); + lock.applications.install_dirs.clone() +} + +#[tauri::command] +pub fn delete_download_dir(index: usize) { + let mut lock = borrow_db_mut_checked(); + lock.applications.install_dirs.remove(index); + drop(lock); + save_db(); +} + +#[tauri::command] +pub fn add_download_dir(new_dir: PathBuf) -> Result<(), InternalError<()>> { + // Check the new directory is all good + let new_dir_path = Path::new(&new_dir); + if new_dir_path.exists() { + let dir_contents = new_dir_path.read_dir()?; + if dir_contents.count() != 0 { + return Err(Error::new( + ErrorKind::DirectoryNotEmpty, + "Selected directory cannot contain any existing files", + ) + .into()); + } + } else { + create_dir_all(new_dir_path)?; + } + + // Add it to the dictionary + let mut lock = borrow_db_mut_checked(); + if lock.applications.install_dirs.contains(&new_dir) { + return Err(Error::new( + ErrorKind::AlreadyExists, + "Selected directory already exists in database", + ) + .into()); + } + lock.applications.install_dirs.push(new_dir); + drop(lock); + save_db(); + + Ok(()) +} + +#[tauri::command] +pub fn update_settings(new_settings: Value) { + let mut db_lock = borrow_db_mut_checked(); + let mut current_settings = serde_json::to_value(db_lock.settings.clone()).unwrap(); + for (key, value) in new_settings.as_object().unwrap() { + current_settings[key] = value.clone(); + } + let new_settings: Settings = serde_json::from_value(current_settings).unwrap(); + db_lock.settings = new_settings; + println!("new Settings: {:?}", db_lock.settings); +} +#[tauri::command] +pub fn fetch_settings() -> Settings { + borrow_db_checked().settings.clone() +} +#[tauri::command] +pub fn fetch_system_data() -> SystemData { + let db_handle = borrow_db_checked(); + SystemData::new( + db_handle.auth.as_ref().unwrap().client_id.clone(), + db_handle.base_url.clone(), + DATA_ROOT_DIR.lock().unwrap().to_string_lossy().to_string(), + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), + ) +} diff --git a/src-tauri/src/database/db.rs b/src-tauri/src/database/db.rs new file mode 100644 index 0000000..b92de3d --- /dev/null +++ b/src-tauri/src/database/db.rs @@ -0,0 +1,256 @@ +use std::{ + collections::HashMap, + fs::{self, create_dir_all}, + hash::Hash, + path::{Path, PathBuf}, + sync::{LazyLock, Mutex, RwLockReadGuard, RwLockWriteGuard}, +}; + +use chrono::Utc; +use directories::BaseDirs; +use log::{debug, error, info}; +use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_with::serde_as; +use tauri::AppHandle; +use url::Url; + +use crate::{ + database::settings::Settings, + download_manager::downloadable_metadata::DownloadableMetadata, + games::{library::push_game_update, state::GameStatusManager}, + process::process_manager::Platform, + DB, +}; + +#[derive(serde::Serialize, Clone, Deserialize)] +pub struct DatabaseAuth { + pub private: String, + pub cert: String, + pub client_id: String, +} + +// Strings are version names for a particular game +#[derive(Serialize, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum GameDownloadStatus { + Remote {}, + SetupRequired { + version_name: String, + install_dir: String, + }, + Installed { + version_name: String, + install_dir: String, + }, +} + +// Stuff that shouldn't be synced to disk +#[derive(Clone, Serialize)] +pub enum ApplicationTransientStatus { + Downloading { version_name: String }, + Uninstalling {}, + Updating { version_name: String }, + Running {}, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GameVersion { + pub game_id: String, + pub version_name: String, + + pub platform: Platform, + + pub launch_command: String, + pub launch_args: Vec, + + pub setup_command: String, + pub setup_args: Vec, + + pub only_setup: bool, + + pub version_index: usize, + pub delta: bool, + + pub umu_id_override: Option, +} + +#[serde_as] +#[derive(Serialize, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DatabaseApplications { + pub install_dirs: Vec, + // Guaranteed to exist if the game also exists in the app state map + pub game_statuses: HashMap, + pub game_versions: HashMap>, + pub installed_game_version: HashMap, + + #[serde(skip)] + pub transient_statuses: HashMap, +} + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct Database { + #[serde(default)] + pub settings: Settings, + pub auth: Option, + pub base_url: String, + pub applications: DatabaseApplications, + pub prev_database: Option, +} +impl Database { + fn new>(games_base_dir: T, prev_database: Option) -> Self { + Self { + applications: DatabaseApplications { + install_dirs: vec![games_base_dir.into()], + game_statuses: HashMap::new(), + game_versions: HashMap::new(), + installed_game_version: HashMap::new(), + transient_statuses: HashMap::new(), + }, + prev_database, + base_url: "".to_owned(), + auth: None, + settings: Settings { + autostart: false, + max_download_threads: 4, + }, + } + } +} +pub static DATA_ROOT_DIR: LazyLock> = + LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop"))); + +// Custom JSON serializer to support everything we need +#[derive(Debug, Default, Clone)] +pub struct DropDatabaseSerializer; + +impl DeSerializer for DropDatabaseSerializer { + fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult> { + serde_json::to_vec(val).map_err(|e| DeSerError::Internal(e.to_string())) + } + + fn deserialize(&self, s: R) -> rustbreak::error::DeSerResult { + serde_json::from_reader(s).map_err(|e| DeSerError::Internal(e.to_string())) + } +} + +pub type DatabaseInterface = + rustbreak::Database; + +pub trait DatabaseImpls { + fn set_up_database() -> DatabaseInterface; + fn database_is_set_up(&self) -> bool; + fn fetch_base_url(&self) -> Url; +} +impl DatabaseImpls for DatabaseInterface { + fn set_up_database() -> DatabaseInterface { + let data_root_dir = DATA_ROOT_DIR.lock().unwrap(); + let db_path = data_root_dir.join("drop.db"); + let games_base_dir = data_root_dir.join("games"); + let logs_root_dir = data_root_dir.join("logs"); + + debug!("creating data directory at {:?}", data_root_dir); + create_dir_all(data_root_dir.clone()).unwrap(); + create_dir_all(games_base_dir.clone()).unwrap(); + create_dir_all(logs_root_dir.clone()).unwrap(); + + let exists = fs::exists(db_path.clone()).unwrap(); + + match exists { + true => match PathDatabase::load_from_path(db_path.clone()) { + Ok(db) => db, + Err(e) => handle_invalid_database(e, db_path, games_base_dir), + }, + false => { + let default = Database::new(games_base_dir, None); + debug!( + "Creating database at path {}", + db_path.as_os_str().to_str().unwrap() + ); + PathDatabase::create_at_path(db_path, default) + .expect("Database could not be created") + } + } + } + + fn database_is_set_up(&self) -> bool { + !self.borrow_data().unwrap().base_url.is_empty() + } + + fn fetch_base_url(&self) -> Url { + let handle = self.borrow_data().unwrap(); + Url::parse(&handle.base_url).unwrap() + } +} + +pub fn set_game_status, &DownloadableMetadata)>( + app_handle: &AppHandle, + meta: DownloadableMetadata, + setter: F, +) { + let mut db_handle = borrow_db_mut_checked(); + setter(&mut db_handle, &meta); + drop(db_handle); + save_db(); + + let status = GameStatusManager::fetch_state(&meta.id); + + push_game_update(app_handle, &meta.id, status); +} +// TODO: Make the error relelvant rather than just assume that it's a Deserialize error +fn handle_invalid_database( + _e: RustbreakError, + db_path: PathBuf, + games_base_dir: PathBuf, +) -> rustbreak::Database { + let new_path = { + let time = Utc::now().timestamp(); + let mut base = db_path.clone(); + base.set_file_name(format!("drop.db.backup-{}", time)); + base + }; + info!( + "old database stored at: {}", + new_path.to_string_lossy().to_string() + ); + fs::rename(&db_path, &new_path).unwrap(); + + let db = Database::new( + games_base_dir.into_os_string().into_string().unwrap(), + Some(new_path), + ); + + PathDatabase::create_at_path(db_path, db).expect("Database could not be created") +} + +pub fn borrow_db_checked<'a>() -> RwLockReadGuard<'a, Database> { + match DB.borrow_data() { + Ok(data) => data, + Err(e) => { + error!("database borrow failed with error {}", e); + panic!("database borrow failed with error {}", e); + } + } +} + +pub fn borrow_db_mut_checked<'a>() -> RwLockWriteGuard<'a, Database> { + match DB.borrow_data_mut() { + Ok(data) => data, + Err(e) => { + error!("database borrow mut failed with error {}", e); + panic!("database borrow mut failed with error {}", e); + } + } +} + +pub fn save_db() { + match DB.save() { + Ok(_) => {} + Err(e) => { + error!("database failed to save with error {}", e); + panic!("database failed to save with error {}", e) + } + } +} diff --git a/src-tauri/src/database/debug.rs b/src-tauri/src/database/debug.rs new file mode 100644 index 0000000..45d2034 --- /dev/null +++ b/src-tauri/src/database/debug.rs @@ -0,0 +1,21 @@ +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemData { + client_id: String, + base_url: String, + data_dir: String, + log_level: String, +} + +impl SystemData { + pub fn new(client_id: String, base_url: String, data_dir: String, log_level: String) -> Self { + Self { + client_id, + base_url, + data_dir, + log_level, + } + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs new file mode 100644 index 0000000..2a4255b --- /dev/null +++ b/src-tauri/src/database/mod.rs @@ -0,0 +1,4 @@ +pub mod commands; +pub mod db; +pub mod debug; +pub mod settings; diff --git a/src-tauri/src/database/settings.rs b/src-tauri/src/database/settings.rs new file mode 100644 index 0000000..b8c1742 --- /dev/null +++ b/src-tauri/src/database/settings.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub autostart: bool, + pub max_download_threads: usize, + // ... other settings ... +} +impl Default for Settings { + fn default() -> Self { + Self { + autostart: false, + max_download_threads: 4, + } + } +} +// Ideally use pointers instead of a macro to assign the settings +// fn deserialize_into(v: serde_json::Value, t: &mut T) -> Result<(), serde_json::Error> +// where T: for<'a> Deserialize<'a> +// { +// *t = serde_json::from_value(v)?; +// Ok(()) +// } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs deleted file mode 100644 index bfad526..0000000 --- a/src-tauri/src/db.rs +++ /dev/null @@ -1,226 +0,0 @@ -use std::{ - collections::HashMap, - fs::{self, create_dir_all}, - path::{Path, PathBuf}, - sync::{LazyLock, Mutex, RwLockWriteGuard}, -}; - -use directories::BaseDirs; -use log::debug; -use rustbreak::{DeSerError, DeSerializer, PathDatabase}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use tauri::AppHandle; -use url::Url; - -use crate::{library::push_application_update, process::process_manager::Platform, state::DownloadStatusManager, DB}; - -#[derive(serde::Serialize, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DatabaseAuth { - pub private: String, - pub cert: String, - pub client_id: String, -} - -pub struct GameStatusData { - version_name: String, - install_dir: String, -} - -// Strings are version names for a particular game -#[derive(Serialize, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum ApplicationStatus { - Remote {}, - SetupRequired { - version_name: String, - install_dir: String, - }, - Installed { - version_name: String, - install_dir: String, - }, -} - -// Stuff that shouldn't be synced to disk -#[derive(Clone, Serialize)] -pub enum ApplicationTransientStatus { - Downloading { version_name: String }, - Uninstalling {}, - Updating { version_name: String }, - Running {}, -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ApplicationVersion { - pub version_index: usize, - pub version_name: String, - pub launch_command: String, - pub setup_command: String, - pub platform: Platform, -} - -#[derive(Serialize, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DatabaseApplications { - pub install_dirs: Vec, - // Guaranteed to exist if the game also exists in the app state map - pub statuses: HashMap, - pub versions: HashMap>, - - #[serde(skip)] - pub transient_statuses: HashMap, -} - -#[derive(Serialize, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Database { - pub auth: Option, - pub base_url: String, - pub applications: DatabaseApplications, -} -pub static DATA_ROOT_DIR: LazyLock> = - LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop"))); - -// Custom JSON serializer to support everything we need -#[derive(Debug, Default, Clone)] -pub struct DropDatabaseSerializer; - -impl DeSerializer for DropDatabaseSerializer { - fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult> { - serde_json::to_vec(val).map_err(|e| DeSerError::Internal(e.to_string())) - } - - fn deserialize(&self, s: R) -> rustbreak::error::DeSerResult { - serde_json::from_reader(s).map_err(|e| DeSerError::Internal(e.to_string())) - } -} - -pub type DatabaseInterface = - rustbreak::Database; - -pub trait DatabaseImpls { - fn set_up_database() -> DatabaseInterface; - fn database_is_set_up(&self) -> bool; - fn fetch_base_url(&self) -> Url; -} -impl DatabaseImpls for DatabaseInterface { - fn set_up_database() -> DatabaseInterface { - let data_root_dir = DATA_ROOT_DIR.lock().unwrap(); - let db_path = data_root_dir.join("drop.db"); - let games_base_dir = data_root_dir.join("games"); - let logs_root_dir = data_root_dir.join("logs"); - - debug!("Creating data directory at {:?}", data_root_dir); - create_dir_all(data_root_dir.clone()).unwrap(); - debug!("Creating games directory"); - create_dir_all(games_base_dir.clone()).unwrap(); - debug!("Creating logs directory"); - create_dir_all(logs_root_dir.clone()).unwrap(); - - #[allow(clippy::let_and_return)] - let exists = fs::exists(db_path.clone()).unwrap(); - - match exists { - true => PathDatabase::load_from_path(db_path).expect("Database loading failed"), - false => { - let default = Database { - auth: None, - base_url: "".to_string(), - applications: DatabaseApplications { - install_dirs: vec![games_base_dir.to_str().unwrap().to_string()], - statuses: HashMap::new(), - transient_statuses: HashMap::new(), - versions: HashMap::new(), - }, - }; - debug!( - "Creating database at path {}", - db_path.as_os_str().to_str().unwrap() - ); - PathDatabase::create_at_path(db_path, default) - .expect("Database could not be created") - } - } - } - - fn database_is_set_up(&self) -> bool { - !self.borrow_data().unwrap().base_url.is_empty() - } - - fn fetch_base_url(&self) -> Url { - let handle = self.borrow_data().unwrap(); - Url::parse(&handle.base_url).unwrap() - } -} - -#[tauri::command] -pub fn add_download_dir(new_dir: String) -> Result<(), String> { - // Check the new directory is all good - let new_dir_path = Path::new(&new_dir); - if new_dir_path.exists() { - let metadata = new_dir_path - .metadata() - .map_err(|e| format!("Unable to access file or directory: {}", e))?; - if !metadata.is_dir() { - return Err("Invalid path: not a directory".to_string()); - } - let dir_contents = new_dir_path - .read_dir() - .map_err(|e| format!("Unable to check directory contents: {}", e))?; - if dir_contents.count() != 0 { - return Err("Directory is not empty".to_string()); - } - } else { - create_dir_all(new_dir_path) - .map_err(|e| format!("Unable to create directories to path: {}", e))?; - } - - // Add it to the dictionary - let mut lock = DB.borrow_data_mut().unwrap(); - if lock.applications.install_dirs.contains(&new_dir) { - return Err("Download directory already used".to_string()); - } - lock.applications.install_dirs.push(new_dir); - drop(lock); - DB.save().unwrap(); - - Ok(()) -} - -#[tauri::command] -pub fn delete_download_dir(index: usize) -> Result<(), String> { - let mut lock = DB.borrow_data_mut().unwrap(); - lock.applications.install_dirs.remove(index); - drop(lock); - DB.save().unwrap(); - - Ok(()) -} - -// Will, in future, return disk/remaining size -// Just returns the directories that have been set up -#[tauri::command] -pub fn fetch_download_dir_stats() -> Result, String> { - let lock = DB.borrow_data().unwrap(); - let directories = lock.applications.install_dirs.clone(); - drop(lock); - - Ok(directories) -} - -pub fn set_application_status, &String)>( - app_handle: &AppHandle, - id: String, - setter: F, -) { - let mut db_handle = DB.borrow_data_mut().unwrap(); - setter(&mut db_handle, &id); - drop(db_handle); - DB.save().unwrap(); - - let status = DownloadStatusManager::fetch_state(&id); - - push_application_update(app_handle, id, status); -} diff --git a/src-tauri/src/download_manager/commands.rs b/src-tauri/src/download_manager/commands.rs new file mode 100644 index 0000000..0a65c0d --- /dev/null +++ b/src-tauri/src/download_manager/commands.rs @@ -0,0 +1,31 @@ +use std::sync::Mutex; + +use crate::{download_manager::downloadable_metadata::DownloadableMetadata, AppState}; + +#[tauri::command] +pub fn pause_downloads(state: tauri::State<'_, Mutex>) { + state.lock().unwrap().download_manager.pause_downloads() +} + +#[tauri::command] +pub fn resume_downloads(state: tauri::State<'_, Mutex>) { + state.lock().unwrap().download_manager.resume_downloads() +} + +#[tauri::command] +pub fn move_download_in_queue( + state: tauri::State<'_, Mutex>, + old_index: usize, + new_index: usize, +) { + state + .lock() + .unwrap() + .download_manager + .rearrange(old_index, new_index) +} + +#[tauri::command] +pub fn cancel_game(state: tauri::State<'_, Mutex>, meta: DownloadableMetadata) { + state.lock().unwrap().download_manager.cancel(meta) +} diff --git a/src-tauri/src/download_manager/download_manager.rs b/src-tauri/src/download_manager/download_manager.rs index 064d57e..b3dee68 100644 --- a/src-tauri/src/download_manager/download_manager.rs +++ b/src-tauri/src/download_manager/download_manager.rs @@ -4,31 +4,29 @@ use std::{ fmt::Debug, sync::{ mpsc::{SendError, Sender}, - Arc, Mutex, MutexGuard, + Mutex, MutexGuard, }, thread::JoinHandle, }; -use log::info; +use log::{debug, info}; use serde::Serialize; -use crate::downloads::download_agent::GameDownloadAgent; +use crate::error::application_download_error::ApplicationDownloadError; -use super::{application_download_error::ApplicationDownloadError, download_manager_builder::{CurrentProgressObject, DownloadAgent, DownloadableQueueStandin}, downloadable::Downloadable, downloadable_metadata::DownloadableMetadata, queue::Queue}; +use super::{ + download_manager_builder::{CurrentProgressObject, DownloadAgent}, + downloadable_metadata::DownloadableMetadata, + queue::Queue, +}; -pub enum DownloadType { - Game, - Tool, - DLC, - Mod -} pub enum DownloadManagerSignal { /// Resumes (or starts) the DownloadManager Go, /// Pauses the DownloadManager Stop, /// Called when a DownloadAgent has fully completed a download. - Completed(String), + Completed(DownloadableMetadata), /// Generates and appends a DownloadAgent /// to the registry and queue Queue(DownloadAgent), @@ -36,10 +34,10 @@ pub enum DownloadManagerSignal { /// download, sync everything to disk, and /// then exit Finish, - /// Stops (but doesn't remove) current download - Cancel, + /// Stops, removes, and tells a download to cleanup + Cancel(DownloadableMetadata), /// Removes a given application - Remove(String), + Remove(DownloadableMetadata), /// Any error which occurs in the agent Error(ApplicationDownloadError), /// Pushes UI update @@ -47,7 +45,7 @@ pub enum DownloadManagerSignal { UpdateUIStats(usize, usize), //kb/s and seconds /// Uninstall download /// Takes download ID - Uninstall(String), + Uninstall(DownloadableMetadata), } #[derive(Debug, Clone)] @@ -86,27 +84,11 @@ pub enum DownloadStatus { /// which provides raw access to the underlying queue. /// THIS EDITING IS BLOCKING!!! pub struct DownloadManager { - terminator: JoinHandle>, + terminator: Mutex>>>, download_queue: Queue, progress: CurrentProgressObject, command_sender: Sender, } -impl From> for DownloadableQueueStandin { - fn from(value: Arc) -> Self { - Self { - id: value.id(), - status: Mutex::from(DownloadStatus::Queued), - progress: value.progress().clone(), - } - } -} -impl Debug for DownloadableQueueStandin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DownloadableQueueStandin") - .field("id", &self.id) - .finish() - } -} #[allow(dead_code)] impl DownloadManager { @@ -117,7 +99,7 @@ impl DownloadManager { command_sender: Sender, ) -> Self { Self { - terminator, + terminator: Mutex::new(Some(terminator)), download_queue, progress, command_sender, @@ -126,40 +108,35 @@ impl DownloadManager { pub fn queue_download( &self, - id: String, - version: String, - target_download_dir: usize, + download: DownloadAgent, ) -> Result<(), SendError> { - info!("Adding download id {}", id); - self.command_sender.send(DownloadManagerSignal::Queue( - id, - version, - target_download_dir, - ))?; + info!("creating download with meta {:?}", download.metadata()); + self.command_sender + .send(DownloadManagerSignal::Queue(download))?; self.command_sender.send(DownloadManagerSignal::Go) } - pub fn edit(&self) -> MutexGuard<'_, VecDeque>> { + pub fn edit(&self) -> MutexGuard<'_, VecDeque> { self.download_queue.edit() } - pub fn read_queue(&self) -> VecDeque> { + pub fn read_queue(&self) -> VecDeque { self.download_queue.read() } pub fn get_current_download_progress(&self) -> Option { let progress_object = (*self.progress.lock().unwrap()).clone()?; Some(progress_object.get_progress()) } - pub fn rearrange_string(&self, id: String, new_index: usize) { + pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) { let mut queue = self.edit(); - let current_index = get_index_from_id(&mut queue, id).unwrap(); + let current_index = get_index_from_id(&mut queue, meta).unwrap(); let to_move = queue.remove(current_index).unwrap(); queue.insert(new_index, to_move); self.command_sender .send(DownloadManagerSignal::UpdateUIQueue) .unwrap(); } - pub fn cancel(&self, id: String) { + pub fn cancel(&self, meta: DownloadableMetadata) { self.command_sender - .send(DownloadManagerSignal::Remove(id)) + .send(DownloadManagerSignal::Cancel(meta)) .unwrap(); } pub fn rearrange(&self, current_index: usize, new_index: usize) { @@ -170,16 +147,18 @@ impl DownloadManager { let needs_pause = current_index == 0 || new_index == 0; if needs_pause { self.command_sender - .send(DownloadManagerSignal::Cancel) + .send(DownloadManagerSignal::Stop) .unwrap(); } - info!("moving {} to {}", current_index, new_index); + debug!( + "moving download at index {} to index {}", + current_index, new_index + ); let mut queue = self.edit(); let to_move = queue.remove(current_index).unwrap(); queue.insert(new_index, to_move); - info!("new queue: {:?}", queue); drop(queue); if needs_pause { @@ -188,6 +167,10 @@ impl DownloadManager { self.command_sender .send(DownloadManagerSignal::UpdateUIQueue) .unwrap(); + self.command_sender + .send(DownloadManagerSignal::Go) + .unwrap(); + } pub fn pause_downloads(&self) { self.command_sender @@ -197,26 +180,30 @@ impl DownloadManager { pub fn resume_downloads(&self) { self.command_sender.send(DownloadManagerSignal::Go).unwrap(); } - pub fn ensure_terminated(self) -> Result, Box> { + pub fn ensure_terminated(&self) -> Result, Box> { self.command_sender .send(DownloadManagerSignal::Finish) .unwrap(); - self.terminator.join() + let terminator = self.terminator.lock().unwrap().take(); + terminator.unwrap().join() } - pub fn uninstall_application(&self, id: String) { + pub fn uninstall_application(&self, meta: DownloadableMetadata) { self.command_sender - .send(DownloadManagerSignal::Uninstall(id)) + .send(DownloadManagerSignal::Uninstall(meta)) .unwrap(); } + pub fn get_sender(&self) -> Sender { + self.command_sender.clone() + } } /// Takes in the locked value from .edit() and attempts to /// get the index of whatever id is passed in fn get_index_from_id( - queue: &mut MutexGuard<'_, VecDeque>>, - id: String, + queue: &mut MutexGuard<'_, VecDeque>, + meta: &DownloadableMetadata, ) -> Option { queue .iter() - .position(|download_agent| download_agent.id == id) + .position(|download_agent| download_agent == meta) } diff --git a/src-tauri/src/download_manager/download_manager_builder.rs b/src-tauri/src/download_manager/download_manager_builder.rs index fe0da0d..dd43dc2 100644 --- a/src-tauri/src/download_manager/download_manager_builder.rs +++ b/src-tauri/src/download_manager/download_manager_builder.rs @@ -1,30 +1,29 @@ use std::{ collections::HashMap, - fs::remove_dir_all, sync::{ mpsc::{channel, Receiver, Sender}, - Arc, Mutex, RwLockWriteGuard, + Arc, Mutex, }, thread::{spawn, JoinHandle}, }; -use log::{error, info}; +use log::{debug, error, info, warn}; use tauri::{AppHandle, Emitter}; use crate::{ - db::{set_application_status, ApplicationStatus, ApplicationTransientStatus, Database}, download_manager::{download_manager::{DownloadStatus, DownloadType}, generate_downloadable::generate_downloadable}, downloads::download_agent::{self, GameDownloadAgent}, library::{ - on_game_complete, push_application_update, QueueUpdateEvent, - QueueUpdateEventQueueData, StatsUpdateEvent, - }, state::DownloadStatusManager, DB + error::application_download_error::ApplicationDownloadError, + games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}, }; -use super::{application_download_error::ApplicationDownloadError, download_manager::{DownloadManager, DownloadManagerSignal, DownloadManagerStatus}, download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, downloadable::Downloadable, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject, queue::Queue}; +use super::{ + download_manager::{DownloadManager, DownloadManagerSignal, DownloadManagerStatus}, + download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, + downloadable::Downloadable, + downloadable_metadata::DownloadableMetadata, + progress_object::ProgressObject, + queue::Queue, +}; -pub struct DownloadableQueueStandin { - pub id: String, - pub status: Mutex, - pub progress: Arc, -} pub type DownloadAgent = Arc>; pub type CurrentProgressObject = Arc>>>; @@ -57,7 +56,7 @@ whichever download queue order is required. +----------------------------------------------------------------------------+ This download queue does not actually own any of the DownloadAgents. It is -simply a id-based reference system. The actual Agents are stored in the +simply an id-based reference system. The actual Agents are stored in the download_agent_registry HashMap, as ordering is no issue here. This is why appending or removing from the download_queue must be done via signals. @@ -66,7 +65,7 @@ Behold, my madness - quexeky */ pub struct DownloadManagerBuilder { - download_agent_registry: HashMap, DownloadAgent>, + download_agent_registry: HashMap, download_queue: Queue, command_receiver: Receiver, sender: Sender, @@ -104,247 +103,13 @@ impl DownloadManagerBuilder { DownloadManager::new(terminator, queue, active_progress, command_sender) } - fn manage_queue(mut self) -> Result<(), ()> { - loop { - let signal = match self.command_receiver.recv() { - Ok(signal) => signal, - Err(_) => return Err(()), - }; - - match signal { - DownloadManagerSignal::Go => { - self.manage_go_signal(); - } - DownloadManagerSignal::Stop => { - self.manage_stop_signal(); - } - DownloadManagerSignal::Completed(id) => { - self.manage_completed_signal(id); - } - DownloadManagerSignal::Queue(download_agent) => { - self.manage_queue_signal(download_agent); - } - DownloadManagerSignal::Error(e) => { - self.manage_error_signal(e); - } - DownloadManagerSignal::Cancel => { - self.manage_cancel_signal(); - } - DownloadManagerSignal::UpdateUIQueue => { - self.push_ui_queue_update(); - } - DownloadManagerSignal::UpdateUIStats(kbs, time) => { - self.push_ui_stats_update(kbs, time); - } - DownloadManagerSignal::Finish => { - self.stop_and_wait_current_download(); - return Ok(()); - } - DownloadManagerSignal::Remove(id) => { - self.manage_remove_download_from_queue(id); - } - DownloadManagerSignal::Uninstall(id) => { - self.uninstall_application(id); - } - }; - } - } - fn manage_queue_signal(&mut self, download_agent: DownloadAgent) { - info!("Got signal Queue"); - let meta = download_agent.metadata(); - - if self.download_queue.exists(meta.clone()) { - info!("Download with same ID already exists"); - return; - } - - let download_agent = generate_downloadable(meta.clone()); - download_agent.on_initialised(&self.app_handle); - self.download_queue.append(meta.clone()); - self.download_agent_registry.insert(meta, download_agent); - - self.sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap(); - } -} -/* -// Refactored to consolidate this type. It's a monster. -pub type DownloadAgent = Arc>>; - - -impl DownloadManagerBuilder { - fn manage_queue_signal(&mut self, id: String, version: String, target_download_dir: usize) { - info!("Got signal Queue"); - - if let Some(index) = self.download_queue.get_by_id(id.clone()) { - // Should always give us a value - if let Some(download_agent) = self.download_agent_registry.get(&id) { - let download_agent_handle = download_agent.lock().unwrap(); - if download_agent_handle.version() == version { - info!("Application with same version already queued, skipping"); - return; - } - // If it's not the same, we want to cancel the current one, and then add the new one - drop(download_agent_handle); - - self.manage_remove_download_from_queue(id.clone()); - } - } - - let download_agent = Arc::new(Mutex::new(DownloadType::Game.generate( - id.clone(), - version, - target_download_dir, - self.sender.clone(), - ))); - let download_agent_lock = download_agent.lock().unwrap(); - - let agent_status = DownloadStatus::Queued; - let interface_data = DownloadableQueueStandin { - id: id.clone(), - status: Mutex::new(agent_status), - progress: download_agent_lock.progress() - }; - let version_name = download_agent_lock.version().clone(); - - drop(download_agent_lock); - - self.download_agent_registry - .insert(interface_data.id.clone(), download_agent); - self.download_queue.append(interface_data); - - self.set_application_status(id, |db, id| { - db.applications.transient_statuses.insert( - id.to_string(), - ApplicationTransientStatus::Downloading { version_name }, - ); - }); - self.sender - .send(DownloadManagerSignal::UpdateUIQueue) - .unwrap(); - } - fn manage_go_signal(&mut self) { - if !(!self.download_agent_registry.is_empty() && !self.download_queue.empty()) { - return; - } - - if self.current_download_agent.is_some() { - info!("skipping go signal due to existing download job"); - return; - } - - info!("current download queue: {:?}", self.download_queue.read()); - let agent_data = self.download_queue.read().front().unwrap().clone(); - info!("starting download for {}", agent_data.id.clone()); - let download_agent = self - .download_agent_registry - .get(&agent_data.id) - .unwrap() - .clone(); - let download_agent_lock = download_agent.lock().unwrap(); - self.current_download_agent = Some(agent_data); - // Cloning option should be okay because it only clones the Arc inside, not the AgentInterfaceData - let agent_data = self.current_download_agent.clone().unwrap(); - - let version_name = download_agent_lock.version().clone(); - - let progress_object = download_agent_lock.progress(); - *self.progress.lock().unwrap() = Some(progress_object); - - let active_control_flag = download_agent_lock.control_flag(); - self.active_control_flag = Some(active_control_flag.clone()); - - let sender = self.sender.clone(); - - drop(download_agent_lock); - - info!("Spawning download"); - let mut download_thread_lock = self.current_download_thread.lock().unwrap(); - *download_thread_lock = Some(spawn(move || { - let mut download_agent_lock = download_agent.lock().unwrap(); - match download_agent_lock.download() { - // Returns once we've exited the download - // (not necessarily completed) - // The download agent will fire the completed event for us - Ok(_) => {} - // If an error occurred while *starting* the download - Err(err) => { - error!("error while managing download: {}", err); - sender.send(DownloadManagerSignal::Error(err)).unwrap(); - } - }; - drop(download_agent_lock); - })); - - // Set status for applications - for queue_application in self.download_queue.read() { - let mut status_handle = queue_application.status.lock().unwrap(); - if queue_application.id == agent_data.id { - *status_handle = DownloadStatus::Downloading; - } else { - *status_handle = DownloadStatus::Queued; - } - drop(status_handle); - } - - // Set flags for download manager - active_control_flag.set(DownloadThreadControlFlag::Go); - self.set_status(DownloadManagerStatus::Downloading); - self.set_application_status(agent_data.id.clone(), |db, id| { - db.applications.transient_statuses.insert( - id.to_string(), - ApplicationTransientStatus::Downloading { version_name }, - ); - }); - - self.sender - .send(DownloadManagerSignal::UpdateUIQueue) - .unwrap(); + fn set_status(&self, status: DownloadManagerStatus) { + *self.status.lock().unwrap() = status; } - fn push_ui_stats_update(&self, kbs: usize, time: usize) { - let event_data = StatsUpdateEvent { speed: kbs, time }; - - self.app_handle.emit("update_stats", event_data).unwrap(); - } - - fn push_ui_queue_update(&self) { - let queue = self.download_queue.read(); - let queue_objs: Vec = queue - .iter() - .map(|interface| QueueUpdateEventQueueData { - id: interface.id.clone(), - status: interface.status.lock().unwrap().clone(), - progress: interface.progress.get_progress(), - }) - .collect(); - - let status_handle = self.status.lock().unwrap(); - let status = status_handle.clone(); - drop(status_handle); - - let event_data = QueueUpdateEvent { - queue: queue_objs, - status, - }; - self.app_handle.emit("update_queue", event_data).unwrap(); - } - - fn stop_and_wait_current_download(&self) { - self.set_status(DownloadManagerStatus::Paused); - if let Some(current_flag) = &self.active_control_flag { - current_flag.set(DownloadThreadControlFlag::Stop); - } - - let mut download_thread_lock = self.current_download_thread.lock().unwrap(); - if let Some(current_download_thread) = download_thread_lock.take() { - current_download_thread.join().unwrap(); - } - drop(download_thread_lock); - } - - fn remove_and_cleanup_front_download(&mut self, id: &String) -> DownloadAgent { + fn remove_and_cleanup_front_download(&mut self, meta: &DownloadableMetadata) -> DownloadAgent { self.download_queue.pop_front(); - let download_agent = self.download_agent_registry.remove(id).unwrap(); + let download_agent = self.download_agent_registry.remove(meta).unwrap(); self.cleanup_current_download(); download_agent } @@ -361,6 +126,18 @@ impl DownloadManagerBuilder { drop(download_thread_lock); } + fn stop_and_wait_current_download(&self) { + self.set_status(DownloadManagerStatus::Paused); + if let Some(current_flag) = &self.active_control_flag { + current_flag.set(DownloadThreadControlFlag::Stop); + } + + let mut download_thread_lock = self.current_download_thread.lock().unwrap(); + if let Some(current_download_thread) = download_thread_lock.take() { + current_download_thread.join().unwrap(); + } + } + fn manage_queue(mut self) -> Result<(), ()> { loop { let signal = match self.command_receiver.recv() { @@ -375,18 +152,15 @@ impl DownloadManagerBuilder { DownloadManagerSignal::Stop => { self.manage_stop_signal(); } - DownloadManagerSignal::Completed(id) => { - self.manage_completed_signal(id); + DownloadManagerSignal::Completed(meta) => { + self.manage_completed_signal(meta); } - DownloadManagerSignal::Queue(id, version, target_download_dir) => { - self.manage_queue_signal(id, version, target_download_dir); + DownloadManagerSignal::Queue(download_agent) => { + self.manage_queue_signal(download_agent); } DownloadManagerSignal::Error(e) => { self.manage_error_signal(e); } - DownloadManagerSignal::Cancel => { - self.manage_cancel_signal(); - } DownloadManagerSignal::UpdateUIQueue => { self.push_ui_queue_update(); } @@ -397,255 +171,194 @@ impl DownloadManagerBuilder { self.stop_and_wait_current_download(); return Ok(()); } - DownloadManagerSignal::Remove(id) => { - self.manage_remove_download_from_queue(id); - } - DownloadManagerSignal::Uninstall(id) => { - self.uninstall_application(id); + DownloadManagerSignal::Cancel(meta) => { + self.manage_cancel_signal(&meta); } + _ => {} }; } } + fn manage_queue_signal(&mut self, download_agent: DownloadAgent) { + debug!("got signal Queue"); + let meta = download_agent.metadata(); - fn uninstall_application(&mut self, id: String) { - // Removes the download if it's in the queue - self.manage_remove_download_from_queue(id.clone()); + debug!("queue metadata: {:?}", meta); - let mut db_handle = DB.borrow_data_mut().unwrap(); - db_handle - .applications - .transient_statuses - .entry(id.clone()) - .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); - push_application_update( - &self.app_handle, - id.clone(), - (None, Some(ApplicationTransientStatus::Uninstalling {})), - ); - - let previous_state = db_handle.applications.statuses.get(&id).cloned(); - if previous_state.is_none() { - info!("uninstall job doesn't have previous state, failing silently"); + if self.download_queue.exists(meta.clone()) { + warn!("download with same ID already exists"); return; } - let previous_state = previous_state.unwrap(); - if let Some((_version_name, install_dir)) = match previous_state { - ApplicationStatus::Installed { - version_name, - install_dir, - } => Some((version_name, install_dir)), - ApplicationStatus::SetupRequired { - version_name, - install_dir, - } => Some((version_name, install_dir)), - _ => None, - } { - db_handle - .applications - .transient_statuses - .entry(id.clone()) - .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); - drop(db_handle); - let sender = self.sender.clone(); - let app_handle = self.app_handle.clone(); - spawn(move || match remove_dir_all(install_dir) { - Err(e) => { - sender - .send(DownloadManagerSignal::Error(ApplicationDownloadError::IoError( - e.kind(), - ))) - .unwrap(); - } - Ok(_) => { - let mut db_handle = DB.borrow_data_mut().unwrap(); - db_handle.applications.transient_statuses.remove(&id); - db_handle - .applications - .statuses - .entry(id.clone()) - .and_modify(|e| *e = ApplicationStatus::Remote {}); - drop(db_handle); - DB.save().unwrap(); + download_agent.on_initialised(&self.app_handle); + self.download_queue.append(meta.clone()); + self.download_agent_registry.insert(meta, download_agent); - info!("uninstalled {}", id); - - push_application_update(&app_handle, id, (Some(ApplicationStatus::Remote {}), None)); - } - }); - } - } - - fn manage_remove_download_from_queue(&mut self, id: DownloadableMetadata) { - if let Some(current_download) = &self.current_download_agent { - if current_download.lock().unwrap().metadata() == id { - self.manage_cancel_signal(); - } - } - - let index = self.download_queue.get_by_id(id.clone()); - if let Some(index) = index { - let mut queue_handle = self.download_queue.edit(); - queue_handle.remove(index); - set_application_status(&self.app_handle, id, |db_handle, id| { - db_handle.applications.transient_statuses.remove(id); - }); - drop(queue_handle); - } - - if self.current_download_agent.is_none() { - self.manage_go_signal(); - } - - self.push_ui_queue_update(); - } - - fn manage_stop_signal(&mut self) { - info!("Got signal 'Stop'"); - self.set_status(DownloadManagerStatus::Paused); - if let Some(active_control_flag) = self.active_control_flag.clone() { - active_control_flag.set(DownloadThreadControlFlag::Stop); - } - } - - fn manage_completed_signal(&mut self, id: String) { - info!("Got signal 'Completed'"); - if let Some(interface) = &self.current_download_agent { - if interface.id == id { - info!("Popping consumed data"); - let download_agent = self.remove_and_cleanup_front_download(&id); - let download_agent_lock = download_agent.lock().unwrap(); - - drop(download_agent_lock); - - if let Err(error) = - self.current_download_agent.on_complete(&self.app_handle); - { - self.sender - .send(DownloadManagerSignal::Error( - ApplicationDownloadError::Communication(error), - )) - .unwrap(); - } - } - } self.sender .send(DownloadManagerSignal::UpdateUIQueue) .unwrap(); - self.sender.send(DownloadManagerSignal::Go).unwrap(); } - fn manage_go_signal(&mut self) { - if !(!self.download_agent_registry.is_empty() && !self.download_queue.empty()) { + debug!("got signal Go"); + if self.download_agent_registry.is_empty() { + debug!( + "Download agent registry: {:?}", + self.download_agent_registry.len() + ); return; } if self.current_download_agent.is_some() { - info!("skipping go signal due to existing download job"); - return; + if self.download_queue.read().front().unwrap() == &self.current_download_agent.as_ref().unwrap().metadata() { + debug!( + "Current download agent: {:?}", + self.current_download_agent.as_ref().unwrap().metadata() + ); + return; + } } - info!("current download queue: {:?}", self.download_queue.read()); + debug!("current download queue: {:?}", self.download_queue.read()); + + // Should always be Some if the above two statements keep going let agent_data = self.download_queue.read().front().unwrap().clone(); - info!("starting download for {}", agent_data.id.clone()); + + info!("starting download for {:?}", agent_data); + let download_agent = self .download_agent_registry - .get(&agent_data.id) + .get(&agent_data) .unwrap() .clone(); - let download_agent_lock = download_agent.lock().unwrap(); - self.current_download_agent = Some(agent_data); - // Cloning option should be okay because it only clones the Arc inside, not the AgentInterfaceData - let agent_data = self.current_download_agent.clone().unwrap(); - let version_name = download_agent_lock.version().clone(); - - let progress_object = download_agent_lock.progress(); - *self.progress.lock().unwrap() = Some(progress_object); - - let active_control_flag = download_agent_lock.control_flag(); - self.active_control_flag = Some(active_control_flag.clone()); + self.active_control_flag = Some(download_agent.control_flag()); + self.current_download_agent = Some(download_agent.clone()); let sender = self.sender.clone(); - drop(download_agent_lock); - - info!("Spawning download"); let mut download_thread_lock = self.current_download_thread.lock().unwrap(); + let app_handle = self.app_handle.clone(); + *download_thread_lock = Some(spawn(move || { - let mut download_agent_lock = download_agent.lock().unwrap(); - match download_agent_lock.download() { - // Returns once we've exited the download - // (not necessarily completed) - // The download agent will fire the completed event for us - Ok(_) => {} - // If an error occurred while *starting* the download - Err(err) => { - error!("error while managing download: {}", err); - sender.send(DownloadManagerSignal::Error(err)).unwrap(); + match download_agent.download(&app_handle) { + // Ok(true) is for completed and exited properly + Ok(true) => { + debug!("download {:?} has completed", download_agent.metadata()); + download_agent.on_complete(&app_handle); + sender + .send(DownloadManagerSignal::Completed(download_agent.metadata())) + .unwrap(); } - }; - drop(download_agent_lock); + // Ok(false) is for incomplete but exited properly + Ok(false) => { + download_agent.on_incomplete(&app_handle); + } + Err(e) => { + error!("download {:?} has error {}", download_agent.metadata(), &e); + download_agent.on_error(&app_handle, e.clone()); + sender.send(DownloadManagerSignal::Error(e)).unwrap(); + } + } + sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap(); })); - // Set status for applications - for queue_application in self.download_queue.read() { - let mut status_handle = queue_application.status.lock().unwrap(); - if queue_application.id == agent_data.id { - *status_handle = DownloadStatus::Downloading; - } else { - *status_handle = DownloadStatus::Queued; - } - drop(status_handle); - } - - // Set flags for download manager - active_control_flag.set(DownloadThreadControlFlag::Go); self.set_status(DownloadManagerStatus::Downloading); - self.set_application_status(agent_data.id.clone(), |db, id| { - db.applications.transient_statuses.insert( - id.to_string(), - ApplicationTransientStatus::Downloading { version_name }, - ); - }); + let active_control_flag = self.active_control_flag.clone().unwrap(); + active_control_flag.set(DownloadThreadControlFlag::Go); + } + fn manage_stop_signal(&mut self) { + debug!("got signal Stop"); - self.sender - .send(DownloadManagerSignal::UpdateUIQueue) - .unwrap(); + if let Some(active_control_flag) = self.active_control_flag.clone() { + self.set_status(DownloadManagerStatus::Paused); + active_control_flag.set(DownloadThreadControlFlag::Stop); + } + } + fn manage_completed_signal(&mut self, meta: DownloadableMetadata) { + debug!("got signal Completed"); + if let Some(interface) = &self.current_download_agent { + if interface.metadata() == meta { + self.remove_and_cleanup_front_download(&meta); + } + } + self.push_ui_queue_update(); + self.sender.send(DownloadManagerSignal::Go).unwrap(); } fn manage_error_signal(&mut self, error: ApplicationDownloadError) { - let current_status = self.current_download_agent.clone().unwrap(); + debug!("got signal Error"); + if let Some(current_agent) = self.current_download_agent.clone() { + current_agent.on_error(&self.app_handle, error.clone()); - self.stop_and_wait_current_download(); - self.remove_and_cleanup_front_download(¤t_status.id); // Remove all the locks and shit, and remove from queue - - self.app_handle - .emit("download_error", error.to_string()) - .unwrap(); - - let mut lock = current_status.status.lock().unwrap(); - *lock = DownloadStatus::Error; + self.stop_and_wait_current_download(); + self.remove_and_cleanup_front_download(¤t_agent.metadata()); + } self.set_status(DownloadManagerStatus::Error(error)); - - let id = current_status.id.clone(); - self.set_application_status(id, |db_handle, id| { - db_handle.applications.transient_statuses.remove(id); - }); - - self.sender - .send(DownloadManagerSignal::UpdateUIQueue) - .unwrap(); } - fn manage_cancel_signal(&mut self) { - self.stop_and_wait_current_download(); + fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) { + debug!("got signal Cancel"); - info!("cancel waited for download to finish"); + if let Some(current_download) = &self.current_download_agent { + if ¤t_download.metadata() == meta { + self.set_status(DownloadManagerStatus::Paused); + current_download.on_cancelled(&self.app_handle); + self.stop_and_wait_current_download(); - self.cleanup_current_download(); + self.download_queue.pop_front(); + + self.cleanup_current_download(); + debug!("current download queue: {:?}", self.download_queue.read()); + } + // TODO: Collapse these two into a single if statement somehow + else if let Some(download_agent) = self.download_agent_registry.get(meta) { + let index = self.download_queue.get_by_meta(meta); + if let Some(index) = index { + download_agent.on_cancelled(&self.app_handle); + let _ = self.download_queue.edit().remove(index).unwrap(); + let removed = self.download_agent_registry.remove(meta); + debug!( + "removed {:?} from queue {:?}", + removed.map(|x| x.metadata()), + self.download_queue.read() + ); + } + } + } else if let Some(download_agent) = self.download_agent_registry.get(meta) { + let index = self.download_queue.get_by_meta(meta); + if let Some(index) = index { + download_agent.on_cancelled(&self.app_handle); + let _ = self.download_queue.edit().remove(index).unwrap(); + let removed = self.download_agent_registry.remove(meta); + debug!( + "removed {:?} from queue {:?}", + removed.map(|x| x.metadata()), + self.download_queue.read() + ); + } + } + self.push_ui_queue_update(); } - fn set_status(&self, status: DownloadManagerStatus) { - *self.status.lock().unwrap() = status; + fn push_ui_stats_update(&self, kbs: usize, time: usize) { + let event_data = StatsUpdateEvent { speed: kbs, time }; + + self.app_handle.emit("update_stats", event_data).unwrap(); + } + fn push_ui_queue_update(&self) { + let queue = &self.download_queue.read(); + let queue_objs = queue + .iter() + .map(|key| { + let val = self.download_agent_registry.get(key).unwrap(); + QueueUpdateEventQueueData { + meta: DownloadableMetadata::clone(key), + status: val.status(), + progress: val.progress().get_progress(), + current: val.progress().sum(), + max: val.progress().get_max(), + } + }) + .collect(); + + let event_data = QueueUpdateEvent { queue: queue_objs }; + self.app_handle.emit("update_queue", event_data).unwrap(); } } -*/ \ No newline at end of file diff --git a/src-tauri/src/download_manager/downloadable.rs b/src-tauri/src/download_manager/downloadable.rs index 693652b..181b329 100644 --- a/src-tauri/src/download_manager/downloadable.rs +++ b/src-tauri/src/download_manager/downloadable.rs @@ -2,16 +2,22 @@ use std::sync::Arc; use tauri::AppHandle; +use crate::error::application_download_error::ApplicationDownloadError; + use super::{ - application_download_error::ApplicationDownloadError, download_thread_control_flag::DownloadThreadControl, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject + download_manager::DownloadStatus, download_thread_control_flag::DownloadThreadControl, + downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject, }; pub trait Downloadable: Send + Sync { - fn download(&mut self) -> Result<(), ApplicationDownloadError>; + fn download(&self, app_handle: &AppHandle) -> Result; fn progress(&self) -> Arc; fn control_flag(&self) -> DownloadThreadControl; - fn metadata(&self) -> Arc; + fn status(&self) -> DownloadStatus; + fn metadata(&self) -> DownloadableMetadata; fn on_initialised(&self, app_handle: &AppHandle); - fn on_error(&self, app_handle: &AppHandle); + fn on_error(&self, app_handle: &AppHandle, error: ApplicationDownloadError); fn on_complete(&self, app_handle: &AppHandle); + fn on_incomplete(&self, app_handle: &AppHandle); + fn on_cancelled(&self, app_handle: &AppHandle); } diff --git a/src-tauri/src/download_manager/downloadable_metadata.rs b/src-tauri/src/download_manager/downloadable_metadata.rs index 7036c51..790b669 100644 --- a/src-tauri/src/download_manager/downloadable_metadata.rs +++ b/src-tauri/src/download_manager/downloadable_metadata.rs @@ -1,4 +1,26 @@ -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DownloadableMetadata { - id: String, +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone, Copy)] +pub enum DownloadType { + Game, + Tool, + DLC, + Mod, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DownloadableMetadata { + pub id: String, + pub version: Option, + pub download_type: DownloadType, +} +impl DownloadableMetadata { + pub fn new(id: String, version: Option, download_type: DownloadType) -> Self { + Self { + id, + version, + download_type, + } + } } diff --git a/src-tauri/src/download_manager/generate_downloadable.rs b/src-tauri/src/download_manager/generate_downloadable.rs deleted file mode 100644 index 254436f..0000000 --- a/src-tauri/src/download_manager/generate_downloadable.rs +++ /dev/null @@ -1,7 +0,0 @@ -use std::sync::Arc; - -use super::{download_manager_builder::DownloadAgent, downloadable_metadata::DownloadableMetadata}; - -pub fn generate_downloadable(meta: Arc) -> DownloadAgent { - todo!() -} \ No newline at end of file diff --git a/src-tauri/src/download_manager/internal_error.rs b/src-tauri/src/download_manager/internal_error.rs new file mode 100644 index 0000000..4864599 --- /dev/null +++ b/src-tauri/src/download_manager/internal_error.rs @@ -0,0 +1,27 @@ +use std::{fmt::Display, io, sync::mpsc::SendError}; + +use serde_with::SerializeDisplay; + +#[derive(SerializeDisplay)] +pub enum InternalError { + IOError(io::Error), + SignalError(SendError), +} +impl Display for InternalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InternalError::IOError(error) => write!(f, "{}", error), + InternalError::SignalError(send_error) => write!(f, "{}", send_error), + } + } +} +impl From> for InternalError { + fn from(value: SendError) -> Self { + InternalError::SignalError(value) + } +} +impl From for InternalError { + fn from(value: io::Error) -> Self { + InternalError::IOError(value) + } +} diff --git a/src-tauri/src/download_manager/mod.rs b/src-tauri/src/download_manager/mod.rs index 6299068..0bac198 100644 --- a/src-tauri/src/download_manager/mod.rs +++ b/src-tauri/src/download_manager/mod.rs @@ -1,9 +1,10 @@ +pub mod commands; pub mod download_manager; pub mod download_manager_builder; -pub mod progress_object; -pub mod queue; pub mod download_thread_control_flag; pub mod downloadable; -pub mod application_download_error; pub mod downloadable_metadata; -pub mod generate_downloadable; \ No newline at end of file +pub mod internal_error; +pub mod progress_object; +pub mod queue; +pub mod rolling_progress_updates; diff --git a/src-tauri/src/download_manager/progress_object.rs b/src-tauri/src/download_manager/progress_object.rs index a3c3c24..fc0907b 100644 --- a/src-tauri/src/download_manager/progress_object.rs +++ b/src-tauri/src/download_manager/progress_object.rs @@ -2,14 +2,17 @@ use std::{ sync::{ atomic::{AtomicUsize, Ordering}, mpsc::Sender, - Arc, Mutex, RwLock, + Arc, Mutex, }, - time::Instant, + time::{Duration, Instant}, }; -use log::info; +use atomic_instant_full::AtomicInstant; +use throttle_my_fn::throttle; -use super::download_manager::DownloadManagerSignal; +use super::{ + download_manager::DownloadManagerSignal, rolling_progress_updates::RollingProgressWindow, +}; #[derive(Clone)] pub struct ProgressObject { @@ -17,11 +20,10 @@ pub struct ProgressObject { progress_instances: Arc>>>, start: Arc>, sender: Sender, - - points_towards_update: Arc, - points_to_push_update: Arc, - last_update: Arc>, - amount_last_update: Arc, + //last_update: Arc>, + last_update_time: Arc, + bytes_last_update: Arc, + rolling: RollingProgressWindow<250>, } pub struct ProgressHandle { @@ -42,72 +44,32 @@ impl ProgressHandle { pub fn add(&self, amount: usize) { self.progress .fetch_add(amount, std::sync::atomic::Ordering::Relaxed); - self.progress_object.check_push_update(amount); + calculate_update(&self.progress_object); + } + pub fn skip(&self, amount: usize) { + self.progress + .fetch_add(amount, std::sync::atomic::Ordering::Relaxed); + // Offset the bytes at last offset by this amount + self.progress_object + .bytes_last_update + .fetch_add(amount, Ordering::Relaxed); + // Dont' fire update } } -static PROGRESS_UPDATES: usize = 100; - impl ProgressObject { pub fn new(max: usize, length: usize, sender: Sender) -> Self { let arr = Mutex::new((0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect()); // TODO: consolidate this calculation with the set_max function below - let points_to_push_update = max / PROGRESS_UPDATES; Self { max: Arc::new(Mutex::new(max)), progress_instances: Arc::new(arr), start: Arc::new(Mutex::new(Instant::now())), sender, - points_towards_update: Arc::new(AtomicUsize::new(0)), - points_to_push_update: Arc::new(AtomicUsize::new(points_to_push_update)), - last_update: Arc::new(RwLock::new(Instant::now())), - amount_last_update: Arc::new(AtomicUsize::new(0)), - } - } - - pub fn check_push_update(&self, amount_added: usize) { - let current_amount = self - .points_towards_update - .fetch_add(amount_added, Ordering::Relaxed); - - let to_update = self.points_to_push_update.fetch_add(0, Ordering::Relaxed); - - if current_amount >= to_update { - self.points_towards_update - .fetch_sub(to_update, Ordering::Relaxed); - self.sender - .send(DownloadManagerSignal::UpdateUIQueue) - .unwrap(); - } - - let last_update = self.last_update.read().unwrap(); - let last_update_difference = Instant::now().duration_since(*last_update).as_millis(); - if last_update_difference > 1000 { - // push update - drop(last_update); - let mut last_update = self.last_update.write().unwrap(); - *last_update = Instant::now(); - drop(last_update); - - let current_amount = self.sum(); - let max = self.get_max(); - let amount_at_last_update = self.amount_last_update.fetch_add(0, Ordering::Relaxed); - self.amount_last_update - .store(current_amount, Ordering::Relaxed); - - let amount_since_last_update = current_amount - amount_at_last_update; - - let kilobytes_per_second = amount_since_last_update / (last_update_difference as usize).max(1); - - let remaining = max - current_amount; // bytes - let time_remaining = (remaining / 1000) / kilobytes_per_second.max(1); - self.sender - .send(DownloadManagerSignal::UpdateUIStats( - kilobytes_per_second, - time_remaining, - )) - .unwrap(); + last_update_time: Arc::new(AtomicInstant::now()), + bytes_last_update: Arc::new(AtomicUsize::new(0)), + rolling: RollingProgressWindow::new(), } } @@ -127,9 +89,6 @@ impl ProgressObject { } pub fn set_max(&self, new_max: usize) { *self.max.lock().unwrap() = new_max; - self.points_to_push_update - .store(new_max / PROGRESS_UPDATES, Ordering::Relaxed); - info!("points to push update: {}", new_max / PROGRESS_UPDATES); } pub fn set_size(&self, length: usize) { *self.progress_instances.lock().unwrap() = @@ -141,4 +100,56 @@ impl ProgressObject { pub fn get(&self, index: usize) -> Arc { self.progress_instances.lock().unwrap()[index].clone() } + fn update_window(&self, kilobytes_per_second: usize) { + self.rolling.update(kilobytes_per_second); + } +} + +#[throttle(1, Duration::from_millis(20))] +pub fn calculate_update(progress: &ProgressObject) { + let last_update_time = progress + .last_update_time + .swap(Instant::now(), Ordering::SeqCst); + let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis(); + + let current_bytes_downloaded = progress.sum(); + let max = progress.get_max(); + let bytes_at_last_update = progress + .bytes_last_update + .swap(current_bytes_downloaded, Ordering::Relaxed); + + let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update; + + let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1); + + let bytes_remaining = max - current_bytes_downloaded; // bytes + + progress.update_window(kilobytes_per_second); + push_update(progress, bytes_remaining); +} + +#[throttle(1, Duration::from_millis(500))] +pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) { + let average_speed = progress.rolling.get_average(); + let time_remaining = (bytes_remaining / 1000) / average_speed.max(1); + + update_ui(progress, average_speed, time_remaining); + update_queue(progress); +} + +fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) { + progress_object + .sender + .send(DownloadManagerSignal::UpdateUIStats( + kilobytes_per_second, + time_remaining, + )) + .unwrap(); +} + +fn update_queue(progress: &ProgressObject) { + progress + .sender + .send(DownloadManagerSignal::UpdateUIQueue) + .unwrap(); } diff --git a/src-tauri/src/download_manager/queue.rs b/src-tauri/src/download_manager/queue.rs index 64a147e..f3e9493 100644 --- a/src-tauri/src/download_manager/queue.rs +++ b/src-tauri/src/download_manager/queue.rs @@ -7,29 +7,35 @@ use super::downloadable_metadata::DownloadableMetadata; #[derive(Clone)] pub struct Queue { - inner: Arc>>>, + inner: Arc>>, } #[allow(dead_code)] +impl Default for Queue { + fn default() -> Self { + Self::new() + } +} + impl Queue { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(VecDeque::new())), } } - pub fn read(&self) -> VecDeque> { + pub fn read(&self) -> VecDeque { self.inner.lock().unwrap().clone() } - pub fn edit(&self) -> MutexGuard<'_, VecDeque>> { + pub fn edit(&self) -> MutexGuard<'_, VecDeque> { self.inner.lock().unwrap() } - pub fn pop_front(&self) -> Option> { + pub fn pop_front(&self) -> Option { self.edit().pop_front() } - pub fn empty(&self) -> bool { + pub fn is_empty(&self) -> bool { self.inner.lock().unwrap().len() == 0 } - pub fn exists(&self, meta: Arc) -> bool { + pub fn exists(&self, meta: DownloadableMetadata) -> bool { self.read().contains(&meta) } /// Either inserts `interface` at the specified index, or appends to @@ -38,30 +44,28 @@ impl Queue { if self.read().len() > index { self.append(interface); } else { - self.edit().insert(index, Arc::new(interface)); + self.edit().insert(index, interface); } } - pub fn append(&self, interface: Arc) { + pub fn append(&self, interface: DownloadableMetadata) { self.edit().push_back(interface); } - pub fn pop_front_if_equal( - &self, - meta: &Arc, - ) -> Option> { + pub fn pop_front_if_equal(&self, meta: &DownloadableMetadata) -> Option { let mut queue = self.edit(); - let front = match queue.front() { - Some(front) => front, - None => return None, - }; + let front = queue.front()?; if front == meta { return queue.pop_front(); } None } - pub fn get_by_meta(&self, meta: &Arc) -> Option { + pub fn get_by_meta(&self, meta: &DownloadableMetadata) -> Option { self.read().iter().position(|data| data == meta) } - pub fn move_to_index_by_meta(&self, meta: &Arc, new_index: usize) -> Result<(), ()> { + pub fn move_to_index_by_meta( + &self, + meta: &DownloadableMetadata, + new_index: usize, + ) -> Result<(), ()> { let index = match self.get_by_meta(meta) { Some(index) => index, None => return Err(()), diff --git a/src-tauri/src/download_manager/rolling_progress_updates.rs b/src-tauri/src/download_manager/rolling_progress_updates.rs new file mode 100644 index 0000000..2239b9a --- /dev/null +++ b/src-tauri/src/download_manager/rolling_progress_updates.rs @@ -0,0 +1,33 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +#[derive(Clone)] +pub struct RollingProgressWindow { + window: Arc<[AtomicUsize; S]>, + current: Arc, +} +impl RollingProgressWindow { + pub fn new() -> Self { + Self { + window: Arc::new([(); S].map(|_| AtomicUsize::new(0))), + current: Arc::new(AtomicUsize::new(0)), + } + } + pub fn update(&self, kilobytes_per_second: usize) { + let index = self.current.fetch_add(1, Ordering::SeqCst); + let current = &self.window[index % S]; + current.store(kilobytes_per_second, Ordering::SeqCst); + } + pub fn get_average(&self) -> usize { + let current = self.current.load(Ordering::SeqCst); + self.window + .iter() + .enumerate() + .filter(|(i, _)| i < ¤t) + .map(|(_, x)| x.load(Ordering::Relaxed)) + .sum::() + / S + } +} diff --git a/src-tauri/src/downloads/download_agent.rs b/src-tauri/src/downloads/download_agent.rs deleted file mode 100644 index f91372d..0000000 --- a/src-tauri/src/downloads/download_agent.rs +++ /dev/null @@ -1,323 +0,0 @@ -use crate::auth::generate_authorization_header; -use crate::db::DatabaseImpls; -use crate::download_manager::application_download_error::ApplicationDownloadError; -use crate::download_manager::download_manager::DownloadManagerSignal; -use crate::download_manager::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}; -use crate::download_manager::downloadable::Downloadable; -use crate::download_manager::progress_object::{ProgressHandle, ProgressObject}; -use crate::downloads::manifest::{DropDownloadContext, DropManifest}; -use crate::remote::RemoteAccessError; -use crate::DB; -use log::{debug, error, info}; -use rayon::ThreadPoolBuilder; -use std::collections::VecDeque; -use std::fs::{create_dir_all, File}; -use std::path::Path; -use std::sync::mpsc::Sender; -use std::sync::{Arc, Mutex}; -use std::time::Instant; -use urlencoding::encode; - -#[cfg(target_os = "linux")] -use rustix::fs::{fallocate, FallocateFlags}; - -use super::download_logic::download_game_chunk; -use super::stored_manifest::StoredManifest; - -pub struct GameDownloadAgent { - pub id: String, - pub version: String, - pub control_flag: DownloadThreadControl, - contexts: Vec, - completed_contexts: VecDeque, - pub manifest: Mutex>, - pub progress: Arc, - sender: Sender, - pub stored_manifest: StoredManifest, -} - - - -impl GameDownloadAgent { - pub fn new( - id: String, - version: String, - target_download_dir: usize, - sender: Sender, - ) -> Self { - // Don't run by default - let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop); - - let db_lock = DB.borrow_data().unwrap(); - let base_dir = db_lock.applications.install_dirs[target_download_dir].clone(); - drop(db_lock); - - let base_dir_path = Path::new(&base_dir); - let data_base_dir_path = base_dir_path.join(id.clone()); - - let stored_manifest = - StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone()); - - Self { - id, - version, - control_flag, - manifest: Mutex::new(None), - contexts: Vec::new(), - completed_contexts: VecDeque::new(), - progress: Arc::new(ProgressObject::new(0, 0, sender.clone())), - sender, - stored_manifest, - } - } - - // Blocking - pub fn setup_download(&mut self) -> Result<(), ApplicationDownloadError> { - self.ensure_manifest_exists()?; - info!("Ensured manifest exists"); - - self.ensure_contexts()?; - info!("Ensured contexts exists"); - - self.control_flag.set(DownloadThreadControlFlag::Go); - - Ok(()) - } - - // Blocking - pub fn download(&mut self) -> Result<(), ApplicationDownloadError> { - self.setup_download()?; - self.set_progress_object_params(); - let timer = Instant::now(); - self.run().map_err(|_| ApplicationDownloadError::DownloadError)?; - - info!( - "{} took {}ms to download", - self.id, - timer.elapsed().as_millis() - ); - Ok(()) - } - - pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> { - if self.manifest.lock().unwrap().is_some() { - return Ok(()); - } - - self.download_manifest() - } - - fn download_manifest(&self) -> Result<(), ApplicationDownloadError> { - let base_url = DB.fetch_base_url(); - let manifest_url = base_url - .join( - format!( - "/api/v1/client/metadata/manifest?id={}&version={}", - self.id, - encode(&self.version) - ) - .as_str(), - ) - .unwrap(); - - let header = generate_authorization_header(); - let client = reqwest::blocking::Client::new(); - let response = client - .get(manifest_url.to_string()) - .header("Authorization", header) - .send() - .unwrap(); - - if response.status() != 200 { - return Err(ApplicationDownloadError::Communication( - RemoteAccessError::ManifestDownloadFailed( - response.status(), - response.text().unwrap(), - ), - )); - } - - let manifest_download = response.json::().unwrap(); - - if let Ok(mut manifest) = self.manifest.lock() { - *manifest = Some(manifest_download); - return Ok(()); - } - - Err(ApplicationDownloadError::Lock) - } - - fn set_progress_object_params(&self) { - // Avoid re-setting it - if self.progress.get_max() != 0 { - return; - } - - let length = self.contexts.len(); - - let chunk_count = self.contexts.iter().map(|chunk| chunk.length).sum(); - - debug!("Setting ProgressObject max to {}", chunk_count); - self.progress.set_max(chunk_count); - debug!("Setting ProgressObject size to {}", length); - self.progress.set_size(length); - debug!("Setting ProgressObject time to now"); - self.progress.set_time_now(); - } - - pub fn ensure_contexts(&mut self) -> Result<(), ApplicationDownloadError> { - if !self.contexts.is_empty() { - return Ok(()); - } - - self.generate_contexts()?; - Ok(()) - } - - pub fn generate_contexts(&mut self) -> Result<(), ApplicationDownloadError> { - let manifest = self.manifest.lock().unwrap().clone().unwrap(); - let game_id = self.id.clone(); - - let mut contexts = Vec::new(); - let base_path = Path::new(&self.stored_manifest.base_path); - create_dir_all(base_path).unwrap(); - - self.completed_contexts.clear(); - self.completed_contexts - .extend(self.stored_manifest.get_completed_contexts()); - - for (raw_path, chunk) in manifest { - let path = base_path.join(Path::new(&raw_path)); - - let container = path.parent().unwrap(); - create_dir_all(container).unwrap(); - - let file = File::create(path.clone()).unwrap(); - let mut running_offset = 0; - - for (index, length) in chunk.lengths.iter().enumerate() { - contexts.push(DropDownloadContext { - file_name: raw_path.to_string(), - version: chunk.version_name.to_string(), - offset: running_offset, - index, - game_id: game_id.to_string(), - path: path.clone(), - checksum: chunk.checksums[index].clone(), - length: *length, - permissions: chunk.permissions, - }); - running_offset += *length as u64; - } - - #[cfg(target_os = "linux")] - if running_offset > 0 { - let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset); - } - } - self.contexts = contexts; - - Ok(()) - } - - pub fn run(&mut self) -> Result<(), ()> { - info!("downloading game: {}", self.id); - const DOWNLOAD_MAX_THREADS: usize = 1; - - let pool = ThreadPoolBuilder::new() - .num_threads(DOWNLOAD_MAX_THREADS) - .build() - .unwrap(); - - let completed_indexes = Arc::new(boxcar::Vec::new()); - let completed_indexes_loop_arc = completed_indexes.clone(); - - pool.scope(|scope| { - for (index, context) in self.contexts.iter().enumerate() { - let completed_indexes = completed_indexes_loop_arc.clone(); - - let progress = self.progress.get(index); // Clone arcs - let progress_handle = ProgressHandle::new(progress, self.progress.clone()); - // If we've done this one already, skip it - if self.completed_contexts.contains(&index) { - progress_handle.add(context.length); - continue; - } - - let context = context.clone(); - let control_flag = self.control_flag.clone(); // Clone arcs - - let sender = self.sender.clone(); - - scope.spawn(move |_| { - match download_game_chunk(context.clone(), control_flag, progress_handle) { - Ok(res) => { - if res { - completed_indexes.push(index); - } - } - Err(e) => { - error!("{}", e); - sender.send(DownloadManagerSignal::Error(e)).unwrap(); - } - } - }); - } - }); - - let newly_completed = completed_indexes.to_owned(); - - let completed_lock_len = { - for (item, _) in newly_completed.iter() { - self.completed_contexts.push_front(item); - } - - self.completed_contexts.len() - }; - - // If we're not out of contexts, we're not done, so we don't fire completed - if completed_lock_len != self.contexts.len() { - info!("da for {} exited without completing", self.id.clone()); - self.stored_manifest - .set_completed_contexts(&self.completed_contexts.clone().into()); - info!("Setting completed contexts"); - self.stored_manifest.write(); - info!("Wrote completed contexts"); - return Ok(()); - } - - // We've completed - self.sender - .send(DownloadManagerSignal::Completed(self.id.clone())) - .unwrap(); - - Ok(()) - } -} - -impl Downloadable for GameDownloadAgent { - - fn download(&mut self) -> Result<(), ApplicationDownloadError> { - self.download() - } - - fn progress(&self) -> Arc { - self.progress.clone() - } - - fn control_flag(&self) -> DownloadThreadControl { - self.control_flag.clone() - } - - fn metadata(&self) -> crate::download_manager::downloadable_metadata::DownloadableMetadata { - todo!() - } - - fn on_error(&self) { - todo!() - } - - fn on_complete(&self, app_handle: &AppHandle>) { - on_game_complete(id, version, install_dir, app_handle) - } -} \ No newline at end of file diff --git a/src-tauri/src/downloads/download_commands.rs b/src-tauri/src/downloads/download_commands.rs deleted file mode 100644 index 4ac3554..0000000 --- a/src-tauri/src/downloads/download_commands.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Mutex; - -use crate::AppState; - -#[tauri::command] -pub fn download_game( - game_id: String, - game_version: String, - install_dir: usize, - state: tauri::State<'_, Mutex>, -) -> Result<(), String> { - state - .lock() - .unwrap() - .download_manager - .queue_download(game_id, game_version, install_dir) - .map_err(|_| "An error occurred while communicating with the download manager.".to_string()) -} - -#[tauri::command] -pub fn pause_game_downloads(state: tauri::State<'_, Mutex>) { - state.lock().unwrap().download_manager.pause_downloads() -} - -#[tauri::command] -pub fn resume_game_downloads(state: tauri::State<'_, Mutex>) { - state.lock().unwrap().download_manager.resume_downloads() -} - -#[tauri::command] -pub fn move_game_in_queue( - state: tauri::State<'_, Mutex>, - old_index: usize, - new_index: usize, -) { - state - .lock() - .unwrap() - .download_manager - .rearrange(old_index, new_index) -} - -#[tauri::command] -pub fn cancel_game(state: tauri::State<'_, Mutex>, game_id: String) { - state.lock().unwrap().download_manager.cancel(game_id) -} - -/* -#[tauri::command] -pub fn get_current_write_speed(state: tauri::State<'_, Mutex>) {} -*/ - -/* -fn use_download_agent( - state: tauri::State<'_, Mutex>, - game_id: String, -) -> Result, String> { - let lock = state.lock().unwrap(); - let download_agent = lock.download_manager.get(&game_id).ok_or("Invalid game ID")?; - Ok(download_agent.clone()) // Clones the Arc, not the underlying data structure -} -*/ diff --git a/src-tauri/src/download_manager/application_download_error.rs b/src-tauri/src/error/application_download_error.rs similarity index 55% rename from src-tauri/src/download_manager/application_download_error.rs rename to src-tauri/src/error/application_download_error.rs index 38c690d..d68bd71 100644 --- a/src-tauri/src/download_manager/application_download_error.rs +++ b/src-tauri/src/error/application_download_error.rs @@ -1,9 +1,14 @@ -use std::{fmt::{Display, Formatter}, io}; +use std::{ + fmt::{Display, Formatter}, + io, +}; -use crate::remote::RemoteAccessError; +use serde_with::SerializeDisplay; + +use super::{remote_access_error::RemoteAccessError, setup_error::SetupError}; // TODO: Rename / separate from downloads -#[derive(Debug, Clone)] +#[derive(Debug, Clone, SerializeDisplay)] pub enum ApplicationDownloadError { Communication(RemoteAccessError), Checksum, @@ -17,25 +22,11 @@ impl Display for ApplicationDownloadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { ApplicationDownloadError::Communication(error) => write!(f, "{}", error), - ApplicationDownloadError::Setup(error) => write!(f, "An error occurred while setting up the download: {}", 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::Setup(error) => write!(f, "an error occurred while setting up the download: {}", 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::IoError(error) => write!(f, "{}", 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"), } } } - -#[derive(Debug, Clone)] -pub enum SetupError { - Context, -} - -impl Display for SetupError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - SetupError::Context => write!(f, "Failed to generate contexts for download"), - } - } -} - diff --git a/src-tauri/src/error/drop_server_error.rs b/src-tauri/src/error/drop_server_error.rs new file mode 100644 index 0000000..ab42263 --- /dev/null +++ b/src-tauri/src/error/drop_server_error.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DropServerError { + pub status_code: usize, + pub status_message: String, + pub message: String, + pub url: String, +} diff --git a/src-tauri/src/error/library_error.rs b/src-tauri/src/error/library_error.rs new file mode 100644 index 0000000..c13dd23 --- /dev/null +++ b/src-tauri/src/error/library_error.rs @@ -0,0 +1,19 @@ +use std::fmt::Display; + +use serde_with::SerializeDisplay; + +#[derive(SerializeDisplay)] +pub enum LibraryError { + MetaNotFound(String), +} +impl Display for LibraryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LibraryError::MetaNotFound(id) => write!( + f, + "Could not locate any installed version of game ID {} in the database", + id + ), + } + } +} diff --git a/src-tauri/src/error/mod.rs b/src-tauri/src/error/mod.rs new file mode 100644 index 0000000..89b74ae --- /dev/null +++ b/src-tauri/src/error/mod.rs @@ -0,0 +1,6 @@ +pub mod application_download_error; +pub mod drop_server_error; +pub mod library_error; +pub mod process_error; +pub mod remote_access_error; +pub mod setup_error; diff --git a/src-tauri/src/error/process_error.rs b/src-tauri/src/error/process_error.rs new file mode 100644 index 0000000..8afc9dc --- /dev/null +++ b/src-tauri/src/error/process_error.rs @@ -0,0 +1,31 @@ +use std::{fmt::Display, io::Error}; + +use serde_with::SerializeDisplay; + +#[derive(SerializeDisplay)] +pub enum ProcessError { + SetupRequired, + NotInstalled, + AlreadyRunning, + NotDownloaded, + InvalidID, + InvalidVersion, + IOError(Error), + InvalidPlatform, +} + +impl Display for ProcessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ProcessError::SetupRequired => "Game not set up", + ProcessError::NotInstalled => "Game not installed", + ProcessError::AlreadyRunning => "Game already running", + ProcessError::NotDownloaded => "Game not downloaded", + ProcessError::InvalidID => "Invalid Game ID", + ProcessError::InvalidVersion => "Invalid Game version", + ProcessError::IOError(error) => &error.to_string(), + ProcessError::InvalidPlatform => "This Game cannot be played on the current platform", + }; + write!(f, "{}", s) + } +} diff --git a/src-tauri/src/error/remote_access_error.rs b/src-tauri/src/error/remote_access_error.rs new file mode 100644 index 0000000..32572b2 --- /dev/null +++ b/src-tauri/src/error/remote_access_error.rs @@ -0,0 +1,69 @@ +use std::{ + error::Error, + fmt::{Display, Formatter}, + sync::Arc, +}; + +use http::StatusCode; +use serde_with::SerializeDisplay; +use url::ParseError; + +use super::drop_server_error::DropServerError; + +#[derive(Debug, Clone, SerializeDisplay)] +pub enum RemoteAccessError { + FetchError(Arc), + ParsingError(ParseError), + InvalidEndpoint, + HandshakeFailed(String), + GameNotFound, + InvalidResponse(DropServerError), + InvalidRedirect, + ManifestDownloadFailed(StatusCode, String), + OutOfSync, + Generic(String), +} + +impl Display for RemoteAccessError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RemoteAccessError::FetchError(error) => write!( + f, + "{}: {}", + error, + error + .source() + .map(|e| e.to_string()) + .or_else(|| Some("Unknown error".to_string())) + .unwrap() + ), + RemoteAccessError::ParsingError(parse_error) => { + write!(f, "{}", parse_error) + } + RemoteAccessError::InvalidEndpoint => write!(f, "invalid drop endpoint"), + RemoteAccessError::HandshakeFailed(message) => write!(f, "failed to complete handshake: {}", message), + RemoteAccessError::GameNotFound => write!(f, "could not find game on server"), + RemoteAccessError::InvalidResponse(error) => write!(f, "server returned an invalid response: {} {}", error.status_code, error.status_message), + RemoteAccessError::InvalidRedirect => write!(f, "server redirect was invalid"), + RemoteAccessError::ManifestDownloadFailed(status, response) => write!( + f, + "failed to download game manifest: {} {}", + status, response + ), + RemoteAccessError::OutOfSync => write!(f, "server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other"), + RemoteAccessError::Generic(message) => write!(f, "{}", message), + } + } +} + +impl From for RemoteAccessError { + fn from(err: reqwest::Error) -> Self { + RemoteAccessError::FetchError(Arc::new(err)) + } +} +impl From for RemoteAccessError { + fn from(err: ParseError) -> Self { + RemoteAccessError::ParsingError(err) + } +} +impl std::error::Error for RemoteAccessError {} diff --git a/src-tauri/src/error/setup_error.rs b/src-tauri/src/error/setup_error.rs new file mode 100644 index 0000000..bd76ce5 --- /dev/null +++ b/src-tauri/src/error/setup_error.rs @@ -0,0 +1,14 @@ +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone)] +pub enum SetupError { + Context, +} + +impl Display for SetupError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + SetupError::Context => write!(f, "failed to generate contexts for download"), + } + } +} diff --git a/src-tauri/src/games/commands.rs b/src-tauri/src/games/commands.rs new file mode 100644 index 0000000..8f1d652 --- /dev/null +++ b/src-tauri/src/games/commands.rs @@ -0,0 +1,53 @@ +use std::sync::Mutex; + +use tauri::AppHandle; + +use crate::{ + database::db::GameVersion, error::{library_error::LibraryError, remote_access_error::RemoteAccessError}, games::library::{get_current_meta, uninstall_game_logic}, AppState +}; + +use super::{ + library::{ + fetch_game_logic, fetch_game_verion_options_logic, fetch_library_logic, FetchGameStruct, + Game, + }, + state::{GameStatusManager, GameStatusWithTransient}, +}; + +#[tauri::command] +pub fn fetch_library(app: AppHandle) -> Result, RemoteAccessError> { + fetch_library_logic(app) +} + +#[tauri::command] +pub fn fetch_game( + game_id: String, + app: tauri::AppHandle, +) -> Result { + fetch_game_logic(game_id, app) +} + +#[tauri::command] +pub fn fetch_game_status(id: String) -> GameStatusWithTransient { + GameStatusManager::fetch_state(&id) +} + +#[tauri::command] +pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), LibraryError> { + let meta = match get_current_meta(&game_id) { + Some(data) => data, + None => return Err(LibraryError::MetaNotFound(game_id)), + }; + println!("{:?}", meta); + uninstall_game_logic(meta, &app_handle); + + Ok(()) +} + +#[tauri::command] +pub fn fetch_game_verion_options( + game_id: String, + state: tauri::State<'_, Mutex>, +) -> Result, RemoteAccessError> { + fetch_game_verion_options_logic(game_id, state) +} diff --git a/src-tauri/src/games/downloads/commands.rs b/src-tauri/src/games/downloads/commands.rs new file mode 100644 index 0000000..67b1359 --- /dev/null +++ b/src-tauri/src/games/downloads/commands.rs @@ -0,0 +1,32 @@ +use std::sync::{Arc, Mutex}; + +use crate::{ + download_manager::{ + download_manager::DownloadManagerSignal, downloadable::Downloadable, + internal_error::InternalError, + }, + AppState, +}; + +use super::download_agent::GameDownloadAgent; + +#[tauri::command] +pub fn download_game( + game_id: String, + game_version: String, + install_dir: usize, + state: tauri::State<'_, Mutex>, +) -> Result<(), InternalError> { + let sender = state.lock().unwrap().download_manager.get_sender(); + let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new( + game_id, + game_version, + install_dir, + sender, + )) as Box); + Ok(state + .lock() + .unwrap() + .download_manager + .queue_download(game_download_agent)?) +} diff --git a/src-tauri/src/games/downloads/download_agent.rs b/src-tauri/src/games/downloads/download_agent.rs new file mode 100644 index 0000000..d4fb1cd --- /dev/null +++ b/src-tauri/src/games/downloads/download_agent.rs @@ -0,0 +1,407 @@ +use crate::auth::generate_authorization_header; +use crate::database::db::{ + borrow_db_checked, set_game_status, ApplicationTransientStatus, DatabaseImpls, + GameDownloadStatus, +}; +use crate::download_manager::download_manager::{DownloadManagerSignal, DownloadStatus}; +use crate::download_manager::download_thread_control_flag::{ + DownloadThreadControl, DownloadThreadControlFlag, +}; +use crate::download_manager::downloadable::Downloadable; +use crate::download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}; +use crate::download_manager::progress_object::{ProgressHandle, ProgressObject}; +use crate::error::application_download_error::ApplicationDownloadError; +use crate::error::remote_access_error::RemoteAccessError; +use crate::games::downloads::manifest::{DropDownloadContext, DropManifest}; +use crate::games::library::{on_game_complete, push_game_update, GameUpdateEvent}; +use crate::remote::requests::make_request; +use crate::DB; +use log::{debug, error, info}; +use rayon::ThreadPoolBuilder; +use slice_deque::SliceDeque; +use std::fs::{create_dir_all, File}; +use std::path::Path; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use tauri::{AppHandle, Emitter}; +use urlencoding::encode; + +#[cfg(target_os = "linux")] +use rustix::fs::{fallocate, FallocateFlags}; + +use super::download_logic::download_game_chunk; +use super::stored_manifest::StoredManifest; + +pub struct GameDownloadAgent { + pub id: String, + pub version: String, + pub control_flag: DownloadThreadControl, + contexts: Mutex>, + completed_contexts: Mutex>, + pub manifest: Mutex>, + pub progress: Arc, + sender: Sender, + pub stored_manifest: StoredManifest, + status: Mutex, +} + +impl GameDownloadAgent { + pub fn new( + id: String, + version: String, + target_download_dir: usize, + sender: Sender, + ) -> Self { + // Don't run by default + let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop); + + let db_lock = borrow_db_checked(); + let base_dir = db_lock.applications.install_dirs[target_download_dir].clone(); + drop(db_lock); + + let base_dir_path = Path::new(&base_dir); + let data_base_dir_path = base_dir_path.join(id.clone()); + + let stored_manifest = + StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone()); + + Self { + id, + version, + control_flag, + manifest: Mutex::new(None), + contexts: Mutex::new(Vec::new()), + completed_contexts: Mutex::new(SliceDeque::new()), + progress: Arc::new(ProgressObject::new(0, 0, sender.clone())), + sender, + stored_manifest, + status: Mutex::new(DownloadStatus::Queued), + } + } + + // Blocking + pub fn setup_download(&self) -> Result<(), ApplicationDownloadError> { + self.ensure_manifest_exists()?; + + self.ensure_contexts()?; + + self.control_flag.set(DownloadThreadControlFlag::Go); + + Ok(()) + } + + // Blocking + pub fn download(&self, app_handle: &AppHandle) -> Result { + self.setup_download()?; + self.set_progress_object_params(); + let timer = Instant::now(); + push_game_update( + app_handle, + &self.metadata().id, + ( + None, + Some(ApplicationTransientStatus::Downloading { + version_name: self.version.clone(), + }), + ), + ); + let res = self + .run() + .map_err(|_| ApplicationDownloadError::DownloadError); + + debug!( + "{} took {}ms to download", + self.id, + timer.elapsed().as_millis() + ); + res + } + + pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> { + if self.manifest.lock().unwrap().is_some() { + return Ok(()); + } + + self.download_manifest() + } + + fn download_manifest(&self) -> Result<(), ApplicationDownloadError> { + let header = generate_authorization_header(); + let client = reqwest::blocking::Client::new(); + let response = make_request( + &client, + &["/api/v1/client/game/manifest"], + &[("id", &self.id), ("version", &self.version)], + |f| f.header("Authorization", header), + ) + .map_err(|e| ApplicationDownloadError::Communication(e))? + .send() + .map_err(|e| ApplicationDownloadError::Communication(e.into()))?; + + if response.status() != 200 { + return Err(ApplicationDownloadError::Communication( + RemoteAccessError::ManifestDownloadFailed( + response.status(), + response.text().unwrap(), + ), + )); + } + + let manifest_download: DropManifest = response.json().unwrap(); + + if let Ok(mut manifest) = self.manifest.lock() { + *manifest = Some(manifest_download); + return Ok(()); + } + + Err(ApplicationDownloadError::Lock) + } + + fn set_progress_object_params(&self) { + // Avoid re-setting it + if self.progress.get_max() != 0 { + return; + } + + let contexts = self.contexts.lock().unwrap(); + + let length = contexts.len(); + + let chunk_count = contexts.iter().map(|chunk| chunk.length).sum(); + + self.progress.set_max(chunk_count); + self.progress.set_size(length); + self.progress.set_time_now(); + } + + pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> { + if !self.contexts.lock().unwrap().is_empty() { + return Ok(()); + } + + self.generate_contexts()?; + Ok(()) + } + + pub fn generate_contexts(&self) -> Result<(), ApplicationDownloadError> { + let manifest = self.manifest.lock().unwrap().clone().unwrap(); + let game_id = self.id.clone(); + + let mut contexts = Vec::new(); + let base_path = Path::new(&self.stored_manifest.base_path); + create_dir_all(base_path).unwrap(); + + { + let mut completed_contexts_lock = self.completed_contexts.lock().unwrap(); + completed_contexts_lock.clear(); + completed_contexts_lock + .extend_from_slice(&self.stored_manifest.get_completed_contexts()); + } + + for (raw_path, chunk) in manifest { + let path = base_path.join(Path::new(&raw_path)); + + let container = path.parent().unwrap(); + create_dir_all(container).unwrap(); + + let file = File::create(path.clone()).unwrap(); + let mut running_offset = 0; + + for (index, length) in chunk.lengths.iter().enumerate() { + contexts.push(DropDownloadContext { + file_name: raw_path.to_string(), + version: chunk.version_name.to_string(), + offset: running_offset, + index, + game_id: game_id.to_string(), + path: path.clone(), + checksum: chunk.checksums[index].clone(), + length: *length, + permissions: chunk.permissions, + }); + running_offset += *length as u64; + } + + #[cfg(target_os = "linux")] + if running_offset > 0 { + let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset); + } + } + *self.contexts.lock().unwrap() = contexts; + + Ok(()) + } + + // TODO: Change return value on Err + pub fn run(&self) -> Result { + let max_download_threads = borrow_db_checked().settings.max_download_threads; + + debug!( + "downloading game: {} with {} threads", + self.id, max_download_threads + ); + let pool = ThreadPoolBuilder::new() + .num_threads(max_download_threads) + .build() + .unwrap(); + + let completed_indexes = Arc::new(boxcar::Vec::new()); + let completed_indexes_loop_arc = completed_indexes.clone(); + + let contexts = self.contexts.lock().unwrap(); + pool.scope(|scope| { + let client = &reqwest::blocking::Client::new(); + for (index, context) in contexts.iter().enumerate() { + let client = client.clone(); + let completed_indexes = completed_indexes_loop_arc.clone(); + + let progress = self.progress.get(index); + let progress_handle = ProgressHandle::new(progress, self.progress.clone()); + + // If we've done this one already, skip it + if self.completed_contexts.lock().unwrap().contains(&index) { + progress_handle.skip(context.length); + continue; + } + + let sender = self.sender.clone(); + + let request = match make_request( + &client, + &["/api/v1/client/chunk"], + &[ + ("id", &context.game_id), + ("version", &context.version), + ("name", &context.file_name), + ("chunk", &context.index.to_string()), + ], + |r| r.header("Authorization", generate_authorization_header()), + ) { + Ok(request) => request, + Err(e) => { + sender.send(DownloadManagerSignal::Error(ApplicationDownloadError::Communication(e))).unwrap(); + continue; + }, + }; + + scope.spawn(move |_| { + match download_game_chunk(context, &self.control_flag, progress_handle, request) + { + Ok(res) => { + if res { + completed_indexes.push(index); + } + } + Err(e) => { + error!("{}", e); + sender.send(DownloadManagerSignal::Error(e)).unwrap(); + } + } + }); + } + }); + + let newly_completed = completed_indexes.to_owned(); + + let completed_lock_len = { + let mut completed_contexts_lock = self.completed_contexts.lock().unwrap(); + for (_, item) in newly_completed.iter() { + completed_contexts_lock.push_front(*item); + } + + completed_contexts_lock.len() + }; + + // If we're not out of contexts, we're not done, so we don't fire completed + if completed_lock_len != contexts.len() { + info!( + "download agent for {} exited without completing ({}/{})", + self.id.clone(), + completed_lock_len, + contexts.len(), + ); + self.stored_manifest + .set_completed_contexts(self.completed_contexts.lock().unwrap().as_slice()); + self.stored_manifest.write(); + return Ok(false); + } + + // We've completed + self.sender + .send(DownloadManagerSignal::Completed(self.metadata())) + .unwrap(); + + Ok(true) + } +} + +impl Downloadable for GameDownloadAgent { + fn download(&self, app_handle: &AppHandle) -> Result { + *self.status.lock().unwrap() = DownloadStatus::Downloading; + self.download(app_handle) + } + + fn progress(&self) -> Arc { + self.progress.clone() + } + + fn control_flag(&self) -> DownloadThreadControl { + self.control_flag.clone() + } + + fn metadata(&self) -> DownloadableMetadata { + DownloadableMetadata { + id: self.id.clone(), + version: Some(self.version.clone()), + download_type: DownloadType::Game, + } + } + + fn on_initialised(&self, _app_handle: &tauri::AppHandle) { + *self.status.lock().unwrap() = DownloadStatus::Queued; + } + + fn on_error(&self, app_handle: &tauri::AppHandle, error: ApplicationDownloadError) { + *self.status.lock().unwrap() = DownloadStatus::Error; + app_handle + .emit("download_error", error.to_string()) + .unwrap(); + + error!("error while managing download: {}", error); + + set_game_status(app_handle, self.metadata(), |db_handle, meta| { + db_handle.applications.transient_statuses.remove(meta); + }); + } + + fn on_complete(&self, app_handle: &tauri::AppHandle) { + on_game_complete( + &self.metadata(), + self.stored_manifest.base_path.to_string_lossy().to_string(), + app_handle, + ) + .unwrap(); + } + + // TODO: fix this function. It doesn't restart the download properly, nor does it reset the state properly + fn on_incomplete(&self, app_handle: &tauri::AppHandle) { + let meta = self.metadata(); + *self.status.lock().unwrap() = DownloadStatus::Queued; + app_handle + .emit( + &format!("update_game/{}", meta.id), + GameUpdateEvent { + game_id: meta.id.clone(), + status: (Some(GameDownloadStatus::Remote {}), None), + }, + ) + .unwrap(); + } + + fn on_cancelled(&self, _app_handle: &tauri::AppHandle) {} + + fn status(&self) -> DownloadStatus { + self.status.lock().unwrap().clone() + } +} diff --git a/src-tauri/src/downloads/download_logic.rs b/src-tauri/src/games/downloads/download_logic.rs similarity index 71% rename from src-tauri/src/downloads/download_logic.rs rename to src-tauri/src/games/downloads/download_logic.rs index d064060..70e5b7e 100644 --- a/src-tauri/src/downloads/download_logic.rs +++ b/src-tauri/src/games/downloads/download_logic.rs @@ -1,17 +1,16 @@ -use crate::auth::generate_authorization_header; -use crate::db::DatabaseImpls; -use crate::download_manager::application_download_error::ApplicationDownloadError; -use crate::download_manager::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}; +use crate::download_manager::download_thread_control_flag::{ + DownloadThreadControl, DownloadThreadControlFlag, +}; use crate::download_manager::progress_object::ProgressHandle; -use crate::downloads::manifest::DropDownloadContext; -use crate::remote::RemoteAccessError; -use crate::DB; +use crate::error::application_download_error::ApplicationDownloadError; +use crate::error::remote_access_error::RemoteAccessError; +use crate::games::downloads::manifest::DropDownloadContext; use log::warn; use md5::{Context, Digest}; -use reqwest::blocking::Response; +use reqwest::blocking::{RequestBuilder, Response}; use std::fs::{set_permissions, Permissions}; -use std::io::Read; +use std::io::{ErrorKind, Read}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::{ @@ -19,7 +18,6 @@ use std::{ io::{self, BufWriter, Seek, SeekFrom, Write}, path::PathBuf, }; -use urlencoding::encode; pub struct DropWriter { hasher: Context, @@ -41,19 +39,17 @@ impl DropWriter { // Write automatically pushes to file and hasher impl Write for DropWriter { fn write(&mut self, buf: &[u8]) -> io::Result { - /* self.hasher.write_all(buf).map_err(|e| { io::Error::new( ErrorKind::Other, format!("Unable to write to hasher: {}", e), ) })?; - */ self.destination.write(buf) } fn flush(&mut self) -> io::Result<()> { - // self.hasher.flush()?; + self.hasher.flush()?; self.destination.flush() } } @@ -64,18 +60,18 @@ impl Seek for DropWriter { } } -pub struct DropDownloadPipeline { +pub struct DropDownloadPipeline<'a, R: Read, W: Write> { pub source: R, pub destination: DropWriter, - pub control_flag: DownloadThreadControl, + pub control_flag: &'a DownloadThreadControl, pub progress: ProgressHandle, pub size: usize, } -impl DropDownloadPipeline { +impl<'a> DropDownloadPipeline<'a, Response, File> { fn new( source: Response, destination: DropWriter, - control_flag: DownloadThreadControl, + control_flag: &'a DownloadThreadControl, progress: ProgressHandle, size: usize, ) -> Self { @@ -120,9 +116,10 @@ impl DropDownloadPipeline { } pub fn download_game_chunk( - ctx: DropDownloadContext, - control_flag: DownloadThreadControl, + ctx: &DropDownloadContext, + control_flag: &DownloadThreadControl, progress: ProgressHandle, + request: RequestBuilder, ) -> Result { // If we're paused if control_flag.get() == DownloadThreadControlFlag::Stop { @@ -130,32 +127,14 @@ pub fn download_game_chunk( return Ok(false); } - let base_url = DB.fetch_base_url(); - - let client = reqwest::blocking::Client::new(); - let chunk_url = base_url - .join(&format!( - "/api/v1/client/chunk?id={}&version={}&name={}&chunk={}", - // Encode the parts we don't trust - ctx.game_id, - encode(&ctx.version), - encode(&ctx.file_name), - ctx.index - )) - .unwrap(); - - let header = generate_authorization_header(); - - let response = client - .get(chunk_url) - .header("Authorization", header) + let response = request .send() .map_err(|e| ApplicationDownloadError::Communication(e.into()))?; if response.status() != 200 { - warn!("{}", response.text().unwrap()); + let err = response.json().unwrap(); return Err(ApplicationDownloadError::Communication( - RemoteAccessError::InvalidCodeError(400), + RemoteAccessError::InvalidResponse(err), )); } @@ -169,8 +148,9 @@ pub fn download_game_chunk( let content_length = response.content_length(); if content_length.is_none() { + warn!("recieved 0 length content from server"); return Err(ApplicationDownloadError::Communication( - RemoteAccessError::InvalidResponse, + RemoteAccessError::InvalidResponse(response.json().unwrap()), )); } @@ -193,19 +173,17 @@ pub fn download_game_chunk( #[cfg(unix)] { let permissions = Permissions::from_mode(ctx.permissions); - set_permissions(ctx.path, permissions).unwrap(); + set_permissions(ctx.path.clone(), permissions).unwrap(); } - /* let checksum = pipeline .finish() - .map_err(|e| GameDownloadError::IoError(e))?; + .map_err(|e| ApplicationDownloadError::IoError(e.kind()))?; let res = hex::encode(checksum.0); if res != ctx.checksum { - return Err(GameDownloadError::Checksum); + return Err(ApplicationDownloadError::Checksum); } - */ Ok(true) } diff --git a/src-tauri/src/downloads/manifest.rs b/src-tauri/src/games/downloads/manifest.rs similarity index 100% rename from src-tauri/src/downloads/manifest.rs rename to src-tauri/src/games/downloads/manifest.rs diff --git a/src-tauri/src/downloads/mod.rs b/src-tauri/src/games/downloads/mod.rs similarity index 74% rename from src-tauri/src/downloads/mod.rs rename to src-tauri/src/games/downloads/mod.rs index 8709ca6..c9b3cd4 100644 --- a/src-tauri/src/downloads/mod.rs +++ b/src-tauri/src/games/downloads/mod.rs @@ -1,5 +1,5 @@ +pub mod commands; pub mod download_agent; -pub mod download_commands; mod download_logic; mod manifest; mod stored_manifest; diff --git a/src-tauri/src/downloads/stored_manifest.rs b/src-tauri/src/games/downloads/stored_manifest.rs similarity index 93% rename from src-tauri/src/downloads/stored_manifest.rs rename to src-tauri/src/games/downloads/stored_manifest.rs index bb5e6b9..fdc232b 100644 --- a/src-tauri/src/downloads/stored_manifest.rs +++ b/src-tauri/src/games/downloads/stored_manifest.rs @@ -5,7 +5,7 @@ use std::{ sync::Mutex, }; -use log::error; +use log::{error, warn}; use serde::{Deserialize, Serialize}; use serde_binary::binary_stream::Endian; @@ -43,12 +43,10 @@ impl StoredManifest { } }; - - match serde_binary::from_vec::(s, Endian::Little) { Ok(manifest) => manifest, Err(e) => { - error!("{}", e); + warn!("{}", e); StoredManifest::new(game_id, game_version, base_path) } } @@ -72,8 +70,8 @@ impl StoredManifest { Err(e) => error!("{}", e), }; } - pub fn set_completed_contexts(&self, completed_contexts: &Vec) { - *self.completed_contexts.lock().unwrap() = completed_contexts.clone(); + pub fn set_completed_contexts(&self, completed_contexts: &[usize]) { + *self.completed_contexts.lock().unwrap() = completed_contexts.to_owned(); } pub fn get_completed_contexts(&self) -> Vec { self.completed_contexts.lock().unwrap().clone() diff --git a/src-tauri/src/games/library.rs b/src-tauri/src/games/library.rs new file mode 100644 index 0000000..b2f2fe2 --- /dev/null +++ b/src-tauri/src/games/library.rs @@ -0,0 +1,353 @@ +use std::fs::remove_dir_all; +use std::sync::Mutex; +use std::thread::spawn; + +use log::{debug, error, warn}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; +use tauri::{AppHandle, Manager}; + +use crate::database::db::{borrow_db_checked, borrow_db_mut_checked, save_db, GameVersion}; +use crate::database::db::{ApplicationTransientStatus, GameDownloadStatus}; +use crate::download_manager::download_manager::DownloadStatus; +use crate::download_manager::downloadable_metadata::DownloadableMetadata; +use crate::error::remote_access_error::RemoteAccessError; +use crate::games::state::{GameStatusManager, GameStatusWithTransient}; +use crate::remote::auth::generate_authorization_header; +use crate::remote::requests::make_request; +use crate::AppState; + +#[derive(serde::Serialize)] +pub struct FetchGameStruct { + game: Game, + status: GameStatusWithTransient, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Game { + id: String, + m_name: String, + m_short_description: String, + m_description: String, + // mDevelopers + // mPublishers + m_icon_id: String, + m_banner_id: String, + m_cover_id: String, + m_image_library: Vec, +} +#[derive(serde::Serialize, Clone)] +pub struct GameUpdateEvent { + pub game_id: String, + pub status: ( + Option, + Option, + ), +} + +#[derive(Serialize, Clone)] +pub struct QueueUpdateEventQueueData { + pub meta: DownloadableMetadata, + pub status: DownloadStatus, + pub progress: f64, + pub current: usize, + pub max: usize, +} + +#[derive(serde::Serialize, Clone)] +pub struct QueueUpdateEvent { + pub queue: Vec, +} + +#[derive(serde::Serialize, Clone)] +pub struct StatsUpdateEvent { + pub speed: usize, + pub time: usize, +} + +pub fn fetch_library_logic(app: AppHandle) -> Result, RemoteAccessError> { + let header = generate_authorization_header(); + + let client = reqwest::blocking::Client::new(); + let response = make_request(&client, &["/api/v1/client/user/library"], &[], |f| { + f.header("Authorization", header) + })? + .send()?; + + if response.status() != 200 { + let err = response.json().unwrap(); + warn!("{:?}", err); + return Err(RemoteAccessError::InvalidResponse(err)); + } + + let games: Vec = response.json()?; + + let state = app.state::>(); + let mut handle = state.lock().unwrap(); + + let mut db_handle = borrow_db_mut_checked(); + + for game in games.iter() { + handle.games.insert(game.id.clone(), game.clone()); + if !db_handle.applications.game_statuses.contains_key(&game.id) { + db_handle + .applications + .game_statuses + .insert(game.id.clone(), GameDownloadStatus::Remote {}); + } + } + + drop(handle); + + Ok(games) +} + +pub fn fetch_game_logic( + id: String, + app: tauri::AppHandle, +) -> Result { + let state = app.state::>(); + let mut state_handle = state.lock().unwrap(); + + let game = state_handle.games.get(&id); + if let Some(game) = game { + let status = GameStatusManager::fetch_state(&id); + + let data = FetchGameStruct { + game: game.clone(), + status, + }; + + return Ok(data); + } + let client = reqwest::blocking::Client::new(); + let response = make_request(&client, &["/api/v1/game/", &id], &[], |r| { + r.header("Authorization", generate_authorization_header()) + })? + .send()?; + + if response.status() == 404 { + return Err(RemoteAccessError::GameNotFound); + } + if response.status() != 200 { + let err = response.json().unwrap(); + warn!("{:?}", err); + return Err(RemoteAccessError::InvalidResponse(err)); + } + + let game: Game = response.json()?; + state_handle.games.insert(id.clone(), game.clone()); + + let mut db_handle = borrow_db_mut_checked(); + + db_handle + .applications + .game_statuses + .entry(id.clone()) + .or_insert(GameDownloadStatus::Remote {}); + drop(db_handle); + + let status = GameStatusManager::fetch_state(&id); + + let data = FetchGameStruct { + game: game.clone(), + status, + }; + + Ok(data) +} + +pub fn fetch_game_verion_options_logic( + game_id: String, + state: tauri::State<'_, Mutex>, +) -> Result, RemoteAccessError> { + let client = reqwest::blocking::Client::new(); + + let response = make_request( + &client, + &["/api/v1/client/game/versions"], + &[("id", &game_id)], + |r| r.header("Authorization", generate_authorization_header()), + )? + .send()?; + + if response.status() != 200 { + let err = response.json().unwrap(); + warn!("{:?}", err); + return Err(RemoteAccessError::InvalidResponse(err)); + } + + let data: Vec = response.json()?; + + let state_lock = state.lock().unwrap(); + let process_manager_lock = state_lock.process_manager.lock().unwrap(); + let data: Vec = data + .into_iter() + .filter(|v| process_manager_lock.valid_platform(&v.platform).unwrap()) + .collect(); + drop(process_manager_lock); + drop(state_lock); + + Ok(data) +} + +pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) { + println!("triggered uninstall for agent"); + let mut db_handle = borrow_db_mut_checked(); + db_handle + .applications + .transient_statuses + .entry(meta.clone()) + .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); + + push_game_update( + app_handle, + &meta.id, + (None, Some(ApplicationTransientStatus::Uninstalling {})), + ); + + let previous_state = db_handle.applications.game_statuses.get(&meta.id).cloned(); + if previous_state.is_none() { + warn!("uninstall job doesn't have previous state, failing silently"); + return; + } + let previous_state = previous_state.unwrap(); + if let Some((_, install_dir)) = match previous_state { + GameDownloadStatus::Installed { + version_name, + install_dir, + } => Some((version_name, install_dir)), + GameDownloadStatus::SetupRequired { + version_name, + install_dir, + } => Some((version_name, install_dir)), + _ => None, + } { + db_handle + .applications + .transient_statuses + .entry(meta.clone()) + .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); + drop(db_handle); + + let app_handle = app_handle.clone(); + spawn(move || match remove_dir_all(install_dir) { + Err(e) => { + error!("{}", e); + } + Ok(_) => { + let mut db_handle = borrow_db_mut_checked(); + db_handle.applications.transient_statuses.remove(&meta); + db_handle + .applications + .game_statuses + .entry(meta.id.clone()) + .and_modify(|e| *e = GameDownloadStatus::Remote {}); + drop(db_handle); + save_db(); + + debug!("uninstalled game id {}", &meta.id); + + push_game_update( + &app_handle, + &meta.id, + (Some(GameDownloadStatus::Remote {}), None), + ); + } + }); + } +} + +pub fn get_current_meta(game_id: &String) -> Option { + borrow_db_checked() + .applications + .installed_game_version + .get(game_id) + .cloned() +} + +pub fn on_game_complete( + meta: &DownloadableMetadata, + install_dir: String, + app_handle: &AppHandle, +) -> Result<(), RemoteAccessError> { + // Fetch game version information from remote + if meta.version.is_none() { + return Err(RemoteAccessError::GameNotFound); + } + + let header = generate_authorization_header(); + + let client = reqwest::blocking::Client::new(); + let response = make_request( + &client, + &["/api/v1/client/metadata/version"], + &[ + ("id", &meta.id), + ("version", meta.version.as_ref().unwrap()), + ], + |f| f.header("Authorization", header), + )? + .send()?; + + let data: GameVersion = response.json()?; + + let mut handle = borrow_db_mut_checked(); + handle + .applications + .game_versions + .entry(meta.id.clone()) + .or_default() + .insert(meta.version.clone().unwrap(), data.clone()); + handle + .applications + .installed_game_version + .insert(meta.id.clone(), meta.clone()); + + drop(handle); + save_db(); + + let status = if data.setup_command.is_empty() { + GameDownloadStatus::Installed { + version_name: meta.version.clone().unwrap(), + install_dir, + } + } else { + GameDownloadStatus::SetupRequired { + version_name: meta.version.clone().unwrap(), + install_dir, + } + }; + + let mut db_handle = borrow_db_mut_checked(); + db_handle + .applications + .game_statuses + .insert(meta.id.clone(), status.clone()); + drop(db_handle); + save_db(); + app_handle + .emit( + &format!("update_game/{}", meta.id), + GameUpdateEvent { + game_id: meta.id.clone(), + status: (Some(status), None), + }, + ) + .unwrap(); + + Ok(()) +} + +pub fn push_game_update(app_handle: &AppHandle, game_id: &String, status: GameStatusWithTransient) { + app_handle + .emit( + &format!("update_game/{}", game_id), + GameUpdateEvent { + game_id: game_id.clone(), + status, + }, + ) + .unwrap(); +} diff --git a/src-tauri/src/games/mod.rs b/src-tauri/src/games/mod.rs new file mode 100644 index 0000000..65c5c6b --- /dev/null +++ b/src-tauri/src/games/mod.rs @@ -0,0 +1,4 @@ +pub mod commands; +pub mod downloads; +pub mod library; +pub mod state; diff --git a/src-tauri/src/games/state.rs b/src-tauri/src/games/state.rs new file mode 100644 index 0000000..19b1769 --- /dev/null +++ b/src-tauri/src/games/state.rs @@ -0,0 +1,29 @@ +use crate::database::db::{borrow_db_checked, ApplicationTransientStatus, GameDownloadStatus}; + +pub type GameStatusWithTransient = ( + Option, + Option, +); +pub struct GameStatusManager {} + +impl GameStatusManager { + pub fn fetch_state(game_id: &String) -> GameStatusWithTransient { + let db_lock = borrow_db_checked(); + let online_state = match db_lock.applications.installed_game_version.get(game_id) { + Some(meta) => db_lock.applications.transient_statuses.get(meta).cloned(), + None => None, + }; + let offline_state = db_lock.applications.game_statuses.get(game_id).cloned(); + drop(db_lock); + + if online_state.is_some() { + return (None, online_state); + } + + if offline_state.is_some() { + return (offline_state, None); + } + + (None, None) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2875944..1e374eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,44 +1,54 @@ -mod auth; -mod db; -mod downloads; -mod library; +mod database; +mod games; +mod autostart; mod cleanup; +mod commands; +mod download_manager; +mod error; mod process; mod remote; -mod state; -mod tools; -pub mod download_manager; -#[cfg(test)] -mod tests; -use crate::db::DatabaseImpls; -use auth::{auth_initiate, generate_authorization_header, manual_recieve_handshake, recieve_handshake, retry_connect}; +use crate::database::db::DatabaseImpls; +use autostart::{get_autostart_enabled, toggle_autostart}; use cleanup::{cleanup_and_exit, quit}; -use db::{ - add_download_dir, delete_download_dir, fetch_download_dir_stats, DatabaseInterface, ApplicationStatus, - DATA_ROOT_DIR, +use commands::fetch_state; +use database::commands::{ + add_download_dir, delete_download_dir, fetch_download_dir_stats, fetch_settings, + fetch_system_data, update_settings, +}; +use database::db::{ + borrow_db_checked, borrow_db_mut_checked, DatabaseInterface, GameDownloadStatus, DATA_ROOT_DIR, +}; +use download_manager::commands::{ + cancel_game, move_download_in_queue, pause_downloads, resume_downloads, }; use download_manager::download_manager::DownloadManager; use download_manager::download_manager_builder::DownloadManagerBuilder; -use downloads::download_commands::*; +use games::commands::{ + fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, +}; +use games::downloads::commands::download_game; +use games::library::Game; use http::Response; use http::{header::*, response::Builder as ResponseBuilder}; -use library::{ - fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, Game, -}; use log::{debug, info, warn, LevelFilter}; use log4rs::append::console::ConsoleAppender; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; use log4rs::Config; -use process::compat::CompatibilityManager; -use process::process_commands::{kill_game, launch_game}; +use process::commands::{kill_game, launch_game}; use process::process_manager::ProcessManager; -use remote::{gen_drop_url, use_remote}; +use remote::auth::{self, generate_authorization_header, recieve_handshake}; +use remote::commands::{ + auth_initiate, gen_drop_url, manual_recieve_handshake, retry_connect, sign_out, use_remote, +}; +use remote::requests::make_request; use serde::{Deserialize, Serialize}; +use std::env; use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use std::{ collections::HashMap, @@ -48,6 +58,7 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Manager, RunEvent, WindowEvent}; use tauri_plugin_deep_link::DeepLinkExt; +use tauri_plugin_dialog::DialogExt; #[derive(Clone, Copy, Serialize)] pub enum AppStatus { @@ -80,29 +91,25 @@ pub struct AppState<'a> { download_manager: Arc, #[serde(skip_serializing)] process_manager: Arc>>, - #[serde(skip_serializing)] - compat_manager: Arc>, -} - -#[tauri::command] -fn fetch_state(state: tauri::State<'_, Mutex>>) -> Result { - let guard = state.lock().unwrap(); - let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?; - drop(guard); - Ok(cloned_state) } fn setup(handle: AppHandle) -> AppState<'static> { let logfile = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new("{d} | {l} | {f} - {m}{n}"))) + .encoder(Box::new(PatternEncoder::new( + "{d} | {l} | {f}:{L} - {m}{n}", + ))) .append(false) .build(DATA_ROOT_DIR.lock().unwrap().join("./drop.log")) .unwrap(); let console = ConsoleAppender::builder() - .encoder(Box::new(PatternEncoder::new("{d} | {l} | {f} - {m}{n}"))) + .encoder(Box::new(PatternEncoder::new( + "{d} | {l} | {f}:{L} - {m}{n}", + ))) .build(); + let log_level = env::var("RUST_LOG").unwrap_or(String::from("Info")); + let config = Config::builder() .appenders(vec![ Appender::builder().build("logfile", Box::new(logfile)), @@ -111,7 +118,7 @@ fn setup(handle: AppHandle) -> AppState<'static> { .build( Root::builder() .appenders(vec!["logfile", "console"]) - .build(LevelFilter::Info), + .build(LevelFilter::from_str(&log_level).expect("Invalid log level")), ) .unwrap(); @@ -120,9 +127,8 @@ fn setup(handle: AppHandle) -> AppState<'static> { let games = HashMap::new(); let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone())); let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone()))); - let compat_manager = Arc::new(Mutex::new(CompatibilityManager::new())); - debug!("Checking if database is set up"); + debug!("checking if database is set up"); let is_set_up = DB.database_is_set_up(); if !is_set_up { return AppState { @@ -131,22 +137,22 @@ fn setup(handle: AppHandle) -> AppState<'static> { games, download_manager, process_manager, - compat_manager, }; } - debug!("Database is set up"); + debug!("database is set up"); - let (app_status, user) = auth::setup().unwrap(); + // TODO: Account for possible failure + let (app_status, user) = auth::setup(); - let db_handle = DB.borrow_data().unwrap(); + let db_handle = borrow_db_checked(); let mut missing_games = Vec::new(); - let statuses = db_handle.applications.statuses.clone(); + let statuses = db_handle.applications.game_statuses.clone(); drop(db_handle); for (game_id, status) in statuses.into_iter() { match status { - db::ApplicationStatus::Remote {} => {} - db::ApplicationStatus::SetupRequired { + database::db::GameDownloadStatus::Remote {} => {} + database::db::GameDownloadStatus::SetupRequired { version_name: _, install_dir, } => { @@ -155,7 +161,7 @@ fn setup(handle: AppHandle) -> AppState<'static> { missing_games.push(game_id); } } - db::ApplicationStatus::Installed { + database::db::GameDownloadStatus::Installed { version_name: _, install_dir, } => { @@ -169,16 +175,23 @@ fn setup(handle: AppHandle) -> AppState<'static> { info!("detected games missing: {:?}", missing_games); - let mut db_handle = DB.borrow_data_mut().unwrap(); + let mut db_handle = borrow_db_mut_checked(); for game_id in missing_games { db_handle .applications - .statuses - .entry(game_id.to_string()) - .and_modify(|v| *v = ApplicationStatus::Remote {}); + .game_statuses + .entry(game_id) + .and_modify(|v| *v = GameDownloadStatus::Remote {}); } + drop(db_handle); - info!("finished setup!"); + + debug!("finished setup!"); + + // Sync autostart state + if let Err(e) = autostart::sync_autostart_on_startup(&handle) { + warn!("failed to sync autostart state: {}", e); + } AppState { status: app_status, @@ -186,7 +199,6 @@ fn setup(handle: AppHandle) -> AppState<'static> { games, download_manager, process_manager, - compat_manager, } } @@ -212,10 +224,15 @@ pub fn run() { // Core utils fetch_state, quit, + fetch_system_data, + // User utils + update_settings, + fetch_settings, // Auth auth_initiate, retry_connect, manual_recieve_handshake, + sign_out, // Remote use_remote, gen_drop_url, @@ -229,28 +246,34 @@ pub fn run() { fetch_game_verion_options, // Downloads download_game, - move_game_in_queue, - pause_game_downloads, - resume_game_downloads, + move_download_in_queue, + pause_downloads, + resume_downloads, cancel_game, uninstall_game, // Processes launch_game, - kill_game + kill_game, + toggle_autostart, + get_autostart_enabled, ]) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--minimize"]), + )) .setup(|app| { let handle = app.handle().clone(); let state = setup(handle); - info!("initialized drop client"); + debug!("initialized drop client"); app.manage(Mutex::new(state)); #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] { use tauri_plugin_deep_link::DeepLinkExt; app.deep_link().register_all()?; - info!("registered all pre-defined deep links"); + debug!("registered all pre-defined deep links"); } let handle = app.handle().clone(); @@ -270,7 +293,7 @@ pub fn run() { .unwrap(); app.deep_link().on_open_url(move |event| { - info!("handling drop:// url"); + debug!("handling drop:// url"); let binding = event.urls(); let url = binding.first().unwrap(); if url.host_str().unwrap() == "handshake" { @@ -300,36 +323,50 @@ pub fn run() { app.webview_windows().get("main").unwrap().show().unwrap(); } "quit" => { - cleanup_and_exit(app); + cleanup_and_exit(app, &app.state()); } _ => { - println!("Menu event not handled: {:?}", event.id); + println!("menu event not handled: {:?}", event.id); } }) .build(app) .expect("error while setting up tray menu"); + { + let mut db_handle = borrow_db_mut_checked(); + if let Some(original) = db_handle.prev_database.take() { + warn!( + "Database corrupted. Original file at {}", + original + .canonicalize() + .unwrap() + .to_string_lossy() + .to_string() + ); + app.dialog() + .message( + "Database corrupted. A copy has been saved at: ".to_string() + + original.to_str().unwrap(), + ) + .title("Database corrupted") + .show(|_| {}); + } + } + Ok(()) }) .register_asynchronous_uri_scheme_protocol("object", move |_ctx, request, responder| { - let base_url = DB.fetch_base_url(); - // Drop leading / let object_id = &request.uri().path()[1..]; - let object_url = base_url - .join("/api/v1/client/object/") - .unwrap() - .join(object_id) - .unwrap(); - let header = generate_authorization_header(); let client: reqwest::blocking::Client = reqwest::blocking::Client::new(); - let response = client - .get(object_url.to_string()) - .header("Authorization", header) - .send(); + let response = make_request(&client, &["/api/v1/client/object/", object_id], &[], |f| { + f.header("Authorization", header) + }) + .unwrap() + .send(); if response.is_err() { warn!( "failed to fetch object with error: {}", @@ -358,7 +395,7 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while running tauri application"); - app.run(|app_handle, event| { + app.run(|_app_handle, event| { if let RunEvent::ExitRequested { code, api, .. } = event { if code.is_none() { api.prevent_exit(); diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs deleted file mode 100644 index 9795547..0000000 --- a/src-tauri/src/library.rs +++ /dev/null @@ -1,333 +0,0 @@ -use std::sync::Mutex; - -use serde::{Deserialize, Serialize}; -use tauri::Emitter; -use tauri::{AppHandle, Manager}; -use urlencoding::encode; - -use crate::db::DatabaseImpls; -use crate::db::ApplicationVersion; -use crate::db::ApplicationStatus; -use crate::download_manager::download_manager::{DownloadManagerStatus, DownloadStatus}; -use crate::process::process_manager::Platform; -use crate::remote::RemoteAccessError; -use crate::state::{DownloadStatusManager, ApplicationStatusWithTransient}; -use crate::{auth::generate_authorization_header, AppState, DB}; - -#[derive(serde::Serialize)] -pub struct FetchGameStruct { - game: Game, - status: ApplicationStatusWithTransient, -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Game { - id: String, - m_name: String, - m_short_description: String, - m_description: String, - // mDevelopers - // mPublishers - m_icon_id: String, - m_banner_id: String, - m_cover_id: String, - m_image_library: Vec, -} -#[derive(serde::Serialize, Clone)] -pub struct GameUpdateEvent { - pub game_id: String, - pub status: ApplicationStatusWithTransient, -} - -#[derive(Serialize, Clone)] -pub struct QueueUpdateEventQueueData { - pub id: String, - pub status: DownloadStatus, - pub progress: f64, -} - -#[derive(serde::Serialize, Clone)] -pub struct QueueUpdateEvent { - pub queue: Vec, - pub status: DownloadManagerStatus, -} - -#[derive(serde::Serialize, Clone)] -pub struct StatsUpdateEvent { - pub speed: usize, - pub time: usize, -} - -// Game version with some fields missing and size information -#[derive(serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GameVersionOption { - version_index: usize, - version_name: String, - platform: Platform, - setup_command: String, - launch_command: String, - delta: bool, - umu_id_override: Option, - // total_size: usize, -} - -fn fetch_library_logic(app: AppHandle) -> Result, RemoteAccessError> { - let base_url = DB.fetch_base_url(); - let library_url = base_url.join("/api/v1/client/user/library")?; - - let header = generate_authorization_header(); - - let client = reqwest::blocking::Client::new(); - let response = client - .get(library_url.to_string()) - .header("Authorization", header) - .send()?; - - if response.status() != 200 { - return Err(response.status().as_u16().into()); - } - - let games: Vec = response.json::>()?; - - let state = app.state::>(); - let mut handle = state.lock().unwrap(); - - let mut db_handle = DB.borrow_data_mut().unwrap(); - - for game in games.iter() { - handle.games.insert(game.id.clone(), game.clone()); - if !db_handle.applications.statuses.contains_key(&game.id) { - db_handle - .applications - .statuses - .insert(game.id.clone(), ApplicationStatus::Remote {}); - } - } - - drop(handle); - - Ok(games) -} - -#[tauri::command] -pub fn fetch_library(app: AppHandle) -> Result, String> { - fetch_library_logic(app).map_err(|e| e.to_string()) -} - -fn fetch_game_logic( - id: String, - app: tauri::AppHandle, -) -> Result { - let state = app.state::>(); - let mut state_handle = state.lock().unwrap(); - - let game = state_handle.games.get(&id); - if let Some(game) = game { - let status = DownloadStatusManager::fetch_state(&id); - - let data = FetchGameStruct { - game: game.clone(), - status, - }; - - return Ok(data); - } - - let base_url = DB.fetch_base_url(); - - let endpoint = base_url.join(&format!("/api/v1/game/{}", id))?; - let header = generate_authorization_header(); - - let client = reqwest::blocking::Client::new(); - let response = client - .get(endpoint.to_string()) - .header("Authorization", header) - .send()?; - - if response.status() == 404 { - return Err(RemoteAccessError::GameNotFound); - } - if response.status() != 200 { - return Err(RemoteAccessError::InvalidCodeError( - response.status().into(), - )); - } - - let game = response.json::()?; - state_handle.games.insert(id.clone(), game.clone()); - - let mut db_handle = DB.borrow_data_mut().unwrap(); - - db_handle - .applications - .statuses - .entry(id.clone()) - .or_insert(ApplicationStatus::Remote {}); - drop(db_handle); - - let status = DownloadStatusManager::fetch_state(&id); - - let data = FetchGameStruct { - game: game.clone(), - status, - }; - - Ok(data) -} - -#[tauri::command] -pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result { - let result = fetch_game_logic(id, app); - - if result.is_err() { - return Err(result.err().unwrap().to_string()); - } - - Ok(result.unwrap()) -} - -#[tauri::command] -pub fn fetch_game_status(id: String) -> Result { - let status = DownloadStatusManager::fetch_state(&id); - - Ok(status) -} - -fn fetch_game_verion_options_logic<'a>( - game_id: String, - state: tauri::State<'_, Mutex>, -) -> Result, RemoteAccessError> { - let base_url = DB.fetch_base_url(); - - let endpoint = - base_url.join(format!("/api/v1/client/metadata/versions?id={}", game_id).as_str())?; - let header = generate_authorization_header(); - - let client = reqwest::blocking::Client::new(); - let response = client - .get(endpoint.to_string()) - .header("Authorization", header) - .send()?; - - if response.status() != 200 { - return Err(RemoteAccessError::InvalidCodeError( - response.status().into(), - )); - } - - let data = response.json::>()?; - - let state_lock = state.lock().unwrap(); - let process_manager_lock = state_lock.process_manager.lock().unwrap(); - let data = data - .into_iter() - .filter(|v| process_manager_lock.valid_platform(&v.platform).unwrap()) - .collect::>(); - drop(process_manager_lock); - drop(state_lock); - - Ok(data) -} - -#[tauri::command] -pub fn fetch_game_verion_options<'a>( - game_id: String, - state: tauri::State<'_, Mutex>, -) -> Result, String> { - fetch_game_verion_options_logic(game_id, state).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub fn uninstall_game( - game_id: String, - state: tauri::State<'_, Mutex>, -) -> Result<(), String> { - let state_lock = state.lock().unwrap(); - state_lock.download_manager.uninstall_application(game_id); - drop(state_lock); - - Ok(()) -} - -pub fn push_application_update(app_handle: &AppHandle, id: String, status: ApplicationStatusWithTransient) { - app_handle - .emit( - &format!("update_game/{}", id), - GameUpdateEvent { - game_id: id, - status, - }, - ) - .unwrap(); -} - -pub fn on_game_complete( - game_id: String, - version_name: String, - install_dir: String, - app_handle: &AppHandle, -) -> Result<(), RemoteAccessError> { - // Fetch game version information from remote - let base_url = DB.fetch_base_url(); - - let endpoint = base_url.join( - format!( - "/api/v1/client/metadata/version?id={}&version={}", - game_id, - encode(&version_name) - ) - .as_str(), - )?; - let header = generate_authorization_header(); - - let client = reqwest::blocking::Client::new(); - let response = client - .get(endpoint.to_string()) - .header("Authorization", header) - .send()?; - - let data = response.json::()?; - - let mut handle = DB.borrow_data_mut().unwrap(); - handle - .applications - .versions - .entry(game_id.clone()) - .or_default() - .insert(version_name.clone(), data.clone()); - drop(handle); - DB.save().unwrap(); - - let status = if data.setup_command.is_empty() { - ApplicationStatus::Installed { - version_name, - install_dir, - } - } else { - ApplicationStatus::SetupRequired { - version_name, - install_dir, - } - }; - - let mut db_handle = DB.borrow_data_mut().unwrap(); - db_handle - .applications - .statuses - .insert(game_id.clone(), status.clone()); - drop(db_handle); - DB.save().unwrap(); - app_handle - .emit( - &format!("update_game/{}", game_id), - GameUpdateEvent { - game_id, - status: (Some(status), None), - }, - ) - .unwrap(); - - Ok(()) -} diff --git a/src-tauri/src/process/process_commands.rs b/src-tauri/src/process/commands.rs similarity index 51% rename from src-tauri/src/process/process_commands.rs rename to src-tauri/src/process/commands.rs index 74d7dfd..8a213a4 100644 --- a/src-tauri/src/process/process_commands.rs +++ b/src-tauri/src/process/commands.rs @@ -1,16 +1,25 @@ use std::sync::Mutex; -use crate::AppState; +use crate::{error::process_error::ProcessError, AppState}; #[tauri::command] pub fn launch_game( - game_id: String, + id: String, state: tauri::State<'_, Mutex>, -) -> Result<(), String> { +) -> Result<(), ProcessError> { let state_lock = state.lock().unwrap(); let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); - process_manager_lock.launch_process(game_id)?; + //let meta = DownloadableMetadata { + // id, + // version: Some(version), + // download_type: DownloadType::Game, + //}; + + match process_manager_lock.launch_process(id) { + Ok(_) => {} + Err(e) => return Err(e), + }; drop(process_manager_lock); drop(state_lock); @@ -22,8 +31,10 @@ pub fn launch_game( pub fn kill_game( game_id: String, state: tauri::State<'_, Mutex>, -) -> Result<(), String> { +) -> Result<(), ProcessError> { let state_lock = state.lock().unwrap(); let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); - process_manager_lock.kill_game(game_id).map_err(|x| x.to_string()) -} \ No newline at end of file + process_manager_lock + .kill_game(game_id) + .map_err(ProcessError::IOError) +} diff --git a/src-tauri/src/process/compat.rs b/src-tauri/src/process/compat.rs index 65e8ece..2b4f307 100644 --- a/src-tauri/src/process/compat.rs +++ b/src-tauri/src/process/compat.rs @@ -1,51 +1,13 @@ -use std::{ - fs::create_dir_all, - path::PathBuf, - sync::atomic::{AtomicBool, Ordering}, -}; +// Since this code isn't being used, we can either: +// 1. Delete the entire file if compatibility features are not planned +// 2. Or add a TODO comment if planning to implement later -use crate::db::DATA_ROOT_DIR; - -pub struct CompatibilityManager { - compat_tools_path: PathBuf, - prefixes_path: PathBuf, - created_paths: AtomicBool, -} +// Option 1: Delete the file +// Delete src-tauri/src/process/compat.rs +// Option 2: Add TODO comment /* -This gets built into both the Windows & Linux client, but -we only need it in the Linux client. Therefore, it should -do nothing but take a little bit of memory if we're on -Windows. +TODO: Compatibility layer for running Windows games on Linux +This module is currently unused but reserved for future implementation +of Windows game compatibility features on Linux. */ -impl CompatibilityManager { - pub fn new() -> Self { - let root_dir_lock = DATA_ROOT_DIR.lock().unwrap(); - let compat_tools_path = root_dir_lock.join("compatibility_tools"); - let prefixes_path = root_dir_lock.join("prefixes"); - drop(root_dir_lock); - - Self { - compat_tools_path, - prefixes_path, - created_paths: AtomicBool::new(false), - } - } - - fn ensure_paths_exist(&self) -> Result<(), String> { - if self.created_paths.fetch_and(true, Ordering::Relaxed) { - return Ok(()); - } - if !self.compat_tools_path.exists() { - create_dir_all(self.compat_tools_path.clone()).map_err(|e| e.to_string())?; - } - if !self.prefixes_path.exists() { - create_dir_all(self.prefixes_path.clone()).map_err(|e| e.to_string())?; - } - self.created_paths.store(true, Ordering::Relaxed); - - Ok(()) - } - - -} diff --git a/src-tauri/src/process/mod.rs b/src-tauri/src/process/mod.rs index 85692c9..6a1aed5 100644 --- a/src-tauri/src/process/mod.rs +++ b/src-tauri/src/process/mod.rs @@ -1,3 +1,3 @@ +pub mod commands; pub mod compat; -pub mod process_commands; pub mod process_manager; diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index b6da0f0..4d8ae12 100644 --- a/src-tauri/src/process/process_manager.rs +++ b/src-tauri/src/process/process_manager.rs @@ -1,23 +1,26 @@ use std::{ collections::HashMap, fs::{File, OpenOptions}, - io, + io::{self, Error}, path::{Path, PathBuf}, process::{Child, Command, ExitStatus}, sync::{Arc, Mutex}, thread::spawn, }; -use log::{info, warn}; +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use shared_child::SharedChild; use tauri::{AppHandle, Manager}; use umu_wrapper_lib::command_builder::UmuCommandBuilder; use crate::{ - db::{ApplicationStatus, ApplicationTransientStatus, DATA_ROOT_DIR}, - library::push_application_update, - state::DownloadStatusManager, + database::db::{ + borrow_db_mut_checked, ApplicationTransientStatus, GameDownloadStatus, GameVersion, DATA_ROOT_DIR + }, + download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}, + error::process_error::ProcessError, + games::{library::push_game_update, state::GameStatusManager}, AppState, DB, }; @@ -63,13 +66,8 @@ impl ProcessManager<'_> { } } - // There's no easy way to distinguish between an executable name with - // spaces and it's arguments. - // I think if we just join the install_dir to whatever the user provides us, we'll be alright - // In future, we should have a separate field for executable name and it's arguments - fn process_command(&self, install_dir: &String, raw_command: String) -> (PathBuf, Vec) { - // let command_components = raw_command.split(" ").collect::>(); - let root = raw_command; + fn process_command(&self, install_dir: &String, command: Vec) -> (PathBuf, Vec) { + let root = &command[0]; let install_dir = Path::new(install_dir); let absolute_exe = install_dir.join(root); @@ -83,17 +81,17 @@ impl ProcessManager<'_> { (absolute_exe, Vec::new()) } pub fn kill_game(&mut self, game_id: String) -> Result<(), io::Error> { - return match self.processes.get(&game_id) { + match self.processes.get(&game_id) { Some(child) => { child.kill()?; child.wait()?; Ok(()) - }, + } None => Err(io::Error::new( io::ErrorKind::NotFound, "Game ID not running", )), - }; + } } fn on_process_finish(&mut self, game_id: String, result: Result) { @@ -102,25 +100,31 @@ impl ProcessManager<'_> { return; } - info!("process for {} exited with {:?}", game_id, result); + debug!("process for {:?} exited with {:?}", &game_id, result); self.processes.remove(&game_id); - let mut db_handle = DB.borrow_data_mut().unwrap(); - db_handle.applications.transient_statuses.remove(&game_id); + let mut db_handle = borrow_db_mut_checked(); + let meta = db_handle + .applications + .installed_game_version + .get(&game_id) + .cloned() + .unwrap(); + db_handle.applications.transient_statuses.remove(&meta); - let current_state = db_handle.applications.statuses.get(&game_id).cloned(); + let current_state = db_handle.applications.game_statuses.get(&game_id).cloned(); if let Some(saved_state) = current_state { - if let ApplicationStatus::SetupRequired { + if let GameDownloadStatus::SetupRequired { version_name, install_dir, } = saved_state { if let Ok(exit_code) = result { if exit_code.success() { - db_handle.applications.statuses.insert( + db_handle.applications.game_statuses.insert( game_id.clone(), - ApplicationStatus::Installed { + GameDownloadStatus::Installed { version_name: version_name.to_string(), install_dir: install_dir.to_string(), }, @@ -131,9 +135,9 @@ impl ProcessManager<'_> { } drop(db_handle); - let status = DownloadStatusManager::fetch_state(&game_id); + let status = GameStatusManager::fetch_state(&game_id); - push_application_update(&self.app_handle, game_id.clone(), status); + push_game_update(&self.app_handle, &game_id, status); // TODO better management } @@ -145,57 +149,86 @@ impl ProcessManager<'_> { .contains_key(&(current.clone(), platform.clone()))) } - pub fn launch_process(&mut self, game_id: String) -> Result<(), String> { + pub fn launch_process(&mut self, game_id: String) -> Result<(), ProcessError> { if self.processes.contains_key(&game_id) { - return Err("Game or setup is already running.".to_owned()); + return Err(ProcessError::AlreadyRunning); } - let mut db_lock = DB.borrow_data_mut().unwrap(); - let game_status = db_lock + let version = match DB + .borrow_data() + .unwrap() .applications - .statuses + .game_statuses .get(&game_id) - .ok_or("Game not installed")?; - - let status_metadata: Option<(&String, &String)> = match game_status { - ApplicationStatus::Installed { - version_name, - install_dir, - } => Some((version_name, install_dir)), - ApplicationStatus::SetupRequired { - version_name, - install_dir, - } => Some((version_name, install_dir)), - _ => None, + .cloned() + { + Some(GameDownloadStatus::Installed { version_name, .. }) => version_name, + Some(GameDownloadStatus::SetupRequired { .. }) => { + return Err(ProcessError::SetupRequired) + } + _ => return Err(ProcessError::NotInstalled), + }; + let meta = DownloadableMetadata { + id: game_id.clone(), + version: Some(version.clone()), + download_type: DownloadType::Game, }; - if status_metadata.is_none() { - return Err("Game has not been downloaded.".to_owned()); - } + let mut db_lock = borrow_db_mut_checked(); + debug!( + "Launching process {:?} with games {:?}", + &game_id, db_lock.applications.game_versions + ); + + let game_status = db_lock + .applications + .game_statuses + .get(&game_id) + .ok_or(ProcessError::NotInstalled)?; + + let (version_name, install_dir) = match game_status { + GameDownloadStatus::Installed { + version_name, + install_dir, + } => (version_name, install_dir), + GameDownloadStatus::SetupRequired { + version_name, + install_dir, + } => (version_name, install_dir), + _ => return Err(ProcessError::NotDownloaded), + }; - let (version_name, install_dir) = status_metadata.unwrap(); let game_version = db_lock .applications - .versions + .game_versions .get(&game_id) - .ok_or("Invalid game ID".to_owned())? + .ok_or(ProcessError::InvalidID)? .get(version_name) - .ok_or("Invalid version name".to_owned())?; + .ok_or(ProcessError::InvalidVersion)?; - let raw_command: String = match game_status { - ApplicationStatus::Installed { + let mut command: Vec = Vec::new(); + + match game_status { + GameDownloadStatus::Installed { version_name: _, install_dir: _, - } => game_version.launch_command.clone(), - ApplicationStatus::SetupRequired { + } => { + command.extend([game_version.launch_command.clone()]); + command.extend(game_version.launch_args.clone()); + }, + GameDownloadStatus::SetupRequired { version_name: _, install_dir: _, - } => game_version.setup_command.clone(), + } => { + command.extend([game_version.setup_command.clone()]); + command.extend(game_version.setup_args.clone()); + }, _ => panic!("unreachable code"), }; + info!("Command: {:?}", &command); - let (command, args) = self.process_command(install_dir, raw_command); + let (command, args) = self.process_command(install_dir, command); let target_current_dir = command.parent().unwrap().to_str().unwrap(); @@ -211,11 +244,13 @@ impl ProcessManager<'_> { .truncate(true) .read(true) .create(true) - .open( - self.log_output_dir - .join(format!("{}-{}.log", game_id, current_time.timestamp())), - ) - .map_err(|v| v.to_string())?; + .open(self.log_output_dir.join(format!( + "{}-{}-{}.log", + &game_id, + &version, + current_time.timestamp() + ))) + .map_err(ProcessError::IOError)?; let error_file = OpenOptions::new() .write(true) @@ -223,11 +258,12 @@ impl ProcessManager<'_> { .read(true) .create(true) .open(self.log_output_dir.join(format!( - "{}-{}-error.log", - game_id, + "{}-{}-{}-error.log", + &game_id, + &version, current_time.timestamp() ))) - .map_err(|v| v.to_string())?; + .map_err(ProcessError::IOError)?; let current_platform = self.current_platform.clone(); let target_platform = game_version.platform.clone(); @@ -235,36 +271,36 @@ impl ProcessManager<'_> { let game_launcher = self .game_launchers .get(&(current_platform, target_platform)) - .ok_or("Invalid version for this platform.") - .map_err(|e| e.to_string())?; + .ok_or(ProcessError::InvalidPlatform)?; - let launch_process = game_launcher.launch_process( - &game_id, - version_name, - command.to_str().unwrap().to_owned(), - args, - &target_current_dir.to_string(), - log_file, - error_file, - )?; + let launch_process = game_launcher + .launch_process( + &meta, + command.to_string_lossy().to_string(), + game_version, + target_current_dir, + log_file, + error_file, + ) + .map_err(ProcessError::IOError)?; let launch_process_handle = - Arc::new(SharedChild::new(launch_process).map_err(|e| e.to_string())?); + Arc::new(SharedChild::new(launch_process).map_err(ProcessError::IOError)?); db_lock .applications .transient_statuses - .insert(game_id.clone(), ApplicationTransientStatus::Running {}); + .insert(meta.clone(), ApplicationTransientStatus::Running {}); - push_application_update( + push_game_update( &self.app_handle, - game_id.clone(), + &meta.id, (None, Some(ApplicationTransientStatus::Running {})), ); let wait_thread_handle = launch_process_handle.clone(); let wait_thread_apphandle = self.app_handle.clone(); - let wait_thread_game_id = game_id.clone(); + let wait_thread_game_id = meta.clone(); spawn(move || { let result: Result = launch_process_handle.wait(); @@ -273,7 +309,7 @@ impl ProcessManager<'_> { let app_state_handle = app_state.lock().unwrap(); let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap(); - process_manager_handle.on_process_finish(wait_thread_game_id, result); + process_manager_handle.on_process_finish(wait_thread_game_id.id, result); // As everything goes out of scope, they should get dropped // But just to explicit about it @@ -281,10 +317,7 @@ impl ProcessManager<'_> { drop(app_state_handle); }); - self.processes.insert(game_id, wait_thread_handle); - - info!("finished spawning process"); - + self.processes.insert(meta.id, wait_thread_handle); Ok(()) } } @@ -298,35 +331,32 @@ pub enum Platform { pub trait ProcessHandler: Send + 'static { fn launch_process( &self, - game_id: &String, - version_name: &String, - command: String, - args: Vec, - current_dir: &String, + meta: &DownloadableMetadata, + launch_command: String, + game_version: &GameVersion, + current_dir: &str, log_file: File, error_file: File, - ) -> Result; + ) -> Result; } struct NativeGameLauncher; impl ProcessHandler for NativeGameLauncher { fn launch_process( &self, - game_id: &String, - version_name: &String, - command: String, - args: Vec, - current_dir: &String, + _meta: &DownloadableMetadata, + launch_command: String, + game_version: &GameVersion, + current_dir: &str, log_file: File, error_file: File, - ) -> Result { - Command::new(command) + ) -> Result { + Command::new(PathBuf::from(launch_command)) .current_dir(current_dir) .stdout(log_file) .stderr(error_file) - .args(args) + .args(game_version.launch_args.clone()) .spawn() - .map_err(|v| v.to_string()) } } @@ -335,19 +365,23 @@ struct UMULauncher; impl ProcessHandler for UMULauncher { fn launch_process( &self, - game_id: &String, - version_name: &String, - command: String, - args: Vec, - current_dir: &String, - log_file: File, - error_file: File, - ) -> Result { - UmuCommandBuilder::new(UMU_LAUNCHER_EXECUTABLE, command) - .game_id(game_id.into()) - .launch_args(args) + _meta: &DownloadableMetadata, + launch_command: String, + game_version: &GameVersion, + _current_dir: &str, + _log_file: File, + _error_file: File, + ) -> Result { + println!("Game override: .{:?}.", &game_version.umu_id_override); + let game_id = match &game_version.umu_id_override { + Some(game_override) => game_override.is_empty().then_some(game_version.game_id.clone()).unwrap_or(game_override.clone()) , + None => game_version.game_id.clone() + }; + info!("Game ID: {}", game_id); + UmuCommandBuilder::new(UMU_LAUNCHER_EXECUTABLE, launch_command) + .game_id(game_id) + .launch_args(game_version.launch_args.clone()) .build() .spawn() - .map_err(|x| x.to_string()) } } diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs deleted file mode 100644 index 1559155..0000000 --- a/src-tauri/src/remote.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::{ - error::Error, - fmt::{Display, Formatter}, - sync::{Arc, Mutex}, -}; - -use http::StatusCode; -use log::{info, warn}; -use serde::Deserialize; -use url::{ParseError, Url}; - -use crate::{AppState, AppStatus, DB}; - -#[derive(Debug, Clone)] -pub enum RemoteAccessError { - FetchError(Arc), - ParsingError(ParseError), - InvalidCodeError(u16), - InvalidEndpoint, - HandshakeFailed(String), - GameNotFound, - InvalidResponse, - InvalidRedirect, - ManifestDownloadFailed(StatusCode, String), - OutOfSync, -} - -impl Display for RemoteAccessError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - RemoteAccessError::FetchError(error) => write!( - f, - "{}: {}", - error, - error - .source() - .map(|e| e.to_string()) - .or_else(|| Some("Unknown error".to_string())) - .unwrap() - ), - RemoteAccessError::ParsingError(parse_error) => { - write!(f, "{}", parse_error) - } - RemoteAccessError::InvalidCodeError(error) => write!(f, "Invalid HTTP code {}", error), - RemoteAccessError::InvalidEndpoint => write!(f, "Invalid drop endpoint"), - RemoteAccessError::HandshakeFailed(message) => write!(f, "Failed to complete handshake: {}", message), - RemoteAccessError::GameNotFound => write!(f, "Could not find game on server"), - RemoteAccessError::InvalidResponse => write!(f, "Server returned an invalid response"), - RemoteAccessError::InvalidRedirect => write!(f, "Server redirect was invalid"), - RemoteAccessError::ManifestDownloadFailed(status, response) => write!( - f, - "Failed to download game manifest: {} {}", - status, response - ), - RemoteAccessError::OutOfSync => write!(f, "Server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other."), - } - } -} - -impl From for RemoteAccessError { - fn from(err: reqwest::Error) -> Self { - RemoteAccessError::FetchError(Arc::new(err)) - } -} -impl From for RemoteAccessError { - fn from(err: ParseError) -> Self { - RemoteAccessError::ParsingError(err) - } -} -impl From for RemoteAccessError { - fn from(err: u16) -> Self { - RemoteAccessError::InvalidCodeError(err) - } -} - -impl std::error::Error for RemoteAccessError {} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DropServerError { - pub status_code: usize, - pub status_message: String, - pub message: String, - pub url: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DropHealthcheck { - app_name: String, -} - -async fn use_remote_logic<'a>( - url: String, - state: tauri::State<'_, Mutex>>, -) -> Result<(), RemoteAccessError> { - info!("connecting to url {}", url); - let base_url = Url::parse(&url)?; - - // Test Drop url - let test_endpoint = base_url.join("/api/v1")?; - let response = reqwest::get(test_endpoint.to_string()).await?; - - let result = response.json::().await?; - - if result.app_name != "Drop" { - warn!("user entered drop endpoint that connected, but wasn't identified as Drop"); - return Err(RemoteAccessError::InvalidEndpoint); - } - - let mut app_state = state.lock().unwrap(); - app_state.status = AppStatus::SignedOut; - drop(app_state); - - let mut db_state = DB.borrow_data_mut().unwrap(); - db_state.base_url = base_url.to_string(); - drop(db_state); - - DB.save().unwrap(); - - Ok(()) -} - -#[tauri::command] -pub async fn use_remote<'a>( - url: String, - state: tauri::State<'_, Mutex>>, -) -> Result<(), String> { - let result = use_remote_logic(url, state).await; - - if result.is_err() { - return Err(result.err().unwrap().to_string()); - } - - Ok(()) -} - -#[tauri::command] -pub fn gen_drop_url(path: String) -> Result { - let base_url = { - let handle = DB.borrow_data().unwrap(); - - if handle.base_url.is_empty() { - return Ok("".to_string()); - }; - - Url::parse(&handle.base_url).unwrap() - }; - - let url = base_url.join(&path).unwrap(); - - Ok(url.to_string()) -} diff --git a/src-tauri/src/auth.rs b/src-tauri/src/remote/auth.rs similarity index 62% rename from src-tauri/src/auth.rs rename to src-tauri/src/remote/auth.rs index 24c8b57..d2594de 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/remote/auth.rs @@ -1,18 +1,22 @@ use std::{env, sync::Mutex}; use chrono::Utc; -use log::{info, warn}; +use log::{debug, error, warn}; use openssl::{ec::EcKey, hash::MessageDigest, pkey::PKey, sign::Signer}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager}; use url::Url; use crate::{ - db::{DatabaseAuth, DatabaseImpls}, - remote::{DropServerError, RemoteAccessError}, + database::db::{ + borrow_db_checked, borrow_db_mut_checked, save_db, DatabaseAuth, DatabaseImpls, + }, + error::{drop_server_error::DropServerError, remote_access_error::RemoteAccessError}, AppState, AppStatus, User, DB, }; +use super::requests::make_request; + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InitiateRequestBody { @@ -35,6 +39,7 @@ struct HandshakeResponse { id: String, } +// TODO: Change return value on Err pub fn sign_nonce(private_key: String, nonce: String) -> Result { let client_private_key = EcKey::private_key_from_pem(private_key.as_bytes()).unwrap(); let pkey_private_key = PKey::from_ec_key(client_private_key).unwrap(); @@ -50,7 +55,7 @@ pub fn sign_nonce(private_key: String, nonce: String) -> Result { pub fn generate_authorization_header() -> String { let certs = { - let db = DB.borrow_data().unwrap(); + let db = borrow_db_checked(); db.auth.clone().unwrap() }; @@ -64,40 +69,38 @@ pub fn generate_authorization_header() -> String { pub fn fetch_user() -> Result { let base_url = DB.fetch_base_url(); - let endpoint = base_url.join("/api/v1/client/user")?; let header = generate_authorization_header(); let client = reqwest::blocking::Client::new(); - let response = client - .get(endpoint.to_string()) - .header("Authorization", header) - .send()?; - + let response = make_request(&client, &["/api/v1/client/user"], &[], |f| { + f.header("Authorization", header) + })? + .send()?; if response.status() != 200 { - let data = response.json::()?; - info!("Could not fetch user: {}", data.status_message); + let err: DropServerError = response.json()?; + warn!("{:?}", err); - if data.status_message == "Nonce expired" { + if err.status_message == "Nonce expired" { return Err(RemoteAccessError::OutOfSync); } - return Err(RemoteAccessError::InvalidCodeError(0)); + return Err(RemoteAccessError::InvalidResponse(err)); } - let user = response.json::()?; - - Ok(user) + response.json::().map_err(|e| e.into()) } fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAccessError> { let path_chunks: Vec<&str> = path.split("/").collect(); if path_chunks.len() != 3 { app.emit("auth/failed", ()).unwrap(); - return Err(RemoteAccessError::InvalidResponse); + return Err(RemoteAccessError::HandshakeFailed( + "failed to parse token".to_string(), + )); } let base_url = { - let handle = DB.borrow_data().unwrap(); + let handle = borrow_db_checked(); Url::parse(handle.base_url.as_str())? }; @@ -111,18 +114,18 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc let endpoint = base_url.join("/api/v1/client/auth/handshake")?; let client = reqwest::blocking::Client::new(); let response = client.post(endpoint).json(&body).send()?; - info!("{}", response.status().as_u16()); - let response_struct = response.json::()?; + debug!("handshake responsded with {}", response.status().as_u16()); + let response_struct: HandshakeResponse = response.json()?; { - let mut handle = DB.borrow_data_mut().unwrap(); + let mut handle = borrow_db_mut_checked(); handle.auth = Some(DatabaseAuth { private: response_struct.private, cert: response_struct.certificate, client_id: response_struct.id, }); drop(handle); - DB.save().unwrap(); + save_db(); } { @@ -135,12 +138,6 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc Ok(()) } -#[tauri::command] -pub fn manual_recieve_handshake(app: AppHandle, token: String) -> Result<(), String> { - recieve_handshake(app, format!("handshake/{}", token)); - Ok(()) -} - pub fn recieve_handshake(app: AppHandle, path: String) { // Tell the app we're processing app.emit("auth/processing", ()).unwrap(); @@ -155,9 +152,9 @@ pub fn recieve_handshake(app: AppHandle, path: String) { app.emit("auth/finished", ()).unwrap(); } -fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { +pub fn auth_initiate_logic() -> Result<(), RemoteAccessError> { let base_url = { - let db_lock = DB.borrow_data().unwrap(); + let db_lock = borrow_db_checked(); Url::parse(&db_lock.base_url.clone())? }; @@ -171,8 +168,8 @@ fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { let response = client.post(endpoint.to_string()).json(&body).send()?; if response.status() != 200 { - let data = response.json::()?; - info!("Could not start handshake: {}", data.status_message); + let data: DropServerError = response.json()?; + error!("could not start handshake: {}", data.status_message); return Err(RemoteAccessError::HandshakeFailed(data.status_message)); } @@ -180,53 +177,25 @@ fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { let redir_url = response.text()?; let complete_redir_url = base_url.join(&redir_url)?; - info!("opening web browser to continue authentication"); + debug!("opening web browser to continue authentication"); webbrowser::open(complete_redir_url.as_ref()).unwrap(); Ok(()) } -#[tauri::command] -pub fn auth_initiate<'a>() -> Result<(), String> { - let result = auth_initiate_wrapper(); - if result.is_err() { - return Err(result.err().unwrap().to_string()); - } - - Ok(()) -} - -#[tauri::command] -pub fn retry_connect(state: tauri::State<'_, Mutex>) -> Result<(), ()> { - let (app_status, user) = setup()?; - - let mut guard = state.lock().unwrap(); - guard.status = app_status; - guard.user = user; - drop(guard); - - Ok(()) -} - -pub fn setup() -> Result<(AppStatus, Option), ()> { - let data = DB.borrow_data().unwrap(); +pub fn setup() -> (AppStatus, Option) { + let data = borrow_db_checked(); let auth = data.auth.clone(); drop(data); if auth.is_some() { - let user_result = fetch_user(); - if user_result.is_err() { - let error = user_result.err().unwrap(); - warn!("auth setup failed with: {}", error); - match error { - RemoteAccessError::FetchError(_) => { - return Ok((AppStatus::ServerUnavailable, None)) - } - _ => return Ok((AppStatus::SignedInNeedsReauth, None)), - } - } - return Ok((AppStatus::SignedIn, Some(user_result.unwrap()))); + let user_result = match fetch_user() { + Ok(data) => data, + Err(RemoteAccessError::FetchError(_)) => return (AppStatus::ServerUnavailable, None), + Err(_) => return (AppStatus::SignedInNeedsReauth, None), + }; + return (AppStatus::SignedIn, Some(user_result)); } - Ok((AppStatus::SignedOut, None)) + (AppStatus::SignedOut, None) } diff --git a/src-tauri/src/remote/commands.rs b/src-tauri/src/remote/commands.rs new file mode 100644 index 0000000..82781c8 --- /dev/null +++ b/src-tauri/src/remote/commands.rs @@ -0,0 +1,78 @@ +use std::sync::Mutex; + +use tauri::{AppHandle, Emitter, Manager}; +use url::Url; + +use crate::{ + database::db::{borrow_db_checked, borrow_db_mut_checked, save_db}, + error::remote_access_error::RemoteAccessError, + AppState, AppStatus, +}; + +use super::{ + auth::{auth_initiate_logic, recieve_handshake, setup}, + remote::use_remote_logic, +}; + +#[tauri::command] +pub fn use_remote( + url: String, + state: tauri::State<'_, Mutex>>, +) -> Result<(), RemoteAccessError> { + use_remote_logic(url, state) +} + +#[tauri::command] +pub fn gen_drop_url(path: String) -> Result { + let base_url = { + let handle = borrow_db_checked(); + + Url::parse(&handle.base_url).map_err(RemoteAccessError::ParsingError)? + }; + + let url = base_url.join(&path).unwrap(); + + Ok(url.to_string()) +} + +#[tauri::command] +pub fn sign_out(app: AppHandle) { + // Clear auth from database + { + let mut handle = borrow_db_mut_checked(); + handle.auth = None; + drop(handle); + save_db(); + } + + // Update app state + { + let app_state = app.state::>(); + let mut app_state_handle = app_state.lock().unwrap(); + app_state_handle.status = AppStatus::SignedOut; + app_state_handle.user = None; + } + + // Emit event for frontend + app.emit("auth/signedout", ()).unwrap(); +} + +#[tauri::command] +pub fn retry_connect(state: tauri::State<'_, Mutex>) { + let (app_status, user) = setup(); + + let mut guard = state.lock().unwrap(); + guard.status = app_status; + guard.user = user; + drop(guard); +} + +#[tauri::command] +pub fn auth_initiate() -> Result<(), RemoteAccessError> { + auth_initiate_logic() +} + +#[tauri::command] +pub fn manual_recieve_handshake(app: AppHandle, token: String) { + recieve_handshake(app, format!("handshake/{}", token)); +} diff --git a/src-tauri/src/remote/mod.rs b/src-tauri/src/remote/mod.rs new file mode 100644 index 0000000..a2eb666 --- /dev/null +++ b/src-tauri/src/remote/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +pub mod commands; +pub mod remote; +pub mod requests; diff --git a/src-tauri/src/remote/remote.rs b/src-tauri/src/remote/remote.rs new file mode 100644 index 0000000..01f2c2b --- /dev/null +++ b/src-tauri/src/remote/remote.rs @@ -0,0 +1,48 @@ +use std::sync::Mutex; + +use log::{debug, warn}; +use serde::Deserialize; +use url::Url; + +use crate::{ + database::db::{borrow_db_mut_checked, save_db}, + error::remote_access_error::RemoteAccessError, + AppState, AppStatus, +}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DropHealthcheck { + app_name: String, +} + +pub fn use_remote_logic( + url: String, + state: tauri::State<'_, Mutex>>, +) -> Result<(), RemoteAccessError> { + debug!("connecting to url {}", url); + let base_url = Url::parse(&url)?; + + // Test Drop url + let test_endpoint = base_url.join("/api/v1")?; + let response = reqwest::blocking::get(test_endpoint.to_string())?; + + let result: DropHealthcheck = response.json()?; + + if result.app_name != "Drop" { + warn!("user entered drop endpoint that connected, but wasn't identified as Drop"); + return Err(RemoteAccessError::InvalidEndpoint); + } + + let mut app_state = state.lock().unwrap(); + app_state.status = AppStatus::SignedOut; + drop(app_state); + + let mut db_state = borrow_db_mut_checked(); + db_state.base_url = base_url.to_string(); + drop(db_state); + + save_db(); + + Ok(()) +} diff --git a/src-tauri/src/remote/requests.rs b/src-tauri/src/remote/requests.rs new file mode 100644 index 0000000..44cdc83 --- /dev/null +++ b/src-tauri/src/remote/requests.rs @@ -0,0 +1,23 @@ +use reqwest::blocking::{Client, RequestBuilder}; + +use crate::{database::db::DatabaseImpls, error::remote_access_error::RemoteAccessError, DB}; + +pub fn make_request, F: FnOnce(RequestBuilder) -> RequestBuilder>( + client: &Client, + path_components: &[T], + query: &[(T, T)], + f: F, +) -> Result { + let mut base_url = DB.fetch_base_url(); + for endpoint in path_components { + base_url = base_url.join(endpoint.as_ref())?; + } + { + let mut queries = base_url.query_pairs_mut(); + for (param, val) in query { + queries.append_pair(param.as_ref(), val.as_ref()); + } + } + let response = client.get(base_url); + Ok(f(response)) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs deleted file mode 100644 index dd95868..0000000 --- a/src-tauri/src/state.rs +++ /dev/null @@ -1,27 +0,0 @@ - -use crate::{ - db::{ApplicationStatus, ApplicationTransientStatus}, - DB, -}; - -pub type ApplicationStatusWithTransient = (Option, Option); -pub struct DownloadStatusManager {} - -impl DownloadStatusManager { - pub fn fetch_state(id: &String) -> ApplicationStatusWithTransient { - let db_lock = DB.borrow_data().unwrap(); - let offline_state = db_lock.applications.statuses.get(id).cloned(); - let online_state = db_lock.applications.transient_statuses.get(id).cloned(); - drop(db_lock); - - if online_state.is_some() { - return (None, online_state); - } - - if offline_state.is_some() { - return (offline_state, None); - } - - (None, None) - } -} diff --git a/src-tauri/src/tests/mod.rs b/src-tauri/src/tests/mod.rs deleted file mode 100644 index 401ca7e..0000000 --- a/src-tauri/src/tests/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod progress_tests; diff --git a/src-tauri/src/tests/progress_tests.rs b/src-tauri/src/tests/progress_tests.rs deleted file mode 100644 index 228b22b..0000000 --- a/src-tauri/src/tests/progress_tests.rs +++ /dev/null @@ -1,29 +0,0 @@ -/* -use atomic_counter::RelaxedCounter; - -use crate::downloads::progress::ProgressChecker; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; - - -#[test] -fn test_progress_sequentially() { - let counter = Arc::new(RelaxedCounter::new(0)); - let callback = Arc::new(AtomicBool::new(false)); - let p = ProgressChecker::new(Box::new(test_fn), counter.clone(), callback, 100); - p.run_contexts_sequentially((1..100).collect()); - println!("Progress: {}", p.get_progress_percentage()); -} -#[test] -fn test_progress_parallel() { - let counter = Arc::new(RelaxedCounter::new(0)); - let callback = Arc::new(AtomicBool::new(false)); - let p = ProgressChecker::new(Box::new(test_fn), counter.clone(), callback, 100); - p.run_contexts_parallel_background((1..100).collect(), 10); -} - -fn test_fn(int: usize, _callback: Arc, _counter: Arc) { - println!("{}", int); -} - -*/ diff --git a/src-tauri/src/tools/compatibility_layer.rs b/src-tauri/src/tools/compatibility_layer.rs deleted file mode 100644 index 3644319..0000000 --- a/src-tauri/src/tools/compatibility_layer.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub struct CompatibilityLayer { - -} \ No newline at end of file diff --git a/src-tauri/src/tools/mod.rs b/src-tauri/src/tools/mod.rs deleted file mode 100644 index 0279e82..0000000 --- a/src-tauri/src/tools/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod prefix; -mod registry; -mod tool; -mod compatibility_layer; \ No newline at end of file diff --git a/src-tauri/src/tools/prefix.rs b/src-tauri/src/tools/prefix.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src-tauri/src/tools/registry.rs b/src-tauri/src/tools/registry.rs deleted file mode 100644 index fa50426..0000000 --- a/src-tauri/src/tools/registry.rs +++ /dev/null @@ -1,7 +0,0 @@ -use std::collections::HashMap; - -use crate::download_manager::downloadable::Downloadable; - -pub struct Registry { - tools: HashMap -} diff --git a/src-tauri/src/tools/tool.rs b/src-tauri/src/tools/tool.rs deleted file mode 100644 index f3a0de0..0000000 --- a/src-tauri/src/tools/tool.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::sync::Arc; - -use crate::download_manager::{download_thread_control_flag::DownloadThreadControl, downloadable::Downloadable, progress_object::ProgressObject}; - -pub struct ToolDownloadAgent { - id: String, - version: String, - location: String, - control_flag: DownloadThreadControl, - progress: Arc, -} -impl Downloadable for ToolDownloadAgent { - fn download(&mut self) -> Result<(), crate::download_manager::application_download_error::ApplicationDownloadError> { - todo!() - } - - fn progress(&self) -> Arc { - todo!() - } - - fn control_flag(&self) -> DownloadThreadControl { - todo!() - } - - fn metadata(&self) -> crate::download_manager::downloadable_metadata::DownloadableMetadata { - todo!() - } - - fn on_error(&self) { - todo!() - } - - fn on_complete(&self) { - todo!() - } -} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0efb8e0..1dead92 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.0", "productName": "Drop Desktop Client", - "version": "0.1.0", + "version": "0.2.0-beta", "identifier": "dev.drop.app", "build": { "beforeDevCommand": "yarn dev --port 1432", @@ -23,7 +23,7 @@ }, "bundle": { "active": true, - "targets": ["nsis", "deb", "rpm", "dmg"], + "targets": ["nsis", "deb", "rpm", "dmg", "appimage"], "windows": { "nsis": { "installMode": "both" diff --git a/types.ts b/types.ts index 63e2f59..60a5b23 100644 --- a/types.ts +++ b/types.ts @@ -59,3 +59,21 @@ export type GameStatus = { type: GameStatusEnum; version_name?: string; }; + +export enum DownloadableType { + Game = "Game", + Tool = "Tool", + DLC = "DLC", + Mod = "Mod" +} + +export type DownloadableMetadata = { + id: string, + version: string, + downloadType: DownloadableType +} + +export type Settings = { + autostart: boolean, + maxDownloadThreads: number, +} \ No newline at end of file