diff --git a/app.vue b/app.vue index b921045..9c11de6 100644 --- a/app.vue +++ b/app.vue @@ -6,7 +6,9 @@ diff --git a/composables/downloads.ts b/composables/downloads.ts new file mode 100644 index 0000000..860a8c3 --- /dev/null +++ b/composables/downloads.ts @@ -0,0 +1,27 @@ +import { listen } from "@tauri-apps/api/event"; + +export type QueueState = { + queue: Array<{ id: string; 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; +}); 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/gamepad.ts b/gamepad.ts new file mode 100644 index 0000000..5218db9 --- /dev/null +++ b/gamepad.ts @@ -0,0 +1,67 @@ +import gameControl, { XBoxButton, Button, type GCGamepad } from 'esm-gamecontroller.js'; + +let mainGamepad: Ref = ref(undefined); + +let buttonIndex = 0; +const buttons = computed((): any[] => { + let as = document.getElementsByTagName('a'); + let buttons = document.getElementsByTagName('button'); + return [].concat(Array.from(as)).concat(Array.from(buttons)); +}) + +const wrap = (num: number, min: number, max: number) => ((((num - min) % (max - min)) + (max - min)) % (max - min)) + min; + +setInterval(() => { + mainGamepad.value = navigator.getGamepads().filter(g => g !== null)[0]?.axes[1]; + console.log(navigator.getGamepads().filter(g => g !== null)[0]?.axes) + }, 100); + +watch(mainGamepad, (v) => { + console.log(v) + if (!v || v == 0) return; + buttonIndex = wrap(buttonIndex + v > 0 ? 1 : -1, 0, buttons.value.length); + + console.log(`Focusing ${buttonIndex}`) + console.log(buttons.value[buttonIndex]); + + buttons.value[buttonIndex].focus() +}) + + + +/* +setInterval(() => { + console.log(gamepads[0]); + console.log(gamepads[0].checkStatus()) +}, 1000); +*/ + +/* +window.ongamepadconnected = (e) => { + console.log("ongamepadconnected", e); +} + + +function selectButton(index: number) { + buttonIndex = (buttonIndex + index) % buttons.value.length; + console.log(`Selecting button ${buttonIndex}`); + buttons.value[buttonIndex].focus() +} + +function processGamepads() { + while (true) { + console.log("Processing gamepads"); + let gamepad = navigator.getGamepads()[0]; + if (!gamepad) continue; + let direction = gamepad.axes[1]; + if (direction > 0.1) { + selectButton(1) + console.log("Selecting button 1") + } + else if (direction < -0.1) { + selectButton(-1) + console.log("Selecting button -1") + } + } +} +*/ \ No newline at end of file diff --git a/package.json b/package.json index 7226f0f..fb6e428 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,19 @@ "dependencies": { "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", + "@maulingmonkey/gamepad": "^0.0.5", "@nuxtjs/tailwindcss": "^6.12.2", "@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", + "esm-gamecontroller.js": "^1.0.4", + "gamepad-events": "^0.7.0", "markdown-it": "^14.1.0", - "moment": "^2.30.1", "nuxt": "^3.13.0", "scss": "^0.2.4", + "tauri-plugin-gamepad-api": "^0.0.5", "vue": "latest", "vue-router": "latest", "vuedraggable": "^4.1.0" diff --git a/pages/library/[id]/index.vue b/pages/library/[id]/index.vue index a770ac8..4cc2e7e 100644 --- a/pages/library/[id]/index.vue +++ b/pages/library/[id]/index.vue @@ -20,8 +20,9 @@ installFlow()" - @play="() => play()" + @launch="() => launch()" @queue="() => queue()" + @uninstall="() => uninstall()" :status="status" /> diff --git a/pages/queue.vue b/pages/queue.vue index 6572203..907ab2d 100644 --- a/pages/queue.vue +++ b/pages/queue.vue @@ -1,18 +1,21 @@ - + + + + {{ formatKilobytes(stats.speed) }} + {{ formatTime(stats.time) }} left + + + + + + - + - + @@ -30,31 +33,19 @@ {{ element.status }} - - + + cancelGame(element.id)" class="group"> - + Loading... - + No items in the queue @@ -63,21 +54,67 @@ diff --git a/pages/settings.vue b/pages/settings.vue index 1e92a0f..1363f89 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -9,25 +9,18 @@ - + - + ? 'text-zinc-100' + : 'text-zinc-400 group-hover:text-zinc-200', + 'transition h-6 w-6 shrink-0', + ]" aria-hidden="true" /> {{ item.label }} @@ -43,11 +36,13 @@ diff --git a/pages/settings/compatibility.vue b/pages/settings/compatibility.vue new file mode 100644 index 0000000..27e0f69 --- /dev/null +++ b/pages/settings/compatibility.vue @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins.ts b/plugins.ts new file mode 100644 index 0000000..e65d2d4 --- /dev/null +++ b/plugins.ts @@ -0,0 +1 @@ +import 'tauri-plugin-gamepad-api' \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 74a6576..dfed7f9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -355,6 +355,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" @@ -965,6 +971,7 @@ dependencies = [ name = "drop-app" version = "0.1.0" dependencies = [ + "boxcar", "chrono", "directories", "hex", @@ -984,6 +991,8 @@ dependencies = [ "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-gamepad", + "tauri-plugin-os", "tauri-plugin-shell", "tauri-plugin-single-instance", "tokio", @@ -1061,6 +1070,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-iterator" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c280b9e6b3ae19e152d8e31cf47f18389781e119d4013a2a2bb0180e5facc635" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.91", +] + [[package]] name = "enumflags2" version = "0.7.10" @@ -1435,6 +1464,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" @@ -1457,6 +1496,40 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "gilrs" +version = "0.11.0" +source = "git+https://github.com/eugenehp/gilrs.git#cbcff6a4cd722a132bf2ca3c55fa9147775c9fe9" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "serde", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.0" +source = "git+https://github.com/eugenehp/gilrs.git#cbcff6a4cd722a132bf2ca3c55fa9147775c9fe9" +dependencies = [ + "core-foundation 0.10.0", + "inotify", + "io-kit-sys", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix 0.29.0", + "serde", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2022,6 +2095,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.6.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instant" version = "0.1.13" @@ -2031,6 +2124,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipnet" version = "2.10.1" @@ -2235,6 +2338,16 @@ dependencies = [ "libc", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -2306,6 +2419,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2468,6 +2590,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2853,6 +2987,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" @@ -4107,6 +4252,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" @@ -4388,6 +4542,39 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-gamepad" +version = "0.0.5" +source = "git+https://github.com/quexeky/tauri-plugin-gamepad.git#92cafc0aae66a3de01c252ae2ed599b329b0c686" +dependencies = [ + "enum-iterator", + "gilrs", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.9", + "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" @@ -5021,6 +5208,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.2.0" @@ -5856,7 +6049,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.27.1", "ordered-stream", "rand 0.8.5", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64f0f9b..6df35ed 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 @@ -41,12 +41,13 @@ http = "1.1.0" urlencoding = "2.1.3" md5 = "0.7.0" chrono = "0.4.38" +tauri-plugin-os = "2" +boxcar = "0.2.7" +tauri-plugin-gamepad = {git = "https://github.com/quexeky/tauri-plugin-gamepad.git", version = "0.0.5"} [dependencies.tauri] version = "2.1.1" -features = [ - "tray-icon" -] +features = ["tray-icon"] [dependencies.tokio] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4bafeab..bb5ce00 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,8 @@ "core:window:allow-maximize", "core:window:allow-close", "deep-link:default", - "dialog:default" + "dialog:default", + "os:default", + "gamepad:default" ] } \ No newline at end of file diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 0f1010f..9d6afb5 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -1,7 +1,6 @@ use std::{ env, sync::Mutex, - time::{SystemTime, UNIX_EPOCH}, }; use chrono::Utc; @@ -13,7 +12,7 @@ use url::Url; use crate::{ db::{DatabaseAuth, DatabaseImpls}, - remote::RemoteAccessError, + remote::{DropServerError, RemoteAccessError}, AppState, AppStatus, User, DB, }; @@ -78,7 +77,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)); } @@ -147,7 +152,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 +164,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 +184,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 +207,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 +225,5 @@ pub fn setup() -> Result<(AppStatus, Option), ()> { return Ok((AppStatus::SignedIn, Some(user_result.unwrap()))); } - drop(data); - Ok((AppStatus::SignedOut, None)) } 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..662d943 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -21,6 +21,11 @@ pub struct DatabaseAuth { pub client_id: String, } +pub struct GameStatusData { + version_name: String, + install_dir: String, +} + // Strings are version names for a particular game #[derive(Serialize, Clone, Deserialize)] #[serde(tag = "type")] @@ -42,6 +47,7 @@ pub enum GameTransientStatus { Downloading { version_name: String }, Uninstalling {}, Updating { version_name: String }, + Running {}, } #[derive(Serialize, Deserialize, Clone)] diff --git a/src-tauri/src/downloads/download_agent.rs b/src-tauri/src/downloads/download_agent.rs index 99d8217..8b070be 100644 --- a/src-tauri/src/downloads/download_agent.rs +++ b/src-tauri/src/downloads/download_agent.rs @@ -4,11 +4,9 @@ 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::collections::VecDeque; use std::fmt::{Display, Formatter}; use std::fs::{create_dir_all, File}; use std::io; @@ -32,24 +30,24 @@ pub struct GameDownloadAgent { pub version: String, pub control_flag: DownloadThreadControl, contexts: Vec, - completed_contexts: Mutex>, + completed_contexts: VecDeque, pub manifest: Mutex>, pub progress: Arc, sender: Sender, pub stored_manifest: StoredManifest, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum GameDownloadError { Communication(RemoteAccessError), Checksum, Setup(SetupError), Lock, - IoError(io::Error), + IoError(io::ErrorKind), DownloadError, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum SetupError { Context, } @@ -101,7 +99,7 @@ impl GameDownloadAgent { control_flag, manifest: Mutex::new(None), contexts: Vec::new(), - completed_contexts: Mutex::new(Vec::new()), + completed_contexts: VecDeque::new(), progress: Arc::new(ProgressObject::new(0, 0, sender.clone())), sender, stored_manifest, @@ -219,12 +217,9 @@ impl GameDownloadAgent { 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() - ); + self.completed_contexts.clear(); + self.completed_contexts + .extend(self.stored_manifest.get_completed_contexts()); for (raw_path, chunk) in manifest { let path = base_path.join(Path::new(&raw_path)); @@ -260,7 +255,7 @@ impl GameDownloadAgent { Ok(()) } - pub fn run(&self) -> Result<(), ()> { + pub fn run(&mut self) -> Result<(), ()> { info!("downloading game: {}", self.id); const DOWNLOAD_MAX_THREADS: usize = 1; @@ -269,56 +264,57 @@ impl GameDownloadAgent { .build() .unwrap(); - let completed_indexes = Arc::new(Mutex::new(Vec::new())); + let completed_indexes = Arc::new(boxcar::Vec::new()); let completed_indexes_loop_arc = completed_indexes.clone(); - pool.scope(move |scope| { - let completed_lock = self.completed_contexts.lock().unwrap(); - + pool.scope(|scope| { for (index, context) in self.contexts.iter().enumerate() { + let completed_indexes = completed_indexes_loop_arc.clone(); + let progress = self.progress.get(index); // Clone arcs let progress_handle = ProgressHandle::new(progress, self.progress.clone()); // If we've done this one already, skip it - if completed_lock.contains(&index) { + if self.completed_contexts.contains(&index) { progress_handle.add(context.length); continue; } let context = context.clone(); let control_flag = self.control_flag.clone(); // Clone arcs - let completed_indexes_ref = completed_indexes_loop_arc.clone(); + + let sender = self.sender.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); + completed_indexes.push(index); } } Err(e) => { - error!("GameDownloadError: {}", e); - self.sender.send(DownloadManagerSignal::Error(e)).unwrap(); + error!("{}", e); + sender.send(DownloadManagerSignal::Error(e)).unwrap(); } } }); } }); + let newly_completed = completed_indexes.to_owned(); + let completed_lock_len = { - let mut completed_lock = self.completed_contexts.lock().unwrap(); - let newly_completed_lock = completed_indexes.lock().unwrap(); + for (item, item_ref) in newly_completed.iter() { + self.completed_contexts.push_front(item); + } - completed_lock.extend(newly_completed_lock.iter()); - - completed_lock.len() + self.completed_contexts.len() }; // If we're not out of contexts, we're not done, so we don't fire completed if completed_lock_len != self.contexts.len() { info!("da for {} exited without completing", self.id.clone()); self.stored_manifest - .set_completed_contexts(&self.completed_contexts); + .set_completed_contexts(&self.completed_contexts.clone().into()); info!("Setting completed contexts"); self.stored_manifest.write(); info!("Wrote completed contexts"); diff --git a/src-tauri/src/downloads/download_logic.rs b/src-tauri/src/downloads/download_logic.rs index 52cd6ba..420d0ef 100644 --- a/src-tauri/src/downloads/download_logic.rs +++ b/src-tauri/src/downloads/download_logic.rs @@ -6,14 +6,11 @@ use crate::DB; use log::warn; use md5::{Context, Digest}; use reqwest::blocking::Response; -use tauri::utils::acl::Permission; 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::{ fs::{File, OpenOptions}, io::{self, BufWriter, Seek, SeekFrom, Write}, @@ -186,7 +183,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| GameDownloadError::IoError(e.kind()))?; if !completed { return Ok(false); }; diff --git a/src-tauri/src/downloads/download_manager.rs b/src-tauri/src/downloads/download_manager.rs index 8676087..55bfa83 100644 --- a/src-tauri/src/downloads/download_manager.rs +++ b/src-tauri/src/downloads/download_manager.rs @@ -40,9 +40,14 @@ pub enum DownloadManagerSignal { /// Any error which occurs in the agent Error(GameDownloadError), /// Pushes UI update - Update, + UpdateUIQueue, + UpdateUIStats(usize, usize), //kb/s and seconds + /// Uninstall game + /// Takes game ID + Uninstall(String), } +#[derive(Debug, Clone)] pub enum DownloadManagerStatus { Downloading, Paused, @@ -51,6 +56,15 @@ pub enum DownloadManagerStatus { Finished, } +impl Serialize for DownloadManagerStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&format!["{:?}", self]) + } +} + #[derive(Serialize, Clone)] pub enum GameDownloadStatus { Queued, @@ -142,7 +156,7 @@ impl DownloadManager { 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) { @@ -174,7 +188,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,6 +205,11 @@ impl DownloadManager { .unwrap(); self.terminator.join() } + pub fn uninstall_game(&self, game_id: String) { + self.command_sender + .send(DownloadManagerSignal::Uninstall(game_id)) + .unwrap(); + } } /// Takes in the locked value from .edit() and attempts to diff --git a/src-tauri/src/downloads/download_manager_builder.rs b/src-tauri/src/downloads/download_manager_builder.rs index 9d2ab9d..91ac61c 100644 --- a/src-tauri/src/downloads/download_manager_builder.rs +++ b/src-tauri/src/downloads/download_manager_builder.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + fs::remove_dir_all, sync::{ mpsc::{channel, Receiver, Sender}, Arc, Mutex, RwLockWriteGuard, @@ -12,7 +13,10 @@ use tauri::{AppHandle, Emitter}; use crate::{ db::{Database, GameStatus, GameTransientStatus}, - library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData}, + library::{ + on_game_complete, push_game_update, QueueUpdateEvent, + QueueUpdateEventQueueData, StatsUpdateEvent, + }, state::GameStatusManager, DB, }; @@ -108,7 +112,7 @@ impl DownloadManagerBuilder { DownloadManager::new(terminator, queue, active_progress, command_sender) } - fn set_game_status, &String) -> ()>( + fn set_game_status, &String)>( &self, id: String, setter: F, @@ -120,18 +124,16 @@ impl DownloadManagerBuilder { let status = GameStatusManager::fetch_state(&id); - self.app_handle - .emit( - &format!("update_game/{}", id), - GameUpdateEvent { - game_id: id, - status, - }, - ) - .unwrap(); + push_game_update(&self.app_handle, id, status); } - fn push_manager_update(&self) { + fn push_ui_stats_update(&self, kbs: usize, time: usize) { + let event_data = StatsUpdateEvent { speed: kbs, time }; + + self.app_handle.emit("update_stats", event_data).unwrap(); + } + + fn push_ui_queue_update(&self) { let queue = self.download_queue.read(); let queue_objs: Vec = queue .iter() @@ -142,7 +144,14 @@ impl DownloadManagerBuilder { }) .collect(); - let event_data = QueueUpdateEvent { queue: queue_objs }; + let status_handle = self.status.lock().unwrap(); + let status = status_handle.clone(); + drop(status_handle); + + let event_data = QueueUpdateEvent { + queue: queue_objs, + status, + }; self.app_handle.emit("update_queue", event_data).unwrap(); } @@ -159,9 +168,7 @@ impl DownloadManagerBuilder { drop(download_thread_lock); } - fn sync_download_agent(&self) {} - - fn remove_and_cleanup_game(&mut self, game_id: &String) -> Arc> { + fn remove_and_cleanup_front_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(); @@ -206,40 +213,117 @@ impl DownloadManagerBuilder { DownloadManagerSignal::Cancel => { self.manage_cancel_signal(); } - DownloadManagerSignal::Update => { - self.push_manager_update(); + DownloadManagerSignal::UpdateUIQueue => { + self.push_ui_queue_update(); + } + DownloadManagerSignal::UpdateUIStats(kbs, time) => { + self.push_ui_stats_update(kbs, time); } DownloadManagerSignal::Finish => { self.stop_and_wait_current_download(); return Ok(()); } DownloadManagerSignal::Remove(game_id) => { - self.manage_remove_game(game_id); + self.manage_remove_game_queue(game_id); + } + DownloadManagerSignal::Uninstall(game_id) => { + self.uninstall_game(game_id); } }; } } - fn manage_remove_game(&mut self, game_id: String) { + fn uninstall_game(&mut self, game_id: String) { + // Removes the game if it's in the queue + self.manage_remove_game_queue(game_id.clone()); + + let mut db_handle = DB.borrow_data_mut().unwrap(); + db_handle + .games + .transient_statuses + .entry(game_id.clone()) + .and_modify(|v| *v = GameTransientStatus::Uninstalling {}); + push_game_update( + &self.app_handle, + game_id.clone(), + (None, Some(GameTransientStatus::Uninstalling {})), + ); + + let previous_state = db_handle.games.statuses.get(&game_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 { + GameStatus::Installed { + version_name, + install_dir, + } => Some((version_name, install_dir)), + GameStatus::SetupRequired { + version_name, + install_dir, + } => Some((version_name, install_dir)), + _ => None, + } { + db_handle + .games + .transient_statuses + .entry(game_id.clone()) + .and_modify(|v| *v = GameTransientStatus::Uninstalling {}); + drop(db_handle); + + let sender = self.sender.clone(); + let app_handle = self.app_handle.clone(); + spawn(move || match remove_dir_all(install_dir) { + Err(e) => { + sender + .send(DownloadManagerSignal::Error(GameDownloadError::IoError( + e.kind(), + ))) + .unwrap(); + } + Ok(_) => { + let mut db_handle = DB.borrow_data_mut().unwrap(); + db_handle.games.transient_statuses.remove(&game_id); + db_handle + .games + .statuses + .entry(game_id.clone()) + .and_modify(|e| *e = GameStatus::Remote {}); + drop(db_handle); + DB.save().unwrap(); + + info!("uninstalled {}", game_id); + + push_game_update(&app_handle, game_id, (Some(GameStatus::Remote {}), None)); + } + }); + } + } + + fn manage_remove_game_queue(&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); + let index = self.download_queue.get_by_id(game_id.clone()); + if let Some(index) = index { + 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(); + self.push_ui_queue_update(); } fn manage_stop_signal(&mut self) { @@ -256,11 +340,16 @@ impl DownloadManagerBuilder { // 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 = self.remove_and_cleanup_front_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(); + let install_dir = download_agent_lock + .stored_manifest + .base_path + .clone() + .to_string_lossy() + .to_string(); drop(download_agent_lock); @@ -275,12 +364,30 @@ impl DownloadManagerBuilder { } } } - self.sender.send(DownloadManagerSignal::Update).unwrap(); + self.sender + .send(DownloadManagerSignal::UpdateUIQueue) + .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"); + + if let Some(index) = self.download_queue.get_by_id(id.clone()) { + // Should always give us a value + if let Some(download_agent) = self.download_agent_registry.get(&id) { + let download_agent_handle = download_agent.lock().unwrap(); + if download_agent_handle.version == version { + info!("game with same version already queued, skipping"); + return; + } + // If it's not the same, we want to cancel the current one, and then add the new one + drop(download_agent_handle); + + self.manage_remove_game_queue(id.clone()); + } + } + let download_agent = Arc::new(Mutex::new(GameDownloadAgent::new( id.clone(), version, @@ -309,7 +416,9 @@ impl DownloadManagerBuilder { GameTransientStatus::Downloading { version_name }, ); }); - self.sender.send(DownloadManagerSignal::Update).unwrap(); + self.sender + .send(DownloadManagerSignal::UpdateUIQueue) + .unwrap(); } fn manage_go_signal(&mut self) { @@ -386,12 +495,19 @@ impl DownloadManagerBuilder { ); }); - self.sender.send(DownloadManagerSignal::Update).unwrap(); + self.sender + .send(DownloadManagerSignal::UpdateUIQueue) + .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 + self.stop_and_wait_current_download(); + self.remove_and_cleanup_front_game(¤t_status.id); // Remove all the locks and shit, and remove from queue + + self.app_handle + .emit("download_error", error.to_string()) + .unwrap(); let mut lock = current_status.status.lock().unwrap(); *lock = GameDownloadStatus::Error; @@ -402,7 +518,9 @@ impl DownloadManagerBuilder { db_handle.games.transient_statuses.remove(id); }); - self.sender.send(DownloadManagerSignal::Update).unwrap(); + self.sender + .send(DownloadManagerSignal::UpdateUIQueue) + .unwrap(); } fn manage_cancel_signal(&mut self) { self.stop_and_wait_current_download(); diff --git a/src-tauri/src/downloads/mod.rs b/src-tauri/src/downloads/mod.rs index 023b2c7..2694cd7 100644 --- a/src-tauri/src/downloads/mod.rs +++ b/src-tauri/src/downloads/mod.rs @@ -7,4 +7,4 @@ mod download_thread_control_flag; mod manifest; mod progress_object; pub mod queue; -mod stored_manifest; \ No newline at end of file +mod stored_manifest; diff --git a/src-tauri/src/downloads/progress_object.rs b/src-tauri/src/downloads/progress_object.rs index 143656d..2e0a194 100644 --- a/src-tauri/src/downloads/progress_object.rs +++ b/src-tauri/src/downloads/progress_object.rs @@ -2,9 +2,9 @@ use std::{ sync::{ atomic::{AtomicUsize, Ordering}, mpsc::Sender, - Arc, Mutex, + Arc, Mutex, RwLock, }, - time::Instant, + time::{Duration, Instant}, }; use log::info; @@ -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/stored_manifest.rs b/src-tauri/src/downloads/stored_manifest.rs index cd4006d..bb5e6b9 100644 --- a/src-tauri/src/downloads/stored_manifest.rs +++ b/src-tauri/src/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/lib.rs b/src-tauri/src/lib.rs index 6e6dcf5..47214a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,42 +3,46 @@ mod db; mod downloads; mod library; +mod cleanup; mod process; mod remote; mod state; #[cfg(test)] mod tests; -mod cleanup; use crate::db::DatabaseImpls; use auth::{auth_initiate, generate_authorization_header, recieve_handshake, retry_connect}; 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, GameStatus, DATA_ROOT_DIR, }; use downloads::download_commands::*; use downloads::download_manager::DownloadManager; use downloads::download_manager_builder::DownloadManagerBuilder; +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 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::compat::CompatibilityManager; use process::process_commands::launch_game; use process::process_manager::ProcessManager; use remote::{gen_drop_url, use_remote}; use serde::{Deserialize, Serialize}; +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_plugin_deep_link::DeepLinkExt; @@ -65,7 +69,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 +77,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 +116,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 +129,72 @@ 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.games.statuses.clone(); + drop(db_handle); + for (game_id, status) in statuses.into_iter() { + match status { + db::GameStatus::Remote {} => {} + db::GameStatus::SetupRequired { + version_name: _, + install_dir, + } => { + let install_dir_path = Path::new(&install_dir); + if !install_dir_path.exists() { + missing_games.push(game_id); + } + } + db::GameStatus::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 + .games + .statuses + .entry(game_id.to_string()) + .and_modify(|v| *v = GameStatus::Remote {}); + } + drop(db_handle); + info!("finished setup!"); + 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,7 +204,7 @@ pub fn run() { })); } - let mut app = builder + let app = builder .plugin(tauri_plugin_deep_link::init()) .invoke_handler(tauri::generate_handler![ // Core utils @@ -178,11 +230,13 @@ pub fn run() { pause_game_downloads, resume_game_downloads, cancel_game, + uninstall_game, // Processes launch_game, ]) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_gamepad::init()) .setup(|app| { let handle = app.handle().clone(); let state = setup(handle); @@ -272,8 +326,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 +346,16 @@ pub fn run() { responder.respond(resp); }) - .on_window_event(|window, event| match event { - WindowEvent::CloseRequested { api, .. } => { - window.hide().unwrap(); - api.prevent_close(); - } - _ => (), + .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, .. } => { - if code.is_none() { - api.prevent_exit(); - } + app.run(|app_handle, event| if let RunEvent::ExitRequested { code, api, .. } = event { + if code.is_none() { + api.prevent_exit(); } - _ => {} }); } diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 6e8536e..e5705a1 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -7,8 +7,8 @@ use urlencoding::encode; use crate::db::DatabaseImpls; use crate::db::GameVersion; -use crate::db::{GameStatus, GameTransientStatus}; -use crate::downloads::download_manager::GameDownloadStatus; +use crate::db::GameStatus; +use crate::downloads::download_manager::{DownloadManagerStatus, GameDownloadStatus}; use crate::process::process_manager::Platform; use crate::remote::RemoteAccessError; use crate::state::{GameStatusManager, GameStatusWithTransient}; @@ -37,7 +37,7 @@ pub struct Game { #[derive(serde::Serialize, Clone)] pub struct GameUpdateEvent { pub game_id: String, - pub status: (Option, Option), + pub status: GameStatusWithTransient, } #[derive(Serialize, Clone)] @@ -50,6 +50,13 @@ pub struct QueueUpdateEventQueueData { #[derive(serde::Serialize, Clone)] pub struct QueueUpdateEvent { pub queue: Vec, + pub status: DownloadManagerStatus, +} + +#[derive(serde::Serialize, Clone)] +pub struct StatsUpdateEvent { + pub speed: usize, + pub time: usize, } // Game version with some fields missing and size information @@ -232,6 +239,30 @@ pub fn fetch_game_verion_options<'a>( fetch_game_verion_options_logic(game_id, state).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn uninstall_game( + game_id: String, + state: tauri::State<'_, Mutex>, +) -> Result<(), String> { + let state_lock = state.lock().unwrap(); + state_lock.download_manager.uninstall_game(game_id); + drop(state_lock); + + Ok(()) +} + +pub fn push_game_update(app_handle: &AppHandle, id: String, status: GameStatusWithTransient) { + app_handle + .emit( + &format!("update_game/{}", id), + GameUpdateEvent { + game_id: id, + status, + }, + ) + .unwrap(); +} + pub fn on_game_complete( game_id: String, version_name: String, 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..6b4aba5 100644 --- a/src-tauri/src/process/process_commands.rs +++ b/src-tauri/src/process/process_commands.rs @@ -3,11 +3,14 @@ use std::sync::Mutex; use crate::AppState; #[tauri::command] -pub fn launch_game(game_id: String, state: tauri::State<'_, Mutex>) -> Result<(), String> { +pub fn launch_game( + 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)?; + process_manager_lock.launch_process(game_id)?; drop(process_manager_lock); drop(state_lock); diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index 5266849..a818522 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}, 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 tauri::{AppHandle, Manager}; use crate::{ - db::{GameStatus, DATA_ROOT_DIR}, - DB, + db::{GameStatus, GameTransientStatus, DATA_ROOT_DIR}, + library::push_game_update, + 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,12 +39,28 @@ 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) { + fn process_command(&self, install_dir: &String, raw_command: String) -> (PathBuf, Vec) { let command_components = raw_command.split(" ").collect::>(); let root = command_components[0].to_string(); @@ -47,41 +68,90 @@ impl ProcessManager { 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, args) + } + + fn on_process_finish(&mut self, game_id: String, result: Result) { + if !self.processes.contains_key(&game_id) { + warn!("process on_finish was called, but game_id is no longer valid. finished with result: {:?}", result); + return; + } + + info!("process for {} exited with {:?}", game_id, result); + + self.processes.remove(&game_id); + + let mut db_handle = DB.borrow_data_mut().unwrap(); + db_handle.games.transient_statuses.remove(&game_id); + + let current_state = db_handle.games.statuses.get(&game_id).cloned(); + if let Some(saved_state) = current_state { + if let GameStatus::SetupRequired { + version_name, + install_dir, + } = saved_state { + if let Ok(exit_code) = result { + if exit_code.success() { + db_handle.games.statuses.insert( + game_id.clone(), + GameStatus::Installed { + version_name: version_name.to_string(), + install_dir: install_dir.to_string(), + }, + ); + } + } + } + } + drop(db_handle); + + let status = GameStatusManager::fetch_state(&game_id); + + push_game_update(&self.app_handle, game_id.clone(), 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> { + pub fn launch_process(&mut self, game_id: String) -> Result<(), String> { if self.processes.contains_key(&game_id) { 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(); let game_status = db_lock .games .statuses .get(&game_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 { + GameStatus::Installed { + version_name, + install_dir, + } => Some((version_name, install_dir)), + GameStatus::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 @@ -90,13 +160,30 @@ impl ProcessManager { .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 { + GameStatus::Installed { + version_name: _, + install_dir: _, + } => game_version.launch_command.clone(), + GameStatus::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) @@ -107,46 +194,132 @@ impl ProcessManager { ) .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", + game_id, + 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( + &game_id, + version_name, + command.to_str().unwrap().to_owned(), + args, + &target_current_dir.to_string(), + log_file, + error_file, + )?; + + let launch_process_handle = Arc::new(Mutex::new(launch_process)); + + db_lock + .games + .transient_statuses + .insert(game_id.clone(), GameTransientStatus::Running {}); + + push_game_update( + &self.app_handle, + game_id.clone(), + (None, Some(GameTransientStatus::Running {})), + ); + + let wait_thread_handle = launch_process_handle.clone(); + let wait_thread_apphandle = self.app_handle.clone(); + let wait_thread_game_id = game_id.clone(); + + spawn(move || { + let mut child_handle = wait_thread_handle.lock().unwrap(); + let result: Result = child_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); + drop(child_handle); + }); + + self.processes.insert(game_id, launch_process_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, + game_id: &String, + version_name: &String, + 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, + game_id: &String, + version_name: &String, + 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; - }); +struct UMULauncher; +impl ProcessHandler for UMULauncher { + fn launch_process( + &self, + game_id: &String, + version_name: &String, + command: String, + args: Vec, + current_dir: &String, + log_file: File, + error_file: File, + ) -> Result { + todo!() + } +} diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 33655c2..1559155 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -1,4 +1,5 @@ use std::{ + error::Error, fmt::{Display, Formatter}, sync::{Arc, Mutex}, }; @@ -16,23 +17,33 @@ pub enum RemoteAccessError { ParsingError(ParseError), InvalidCodeError(u16), InvalidEndpoint, - HandshakeFailed, + HandshakeFailed(String), GameNotFound, InvalidResponse, InvalidRedirect, ManifestDownloadFailed(StatusCode, String), + OutOfSync, } impl Display for RemoteAccessError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - RemoteAccessError::FetchError(error) => write!(f, "{}", error), + 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::InvalidRedirect => write!(f, "Server redirect was invalid"), @@ -41,6 +52,7 @@ impl Display for RemoteAccessError { "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."), } } } @@ -63,6 +75,15 @@ impl From for RemoteAccessError { impl std::error::Error for RemoteAccessError {} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DropServerError { + pub status_code: usize, + pub status_message: String, + pub message: String, + pub url: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DropHealthcheck { @@ -71,7 +92,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 +124,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 index 9f66307..cf764da 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,14 +1,10 @@ -use std::collections::HashMap; use crate::{ db::{GameStatus, GameTransientStatus}, DB, }; -pub type GameStatusWithTransient = ( - Option, - Option, -); +pub type GameStatusWithTransient = (Option, Option); pub struct GameStatusManager {} impl GameStatusManager { @@ -26,6 +22,6 @@ impl GameStatusManager { return (offline_state, None); } - return (None, None); + (None, None) } } 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..63e2f59 100644 --- a/types.ts +++ b/types.ts @@ -52,6 +52,7 @@ export enum GameStatusEnum { Updating = "Updating", Uninstalling = "Uninstalling", SetupRequired = "SetupRequired", + Running = "Running" } export type GameStatus = { diff --git a/yarn.lock b/yarn.lock index 669d164..867ec0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -773,6 +773,11 @@ semver "^7.3.5" tar "^6.1.11" +"@maulingmonkey/gamepad@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@maulingmonkey/gamepad/-/gamepad-0.0.5.tgz#50220895cc310f66f0678e07fa1e17959332ac20" + integrity sha512-dokZlauyTVAW2UEFC8T/L+1Oc7oK+vgakjfa4JZQPxkSoCTf7e3l0zV3E/Gys3Ps43Mmc8mnX+7d1ICTyD5pcg== + "@netlify/functions@^2.8.0": version "2.8.2" resolved "https://registry.yarnpkg.com/@netlify/functions/-/functions-2.8.2.tgz#653395b901a74a6189e913a089f9cb90083ca6ce" @@ -1307,6 +1312,11 @@ dependencies: "@tanstack/virtual-core" "3.10.8" +"@tauri-apps/api@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.1.1.tgz#77d4ddb683d31072de4e6a47c8613d9db011652b" + integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A== + "@tauri-apps/api@>=2.0.0", "@tauri-apps/api@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.1.tgz#dc49d899fb873b96ee1d46a171384625ba5ad404" @@ -1392,6 +1402,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" @@ -2769,6 +2786,11 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== +esm-gamecontroller.js@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/esm-gamecontroller.js/-/esm-gamecontroller.js-1.0.4.tgz#215998947c796ead90133b4cb2cef7852729d985" + integrity sha512-787c6eP+DwFJqUsAudnb0zt7ipG+pGYjVT1vyIecsVpRW/GJw5YGV5FQT5asuMQ2k1JCnTY3MTVEUftKkyYoNw== + estree-walker@2.0.2, estree-walker@^2.0.1, estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" @@ -2945,6 +2967,11 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +gamepad-events@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/gamepad-events/-/gamepad-events-0.7.0.tgz#63566540dd930f7d5997335a401118b390399494" + integrity sha512-930McsulrWFlGqzlC5plW4WQhqL+3PZ9PAaICqT7OtQLSQ5BzF+wJ32LPzjpZYaoCH7NzvEgtzadqZs8rzNkJw== + gauge@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" @@ -3928,11 +3955,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" @@ -5580,6 +5602,14 @@ tar@^6.1.11, tar@^6.2.0: mkdirp "^1.0.3" yallist "^4.0.0" +tauri-plugin-gamepad-api@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/tauri-plugin-gamepad-api/-/tauri-plugin-gamepad-api-0.0.5.tgz#39f9137421b632956f1d617a239b08814ce2c19f" + integrity sha512-WO0xwXmPr4PMe9HzljKUbTkGv7J6ur2Df/OuEPsxBeSA5QPffAJR3N32M4pM4yW72D9Vt19XplPnHHDVATVMSw== + dependencies: + "@tauri-apps/api" "2.1.1" + tslib "^2.1.0" + terser@^5.17.4: version "5.34.1" resolved "https://registry.yarnpkg.com/terser/-/terser-5.34.1.tgz#af40386bdbe54af0d063e0670afd55c3105abeb6"
@@ -30,31 +33,19 @@ {{ element.status }} - - + + cancelGame(element.id)" class="group"> - +
{{ element.status }}
Loading...