diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..4099407 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +23 diff --git a/README.md b/README.md index b0df333..903f4ca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,19 @@ Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI. +## Running +Before setting up the drop app, be sure that you have a server set up. +The instructions for this can be found on the [Drop Wiki](https://wiki.droposs.org/guides/quickstart.html) + +## Current features +Currently supported are the following features: +- Signin (with custom server) +- Database registering & recovery +- Dynamic library fetching from server +- Installing & uninstalling games +- Download progress monitoring +- Launching / playing games + ## Development Install dependencies with `yarn` @@ -10,7 +23,7 @@ Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use she To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`: -e.g. `RUST_LOG=debug yarn taudi dev` +e.g. `RUST_LOG=debug yarn tauri dev` ## Contributing Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines. \ No newline at end of file diff --git a/app.vue b/app.vue index b921045..f81c432 100644 --- a/app.vue +++ b/app.vue @@ -6,7 +6,7 @@ diff --git a/components/InitiateAuthModule.vue b/components/InitiateAuthModule.vue index 5f7158c..1f9be6c 100644 --- a/components/InitiateAuthModule.vue +++ b/components/InitiateAuthModule.vue @@ -42,6 +42,31 @@ +
+

Having trouble?

+

+ You can manually enter the token from your web browser. +

+
+ + + Submit + +
+
+
@@ -101,6 +126,10 @@ import { invoke } from "@tauri-apps/api/core"; const loading = ref(false); const error = ref(); +const offerManual = ref(false); +const manualToken = ref(""); +const manualLoading = ref(false); + async function auth() { await invoke("auth_initiate"); } @@ -111,5 +140,23 @@ function authWrapper_wrapper() { loading.value = false; error.value = e; }); + setTimeout(() => { + offerManual.value = true; + }, 10000); +} + +async function continueManual() { + await invoke("manual_recieve_handshake", { token: manualToken.value }); +} + +function continueManual_wrapper() { + loading.value = true; + continueManual() + .catch((e) => { + error.value = e; + }) + .finally(() => { + loading.value = false; + }); } diff --git a/components/PageWidget.vue b/components/PageWidget.vue new file mode 100644 index 0000000..ad9428b --- /dev/null +++ b/components/PageWidget.vue @@ -0,0 +1,7 @@ + diff --git a/composables/downloads.ts b/composables/downloads.ts new file mode 100644 index 0000000..55de7fb --- /dev/null +++ b/composables/downloads.ts @@ -0,0 +1,28 @@ +import { listen } from "@tauri-apps/api/event"; +import type { DownloadableMetadata } from "~/types"; + +export type QueueState = { + queue: Array<{ meta: DownloadableMetadata; status: string; progress: number | null }>; + status: string; +}; + +export type StatsState = { + speed: number; // Bytes per second + time: number; // Seconds, +}; + +export const useQueueState = () => + useState("queue", () => ({ queue: [], status: "Unknown" })); + +export const useStatsState = () => + useState("stats", () => ({ speed: 0, time: 0 })); + +listen("update_queue", (event) => { + const queue = useQueueState(); + queue.value = event.payload as QueueState; +}); + +listen("update_stats", (event) => { + const stats = useStatsState(); + stats.value = event.payload as StatsState; +}); \ No newline at end of file diff --git a/composables/game.ts b/composables/game.ts index 7f28435..3a0f0ec 100644 --- a/composables/game.ts +++ b/composables/game.ts @@ -13,6 +13,7 @@ export type SerializedGameStatus = [ ]; const parseStatus = (status: SerializedGameStatus): GameStatus => { + console.log(status); if (status[0]) { return { type: status[0].type, @@ -28,28 +29,29 @@ const parseStatus = (status: SerializedGameStatus): GameStatus => { } }; -export const useGame = async (id: string) => { - if (!gameRegistry[id]) { +export const useGame = async (gameId: string) => { + if (!gameRegistry[gameId]) { const data: { game: Game; status: SerializedGameStatus } = await invoke( "fetch_game", { - id, + gameId, } ); - gameRegistry[id] = data.game; - if (!gameStatusRegistry[id]) { - gameStatusRegistry[id] = ref(parseStatus(data.status)); + gameRegistry[gameId] = data.game; + if (!gameStatusRegistry[gameId]) { + gameStatusRegistry[gameId] = ref(parseStatus(data.status)); - listen(`update_game/${id}`, (event) => { + listen(`update_game/${gameId}`, (event) => { const payload: { status: SerializedGameStatus; } = event.payload as any; - gameStatusRegistry[id].value = parseStatus(payload.status); + console.log(payload.status); + gameStatusRegistry[gameId].value = parseStatus(payload.status); }); } } - const game = gameRegistry[id]; - const status = gameStatusRegistry[id]; + const game = gameRegistry[gameId]; + const status = gameStatusRegistry[gameId]; return { game, status }; -}; +}; \ No newline at end of file diff --git a/composables/queue.ts b/composables/queue.ts deleted file mode 100644 index 0487260..0000000 --- a/composables/queue.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { listen } from "@tauri-apps/api/event"; - -export type QueueState = { - queue: Array<{ id: string; status: string, progress: number | null }>; -}; - -export const useQueueState = () => - useState("queue", () => ({ queue: [] })); - -listen("update_queue", (event) => { - const queue = useQueueState(); - queue.value = event.payload as QueueState; -}); diff --git a/composables/state-navigation.ts b/composables/state-navigation.ts index 790d258..f7a9b4b 100644 --- a/composables/state-navigation.ts +++ b/composables/state-navigation.ts @@ -1,4 +1,5 @@ import { listen } from "@tauri-apps/api/event"; +import { data } from "autoprefixer"; import { AppStatus, type AppState } from "~/types"; export function setupHooks() { @@ -18,6 +19,20 @@ export function setupHooks() { router.push("/store"); }); + listen("download_error", (event) => { + createModal( + ModalType.Notification, + { + title: "Drop encountered an error while downloading", + description: `Drop encountered an error while downloading your game: "${( + event.payload as unknown as string + ).toString()}"`, + buttonText: "Close" + }, + (e, c) => c() + ); + }); + /* document.addEventListener("contextmenu", (event) => { diff --git a/package.json b/package.json index 7226f0f..c3f715d 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "@tauri-apps/api": ">=2.0.0", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "^2.0.1", + "@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-shell": ">=2.0.0", "markdown-it": "^14.1.0", - "moment": "^2.30.1", "nuxt": "^3.13.0", "scss": "^0.2.4", "vue": "latest", diff --git a/pages/account.vue b/pages/account.vue new file mode 100644 index 0000000..6a072de --- /dev/null +++ b/pages/account.vue @@ -0,0 +1,72 @@ + + + diff --git a/pages/library.vue b/pages/library.vue index 8acd519..a1838d9 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -6,29 +6,22 @@ v-for="(nav, navIdx) in navigation" :key="nav.route" :class="[ - 'transition group rounded flex justify-between gap-x-6 py-2 px-3', - navIdx === currentNavigationIndex ? 'bg-zinc-900' : '', + 'transition-all duration-200 rounded-lg flex items-center py-1.5 px-3', + navIdx === currentNavigationIndex + ? 'bg-zinc-800 text-zinc-100' + : 'bg-zinc-900/50 text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-300', ]" :href="nav.route" > -
+
-
-

- {{ nav.label }} -

-
+

+ {{ nav.label }} +

diff --git a/pages/library/[id]/index.vue b/pages/library/[id]/index.vue index a770ac8..3563722 100644 --- a/pages/library/[id]/index.vue +++ b/pages/library/[id]/index.vue @@ -20,8 +20,10 @@ - - - -
- - -
-
- -
-
-
-
- Install {{ game.mName }}? - -
-

- Drop will add {{ game.mName }} to the queue to be - downloaded. While downloading, Drop may use up a large - amount of resources, particularly network bandwidth and - CPU utilisation. -

-
-
-
- -
-
- - Version -
- - {{ - versionOptions[installVersionIndex].versionName - }} - on - {{ - versionOptions[installVersionIndex].platform - }} - - - - - - - -
  • - {{ version.versionName }} on - {{ version.platform }} - - - -
  • -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - There are no supported versions to install. Please - contact your server admin or try again later. -

    -
    -
    -
    -
    - - Install to -
    - - {{ - installDirs[installDir] - }} - - - - - - - -
  • - {{ dir }} - - - -
  • -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -

    - {{ installError }} -

    -
    -
    -
    -
    -
    - - Install - - -
    -
    -
    + + + + diff --git a/pages/queue.vue b/pages/queue.vue index 6572203..d78967c 100644 --- a/pages/queue.vue +++ b/pages/queue.vue @@ -1,27 +1,32 @@ + + \ No newline at end of file diff --git a/pages/settings/interface.vue b/pages/settings/interface.vue index 27e0f69..3df236b 100644 --- a/pages/settings/interface.vue +++ b/pages/settings/interface.vue @@ -1,3 +1,7 @@ \ No newline at end of file + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 74a6576..069c108 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -261,6 +261,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -355,6 +366,12 @@ dependencies = [ "piper", ] +[[package]] +name = "boxcar" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f839cdf7e2d3198ac6ca003fd8ebc61715755f41c1cad15ff13df67531e00ed" + [[package]] name = "brotli" version = "7.0.0" @@ -864,7 +881,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", ] [[package]] @@ -873,7 +899,18 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", ] [[package]] @@ -965,6 +1002,7 @@ dependencies = [ name = "drop-app" version = "0.1.0" dependencies = [ + "boxcar", "chrono", "directories", "hex", @@ -980,13 +1018,18 @@ dependencies = [ "serde", "serde-binary", "serde_json", + "serde_with", + "shared_child", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-os", "tauri-plugin-shell", "tauri-plugin-single-instance", "tokio", + "umu-wrapper-lib", "url", "urlencoding", "uuid", @@ -1037,7 +1080,7 @@ dependencies = [ "rustc_version", "toml 0.8.2", "vswhom", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -1435,6 +1478,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2853,6 +2906,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb6651f4be5e39563c4fe5cc8326349eb99a25d805a3493f791d5bfd0269e430" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + [[package]] name = "os_pipe" version = "1.2.1" @@ -3793,9 +3857,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.11.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" dependencies = [ "base64 0.22.1", "chrono", @@ -3811,9 +3875,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.11.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" dependencies = [ "darling", "proc-macro2", @@ -4107,6 +4171,15 @@ dependencies = [ "syn 2.0.91", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -4205,7 +4278,7 @@ checksum = "e545de0a2dfe296fa67db208266cd397c5a55ae782da77973ef4c4fac90e9f2c" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 5.0.1", "dunce", "embed_plist", "futures-util", @@ -4255,7 +4328,7 @@ checksum = "7bd2a4bcfaf5fb9f4be72520eefcb61ae565038f8ccba2a497d8c28f463b8c01" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 5.0.1", "glob", "heck 0.5.0", "json-patch", @@ -4327,6 +4400,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c13f843e5e5df3eed270fc42b02923cc1a6b5c7e56b0f3ac1d858ab2c8b5fb" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.9", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.2.0" @@ -4388,6 +4475,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-os" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda2d571a9baf0664c1f2088db227e3072f9028602fafa885deade7547c3b738" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.9", +] + [[package]] name = "tauri-plugin-shell" version = "2.2.0" @@ -4813,7 +4918,7 @@ checksum = "d48a05076dd272615d03033bf04f480199f7d1b66a8ac64d75c625fc4a70c06b" dependencies = [ "core-graphics", "crossbeam-channel", - "dirs", + "dirs 5.0.1", "libappindicator", "muda", "objc2", @@ -4870,6 +4975,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "umu-wrapper-lib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa36636bef667cee9eb4f497c24279182b8b9f098fd04b0b8c5d2ebc4e451f1" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5713,6 +5824,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64f0f9b..73aa54e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "drop-app" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "The client application for the open-source, self-hosted game distribution platform Drop" +authors = ["Drop OSS"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -26,7 +26,6 @@ tauri-build = { version = "2.0.0", features = [] } [dependencies] tauri-plugin-shell = "2.0.0" -serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" serde-binary = "0.5.0" rayon = "1.10.0" @@ -41,12 +40,16 @@ http = "1.1.0" urlencoding = "2.1.3" md5 = "0.7.0" chrono = "0.4.38" +tauri-plugin-os = "2" +boxcar = "0.2.7" +umu-wrapper-lib = "0.1.0" +tauri-plugin-autostart = "2.0.0" +shared_child = "1.0.1" +serde_with = "3.12.0" [dependencies.tauri] version = "2.1.1" -features = [ - "tray-icon" -] +features = ["tray-icon"] [dependencies.tokio] @@ -81,6 +84,10 @@ features = [] # You can also use "yaml_enc" or "bin_enc" version = "0.12" features = ["json", "blocking"] +[dependencies.serde] +version = "1" +features = ["derive", "rc"] + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4bafeab..1b818b3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "core:window:allow-maximize", "core:window:allow-close", "deep-link:default", - "dialog:default" + "dialog:default", + "os:default" ] } \ No newline at end of file diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 0f1010f..7044140 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -1,8 +1,4 @@ -use std::{ - env, - sync::Mutex, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::{env, sync::Mutex}; use chrono::Utc; use log::{info, warn}; @@ -13,7 +9,7 @@ use url::Url; use crate::{ db::{DatabaseAuth, DatabaseImpls}, - remote::RemoteAccessError, + remote::{DropServerError, RemoteAccessError}, AppState, AppStatus, User, DB, }; @@ -78,7 +74,13 @@ pub fn fetch_user() -> Result { .send()?; if response.status() != 200 { - info!("Could not fetch user: {}", response.text().unwrap()); + let data = response.json::()?; + info!("Could not fetch user: {}", data.status_message); + + if data.status_message == "Nonce expired" { + return Err(RemoteAccessError::OutOfSync); + } + return Err(RemoteAccessError::InvalidCodeError(0)); } @@ -91,7 +93,9 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc let path_chunks: Vec<&str> = path.split("/").collect(); if path_chunks.len() != 3 { app.emit("auth/failed", ()).unwrap(); - return Err(RemoteAccessError::InvalidResponse); + return Err(RemoteAccessError::HandshakeFailed( + "failed to parse token".to_string(), + )); } let base_url = { @@ -133,6 +137,12 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc Ok(()) } +#[tauri::command] +pub fn manual_recieve_handshake(app: AppHandle, token: String) -> Result<(), String> { + recieve_handshake(app, format!("handshake/{}", token)); + Ok(()) +} + pub fn recieve_handshake(app: AppHandle, path: String) { // Tell the app we're processing app.emit("auth/processing", ()).unwrap(); @@ -147,7 +157,7 @@ pub fn recieve_handshake(app: AppHandle, path: String) { app.emit("auth/finished", ()).unwrap(); } -async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { +fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { let base_url = { let db_lock = DB.borrow_data().unwrap(); Url::parse(&db_lock.base_url.clone())? @@ -159,14 +169,17 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { platform: env::consts::OS.to_string(), }; - let client = reqwest::Client::new(); - let response = client.post(endpoint.to_string()).json(&body).send().await?; + let client = reqwest::blocking::Client::new(); + let response = client.post(endpoint.to_string()).json(&body).send()?; if response.status() != 200 { - return Err(RemoteAccessError::InvalidRedirect); + let data = response.json::()?; + info!("Could not start handshake: {}", data.status_message); + + return Err(RemoteAccessError::HandshakeFailed(data.status_message)); } - let redir_url = response.text().await?; + let redir_url = response.text()?; let complete_redir_url = base_url.join(&redir_url)?; info!("opening web browser to continue authentication"); @@ -176,8 +189,8 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> { } #[tauri::command] -pub async fn auth_initiate<'a>() -> Result<(), String> { - let result = auth_initiate_wrapper().await; +pub fn auth_initiate<'a>() -> Result<(), String> { + let result = auth_initiate_wrapper(); if result.is_err() { return Err(result.err().unwrap().to_string()); } @@ -199,8 +212,10 @@ pub fn retry_connect(state: tauri::State<'_, Mutex>) -> Result<(), ()> pub fn setup() -> Result<(AppStatus, Option), ()> { let data = DB.borrow_data().unwrap(); + let auth = data.auth.clone(); + drop(data); - if data.auth.is_some() { + if auth.is_some() { let user_result = fetch_user(); if user_result.is_err() { let error = user_result.err().unwrap(); @@ -215,7 +230,31 @@ pub fn setup() -> Result<(AppStatus, Option), ()> { return Ok((AppStatus::SignedIn, Some(user_result.unwrap()))); } - drop(data); - Ok((AppStatus::SignedOut, None)) } + +#[tauri::command] +pub fn sign_out(app: AppHandle) -> Result<(), String> { + info!("Signing out user"); + + // Clear auth from database + { + let mut handle = DB.borrow_data_mut().unwrap(); + handle.auth = None; + drop(handle); + DB.save().unwrap(); + } + + // Update app state + { + let app_state = app.state::>(); + let mut app_state_handle = app_state.lock().unwrap(); + app_state_handle.status = AppStatus::SignedOut; + app_state_handle.user = None; + } + + // Emit event for frontend + app.emit("auth/signedout", ()).unwrap(); + + Ok(()) +} diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs new file mode 100644 index 0000000..bd526be --- /dev/null +++ b/src-tauri/src/autostart.rs @@ -0,0 +1,69 @@ +use log::info; +use tauri::AppHandle; +use tauri_plugin_autostart::ManagerExt; +use crate::DB; + +#[tauri::command] +pub async fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> { + let manager = app.autolaunch(); + if enabled { + manager.enable().map_err(|e| e.to_string())?; + info!("Enabled autostart"); + } else { + manager.disable().map_err(|e| e.to_string())?; + info!("Disabled autostart"); + } + + // Store the state in DB + let mut db_handle = DB.borrow_data_mut().map_err(|e| e.to_string())?; + db_handle.settings.autostart = enabled; + drop(db_handle); + DB.save().map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn get_autostart_enabled(app: AppHandle) -> Result { + // First check DB state + let db_handle = DB.borrow_data().map_err(|e| e.to_string())?; + let db_state = db_handle.settings.autostart; + drop(db_handle); + + // Get actual system state + let manager = app.autolaunch(); + let system_state = manager.is_enabled().map_err(|e| e.to_string())?; + + // If they don't match, sync to DB state + if db_state != system_state { + if db_state { + manager.enable().map_err(|e| e.to_string())?; + } else { + manager.disable().map_err(|e| e.to_string())?; + } + } + + Ok(db_state) +} + +// New function to sync state on startup +pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> { + let db_handle = DB.borrow_data().map_err(|e| e.to_string())?; + let should_be_enabled = db_handle.settings.autostart; + drop(db_handle); + + let manager = app.autolaunch(); + let current_state = manager.is_enabled().map_err(|e| e.to_string())?; + + if current_state != should_be_enabled { + if should_be_enabled { + manager.enable().map_err(|e| e.to_string())?; + info!("Synced autostart: enabled"); + } else { + manager.disable().map_err(|e| e.to_string())?; + info!("Synced autostart: disabled"); + } + } + + Ok(()) +} diff --git a/src-tauri/src/cleanup.rs b/src-tauri/src/cleanup.rs index 2fe2d2d..925ea61 100644 --- a/src-tauri/src/cleanup.rs +++ b/src-tauri/src/cleanup.rs @@ -1,17 +1,14 @@ -use std::sync::Mutex; use log::info; use tauri::AppHandle; -use crate::AppState; #[tauri::command] pub fn quit(app: tauri::AppHandle) { cleanup_and_exit(&app); } - -pub fn cleanup_and_exit(app: &AppHandle, ) { +pub fn cleanup_and_exit(app: &AppHandle) { info!("exiting drop application..."); app.exit(0); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 00e93ab..a9ad8da 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2,19 +2,21 @@ use std::{ collections::HashMap, fs::{self, create_dir_all}, path::{Path, PathBuf}, - sync::{LazyLock, Mutex}, + sync::{Arc, LazyLock, Mutex, RwLockWriteGuard}, time::{Instant, SystemTime, UNIX_EPOCH}, }; +use chrono::Utc; use directories::BaseDirs; use log::debug; -use rustbreak::{DeSerError, DeSerializer, PathDatabase}; +use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_with::serde_as; +use tauri::AppHandle; use url::Url; -use crate::{process::process_manager::Platform, DB}; +use crate::{download_manager::downloadable_metadata::DownloadableMetadata, games::{library::push_game_update, state::GameStatusManager}, process::process_manager::Platform, DB}; #[derive(serde::Serialize, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] pub struct DatabaseAuth { pub private: String, pub cert: String, @@ -24,7 +26,7 @@ pub struct DatabaseAuth { // Strings are version names for a particular game #[derive(Serialize, Clone, Deserialize)] #[serde(tag = "type")] -pub enum GameStatus { +pub enum GameDownloadStatus { Remote {}, SetupRequired { version_name: String, @@ -38,13 +40,14 @@ pub enum GameStatus { // Stuff that shouldn't be synced to disk #[derive(Clone, Serialize)] -pub enum GameTransientStatus { +pub enum ApplicationTransientStatus { Downloading { version_name: String }, Uninstalling {}, Updating { version_name: String }, + Running {}, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct GameVersion { pub version_index: usize, @@ -54,24 +57,43 @@ pub struct GameVersion { pub platform: Platform, } +#[serde_as] #[derive(Serialize, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct DatabaseGames { +pub struct DatabaseApplications { pub install_dirs: Vec, // Guaranteed to exist if the game also exists in the app state map - pub statuses: HashMap, - pub versions: HashMap>, + pub game_statuses: HashMap, + pub game_versions: HashMap>, + pub installed_game_version: HashMap, #[serde(skip)] - pub transient_statuses: HashMap, + pub transient_statuses: HashMap, } -#[derive(Serialize, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Serialize, Deserialize, Clone)] +pub struct Settings { + pub autostart: bool, + // ... other settings ... +} + +impl Default for Settings { + fn default() -> Self { + Self { + autostart: false, + // ... other settings defaults ... + } + } +} + +#[derive(Serialize, Deserialize, Clone)] pub struct Database { + #[serde(default)] + pub settings: Settings, pub auth: Option, pub base_url: String, - pub games: DatabaseGames, + pub applications: DatabaseApplications, + pub prev_database: Option } pub static DATA_ROOT_DIR: LazyLock> = LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop"))); @@ -112,21 +134,27 @@ impl DatabaseImpls for DatabaseInterface { debug!("Creating logs directory"); create_dir_all(logs_root_dir.clone()).unwrap(); - #[allow(clippy::let_and_return)] let exists = fs::exists(db_path.clone()).unwrap(); match exists { - true => PathDatabase::load_from_path(db_path).expect("Database loading failed"), + true => + match PathDatabase::load_from_path(db_path.clone()) { + Ok(db) => db, + Err(e) => handle_invalid_database(e, db_path, games_base_dir), + }, false => { let default = Database { + settings: Settings::default(), auth: None, base_url: "".to_string(), - games: DatabaseGames { + applications: DatabaseApplications { install_dirs: vec![games_base_dir.to_str().unwrap().to_string()], - statuses: HashMap::new(), + game_statuses: HashMap::new(), transient_statuses: HashMap::new(), - versions: HashMap::new(), + game_versions: HashMap::new(), + installed_game_version: HashMap::new(), }, + prev_database: None, }; debug!( "Creating database at path {}", @@ -172,10 +200,10 @@ pub fn add_download_dir(new_dir: String) -> Result<(), String> { // Add it to the dictionary let mut lock = DB.borrow_data_mut().unwrap(); - if lock.games.install_dirs.contains(&new_dir) { + if lock.applications.install_dirs.contains(&new_dir) { return Err("Download directory already used".to_string()); } - lock.games.install_dirs.push(new_dir); + lock.applications.install_dirs.push(new_dir); drop(lock); DB.save().unwrap(); @@ -185,7 +213,7 @@ pub fn add_download_dir(new_dir: String) -> Result<(), String> { #[tauri::command] pub fn delete_download_dir(index: usize) -> Result<(), String> { let mut lock = DB.borrow_data_mut().unwrap(); - lock.games.install_dirs.remove(index); + lock.applications.install_dirs.remove(index); drop(lock); DB.save().unwrap(); @@ -197,8 +225,54 @@ pub fn delete_download_dir(index: usize) -> Result<(), String> { #[tauri::command] pub fn fetch_download_dir_stats() -> Result, String> { let lock = DB.borrow_data().unwrap(); - let directories = lock.games.install_dirs.clone(); + let directories = lock.applications.install_dirs.clone(); drop(lock); Ok(directories) } + +pub fn set_game_status, &DownloadableMetadata)>( + app_handle: &AppHandle, + meta: DownloadableMetadata, + setter: F, +) { + let mut db_handle = DB.borrow_data_mut().unwrap(); + setter(&mut db_handle, &meta); + drop(db_handle); + DB.save().unwrap(); + + let status = GameStatusManager::fetch_state(&meta.id); + + push_game_update(app_handle, &meta, status); +} + +// TODO: Make the error relelvant rather than just assume that it's a Deserialize error +fn handle_invalid_database(_e: RustbreakError, db_path: PathBuf, games_base_dir: PathBuf) -> rustbreak::Database { + let new_path = { + let time = Utc::now().timestamp(); + let mut base = db_path.clone().into_os_string(); + base.push("."); + base.push(time.to_string()); + base + }; + fs::copy(&db_path, &new_path).unwrap(); + + let db = Database { + auth: None, + base_url: "".to_string(), + applications: DatabaseApplications { + install_dirs: vec![games_base_dir.to_str().unwrap().to_string()], + game_statuses: HashMap::new(), + transient_statuses: HashMap::new(), + game_versions: HashMap::new(), + installed_game_version: HashMap::new(), + }, + prev_database: Some(new_path.into()), + settings: Settings { autostart: false }, + }; + + PathDatabase::create_at_path(db_path, db) + .expect("Database could not be created") + + +} \ No newline at end of file diff --git a/src-tauri/src/debug.rs b/src-tauri/src/debug.rs new file mode 100644 index 0000000..de42dd6 --- /dev/null +++ b/src-tauri/src/debug.rs @@ -0,0 +1,23 @@ +use crate::{DATA_ROOT_DIR, DB}; +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemData { + client_id: String, + base_url: String, + data_dir: String, +} + +#[tauri::command] +pub fn fetch_system_data() -> Result { + let db_handle = DB.borrow_data().map_err(|e| e.to_string())?; + let system_data = SystemData { + client_id: db_handle.auth.as_ref().unwrap().client_id.clone(), + base_url: db_handle.base_url.clone(), + data_dir: DATA_ROOT_DIR.lock().unwrap().to_string_lossy().to_string(), + }; + drop(db_handle); + + Ok(system_data) +} diff --git a/src-tauri/src/download_manager/application_download_error.rs b/src-tauri/src/download_manager/application_download_error.rs new file mode 100644 index 0000000..38c690d --- /dev/null +++ b/src-tauri/src/download_manager/application_download_error.rs @@ -0,0 +1,41 @@ +use std::{fmt::{Display, Formatter}, io}; + +use crate::remote::RemoteAccessError; + +// TODO: Rename / separate from downloads +#[derive(Debug, Clone)] +pub enum ApplicationDownloadError { + Communication(RemoteAccessError), + Checksum, + Setup(SetupError), + Lock, + IoError(io::ErrorKind), + DownloadError, +} + +impl Display for ApplicationDownloadError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ApplicationDownloadError::Communication(error) => write!(f, "{}", error), + ApplicationDownloadError::Setup(error) => write!(f, "An error occurred while setting up the download: {}", error), + ApplicationDownloadError::Lock => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"), + ApplicationDownloadError::Checksum => write!(f, "Checksum failed to validate for download"), + ApplicationDownloadError::IoError(error) => write!(f, "{}", error), + ApplicationDownloadError::DownloadError => write!(f, "Download failed. See Download Manager status for specific error"), + } + } +} + +#[derive(Debug, Clone)] +pub enum SetupError { + Context, +} + +impl Display for SetupError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + SetupError::Context => write!(f, "Failed to generate contexts for download"), + } + } +} + diff --git a/src-tauri/src/downloads/download_manager.rs b/src-tauri/src/download_manager/download_manager.rs similarity index 60% rename from src-tauri/src/downloads/download_manager.rs rename to src-tauri/src/download_manager/download_manager.rs index 8676087..ec950f8 100644 --- a/src-tauri/src/downloads/download_manager.rs +++ b/src-tauri/src/download_manager/download_manager.rs @@ -12,47 +12,57 @@ use std::{ use log::info; use serde::Serialize; -use super::{ - download_agent::{GameDownloadAgent, GameDownloadError}, - download_manager_builder::CurrentProgressObject, - progress_object::ProgressObject, - queue::Queue, -}; + +use super::{application_download_error::ApplicationDownloadError, download_manager_builder::{CurrentProgressObject, DownloadAgent}, downloadable_metadata::DownloadableMetadata, queue::Queue}; pub enum DownloadManagerSignal { /// Resumes (or starts) the DownloadManager Go, /// Pauses the DownloadManager Stop, - /// Called when a GameDownloadAgent has fully completed a download. - Completed(String), - /// Generates and appends a GameDownloadAgent + /// Called when a DownloadAgent has fully completed a download. + Completed(DownloadableMetadata), + /// Generates and appends a DownloadAgent /// to the registry and queue - Queue(String, String, usize), + Queue(DownloadAgent), /// Tells the Manager to stop the current /// download, sync everything to disk, and /// then exit Finish, - /// Stops (but doesn't remove) current download - Cancel, - /// Removes a given game - Remove(String), + /// Stops, removes, and tells a download to cleanup + Cancel(DownloadableMetadata), + /// Removes a given application + Remove(DownloadableMetadata), /// Any error which occurs in the agent - Error(GameDownloadError), + Error(ApplicationDownloadError), /// Pushes UI update - Update, + UpdateUIQueue, + UpdateUIStats(usize, usize), //kb/s and seconds + /// Uninstall download + /// Takes download ID + Uninstall(DownloadableMetadata), } +#[derive(Debug, Clone)] pub enum DownloadManagerStatus { Downloading, Paused, Empty, - Error(GameDownloadError), + Error(ApplicationDownloadError), Finished, } -#[derive(Serialize, Clone)] -pub enum GameDownloadStatus { +impl Serialize for DownloadManagerStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&format!["{:?}", self]) + } +} + +#[derive(Serialize, Clone, Debug)] +pub enum DownloadStatus { Queued, Downloading, Error, @@ -74,27 +84,6 @@ pub struct DownloadManager { progress: CurrentProgressObject, command_sender: Sender, } -pub struct GameDownloadAgentQueueStandin { - pub id: String, - pub status: Mutex, - pub progress: Arc, -} -impl From> for GameDownloadAgentQueueStandin { - fn from(value: Arc) -> Self { - Self { - id: value.id.clone(), - status: Mutex::from(GameDownloadStatus::Queued), - progress: value.progress.clone(), - } - } -} -impl Debug for GameDownloadAgentQueueStandin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GameDownloadAgentQueueStandin") - .field("id", &self.id) - .finish() - } -} #[allow(dead_code)] impl DownloadManager { @@ -112,42 +101,36 @@ impl DownloadManager { } } - pub fn queue_game( + pub fn queue_download( &self, - id: String, - version: String, - target_download_dir: usize, + download: DownloadAgent ) -> Result<(), SendError> { - info!("Adding game id {}", id); - self.command_sender.send(DownloadManagerSignal::Queue( - id, - version, - target_download_dir, - ))?; + info!("Adding download id {:?}", download.metadata()); + self.command_sender.send(DownloadManagerSignal::Queue(download))?; self.command_sender.send(DownloadManagerSignal::Go) } - pub fn edit(&self) -> MutexGuard<'_, VecDeque>> { + pub fn edit(&self) -> MutexGuard<'_, VecDeque> { self.download_queue.edit() } - pub fn read_queue(&self) -> VecDeque> { + pub fn read_queue(&self) -> VecDeque { self.download_queue.read() } - pub fn get_current_game_download_progress(&self) -> Option { + pub fn get_current_download_progress(&self) -> Option { let progress_object = (*self.progress.lock().unwrap()).clone()?; Some(progress_object.get_progress()) } - pub fn rearrange_string(&self, id: String, new_index: usize) { + pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) { let mut queue = self.edit(); - let current_index = get_index_from_id(&mut queue, id).unwrap(); + let current_index = get_index_from_id(&mut queue, meta).unwrap(); let to_move = queue.remove(current_index).unwrap(); queue.insert(new_index, to_move); self.command_sender - .send(DownloadManagerSignal::Update) + .send(DownloadManagerSignal::UpdateUIQueue) .unwrap(); } - pub fn cancel(&self, game_id: String) { + pub fn cancel(&self, meta: DownloadableMetadata) { self.command_sender - .send(DownloadManagerSignal::Remove(game_id)) + .send(DownloadManagerSignal::Cancel(meta)) .unwrap(); } pub fn rearrange(&self, current_index: usize, new_index: usize) { @@ -158,7 +141,7 @@ impl DownloadManager { let needs_pause = current_index == 0 || new_index == 0; if needs_pause { self.command_sender - .send(DownloadManagerSignal::Cancel) + .send(DownloadManagerSignal::Stop) .unwrap(); } @@ -174,7 +157,7 @@ impl DownloadManager { self.command_sender.send(DownloadManagerSignal::Go).unwrap(); } self.command_sender - .send(DownloadManagerSignal::Update) + .send(DownloadManagerSignal::UpdateUIQueue) .unwrap(); } pub fn pause_downloads(&self) { @@ -191,15 +174,23 @@ impl DownloadManager { .unwrap(); self.terminator.join() } + pub fn uninstall_application(&self, meta: DownloadableMetadata) { + self.command_sender + .send(DownloadManagerSignal::Uninstall(meta)) + .unwrap(); + } + pub fn get_sender(&self) -> Sender { + self.command_sender.clone() + } } /// Takes in the locked value from .edit() and attempts to -/// get the index of whatever game_id is passed in +/// get the index of whatever id is passed in fn get_index_from_id( - queue: &mut MutexGuard<'_, VecDeque>>, - id: String, + queue: &mut MutexGuard<'_, VecDeque>, + meta: &DownloadableMetadata, ) -> Option { queue .iter() - .position(|download_agent| download_agent.id == id) + .position(|download_agent| download_agent == meta) } diff --git a/src-tauri/src/download_manager/download_manager_builder.rs b/src-tauri/src/download_manager/download_manager_builder.rs new file mode 100644 index 0000000..406a500 --- /dev/null +++ b/src-tauri/src/download_manager/download_manager_builder.rs @@ -0,0 +1,343 @@ +use std::{ + collections::HashMap, + fs::remove_dir_all, + sync::{ + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, RwLockWriteGuard, + }, + thread::{spawn, JoinHandle}, +}; + +use log::{error, info}; +use tauri::{AppHandle, Emitter}; + +use crate::games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}; + +use super::{application_download_error::ApplicationDownloadError, download_manager::{DownloadManager, DownloadManagerSignal, DownloadManagerStatus}, download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, downloadable::Downloadable, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject, queue::Queue}; + +pub type DownloadAgent = Arc>; +pub type CurrentProgressObject = Arc>>>; + +/* + +Welcome to the download manager, the most overengineered, glorious piece of bullshit. + +The download manager takes a queue of ids and their associated +DownloadAgents, and then, one-by-one, executes them. It provides an interface +to interact with the currently downloading agent, and manage the queue. + +When the DownloadManager is initialised, it is designed to provide a reference +which can be used to provide some instructions (the DownloadManagerInterface), +but other than that, it runs without any sort of interruptions. + +It does this by opening up two data structures. Primarily is the command_receiver, +and mpsc (multi-channel-single-producer) which allows commands to be sent from +the Interface, and queued up for the Manager to process. + +These have been mapped in the DownloadManagerSignal docs. + +The other way to interact with the DownloadManager is via the donwload_queue, +which is just a collection of ids which may be rearranged to suit +whichever download queue order is required. + ++----------------------------------------------------------------------------+ +| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! | +| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE | +| WHICH HAS NOT BEEN ACCOUNTED FOR | ++----------------------------------------------------------------------------+ + +This download queue does not actually own any of the DownloadAgents. It is +simply a id-based reference system. The actual Agents are stored in the +download_agent_registry HashMap, as ordering is no issue here. This is why +appending or removing from the download_queue must be done via signals. + +Behold, my madness - quexeky + +*/ + +pub struct DownloadManagerBuilder { + download_agent_registry: HashMap, + download_queue: Queue, + command_receiver: Receiver, + sender: Sender, + progress: CurrentProgressObject, + 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, +} +impl DownloadManagerBuilder { + pub fn build(app_handle: AppHandle) -> DownloadManager { + let queue = Queue::new(); + let (command_sender, command_receiver) = channel(); + let active_progress = Arc::new(Mutex::new(None)); + let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty)); + + let manager = Self { + download_agent_registry: HashMap::new(), + download_queue: queue.clone(), + command_receiver, + status: status.clone(), + sender: command_sender.clone(), + progress: active_progress.clone(), + app_handle, + + current_download_agent: None, + current_download_thread: Mutex::new(None), + active_control_flag: None, + }; + + let terminator = spawn(|| manager.manage_queue()); + + DownloadManager::new(terminator, queue, active_progress, command_sender) + } + + fn set_status(&self, status: DownloadManagerStatus) { + *self.status.lock().unwrap() = status; + } + + fn remove_and_cleanup_front_download(&mut self, meta: &DownloadableMetadata) -> DownloadAgent { + self.download_queue.pop_front(); + let download_agent = self.download_agent_registry.remove(meta).unwrap(); + self.cleanup_current_download(); + download_agent + } + + // CAREFUL WITH THIS FUNCTION + // Make sure the download thread is terminated + 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; + drop(download_thread_lock); + + } + + fn stop_and_wait_current_download(&self) { + self.set_status(DownloadManagerStatus::Paused); + if let Some(current_flag) = &self.active_control_flag { + current_flag.set(DownloadThreadControlFlag::Stop); + } + + let mut download_thread_lock = self.current_download_thread.lock().unwrap(); + if let Some(current_download_thread) = download_thread_lock.take() { + current_download_thread.join().unwrap(); + } + } + + + fn manage_queue(mut self) -> Result<(), ()> { + loop { + let signal = match self.command_receiver.recv() { + Ok(signal) => signal, + Err(_) => return Err(()), + }; + + match signal { + DownloadManagerSignal::Go => { + self.manage_go_signal(); + } + DownloadManagerSignal::Stop => { + self.manage_stop_signal(); + } + DownloadManagerSignal::Completed(meta) => { + self.manage_completed_signal(meta); + } + DownloadManagerSignal::Queue(download_agent) => { + self.manage_queue_signal(download_agent); + } + DownloadManagerSignal::Error(e) => { + self.manage_error_signal(e); + } + DownloadManagerSignal::UpdateUIQueue => { + self.push_ui_queue_update(); + } + DownloadManagerSignal::UpdateUIStats(kbs, time) => { + self.push_ui_stats_update(kbs, time); + } + DownloadManagerSignal::Finish => { + self.stop_and_wait_current_download(); + return Ok(()); + } + DownloadManagerSignal::Cancel(meta) => { + self.manage_cancel_signal(&meta); + } + _ => {} + }; + } + } + fn manage_queue_signal(&mut self, download_agent: DownloadAgent) { + info!("Got signal Queue"); + let meta = download_agent.metadata(); + + info!("Meta: {:?}", meta); + + if self.download_queue.exists(meta.clone()) { + info!("Download with same ID already exists"); + return; + } + + download_agent.on_initialised(&self.app_handle); + self.download_queue.append(meta.clone()); + self.download_agent_registry.insert(meta, download_agent); + + self.sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap(); + } + + fn manage_go_signal(&mut self) { + info!("Got signal Go"); + if self.download_agent_registry.is_empty() { + info!("Download agent registry: {:?}", self.download_agent_registry.len()); + return; + } + + if self.current_download_agent.is_some() { + info!("Current download agent: {:?}", self.current_download_agent.as_ref().unwrap().metadata()); + return; + } + + info!("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 download_agent = self + .download_agent_registry + .get(&agent_data) + .unwrap() + .clone(); + + self.active_control_flag = Some(download_agent.control_flag()); + self.current_download_agent = Some(download_agent.clone()); + + let sender = self.sender.clone(); + + let mut download_thread_lock = self.current_download_thread.lock().unwrap(); + let app_handle = self.app_handle.clone(); + + *download_thread_lock = Some(spawn(move || { + match download_agent.download(&app_handle) { + // Ok(true) is for completed and exited properly + Ok(true) => { + download_agent.on_complete(&app_handle); + sender.send(DownloadManagerSignal::Completed(download_agent.metadata())).unwrap(); + }, + // Ok(false) is for incomplete but exited properly + Ok(false) => { + download_agent.on_incomplete(&app_handle); + }, + Err(e) => { + download_agent.on_error(&app_handle, e.clone()); + sender.send(DownloadManagerSignal::Error(e)).unwrap(); + }, + } + sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap(); + })); + + self.set_status(DownloadManagerStatus::Downloading); + let active_control_flag = self.active_control_flag.clone().unwrap(); + active_control_flag.set(DownloadThreadControlFlag::Go); + } + fn manage_stop_signal(&mut self) { + info!("Got signal Stop"); + + if let Some(active_control_flag) = self.active_control_flag.clone() { + self.set_status(DownloadManagerStatus::Paused); + active_control_flag.set(DownloadThreadControlFlag::Stop); + } + } + fn manage_completed_signal(&mut self, meta: DownloadableMetadata) { + info!("Got signal Completed"); + if let Some(interface) = &self.current_download_agent { + if interface.metadata() == meta { + info!("Popping consumed data"); + self.remove_and_cleanup_front_download(&meta); + } + } + self.push_ui_queue_update(); + self.sender.send(DownloadManagerSignal::Go).unwrap(); + } + fn manage_error_signal(&mut self, error: ApplicationDownloadError) { + info!("Got signal Error"); + let current_agent = self.current_download_agent.clone().unwrap(); + + current_agent.on_error(&self.app_handle, error.clone()); + + self.stop_and_wait_current_download(); + self.remove_and_cleanup_front_download(¤t_agent.metadata()); + + self.set_status(DownloadManagerStatus::Error(error)); + } + fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) { + info!("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(); + + self.download_queue.pop_front(); + + self.cleanup_current_download(); + info!("Current donwload 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) { + info!("Object exists in registry"); + 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); + info!("Removed {:?} from queue {:?}", removed.and_then(|x| Some(x.metadata())), self.download_queue.read()); + } + } + } + } + else { + if let Some(download_agent) = self.download_agent_registry.get(meta) { + info!("Object exists in registry"); + 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); + info!("Removed {:?} from queue {:?}", removed.and_then(|x| Some(x.metadata())), self.download_queue.read()); + } + } + } + self.push_ui_queue_update(); + } + fn push_ui_stats_update(&self, kbs: usize, time: usize) { + let event_data = StatsUpdateEvent { speed: kbs, time }; + + self.app_handle.emit("update_stats", event_data).unwrap(); + } + fn push_ui_queue_update(&self) { + let queue = &self.download_queue.read(); + let queue_objs = queue + .iter() + .map(|(key)| { + let val = self.download_agent_registry.get(key).unwrap(); + QueueUpdateEventQueueData { + meta: DownloadableMetadata::clone(&key), + status: val.status(), + progress: val.progress().get_progress() + }}) + .collect(); + + let event_data = QueueUpdateEvent { + queue: queue_objs, + }; + self.app_handle.emit("update_queue", event_data).unwrap(); + } +} \ No newline at end of file diff --git a/src-tauri/src/downloads/download_thread_control_flag.rs b/src-tauri/src/download_manager/download_thread_control_flag.rs similarity index 100% rename from src-tauri/src/downloads/download_thread_control_flag.rs rename to src-tauri/src/download_manager/download_thread_control_flag.rs diff --git a/src-tauri/src/download_manager/downloadable.rs b/src-tauri/src/download_manager/downloadable.rs new file mode 100644 index 0000000..e917882 --- /dev/null +++ b/src-tauri/src/download_manager/downloadable.rs @@ -0,0 +1,20 @@ +use std::{fmt::{self, Debug}, sync::{mpsc::Sender, Arc}}; + +use tauri::AppHandle; + +use super::{ + application_download_error::ApplicationDownloadError, download_manager::{DownloadManagerSignal, DownloadStatus}, download_thread_control_flag::DownloadThreadControl, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject +}; + +pub trait Downloadable: Send + Sync { + fn download(&self, app_handle: &AppHandle) -> Result; + fn progress(&self) -> Arc; + fn control_flag(&self) -> DownloadThreadControl; + fn status(&self) -> DownloadStatus; + fn metadata(&self) -> DownloadableMetadata; + fn on_initialised(&self, app_handle: &AppHandle); + fn on_error(&self, app_handle: &AppHandle, error: ApplicationDownloadError); + fn on_complete(&self, app_handle: &AppHandle); + fn on_incomplete(&self, app_handle: &AppHandle); + fn on_cancelled(&self, app_handle: &AppHandle); +} \ No newline at end of file diff --git a/src-tauri/src/download_manager/downloadable_metadata.rs b/src-tauri/src/download_manager/downloadable_metadata.rs new file mode 100644 index 0000000..7512af4 --- /dev/null +++ b/src-tauri/src/download_manager/downloadable_metadata.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone, Copy)] +pub enum DownloadType { + Game, + Tool, + DLC, + Mod +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DownloadableMetadata { + pub id: String, + pub version: Option, + pub download_type: DownloadType +} +impl DownloadableMetadata { + pub fn new(id: String, version: Option, download_type: DownloadType) -> Self { + Self { + id, + version, + download_type + } + } +} \ No newline at end of file diff --git a/src-tauri/src/download_manager/generate_downloadable.rs b/src-tauri/src/download_manager/generate_downloadable.rs new file mode 100644 index 0000000..8f53185 --- /dev/null +++ b/src-tauri/src/download_manager/generate_downloadable.rs @@ -0,0 +1,7 @@ +use std::sync::Arc; + +use super::{download_manager_builder::DownloadAgent, downloadable_metadata::DownloadableMetadata}; + +pub fn generate_downloadable(meta: DownloadableMetadata) -> DownloadAgent { + todo!() +} \ No newline at end of file diff --git a/src-tauri/src/download_manager/mod.rs b/src-tauri/src/download_manager/mod.rs new file mode 100644 index 0000000..6299068 --- /dev/null +++ b/src-tauri/src/download_manager/mod.rs @@ -0,0 +1,9 @@ +pub mod download_manager; +pub mod download_manager_builder; +pub mod progress_object; +pub mod queue; +pub mod download_thread_control_flag; +pub mod downloadable; +pub mod application_download_error; +pub mod downloadable_metadata; +pub mod generate_downloadable; \ No newline at end of file diff --git a/src-tauri/src/downloads/progress_object.rs b/src-tauri/src/download_manager/progress_object.rs similarity index 58% rename from src-tauri/src/downloads/progress_object.rs rename to src-tauri/src/download_manager/progress_object.rs index 143656d..a3c3c24 100644 --- a/src-tauri/src/downloads/progress_object.rs +++ b/src-tauri/src/download_manager/progress_object.rs @@ -2,7 +2,7 @@ use std::{ sync::{ atomic::{AtomicUsize, Ordering}, mpsc::Sender, - Arc, Mutex, + Arc, Mutex, RwLock, }, time::Instant, }; @@ -19,7 +19,9 @@ pub struct ProgressObject { sender: Sender, points_towards_update: Arc, - points_to_push_update: Arc>, + points_to_push_update: Arc, + last_update: Arc>, + amount_last_update: Arc, } pub struct ProgressHandle { @@ -58,7 +60,9 @@ impl ProgressObject { sender, points_towards_update: Arc::new(AtomicUsize::new(0)), - points_to_push_update: Arc::new(Mutex::new(points_to_push_update)), + points_to_push_update: Arc::new(AtomicUsize::new(points_to_push_update)), + last_update: Arc::new(RwLock::new(Instant::now())), + amount_last_update: Arc::new(AtomicUsize::new(0)), } } @@ -67,16 +71,44 @@ impl ProgressObject { .points_towards_update .fetch_add(amount_added, Ordering::Relaxed); - let to_update_handle = self.points_to_push_update.lock().unwrap(); - let to_update = *to_update_handle; - drop(to_update_handle); + let to_update = self.points_to_push_update.fetch_add(0, Ordering::Relaxed); - if current_amount < to_update { - return; + if current_amount >= to_update { + self.points_towards_update + .fetch_sub(to_update, Ordering::Relaxed); + self.sender + .send(DownloadManagerSignal::UpdateUIQueue) + .unwrap(); + } + + let last_update = self.last_update.read().unwrap(); + let last_update_difference = Instant::now().duration_since(*last_update).as_millis(); + if last_update_difference > 1000 { + // push update + drop(last_update); + let mut last_update = self.last_update.write().unwrap(); + *last_update = Instant::now(); + drop(last_update); + + let current_amount = self.sum(); + let max = self.get_max(); + let amount_at_last_update = self.amount_last_update.fetch_add(0, Ordering::Relaxed); + self.amount_last_update + .store(current_amount, Ordering::Relaxed); + + let amount_since_last_update = current_amount - amount_at_last_update; + + let kilobytes_per_second = amount_since_last_update / (last_update_difference as usize).max(1); + + let remaining = max - current_amount; // bytes + let time_remaining = (remaining / 1000) / kilobytes_per_second.max(1); + self.sender + .send(DownloadManagerSignal::UpdateUIStats( + kilobytes_per_second, + time_remaining, + )) + .unwrap(); } - self.points_towards_update - .fetch_sub(to_update, Ordering::Relaxed); - self.sender.send(DownloadManagerSignal::Update).unwrap(); } pub fn set_time_now(&self) { @@ -95,7 +127,8 @@ impl ProgressObject { } pub fn set_max(&self, new_max: usize) { *self.max.lock().unwrap() = new_max; - *self.points_to_push_update.lock().unwrap() = new_max / PROGRESS_UPDATES; + self.points_to_push_update + .store(new_max / PROGRESS_UPDATES, Ordering::Relaxed); info!("points to push update: {}", new_max / PROGRESS_UPDATES); } pub fn set_size(&self, length: usize) { diff --git a/src-tauri/src/downloads/queue.rs b/src-tauri/src/download_manager/queue.rs similarity index 56% rename from src-tauri/src/downloads/queue.rs rename to src-tauri/src/download_manager/queue.rs index 0ea65ca..cda08df 100644 --- a/src-tauri/src/downloads/queue.rs +++ b/src-tauri/src/download_manager/queue.rs @@ -3,11 +3,11 @@ use std::{ sync::{Arc, Mutex, MutexGuard}, }; -use super::download_manager::GameDownloadAgentQueueStandin; +use super::downloadable_metadata::DownloadableMetadata; #[derive(Clone)] pub struct Queue { - inner: Arc>>>, + inner: Arc>>, } #[allow(dead_code)] @@ -17,49 +17,52 @@ impl Queue { inner: Arc::new(Mutex::new(VecDeque::new())), } } - pub fn read(&self) -> VecDeque> { + pub fn read(&self) -> VecDeque { self.inner.lock().unwrap().clone() } - pub fn edit(&self) -> MutexGuard<'_, VecDeque>> { + pub fn edit(&self) -> MutexGuard<'_, VecDeque> { self.inner.lock().unwrap() } - pub fn pop_front(&self) -> Option> { + pub fn pop_front(&self) -> Option { self.edit().pop_front() } pub fn empty(&self) -> bool { self.inner.lock().unwrap().len() == 0 } + pub fn exists(&self, meta: DownloadableMetadata) -> bool { + self.read().contains(&meta) + } /// Either inserts `interface` at the specified index, or appends to /// the back of the deque if index is greater than the length of the deque - pub fn insert(&self, interface: GameDownloadAgentQueueStandin, index: usize) { + pub fn insert(&self, interface: DownloadableMetadata, index: usize) { if self.read().len() > index { self.append(interface); } else { - self.edit().insert(index, Arc::new(interface)); + self.edit().insert(index, interface); } } - pub fn append(&self, interface: GameDownloadAgentQueueStandin) { - self.edit().push_back(Arc::new(interface)); + pub fn append(&self, interface: DownloadableMetadata) { + self.edit().push_back(interface); } pub fn pop_front_if_equal( &self, - game_id: String, - ) -> Option> { + meta: &DownloadableMetadata, + ) -> Option { let mut queue = self.edit(); let front = match queue.front() { Some(front) => front, None => return None, }; - if front.id == game_id { + if front == meta { return queue.pop_front(); } None } - pub fn get_by_id(&self, game_id: String) -> Option { - self.read().iter().position(|data| data.id == game_id) + pub fn get_by_meta(&self, meta: &DownloadableMetadata) -> Option { + self.read().iter().position(|data| data == meta) } - pub fn move_to_index_by_id(&self, game_id: String, new_index: usize) -> Result<(), ()> { - let index = match self.get_by_id(game_id) { + pub fn move_to_index_by_meta(&self, meta: &DownloadableMetadata, new_index: usize) -> Result<(), ()> { + let index = match self.get_by_meta(meta) { Some(index) => index, None => return Err(()), }; diff --git a/src-tauri/src/downloads/download_agent.rs b/src-tauri/src/downloads/download_agent.rs deleted file mode 100644 index 99d8217..0000000 --- a/src-tauri/src/downloads/download_agent.rs +++ /dev/null @@ -1,335 +0,0 @@ -use crate::auth::generate_authorization_header; -use crate::db::DatabaseImpls; -use crate::downloads::manifest::{DropDownloadContext, DropManifest}; -use crate::downloads::progress_object::ProgressHandle; -use crate::remote::RemoteAccessError; -use crate::DB; -use core::time; -use log::{debug, error, info}; -use rayon::ThreadPoolBuilder; -use serde::ser::{Error, SerializeMap}; -use serde::{Deserialize, Serialize}; -use std::fmt::{Display, Formatter}; -use std::fs::{create_dir_all, File}; -use std::io; -use std::path::Path; -use std::sync::mpsc::Sender; -use std::sync::{Arc, Mutex}; -use std::time::Instant; -use urlencoding::encode; - -#[cfg(target_os = "linux")] -use rustix::fs::{fallocate, FallocateFlags}; - -use super::download_logic::download_game_chunk; -use super::download_manager::DownloadManagerSignal; -use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}; -use super::progress_object::ProgressObject; -use super::stored_manifest::StoredManifest; - -pub struct GameDownloadAgent { - pub id: String, - pub version: String, - pub control_flag: DownloadThreadControl, - contexts: Vec, - completed_contexts: Mutex>, - pub manifest: Mutex>, - pub progress: Arc, - sender: Sender, - pub stored_manifest: StoredManifest, -} - -#[derive(Debug)] -pub enum GameDownloadError { - Communication(RemoteAccessError), - Checksum, - Setup(SetupError), - Lock, - IoError(io::Error), - DownloadError, -} - -#[derive(Debug)] -pub enum SetupError { - Context, -} - -impl Display for GameDownloadError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - GameDownloadError::Communication(error) => write!(f, "{}", error), - GameDownloadError::Setup(error) => write!(f, "An error occurred while setting up the download: {}", error), - GameDownloadError::Lock => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"), - GameDownloadError::Checksum => write!(f, "Checksum failed to validate for download"), - GameDownloadError::IoError(error) => write!(f, "{}", error), - GameDownloadError::DownloadError => write!(f, "Download failed. See Download Manager status for specific error"), - } - } -} - -impl Display for SetupError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - SetupError::Context => write!(f, "Failed to generate contexts for download"), - } - } -} - -impl GameDownloadAgent { - pub fn new( - id: String, - version: String, - target_download_dir: usize, - sender: Sender, - ) -> Self { - // Don't run by default - let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop); - - let db_lock = DB.borrow_data().unwrap(); - let base_dir = db_lock.games.install_dirs[target_download_dir].clone(); - drop(db_lock); - - let base_dir_path = Path::new(&base_dir); - let data_base_dir_path = base_dir_path.join(id.clone()); - - let stored_manifest = - StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone()); - - Self { - id, - version, - control_flag, - manifest: Mutex::new(None), - contexts: Vec::new(), - completed_contexts: Mutex::new(Vec::new()), - progress: Arc::new(ProgressObject::new(0, 0, sender.clone())), - sender, - stored_manifest, - } - } - - // Blocking - pub fn setup_download(&mut self) -> Result<(), GameDownloadError> { - self.ensure_manifest_exists()?; - info!("Ensured manifest exists"); - - self.ensure_contexts()?; - info!("Ensured contexts exists"); - - self.control_flag.set(DownloadThreadControlFlag::Go); - - Ok(()) - } - - // Blocking - pub fn download(&mut self) -> Result<(), GameDownloadError> { - self.setup_download()?; - self.set_progress_object_params(); - let timer = Instant::now(); - self.run().map_err(|_| GameDownloadError::DownloadError)?; - - info!( - "{} took {}ms to download", - self.id, - timer.elapsed().as_millis() - ); - Ok(()) - } - - pub fn ensure_manifest_exists(&self) -> Result<(), GameDownloadError> { - if self.manifest.lock().unwrap().is_some() { - return Ok(()); - } - - self.download_manifest() - } - - fn download_manifest(&self) -> Result<(), GameDownloadError> { - let base_url = DB.fetch_base_url(); - let manifest_url = base_url - .join( - format!( - "/api/v1/client/metadata/manifest?id={}&version={}", - self.id, - encode(&self.version) - ) - .as_str(), - ) - .unwrap(); - - let header = generate_authorization_header(); - let client = reqwest::blocking::Client::new(); - let response = client - .get(manifest_url.to_string()) - .header("Authorization", header) - .send() - .unwrap(); - - if response.status() != 200 { - return Err(GameDownloadError::Communication( - RemoteAccessError::ManifestDownloadFailed( - response.status(), - response.text().unwrap(), - ), - )); - } - - let manifest_download = response.json::().unwrap(); - - if let Ok(mut manifest) = self.manifest.lock() { - *manifest = Some(manifest_download); - return Ok(()); - } - - Err(GameDownloadError::Lock) - } - - fn set_progress_object_params(&self) { - // Avoid re-setting it - if self.progress.get_max() != 0 { - return; - } - - let length = self.contexts.len(); - - let chunk_count = self.contexts.iter().map(|chunk| chunk.length).sum(); - - debug!("Setting ProgressObject max to {}", chunk_count); - self.progress.set_max(chunk_count); - debug!("Setting ProgressObject size to {}", length); - self.progress.set_size(length); - debug!("Setting ProgressObject time to now"); - self.progress.set_time_now(); - } - - pub fn ensure_contexts(&mut self) -> Result<(), GameDownloadError> { - if !self.contexts.is_empty() { - return Ok(()); - } - - self.generate_contexts()?; - Ok(()) - } - - pub fn generate_contexts(&mut self) -> Result<(), GameDownloadError> { - let manifest = self.manifest.lock().unwrap().clone().unwrap(); - let game_id = self.id.clone(); - - let mut contexts = Vec::new(); - let base_path = Path::new(&self.stored_manifest.base_path); - create_dir_all(base_path).unwrap(); - - *self.completed_contexts.lock().unwrap() = self.stored_manifest.get_completed_contexts(); - - info!( - "Completed contexts: {:?}", - *self.completed_contexts.lock().unwrap() - ); - - for (raw_path, chunk) in manifest { - let path = base_path.join(Path::new(&raw_path)); - - let container = path.parent().unwrap(); - create_dir_all(container).unwrap(); - - let file = File::create(path.clone()).unwrap(); - let mut running_offset = 0; - - for (index, length) in chunk.lengths.iter().enumerate() { - contexts.push(DropDownloadContext { - file_name: raw_path.to_string(), - version: chunk.version_name.to_string(), - offset: running_offset, - index, - game_id: game_id.to_string(), - path: path.clone(), - checksum: chunk.checksums[index].clone(), - length: *length, - permissions: chunk.permissions, - }); - running_offset += *length as u64; - } - - #[cfg(target_os = "linux")] - if running_offset > 0 { - let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset); - } - } - self.contexts = contexts; - - Ok(()) - } - - pub fn run(&self) -> Result<(), ()> { - info!("downloading game: {}", self.id); - const DOWNLOAD_MAX_THREADS: usize = 1; - - let pool = ThreadPoolBuilder::new() - .num_threads(DOWNLOAD_MAX_THREADS) - .build() - .unwrap(); - - let completed_indexes = Arc::new(Mutex::new(Vec::new())); - let completed_indexes_loop_arc = completed_indexes.clone(); - - pool.scope(move |scope| { - let completed_lock = self.completed_contexts.lock().unwrap(); - - for (index, context) in self.contexts.iter().enumerate() { - let progress = self.progress.get(index); // Clone arcs - let progress_handle = ProgressHandle::new(progress, self.progress.clone()); - // If we've done this one already, skip it - if completed_lock.contains(&index) { - progress_handle.add(context.length); - continue; - } - - let context = context.clone(); - let control_flag = self.control_flag.clone(); // Clone arcs - let completed_indexes_ref = completed_indexes_loop_arc.clone(); - - scope.spawn(move |_| { - match download_game_chunk(context.clone(), control_flag, progress_handle) { - Ok(res) => { - if res { - let mut lock = completed_indexes_ref.lock().unwrap(); - lock.push(index); - } - } - Err(e) => { - error!("GameDownloadError: {}", e); - self.sender.send(DownloadManagerSignal::Error(e)).unwrap(); - } - } - }); - } - }); - - let completed_lock_len = { - let mut completed_lock = self.completed_contexts.lock().unwrap(); - let newly_completed_lock = completed_indexes.lock().unwrap(); - - completed_lock.extend(newly_completed_lock.iter()); - - completed_lock.len() - }; - - // If we're not out of contexts, we're not done, so we don't fire completed - if completed_lock_len != self.contexts.len() { - info!("da for {} exited without completing", self.id.clone()); - self.stored_manifest - .set_completed_contexts(&self.completed_contexts); - info!("Setting completed contexts"); - self.stored_manifest.write(); - info!("Wrote completed contexts"); - return Ok(()); - } - - // We've completed - self.sender - .send(DownloadManagerSignal::Completed(self.id.clone())) - .unwrap(); - - Ok(()) - } -} diff --git a/src-tauri/src/downloads/download_manager_builder.rs b/src-tauri/src/downloads/download_manager_builder.rs deleted file mode 100644 index 9d2ab9d..0000000 --- a/src-tauri/src/downloads/download_manager_builder.rs +++ /dev/null @@ -1,417 +0,0 @@ -use std::{ - collections::HashMap, - sync::{ - mpsc::{channel, Receiver, Sender}, - Arc, Mutex, RwLockWriteGuard, - }, - thread::{spawn, JoinHandle}, -}; - -use log::{error, info}; -use tauri::{AppHandle, Emitter}; - -use crate::{ - db::{Database, GameStatus, GameTransientStatus}, - library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData}, - state::GameStatusManager, - DB, -}; - -use super::{ - download_agent::{GameDownloadAgent, GameDownloadError}, - download_manager::{ - DownloadManager, DownloadManagerSignal, DownloadManagerStatus, - GameDownloadAgentQueueStandin, GameDownloadStatus, - }, - download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, - progress_object::ProgressObject, - queue::Queue, -}; - -/* - -Welcome to the download manager, the most overengineered, glorious piece of bullshit. - -The download manager takes a queue of game_ids and their associated -GameDownloadAgents, and then, one-by-one, executes them. It provides an interface -to interact with the currently downloading agent, and manage the queue. - -When the DownloadManager is initialised, it is designed to provide a reference -which can be used to provide some instructions (the DownloadManagerInterface), -but other than that, it runs without any sort of interruptions. - -It does this by opening up two data structures. Primarily is the command_receiver, -and mpsc (multi-channel-single-producer) which allows commands to be sent from -the Interface, and queued up for the Manager to process. - -These have been mapped in the DownloadManagerSignal docs. - -The other way to interact with the DownloadManager is via the donwload_queue, -which is just a collection of ids which may be rearranged to suit -whichever download queue order is required. - -+----------------------------------------------------------------------------+ -| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! | -| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE | -| WHICH HAS NOT BEEN ACCOUNTED FOR | -+----------------------------------------------------------------------------+ - -This download queue does not actually own any of the GameDownloadAgents. It is -simply a id-based reference system. The actual Agents are stored in the -download_agent_registry HashMap, as ordering is no issue here. This is why -appending or removing from the download_queue must be done via signals. - -Behold, my madness - quexeky - -*/ - -// Refactored to consolidate this type. It's a monster. -pub type CurrentProgressObject = Arc>>>; - -pub struct DownloadManagerBuilder { - download_agent_registry: HashMap>>, - download_queue: Queue, - command_receiver: Receiver, - sender: Sender, - progress: CurrentProgressObject, - status: Arc>, - app_handle: AppHandle, - - current_download_agent: Option>, // Should be the only game download agent in the map with the "Go" flag - current_download_thread: Mutex>>, - active_control_flag: Option, -} - -impl DownloadManagerBuilder { - pub fn build(app_handle: AppHandle) -> DownloadManager { - let queue = Queue::new(); - let (command_sender, command_receiver) = channel(); - let active_progress = Arc::new(Mutex::new(None)); - let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty)); - - let manager = Self { - download_agent_registry: HashMap::new(), - download_queue: queue.clone(), - command_receiver, - status: status.clone(), - sender: command_sender.clone(), - progress: active_progress.clone(), - app_handle, - - current_download_agent: None, - current_download_thread: Mutex::new(None), - active_control_flag: None, - }; - - let terminator = spawn(|| manager.manage_queue()); - - DownloadManager::new(terminator, queue, active_progress, command_sender) - } - - fn set_game_status, &String) -> ()>( - &self, - id: String, - setter: F, - ) { - let mut db_handle = DB.borrow_data_mut().unwrap(); - setter(&mut db_handle, &id); - drop(db_handle); - DB.save().unwrap(); - - let status = GameStatusManager::fetch_state(&id); - - self.app_handle - .emit( - &format!("update_game/{}", id), - GameUpdateEvent { - game_id: id, - status, - }, - ) - .unwrap(); - } - - fn push_manager_update(&self) { - let queue = self.download_queue.read(); - let queue_objs: Vec = queue - .iter() - .map(|interface| QueueUpdateEventQueueData { - id: interface.id.clone(), - status: interface.status.lock().unwrap().clone(), - progress: interface.progress.get_progress(), - }) - .collect(); - - let event_data = QueueUpdateEvent { queue: queue_objs }; - self.app_handle.emit("update_queue", event_data).unwrap(); - } - - fn stop_and_wait_current_download(&self) { - self.set_status(DownloadManagerStatus::Paused); - if let Some(current_flag) = &self.active_control_flag { - current_flag.set(DownloadThreadControlFlag::Stop); - } - - let mut download_thread_lock = self.current_download_thread.lock().unwrap(); - if let Some(current_download_thread) = download_thread_lock.take() { - current_download_thread.join().unwrap(); - } - drop(download_thread_lock); - } - - fn sync_download_agent(&self) {} - - fn remove_and_cleanup_game(&mut self, game_id: &String) -> Arc> { - self.download_queue.pop_front(); - let download_agent = self.download_agent_registry.remove(game_id).unwrap(); - self.cleanup_current_download(); - download_agent - } - - // CAREFUL WITH THIS FUNCTION - // Make sure the download thread is terminated - 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; - drop(download_thread_lock); - } - - fn manage_queue(mut self) -> Result<(), ()> { - loop { - let signal = match self.command_receiver.recv() { - Ok(signal) => signal, - Err(_) => return Err(()), - }; - - match signal { - DownloadManagerSignal::Go => { - self.manage_go_signal(); - } - DownloadManagerSignal::Stop => { - self.manage_stop_signal(); - } - DownloadManagerSignal::Completed(game_id) => { - self.manage_completed_signal(game_id); - } - DownloadManagerSignal::Queue(game_id, version, target_download_dir) => { - self.manage_queue_signal(game_id, version, target_download_dir); - } - DownloadManagerSignal::Error(e) => { - self.manage_error_signal(e); - } - DownloadManagerSignal::Cancel => { - self.manage_cancel_signal(); - } - DownloadManagerSignal::Update => { - self.push_manager_update(); - } - DownloadManagerSignal::Finish => { - self.stop_and_wait_current_download(); - return Ok(()); - } - DownloadManagerSignal::Remove(game_id) => { - self.manage_remove_game(game_id); - } - }; - } - } - - fn manage_remove_game(&mut self, game_id: String) { - if let Some(current_download) = &self.current_download_agent { - if current_download.id == game_id { - self.manage_cancel_signal(); - } - } - - let index = self.download_queue.get_by_id(game_id.clone()).unwrap(); - let mut queue_handle = self.download_queue.edit(); - queue_handle.remove(index); - self.set_game_status(game_id, |db_handle, id| { - db_handle.games.transient_statuses.remove(id); - }); - drop(queue_handle); - - if self.current_download_agent.is_none() { - self.manage_go_signal(); - } - - self.push_manager_update(); - } - - fn manage_stop_signal(&mut self) { - info!("Got signal 'Stop'"); - self.set_status(DownloadManagerStatus::Paused); - if let Some(active_control_flag) = self.active_control_flag.clone() { - active_control_flag.set(DownloadThreadControlFlag::Stop); - } - } - - fn manage_completed_signal(&mut self, game_id: String) { - info!("Got signal 'Completed'"); - if let Some(interface) = &self.current_download_agent { - // When if let chains are stabilised, combine these two statements - if interface.id == game_id { - info!("Popping consumed data"); - let download_agent = self.remove_and_cleanup_game(&game_id); - let download_agent_lock = download_agent.lock().unwrap(); - - let version = download_agent_lock.version.clone(); - let install_dir = download_agent_lock.stored_manifest.base_path.clone().to_string_lossy().to_string(); - - drop(download_agent_lock); - - if let Err(error) = - on_game_complete(game_id, version, install_dir, &self.app_handle) - { - self.sender - .send(DownloadManagerSignal::Error( - GameDownloadError::Communication(error), - )) - .unwrap(); - } - } - } - self.sender.send(DownloadManagerSignal::Update).unwrap(); - self.sender.send(DownloadManagerSignal::Go).unwrap(); - } - - fn manage_queue_signal(&mut self, id: String, version: String, target_download_dir: usize) { - info!("Got signal Queue"); - let download_agent = Arc::new(Mutex::new(GameDownloadAgent::new( - id.clone(), - version, - target_download_dir, - self.sender.clone(), - ))); - let download_agent_lock = download_agent.lock().unwrap(); - - let agent_status = GameDownloadStatus::Queued; - let interface_data = GameDownloadAgentQueueStandin { - id: id.clone(), - status: Mutex::new(agent_status), - progress: download_agent_lock.progress.clone(), - }; - let version_name = download_agent_lock.version.clone(); - - drop(download_agent_lock); - - self.download_agent_registry - .insert(interface_data.id.clone(), download_agent); - self.download_queue.append(interface_data); - - self.set_game_status(id, |db, id| { - db.games.transient_statuses.insert( - id.to_string(), - GameTransientStatus::Downloading { version_name }, - ); - }); - self.sender.send(DownloadManagerSignal::Update).unwrap(); - } - - fn manage_go_signal(&mut self) { - if !(!self.download_agent_registry.is_empty() && !self.download_queue.empty()) { - return; - } - - if self.current_download_agent.is_some() { - info!("skipping go signal due to existing download job"); - return; - } - - info!("current download queue: {:?}", self.download_queue.read()); - let agent_data = self.download_queue.read().front().unwrap().clone(); - info!("starting download for {}", agent_data.id.clone()); - let download_agent = self - .download_agent_registry - .get(&agent_data.id) - .unwrap() - .clone(); - let download_agent_lock = download_agent.lock().unwrap(); - self.current_download_agent = Some(agent_data); - // Cloning option should be okay because it only clones the Arc inside, not the AgentInterfaceData - let agent_data = self.current_download_agent.clone().unwrap(); - - let version_name = download_agent_lock.version.clone(); - - let progress_object = download_agent_lock.progress.clone(); - *self.progress.lock().unwrap() = Some(progress_object); - - let active_control_flag = download_agent_lock.control_flag.clone(); - self.active_control_flag = Some(active_control_flag.clone()); - - let sender = self.sender.clone(); - - drop(download_agent_lock); - - info!("Spawning download"); - let mut download_thread_lock = self.current_download_thread.lock().unwrap(); - *download_thread_lock = Some(spawn(move || { - let mut download_agent_lock = download_agent.lock().unwrap(); - match download_agent_lock.download() { - // Returns once we've exited the download - // (not necessarily completed) - // The download agent will fire the completed event for us - Ok(_) => {} - // If an error occurred while *starting* the download - Err(err) => { - error!("error while managing download: {}", err); - sender.send(DownloadManagerSignal::Error(err)).unwrap(); - } - }; - drop(download_agent_lock); - })); - - // Set status for games - for queue_game in self.download_queue.read() { - let mut status_handle = queue_game.status.lock().unwrap(); - if queue_game.id == agent_data.id { - *status_handle = GameDownloadStatus::Downloading; - } else { - *status_handle = GameDownloadStatus::Queued; - } - drop(status_handle); - } - - // Set flags for download manager - active_control_flag.set(DownloadThreadControlFlag::Go); - self.set_status(DownloadManagerStatus::Downloading); - self.set_game_status(agent_data.id.clone(), |db, id| { - db.games.transient_statuses.insert( - id.to_string(), - GameTransientStatus::Downloading { version_name }, - ); - }); - - self.sender.send(DownloadManagerSignal::Update).unwrap(); - } - fn manage_error_signal(&mut self, error: GameDownloadError) { - let current_status = self.current_download_agent.clone().unwrap(); - - self.remove_and_cleanup_game(¤t_status.id); // Remove all the locks and shit - - let mut lock = current_status.status.lock().unwrap(); - *lock = GameDownloadStatus::Error; - self.set_status(DownloadManagerStatus::Error(error)); - - let game_id = current_status.id.clone(); - self.set_game_status(game_id, |db_handle, id| { - db_handle.games.transient_statuses.remove(id); - }); - - self.sender.send(DownloadManagerSignal::Update).unwrap(); - } - fn manage_cancel_signal(&mut self) { - self.stop_and_wait_current_download(); - - info!("cancel waited for download to finish"); - - self.cleanup_current_download(); - } - fn set_status(&self, status: DownloadManagerStatus) { - *self.status.lock().unwrap() = status; - } -} diff --git a/src-tauri/src/downloads/mod.rs b/src-tauri/src/downloads/mod.rs deleted file mode 100644 index 023b2c7..0000000 --- a/src-tauri/src/downloads/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod download_agent; -pub mod download_commands; -mod download_logic; -pub mod download_manager; -pub mod download_manager_builder; -mod download_thread_control_flag; -mod manifest; -mod progress_object; -pub mod queue; -mod stored_manifest; \ No newline at end of file diff --git a/src-tauri/src/games/downloads/download_agent.rs b/src-tauri/src/games/downloads/download_agent.rs new file mode 100644 index 0000000..5a92380 --- /dev/null +++ b/src-tauri/src/games/downloads/download_agent.rs @@ -0,0 +1,404 @@ +use crate::auth::generate_authorization_header; +use crate::db::{set_game_status, GameDownloadStatus, ApplicationTransientStatus, DatabaseImpls}; +use crate::download_manager::application_download_error::ApplicationDownloadError; +use crate::download_manager::download_manager::{DownloadManagerSignal, DownloadStatus}; +use crate::download_manager::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}; +use crate::download_manager::downloadable::Downloadable; +use crate::download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}; +use crate::download_manager::progress_object::{ProgressHandle, ProgressObject}; +use crate::games::downloads::manifest::{DropDownloadContext, DropManifest}; +use crate::games::library::{on_game_complete, push_game_update}; +use crate::remote::RemoteAccessError; +use crate::DB; +use log::{debug, error, info}; +use rayon::ThreadPoolBuilder; +use tauri::{AppHandle, Emitter}; +use std::collections::VecDeque; +use std::fs::{create_dir_all, remove_dir_all, File}; +use std::path::Path; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::thread::spawn; +use std::time::Instant; +use urlencoding::encode; + +#[cfg(target_os = "linux")] +use rustix::fs::{fallocate, FallocateFlags}; + +use super::download_logic::download_game_chunk; +use super::stored_manifest::StoredManifest; + +pub struct GameDownloadAgent { + pub id: String, + pub version: String, + pub control_flag: DownloadThreadControl, + contexts: Mutex>, + completed_contexts: Mutex>, + pub manifest: Mutex>, + pub progress: Arc, + sender: Sender, + pub stored_manifest: StoredManifest, + status: Mutex +} + +impl GameDownloadAgent { + pub fn new( + id: String, + version: String, + target_download_dir: usize, + sender: Sender, + ) -> Self { + // Don't run by default + let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop); + + let db_lock = DB.borrow_data().unwrap(); + let base_dir = db_lock.applications.install_dirs[target_download_dir].clone(); + drop(db_lock); + + let base_dir_path = Path::new(&base_dir); + let data_base_dir_path = base_dir_path.join(id.clone()); + + let stored_manifest = + StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone()); + + Self { + id, + version, + control_flag, + manifest: Mutex::new(None), + contexts: Mutex::new(Vec::new()), + completed_contexts: Mutex::new(VecDeque::new()), + progress: Arc::new(ProgressObject::new(0, 0, sender.clone())), + sender, + stored_manifest, + status: Mutex::new(DownloadStatus::Queued), + } + } + + // Blocking + pub fn setup_download(&self) -> Result<(), ApplicationDownloadError> { + self.ensure_manifest_exists()?; + info!("Ensured manifest exists"); + + self.ensure_contexts()?; + info!("Ensured contexts exists"); + + self.control_flag.set(DownloadThreadControlFlag::Go); + + Ok(()) + } + + // Blocking + pub fn download(&self, app_handle: &AppHandle) -> Result { + info!("Setting up download"); + self.setup_download()?; + info!("Setting progress object params"); + self.set_progress_object_params(); + info!("Running"); + let timer = Instant::now(); + push_game_update(app_handle, &self.metadata(), (None, Some(ApplicationTransientStatus::Downloading { version_name: self.version.clone() }))); + let res = self.run().map_err(|_| ApplicationDownloadError::DownloadError); + + info!( + "{} took {}ms to download", + self.id, + timer.elapsed().as_millis() + ); + res + } + + pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> { + if self.manifest.lock().unwrap().is_some() { + return Ok(()); + } + + self.download_manifest() + } + + fn download_manifest(&self) -> Result<(), ApplicationDownloadError> { + let base_url = DB.fetch_base_url(); + let manifest_url = base_url + .join( + format!( + "/api/v1/client/metadata/manifest?id={}&version={}", + self.id, + encode(&self.version) + ) + .as_str(), + ) + .unwrap(); + + let header = generate_authorization_header(); + let client = reqwest::blocking::Client::new(); + let response = client + .get(manifest_url.to_string()) + .header("Authorization", header) + .send() + .unwrap(); + + if response.status() != 200 { + return Err(ApplicationDownloadError::Communication( + RemoteAccessError::ManifestDownloadFailed( + response.status(), + response.text().unwrap(), + ), + )); + } + + let manifest_download = response.json::().unwrap(); + + if let Ok(mut manifest) = self.manifest.lock() { + *manifest = Some(manifest_download); + return Ok(()); + } + + Err(ApplicationDownloadError::Lock) + } + + fn set_progress_object_params(&self) { + // Avoid re-setting it + if self.progress.get_max() != 0 { + return; + } + + let contexts = self.contexts.lock().unwrap(); + + let length = contexts.len(); + + let chunk_count = contexts.iter().map(|chunk| chunk.length).sum(); + + debug!("Setting ProgressObject max to {}", chunk_count); + self.progress.set_max(chunk_count); + debug!("Setting ProgressObject size to {}", length); + self.progress.set_size(length); + debug!("Setting ProgressObject time to now"); + self.progress.set_time_now(); + } + + pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> { + if !self.contexts.lock().unwrap().is_empty() { + return Ok(()); + } + + self.generate_contexts()?; + Ok(()) + } + + pub fn generate_contexts(&self) -> Result<(), ApplicationDownloadError> { + let manifest = self.manifest.lock().unwrap().clone().unwrap(); + let game_id = self.id.clone(); + + let mut contexts = Vec::new(); + let base_path = Path::new(&self.stored_manifest.base_path); + create_dir_all(base_path).unwrap(); + + + { + let mut completed_contexts_lock = self.completed_contexts.lock().unwrap(); + completed_contexts_lock.clear(); + completed_contexts_lock + .extend(self.stored_manifest.get_completed_contexts()); + } + + for (raw_path, chunk) in manifest { + let path = base_path.join(Path::new(&raw_path)); + + let container = path.parent().unwrap(); + create_dir_all(container).unwrap(); + + let file = File::create(path.clone()).unwrap(); + let mut running_offset = 0; + + for (index, length) in chunk.lengths.iter().enumerate() { + contexts.push(DropDownloadContext { + file_name: raw_path.to_string(), + version: chunk.version_name.to_string(), + offset: running_offset, + index, + game_id: game_id.to_string(), + path: path.clone(), + checksum: chunk.checksums[index].clone(), + length: *length, + permissions: chunk.permissions, + }); + running_offset += *length as u64; + } + + #[cfg(target_os = "linux")] + if running_offset > 0 { + let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset); + } + } + *self.contexts.lock().unwrap() = contexts; + + Ok(()) + } + + pub fn run(&self) -> Result { + info!("downloading game: {}", self.id); + const DOWNLOAD_MAX_THREADS: usize = 1; + + let pool = ThreadPoolBuilder::new() + .num_threads(DOWNLOAD_MAX_THREADS) + .build() + .unwrap(); + + let completed_indexes = Arc::new(boxcar::Vec::new()); + let completed_indexes_loop_arc = completed_indexes.clone(); + + let base_url = DB.fetch_base_url(); + + + + let contexts = self.contexts.lock().unwrap(); + pool.scope(|scope| { + let client = &reqwest::blocking::Client::new(); + for (index, context) in contexts.iter().enumerate() { + let client = client.clone(); + let completed_indexes = completed_indexes_loop_arc.clone(); + + let progress = self.progress.get(index); + let progress_handle = ProgressHandle::new(progress, self.progress.clone()); + // If we've done this one already, skip it + if self.completed_contexts.lock().unwrap().contains(&index) { + progress_handle.add(context.length); + continue; + } + + let sender = self.sender.clone(); + + let request = generate_request(&base_url, client, &context); + + + scope.spawn(move |_| { + match download_game_chunk(context, &self.control_flag, progress_handle, request) { + Ok(res) => { + if res { + completed_indexes.push(index); + } + } + Err(e) => { + error!("{}", e); + sender.send(DownloadManagerSignal::Error(e)).unwrap(); + } + } + info!("Completed context id {}", index); + }); + } + }); + + let newly_completed = completed_indexes.to_owned(); + + + let completed_lock_len = { + let mut completed_contexts_lock = self.completed_contexts.lock().unwrap(); + for (item, _) in newly_completed.iter() { + completed_contexts_lock.push_front(item); + } + + completed_contexts_lock.len() + }; + + info!("Got newly completed"); + + // If we're not out of contexts, we're not done, so we don't fire completed + if completed_lock_len != contexts.len() { + info!("da for {} exited without completing", self.id.clone()); + self.stored_manifest + .set_completed_contexts(&self.completed_contexts.lock().unwrap().clone().into()); + info!("Setting completed contexts"); + self.stored_manifest.write(); + info!("Wrote completed contexts"); + return Ok(false); + } + + info!("Sending completed signal"); + + + // We've completed + self.sender + .send(DownloadManagerSignal::Completed(self.metadata())) + .unwrap(); + + Ok(true) + } +} + +fn generate_request(base_url: &url::Url, client: reqwest::blocking::Client, context: &DropDownloadContext) -> reqwest::blocking::RequestBuilder { + let chunk_url = base_url + .join(&format!( + "/api/v1/client/chunk?id={}&version={}&name={}&chunk={}", + // Encode the parts we don't trust + context.game_id, + encode(&context.version), + encode(&context.file_name), + context.index + )) + .unwrap(); + + let header = generate_authorization_header(); + + let request = client + .get(chunk_url) + .header("Authorization", header); + request +} + +impl Downloadable for GameDownloadAgent { + fn download(&self, app_handle: &AppHandle) -> Result { + *self.status.lock().unwrap() = DownloadStatus::Downloading; + self.download(app_handle) + } + + fn progress(&self) -> Arc { + self.progress.clone() + } + + fn control_flag(&self) -> DownloadThreadControl { + self.control_flag.clone() + } + + fn metadata(&self) -> DownloadableMetadata { + DownloadableMetadata { + id: self.id.clone(), + version: Some(self.version.clone()), + download_type: DownloadType::Game, + } + } + + fn on_initialised(&self, _app_handle: &tauri::AppHandle) { + *self.status.lock().unwrap() = DownloadStatus::Queued; + return; + } + + fn on_error(&self, app_handle: &tauri::AppHandle, error: ApplicationDownloadError) { + *self.status.lock().unwrap() = DownloadStatus::Error; + app_handle + .emit("download_error", error.to_string()) + .unwrap(); + + error!("error while managing download: {}", error); + + set_game_status(app_handle, self.metadata(), |db_handle, meta| { + db_handle.applications.transient_statuses.remove(meta); + }); + + } + + fn on_complete(&self, app_handle: &tauri::AppHandle) { + on_game_complete(&self.metadata(), self.stored_manifest.base_path.to_string_lossy().to_string(), app_handle).unwrap(); + } + + fn on_incomplete(&self, _app_handle: &tauri::AppHandle) { + *self.status.lock().unwrap() = DownloadStatus::Queued; + return; + } + + fn on_cancelled(&self, _app_handle: &tauri::AppHandle) { + return; + } + + fn status(&self) -> DownloadStatus { + self.status.lock().unwrap().clone() + } +} \ No newline at end of file diff --git a/src-tauri/src/downloads/download_commands.rs b/src-tauri/src/games/downloads/download_commands.rs similarity index 69% rename from src-tauri/src/downloads/download_commands.rs rename to src-tauri/src/games/downloads/download_commands.rs index d3ca2e7..2bbd5e8 100644 --- a/src-tauri/src/downloads/download_commands.rs +++ b/src-tauri/src/games/downloads/download_commands.rs @@ -1,6 +1,8 @@ -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; -use crate::AppState; +use crate::{download_manager::{downloadable::Downloadable, downloadable_metadata::DownloadableMetadata}, AppState}; + +use super::download_agent::GameDownloadAgent; #[tauri::command] pub fn download_game( @@ -9,11 +11,15 @@ pub fn download_game( install_dir: usize, state: tauri::State<'_, Mutex>, ) -> Result<(), String> { + let sender = state.lock().unwrap().download_manager.get_sender(); + let game_download_agent = Arc::new( + Box::new(GameDownloadAgent::new(game_id, game_version, install_dir, sender)) as Box + ); state .lock() .unwrap() .download_manager - .queue_game(game_id, game_version, install_dir) + .queue_download(game_download_agent) .map_err(|_| "An error occurred while communicating with the download manager.".to_string()) } @@ -41,8 +47,8 @@ pub fn move_game_in_queue( } #[tauri::command] -pub fn cancel_game(state: tauri::State<'_, Mutex>, game_id: String) { - state.lock().unwrap().download_manager.cancel(game_id) +pub fn cancel_game(state: tauri::State<'_, Mutex>, meta: DownloadableMetadata) { + state.lock().unwrap().download_manager.cancel(meta) } /* diff --git a/src-tauri/src/downloads/download_logic.rs b/src-tauri/src/games/downloads/download_logic.rs similarity index 71% rename from src-tauri/src/downloads/download_logic.rs rename to src-tauri/src/games/downloads/download_logic.rs index 52cd6ba..ec4e87f 100644 --- a/src-tauri/src/downloads/download_logic.rs +++ b/src-tauri/src/games/downloads/download_logic.rs @@ -1,19 +1,20 @@ use crate::auth::generate_authorization_header; use crate::db::DatabaseImpls; -use crate::downloads::manifest::DropDownloadContext; -use crate::remote::RemoteAccessError; +use crate::download_manager::application_download_error::ApplicationDownloadError; +use crate::download_manager::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}; +use crate::download_manager::progress_object::ProgressHandle; +use crate::games::downloads::manifest::DropDownloadContext; +use crate::remote::{DropServerError, RemoteAccessError}; use crate::DB; -use log::warn; +use log::{error, info, warn}; use md5::{Context, Digest}; -use reqwest::blocking::Response; -use tauri::utils::acl::Permission; +use reqwest::blocking::{Client, Request, RequestBuilder, Response}; use std::fs::{set_permissions, Permissions}; use std::io::Read; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::thread::sleep; -use std::time::Duration; +use std::sync::Arc; use std::{ fs::{File, OpenOptions}, io::{self, BufWriter, Seek, SeekFrom, Write}, @@ -21,10 +22,6 @@ use std::{ }; use urlencoding::encode; -use super::download_agent::GameDownloadError; -use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}; -use super::progress_object::ProgressHandle; - pub struct DropWriter { hasher: Context, destination: W, @@ -68,18 +65,18 @@ impl Seek for DropWriter { } } -pub struct DropDownloadPipeline { +pub struct DropDownloadPipeline<'a, R: Read, W: Write> { pub source: R, pub destination: DropWriter, - pub control_flag: DownloadThreadControl, + pub control_flag: &'a DownloadThreadControl, pub progress: ProgressHandle, pub size: usize, } -impl DropDownloadPipeline { +impl<'a> DropDownloadPipeline<'a, Response, File> { fn new( source: Response, destination: DropWriter, - control_flag: DownloadThreadControl, + control_flag: &'a DownloadThreadControl, progress: ProgressHandle, size: usize, ) -> Self { @@ -124,41 +121,24 @@ impl DropDownloadPipeline { } pub fn download_game_chunk( - ctx: DropDownloadContext, - control_flag: DownloadThreadControl, + ctx: &DropDownloadContext, + control_flag: &DownloadThreadControl, progress: ProgressHandle, -) -> Result { + request: RequestBuilder +) -> Result { // If we're paused if control_flag.get() == DownloadThreadControlFlag::Stop { progress.set(0); return Ok(false); } - let base_url = DB.fetch_base_url(); - - let client = reqwest::blocking::Client::new(); - let chunk_url = base_url - .join(&format!( - "/api/v1/client/chunk?id={}&version={}&name={}&chunk={}", - // Encode the parts we don't trust - ctx.game_id, - encode(&ctx.version), - encode(&ctx.file_name), - ctx.index - )) - .unwrap(); - - let header = generate_authorization_header(); - - let response = client - .get(chunk_url) - .header("Authorization", header) + let response = request .send() - .map_err(|e| GameDownloadError::Communication(e.into()))?; + .map_err(|e| ApplicationDownloadError::Communication(e.into()))?; if response.status() != 200 { warn!("{}", response.text().unwrap()); - return Err(GameDownloadError::Communication( + return Err(ApplicationDownloadError::Communication( RemoteAccessError::InvalidCodeError(400), )); } @@ -173,8 +153,9 @@ pub fn download_game_chunk( let content_length = response.content_length(); if content_length.is_none() { - return Err(GameDownloadError::Communication( - RemoteAccessError::InvalidResponse, + error!("Recieved 0 length content from server"); + return Err(ApplicationDownloadError::Communication( + RemoteAccessError::InvalidResponse(response.json::().unwrap()), )); } @@ -186,7 +167,9 @@ pub fn download_game_chunk( content_length.unwrap().try_into().unwrap(), ); - let completed = pipeline.copy().map_err(GameDownloadError::IoError)?; + let completed = pipeline + .copy() + .map_err(|e| ApplicationDownloadError::IoError(e.kind()))?; if !completed { return Ok(false); }; @@ -195,7 +178,7 @@ pub fn download_game_chunk( #[cfg(unix)] { let permissions = Permissions::from_mode(ctx.permissions); - set_permissions(ctx.path, permissions).unwrap(); + set_permissions(ctx.path.clone(), permissions).unwrap(); } /* diff --git a/src-tauri/src/downloads/manifest.rs b/src-tauri/src/games/downloads/manifest.rs similarity index 100% rename from src-tauri/src/downloads/manifest.rs rename to src-tauri/src/games/downloads/manifest.rs diff --git a/src-tauri/src/games/downloads/mod.rs b/src-tauri/src/games/downloads/mod.rs new file mode 100644 index 0000000..ba0fac2 --- /dev/null +++ b/src-tauri/src/games/downloads/mod.rs @@ -0,0 +1,5 @@ +pub mod download_agent; +pub mod download_commands; +mod download_logic; +mod manifest; +mod stored_manifest; \ No newline at end of file diff --git a/src-tauri/src/downloads/stored_manifest.rs b/src-tauri/src/games/downloads/stored_manifest.rs similarity index 87% rename from src-tauri/src/downloads/stored_manifest.rs rename to src-tauri/src/games/downloads/stored_manifest.rs index cd4006d..bb5e6b9 100644 --- a/src-tauri/src/downloads/stored_manifest.rs +++ b/src-tauri/src/games/downloads/stored_manifest.rs @@ -1,12 +1,11 @@ use std::{ - default, fs::File, io::{Read, Write}, - path::{Path, PathBuf}, + path::PathBuf, sync::Mutex, }; -use log::{error, info}; +use log::error; use serde::{Deserialize, Serialize}; use serde_binary::binary_stream::Endian; @@ -44,15 +43,15 @@ impl StoredManifest { } }; - let manifest = match serde_binary::from_vec::(s, Endian::Little) { + + + match serde_binary::from_vec::(s, Endian::Little) { Ok(manifest) => manifest, Err(e) => { error!("{}", e); StoredManifest::new(game_id, game_version, base_path) } - }; - - return manifest; + } } pub fn write(&self) { let manifest_raw = match serde_binary::to_vec(&self, Endian::Little) { @@ -73,8 +72,8 @@ impl StoredManifest { Err(e) => error!("{}", e), }; } - pub fn set_completed_contexts(&self, completed_contexts: &Mutex>) { - *self.completed_contexts.lock().unwrap() = completed_contexts.lock().unwrap().clone(); + pub fn set_completed_contexts(&self, completed_contexts: &Vec) { + *self.completed_contexts.lock().unwrap() = completed_contexts.clone(); } pub fn get_completed_contexts(&self) -> Vec { self.completed_contexts.lock().unwrap().clone() diff --git a/src-tauri/src/library.rs b/src-tauri/src/games/library.rs similarity index 58% rename from src-tauri/src/library.rs rename to src-tauri/src/games/library.rs index 6e8536e..8d40274 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/games/library.rs @@ -1,17 +1,20 @@ +use std::fs::remove_dir_all; use std::sync::Mutex; +use std::thread::spawn; +use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use tauri::Emitter; use tauri::{AppHandle, Manager}; use urlencoding::encode; -use crate::db::DatabaseImpls; +use crate::db::{ApplicationTransientStatus, DatabaseImpls, GameDownloadStatus}; use crate::db::GameVersion; -use crate::db::{GameStatus, GameTransientStatus}; -use crate::downloads::download_manager::GameDownloadStatus; +use crate::download_manager::download_manager::DownloadStatus; +use crate::download_manager::downloadable_metadata::DownloadableMetadata; use crate::process::process_manager::Platform; use crate::remote::RemoteAccessError; -use crate::state::{GameStatusManager, GameStatusWithTransient}; +use crate::games::state::{GameStatusManager, GameStatusWithTransient}; use crate::{auth::generate_authorization_header, AppState, DB}; #[derive(serde::Serialize)] @@ -37,13 +40,13 @@ pub struct Game { #[derive(serde::Serialize, Clone)] pub struct GameUpdateEvent { pub game_id: String, - pub status: (Option, Option), + pub status: (Option, Option), } #[derive(Serialize, Clone)] pub struct QueueUpdateEventQueueData { - pub id: String, - pub status: GameDownloadStatus, + pub meta: DownloadableMetadata, + pub status: DownloadStatus, pub progress: f64, } @@ -52,6 +55,12 @@ pub struct QueueUpdateEvent { pub queue: Vec, } +#[derive(serde::Serialize, Clone)] +pub struct StatsUpdateEvent { + pub speed: usize, + pub time: usize, +} + // Game version with some fields missing and size information #[derive(serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -91,11 +100,11 @@ fn fetch_library_logic(app: AppHandle) -> Result, RemoteAccessError> { for game in games.iter() { handle.games.insert(game.id.clone(), game.clone()); - if !db_handle.games.statuses.contains_key(&game.id) { + if !db_handle.applications.game_statuses.contains_key(&game.id) { db_handle - .games - .statuses - .insert(game.id.clone(), GameStatus::Remote {}); + .applications + .game_statuses + .insert(game.id.clone(), GameDownloadStatus::Remote {}); } } @@ -154,10 +163,10 @@ fn fetch_game_logic( let mut db_handle = DB.borrow_data_mut().unwrap(); db_handle - .games - .statuses + .applications + .game_statuses .entry(id.clone()) - .or_insert(GameStatus::Remote {}); + .or_insert(GameDownloadStatus::Remote {}); drop(db_handle); let status = GameStatusManager::fetch_state(&id); @@ -171,8 +180,8 @@ fn fetch_game_logic( } #[tauri::command] -pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result { - let result = fetch_game_logic(id, app); +pub fn fetch_game(game_id: String, app: tauri::AppHandle) -> Result { + let result = fetch_game_logic(game_id, app); if result.is_err() { return Err(result.err().unwrap().to_string()); @@ -224,6 +233,89 @@ fn fetch_game_verion_options_logic<'a>( Ok(data) } +#[tauri::command] +pub fn uninstall_game( + game_id: String, + state: tauri::State<'_, Mutex>, + app_handle: AppHandle +) -> Result<(), String> { + let meta = get_current_meta(&game_id)?; + println!("{:?}", meta); + uninstall_game_logic(meta, &app_handle); + + Ok(()) +} + +fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) { + println!("Triggered uninstall for agent"); + let mut db_handle = DB.borrow_data_mut().unwrap(); + db_handle + .applications + .transient_statuses + .entry(meta.clone()) + .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); + + push_game_update( + app_handle, + &meta, + (None, Some(ApplicationTransientStatus::Uninstalling {})), + ); + + let previous_state = db_handle.applications.game_statuses.get(&meta.id).cloned(); + if previous_state.is_none() { + info!("uninstall job doesn't have previous state, failing silently"); + return; + } + let previous_state = previous_state.unwrap(); + if let Some((version_name, install_dir)) = match previous_state { + GameDownloadStatus::Installed { + version_name, + install_dir, + } => Some((version_name, install_dir)), + GameDownloadStatus::SetupRequired { + version_name, + install_dir, + } => Some((version_name, install_dir)), + _ => None, + } { + db_handle + .applications + .transient_statuses + .entry(meta.clone()) + .and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {}); + drop(db_handle); + + let app_handle = app_handle.clone(); + spawn(move || match remove_dir_all(install_dir) { + Err(e) => { + error!("{}", e); + } + Ok(_) => { + let mut db_handle = DB.borrow_data_mut().unwrap(); + db_handle.applications.transient_statuses.remove(&meta); + db_handle + .applications + .game_statuses + .entry(meta.id.clone()) + .and_modify(|e| *e = GameDownloadStatus::Remote {}); + drop(db_handle); + DB.save().unwrap(); + + info!("uninstalled game id {}", &meta.id); + + push_game_update(&app_handle, &meta, (Some(GameDownloadStatus::Remote {}), None)); + } + }); + } +} + +pub fn get_current_meta(game_id: &String) -> Result { + match DB.borrow_data().unwrap().applications.installed_game_version.get(game_id) { + Some(meta) => Ok(meta.clone()), + None => Err(String::from("Could not find installed version")), + } +} + #[tauri::command] pub fn fetch_game_verion_options<'a>( game_id: String, @@ -233,19 +325,19 @@ pub fn fetch_game_verion_options<'a>( } pub fn on_game_complete( - game_id: String, - version_name: String, + meta: &DownloadableMetadata, install_dir: String, app_handle: &AppHandle, ) -> Result<(), RemoteAccessError> { // Fetch game version information from remote let base_url = DB.fetch_base_url(); + if meta.version.is_none() { return Err(RemoteAccessError::GameNotFound) } let endpoint = base_url.join( format!( "/api/v1/client/metadata/version?id={}&version={}", - game_id, - encode(&version_name) + meta.id, + encode(meta.version.as_ref().unwrap()) ) .as_str(), )?; @@ -261,38 +353,43 @@ pub fn on_game_complete( let mut handle = DB.borrow_data_mut().unwrap(); handle - .games - .versions - .entry(game_id.clone()) + .applications + .game_versions + .entry(meta.id.clone()) .or_default() - .insert(version_name.clone(), data.clone()); + .insert(meta.version.clone().unwrap(), data.clone()); + handle + .applications + .installed_game_version + .insert(meta.id.clone(), meta.clone()); + drop(handle); DB.save().unwrap(); let status = if data.setup_command.is_empty() { - GameStatus::Installed { - version_name, + GameDownloadStatus::Installed { + version_name: meta.version.clone().unwrap(), install_dir, } } else { - GameStatus::SetupRequired { - version_name, + GameDownloadStatus::SetupRequired { + version_name: meta.version.clone().unwrap(), install_dir, } }; let mut db_handle = DB.borrow_data_mut().unwrap(); db_handle - .games - .statuses - .insert(game_id.clone(), status.clone()); + .applications + .game_statuses + .insert(meta.id.clone(), status.clone()); drop(db_handle); DB.save().unwrap(); app_handle .emit( - &format!("update_game/{}", game_id), + &format!("update_game/{}", meta.id), GameUpdateEvent { - game_id, + game_id: meta.id.clone(), status: (Some(status), None), }, ) @@ -300,3 +397,15 @@ pub fn on_game_complete( Ok(()) } + +pub fn push_game_update(app_handle: &AppHandle, meta: &DownloadableMetadata, status: GameStatusWithTransient) { + app_handle + .emit( + &format!("update_game/{}", meta.id), + GameUpdateEvent { + game_id: meta.id.clone(), + status, + }, + ) + .unwrap(); +} \ No newline at end of file diff --git a/src-tauri/src/games/mod.rs b/src-tauri/src/games/mod.rs new file mode 100644 index 0000000..e49a4ef --- /dev/null +++ b/src-tauri/src/games/mod.rs @@ -0,0 +1,3 @@ +pub mod downloads; +pub mod library; +pub mod state; \ No newline at end of file diff --git a/src-tauri/src/games/state.rs b/src-tauri/src/games/state.rs new file mode 100644 index 0000000..778da84 --- /dev/null +++ b/src-tauri/src/games/state.rs @@ -0,0 +1,32 @@ +use crate::{ + db::{ApplicationTransientStatus, GameDownloadStatus}, download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}, fetch_state, DB +}; + +pub type GameStatusWithTransient = (Option, Option); +pub struct GameStatusManager {} + +impl GameStatusManager { + pub fn fetch_state(game_id: &String) -> GameStatusWithTransient { + let db_lock = DB.borrow_data().unwrap(); + let online_state = match db_lock.applications.installed_game_version.get(game_id) { + Some(meta) => db_lock + .applications + .transient_statuses + .get(meta) + .cloned(), + None => None, + }; + let offline_state = db_lock.applications.game_statuses.get(game_id).cloned(); + drop(db_lock); + + if online_state.is_some() { + return (None, online_state); + } + + if offline_state.is_some() { + return (offline_state, None); + } + + (None, None) + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e6dcf5..5bc1538 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,46 +1,58 @@ mod auth; mod db; -mod downloads; -mod library; +mod games; +mod autostart; +mod cleanup; +mod debug; mod process; mod remote; -mod state; +mod tools; +pub mod download_manager; #[cfg(test)] mod tests; -mod cleanup; +use crate::autostart::{get_autostart_enabled, toggle_autostart}; use crate::db::DatabaseImpls; -use auth::{auth_initiate, generate_authorization_header, recieve_handshake, retry_connect}; +use auth::{ + auth_initiate, generate_authorization_header, manual_recieve_handshake, recieve_handshake, + retry_connect, sign_out, +}; use cleanup::{cleanup_and_exit, quit}; use db::{ - add_download_dir, delete_download_dir, fetch_download_dir_stats, DatabaseInterface, + add_download_dir, delete_download_dir, fetch_download_dir_stats, DatabaseInterface, GameDownloadStatus, DATA_ROOT_DIR, }; -use downloads::download_commands::*; -use downloads::download_manager::DownloadManager; -use downloads::download_manager_builder::DownloadManagerBuilder; +use debug::fetch_system_data; +use download_manager::download_manager::DownloadManager; +use download_manager::download_manager_builder::DownloadManagerBuilder; +use games::downloads::download_commands::{cancel_game, download_game, move_game_in_queue, pause_game_downloads, resume_game_downloads}; +use http::Response; use http::{header::*, response::Builder as ResponseBuilder}; -use library::{fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, Game}; -use log::{debug, info, LevelFilter}; +use games::library::{ + fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, Game +}; +use log::{debug, info, warn, LevelFilter}; use log4rs::append::console::ConsoleAppender; use log4rs::append::file::FileAppender; -use log4rs::append::rolling_file::RollingFileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; use log4rs::Config; -use process::process_commands::launch_game; +use process::compat::CompatibilityManager; +use process::process_commands::{kill_game, launch_game}; use process::process_manager::ProcessManager; use remote::{gen_drop_url, use_remote}; use serde::{Deserialize, Serialize}; +use tauri_plugin_dialog::DialogExt; +use std::path::Path; use std::sync::Arc; use std::{ collections::HashMap, sync::{LazyLock, Mutex}, }; -use tauri::menu::{Menu, MenuItem, MenuItemBuilder, PredefinedMenuItem}; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; -use tauri::{AppHandle, Manager, RunEvent, WindowEvent}; +use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_deep_link::DeepLinkExt; #[derive(Clone, Copy, Serialize)] @@ -65,7 +77,7 @@ pub struct User { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AppState { +pub struct AppState<'a> { status: AppStatus, user: Option, games: HashMap, @@ -73,18 +85,20 @@ pub struct AppState { #[serde(skip_serializing)] download_manager: Arc, #[serde(skip_serializing)] - process_manager: Arc>, + process_manager: Arc>>, + #[serde(skip_serializing)] + compat_manager: Arc>, } #[tauri::command] -fn fetch_state(state: tauri::State<'_, Mutex>) -> Result { +fn fetch_state(state: tauri::State<'_, Mutex>>) -> Result { let guard = state.lock().unwrap(); - let cloned_state = guard.clone(); + let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?; drop(guard); Ok(cloned_state) } -fn setup(handle: AppHandle) -> AppState { +fn setup(handle: AppHandle) -> AppState<'static> { let logfile = FileAppender::builder() .encoder(Box::new(PatternEncoder::new("{d} | {l} | {f} - {m}{n}"))) .append(false) @@ -110,8 +124,9 @@ fn setup(handle: AppHandle) -> AppState { log4rs::init_config(config).unwrap(); let games = HashMap::new(); - let download_manager = Arc::new(DownloadManagerBuilder::build(handle)); - let process_manager = Arc::new(Mutex::new(ProcessManager::new())); + let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone())); + let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone()))); + let compat_manager = Arc::new(Mutex::new(CompatibilityManager::new())); debug!("Checking if database is set up"); let is_set_up = DB.database_is_set_up(); @@ -122,27 +137,80 @@ fn setup(handle: AppHandle) -> AppState { games, download_manager, process_manager, + compat_manager, }; } debug!("Database is set up"); let (app_status, user) = auth::setup().unwrap(); + + let db_handle = DB.borrow_data().unwrap(); + let mut missing_games = Vec::new(); + let statuses = db_handle.applications.game_statuses.clone(); + drop(db_handle); + for (game_id, status) in statuses.into_iter() { + match status { + db::GameDownloadStatus::Remote {} => {} + db::GameDownloadStatus::SetupRequired { + version_name: _, + install_dir, + } => { + let install_dir_path = Path::new(&install_dir); + if !install_dir_path.exists() { + missing_games.push(game_id); + } + } + db::GameDownloadStatus::Installed { + version_name: _, + install_dir, + } => { + let install_dir_path = Path::new(&install_dir); + if !install_dir_path.exists() { + missing_games.push(game_id); + } + } + } + } + + info!("detected games missing: {:?}", missing_games); + + let mut db_handle = DB.borrow_data_mut().unwrap(); + for game_id in missing_games { + db_handle + .applications + .game_statuses + .entry(game_id) + .and_modify(|v| *v = GameDownloadStatus::Remote {}); + } + + drop(db_handle); + + + info!("finished setup!"); + + // Sync autostart state + if let Err(e) = autostart::sync_autostart_on_startup(&handle) { + warn!("Failed to sync autostart state: {}", e); + } + AppState { status: app_status, user, games, download_manager, process_manager, + compat_manager, } } - pub static DB: LazyLock = LazyLock::new(DatabaseInterface::set_up_database); #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let mut builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init()); + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_dialog::init()); #[cfg(desktop)] #[allow(unused_variables)] @@ -152,15 +220,18 @@ pub fn run() { })); } - let mut app = builder + let app = builder .plugin(tauri_plugin_deep_link::init()) .invoke_handler(tauri::generate_handler![ // Core utils fetch_state, quit, + fetch_system_data, // Auth auth_initiate, retry_connect, + manual_recieve_handshake, + sign_out, // Remote use_remote, gen_drop_url, @@ -178,11 +249,19 @@ pub fn run() { pause_game_downloads, resume_game_downloads, cancel_game, + uninstall_game, // Processes launch_game, + kill_game, + toggle_autostart, + get_autostart_enabled, ]) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--minimize"]), + )) .setup(|app| { let handle = app.handle().clone(); let state = setup(handle); @@ -253,6 +332,18 @@ pub fn run() { .build(app) .expect("error while setting up tray menu"); + { + let mut db_handle = DB.borrow_data_mut().unwrap(); + if let Some(original) = db_handle.prev_database.take() { + warn!("Database corrupted. Original file at {}", original.canonicalize().unwrap().to_string_lossy().to_string()); + app.dialog() + .message("Database corrupted. A copy has been saved at: ".to_string() + original.to_str().unwrap()) + .title("Database corrupted") + .show(|_| {}); + } + } + + Ok(()) }) .register_asynchronous_uri_scheme_protocol("object", move |_ctx, request, responder| { @@ -272,8 +363,16 @@ pub fn run() { let response = client .get(object_url.to_string()) .header("Authorization", header) - .send() - .unwrap(); + .send(); + if response.is_err() { + warn!( + "failed to fetch object with error: {}", + response.err().unwrap() + ); + responder.respond(Response::builder().status(500).body(Vec::new()).unwrap()); + return; + } + let response = response.unwrap(); let resp_builder = ResponseBuilder::new().header( CONTENT_TYPE, @@ -284,22 +383,20 @@ pub fn run() { responder.respond(resp); }) - .on_window_event(|window, event| match event { - WindowEvent::CloseRequested { api, .. } => { + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { window.hide().unwrap(); api.prevent_close(); } - _ => (), }) .build(tauri::generate_context!()) .expect("error while running tauri application"); - app.run(|app_handle, event| match event { - RunEvent::ExitRequested { code, api, .. } => { + app.run(|app_handle, event| { + if let RunEvent::ExitRequested { code, api, .. } = event { if code.is_none() { api.prevent_exit(); } } - _ => {} }); } diff --git a/src-tauri/src/process/compat.rs b/src-tauri/src/process/compat.rs new file mode 100644 index 0000000..65e8ece --- /dev/null +++ b/src-tauri/src/process/compat.rs @@ -0,0 +1,51 @@ +use std::{ + fs::create_dir_all, + path::PathBuf, + sync::atomic::{AtomicBool, Ordering}, +}; + +use crate::db::DATA_ROOT_DIR; + +pub struct CompatibilityManager { + compat_tools_path: PathBuf, + prefixes_path: PathBuf, + created_paths: AtomicBool, +} + +/* +This gets built into both the Windows & Linux client, but +we only need it in the Linux client. Therefore, it should +do nothing but take a little bit of memory if we're on +Windows. +*/ +impl CompatibilityManager { + pub fn new() -> Self { + let root_dir_lock = DATA_ROOT_DIR.lock().unwrap(); + let compat_tools_path = root_dir_lock.join("compatibility_tools"); + let prefixes_path = root_dir_lock.join("prefixes"); + drop(root_dir_lock); + + Self { + compat_tools_path, + prefixes_path, + created_paths: AtomicBool::new(false), + } + } + + fn ensure_paths_exist(&self) -> Result<(), String> { + if self.created_paths.fetch_and(true, Ordering::Relaxed) { + return Ok(()); + } + if !self.compat_tools_path.exists() { + create_dir_all(self.compat_tools_path.clone()).map_err(|e| e.to_string())?; + } + if !self.prefixes_path.exists() { + create_dir_all(self.prefixes_path.clone()).map_err(|e| e.to_string())?; + } + self.created_paths.store(true, Ordering::Relaxed); + + Ok(()) + } + + +} diff --git a/src-tauri/src/process/mod.rs b/src-tauri/src/process/mod.rs index a4ceb45..85692c9 100644 --- a/src-tauri/src/process/mod.rs +++ b/src-tauri/src/process/mod.rs @@ -1,2 +1,3 @@ +pub mod compat; +pub mod process_commands; pub mod process_manager; -pub mod process_commands; \ No newline at end of file diff --git a/src-tauri/src/process/process_commands.rs b/src-tauri/src/process/process_commands.rs index 327728f..c74ff8b 100644 --- a/src-tauri/src/process/process_commands.rs +++ b/src-tauri/src/process/process_commands.rs @@ -1,16 +1,40 @@ use std::sync::Mutex; -use crate::AppState; +use crate::{db::GameDownloadStatus, download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}, games::library::get_current_meta, AppState, DB}; #[tauri::command] -pub fn launch_game(game_id: String, state: tauri::State<'_, Mutex>) -> Result<(), String> { +pub fn launch_game( + id: String, + state: tauri::State<'_, Mutex>, +) -> Result<(), String> { let state_lock = state.lock().unwrap(); let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); - process_manager_lock.launch_game(game_id)?; + let version = match DB.borrow_data().unwrap().applications.game_statuses.get(&id).cloned() { + Some(GameDownloadStatus::Installed { version_name, install_dir }) => version_name, + Some(GameDownloadStatus::SetupRequired { version_name, install_dir }) => return Err(String::from("Game setup still required")), + _ => return Err(String::from("Game not installed")) + }; + + + let meta = DownloadableMetadata { id, version: Some(version), download_type: DownloadType::Game }; + + + process_manager_lock.launch_process(meta)?; drop(process_manager_lock); drop(state_lock); Ok(()) } + +#[tauri::command] +pub fn kill_game( + game_id: String, + state: tauri::State<'_, Mutex>, +) -> Result<(), String> { + let meta = get_current_meta(&game_id)?; + let state_lock = state.lock().unwrap(); + let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); + process_manager_lock.kill_game(meta).map_err(|x| x.to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index 5266849..c376037 100644 --- a/src-tauri/src/process/process_manager.rs +++ b/src-tauri/src/process/process_manager.rs @@ -1,28 +1,33 @@ use std::{ collections::HashMap, fs::{File, OpenOptions}, - io::{Stdout, Write}, + io, path::{Path, PathBuf}, - process::{Child, Command}, - sync::LazyLock, + process::{Child, Command, ExitStatus}, + sync::{Arc, Mutex}, + thread::spawn, }; -use log::info; +use log::{info, warn}; use serde::{Deserialize, Serialize}; +use shared_child::SharedChild; +use tauri::{AppHandle, Manager}; +use umu_wrapper_lib::command_builder::UmuCommandBuilder; use crate::{ - db::{GameStatus, DATA_ROOT_DIR}, - DB, + db::{GameDownloadStatus, ApplicationTransientStatus, DATA_ROOT_DIR}, download_manager::{downloadable::Downloadable, downloadable_metadata::DownloadableMetadata}, games::library::push_game_update, games::state::GameStatusManager, AppState, DB }; -pub struct ProcessManager { +pub struct ProcessManager<'a> { current_platform: Platform, log_output_dir: PathBuf, - processes: HashMap, + processes: HashMap>, + app_handle: AppHandle, + game_launchers: HashMap<(Platform, Platform), &'a (dyn ProcessHandler + Sync + Send + 'static)>, } -impl ProcessManager { - pub fn new() -> Self { +impl ProcessManager<'_> { + pub fn new(app_handle: AppHandle) -> Self { let root_dir_lock = DATA_ROOT_DIR.lock().unwrap(); let log_output_dir = root_dir_lock.join("logs"); drop(root_dir_lock); @@ -34,119 +39,311 @@ impl ProcessManager { Platform::Linux }, + app_handle, processes: HashMap::new(), log_output_dir, + game_launchers: HashMap::from([ + // Current platform to target platform + ( + (Platform::Windows, Platform::Windows), + &NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), + ), + ( + (Platform::Linux, Platform::Linux), + &NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), + ), + ( + (Platform::Linux, Platform::Windows), + &UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), + ), + ]), } } - fn process_command(&self, install_dir: &String, raw_command: String) -> (String, Vec) { - let command_components = raw_command.split(" ").collect::>(); - let root = command_components[0].to_string(); + // There's no easy way to distinguish between an executable name with + // spaces and it's arguments. + // I think if we just join the install_dir to whatever the user provides us, we'll be alright + // In future, we should have a separate field for executable name and it's arguments + fn process_command(&self, install_dir: &String, raw_command: String) -> (PathBuf, Vec) { + // let command_components = raw_command.split(" ").collect::>(); + let root = raw_command; let install_dir = Path::new(install_dir); let absolute_exe = install_dir.join(root); + /* let args = command_components[1..] - .into_iter() + .iter() .map(|v| v.to_string()) .collect(); - (absolute_exe.to_str().unwrap().to_owned(), args) + */ + (absolute_exe, Vec::new()) + } + pub fn kill_game(&mut self, meta: DownloadableMetadata) -> Result<(), io::Error> { + return match self.processes.get(&meta) { + Some(child) => { + child.kill()?; + child.wait()?; + Ok(()) + }, + None => Err(io::Error::new( + io::ErrorKind::NotFound, + "Game ID not running", + )), + }; + } + + fn on_process_finish(&mut self, meta: DownloadableMetadata, result: Result) { + if !self.processes.contains_key(&meta) { + warn!("process on_finish was called, but game_id is no longer valid. finished with result: {:?}", result); + return; + } + + info!("process for {:?} exited with {:?}", meta, result); + + self.processes.remove(&meta); + + let mut db_handle = DB.borrow_data_mut().unwrap(); + db_handle.applications.transient_statuses.remove(&meta); + + let current_state = db_handle.applications.game_statuses.get(&meta.id).cloned(); + if let Some(saved_state) = current_state { + if let GameDownloadStatus::SetupRequired { + version_name, + install_dir, + } = saved_state + { + if let Ok(exit_code) = result { + if exit_code.success() { + db_handle.applications.game_statuses.insert( + meta.id.clone(), + GameDownloadStatus::Installed { + version_name: version_name.to_string(), + install_dir: install_dir.to_string(), + }, + ); + } + } + } + } + drop(db_handle); + + let status = GameStatusManager::fetch_state(&meta.id); + + push_game_update(&self.app_handle, &meta, status); + + // TODO better management } pub fn valid_platform(&self, platform: &Platform) -> Result { let current = &self.current_platform; - let valid_platforms = PROCESS_COMPATABILITY_MATRIX - .get(current) - .ok_or("Incomplete platform compatability matrix.")?; - - Ok(valid_platforms.contains(platform)) + Ok(self + .game_launchers + .contains_key(&(current.clone(), platform.clone()))) } - pub fn launch_game(&mut self, game_id: String) -> Result<(), String> { - if self.processes.contains_key(&game_id) { + pub fn launch_process(&mut self, meta: DownloadableMetadata) -> Result<(), String> { + if self.processes.contains_key(&meta) { return Err("Game or setup is already running.".to_owned()); } - let db_lock = DB.borrow_data().unwrap(); + let mut db_lock = DB.borrow_data_mut().unwrap(); + info!("Launching process {:?} with games {:?}", meta, db_lock.applications.game_versions); + let game_status = db_lock - .games - .statuses - .get(&game_id) + .applications + .game_statuses + .get(&meta.id) .ok_or("Game not installed")?; - let GameStatus::Installed { - version_name, - install_dir, - } = game_status - else { - return Err("Game not installed.".to_owned()); + let status_metadata: Option<(&String, &String)> = match game_status { + GameDownloadStatus::Installed { + version_name, + install_dir, + } => Some((version_name, install_dir)), + GameDownloadStatus::SetupRequired { + version_name, + install_dir, + } => Some((version_name, install_dir)), + _ => None, }; + if status_metadata.is_none() { + return Err("Game has not been downloaded.".to_owned()); + } + + let (version_name, install_dir) = status_metadata.unwrap(); + let game_version = db_lock - .games - .versions - .get(&game_id) + .applications + .game_versions + .get(&meta.id) .ok_or("Invalid game ID".to_owned())? .get(version_name) .ok_or("Invalid version name".to_owned())?; - let (command, args) = - self.process_command(install_dir, game_version.launch_command.clone()); + let raw_command: String = match game_status { + GameDownloadStatus::Installed { + version_name: _, + install_dir: _, + } => game_version.launch_command.clone(), + GameDownloadStatus::SetupRequired { + version_name: _, + install_dir: _, + } => game_version.setup_command.clone(), + _ => panic!("unreachable code"), + }; - info!("launching process {} in {}", command, install_dir); + let (command, args) = self.process_command(install_dir, raw_command); + + let target_current_dir = command.parent().unwrap().to_str().unwrap(); + + info!( + "launching process {} in {}", + command.to_str().unwrap(), + target_current_dir + ); let current_time = chrono::offset::Local::now(); - let mut log_file = OpenOptions::new() + let log_file = OpenOptions::new() .write(true) .truncate(true) .read(true) .create(true) .open( self.log_output_dir - .join(format!("{}-{}.log", game_id, current_time.timestamp())), + .join(format!("{}-{}-{}.log", meta.id.clone(), meta.version.clone().unwrap_or_default(), current_time.timestamp())), ) .map_err(|v| v.to_string())?; - let mut error_file = OpenOptions::new() + let error_file = OpenOptions::new() .write(true) .truncate(true) .read(true) .create(true) - .open( - self.log_output_dir - .join(format!("{}-{}-error.log", game_id, current_time.timestamp())), - ) + .open(self.log_output_dir.join(format!( + "{}-{}-{}-error.log", + meta.id.clone(), + meta.version.clone().unwrap_or_default(), + current_time.timestamp() + ))) .map_err(|v| v.to_string())?; - info!("opened log file for {}", command); + let current_platform = self.current_platform.clone(); + let target_platform = game_version.platform.clone(); - let launch_process = Command::new(command) - .current_dir(install_dir) - .stdout(log_file) - .stderr(error_file) - .args(args) - .spawn() - .map_err(|v| v.to_string())?; + let game_launcher = self + .game_launchers + .get(&(current_platform, target_platform)) + .ok_or("Invalid version for this platform.") + .map_err(|e| e.to_string())?; - self.processes.insert(game_id, launch_process); + let launch_process = game_launcher.launch_process( + &meta, + command.to_str().unwrap().to_owned(), + args, + &target_current_dir.to_string(), + log_file, + error_file, + )?; + + let launch_process_handle = + Arc::new(SharedChild::new(launch_process).map_err(|e| e.to_string())?); + + db_lock + .applications + .transient_statuses + .insert(meta.clone(), ApplicationTransientStatus::Running {}); + + push_game_update( + &self.app_handle, + &meta, + (None, Some(ApplicationTransientStatus::Running {})), + ); + + let wait_thread_handle = launch_process_handle.clone(); + let wait_thread_apphandle = self.app_handle.clone(); + let wait_thread_game_id = meta.clone(); + + spawn(move || { + let result: Result = launch_process_handle.wait(); + + let app_state = wait_thread_apphandle.state::>(); + let app_state_handle = app_state.lock().unwrap(); + + let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap(); + process_manager_handle.on_process_finish(wait_thread_game_id, result); + + // As everything goes out of scope, they should get dropped + // But just to explicit about it + drop(process_manager_handle); + drop(app_state_handle); + }); + + self.processes.insert(meta, wait_thread_handle); + + info!("finished spawning process"); Ok(()) } } -#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone)] +#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Debug)] pub enum Platform { Windows, Linux, } -pub type ProcessCompatabilityMatrix = HashMap>; -pub static PROCESS_COMPATABILITY_MATRIX: LazyLock = - LazyLock::new(|| { - let mut matrix: ProcessCompatabilityMatrix = HashMap::new(); +pub trait ProcessHandler: Send + 'static { + fn launch_process( + &self, + meta: &DownloadableMetadata, + command: String, + args: Vec, + current_dir: &String, + log_file: File, + error_file: File, + ) -> Result; +} - matrix.insert(Platform::Windows, vec![Platform::Windows]); - matrix.insert(Platform::Linux, vec![Platform::Linux]); // TODO: add Proton support +struct NativeGameLauncher; +impl ProcessHandler for NativeGameLauncher { + fn launch_process( + &self, + meta: &DownloadableMetadata, + command: String, + args: Vec, + current_dir: &String, + log_file: File, + error_file: File, + ) -> Result { + Command::new(command) + .current_dir(current_dir) + .stdout(log_file) + .stderr(error_file) + .args(args) + .spawn() + .map_err(|v| v.to_string()) + } +} - return matrix; - }); +const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run"; +struct UMULauncher; +impl ProcessHandler for UMULauncher { + fn launch_process( + &self, + meta: &DownloadableMetadata, + command: String, + args: Vec, + current_dir: &String, + log_file: File, + error_file: File, + ) -> Result { + UmuCommandBuilder::new(UMU_LAUNCHER_EXECUTABLE, command) + .game_id(String::from("0")) + .launch_args(args) + .build() + .spawn() + .map_err(|x| x.to_string()) + } +} diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 33655c2..04c56db 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -1,10 +1,12 @@ use std::{ + error::Error, fmt::{Display, Formatter}, sync::{Arc, Mutex}, }; use http::StatusCode; use log::{info, warn}; +use reqwest::blocking::Response; use serde::Deserialize; use url::{ParseError, Url}; @@ -16,31 +18,44 @@ pub enum RemoteAccessError { ParsingError(ParseError), InvalidCodeError(u16), InvalidEndpoint, - HandshakeFailed, + HandshakeFailed(String), GameNotFound, - InvalidResponse, + InvalidResponse(DropServerError), InvalidRedirect, ManifestDownloadFailed(StatusCode, String), + OutOfSync, + Generic(String), } impl Display for RemoteAccessError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - RemoteAccessError::FetchError(error) => write!(f, "{}", error), + RemoteAccessError::FetchError(error) => write!( + f, + "{}: {}", + error, + error + .source() + .map(|e| e.to_string()) + .or_else(|| Some("Unknown error".to_string())) + .unwrap() + ), RemoteAccessError::ParsingError(parse_error) => { write!(f, "{}", parse_error) } RemoteAccessError::InvalidCodeError(error) => write!(f, "Invalid HTTP code {}", error), RemoteAccessError::InvalidEndpoint => write!(f, "Invalid drop endpoint"), - RemoteAccessError::HandshakeFailed => write!(f, "Failed to complete handshake"), + RemoteAccessError::HandshakeFailed(message) => write!(f, "Failed to complete handshake: {}", message), RemoteAccessError::GameNotFound => write!(f, "Could not find game on server"), - RemoteAccessError::InvalidResponse => write!(f, "Server returned an invalid response"), + RemoteAccessError::InvalidResponse(error) => write!(f, "Server returned an invalid response: {} {}", error.status_code, error.status_message), RemoteAccessError::InvalidRedirect => write!(f, "Server redirect was invalid"), RemoteAccessError::ManifestDownloadFailed(status, response) => write!( f, "Failed to download game manifest: {} {}", status, response ), + RemoteAccessError::OutOfSync => write!(f, "Server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other."), + RemoteAccessError::Generic(message) => write!(f, "{}", message), } } } @@ -63,6 +78,15 @@ impl From for RemoteAccessError { impl std::error::Error for RemoteAccessError {} +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DropServerError { + pub status_code: usize, + pub status_message: String, + pub message: String, + pub url: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DropHealthcheck { @@ -71,7 +95,7 @@ struct DropHealthcheck { async fn use_remote_logic<'a>( url: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result<(), RemoteAccessError> { info!("connecting to url {}", url); let base_url = Url::parse(&url)?; @@ -103,7 +127,7 @@ async fn use_remote_logic<'a>( #[tauri::command] pub async fn use_remote<'a>( url: String, - state: tauri::State<'_, Mutex>, + state: tauri::State<'_, Mutex>>, ) -> Result<(), String> { let result = use_remote_logic(url, state).await; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs deleted file mode 100644 index 9f66307..0000000 --- a/src-tauri/src/state.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::collections::HashMap; - -use crate::{ - db::{GameStatus, GameTransientStatus}, - DB, -}; - -pub type GameStatusWithTransient = ( - Option, - Option, -); -pub struct GameStatusManager {} - -impl GameStatusManager { - pub fn fetch_state(game_id: &String) -> GameStatusWithTransient { - let db_lock = DB.borrow_data().unwrap(); - let offline_state = db_lock.games.statuses.get(game_id).cloned(); - let online_state = db_lock.games.transient_statuses.get(game_id).cloned(); - drop(db_lock); - - if online_state.is_some() { - return (None, online_state); - } - - if offline_state.is_some() { - return (offline_state, None); - } - - return (None, None); - } -} diff --git a/src-tauri/src/tools/compatibility_layer.rs b/src-tauri/src/tools/compatibility_layer.rs new file mode 100644 index 0000000..3644319 --- /dev/null +++ b/src-tauri/src/tools/compatibility_layer.rs @@ -0,0 +1,3 @@ +pub struct CompatibilityLayer { + +} \ No newline at end of file diff --git a/src-tauri/src/tools/mod.rs b/src-tauri/src/tools/mod.rs new file mode 100644 index 0000000..0279e82 --- /dev/null +++ b/src-tauri/src/tools/mod.rs @@ -0,0 +1,4 @@ +mod prefix; +mod registry; +mod tool; +mod compatibility_layer; \ No newline at end of file diff --git a/src-tauri/src/tools/prefix.rs b/src-tauri/src/tools/prefix.rs new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/tools/registry.rs b/src-tauri/src/tools/registry.rs new file mode 100644 index 0000000..fa50426 --- /dev/null +++ b/src-tauri/src/tools/registry.rs @@ -0,0 +1,7 @@ +use std::collections::HashMap; + +use crate::download_manager::downloadable::Downloadable; + +pub struct Registry { + tools: HashMap +} diff --git a/src-tauri/src/tools/tool.rs b/src-tauri/src/tools/tool.rs new file mode 100644 index 0000000..43fc9f0 --- /dev/null +++ b/src-tauri/src/tools/tool.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use tauri::AppHandle; + +use crate::download_manager::{application_download_error::ApplicationDownloadError, download_thread_control_flag::DownloadThreadControl, downloadable::Downloadable, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject}; + +pub struct ToolDownloadAgent { + id: String, + version: String, + location: String, + control_flag: DownloadThreadControl, + progress: Arc, +} +impl Downloadable for ToolDownloadAgent { + fn download(&self, app_handle: &AppHandle) -> Result { + todo!() + } + + fn progress(&self) -> Arc { + todo!() + } + + fn control_flag(&self) -> DownloadThreadControl { + todo!() + } + + fn status(&self) -> crate::download_manager::download_manager::DownloadStatus { + todo!() + } + + fn metadata(&self) -> DownloadableMetadata { + todo!() + } + + fn on_initialised(&self, app_handle: &tauri::AppHandle) { + todo!() + } + + fn on_error(&self, app_handle: &tauri::AppHandle, error: crate::download_manager::application_download_error::ApplicationDownloadError) { + todo!() + } + + fn on_complete(&self, app_handle: &tauri::AppHandle) { + todo!() + } + + fn on_incomplete(&self, app_handle: &tauri::AppHandle) { + todo!() + } + + fn on_cancelled(&self, app_handle: &tauri::AppHandle) { + todo!() + } +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8fecb09..0efb8e0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,6 +40,7 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "externalBin": [] } } diff --git a/types.ts b/types.ts index 5702594..720ec28 100644 --- a/types.ts +++ b/types.ts @@ -52,9 +52,23 @@ export enum GameStatusEnum { Updating = "Updating", Uninstalling = "Uninstalling", SetupRequired = "SetupRequired", + Running = "Running" } export type GameStatus = { type: GameStatusEnum; version_name?: string; }; + +export enum DownloadableType { + Game = "Game", + Tool = "Tool", + DLC = "DLC", + Mod = "Mod" +} + +export type DownloadableMetadata = { + id: string, + version: string, + downloadType: DownloadableType +} \ No newline at end of file diff --git a/utils/generateGameMeta.ts b/utils/generateGameMeta.ts new file mode 100644 index 0000000..fdfc4b7 --- /dev/null +++ b/utils/generateGameMeta.ts @@ -0,0 +1,9 @@ +import { type DownloadableMetadata, DownloadableType } from '~/types' + +export default function generateGameMeta(gameId: string, version: string): DownloadableMetadata { + return { + id: gameId, + version, + downloadType: DownloadableType.Game + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 669d164..2c770d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1392,6 +1392,13 @@ dependencies: "@tauri-apps/api" "^2.0.0" +"@tauri-apps/plugin-os@~2": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.2.0.tgz#ef5511269f59c0ccc580a9d09600034cfaa9743b" + integrity sha512-HszbCdbisMlu5QhCNAN8YIWyz2v33abAWha6+uvV2CKX8P5VSct/y+kEe22JeyqrxCnWlQ3DRx7s49Byg7/0EA== + dependencies: + "@tauri-apps/api" "^2.0.0" + "@tauri-apps/plugin-shell@>=2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0.tgz#b6fc88ab070fd5f620e46405715779aa44eb8428" @@ -3928,11 +3935,6 @@ mlly@^1.3.0, mlly@^1.4.2, mlly@^1.6.1, mlly@^1.7.1: pkg-types "^1.2.0" ufo "^1.5.4" -moment@^2.30.1: - version "2.30.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" - integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== - mri@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"