diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 455fdc7..34d41f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above. run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils # webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2. @@ -69,9 +69,9 @@ jobs: security set-keychain-settings -t 3600 -u build.keychain curl https://droposs.org/drop.crt --output drop.pem - sudo security authorizationdb write com.apple.trust-settings.admin allow - sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem - sudo security authorizationdb remove com.apple.trust-settings.admin + sudo security authorizationdb write com.apple.trust-settings.user allow + security add-trusted-cert -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem + sudo security authorizationdb remove com.apple.trust-settings.user security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain diff --git a/README.md b/README.md index f66cea8..662712b 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,21 @@ -# Drop App +# Drop Desktop Client -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. +The Drop Desktop Client is the companion app for [Drop](https://github.com/Drop-OSS/drop). It is the official & intended way to download and play games on your Drop server. -## 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 Docs](https://docs.droposs.org/docs/guides/quickstart) +## Internals -## 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 +It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI. ## Development +Before setting up a development environemnt, be sure that you have a server set up. The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart). -Install dependencies with `yarn` +Then, install dependencies with `yarn`. This'll install the custom builder's dependencies. Then, check everything works properly with `yarn tauri build`. -Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh` +Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh` 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 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 +Check out the contributing guide on our Developer Docs: [Drop Developer Docs - Contributing](https://developer.droposs.org/contributing). diff --git a/build.mjs b/build.mjs index c4846f1..051b850 100644 --- a/build.mjs +++ b/build.mjs @@ -21,6 +21,13 @@ async function spawn(exec, opts) { }); } +const expectedLibs = ["drop-base/package.json"]; + +for (const lib of expectedLibs) { + const path = `./libs/${lib}`; + if (!fs.existsSync(path)) throw `Missing "${expectedLibs}". Run "git submodule update --init --recursive"`; +} + const views = fs.readdirSync(".").filter((view) => { const expectedPath = `./${view}/package.json`; return fs.existsSync(expectedPath); diff --git a/main/app.vue b/main/app.vue index 7d4165f..1594dd6 100644 --- a/main/app.vue +++ b/main/app.vue @@ -1,5 +1,5 @@ diff --git a/main/composables/downloads.ts b/main/composables/downloads.ts index d75c46f..f9ab1cd 100644 --- a/main/composables/downloads.ts +++ b/main/composables/downloads.ts @@ -32,3 +32,5 @@ listen("update_stats", (event) => { const stats = useStatsState(); stats.value = event.payload as StatsState; }); + +export const useDownloadHistory = () => useState>('history', () => []); \ No newline at end of file diff --git a/main/composables/game.ts b/main/composables/game.ts index e57eb8c..71319bd 100644 --- a/main/composables/game.ts +++ b/main/composables/game.ts @@ -43,6 +43,7 @@ export const useGame = async (gameId: string) => { gameStatusRegistry[gameId] = ref(parseStatus(data.status)); listen(`update_game/${gameId}`, (event) => { + console.log(event); const payload: { status: SerializedGameStatus; version?: GameVersion; diff --git a/main/package.json b/main/package.json index f620973..78f9361 100644 --- a/main/package.json +++ b/main/package.json @@ -1,7 +1,7 @@ { "name": "view", "private": true, - "version": "0.3.1", + "version": "0.3.3", "type": "module", "scripts": { "build": "nuxt generate", diff --git a/main/pages/community.vue b/main/pages/community.vue new file mode 100644 index 0000000..2def01e --- /dev/null +++ b/main/pages/community.vue @@ -0,0 +1,25 @@ + + diff --git a/main/pages/library/[id]/index.vue b/main/pages/library/[id]/index.vue index c55b992..5fc30fc 100644 --- a/main/pages/library/[id]/index.vue +++ b/main/pages/library/[id]/index.vue @@ -243,7 +243,10 @@ -
+
+
+
+ + Loading... +
+
+
+
+
+
+ + diff --git a/main/pages/queue.vue b/main/pages/queue.vue index 8d5c4ef..30eb886 100644 --- a/main/pages/queue.vue +++ b/main/pages/queue.vue @@ -4,18 +4,18 @@ class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900" >
- {{ formatKilobytes(stats.speed) }}/s - {{ formatKilobytes(stats.speed) }}B/s + {{ formatTime(stats.time) }} left
-
+
@@ -62,9 +62,9 @@ class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display" >{{ formatKilobytes(element.current / 1000) - }} + }}B / - {{ formatKilobytes(element.max / 1000) }}{{ formatKilobytes(element.max / 1000) }}B
@@ -91,7 +91,7 @@ diff --git a/main/types.ts b/main/types.ts index 2ccd6ff..f63a9c5 100644 --- a/main/types.ts +++ b/main/types.ts @@ -37,6 +37,13 @@ export type Game = { mImageCarouselObjectIds: string[]; }; +export type Collection = { + id: string; + name: string; + isDefault: boolean; + entries: Array<{ gameId: string; game: Game }>; +}; + export type GameVersion = { launchCommandTemplate: string; }; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e649b7..0933ad2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1284,11 +1284,12 @@ dependencies = [ [[package]] name = "drop-app" -version = "0.3.1" +version = "0.3.3" dependencies = [ "atomic-instant-full", "bitcode", "boxcar", + "bytes", "cacache 13.1.0", "chrono", "deranged", @@ -1296,6 +1297,7 @@ dependencies = [ "droplet-rs", "dynfmt", "filetime", + "futures-core", "futures-lite", "gethostname", "hex 0.4.3", @@ -1312,7 +1314,7 @@ dependencies = [ "rand 0.9.1", "rayon", "regex", - "reqwest 0.12.16", + "reqwest 0.12.22", "reqwest-middleware 0.4.2", "reqwest-middleware-cache", "reqwest-websocket", @@ -1339,6 +1341,7 @@ dependencies = [ "tempfile", "throttle_my_fn", "tokio", + "tokio-util", "umu-wrapper-lib", "url", "urlencoding", @@ -2381,6 +2384,7 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -3110,20 +3114,20 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] [[package]] name = "native_model" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7050d759e3da6673361dddda4f4a743492279dd2c6484a21fbee0a8278620df0" +version = "0.6.4" +source = "git+https://github.com/Drop-OSS/native_model.git#a91b422cbd53116df1f20b2459fb3d8257458bfd" dependencies = [ "anyhow", "bincode", "doc-comment", + "log", "native_model_macro", "rmp-serde", "serde", @@ -3133,10 +3137,10 @@ dependencies = [ [[package]] name = "native_model_macro" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1577a0bebf5ed1754e240baf5d9b1845f51e598b20600aa894f55e11cd20cc6c" +version = "0.6.4" +source = "git+https://github.com/Drop-OSS/native_model.git#a91b422cbd53116df1f20b2459fb3d8257458bfd" dependencies = [ + "log", "proc-macro2", "quote", "syn 2.0.101", @@ -4419,9 +4423,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.16" +version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf597b113be201cb2269b4c39b39a804d01b99ee95a4278f0ed04e45cff1c71" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64 0.22.1", "bytes", @@ -4436,16 +4440,14 @@ dependencies = [ "hyper-rustls", "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", - "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -4491,7 +4493,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.3.1", - "reqwest 0.12.16", + "reqwest 0.12.22", "serde", "thiserror 1.0.69", "tower-service", @@ -4526,7 +4528,7 @@ dependencies = [ "async-tungstenite", "bytes", "futures-util", - "reqwest 0.12.16", + "reqwest 0.12.22", "thiserror 2.0.12", "tokio", "tokio-util", @@ -4689,6 +4691,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.2.0", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4786,6 +4800,19 @@ dependencies = [ "security-framework-sys", ] +[[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework-sys" version = "2.14.0" @@ -5506,7 +5533,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.12.16", + "reqwest 0.12.22", "serde", "serde_json", "serde_repr", @@ -6059,9 +6086,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -6151,9 +6178,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.4" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "bitflags 2.9.1", "bytes", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9d0eb29..55f017c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "drop-app" -version = "0.3.1" +version = "0.3.3" description = "The client application for the open-source, self-hosted game distribution platform Drop" authors = ["Drop OSS"] edition = "2024" @@ -65,7 +65,7 @@ whoami = "1.6.0" filetime = "0.2.25" walkdir = "2.5.0" known-folders = "1.2.0" -native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] } +native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"} tauri-plugin-opener = "2.4.0" bitcode = "0.6.6" reqwest-websocket = "0.5.0" @@ -73,6 +73,9 @@ futures-lite = "2.6.0" page_size = "0.6.0" sysinfo = "0.36.1" humansize = "2.1.3" +tokio-util = { version = "0.7.16", features = ["io"] } +futures-core = "0.3.31" +bytes = "1.10.1" # tailscale = { path = "./tailscale" } [dependencies.dynfmt] @@ -104,9 +107,17 @@ version = "2" features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc" [dependencies.reqwest] -version = "0.12" +version = "0.12.22" default-features = false -features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-webpki-roots"] +features = [ + "json", + "http2", + "blocking", + "rustls-tls", + "native-tls-alpn", + "rustls-tls-native-roots", + "stream", +] [dependencies.serde] version = "1" diff --git a/src-tauri/src/database/db.rs b/src-tauri/src/database/db.rs index bc08f7a..2a50f3a 100644 --- a/src-tauri/src/database/db.rs +++ b/src-tauri/src/database/db.rs @@ -8,7 +8,6 @@ use std::{ use chrono::Utc; use log::{debug, error, info, warn}; -use native_model::{Decode, Encode}; use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError}; use serde::{Serialize, de::DeserializeOwned}; use url::Url; @@ -17,8 +16,13 @@ use crate::DB; use super::models::data::Database; +#[cfg(not(debug_assertions))] +static DATA_ROOT_PREFIX: &'static str = "drop"; +#[cfg(debug_assertions)] +static DATA_ROOT_PREFIX: &str = "drop-debug"; + pub static DATA_ROOT_DIR: LazyLock> = - LazyLock::new(|| Arc::new(dirs::data_dir().unwrap().join("drop"))); + LazyLock::new(|| Arc::new(dirs::data_dir().unwrap().join(DATA_ROOT_PREFIX))); // Custom JSON serializer to support everything we need #[derive(Debug, Default, Clone)] @@ -28,7 +32,7 @@ impl DeSerializer for DropDatabaseSerializer { fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult> { - native_model::rmp_serde_1_3::RmpSerde::encode(val) + native_model::encode(val) .map_err(|e| DeSerError::Internal(e.to_string())) } @@ -36,7 +40,7 @@ impl DeSerializer let mut buf = Vec::new(); s.read_to_end(&mut buf) .map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?; - let val = native_model::rmp_serde_1_3::RmpSerde::decode(buf) + let (val, _version) = native_model::decode(buf) .map_err(|e| DeSerError::Internal(e.to_string()))?; Ok(val) } diff --git a/src-tauri/src/database/models.rs b/src-tauri/src/database/models.rs index 51c3004..848169a 100644 --- a/src-tauri/src/database/models.rs +++ b/src-tauri/src/database/models.rs @@ -1,17 +1,12 @@ -/** - * NEXT BREAKING CHANGE - * - * UPDATE DATABASE TO USE RPMSERDENAMED - * - * WE CAN'T DELETE ANY FIELDS - */ pub mod data { - use std::path::PathBuf; + use std::{hash::Hash, path::PathBuf}; - use native_model::native_model; use serde::{Deserialize, Serialize}; + // NOTE: Within each version, you should NEVER use these types. + // Declare it using the actual version that it is from, i.e. v1::Settings rather than just Settings from here + pub type GameVersion = v1::GameVersion; pub type Database = v3::Database; pub type Settings = v1::Settings; @@ -19,14 +14,29 @@ pub mod data { pub type GameDownloadStatus = v2::GameDownloadStatus; pub type ApplicationTransientStatus = v1::ApplicationTransientStatus; + /** + * Need to be universally accessible by the ID, and the version is just a couple sprinkles on top + */ pub type DownloadableMetadata = v1::DownloadableMetadata; pub type DownloadType = v1::DownloadType; pub type DatabaseApplications = v2::DatabaseApplications; - pub type DatabaseCompatInfo = v2::DatabaseCompatInfo; + // pub type DatabaseCompatInfo = v2::DatabaseCompatInfo; use std::collections::HashMap; - pub mod v1 { + impl PartialEq for DownloadableMetadata { + fn eq(&self, other: &Self) -> bool { + self.id == other.id && self.download_type == other.download_type + } + } + impl Hash for DownloadableMetadata { + fn hash(&self, state: &mut H) { + self.id.hash(state); + self.download_type.hash(state); + } + } + + mod v1 { use crate::process::process_manager::Platform; use serde_with::serde_as; use std::{collections::HashMap, path::PathBuf}; @@ -116,6 +126,7 @@ pub mod data { // Stuff that shouldn't be synced to disk #[derive(Clone, Serialize, Deserialize, Debug)] pub enum ApplicationTransientStatus { + Queued { version_name: String }, Downloading { version_name: String }, Uninstalling {}, Updating { version_name: String }, @@ -144,7 +155,7 @@ pub mod data { } #[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)] - #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)] + #[derive(Debug, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct DownloadableMetadata { pub id: String, @@ -174,22 +185,21 @@ pub mod data { } } - pub mod v2 { + mod v2 { use std::{collections::HashMap, path::PathBuf}; use serde_with::serde_as; use super::{ - ApplicationTransientStatus, DatabaseAuth, Deserialize, DownloadableMetadata, - GameVersion, Serialize, Settings, native_model, v1, + Deserialize, Serialize, native_model, v1, }; - #[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] + #[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::Database)] #[derive(Serialize, Deserialize, Clone, Default)] pub struct Database { #[serde(default)] - pub settings: Settings, - pub auth: Option, + pub settings: v1::Settings, + pub auth: Option, pub base_url: String, pub applications: v1::DatabaseApplications, #[serde(skip)] @@ -198,7 +208,7 @@ pub mod data { pub compat_info: Option, } - #[native_model(id = 8, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] + #[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)] #[derive(Serialize, Deserialize, Clone, Default)] pub struct DatabaseCompatInfo { @@ -221,7 +231,7 @@ pub mod data { // Strings are version names for a particular game #[derive(Serialize, Clone, Deserialize, Debug)] #[serde(tag = "type")] - #[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] + #[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::GameDownloadStatus)] pub enum GameDownloadStatus { Remote {}, SetupRequired { @@ -261,16 +271,17 @@ pub mod data { #[serde_as] #[derive(Serialize, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] - #[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] + #[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from=v1::DatabaseApplications)] 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, + + pub game_versions: HashMap>, + pub installed_game_version: HashMap, #[serde(skip)] - pub transient_statuses: HashMap, + pub transient_statuses: HashMap, } impl From for DatabaseApplications { fn from(value: v1::DatabaseApplications) -> Self { @@ -292,21 +303,21 @@ pub mod data { use std::path::PathBuf; use super::{ - DatabaseApplications, DatabaseAuth, DatabaseCompatInfo, Deserialize, Serialize, - Settings, native_model, v2, + Deserialize, Serialize, + native_model, v2, v1, }; - #[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde)] + #[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde, from = v2::Database)] #[derive(Serialize, Deserialize, Clone, Default)] pub struct Database { #[serde(default)] - pub settings: Settings, - pub auth: Option, + pub settings: v1::Settings, + pub auth: Option, pub base_url: String, - pub applications: DatabaseApplications, + pub applications: v2::DatabaseApplications, #[serde(skip)] pub prev_database: Option, pub cache_dir: PathBuf, - pub compat_info: Option, + pub compat_info: Option, } impl From for Database { @@ -346,5 +357,6 @@ pub mod data { compat_info: None, } } + } } diff --git a/src-tauri/src/database/scan.rs b/src-tauri/src/database/scan.rs index 326aee5..9e447cf 100644 --- a/src-tauri/src/database/scan.rs +++ b/src-tauri/src/database/scan.rs @@ -5,10 +5,10 @@ use log::warn; use crate::{ database::{ db::borrow_db_mut_checked, - models::data::v1::{DownloadType, DownloadableMetadata}, + models::data::{DownloadType, DownloadableMetadata}, }, games::{ - downloads::drop_data::{v1::DropData, DROP_DATA_PATH}, + downloads::drop_data::{DropData, DROP_DATA_PATH}, library::set_partially_installed_db, }, }; diff --git a/src-tauri/src/download_manager/download_manager_builder.rs b/src-tauri/src/download_manager/download_manager_builder.rs index 7d8aeed..b346120 100644 --- a/src-tauri/src/download_manager/download_manager_builder.rs +++ b/src-tauri/src/download_manager/download_manager_builder.rs @@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter}; use crate::{ database::models::data::DownloadableMetadata, + download_manager::download_manager_frontend::DownloadStatus, error::application_download_error::ApplicationDownloadError, games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}, }; @@ -75,7 +76,6 @@ pub struct DownloadManagerBuilder { status: Arc>, app_handle: AppHandle, - current_download_agent: Option, // Should be the only download agent in the map with the "Go" flag current_download_thread: Mutex>>, active_control_flag: Option, } @@ -95,7 +95,6 @@ impl DownloadManagerBuilder { progress: active_progress.clone(), app_handle, - current_download_agent: None, current_download_thread: Mutex::new(None), active_control_flag: None, }; @@ -121,14 +120,18 @@ impl DownloadManagerBuilder { fn cleanup_current_download(&mut self) { self.active_control_flag = None; *self.progress.lock().unwrap() = None; - self.current_download_agent = None; let mut download_thread_lock = self.current_download_thread.lock().unwrap(); - *download_thread_lock = None; + + if let Some(unfinished_thread) = download_thread_lock.take() + && !unfinished_thread.is_finished() + { + unfinished_thread.join().unwrap(); + } drop(download_thread_lock); } - fn stop_and_wait_current_download(&self) { + fn stop_and_wait_current_download(&self) -> bool { self.set_status(DownloadManagerStatus::Paused); if let Some(current_flag) = &self.active_control_flag { current_flag.set(DownloadThreadControlFlag::Stop); @@ -136,8 +139,10 @@ impl DownloadManagerBuilder { 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(); - } + return current_download_thread.join().is_ok(); + }; + + true } fn manage_queue(mut self) -> Result<(), ()> { @@ -190,7 +195,7 @@ impl DownloadManagerBuilder { return; } - download_agent.on_initialised(&self.app_handle); + download_agent.on_queued(&self.app_handle); self.download_queue.append(meta.clone()); self.download_agent_registry.insert(meta, download_agent); @@ -209,23 +214,13 @@ impl DownloadManagerBuilder { return; } - if self.current_download_agent.is_some() - && 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; - } - 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:?}"); + let agent_data = if let Some(agent_data) = self.download_queue.read().front() { + agent_data.clone() + } else { + return; + }; let download_agent = self .download_agent_registry @@ -233,8 +228,22 @@ impl DownloadManagerBuilder { .unwrap() .clone(); + let status = download_agent.status(); + + // This download is already going + if status != DownloadStatus::Queued { + return; + } + + // Ensure all others are marked as queued + for agent in self.download_agent_registry.values() { + if agent.metadata() != agent_data && agent.status() != DownloadStatus::Queued { + agent.on_queued(&self.app_handle); + } + } + + info!("starting download for {agent_data:?}"); self.active_control_flag = Some(download_agent.control_flag()); - self.current_download_agent = Some(download_agent.clone()); let sender = self.sender.clone(); @@ -254,12 +263,16 @@ impl DownloadManagerBuilder { } }; - // If the download gets cancelled + // If the download gets canceled // immediately return, on_cancelled gets called for us earlier if !download_result { return; } + if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop { + return; + } + let validate_result = match download_agent.validate(&app_handle) { Ok(v) => v, Err(e) => { @@ -274,6 +287,10 @@ impl DownloadManagerBuilder { } }; + if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop { + return; + } + if validate_result { download_agent.on_complete(&app_handle); sender @@ -299,8 +316,8 @@ impl DownloadManagerBuilder { } fn manage_completed_signal(&mut self, meta: DownloadableMetadata) { debug!("got signal Completed"); - if let Some(interface) = &self.current_download_agent - && interface.metadata() == meta + if let Some(interface) = self.download_queue.read().front() + && interface == &meta { self.remove_and_cleanup_front_download(&meta); } @@ -310,43 +327,37 @@ impl DownloadManagerBuilder { } fn manage_error_signal(&mut self, error: ApplicationDownloadError) { debug!("got signal Error"); - if let Some(current_agent) = self.current_download_agent.clone() { + if let Some(metadata) = self.download_queue.read().front() + && let Some(current_agent) = self.download_agent_registry.get(metadata) + { current_agent.on_error(&self.app_handle, &error); self.stop_and_wait_current_download(); - self.remove_and_cleanup_front_download(¤t_agent.metadata()); + self.remove_and_cleanup_front_download(metadata); } + self.push_ui_queue_update(); self.set_status(DownloadManagerStatus::Error); } fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) { debug!("got signal Cancel"); - 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(); + // If the current download is the one we're tryna cancel + if let Some(current_metadata) = self.download_queue.read().front() + && current_metadata == meta + && let Some(current_download) = self.download_agent_registry.get(current_metadata) + { + self.set_status(DownloadManagerStatus::Paused); + current_download.on_cancelled(&self.app_handle); + self.stop_and_wait_current_download(); - self.download_queue.pop_front(); + 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) { + self.cleanup_current_download(); + self.download_agent_registry.remove(meta); + debug!("current download queue: {:?}", self.download_queue.read()); + } + // else just cancel it + 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); @@ -359,6 +370,7 @@ impl DownloadManagerBuilder { ); } } + self.sender.send(DownloadManagerSignal::Go).unwrap(); self.push_ui_queue_update(); } fn push_ui_stats_update(&self, kbs: usize, time: usize) { diff --git a/src-tauri/src/download_manager/download_manager_frontend.rs b/src-tauri/src/download_manager/download_manager_frontend.rs index 46ee853..13d20e6 100644 --- a/src-tauri/src/download_manager/download_manager_frontend.rs +++ b/src-tauri/src/download_manager/download_manager_frontend.rs @@ -62,7 +62,7 @@ impl Serialize for DownloadManagerStatus { } } -#[derive(Serialize, Clone, Debug)] +#[derive(Serialize, Clone, Debug, PartialEq)] pub enum DownloadStatus { Queued, Downloading, diff --git a/src-tauri/src/download_manager/downloadable.rs b/src-tauri/src/download_manager/downloadable.rs index 9a74077..547daf8 100644 --- a/src-tauri/src/download_manager/downloadable.rs +++ b/src-tauri/src/download_manager/downloadable.rs @@ -12,6 +12,12 @@ use super::{ util::{download_thread_control_flag::DownloadThreadControl, progress_object::ProgressObject}, }; +/** + * Downloadables are responsible for managing their specific object's download state + * e.g, the GameDownloadAgent is responsible for pushing game updates + * + * But the download manager manages the queue state + */ pub trait Downloadable: Send + Sync { fn download(&self, app_handle: &AppHandle) -> Result; fn validate(&self, app_handle: &AppHandle) -> Result; @@ -20,7 +26,7 @@ pub trait Downloadable: Send + Sync { fn control_flag(&self) -> DownloadThreadControl; fn status(&self) -> DownloadStatus; fn metadata(&self) -> DownloadableMetadata; - fn on_initialised(&self, app_handle: &AppHandle); + fn on_queued(&self, app_handle: &AppHandle); fn on_error(&self, app_handle: &AppHandle, error: &ApplicationDownloadError); fn on_complete(&self, app_handle: &AppHandle); fn on_cancelled(&self, app_handle: &AppHandle); diff --git a/src-tauri/src/download_manager/util/progress_object.rs b/src-tauri/src/download_manager/util/progress_object.rs index 2f0bb5d..43c8d76 100644 --- a/src-tauri/src/download_manager/util/progress_object.rs +++ b/src-tauri/src/download_manager/util/progress_object.rs @@ -1,8 +1,8 @@ use std::{ sync::{ + Arc, Mutex, atomic::{AtomicUsize, Ordering}, mpsc::Sender, - Arc, Mutex, }, time::{Duration, Instant}, }; @@ -23,7 +23,7 @@ pub struct ProgressObject { //last_update: Arc>, last_update_time: Arc, bytes_last_update: Arc, - rolling: RollingProgressWindow<250>, + rolling: RollingProgressWindow<1000>, } #[derive(Clone)] @@ -120,7 +120,7 @@ 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 time_since_last_update = Instant::now().duration_since(last_update_time).as_millis_f64(); let current_bytes_downloaded = progress.sum(); let max = progress.get_max(); @@ -128,17 +128,17 @@ pub fn calculate_update(progress: &ProgressObject) { .bytes_last_update .swap(current_bytes_downloaded, Ordering::Acquire); - let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update; + let bytes_since_last_update = current_bytes_downloaded.saturating_sub(bytes_at_last_update) as f64; - let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1); + let kilobytes_per_second = bytes_since_last_update / time_since_last_update; let bytes_remaining = max.saturating_sub(current_bytes_downloaded); // bytes - progress.update_window(kilobytes_per_second); + progress.update_window(kilobytes_per_second as usize); push_update(progress, bytes_remaining); } -#[throttle(1, Duration::from_millis(500))] +#[throttle(1, Duration::from_millis(250))] 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); diff --git a/src-tauri/src/download_manager/util/rolling_progress_updates.rs b/src-tauri/src/download_manager/util/rolling_progress_updates.rs index fadae99..868b200 100644 --- a/src-tauri/src/download_manager/util/rolling_progress_updates.rs +++ b/src-tauri/src/download_manager/util/rolling_progress_updates.rs @@ -1,6 +1,6 @@ use std::sync::{ - atomic::{AtomicUsize, Ordering}, Arc, + atomic::{AtomicUsize, Ordering}, }; #[derive(Clone)] @@ -22,17 +22,22 @@ impl RollingProgressWindow { } pub fn get_average(&self) -> usize { let current = self.current.load(Ordering::SeqCst); - self.window + let valid = self + .window .iter() .enumerate() .filter(|(i, _)| i < ¤t) .map(|(_, x)| x.load(Ordering::Acquire)) - .sum::() - / S + .collect::>(); + let amount = valid.len(); + let sum = valid.into_iter().sum::(); + + sum / amount } pub fn reset(&self) { self.window .iter() .for_each(|x| x.store(0, Ordering::Release)); + self.current.store(0, Ordering::Release); } } diff --git a/src-tauri/src/error/application_download_error.rs b/src-tauri/src/error/application_download_error.rs index a6294ea..f8771a0 100644 --- a/src-tauri/src/error/application_download_error.rs +++ b/src-tauri/src/error/application_download_error.rs @@ -1,6 +1,6 @@ use std::{ fmt::{Display, Formatter}, - io, + io, sync::Arc, }; use serde_with::SerializeDisplay; @@ -11,17 +11,20 @@ use super::remote_access_error::RemoteAccessError; // TODO: Rename / separate from downloads #[derive(Debug, SerializeDisplay)] pub enum ApplicationDownloadError { + NotInitialized, Communication(RemoteAccessError), DiskFull(u64, u64), + #[allow(dead_code)] Checksum, Lock, - IoError(io::ErrorKind), + IoError(Arc), DownloadError, } impl Display for ApplicationDownloadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { + ApplicationDownloadError::NotInitialized => write!(f, "Download not initalized, did something go wrong?"), ApplicationDownloadError::DiskFull(required, available) => write!( f, "Game requires {}, {} remaining left on disk.", @@ -39,7 +42,7 @@ impl Display for ApplicationDownloadError { ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"), ApplicationDownloadError::DownloadError => write!( f, - "download failed. See Download Manager status for specific error" + "Download failed. See Download Manager status for specific error" ), } } diff --git a/src-tauri/src/error/remote_access_error.rs b/src-tauri/src/error/remote_access_error.rs index fdc4b0c..d57ce15 100644 --- a/src-tauri/src/error/remote_access_error.rs +++ b/src-tauri/src/error/remote_access_error.rs @@ -23,6 +23,7 @@ pub enum RemoteAccessError { ManifestDownloadFailed(StatusCode, String), OutOfSync, Cache(std::io::Error), + CorruptedState, } impl Display for RemoteAccessError { @@ -81,6 +82,10 @@ impl Display for RemoteAccessError { "server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other" ), RemoteAccessError::Cache(error) => write!(f, "Cache Error: {error}"), + RemoteAccessError::CorruptedState => write!( + f, + "Drop encountered a corrupted internal state. Please report this to the developers, with details of reproduction." + ), } } } diff --git a/src-tauri/src/games/collections/collection.rs b/src-tauri/src/games/collections/collection.rs index 05fd842..ea29474 100644 --- a/src-tauri/src/games/collections/collection.rs +++ b/src-tauri/src/games/collections/collection.rs @@ -1,10 +1,11 @@ +use bitcode::{Decode, Encode}; use serde::{Deserialize, Serialize}; use crate::games::library::Game; pub type Collections = Vec; -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)] #[serde(rename_all = "camelCase")] pub struct Collection { id: String, @@ -14,7 +15,7 @@ pub struct Collection { entries: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)] #[serde(rename_all = "camelCase")] pub struct CollectionObject { collection_id: String, diff --git a/src-tauri/src/games/collections/commands.rs b/src-tauri/src/games/collections/commands.rs index bff8928..0db6a49 100644 --- a/src-tauri/src/games/collections/commands.rs +++ b/src-tauri/src/games/collections/commands.rs @@ -1,109 +1,110 @@ use serde_json::json; -use url::Url; use crate::{ - DB, - database::db::DatabaseImpls, error::remote_access_error::RemoteAccessError, - remote::{auth::generate_authorization_header, requests::make_request, utils::DROP_CLIENT_SYNC}, + remote::{ + auth::generate_authorization_header, + cache::{cache_object, get_cached_object}, + requests::{generate_url, make_authenticated_get}, + utils::DROP_CLIENT_ASYNC, + }, }; use super::collection::{Collection, Collections}; #[tauri::command] -pub fn fetch_collections() -> Result { - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request(&client, &["/api/v1/client/collection"], &[], |r| { - r.header("Authorization", generate_authorization_header()) - })? - .send()?; +pub async fn fetch_collections( + hard_refresh: Option, +) -> Result { + let do_hard_refresh = hard_refresh.unwrap_or(false); + if !do_hard_refresh && let Ok(cached_response) = get_cached_object::("collections") + { + return Ok(cached_response); + } - Ok(response.json()?) + let response = + make_authenticated_get(generate_url(&["/api/v1/client/collection"], &[])?).await?; + + let collections: Collections = response.json().await?; + + cache_object("collections", &collections)?; + + Ok(collections) } #[tauri::command] -pub fn fetch_collection(collection_id: String) -> Result { - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request( - &client, +pub async fn fetch_collection(collection_id: String) -> Result { + let response = make_authenticated_get(generate_url( &["/api/v1/client/collection/", &collection_id], &[], - |r| r.header("Authorization", generate_authorization_header()), - )? - .send()?; + )?) + .await?; - Ok(response.json()?) + Ok(response.json().await?) } #[tauri::command] -pub fn create_collection(name: String) -> Result { - let client = DROP_CLIENT_SYNC.clone(); - let base_url = DB.fetch_base_url(); - - let base_url = Url::parse(&format!("{base_url}api/v1/client/collection/"))?; +pub async fn create_collection(name: String) -> Result { + let client = DROP_CLIENT_ASYNC.clone(); + let url = generate_url(&["/api/v1/client/collection"], &[])?; let response = client - .post(base_url) + .post(url) .header("Authorization", generate_authorization_header()) .json(&json!({"name": name})) - .send()?; + .send() + .await?; - Ok(response.json()?) + Ok(response.json().await?) } #[tauri::command] -pub fn add_game_to_collection( +pub async fn add_game_to_collection( collection_id: String, game_id: String, ) -> Result<(), RemoteAccessError> { - let client = DROP_CLIENT_SYNC.clone(); - let url = Url::parse(&format!( - "{}api/v1/client/collection/{}/entry/", - DB.fetch_base_url(), - collection_id - ))?; + let client = DROP_CLIENT_ASYNC.clone(); + + let url = generate_url(&["/api/v1/client/collection", &collection_id, "entry"], &[])?; client .post(url) .header("Authorization", generate_authorization_header()) .json(&json!({"id": game_id})) - .send()?; + .send() + .await?; Ok(()) } #[tauri::command] -pub fn delete_collection(collection_id: String) -> Result { - let client = DROP_CLIENT_SYNC.clone(); - let base_url = Url::parse(&format!( - "{}api/v1/client/collection/{}", - DB.fetch_base_url(), - collection_id - ))?; +pub async fn delete_collection(collection_id: String) -> Result { + let client = DROP_CLIENT_ASYNC.clone(); + + let url = generate_url(&["/api/v1/client/collection", &collection_id], &[])?; let response = client - .delete(base_url) + .delete(url) .header("Authorization", generate_authorization_header()) - .send()?; + .send() + .await?; - Ok(response.json()?) + Ok(response.json().await?) } #[tauri::command] -pub fn delete_game_in_collection( +pub async fn delete_game_in_collection( collection_id: String, game_id: String, ) -> Result<(), RemoteAccessError> { - let client = DROP_CLIENT_SYNC.clone(); - let base_url = Url::parse(&format!( - "{}api/v1/client/collection/{}/entry", - DB.fetch_base_url(), - collection_id - ))?; + let client = DROP_CLIENT_ASYNC.clone(); + + let url = generate_url(&["/api/v1/client/collection", &collection_id, "entry"], &[])?; client - .delete(base_url) + .delete(url) .header("Authorization", generate_authorization_header()) .json(&json!({"id": game_id})) - .send()?; + .send() + .await?; Ok(()) } diff --git a/src-tauri/src/games/commands.rs b/src-tauri/src/games/commands.rs index c26e4c6..e36d934 100644 --- a/src-tauri/src/games/commands.rs +++ b/src-tauri/src/games/commands.rs @@ -18,28 +18,30 @@ use crate::{ use super::{ library::{ - FetchGameStruct, Game, fetch_game_logic, fetch_game_verion_options_logic, + FetchGameStruct, Game, fetch_game_logic, fetch_game_version_options_logic, fetch_library_logic, }, state::{GameStatusManager, GameStatusWithTransient}, }; #[tauri::command] -pub fn fetch_library( - state: tauri::State<'_, Mutex>, +pub async fn fetch_library( + state: tauri::State<'_, Mutex>>, + hard_refresh: Option, ) -> Result, RemoteAccessError> { offline!( state, fetch_library_logic, fetch_library_logic_offline, - state - ) + state, + hard_refresh + ).await } #[tauri::command] -pub fn fetch_game( +pub async fn fetch_game( game_id: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result { offline!( state, @@ -47,7 +49,7 @@ pub fn fetch_game( fetch_game_logic_offline, game_id, state - ) + ).await } #[tauri::command] @@ -68,9 +70,9 @@ pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), Libr } #[tauri::command] -pub fn fetch_game_verion_options( +pub async fn fetch_game_version_options( game_id: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result, RemoteAccessError> { - fetch_game_verion_options_logic(game_id, state) + fetch_game_version_options_logic(game_id, state).await } diff --git a/src-tauri/src/games/downloads/commands.rs b/src-tauri/src/games/downloads/commands.rs index 0fa6df9..83aee8e 100644 --- a/src-tauri/src/games/downloads/commands.rs +++ b/src-tauri/src/games/downloads/commands.rs @@ -3,44 +3,47 @@ use std::{ sync::{Arc, Mutex}, }; + use crate::{ - database::{db::borrow_db_checked, models::data::GameDownloadStatus}, - download_manager:: - downloadable::Downloadable - , - error::application_download_error::ApplicationDownloadError, AppState, + database::{ + db::borrow_db_checked, + models::data::GameDownloadStatus, + }, + download_manager::downloadable::Downloadable, + error::application_download_error::ApplicationDownloadError, }; use super::download_agent::GameDownloadAgent; #[tauri::command] -pub fn download_game( +pub async fn download_game( game_id: String, game_version: String, install_dir: usize, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result<(), ApplicationDownloadError> { - let sender = state.lock().unwrap().download_manager.get_sender(); - let game_download_agent = GameDownloadAgent::new_from_index( - game_id, - game_version, - install_dir, - sender, - )?; - let game_download_agent = Arc::new(Box::new(game_download_agent) as Box); + let sender = { state.lock().unwrap().download_manager.get_sender().clone() }; + + let game_download_agent = + GameDownloadAgent::new_from_index(game_id.clone(), game_version.clone(), install_dir, sender).await?; + + let game_download_agent = + Arc::new(Box::new(game_download_agent) as Box); state - .lock() - .unwrap() - .download_manager - .queue_download(game_download_agent).unwrap(); + .lock() + .unwrap() + .download_manager + .queue_download(game_download_agent.clone()) + .unwrap(); + Ok(()) } #[tauri::command] -pub fn resume_download( +pub async fn resume_download( game_id: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result<(), ApplicationDownloadError> { let s = borrow_db_checked() .applications @@ -62,17 +65,21 @@ pub fn resume_download( let sender = state.lock().unwrap().download_manager.get_sender(); let parent_dir: PathBuf = install_dir.into(); - let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new( - game_id, - version_name.clone(), - parent_dir.parent().unwrap().to_path_buf(), - sender, - )?) as Box); + let game_download_agent = Arc::new(Box::new( + GameDownloadAgent::new( + game_id, + version_name.clone(), + parent_dir.parent().unwrap().to_path_buf(), + sender, + ) + .await?, + ) as Box); state - .lock() - .unwrap() - .download_manager - .queue_download(game_download_agent).unwrap(); + .lock() + .unwrap() + .download_manager + .queue_download(game_download_agent) + .unwrap(); Ok(()) } diff --git a/src-tauri/src/games/downloads/download_agent.rs b/src-tauri/src/games/downloads/download_agent.rs index 7946048..e832f3c 100644 --- a/src-tauri/src/games/downloads/download_agent.rs +++ b/src-tauri/src/games/downloads/download_agent.rs @@ -11,16 +11,18 @@ use crate::download_manager::util::download_thread_control_flag::{ use crate::download_manager::util::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::downloads::manifest::{ + DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody, +}; use crate::games::downloads::validate::validate_game_chunk; use crate::games::library::{on_game_complete, push_game_update, set_partially_installed}; use crate::games::state::GameStatusManager; use crate::process::utils::get_disk_available; -use crate::remote::requests::make_request; -use crate::remote::utils::DROP_CLIENT_SYNC; +use crate::remote::requests::generate_url; +use crate::remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC}; use log::{debug, error, info, warn}; use rayon::ThreadPoolBuilder; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{OpenOptions, create_dir_all}; use std::path::{Path, PathBuf}; use std::sync::mpsc::Sender; @@ -31,16 +33,19 @@ use tauri::{AppHandle, Emitter}; #[cfg(target_os = "linux")] use rustix::fs::{FallocateFlags, fallocate}; -use super::download_logic::download_game_chunk; +use super::download_logic::download_game_bucket; use super::drop_data::DropData; static RETRY_COUNT: usize = 3; +const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000; +const MAX_FILES_PER_BUCKET: usize = (1024 / 4) - 1; + pub struct GameDownloadAgent { pub id: String, pub version: String, pub control_flag: DownloadThreadControl, - contexts: Mutex>, + buckets: Mutex>, context_map: Mutex>, pub manifest: Mutex>, pub progress: Arc, @@ -50,19 +55,21 @@ pub struct GameDownloadAgent { } impl GameDownloadAgent { - pub fn new_from_index( + pub async fn new_from_index( id: String, version: String, target_download_dir: usize, sender: Sender, ) -> Result { - let db_lock = borrow_db_checked(); - let base_dir = db_lock.applications.install_dirs[target_download_dir].clone(); - drop(db_lock); + let base_dir = { + let db_lock = borrow_db_checked(); - Self::new(id, version, base_dir, sender) + db_lock.applications.install_dirs[target_download_dir].clone() + }; + + Self::new(id, version, base_dir, sender).await } - pub fn new( + pub async fn new( id: String, version: String, base_dir: PathBuf, @@ -77,12 +84,14 @@ impl GameDownloadAgent { let stored_manifest = DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone()); + let context_lock = stored_manifest.contexts.lock().unwrap().clone(); + let result = Self { id, version, control_flag, manifest: Mutex::new(None), - contexts: Mutex::new(Vec::new()), + buckets: Mutex::new(Vec::new()), context_map: Mutex::new(HashMap::new()), progress: Arc::new(ProgressObject::new(0, 0, sender.clone())), sender, @@ -90,7 +99,7 @@ impl GameDownloadAgent { status: Mutex::new(DownloadStatus::Queued), }; - result.ensure_manifest_exists()?; + result.ensure_manifest_exists().await?; let required_space = result .manifest @@ -99,9 +108,15 @@ impl GameDownloadAgent { .as_ref() .unwrap() .values() - .map(|e| e.lengths.iter().sum::()) - .sum::() - as u64; + .map(|e| { + e.lengths + .iter() + .enumerate() + .filter(|(i, _)| *context_lock.get(&e.checksums[*i]).unwrap_or(&false)) + .map(|(_, v)| v) + .sum::() + }) + .sum::() as u64; let available_space = get_disk_available(data_base_dir_path)? as u64; @@ -117,26 +132,25 @@ impl GameDownloadAgent { // Blocking pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> { - self.ensure_manifest_exists()?; + let mut db_lock = borrow_db_mut_checked(); + let status = ApplicationTransientStatus::Downloading { + version_name: self.version.clone(), + }; + db_lock + .applications + .transient_statuses + .insert(self.metadata(), status.clone()); + // Don't use GameStatusManager because this game isn't installed + push_game_update(app_handle, &self.metadata().id, None, (None, Some(status))); - self.ensure_contexts()?; + if !self.check_manifest_exists() { + return Err(ApplicationDownloadError::NotInitialized); + } + + self.ensure_buckets()?; self.control_flag.set(DownloadThreadControlFlag::Go); - let mut db_lock = borrow_db_mut_checked(); - db_lock.applications.transient_statuses.insert( - self.metadata(), - ApplicationTransientStatus::Downloading { - version_name: self.version.clone(), - }, - ); - push_game_update( - app_handle, - &self.metadata().id, - None, - GameStatusManager::fetch_state(&self.metadata().id, &db_lock), - ); - Ok(()) } @@ -147,9 +161,7 @@ impl GameDownloadAgent { info!("beginning download for {}...", self.metadata().id); - let res = self - .run() - .map_err(|()| ApplicationDownloadError::DownloadError); + let res = self.run().map_err(ApplicationDownloadError::Communication); debug!( "{} took {}ms to download", @@ -159,37 +171,43 @@ impl GameDownloadAgent { res } - pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> { + pub fn check_manifest_exists(&self) -> bool { + self.manifest.lock().unwrap().is_some() + } + + pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> { if self.manifest.lock().unwrap().is_some() { return Ok(()); } - self.download_manifest() + self.download_manifest().await } - fn download_manifest(&self) -> Result<(), ApplicationDownloadError> { - let header = generate_authorization_header(); - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request( - &client, + async fn download_manifest(&self) -> Result<(), ApplicationDownloadError> { + let client = DROP_CLIENT_ASYNC.clone(); + let url = generate_url( &["/api/v1/client/game/manifest"], &[("id", &self.id), ("version", &self.version)], - |f| f.header("Authorization", header), ) - .map_err(ApplicationDownloadError::Communication)? - .send() - .map_err(|e| ApplicationDownloadError::Communication(e.into()))?; + .map_err(ApplicationDownloadError::Communication)?; + + let response = client + .get(url) + .header("Authorization", generate_authorization_header()) + .send() + .await + .map_err(|e| ApplicationDownloadError::Communication(e.into()))?; if response.status() != 200 { return Err(ApplicationDownloadError::Communication( RemoteAccessError::ManifestDownloadFailed( response.status(), - response.text().unwrap(), + response.text().await.unwrap(), ), )); } - let manifest_download: DropManifest = response.json().unwrap(); + let manifest_download: DropManifest = response.json().await.unwrap(); if let Ok(mut manifest) = self.manifest.lock() { *manifest = Some(manifest_download); @@ -201,20 +219,23 @@ impl GameDownloadAgent { // Sets it up for both download and validate fn setup_progress(&self) { - let contexts = self.contexts.lock().unwrap(); + let buckets = self.buckets.lock().unwrap(); - let length = contexts.len(); + let chunk_count = buckets.iter().map(|e| e.drops.len()).sum(); - let chunk_count = contexts.iter().map(|chunk| chunk.length).sum(); + let total_length = buckets + .iter() + .map(|bucket| bucket.drops.iter().map(|e| e.length).sum::()) + .sum(); - self.progress.set_max(chunk_count); - self.progress.set_size(length); + self.progress.set_max(total_length); + self.progress.set_size(chunk_count); self.progress.reset(); } - pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> { - if self.contexts.lock().unwrap().is_empty() { - self.generate_contexts()?; + pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> { + if self.buckets.lock().unwrap().is_empty() { + self.generate_buckets()?; } *self.context_map.lock().unwrap() = self.dropdata.get_contexts(); @@ -222,14 +243,18 @@ impl GameDownloadAgent { Ok(()) } - pub fn generate_contexts(&self) -> Result<(), ApplicationDownloadError> { + pub fn generate_buckets(&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.dropdata.base_path); create_dir_all(base_path).unwrap(); + let mut buckets = Vec::new(); + + let mut current_buckets = HashMap::::new(); + let mut current_bucket_sizes = HashMap::::new(); + for (raw_path, chunk) in manifest { let path = base_path.join(Path::new(&raw_path)); @@ -244,42 +269,95 @@ impl GameDownloadAgent { .truncate(false) .open(path.clone()) .unwrap(); - let mut running_offset = 0; + let mut file_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(), + let drop = DownloadDrop { + filename: raw_path.to_string(), + start: file_running_offset, length: *length, + checksum: chunk.checksums[index].clone(), permissions: chunk.permissions, - }); - running_offset += *length as u64; + path: path.clone(), + index, + }; + file_running_offset += *length; + + if *length >= TARGET_BUCKET_SIZE { + // They get their own bucket + + buckets.push(DownloadBucket { + game_id: game_id.clone(), + version: chunk.version_name.clone(), + drops: vec![drop], + }); + + continue; + } + + let current_bucket_size = current_bucket_sizes + .entry(chunk.version_name.clone()) + .or_insert_with(|| 0); + let c_version_name = chunk.version_name.clone(); + let c_game_id = game_id.clone(); + let current_bucket = current_buckets + .entry(chunk.version_name.clone()) + .or_insert_with(|| DownloadBucket { + game_id: c_game_id, + version: c_version_name, + drops: vec![], + }); + + if (*current_bucket_size + length >= TARGET_BUCKET_SIZE + || current_bucket.drops.len() >= MAX_FILES_PER_BUCKET) + && !current_bucket.drops.is_empty() + { + // Move current bucket into list and make a new one + buckets.push(current_bucket.clone()); + *current_bucket = DownloadBucket { + game_id: game_id.clone(), + version: chunk.version_name.clone(), + drops: vec![], + }; + *current_bucket_size = 0; + } + + current_bucket.drops.push(drop); + *current_bucket_size += *length; } #[cfg(target_os = "linux")] - if running_offset > 0 && !already_exists { - let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset); + if file_running_offset > 0 && !already_exists { + let _ = fallocate(file, FallocateFlags::empty(), 0, file_running_offset as u64); } } - let existing_contexts = self.dropdata.get_completed_contexts(); + + for (_, bucket) in current_buckets.into_iter() { + if !bucket.drops.is_empty() { + buckets.push(bucket); + } + } + + info!("buckets: {}", buckets.len()); + + let existing_contexts = self.dropdata.get_contexts(); self.dropdata.set_contexts( - &contexts + &buckets .iter() - .map(|x| (x.checksum.clone(), existing_contexts.contains(&x.checksum))) + .flat_map(|x| x.drops.iter().map(|v| v.checksum.clone())) + .map(|x| { + let contains = existing_contexts.get(&x).unwrap_or(&false); + (x, *contains) + }) .collect::>(), ); - *self.contexts.lock().unwrap() = contexts; + *self.buckets.lock().unwrap() = buckets; Ok(()) } - fn run(&self) -> Result { + fn run(&self) -> Result { self.setup_progress(); let max_download_threads = borrow_db_checked().settings.max_download_threads; @@ -292,81 +370,110 @@ impl GameDownloadAgent { .build() .unwrap(); + let buckets = self.buckets.lock().unwrap(); + + let mut download_contexts = HashMap::::new(); + + let versions = buckets + .iter() + .map(|e| &e.version) + .collect::>() + .into_iter() + .cloned() + .collect::>(); + + info!("downloading across these versions: {versions:?}"); + let completed_contexts = Arc::new(boxcar::Vec::new()); let completed_indexes_loop_arc = completed_contexts.clone(); - let contexts = self.contexts.lock().unwrap(); + for version in versions { + let download_context = DROP_CLIENT_SYNC + .post(generate_url(&["/api/v2/client/context"], &[]).unwrap()) + .json(&ManifestBody { + game: self.id.clone(), + version: version.clone(), + }) + .header("Authorization", generate_authorization_header()) + .send()?; + + if download_context.status() != 200 { + return Err(RemoteAccessError::InvalidResponse(download_context.json()?)); + } + + let download_context = download_context.json::()?; + info!( + "download context: ({}) {}", + &version, download_context.context + ); + download_contexts.insert(version, download_context); + } + + let download_contexts = &download_contexts; + pool.scope(|scope| { - let client = &DROP_CLIENT_SYNC.clone(); let context_map = self.context_map.lock().unwrap(); - for (index, context) in contexts.iter().enumerate() { - let client = client.clone(); - let completed_indexes = completed_indexes_loop_arc.clone(); + for (index, bucket) in buckets.iter().enumerate() { + let mut bucket = (*bucket).clone(); + let completed_contexts = 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 // Note to future DecDuck, DropData gets loaded into context_map - if let Some(v) = context_map.get(&context.checksum) - && *v - { - progress_handle.skip(context.length); + let todo_drops = bucket + .drops + .into_iter() + .filter(|e| { + let todo = !*context_map.get(&e.checksum).unwrap_or(&false); + if !todo { + progress_handle.skip(e.length); + } + todo + }) + .collect::>(); + + if todo_drops.is_empty() { continue; - } + }; + + bucket.drops = todo_drops; 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, - ) { - Ok(request) => request, - Err(e) => { - sender - .send(DownloadManagerSignal::Error( - ApplicationDownloadError::Communication(e), - )) - .unwrap(); - continue; - } - }; + let download_context = download_contexts + .get(&bucket.version) + .ok_or(RemoteAccessError::CorruptedState) + .unwrap(); scope.spawn(move |_| { // 3 attempts for i in 0..RETRY_COUNT { let loop_progress_handle = progress_handle.clone(); - match download_game_chunk( - context, + match download_game_bucket( + &bucket, + download_context, &self.control_flag, loop_progress_handle, - request.try_clone().unwrap(), ) { Ok(true) => { - completed_indexes.push(context.checksum.clone()); + for drop in bucket.drops { + completed_contexts.push(drop.checksum); + } return; } Ok(false) => return, Err(e) => { warn!("game download agent error: {e}"); - let retry = match &e { - ApplicationDownloadError::Communication( - _remote_access_error, - ) => true, - ApplicationDownloadError::Checksum => true, - ApplicationDownloadError::Lock => true, - ApplicationDownloadError::IoError(_error_kind) => false, - ApplicationDownloadError::DownloadError => false, - ApplicationDownloadError::DiskFull(_, _) => false, - }; + let retry = matches!( + &e, + ApplicationDownloadError::Communication(_) + | ApplicationDownloadError::Checksum + | ApplicationDownloadError::Lock + | ApplicationDownloadError::IoError(_) + ); if i == RETRY_COUNT - 1 || !retry { warn!("retry logic failed, not re-attempting."); @@ -390,14 +497,14 @@ impl GameDownloadAgent { context_map_lock.values().filter(|x| **x).count() }; + let context_map_lock = self.context_map.lock().unwrap(); - let contexts = contexts + let contexts = buckets .iter() + .flat_map(|x| x.drops.iter().map(|e| e.checksum.clone())) .map(|x| { - ( - x.checksum.clone(), - context_map_lock.get(&x.checksum).copied().unwrap_or(false), - ) + let completed = context_map_lock.get(&x).unwrap_or(&false); + (x, *completed) }) .collect::>(); drop(context_map_lock); @@ -408,10 +515,11 @@ impl GameDownloadAgent { // If there are any contexts left which are false if !contexts.iter().all(|x| x.1) { info!( - "download agent for {} exited without completing ({}/{})", + "download agent for {} exited without completing ({}/{}) ({} buckets)", self.id.clone(), completed_lock_len, contexts.len(), + buckets.len() ); return Ok(false); } @@ -424,31 +532,30 @@ impl GameDownloadAgent { self.control_flag.set(DownloadThreadControlFlag::Go); + let status = ApplicationTransientStatus::Validating { + version_name: self.version.clone(), + }; + let mut db_lock = borrow_db_mut_checked(); - db_lock.applications.transient_statuses.insert( - self.metadata(), - ApplicationTransientStatus::Validating { - version_name: self.version.clone(), - }, - ); - push_game_update( - app_handle, - &self.metadata().id, - None, - GameStatusManager::fetch_state(&self.metadata().id, &db_lock), - ); + db_lock + .applications + .transient_statuses + .insert(self.metadata(), status.clone()); + push_game_update(app_handle, &self.metadata().id, None, (None, Some(status))); } pub fn validate(&self, app_handle: &AppHandle) -> Result { self.setup_validate(app_handle); - let contexts = self.contexts.lock().unwrap(); + let buckets = self.buckets.lock().unwrap(); + let contexts: Vec = buckets + .clone() + .into_iter() + .flat_map(|e| -> Vec { e.into() }) + .collect(); let max_download_threads = borrow_db_checked().settings.max_download_threads; - debug!( - "validating game: {} with {} threads", - self.dropdata.game_id, max_download_threads - ); + info!("{} validation contexts", contexts.len()); let pool = ThreadPoolBuilder::new() .num_threads(max_download_threads) .build() @@ -532,8 +639,17 @@ impl Downloadable for GameDownloadAgent { } } - fn on_initialised(&self, _app_handle: &tauri::AppHandle) { + fn on_queued(&self, app_handle: &tauri::AppHandle) { *self.status.lock().unwrap() = DownloadStatus::Queued; + let mut db_lock = borrow_db_mut_checked(); + let status = ApplicationTransientStatus::Queued { + version_name: self.version.clone(), + }; + db_lock + .applications + .transient_statuses + .insert(self.metadata(), status.clone()); + push_game_update(app_handle, &self.id, None, (None, Some(status))); } fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) { @@ -542,13 +658,20 @@ impl Downloadable for GameDownloadAgent { .emit("download_error", error.to_string()) .unwrap(); - error!("error while managing download: {error}"); + error!("error while managing download: {error:?}"); let mut handle = borrow_db_mut_checked(); handle .applications .transient_statuses .remove(&self.metadata()); + + push_game_update( + app_handle, + &self.id, + None, + GameStatusManager::fetch_state(&self.id, &handle), + ); } fn on_complete(&self, app_handle: &tauri::AppHandle) { @@ -561,15 +684,8 @@ impl Downloadable for GameDownloadAgent { } fn on_cancelled(&self, app_handle: &tauri::AppHandle) { + info!("cancelled {}", self.id); self.cancel(app_handle); - /* - on_game_incomplete( - &self.metadata(), - self.dropdata.base_path.to_string_lossy().to_string(), - app_handle, - ) - .unwrap(); - */ } fn status(&self) -> DownloadStatus { diff --git a/src-tauri/src/games/downloads/download_logic.rs b/src-tauri/src/games/downloads/download_logic.rs index 4b35693..cd45919 100644 --- a/src-tauri/src/games/downloads/download_logic.rs +++ b/src-tauri/src/games/downloads/download_logic.rs @@ -5,37 +5,50 @@ use crate::download_manager::util::progress_object::ProgressHandle; use crate::error::application_download_error::ApplicationDownloadError; use crate::error::drop_server_error::DropServerError; use crate::error::remote_access_error::RemoteAccessError; -use crate::games::downloads::manifest::DropDownloadContext; +use crate::games::downloads::manifest::{ChunkBody, DownloadBucket, DownloadContext, DownloadDrop}; use crate::remote::auth::generate_authorization_header; -use log::{debug, warn}; +use crate::remote::requests::generate_url; +use crate::remote::utils::DROP_CLIENT_SYNC; +use log::{debug, info, warn}; use md5::{Context, Digest}; -use reqwest::blocking::{RequestBuilder, Response}; +use reqwest::blocking::Response; -use std::fs::{set_permissions, Permissions}; +use std::fs::{Permissions, set_permissions}; use std::io::Read; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; +use std::time::Instant; use std::{ fs::{File, OpenOptions}, io::{self, BufWriter, Seek, SeekFrom, Write}, path::PathBuf, }; +static MAX_PACKET_LENGTH: usize = 4096 * 4; +static BUMP_SIZE: usize = 4096 * 16; + pub struct DropWriter { hasher: Context, - destination: W, + destination: BufWriter, + progress: ProgressHandle, } impl DropWriter { - fn new(path: PathBuf) -> Self { - let destination = OpenOptions::new().write(true).create(true).truncate(false).open(&path).unwrap(); - Self { - destination, + fn new(path: PathBuf, progress: ProgressHandle) -> Result { + let destination = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&path)?; + Ok(Self { + destination: BufWriter::with_capacity(1024 * 1024, destination), hasher: Context::new(), - } + progress, + }) } fn finish(mut self) -> io::Result { - self.flush().unwrap(); + self.flush()?; Ok(self.hasher.compute()) } } @@ -45,7 +58,10 @@ impl Write for DropWriter { self.hasher .write_all(buf) .map_err(|e| io::Error::other(format!("Unable to write to hasher: {e}")))?; - self.destination.write(buf) + let bytes_written = self.destination.write(buf)?; + self.progress.add(bytes_written); + + Ok(bytes_written) } fn flush(&mut self) -> io::Result<()> { @@ -62,91 +78,126 @@ impl Seek for DropWriter { pub struct DropDownloadPipeline<'a, R: Read, W: Write> { pub source: R, - pub destination: DropWriter, + pub drops: Vec, + pub destination: Vec>, pub control_flag: &'a DownloadThreadControl, - pub progress: ProgressHandle, - pub size: usize, + #[allow(dead_code)] + progress: ProgressHandle, } + impl<'a> DropDownloadPipeline<'a, Response, File> { fn new( source: Response, - destination: DropWriter, + drops: Vec, control_flag: &'a DownloadThreadControl, progress: ProgressHandle, - size: usize, - ) -> Self { - Self { + ) -> Result { + Ok(Self { source, - destination, + destination: drops + .iter() + .map(|drop| DropWriter::new(drop.path.clone(), progress.clone())) + .try_collect()?, + drops, control_flag, progress, - size, - } + }) } fn copy(&mut self) -> Result { - let copy_buf_size = 512; - let mut copy_buf = vec![0; copy_buf_size]; - let mut buf_writer = BufWriter::with_capacity(1024 * 1024, &mut self.destination); + let mut copy_buffer = [0u8; MAX_PACKET_LENGTH]; + for (index, drop) in self.drops.iter().enumerate() { + let destination = self + .destination + .get_mut(index) + .ok_or(io::Error::other("no destination")) + .unwrap(); + let mut remaining = drop.length; + if drop.start != 0 { + destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?; + } + let mut last_bump = 0; + loop { + let size = MAX_PACKET_LENGTH.min(remaining); + let size = self.source.read(&mut copy_buffer[0..size]).inspect_err(|_| { + info!("got error from {}", drop.filename); + })?; + remaining -= size; + last_bump += size; + + destination.write_all(©_buffer[0..size])?; + + if last_bump > BUMP_SIZE { + last_bump -= BUMP_SIZE; + if self.control_flag.get() == DownloadThreadControlFlag::Stop { + return Ok(false); + } + } + + if remaining == 0 { + break; + }; + } - let mut current_size = 0; - loop { if self.control_flag.get() == DownloadThreadControlFlag::Stop { - buf_writer.flush()?; return Ok(false); } - - let mut bytes_read = self.source.read(&mut copy_buf)?; - current_size += bytes_read; - - if current_size > self.size { - let over = current_size - self.size; - warn!("server sent too many bytes... {over} over"); - bytes_read -= over; - current_size = self.size; - } - - buf_writer.write_all(©_buf[0..bytes_read])?; - self.progress.add(bytes_read); - - if current_size >= self.size { - debug!( - "finished with final size of {} vs {}", - current_size, self.size - ); - break; - } } - buf_writer.flush()?; Ok(true) } - fn finish(self) -> Result { - let checksum = self.destination.finish()?; - Ok(checksum) + #[allow(dead_code)] + fn debug_skip_checksum(self) { + self.destination + .into_iter() + .for_each(|mut e| e.flush().unwrap()); + } + + fn finish(self) -> Result, io::Error> { + let checksums = self + .destination + .into_iter() + .map(|e| e.finish()) + .try_collect()?; + Ok(checksums) } } -pub fn download_game_chunk( - ctx: &DropDownloadContext, +pub fn download_game_bucket( + bucket: &DownloadBucket, + ctx: &DownloadContext, control_flag: &DownloadThreadControl, progress: ProgressHandle, - request: RequestBuilder, ) -> Result { // If we're paused if control_flag.get() == DownloadThreadControlFlag::Stop { progress.set(0); return Ok(false); } - let response = request - .header("Authorization", generate_authorization_header()) + + let start = Instant::now(); + + let header = generate_authorization_header(); + + let url = generate_url(&["/api/v2/client/chunk"], &[]) + .map_err(ApplicationDownloadError::Communication)?; + + let body = ChunkBody::create(ctx, &bucket.drops); + + let response = DROP_CLIENT_SYNC + .post(url) + .json(&body) + .header("Authorization", header) .send() .map_err(|e| ApplicationDownloadError::Communication(e.into()))?; if response.status() != 200 { - debug!("chunk request got status code: {}", response.status()); - let raw_res = response.text().unwrap(); + info!("chunk request got status code: {}", response.status()); + let raw_res = response.text().map_err(|e| { + ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into())) + })?; + info!("{raw_res}"); if let Ok(err) = serde_json::from_str::(&raw_res) { return Err(ApplicationDownloadError::Communication( RemoteAccessError::InvalidResponse(err), @@ -157,34 +208,41 @@ pub fn download_game_chunk( )); } - let mut destination = DropWriter::new(ctx.path.clone()); + let lengths = response + .headers() + .get("Content-Lengths") + .ok_or(ApplicationDownloadError::Communication( + RemoteAccessError::UnparseableResponse("missing Content-Lengths header".to_owned()), + ))? + .to_str() + .unwrap(); - if ctx.offset != 0 { - destination - .seek(SeekFrom::Start(ctx.offset)) - .expect("Failed to seek to file offset"); + for (i, raw_length) in lengths.split(",").enumerate() { + let length = raw_length.parse::().unwrap_or(0); + let Some(drop) = bucket.drops.get(i) else { + warn!("invalid number of Content-Lengths recieved: {i}, {lengths}"); + return Err(ApplicationDownloadError::DownloadError); + }; + if drop.length != length { + warn!( + "for {}, expected {}, got {} ({})", + drop.filename, drop.length, raw_length, length + ); + return Err(ApplicationDownloadError::DownloadError); + } } - let content_length = response.content_length(); - if content_length.is_none() { - warn!("recieved 0 length content from server"); - return Err(ApplicationDownloadError::Communication( - RemoteAccessError::InvalidResponse(response.json().unwrap()), - )); - } + let timestep = start.elapsed().as_millis(); - let length = content_length.unwrap().try_into().unwrap(); - - if length != ctx.length { - return Err(ApplicationDownloadError::DownloadError); - } + debug!("took {}ms to start downloading", timestep); let mut pipeline = - DropDownloadPipeline::new(response, destination, control_flag, progress, length); + DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress) + .map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?; let completed = pipeline .copy() - .map_err(|e| ApplicationDownloadError::IoError(e.kind()))?; + .map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?; if !completed { return Ok(false); } @@ -192,23 +250,25 @@ pub fn download_game_chunk( // If we complete the file, set the permissions (if on Linux) #[cfg(unix)] { - let permissions = Permissions::from_mode(ctx.permissions); - set_permissions(ctx.path.clone(), permissions).unwrap(); + for drop in bucket.drops.iter() { + let permissions = Permissions::from_mode(drop.permissions); + set_permissions(drop.path.clone(), permissions) + .map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?; + } } - let checksum = pipeline + let checksums = pipeline .finish() - .map_err(|e| ApplicationDownloadError::IoError(e.kind()))?; + .map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?; - let res = hex::encode(checksum.0); - if res != ctx.checksum { - return Err(ApplicationDownloadError::Checksum); + for (index, drop) in bucket.drops.iter().enumerate() { + let res = hex::encode(**checksums.get(index).unwrap()); + if res != drop.checksum { + warn!("context didn't match... doing nothing because we will validate later."); + // return Ok(false); + // return Err(ApplicationDownloadError::Checksum); + } } - debug!( - "Successfully finished download #{}, copied {} bytes", - ctx.checksum, length - ); - Ok(true) } diff --git a/src-tauri/src/games/downloads/drop_data.rs b/src-tauri/src/games/downloads/drop_data.rs index 38ee575..52ad3fb 100644 --- a/src-tauri/src/games/downloads/drop_data.rs +++ b/src-tauri/src/games/downloads/drop_data.rs @@ -76,14 +76,6 @@ impl DropData { pub fn set_context(&self, context: String, state: bool) { self.contexts.lock().unwrap().entry(context).insert_entry(state); } - pub fn get_completed_contexts(&self) -> Vec { - self.contexts - .lock() - .unwrap() - .iter() - .filter_map(|x| if *x.1 { Some(x.0.clone()) } else { None }) - .collect() - } pub fn get_contexts(&self) -> HashMap { self.contexts.lock().unwrap().clone() } diff --git a/src-tauri/src/games/downloads/manifest.rs b/src-tauri/src/games/downloads/manifest.rs index 7c5b3a9..b1b4baa 100644 --- a/src-tauri/src/games/downloads/manifest.rs +++ b/src-tauri/src/games/downloads/manifest.rs @@ -2,6 +2,65 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +#[derive(Debug, Clone, Serialize)] +// Drops go in buckets +pub struct DownloadDrop { + pub index: usize, + pub filename: String, + pub path: PathBuf, + pub start: usize, + pub length: usize, + pub checksum: String, + pub permissions: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DownloadBucket { + pub game_id: String, + pub version: String, + pub drops: Vec, +} + +#[derive(Deserialize)] +pub struct DownloadContext { + pub context: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChunkBodyFile { + filename: String, + chunk_index: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChunkBody { + pub context: String, + pub files: Vec, +} + +#[derive(Serialize)] +pub struct ManifestBody { + pub game: String, + pub version: String, +} + +impl ChunkBody { + pub fn create(context: &DownloadContext, drops: &[DownloadDrop]) -> ChunkBody { + Self { + context: context.context.clone(), + files: drops + .iter() + .map(|e| ChunkBodyFile { + filename: e.filename.clone(), + chunk_index: e.index, + }) + .collect(), + } + } +} + pub type DropManifest = HashMap; #[derive(Serialize, Deserialize, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)] #[serde(rename_all = "camelCase")] @@ -14,14 +73,26 @@ pub struct DropChunk { } #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct DropDownloadContext { - pub file_name: String, - pub version: String, +pub struct DropValidateContext { pub index: usize, - pub offset: u64, - pub game_id: String, + pub offset: usize, pub path: PathBuf, pub checksum: String, pub length: usize, - pub permissions: u32, +} + +impl From for Vec { + fn from(value: DownloadBucket) -> Self { + value + .drops + .into_iter() + .map(|e| DropValidateContext { + index: e.index, + offset: e.start, + path: e.path, + checksum: e.checksum, + length: e.length, + }) + .collect() + } } diff --git a/src-tauri/src/games/downloads/validate.rs b/src-tauri/src/games/downloads/validate.rs index 90e9e41..1d11cb8 100644 --- a/src-tauri/src/games/downloads/validate.rs +++ b/src-tauri/src/games/downloads/validate.rs @@ -7,24 +7,22 @@ use log::debug; use md5::Context; use crate::{ - download_manager:: - util::{ - download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, - progress_object::ProgressHandle, - } - , + download_manager::util::{ + download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, + progress_object::ProgressHandle, + }, error::application_download_error::ApplicationDownloadError, - games::downloads::manifest::DropDownloadContext, + games::downloads::manifest::DropValidateContext, }; pub fn validate_game_chunk( - ctx: &DropDownloadContext, + ctx: &DropValidateContext, control_flag: &DownloadThreadControl, progress: ProgressHandle, ) -> Result { debug!( "Starting chunk validation {}, {}, {} #{}", - ctx.file_name, ctx.index, ctx.offset, ctx.checksum + ctx.path.display(), ctx.index, ctx.offset, ctx.checksum ); // If we're paused if control_flag.get() == DownloadThreadControlFlag::Stop { @@ -38,7 +36,7 @@ pub fn validate_game_chunk( if ctx.offset != 0 { source - .seek(SeekFrom::Start(ctx.offset)) + .seek(SeekFrom::Start(ctx.offset.try_into().unwrap())) .expect("Failed to seek to file offset"); } diff --git a/src-tauri/src/games/library.rs b/src-tauri/src/games/library.rs index 4c5bad5..2978908 100644 --- a/src-tauri/src/games/library.rs +++ b/src-tauri/src/games/library.rs @@ -14,13 +14,15 @@ use crate::database::models::data::{ ApplicationTransientStatus, DownloadableMetadata, GameDownloadStatus, GameVersion, }; use crate::download_manager::download_manager_frontend::DownloadStatus; +use crate::error::drop_server_error::DropServerError; use crate::error::library_error::LibraryError; use crate::error::remote_access_error::RemoteAccessError; use crate::games::state::{GameStatusManager, GameStatusWithTransient}; use crate::remote::auth::generate_authorization_header; use crate::remote::cache::cache_object_db; use crate::remote::cache::{cache_object, get_cached_object, get_cached_object_db}; -use crate::remote::requests::make_request; +use crate::remote::requests::generate_url; +use crate::remote::utils::DROP_CLIENT_ASYNC; use crate::remote::utils::DROP_CLIENT_SYNC; use bitcode::{Decode, Encode}; @@ -76,24 +78,33 @@ pub struct StatsUpdateEvent { pub time: usize, } -pub fn fetch_library_logic( - state: tauri::State<'_, Mutex>, +pub async fn fetch_library_logic( + state: tauri::State<'_, Mutex>>, + hard_fresh: Option, ) -> Result, RemoteAccessError> { - let header = generate_authorization_header(); + let do_hard_refresh = hard_fresh.unwrap_or(false); + if !do_hard_refresh && let Ok(library) = get_cached_object("library") { + return Ok(library); + } - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request(&client, &["/api/v1/client/user/library"], &[], |f| { - f.header("Authorization", header) - })? - .send()?; + let client = DROP_CLIENT_ASYNC.clone(); + let response = generate_url(&["/api/v1/client/user/library"], &[])?; + let response = client + .get(response) + .header("Authorization", generate_authorization_header()) + .send() + .await?; if response.status() != 200 { - let err = response.json().unwrap(); + let err = response.json().await.unwrap_or(DropServerError { + status_code: 500, + status_message: "Invalid response from server.".to_owned(), + }); warn!("{err:?}"); return Err(RemoteAccessError::InvalidResponse(err)); } - let mut games: Vec = response.json()?; + let mut games: Vec = response.json().await?; let mut handle = state.lock().unwrap(); @@ -135,73 +146,90 @@ pub fn fetch_library_logic( Ok(games) } -pub fn fetch_library_logic_offline( - _state: tauri::State<'_, Mutex>, +pub async fn fetch_library_logic_offline( + _state: tauri::State<'_, Mutex>>, + _hard_refresh: Option, ) -> Result, RemoteAccessError> { let mut games: Vec = get_cached_object("library")?; let db_handle = borrow_db_checked(); games.retain(|game| { - db_handle - .applications - .installed_game_version - .contains_key(&game.id) + matches!( + &db_handle + .applications + .game_statuses + .get(&game.id) + .unwrap_or(&GameDownloadStatus::Remote {}), + GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. } + ) }); Ok(games) } -pub fn fetch_game_logic( +pub async fn fetch_game_logic( id: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result { - let mut state_handle = state.lock().unwrap(); + let version = { + let state_handle = state.lock().unwrap(); - let db_lock = borrow_db_checked(); + let db_lock = borrow_db_checked(); - let metadata_option = db_lock.applications.installed_game_version.get(&id); - let version = match metadata_option { - None => None, - Some(metadata) => db_lock - .applications - .game_versions - .get(&metadata.id) - .map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap()) - .cloned(), - }; - - let game = state_handle.games.get(&id); - if let Some(game) = game { - let status = GameStatusManager::fetch_state(&id, &db_lock); - - let data = FetchGameStruct { - game: game.clone(), - status, - version, + let metadata_option = db_lock.applications.installed_game_version.get(&id); + let version = match metadata_option { + None => None, + Some(metadata) => db_lock + .applications + .game_versions + .get(&metadata.id) + .map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap()) + .cloned(), }; - cache_object_db(&id, game, &db_lock)?; + let game = state_handle.games.get(&id); + if let Some(game) = game { + let status = GameStatusManager::fetch_state(&id, &db_lock); - return Ok(data); - } - drop(db_lock); + let data = FetchGameStruct { + game: game.clone(), + status, + version, + }; - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request(&client, &["/api/v1/client/game/", &id], &[], |r| { - r.header("Authorization", generate_authorization_header()) - })? - .send()?; + cache_object_db(&id, game, &db_lock)?; + + return Ok(data); + } + + version + }; + + let client = DROP_CLIENT_ASYNC.clone(); + let response = generate_url(&["/api/v1/client/game/", &id], &[])?; + let response = client + .get(response) + .header("Authorization", generate_authorization_header()) + .send() + .await?; if response.status() == 404 { + let offline_fetch = fetch_game_logic_offline(id.clone(), state).await; + if let Ok(fetch_data) = offline_fetch { + return Ok(fetch_data); + } + return Err(RemoteAccessError::GameNotFound(id)); } if response.status() != 200 { - let err = response.json().unwrap(); + let err = response.json().await.unwrap(); warn!("{err:?}"); return Err(RemoteAccessError::InvalidResponse(err)); } - let game: Game = response.json()?; + let game: Game = response.json().await?; + + let mut state_handle = state.lock().unwrap(); state_handle.games.insert(id.clone(), game.clone()); let mut db_handle = borrow_db_mut_checked(); @@ -227,24 +255,20 @@ pub fn fetch_game_logic( Ok(data) } -pub fn fetch_game_logic_offline( +pub async fn fetch_game_logic_offline( id: String, - _state: tauri::State<'_, Mutex>, + _state: tauri::State<'_, Mutex>>, ) -> Result { let db_handle = borrow_db_checked(); let metadata_option = db_handle.applications.installed_game_version.get(&id); let version = match metadata_option { None => None, - Some(metadata) => Some( - db_handle - .applications - .game_versions - .get(&metadata.id) - .unwrap() - .get(metadata.version.as_ref().unwrap()) - .unwrap() - .clone(), - ), + Some(metadata) => db_handle + .applications + .game_versions + .get(&metadata.id) + .map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap()) + .cloned(), }; let status = GameStatusManager::fetch_state(&id, &db_handle); @@ -259,27 +283,26 @@ pub fn fetch_game_logic_offline( }) } -pub fn fetch_game_verion_options_logic( +pub async fn fetch_game_version_options_logic( game_id: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result, RemoteAccessError> { - let client = DROP_CLIENT_SYNC.clone(); + let client = DROP_CLIENT_ASYNC.clone(); - let response = make_request( - &client, - &["/api/v1/client/game/versions"], - &[("id", &game_id)], - |r| r.header("Authorization", generate_authorization_header()), - )? - .send()?; + let response = generate_url(&["/api/v1/client/game/versions"], &[("id", &game_id)])?; + let response = client + .get(response) + .header("Authorization", generate_authorization_header()) + .send() + .await?; if response.status() != 200 { - let err = response.json().unwrap(); + let err = response.json().await.unwrap(); warn!("{err:?}"); return Err(RemoteAccessError::InvalidResponse(err)); } - let data: Vec = response.json()?; + let data: Vec = response.json().await?; let state_lock = state.lock().unwrap(); let process_manager_lock = state_lock.process_manager.lock().unwrap(); @@ -346,8 +369,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) db_handle .applications .transient_statuses - .entry(meta.clone()) - .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); + .insert(meta.clone(), ApplicationTransientStatus::Uninstalling {}); push_game_update( app_handle, @@ -381,8 +403,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) db_handle .applications .transient_statuses - .entry(meta.clone()) - .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); + .insert(meta.clone(), ApplicationTransientStatus::Uninstalling {}); drop(db_handle); @@ -400,8 +421,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) db_handle .applications .game_statuses - .entry(meta.id.clone()) - .and_modify(|e| *e = GameDownloadStatus::Remote {}); + .insert(meta.id.clone(), GameDownloadStatus::Remote {}); let _ = db_handle.applications.transient_statuses.remove(&meta); push_game_update( @@ -413,8 +433,6 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) debug!("uninstalled game id {}", &meta.id); app_handle.emit("update_library", ()).unwrap(); - - drop(db_handle); } }); } else { @@ -440,19 +458,18 @@ pub fn on_game_complete( return Err(RemoteAccessError::GameNotFound(meta.id.clone())); } - let header = generate_authorization_header(); - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request( - &client, + let response = generate_url( &["/api/v1/client/game/version"], &[ ("id", &meta.id), ("version", meta.version.as_ref().unwrap()), ], - |f| f.header("Authorization", header), - )? - .send()?; + )?; + let response = client + .get(response) + .header("Authorization", generate_authorization_header()) + .send()?; let game_version: GameVersion = response.json()?; @@ -488,6 +505,7 @@ pub fn on_game_complete( .game_statuses .insert(meta.id.clone(), status.clone()); drop(db_handle); + app_handle .emit( &format!("update_game/{}", meta.id), @@ -508,6 +526,13 @@ pub fn push_game_update( version: Option, status: GameStatusWithTransient, ) { + if let Some(GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }) = + &status.0 + && version.is_none() + { + panic!("pushed game for installed game that doesn't have version information"); + } + app_handle .emit( &format!("update_game/{game_id}"), diff --git a/src-tauri/src/games/state.rs b/src-tauri/src/games/state.rs index 668eb1b..9cffc6d 100644 --- a/src-tauri/src/games/state.rs +++ b/src-tauri/src/games/state.rs @@ -1,4 +1,6 @@ -use crate::database::models::data::{ApplicationTransientStatus, Database, GameDownloadStatus}; +use crate::database::models::data::{ + ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata, GameDownloadStatus, +}; pub type GameStatusWithTransient = ( Option, @@ -8,10 +10,16 @@ pub struct GameStatusManager {} impl GameStatusManager { pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient { - let online_state = match database.applications.installed_game_version.get(game_id) { - Some(meta) => database.applications.transient_statuses.get(meta).cloned(), - None => None, - }; + let online_state = database + .applications + .transient_statuses + .get(&DownloadableMetadata { + id: game_id.to_string(), + download_type: DownloadType::Game, + version: None, + }) + .cloned(); + let offline_state = database.applications.game_statuses.get(game_id).cloned(); if online_state.is_some() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f02bf8..90caf5c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,8 @@ +#![deny(unused_must_use)] #![feature(fn_traits)] #![feature(duration_constructors)] +#![feature(duration_millis_float)] +#![feature(iterator_try_collect)] #![deny(clippy::all)] mod database; @@ -38,7 +41,7 @@ use games::collections::commands::{ fetch_collection, fetch_collections, }; use games::commands::{ - fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, + fetch_game, fetch_game_status, fetch_game_version_options, fetch_library, uninstall_game, }; use games::downloads::commands::download_game; use games::library::{Game, update_game_configuration}; @@ -62,7 +65,6 @@ use std::fs::File; use std::io::Write; use std::panic::PanicHookInfo; use std::path::Path; -use std::process::{Command, Stdio}; use std::str::FromStr; use std::sync::Arc; use std::time::SystemTime; @@ -107,13 +109,7 @@ fn create_new_compat_info() -> Option { #[cfg(target_os = "windows")] return None; - let has_umu_installed = Command::new(UMU_LAUNCHER_EXECUTABLE) - .stdout(Stdio::null()) - .spawn(); - if let Err(umu_error) = &has_umu_installed { - warn!("disabling windows support with error: {umu_error}"); - } - let has_umu_installed = has_umu_installed.is_ok(); + let has_umu_installed = UMU_LAUNCHER_EXECUTABLE.is_some(); Some(CompatInfo { umu_installed: has_umu_installed, }) @@ -134,7 +130,7 @@ pub struct AppState<'a> { compat_info: Option, } -fn setup(handle: AppHandle) -> AppState<'static> { +async fn setup(handle: AppHandle) -> AppState<'static> { let logfile = FileAppender::builder() .encoder(Box::new(PatternEncoder::new( "{d} | {l} | {f}:{L} - {m}{n}", @@ -189,7 +185,7 @@ fn setup(handle: AppHandle) -> AppState<'static> { debug!("database is set up"); // TODO: Account for possible failure - let (app_status, user) = auth::setup(); + let (app_status, user) = auth::setup().await; let db_handle = borrow_db_checked(); let mut missing_games = Vec::new(); @@ -316,7 +312,7 @@ pub fn run() { delete_download_dir, fetch_download_dir_stats, fetch_game_status, - fetch_game_verion_options, + fetch_game_version_options, update_game_configuration, // Collections fetch_collections, @@ -348,92 +344,99 @@ pub fn run() { )) .setup(|app| { let handle = app.handle().clone(); - let state = setup(handle); - debug!("initialized drop client"); - app.manage(Mutex::new(state)); - { - use tauri_plugin_deep_link::DeepLinkExt; - let _ = app.deep_link().register_all(); - debug!("registered all pre-defined deep links"); - } + tauri::async_runtime::block_on(async move { + let state = setup(handle).await; + info!("initialized drop client"); + app.manage(Mutex::new(state)); - let handle = app.handle().clone(); + { + use tauri_plugin_deep_link::DeepLinkExt; + let _ = app.deep_link().register_all(); + debug!("registered all pre-defined deep links"); + } - let _main_window = tauri::WebviewWindowBuilder::new( - &handle, - "main", // BTW this is not the name of the window, just the label. Keep this 'main', there are permissions & configs that depend on it - tauri::WebviewUrl::App("main".into()), - ) - .title("Drop Desktop App") - .min_inner_size(1000.0, 500.0) - .inner_size(1536.0, 864.0) - .decorations(false) - .shadow(false) - .data_directory(DATA_ROOT_DIR.join(".webview")) - .build() - .unwrap(); + let handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - debug!("handling drop:// url"); - let binding = event.urls(); - let url = binding.first().unwrap(); - if url.host_str().unwrap() == "handshake" { - recieve_handshake(handle.clone(), url.path().to_string()); + let _main_window = tauri::WebviewWindowBuilder::new( + &handle, + "main", // BTW this is not the name of the window, just the label. Keep this 'main', there are permissions & configs that depend on it + tauri::WebviewUrl::App("main".into()), + ) + .title("Drop Desktop App") + .min_inner_size(1000.0, 500.0) + .inner_size(1536.0, 864.0) + .decorations(false) + .shadow(false) + .data_directory(DATA_ROOT_DIR.join(".webview")) + .build() + .unwrap(); + + app.deep_link().on_open_url(move |event| { + debug!("handling drop:// url"); + let binding = event.urls(); + let url = binding.first().unwrap(); + if url.host_str().unwrap() == "handshake" { + tauri::async_runtime::spawn(recieve_handshake( + handle.clone(), + url.path().to_string(), + )); + } + }); + + let menu = Menu::with_items( + app, + &[ + &MenuItem::with_id(app, "open", "Open", true, None::<&str>).unwrap(), + &PredefinedMenuItem::separator(app).unwrap(), + /* + &MenuItem::with_id(app, "show_library", "Library", true, None::<&str>)?, + &MenuItem::with_id(app, "show_settings", "Settings", true, None::<&str>)?, + &PredefinedMenuItem::separator(app)?, + */ + &MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap(), + ], + ) + .unwrap(); + + run_on_tray(|| { + TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .on_menu_event(|app, event| match event.id.as_ref() { + "open" => { + app.webview_windows().get("main").unwrap().show().unwrap(); + } + "quit" => { + cleanup_and_exit(app, &app.state()); + } + + _ => { + warn!("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() + ); + app.dialog() + .message( + "Database corrupted. A copy has been saved at: ".to_string() + + original.to_str().unwrap(), + ) + .title("Database corrupted") + .show(|_| {}); + } } }); - let menu = Menu::with_items( - app, - &[ - &MenuItem::with_id(app, "open", "Open", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - /* - &MenuItem::with_id(app, "show_library", "Library", true, None::<&str>)?, - &MenuItem::with_id(app, "show_settings", "Settings", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - */ - &MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?, - ], - )?; - - run_on_tray(|| { - TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .on_menu_event(|app, event| match event.id.as_ref() { - "open" => { - app.webview_windows().get("main").unwrap().show().unwrap(); - } - "quit" => { - cleanup_and_exit(app, &app.state()); - } - - _ => { - warn!("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() - ); - 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| { @@ -441,15 +444,21 @@ pub fn run() { fetch_object(request, responder).await; }); }) - .register_asynchronous_uri_scheme_protocol("server", move |ctx, request, responder| { - let state: tauri::State<'_, Mutex> = ctx.app_handle().state(); - offline!( - state, - handle_server_proto, - handle_server_proto_offline, - request, - responder - ); + .register_asynchronous_uri_scheme_protocol("server", |ctx, request, responder| { + tauri::async_runtime::block_on(async move { + let state = ctx + .app_handle() + .state::>>(); + + offline!( + state, + handle_server_proto, + handle_server_proto_offline, + request, + responder + ) + .await; + }); }) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/process/process_handlers.rs b/src-tauri/src/process/process_handlers.rs index 03699fa..dcbee5c 100644 --- a/src-tauri/src/process/process_handlers.rs +++ b/src-tauri/src/process/process_handlers.rs @@ -1,4 +1,11 @@ -use log::debug; +use std::{ + ffi::OsStr, + path::PathBuf, + process::{Command, Stdio}, + sync::LazyLock, +}; + +use log::{debug, info}; use crate::{ AppState, @@ -24,7 +31,31 @@ impl ProcessHandler for NativeGameLauncher { } } -pub const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run"; +pub static UMU_LAUNCHER_EXECUTABLE: LazyLock> = LazyLock::new(|| { + let x = get_umu_executable(); + info!("{:?}", &x); + x +}); +const UMU_BASE_LAUNCHER_EXECUTABLE: &str = "umu-run"; +const UMU_INSTALL_DIRS: [&str; 4] = ["/app/share", "/use/local/share", "/usr/share", "/opt"]; + +fn get_umu_executable() -> Option { + if check_executable_exists(UMU_BASE_LAUNCHER_EXECUTABLE) { + return Some(PathBuf::from(UMU_BASE_LAUNCHER_EXECUTABLE)); + } + + for dir in UMU_INSTALL_DIRS { + let p = PathBuf::from(dir).join(UMU_BASE_LAUNCHER_EXECUTABLE); + if check_executable_exists(&p) { + return Some(p); + } + } + None +} +fn check_executable_exists>(exec: P) -> bool { + let has_umu_installed = Command::new(exec).stdout(Stdio::null()).output(); + has_umu_installed.is_ok() +} pub struct UMULauncher; impl ProcessHandler for UMULauncher { fn create_launch_process( @@ -47,8 +78,8 @@ impl ProcessHandler for UMULauncher { None => game_version.game_id.clone(), }; format!( - "GAMEID={game_id} {umu} \"{launch}\" {args}", - umu = UMU_LAUNCHER_EXECUTABLE, + "GAMEID={game_id} {umu:?} \"{launch}\" {args}", + umu = UMU_LAUNCHER_EXECUTABLE.as_ref().unwrap(), launch = launch_command, args = args.join(" ") ) @@ -80,7 +111,10 @@ impl ProcessHandler for AsahiMuvmLauncher { game_version, current_dir, ); - let mut args_cmd = umu_string.split("umu-run").collect::>().into_iter(); + let mut args_cmd = umu_string + .split("umu-run") + .collect::>() + .into_iter(); let args = args_cmd.next().unwrap().trim(); let cmd = format!("umu-run{}", args_cmd.next().unwrap()); diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index d7dec3a..901671f 100644 --- a/src-tauri/src/process/process_manager.rs +++ b/src-tauri/src/process/process_manager.rs @@ -172,10 +172,23 @@ impl ProcessManager<'_> { let _ = self.app_handle.emit("launch_external_error", &game_id); } - let status = GameStatusManager::fetch_state(&game_id, &db_handle); - drop(db_handle); + // This is too many unwraps for me to be comfortable + let version_data = db_handle + .applications + .game_versions + .get(&game_id) + .unwrap() + .get(&meta.version.unwrap()) + .unwrap(); - push_game_update(&self.app_handle, &game_id, None, status); + let status = GameStatusManager::fetch_state(&game_id, &db_handle); + + push_game_update( + &self.app_handle, + &game_id, + Some(version_data.clone()), + status, + ); } fn fetch_process_handler( @@ -334,11 +347,10 @@ impl ProcessManager<'_> { #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; - #[cfg(target_os = "windows")] - let mut command = Command::new("start"); + let mut command = Command::new("cmd"); #[cfg(target_os = "windows")] - command.raw_arg(format!("/min cmd /C \"{}\"", &launch_string)); + command.raw_arg(format!("/C \"{}\"", &launch_string)); info!("launching (in {install_dir}): {launch_string}",); diff --git a/src-tauri/src/process/utils.rs b/src-tauri/src/process/utils.rs index def2d9e..41f9239 100644 --- a/src-tauri/src/process/utils.rs +++ b/src-tauri/src/process/utils.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use futures_lite::io; use sysinfo::{Disk, DiskRefreshKind, Disks}; @@ -21,7 +21,7 @@ pub fn get_disk_available(mount_point: PathBuf) -> Result String { format!("Nonce {} {} {}", certs.client_id, nonce, signature) } -pub fn fetch_user() -> Result { - let header = generate_authorization_header(); - - let client = DROP_CLIENT_SYNC.clone(); - let response = make_request(&client, &["/api/v1/client/user"], &[], |f| { - f.header("Authorization", header) - })? - .send()?; +pub async fn fetch_user() -> Result { + let response = make_authenticated_get(generate_url(&["/api/v1/client/user"], &[])?).await?; if response.status() != 200 { - let err: DropServerError = response.json()?; + let err: DropServerError = response.json().await?; warn!("{err:?}"); if err.status_message == "Nonce expired" { @@ -80,10 +74,13 @@ pub fn fetch_user() -> Result { return Err(RemoteAccessError::InvalidResponse(err)); } - response.json::().map_err(std::convert::Into::into) + response + .json::() + .await + .map_err(std::convert::Into::into) } -fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAccessError> { +async 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(); @@ -105,13 +102,13 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc }; let endpoint = base_url.join("/api/v1/client/auth/handshake")?; - let client = DROP_CLIENT_SYNC.clone(); - let response = client.post(endpoint).json(&body).send()?; + let client = DROP_CLIENT_ASYNC.clone(); + let response = client.post(endpoint).json(&body).send().await?; debug!("handshake responsded with {}", response.status().as_u16()); if !response.status().is_success() { - return Err(RemoteAccessError::InvalidResponse(response.json()?)); + return Err(RemoteAccessError::InvalidResponse(response.json().await?)); } - let response_struct: HandshakeResponse = response.json()?; + let response_struct: HandshakeResponse = response.json().await?; { let mut handle = borrow_db_mut_checked(); @@ -129,9 +126,10 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc .post(base_url.join("/api/v1/client/user/webtoken").unwrap()) .header("Authorization", header) .send() + .await .unwrap(); - token.text().unwrap() + token.text().await.unwrap() }; let mut handle = borrow_db_mut_checked(); @@ -141,11 +139,11 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc Ok(()) } -pub fn recieve_handshake(app: AppHandle, path: String) { +pub async fn recieve_handshake(app: AppHandle, path: String) { // Tell the app we're processing app.emit("auth/processing", ()).unwrap(); - let handshake_result = recieve_handshake_logic(&app, path); + let handshake_result = recieve_handshake_logic(&app, path).await; if let Err(e) = handshake_result { warn!("error with authentication: {e}"); app.emit("auth/failed", e.to_string()).unwrap(); @@ -153,13 +151,17 @@ pub fn recieve_handshake(app: AppHandle, path: String) { } let app_state = app.state::>(); - let mut state_lock = app_state.lock().unwrap(); - let (app_status, user) = setup(); + let (app_status, user) = setup().await; + + let mut state_lock = app_state.lock().unwrap(); state_lock.status = app_status; state_lock.user = user; + let _ = clear_cached_object("collections"); + let _ = clear_cached_object("library"); + drop(state_lock); app.emit("auth/finished", ()).unwrap(); @@ -199,13 +201,14 @@ pub fn auth_initiate_logic(mode: String) -> Result { Ok(response) } -pub fn setup() -> (AppStatus, Option) { - let data = borrow_db_checked(); - let auth = data.auth.clone(); - drop(data); +pub async fn setup() -> (AppStatus, Option) { + let auth = { + let data = borrow_db_checked(); + data.auth.clone() + }; if auth.is_some() { - let user_result = match fetch_user() { + let user_result = match fetch_user().await { Ok(data) => data, Err(RemoteAccessError::FetchError(_)) => { let user = get_cached_object::("user").unwrap(); diff --git a/src-tauri/src/remote/cache.rs b/src-tauri/src/remote/cache.rs index 2c07037..d4afd14 100644 --- a/src-tauri/src/remote/cache.rs +++ b/src-tauri/src/remote/cache.rs @@ -11,16 +11,16 @@ use crate::{ }; use bitcode::{Decode, DecodeOwned, Encode}; use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder}; -use log::debug; #[macro_export] macro_rules! offline { ($var:expr, $func1:expr, $func2:expr, $( $arg:expr ),* ) => { - if $crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == $crate::AppStatus::Offline { - $func2( $( $arg ), *) + async move { if $crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == $crate::AppStatus::Offline { + $func2( $( $arg ), *).await } else { - $func1( $( $arg ), *) + $func1( $( $arg ), *).await + } } } } @@ -50,6 +50,12 @@ fn read_sync(base: &Path, key: &str) -> io::Result> { Ok(file) } +fn delete_sync(base: &Path, key: &str) -> io::Result<()> { + let cache_path = get_cache_path(base, key); + std::fs::remove_file(cache_path)?; + Ok(()) +} + pub fn cache_object(key: &str, data: &D) -> Result<(), RemoteAccessError> { cache_object_db(key, data, &borrow_db_checked()) } @@ -68,20 +74,22 @@ pub fn get_cached_object_db( key: &str, db: &Database, ) -> Result { - let start = SystemTime::now(); let bytes = read_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?; - let read = start.elapsed().unwrap(); let data = bitcode::decode::(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?; - let decode = start.elapsed().unwrap(); - debug!( - "cache object took: r:{}, d:{}, b:{}", - read.as_millis(), - read.abs_diff(decode).as_millis(), - bytes.len() - ); Ok(data) } +pub fn clear_cached_object(key: &str) -> Result<(), RemoteAccessError> { + clear_cached_object_db(key, &borrow_db_checked()) +} +pub fn clear_cached_object_db( + key: &str, + db: &Database, +) -> Result<(), RemoteAccessError> { + delete_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?; + Ok(()) +} + #[derive(Encode, Decode)] pub struct ObjectCache { content_type: String, diff --git a/src-tauri/src/remote/commands.rs b/src-tauri/src/remote/commands.rs index 15c80cd..3f20493 100644 --- a/src-tauri/src/remote/commands.rs +++ b/src-tauri/src/remote/commands.rs @@ -8,11 +8,14 @@ use tauri::{AppHandle, Emitter, Manager}; use url::Url; use crate::{ - database::db::{borrow_db_checked, borrow_db_mut_checked}, error::remote_access_error::RemoteAccessError, remote::{ + AppState, AppStatus, + database::db::{borrow_db_checked, borrow_db_mut_checked}, + error::remote_access_error::RemoteAccessError, + remote::{ auth::generate_authorization_header, - requests::make_request, + requests::generate_url, utils::{DROP_CLIENT_SYNC, DROP_CLIENT_WS_CLIENT}, - }, AppState, AppStatus + }, }; use super::{ @@ -45,10 +48,11 @@ pub fn gen_drop_url(path: String) -> Result { #[tauri::command] pub fn fetch_drop_object(path: String) -> Result, RemoteAccessError> { let _drop_url = gen_drop_url(path.clone())?; - let req = make_request(&DROP_CLIENT_SYNC, &[&path], &[], |r| { - r.header("Authorization", generate_authorization_header()) - })? - .send(); + let req = generate_url(&[&path], &[])?; + let req = DROP_CLIENT_SYNC + .get(req) + .header("Authorization", generate_authorization_header()) + .send(); match req { Ok(data) => { @@ -83,13 +87,15 @@ pub fn sign_out(app: AppHandle) { } #[tauri::command] -pub fn retry_connect(state: tauri::State<'_, Mutex>) { - let (app_status, user) = setup(); +pub async fn retry_connect(state: tauri::State<'_, Mutex>>) -> Result<(), ()> { + let (app_status, user) = setup().await; let mut guard = state.lock().unwrap(); guard.status = app_status; guard.user = user; drop(guard); + + Ok(()) } #[tauri::command] @@ -124,7 +130,7 @@ pub fn auth_initiate_code(app: AppHandle) -> Result { let code = auth_initiate_logic("code".to_string())?; let header_code = code.clone(); - println!("using code: {} to sign in", code); + println!("using code: {code} to sign in"); tauri::async_runtime::spawn(async move { let load = async || -> Result<(), RemoteAccessError> { @@ -145,9 +151,7 @@ pub fn auth_initiate_code(app: AppHandle) -> Result { match response.response_type.as_str() { "token" => { let recieve_app = app.clone(); - tauri::async_runtime::spawn_blocking(move || { - manual_recieve_handshake(recieve_app, response.value); - }); + manual_recieve_handshake(recieve_app, response.value).await.unwrap(); return Ok(()); } _ => return Err(RemoteAccessError::HandshakeFailed(response.value)), @@ -171,6 +175,8 @@ pub fn auth_initiate_code(app: AppHandle) -> Result { } #[tauri::command] -pub fn manual_recieve_handshake(app: AppHandle, token: String) { - recieve_handshake(app, format!("handshake/{token}")); +pub async fn manual_recieve_handshake(app: AppHandle, token: String) -> Result<(), ()> { + recieve_handshake(app, format!("handshake/{token}")).await; + + Ok(()) } diff --git a/src-tauri/src/remote/requests.rs b/src-tauri/src/remote/requests.rs index 44cdc83..7ebf7b1 100644 --- a/src-tauri/src/remote/requests.rs +++ b/src-tauri/src/remote/requests.rs @@ -1,13 +1,16 @@ -use reqwest::blocking::{Client, RequestBuilder}; +use url::Url; -use crate::{database::db::DatabaseImpls, error::remote_access_error::RemoteAccessError, DB}; +use crate::{ + DB, + database::db::DatabaseImpls, + error::remote_access_error::RemoteAccessError, + remote::{auth::generate_authorization_header, utils::DROP_CLIENT_ASYNC}, +}; -pub fn make_request, F: FnOnce(RequestBuilder) -> RequestBuilder>( - client: &Client, +pub fn generate_url>( path_components: &[T], query: &[(T, T)], - f: F, -) -> Result { +) -> Result { let mut base_url = DB.fetch_base_url(); for endpoint in path_components { base_url = base_url.join(endpoint.as_ref())?; @@ -18,6 +21,13 @@ pub fn make_request, F: FnOnce(RequestBuilder) -> RequestBuilder>( queries.append_pair(param.as_ref(), val.as_ref()); } } - let response = client.get(base_url); - Ok(f(response)) + Ok(base_url) +} + +pub async fn make_authenticated_get(url: Url) -> Result { + DROP_CLIENT_ASYNC + .get(url) + .header("Authorization", generate_authorization_header()) + .send() + .await } diff --git a/src-tauri/src/remote/server_proto.rs b/src-tauri/src/remote/server_proto.rs index d3c87af..ed07c9b 100644 --- a/src-tauri/src/remote/server_proto.rs +++ b/src-tauri/src/remote/server_proto.rs @@ -5,7 +5,7 @@ use tauri::UriSchemeResponder; use crate::{database::db::borrow_db_checked, remote::utils::DROP_CLIENT_SYNC}; -pub fn handle_server_proto_offline(_request: Request>, responder: UriSchemeResponder) { +pub async fn handle_server_proto_offline(_request: Request>, responder: UriSchemeResponder) { let four_oh_four = Response::builder() .status(StatusCode::NOT_FOUND) .body(Vec::new()) @@ -13,7 +13,7 @@ pub fn handle_server_proto_offline(_request: Request>, responder: UriSch responder.respond(four_oh_four); } -pub fn handle_server_proto(request: Request>, responder: UriSchemeResponder) { +pub async fn handle_server_proto(request: Request>, responder: UriSchemeResponder) { let db_handle = borrow_db_checked(); let web_token = match &db_handle.auth.as_ref().unwrap().web_token { Some(e) => e, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0bbcbe5..eebc528 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.3.1", + "version": "0.3.3", "identifier": "dev.drop.client", "build": { "beforeDevCommand": "yarn --cwd main dev --port 1432",