diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8f019520..0751b03a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2894,7 +2894,7 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rai-pal" -version = "0.7.1" +version = "0.8.0" dependencies = [ "async-trait", "base64 0.21.5", @@ -2915,6 +2915,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_urlencoded", "specta", "steamlocate", "tauri", @@ -2924,6 +2925,7 @@ dependencies = [ "tauri-runtime", "tauri-specta", "thiserror", + "tokio", "uuid", "webkit2gtk", "webview2-com 0.27.0", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 71cedd23..481c6de5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rai-pal" -version = "0.7.1" +version = "0.8.0" authors = ["Raicuparta"] license = "GPL-3.0-or-later" repository = "https://github.com/Raicuparta/rai-pal" @@ -41,13 +41,14 @@ serde_json = "1.0.108" lazy_static = "1.4.0" uuid = "1.6.1" rand = "0.8.5" -winreg = "0.52.0" tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = ["colored"] } log = "0.4.20" tauri-runtime = "0.14.1" base64 = "0.21.5" chrono = "0.4.31" rusqlite = { version = "0.30.0", features = ["bundled"] } +tokio = "1.35.1" +serde_urlencoded = "0.7.1" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "0.18.2" @@ -55,6 +56,7 @@ webview2-com = "0.27.0" # Needed for getting the webview window on linux [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3.9", features = ["shellapi", "winuser"] } +winreg = "0.52.0" [features] # this feature is used for production builds or when `devPath` points to the filesystem @@ -168,7 +170,7 @@ single_match_else = "warn" stable_sort_primitive = "warn" string_add_assign = "warn" struct_excessive_bools = "warn" -too_many_lines = "warn" +too_many_lines = "allow" transmute_ptr_to_ptr = "warn" trivially_copy_pass_by_ref = "warn" unchecked_duration_subtraction = "warn" diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 4d06fbfb..0fb3747e 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -14,6 +14,7 @@ use crate::{ maps::TryGettable, mod_loaders::mod_loader, owned_game, + remote_game, remote_mod, Error, Result, @@ -25,6 +26,7 @@ pub struct AppState { pub mod_loaders: Mutex>, pub local_mods: Mutex>, pub remote_mods: Mutex>, + pub remote_games: Mutex>, } type TauriState<'a> = tauri::State<'a, AppState>; diff --git a/backend/src/events.rs b/backend/src/events.rs index 8da85cc7..98b6d810 100644 --- a/backend/src/events.rs +++ b/backend/src/events.rs @@ -9,6 +9,7 @@ use crate::serializable_enum; serializable_enum!(AppEvent { SyncInstalledGames, SyncOwnedGames, + SyncRemoteGames, SyncModLoaders, SyncLocalMods, SyncRemoteMods, diff --git a/backend/src/game_engines/unity.rs b/backend/src/game_engines/unity.rs index 5cc519cc..1402187f 100644 --- a/backend/src/game_engines/unity.rs +++ b/backend/src/game_engines/unity.rs @@ -143,12 +143,10 @@ fn get_alt_architecture(game_path: &Path) -> Option { // This would usually be UnityPlayer.dll, steam_api.dll, etc. // Here the guessing can go wrong, since it's possible a top level dll is actual x86, // when the actual game is x64. - if let Ok(mut top_level_dlls) = glob_path(&game_folder.join("*.dll")) { - if let Some(Ok(first_dll)) = top_level_dlls.next() { - if let Ok(file) = fs::read(first_dll) { - if let Ok((_, arch)) = read_windows_binary(&file) { - return arch; - } + if let Some(first_dll) = glob_path(&game_folder.join("*.dll")).first() { + if let Ok(file) = fs::read(first_dll) { + if let Ok((_, arch)) = read_windows_binary(&file) { + return arch; } } } @@ -159,14 +157,12 @@ fn get_alt_architecture(game_path: &Path) -> Option { // so I'm leaving it for last. (mostly because my own UUVR mod drops both the // x86 and x64 dlls in the folder so Unity picks the right one) if let Ok(unity_data_path) = get_unity_data_path(game_path) { - if let Ok(mut plugin_dlls) = - glob_path(&unity_data_path.join("Plugins").join("**").join("*.dll")) + if let Some(first_dll) = + glob_path(&unity_data_path.join("Plugins").join("**").join("*.dll")).first() { - if let Some(Ok(first_dll)) = plugin_dlls.next() { - if let Ok(file) = fs::read(first_dll) { - if let Ok((_, arch)) = read_windows_binary(&file) { - return arch; - } + if let Ok(file) = fs::read(first_dll) { + if let Ok((_, arch)) = read_windows_binary(&file) { + return arch; } } } diff --git a/backend/src/game_engines/unreal.rs b/backend/src/game_engines/unreal.rs index 2688b25b..f13cfd26 100644 --- a/backend/src/game_engines/unreal.rs +++ b/backend/src/game_engines/unreal.rs @@ -196,9 +196,8 @@ fn get_shipping_exe(game_exe_path: &Path) -> PathBuf { return game_exe_path.to_path_buf(); } - if let Some(Ok(sibling_shipping_exe)) = glob_path(&parent.join("*Shipping.exe")) - .ok() - .and_then(|mut paths| paths.next()) + if let Some(sibling_shipping_exe) = + glob_path(&parent.join("*Shipping.exe")).first().cloned() { // Case where given exe is a sibling of the shipping exe. return sibling_shipping_exe; @@ -212,7 +211,7 @@ fn get_shipping_exe(game_exe_path: &Path) -> PathBuf { // From here, we start presuming that the given exe is a launcher at the root level, // and we need to dig down to find the shipping exe. - if let Ok(globbed_paths) = glob_path( + let globbed_paths = glob_path( &parent // This portion of the path would usually be the game's name, but no way to guess that. // We know it's not "Engine", but can't exclude with the rust glob crate (we filter it below). @@ -224,29 +223,22 @@ fn get_shipping_exe(game_exe_path: &Path) -> PathBuf { .join("Win*") // The file name may or may not end with Shipping.exe, so we don't test for that yet. .join("*.exe"), - ) { - let mut suitable_paths = globbed_paths.filter_map(|path_result| { - let path = path_result.ok()?; + ); - // Filter for the correct Win* folders, since the glob couldn't do it above. - if path.parent().is_some_and(is_valid_win_folder) + let mut suitable_paths = globbed_paths.iter().filter(|path| { + // Filter for the correct Win* folders, since the glob couldn't do it above. + path.parent().is_some_and(is_valid_win_folder) // The Engine folder can have similar structure, but it's not the one we want. && !path.starts_with(parent.join("Engine")) - { - Some(path) - } else { - None - } - }); - - let first_path = suitable_paths.next(); - if let Some(best_path) = suitable_paths - // Exe that looks like a shipping exe takes priority. - .find(|path| is_shipping_exe(path)) - .or(first_path) - { - return best_path; - } + }); + + let first_path = suitable_paths.next(); + if let Some(best_path) = suitable_paths + // Exe that looks like a shipping exe takes priority. + .find(|path| is_shipping_exe(path)) + .or(first_path) + { + return best_path.clone(); } } diff --git a/backend/src/game_mod.rs b/backend/src/game_mod.rs index 9e7d42ef..089d8a7a 100644 --- a/backend/src/game_mod.rs +++ b/backend/src/game_mod.rs @@ -1,15 +1,8 @@ -use std::collections::{ - HashMap, - HashSet, -}; - use crate::{ game_engines::{ game_engine::GameEngineBrand, unity::UnityScriptingBackend, }, - local_mod::{self,}, - remote_mod, serializable_struct, }; @@ -19,28 +12,3 @@ serializable_struct!(CommonModData { pub unity_backend: Option, pub loader_id: String, // TODO make enum }); - -pub type CommonDataMap = HashMap; - -pub fn get_common_data_map( - local_mods: &local_mod::Map, - remote_mods: &remote_mod::Map, -) -> CommonDataMap { - let keys: HashSet<_> = remote_mods - .keys() - .chain(local_mods.keys()) - .cloned() - .collect(); - - keys.iter() - .filter_map(|key| { - Some(( - key.clone(), - local_mods.get(key).map_or_else( - || remote_mods.get(key).map(|local| local.common.clone()), - |remote| Some(remote.common.clone()), - )?, - )) - }) - .collect() -} diff --git a/backend/src/installed_game.rs b/backend/src/installed_game.rs index aaffe8f4..529c507b 100644 --- a/backend/src/installed_game.rs +++ b/backend/src/installed_game.rs @@ -1,22 +1,21 @@ use std::{ collections::HashMap, - fs::{ - self, - File, - }, + fs::{self,}, path::{ Path, PathBuf, }, }; +use log::error; + use crate::{ game_executable::GameExecutable, - game_mod, mod_manifest, owned_game, paths::{ self, + glob_path, hash_path, }, providers::{ @@ -41,7 +40,7 @@ serializable_struct!(InstalledGame { }); pub type Map = HashMap; -type InstalledModVersions = HashMap>; +type InstalledModVersions = HashMap; impl InstalledGame { pub fn new(path: &Path, name: &str, provider_id: ProviderId) -> Option { @@ -68,8 +67,10 @@ impl InstalledGame { let executable = GameExecutable::new(path)?; - Some(Self { - id: hash_path(&executable.path), + let game_id = hash_path(&executable.path); + + let mut installed_game = Self { + id: game_id, name: name.to_string(), provider: provider_id, installed_mod_versions: HashMap::default(), @@ -78,7 +79,11 @@ impl InstalledGame { thumbnail_url: None, start_command: None, owned_game_id: None, - }) + }; + + installed_game.refresh_installed_mods(); + + Some(installed_game) } pub fn set_discriminator(&mut self, discriminator: &str) -> &Self { @@ -116,8 +121,8 @@ impl InstalledGame { Ok(()) } - pub fn update_available_mods(&mut self, data_map: &game_mod::CommonDataMap) { - self.installed_mod_versions = self.get_available_mods(data_map); + pub fn refresh_installed_mods(&mut self) { + self.installed_mod_versions = self.get_available_mods(); } pub fn open_game_folder(&self) -> Result { @@ -163,17 +168,31 @@ impl InstalledGame { Ok(()) } - pub fn refresh_mods(&mut self, data_map: &game_mod::CommonDataMap) { - self.installed_mod_versions = self.get_available_mods(data_map); + pub fn get_manifest_paths(&self) -> Vec { + match self.get_installed_mod_manifest_path("*") { + Ok(manifests_path) => glob_path(&manifests_path), + Err(err) => { + error!( + "Failed to get mod manifests glob path for game {}. Error: {}", + self.id, err + ); + Vec::default() + } + } } - pub fn get_installed_mods_folder(&self) -> Result { - let installed_mods_folder = paths::app_data_path()? - .join("installed-mods") - .join(&self.id); - fs::create_dir_all(&installed_mods_folder)?; + pub fn get_available_mods(&self) -> InstalledModVersions { + self.get_manifest_paths() + .iter() + .filter_map(|manifest_path| { + let manifest = mod_manifest::get(manifest_path)?; - Ok(installed_mods_folder) + Some(( + manifest_path.file_stem()?.to_str()?.to_string(), + manifest.version, + )) + }) + .collect() } pub fn get_installed_mod_manifest_path(&self, mod_id: &str) -> Result { @@ -183,34 +202,12 @@ impl InstalledGame { .join(format!("{mod_id}.json"))) } - pub fn get_installed_mod_version(&self, mod_id: &str) -> Option { - let manifest_path = self.get_installed_mod_manifest_path(mod_id).ok()?; - let manifest_file = File::open(manifest_path).ok()?; - let manifest: mod_manifest::Manifest = serde_json::from_reader(manifest_file).ok()?; - Some(manifest.version) - } - - pub fn get_available_mods(&self, data_map: &game_mod::CommonDataMap) -> InstalledModVersions { - data_map - .iter() - .filter_map(|(mod_id, mod_data)| { - if equal_or_none( - mod_data.engine, - self.executable.engine.as_ref().map(|engine| engine.brand), - ) && equal_or_none(mod_data.unity_backend, self.executable.scripting_backend) - { - Some((mod_id.clone(), self.get_installed_mod_version(mod_id))) - } else { - None - } - }) - .collect() - } -} + pub fn get_installed_mods_folder(&self) -> Result { + let installed_mods_folder = paths::app_data_path()? + .join("installed-mods") + .join(&self.id); + fs::create_dir_all(&installed_mods_folder)?; -fn equal_or_none(a: Option, b: Option) -> bool { - match (a, b) { - (Some(value_a), Some(value_b)) => value_a == value_b, - _ => true, + Ok(installed_mods_folder) } } diff --git a/backend/src/local_mod.rs b/backend/src/local_mod.rs index 2178e882..2fd22bfa 100644 --- a/backend/src/local_mod.rs +++ b/backend/src/local_mod.rs @@ -38,7 +38,7 @@ serializable_struct!(LocalMod { }); pub fn get_manifest_path(mod_path: &Path) -> PathBuf { - mod_path.join("rai-pal-manifest.json") + mod_path.join(mod_manifest::Manifest::FILE_NAME) } impl LocalMod { diff --git a/backend/src/main.rs b/backend/src/main.rs index f962b128..62bc9b1c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -6,7 +6,6 @@ use std::{ collections::HashMap, path::PathBuf, sync::Mutex, - time::Instant, }; use app_state::{ @@ -15,12 +14,10 @@ use app_state::{ StateData, StatefulHandle, }; -use debug::LoggableInstant; use events::{ AppEvent, EventEmitter, }; -use game_mod::get_common_data_map; use installed_game::InstalledGame; use log::error; use maps::TryGettable; @@ -38,6 +35,7 @@ use providers::{ self, ProviderActions, }, + provider_command::ProviderCommandAction, }; use result::{ Error, @@ -70,6 +68,7 @@ mod owned_game; mod paths; mod pc_gaming_wiki; mod providers; +mod remote_game; mod remote_mod; mod result; mod steam; @@ -105,6 +104,12 @@ async fn get_remote_mods(handle: AppHandle) -> Result { handle.app_state().remote_mods.get_data() } +#[tauri::command] +#[specta::specta] +async fn get_remote_games(handle: AppHandle) -> Result { + handle.app_state().remote_games.get_data() +} + fn update_state( event: AppEvent, data: TData, @@ -175,7 +180,7 @@ async fn download_mod(mod_id: &str, handle: AppHandle) -> Result { .download_mod(&remote_mod) .await?; - refresh_local_mods(&mod_loaders, &handle).await; + refresh_local_mods(&mod_loaders, &handle); Ok(()) } @@ -220,7 +225,7 @@ async fn install_mod(game_id: &str, mod_id: &str, handle: AppHandle) -> Result { } else { // Local mod wasn't in app state, // so let's sync app state to local files in case some file was manually changed. - let disk_local_mods = refresh_local_mods(&mod_loaders, &handle).await; + let disk_local_mods = refresh_local_mods(&mod_loaders, &handle); if state_local_mods.contains_key(mod_id) { disk_local_mods @@ -240,7 +245,7 @@ async fn install_mod(game_id: &str, mod_id: &str, handle: AppHandle) -> Result { mod_loader.open_folder()?; } - refresh_local_mods(&mod_loaders, &handle).await + refresh_local_mods(&mod_loaders, &handle) } } }; @@ -261,16 +266,11 @@ async fn install_mod(game_id: &str, mod_id: &str, handle: AppHandle) -> Result { fn refresh_game_mods_and_exe(game_id: &str, handle: &AppHandle) -> Result { let state = handle.app_state(); - let mod_data_map = game_mod::get_common_data_map( - &handle.app_state().local_mods.get_data()?, - &handle.app_state().remote_mods.get_data()?, - ); - let mut installed_games = state.installed_games.get_data()?; let game = installed_games.try_get_mut(game_id)?; - game.refresh_mods(&mod_data_map); + game.refresh_installed_mods(); game.refresh_executable()?; update_state( @@ -302,7 +302,7 @@ async fn uninstall_mod(game_id: &str, mod_id: &str, handle: AppHandle) -> Result Ok(()) } -async fn refresh_local_mods(mod_loaders: &mod_loader::Map, handle: &AppHandle) -> local_mod::Map { +fn refresh_local_mods(mod_loaders: &mod_loader::Map, handle: &AppHandle) -> local_mod::Map { let local_mods: HashMap<_, _> = mod_loaders .values() .filter_map(|mod_loader| { @@ -345,35 +345,11 @@ async fn refresh_remote_mods(mod_loaders: &mod_loader::Map, handle: &AppHandle) remote_mods } -#[tauri::command] -#[specta::specta] -async fn update_data(handle: AppHandle) -> Result { - let resources_path = paths::resources_path(&handle)?; - let now = &mut Instant::now(); - - let mod_loaders = mod_loader::get_map(&resources_path).await; - now.log_next("get mod loader map"); - - update_state( - AppEvent::SyncModLoaders, - mod_loaders.clone(), - &handle.app_state().mod_loaders, - &handle, - ); - - let local_mods = refresh_local_mods(&mod_loaders, &handle).await; - now.log_next("refresh local mods"); - - let provider_map = provider::get_map(); - now.log_next("get provider map"); - - let mut installed_games: HashMap<_, _> = provider_map +async fn update_installed_games(handle: AppHandle, provider_map: provider::Map) { + let installed_games: HashMap<_, _> = provider_map .iter() .flat_map(|(provider_id, provider)| { let installed_games = provider.get_installed_games(); - now.log_next(&format!("get {provider_id} installed games ({} total)", { - installed_games.as_ref().map(Vec::len).unwrap_or_default() - })); match installed_games { Ok(games) => games, @@ -383,58 +359,96 @@ async fn update_data(handle: AppHandle) -> Result { } } }) - .map(|mut game| { - game.update_available_mods(&get_common_data_map(&local_mods, &HashMap::default())); - (game.id.clone(), game) - }) + .map(|game| (game.id.clone(), game)) .collect(); - now.log_next("get installed game map + update game mods"); update_state( AppEvent::SyncInstalledGames, - installed_games.clone(), + installed_games, &handle.app_state().installed_games, &handle, ); +} - let remote_mods = refresh_remote_mods(&mod_loaders, &handle).await; - now.log_next("refresh remote mods"); - - for game in installed_games.values_mut() { - game.update_available_mods(&get_common_data_map(&local_mods, &remote_mods)); - } - now.log_next("update game mods"); +async fn update_owned_games(handle: AppHandle, provider_map: provider::Map) { + let owned_games: owned_game::Map = provider_map + .iter() + .flat_map(|(provider_id, provider)| match provider.get_owned_games() { + Ok(owned_games) => owned_games, + Err(err) => { + error!("Failed to get owned games for provider '{provider_id}'. Error: {err}"); + Vec::default() + } + }) + .map(|owned_game| (owned_game.id.clone(), owned_game)) + .collect(); update_state( - AppEvent::SyncInstalledGames, - installed_games.clone(), - &handle.app_state().installed_games, + AppEvent::SyncOwnedGames, + owned_games, + &handle.app_state().owned_games, &handle, ); +} - let owned_games: owned_game::Map = futures::future::join_all( +async fn update_remote_games(handle: AppHandle, provider_map: provider::Map) { + let remote_games: remote_game::Map = futures::future::join_all( provider_map .values() - .map(provider::ProviderActions::get_owned_games), + .map(provider::Provider::get_remote_games), ) .await .into_iter() .flat_map(|result| { result.unwrap_or_else(|err| { - error!("Failed to get owned games for a provider: {err}"); + error!("Failed to get remote games for a provider: {err}"); Vec::default() }) }) - .map(|owned_game| (owned_game.id.clone(), owned_game)) + .map(|remote_game| (remote_game.id.clone(), remote_game)) .collect(); - now.log_next(&format!("get owned games ({} total)", owned_games.len())); update_state( - AppEvent::SyncOwnedGames, - owned_games, - &handle.app_state().owned_games, + AppEvent::SyncRemoteGames, + remote_games, + &handle.app_state().remote_games, &handle, ); +} + +async fn update_mods(handle: AppHandle, resources_path: PathBuf) { + let mod_loaders = mod_loader::get_map(&resources_path); + update_state( + AppEvent::SyncModLoaders, + mod_loaders.clone(), + &handle.app_state().mod_loaders, + &handle, + ); + + refresh_local_mods(&mod_loaders, &handle); + refresh_remote_mods(&mod_loaders, &handle).await; +} + +#[tauri::command] +#[specta::specta] +async fn update_data(handle: AppHandle) -> Result { + let resources_path = paths::resources_path(&handle)?; + + let provider_map = provider::get_map(); + + let results = futures::future::join_all([ + tokio::spawn(update_installed_games(handle.clone(), provider_map.clone())), + tokio::spawn(update_owned_games(handle.clone(), provider_map.clone())), + tokio::spawn(update_remote_games(handle.clone(), provider_map)), + tokio::spawn(update_mods(handle, resources_path)), + ]) + .await; + + for result in results { + if let Err(err) = result { + error!("Error updating data: {err}"); + } + } Ok(()) } @@ -450,11 +464,7 @@ async fn add_game(path: PathBuf, handle: AppHandle) -> Result { return Err(Error::GameAlreadyAdded(normalized_path)); } - let mut game = manual_provider::add_game(&normalized_path)?; - game.update_available_mods(&get_common_data_map( - &state.local_mods.get_data()?, - &state.remote_mods.get_data()?, - )); + let game = manual_provider::add_game(&normalized_path)?; let game_name = game.name.clone(); let mut installed_games = state.installed_games.get_data()?.clone(); @@ -498,46 +508,18 @@ async fn remove_game(game_id: &str, handle: AppHandle) -> Result { #[tauri::command] #[specta::specta] -async fn show_game_in_library(owned_game_id: &str, handle: AppHandle) -> Result { - handle - .app_state() - .owned_games - .try_get(owned_game_id)? - .show_library_command - .ok_or_else(Error::CommandNotDefined)? - .run(); - - handle.emit_event(AppEvent::ExecutedProviderCommand, ()); - - Ok(()) -} - -#[tauri::command] -#[specta::specta] -async fn install_game(owned_game_id: &str, handle: AppHandle) -> Result { +async fn run_provider_command( + owned_game_id: &str, + command_action: &str, + handle: AppHandle, +) -> Result { handle .app_state() .owned_games .try_get(owned_game_id)? - .install_command - .ok_or_else(Error::CommandNotDefined)? - .run(); - - handle.emit_event(AppEvent::ExecutedProviderCommand, ()); - - Ok(()) -} - -#[tauri::command] -#[specta::specta] -async fn open_game_page(owned_game_id: &str, handle: AppHandle) -> Result { - handle - .app_state() - .owned_games - .try_get(owned_game_id)? - .open_page_command - .ok_or_else(Error::CommandNotDefined)? - .run(); + .provider_commands + .try_get(command_action)? + .run()?; handle.emit_event(AppEvent::ExecutedProviderCommand, ()); @@ -569,7 +551,7 @@ async fn open_logs_folder() -> Result { #[tauri::command] #[specta::specta] -async fn dummy_command() -> Result<(InstalledGame, AppEvent)> { +async fn dummy_command() -> Result<(InstalledGame, AppEvent, ProviderCommandAction)> { // This command is here just so tauri_specta exports these types. // This should stop being needed once tauri_specta starts supporting events. Err(Error::NotImplemented) @@ -578,8 +560,10 @@ async fn dummy_command() -> Result<(InstalledGame, AppEvent)> { fn main() { // Since I'm making all exposed functions async, panics won't crash anything important, I think. // So I can just catch panics here and show a system message with the error. + #[cfg(target_os = "windows")] std::panic::set_hook(Box::new(|info| { windows::error_dialog(&info.to_string()); + // TODO handle Linux. })); let tauri_builder = tauri::Builder::default() @@ -596,6 +580,7 @@ fn main() { .manage(AppState { installed_games: Mutex::default(), owned_games: Mutex::default(), + remote_games: Mutex::default(), mod_loaders: Mutex::default(), local_mods: Mutex::default(), remote_mods: Mutex::default(), @@ -646,12 +631,11 @@ fn main() { frontend_ready, get_local_mods, get_remote_mods, + get_remote_games, open_mod_loader_folder, refresh_game, open_logs_folder, - show_game_in_library, - install_game, - open_game_page, + run_provider_command, ] ); @@ -675,11 +659,14 @@ fn main() { tauri_builder .run(tauri::generate_context!()) .unwrap_or_else(|error| { + #[cfg(target_os = "windows")] if let tauri::Error::Runtime(tauri_runtime::Error::CreateWebview(webview_error)) = error { windows::webview_error_dialog(&webview_error.to_string()); - } else { - windows::error_dialog(&error.to_string()); + return; } + #[cfg(target_os = "windows")] + windows::error_dialog(&error.to_string()); + // TODO handle Linux. }); } diff --git a/backend/src/mod_loaders/bepinex.rs b/backend/src/mod_loaders/bepinex.rs index c2c6af1d..77af1edb 100644 --- a/backend/src/mod_loaders/bepinex.rs +++ b/backend/src/mod_loaders/bepinex.rs @@ -44,11 +44,10 @@ serializable_struct!(BepInEx { pub id: &'static str, }); -#[async_trait] impl ModLoaderStatic for BepInEx { const ID: &'static str = "bepinex"; - async fn new(resources_path: &Path) -> Result { + fn new(resources_path: &Path) -> Result { Ok(Self { id: Self::ID, data: ModLoaderData { @@ -131,7 +130,10 @@ impl ModLoaderActions for BepInEx { fs::write( game_folder.join("doorstop_config.ini"), - doorstop_config.replace("{{MOD_FILES_PATH}}", paths::path_to_str(game_data_folder)?), + doorstop_config.replace( + "{{MOD_FILES_PATH}}", + game_data_folder.to_string_lossy().as_ref(), + ), )?; // TODO: linux stuff @@ -187,11 +189,8 @@ impl ModLoaderActions for BepInEx { let installed_mods_path = Self::get_installed_mods_path()?; let local_mods = { - let mut local_mods = find_mods(&installed_mods_path, UnityScriptingBackend::Il2Cpp)?; - local_mods.extend(find_mods( - &installed_mods_path, - UnityScriptingBackend::Mono, - )?); + let mut local_mods = find_mods(&installed_mods_path, UnityScriptingBackend::Il2Cpp); + local_mods.extend(find_mods(&installed_mods_path, UnityScriptingBackend::Mono)); local_mods }; @@ -288,26 +287,22 @@ const fn is_legacy(engine: &GameEngine) -> bool { fn find_mods( installed_mods_path: &Path, scripting_backend: UnityScriptingBackend, -) -> Result> { +) -> HashMap { let mods_folder_path = installed_mods_path.join(scripting_backend.to_string()); - let entries: Vec<_> = paths::glob_path(&mods_folder_path.join("*"))?.collect(); - - Ok(entries + paths::glob_path(&mods_folder_path.join("*")) .iter() - .filter_map(|entry| { - entry.as_ref().map_or(None, |mod_path| { - if let Ok(local_mod) = LocalMod::new( - BepInEx::ID, - mod_path, - Some(GameEngineBrand::Unity), - Some(scripting_backend), - ) { - Some((local_mod.common.id.clone(), local_mod)) - } else { - None - } - }) + .filter_map(|mod_path| { + if let Ok(local_mod) = LocalMod::new( + BepInEx::ID, + mod_path, + Some(GameEngineBrand::Unity), + Some(scripting_backend), + ) { + Some((local_mod.common.id.clone(), local_mod)) + } else { + None + } }) - .collect()) + .collect() } diff --git a/backend/src/mod_loaders/mod_loader.rs b/backend/src/mod_loaders/mod_loader.rs index 1541227d..c88a5025 100644 --- a/backend/src/mod_loaders/mod_loader.rs +++ b/backend/src/mod_loaders/mod_loader.rs @@ -174,11 +174,10 @@ pub trait ModLoaderActions { } } -#[async_trait] pub trait ModLoaderStatic { const ID: &'static str; - async fn new(resources_path: &Path) -> Result + fn new(resources_path: &Path) -> Result where Self: Sized; @@ -190,22 +189,22 @@ pub trait ModLoaderStatic { pub type Map = HashMap; pub type DataMap = HashMap; -async fn create_map_entry( +fn create_map_entry( path: &Path, ) -> Result<(String, ModLoader)> where ModLoader: std::convert::From, { - let mod_loader: ModLoader = TModLoader::new(path).await?.into(); + let mod_loader: ModLoader = TModLoader::new(path)?.into(); Ok((TModLoader::ID.to_string(), mod_loader)) } -async fn add_entry(path: &Path, map: &mut Map) +fn add_entry(path: &Path, map: &mut Map) where ModLoader: std::convert::From, { - match create_map_entry::(path).await { + match create_map_entry::(path) { Ok((key, value)) => { map.insert(key, value); } @@ -213,11 +212,11 @@ where } } -pub async fn get_map(resources_path: &Path) -> Map { +pub fn get_map(resources_path: &Path) -> Map { let mut map = Map::new(); - add_entry::(resources_path, &mut map).await; - add_entry::(resources_path, &mut map).await; + add_entry::(resources_path, &mut map); + add_entry::(resources_path, &mut map); map } diff --git a/backend/src/mod_loaders/runnable_loader.rs b/backend/src/mod_loaders/runnable_loader.rs index 65e98d63..b7159bf5 100644 --- a/backend/src/mod_loaders/runnable_loader.rs +++ b/backend/src/mod_loaders/runnable_loader.rs @@ -40,11 +40,10 @@ serializable_enum!(RunnableParameter { GameJson, }); -#[async_trait] impl ModLoaderStatic for RunnableLoader { const ID: &'static str = "runnable"; - async fn new(resources_path: &Path) -> Result + fn new(resources_path: &Path) -> Result where Self: std::marker::Sized, { @@ -137,44 +136,27 @@ impl ModLoaderActions for RunnableLoader { fn get_local_mods(&self) -> Result { let mods_path = Self::get_installed_mods_path()?; - let manifests = glob_path( - &mods_path - .join("*") - // TODO manifest name const somewhere. - .join("rai-pal-manifest.json"), - )?; - let mut mod_map = local_mod::Map::default(); - for manifest_path_result in manifests { - match manifest_path_result { - Ok(manifest_path) => { - if let Some(manifest) = mod_manifest::get(&manifest_path) { - match LocalMod::new( - Self::ID, - manifest_path.parent().unwrap_or(&manifest_path), - manifest.engine, - manifest.unity_backend, - ) { - Ok(local_mod) => { - mod_map.insert(local_mod.common.id.clone(), local_mod); - } - Err(error) => { - error!( - "Failed to create local runnable mod from manifest in {}. Error: {}", - manifest_path.display(), - error - ); - } - } + for manifest_path in glob_path(&mods_path.join("*").join(mod_manifest::Manifest::FILE_NAME)) + { + if let Some(manifest) = mod_manifest::get(&manifest_path) { + match LocalMod::new( + Self::ID, + manifest_path.parent().unwrap_or(&manifest_path), + manifest.engine, + manifest.unity_backend, + ) { + Ok(local_mod) => { + mod_map.insert(local_mod.common.id.clone(), local_mod); + } + Err(error) => { + error!( + "Failed to create local runnable mod from manifest in {}. Error: {}", + manifest_path.display(), + error + ); } - } - Err(error) => { - error!( - "Failed to read mod manifest from {}. Error: {}", - mods_path.display(), - error - ); } } } diff --git a/backend/src/mod_manifest.rs b/backend/src/mod_manifest.rs index c7589b46..3d388a19 100644 --- a/backend/src/mod_manifest.rs +++ b/backend/src/mod_manifest.rs @@ -21,6 +21,10 @@ serializable_struct!(Manifest { pub unity_backend: Option, }); +impl Manifest { + pub const FILE_NAME: &'static str = "rai-pal-manifest.json"; +} + pub fn get(path: &Path) -> Option { match fs::read_to_string(path) .and_then(|manifest_bytes| Ok(serde_json::from_str::(&manifest_bytes)?)) diff --git a/backend/src/owned_game.rs b/backend/src/owned_game.rs index 8a94e625..a73439b2 100644 --- a/backend/src/owned_game.rs +++ b/backend/src/owned_game.rs @@ -4,15 +4,16 @@ use std::collections::{ }; use crate::{ - game_engines::game_engine::GameEngine, game_executable::OperatingSystem, game_mode::GameMode, providers::{ provider::ProviderId, - provider_command::ProviderCommand, + provider_command::{ + ProviderCommand, + ProviderCommandAction, + }, }, serializable_struct, - steam::id_lists::UevrScore, }; serializable_struct!(OwnedGame { @@ -20,14 +21,12 @@ serializable_struct!(OwnedGame { pub provider: ProviderId, pub name: String, pub os_list: HashSet, - pub engine: Option, pub release_date: Option, pub thumbnail_url: Option, pub game_mode: Option, - pub uevr_score: Option, - pub show_library_command: Option, - pub open_page_command: Option, - pub install_command: Option, + + // TODO: the keys for this map should be ProviderCommandAction, but tauri-specta doesn't support that. + pub provider_commands: HashMap, }); impl OwnedGame { @@ -37,14 +36,10 @@ impl OwnedGame { provider, name: name.to_string(), os_list: HashSet::default(), - engine: None, + provider_commands: HashMap::default(), release_date: None, thumbnail_url: None, game_mode: None, - uevr_score: None, - show_library_command: None, - open_page_command: None, - install_command: None, } } @@ -53,11 +48,6 @@ impl OwnedGame { self } - pub fn set_engine(&mut self, engine: GameEngine) -> &mut Self { - self.engine = Some(engine); - self - } - pub fn set_release_date(&mut self, release_date: i64) -> &mut Self { self.release_date = Some(release_date); self @@ -73,23 +63,13 @@ impl OwnedGame { self } - pub fn set_uevr_score(&mut self, uevr_score: UevrScore) -> &mut Self { - self.uevr_score = Some(uevr_score); - self - } - - pub fn set_show_library_command(&mut self, show_library_command: ProviderCommand) -> &mut Self { - self.show_library_command = Some(show_library_command); - self - } - - pub fn set_open_page_command(&mut self, open_page_command: ProviderCommand) -> &mut Self { - self.open_page_command = Some(open_page_command); - self - } - - pub fn set_install_command(&mut self, install_command: ProviderCommand) -> &mut Self { - self.install_command = Some(install_command); + pub fn add_provider_command( + &mut self, + command_action: ProviderCommandAction, + command: ProviderCommand, + ) -> &mut Self { + self.provider_commands + .insert(command_action.to_string(), command); self } } diff --git a/backend/src/paths.rs b/backend/src/paths.rs index b7ac619f..8a44da58 100644 --- a/backend/src/paths.rs +++ b/backend/src/paths.rs @@ -12,10 +12,7 @@ use std::{ }; use directories::ProjectDirs; -use glob::{ - glob, - Paths, -}; +use glob::glob; use log::error; use crate::{ @@ -23,13 +20,26 @@ use crate::{ Result, }; -pub fn path_to_str(path: &Path) -> Result<&str> { - path.to_str() - .ok_or_else(|| Error::PathParseFailure(path.to_path_buf())) -} - -pub fn glob_path(path: &Path) -> Result { - Ok(glob(path_to_str(path)?)?) +pub fn glob_path(path: &Path) -> Vec { + match glob(path.to_string_lossy().as_ref()) { + Ok(paths) => paths + .filter_map(|glob_result| match glob_result { + Ok(globbed_path) => Some(globbed_path), + Err(err) => { + error!( + "Failed to resolve one of the globbed paths from glob '{}'. Error: {}", + path.display(), + err + ); + None + } + }) + .collect(), + Err(err) => { + error!("Failed to glob path `{}`. Error: {}", path.display(), err); + Vec::default() + } + } } pub fn path_parent(path: &Path) -> Result<&Path> { diff --git a/backend/src/pc_gaming_wiki.rs b/backend/src/pc_gaming_wiki.rs index 6f0e6a6b..611d623c 100644 --- a/backend/src/pc_gaming_wiki.rs +++ b/backend/src/pc_gaming_wiki.rs @@ -12,6 +12,7 @@ use crate::{ GameEngineVersion, }, serializable_struct, + Result, }; serializable_struct!(PCGamingWikiTitle { @@ -48,81 +49,89 @@ fn parse_version(version_text: &str) -> Option { }) } -pub async fn get_engine(where_query: &str) -> Option { - let url = format!("https://www.pcgamingwiki.com/w/api.php?action=cargoquery&tables=Infobox_game,Infobox_game_engine&fields=Infobox_game_engine.Engine,Infobox_game_engine.Build&where={where_query}&format=json&join%20on=Infobox_game._pageName%20=%20Infobox_game_engine._pageName"); +pub async fn get_engine(where_query: &str) -> Result> { + let url = format!( + "https://www.pcgamingwiki.com/w/api.php?{}", + serde_urlencoded::to_string([ + ("action", "cargoquery"), + ("tables", "Infobox_game,Infobox_game_engine"), + ( + "fields", + "Infobox_game_engine.Engine,Infobox_game_engine.Build", + ), + ("where", where_query), + ( + "join on", + "Infobox_game._pageName = Infobox_game_engine._pageName", + ), + ("format", "json"), + ])? + ); - let result = reqwest::get(url).await; + Ok( + match reqwest::get(url) + .await? + .json::() + .await + { + Ok(parsed_response) => { + parsed_response + .cargoquery + .into_iter() + // This has high potential for false positives, since we search by title kinda fuzzily, + // and then just pick the first one with an engine. + .find_map(|item| Some((item.title.engine?, item.title.build))) + .and_then(|(engine, build)| { + let version = build + .and_then(|version_text| parse_version(&version_text)) + // On PCGamingWiki, each Unreal major version is considered a separate engine. + // So we can parse the engine name to get the major version. + .or_else(|| parse_version(&engine)); - match result { - Ok(response) => { - match response.json::().await { - Ok(parsed_response) => { - parsed_response - .cargoquery - .into_iter() - // This has high potential for false positives, since we search by title kinda fuzzily, - // and then just pick the first one with an engine. - .find_map(|item| Some((item.title.engine?, item.title.build))) - .and_then(|(engine, build)| { - let version = build - .and_then(|version_text| parse_version(&version_text)) - // On PCGamingWiki, each Unreal major version is considered a separate engine. - // So we can parse the engine name to get the major version. - .or_else(|| parse_version(&engine)); - - // I don't feel like figuring out the exact format, - // since it can sometimes have the engine version included, sometimes not. - if engine.contains("Unreal") { - Some(GameEngine { - brand: GameEngineBrand::Unreal, - version, - }) - } else if engine.contains("Unity") { - Some(GameEngine { - brand: GameEngineBrand::Unity, - version, - }) - } else if engine.contains("Godot") { - Some(GameEngine { - brand: GameEngineBrand::Godot, - version, - }) - } else { - None - } - }) - } - Err(err) => { - error!("Error parsing PCGamingWiki response: {err}"); - None - } + // I don't feel like figuring out the exact format, + // since it can sometimes have the engine version included, sometimes not. + if engine.contains("Unreal") { + Some(GameEngine { + brand: GameEngineBrand::Unreal, + version, + }) + } else if engine.contains("Unity") { + Some(GameEngine { + brand: GameEngineBrand::Unity, + version, + }) + } else if engine.contains("Godot") { + Some(GameEngine { + brand: GameEngineBrand::Godot, + version, + }) + } else { + None + } + }) + } + Err(err) => { + error!("Error parsing PCGamingWiki response: {err}"); + None } - } - Err(err) => { - error!("Error fetching from PCGamingWiki: {err}"); - None - } - } + }, + ) } // Since there's no way to get a game by every provider ID from PCGamingWiki, we try with the game title. -pub async fn get_engine_from_game_title(title: &str) -> Option { +pub async fn get_engine_from_game_title(title: &str) -> Result> { // Use only ascii and lowercase to make it easier to find by title. let lowercase_title = title.to_ascii_lowercase(); // Remove "demo" suffix so that demos can match with the main game. + #[allow(clippy::trivial_regex)] let non_demo_title = regex_replace!(r" demo$", &lowercase_title, ""); // Replace anything that isn't alphanumeric with a % character, the wildcard for the LIKE query. // This way we can still match even if the game has slight differences in the presented title punctuation. // Problem is, this can easily cause false flags (and it does). - // In this case we use %25, which is % encoded for url components. - let clean_title = regex_replace_all!(r"[^a-zA-Z0-9]+", &non_demo_title, "%25"); + let clean_title = regex_replace_all!(r"[^a-zA-Z0-9]+", &non_demo_title, "%"); // Finally do the query by page title. - get_engine(&format!( - "Infobox_game._pageName%20LIKE%20%22{}%22", - clean_title - )) - .await + get_engine(&format!("Infobox_game._pageName LIKE \"{}\"", clean_title)).await } diff --git a/backend/src/providers/epic_provider.rs b/backend/src/providers/epic_provider.rs index ecd43ed6..b45cccf4 100644 --- a/backend/src/providers/epic_provider.rs +++ b/backend/src/providers/epic_provider.rs @@ -1,3 +1,5 @@ +#![cfg(target_os = "windows")] + use std::{ fs::{ self, @@ -9,7 +11,6 @@ use std::{ use async_trait::async_trait; use base64::engine::general_purpose; -use glob::GlobError; use log::error; use winreg::{ enums::HKEY_LOCAL_MACHINE, @@ -17,14 +18,13 @@ use winreg::{ }; use super::{ - provider::{ - self, - ProviderId, + provider::ProviderId, + provider_command::{ + ProviderCommand, + ProviderCommandAction, }, - provider_command::ProviderCommand, }; use crate::{ - game_engines::game_engine::GameEngine, installed_game::InstalledGame, owned_game::OwnedGame, paths::glob_path, @@ -33,13 +33,19 @@ use crate::{ ProviderActions, ProviderStatic, }, + remote_game::{ + self, + RemoteGame, + }, serializable_struct, Result, }; +#[derive(Clone)] pub struct Epic { app_data_path: PathBuf, - engine_cache: provider::EngineCache, + catalog: Vec, + remote_game_cache: remote_game::Map, } impl ProviderStatic for Epic { @@ -54,11 +60,20 @@ impl ProviderStatic for Epic { .and_then(|launcher_reg| launcher_reg.get_value::("AppDataPath")) .map(PathBuf::from)?; - let engine_cache = Self::try_get_engine_cache(); + let remote_game_cache = Self::try_get_remote_game_cache(); + + let mut file = File::open(app_data_path.join("Catalog").join("catcache.bin"))?; + + let mut decoder = base64::read::DecoderReader::new(&mut file, &general_purpose::STANDARD); + let mut json = String::default(); + decoder.read_to_string(&mut json)?; + + let catalog = serde_json::from_str::>(&json)?; Ok(Self { app_data_path, - engine_cache, + catalog, + remote_game_cache, }) } } @@ -128,9 +143,10 @@ impl EpicCatalogItem { #[async_trait] impl ProviderActions for Epic { fn get_installed_games(&self) -> Result> { - let manifests = glob_path(&self.app_data_path.join("Manifests").join("*.item"))?; + let manifests = glob_path(&self.app_data_path.join("Manifests").join("*.item")); Ok(manifests + .iter() .filter_map( |manifest_path_result| match read_manifest(manifest_path_result) { Ok(manifest) => { @@ -148,7 +164,7 @@ impl ProviderActions for Epic { Some(game) } Err(err) => { - error!("Failed to glob manifest path: {err}"); + error!("Failed to parse manifest: {err}"); None } }, @@ -156,16 +172,8 @@ impl ProviderActions for Epic { .collect()) } - async fn get_owned_games(&self) -> Result> { - let mut file = File::open(self.app_data_path.join("Catalog").join("catcache.bin"))?; - - let mut decoder = base64::read::DecoderReader::new(&mut file, &general_purpose::STANDARD); - let mut json = String::default(); - decoder.read_to_string(&mut json)?; - - let items = serde_json::from_str::>(&json)?; - - let owned_games = futures::future::join_all(items.iter().map(|catalog_item| async { + fn get_owned_games(&self) -> Result> { + let owned_games = self.catalog.iter().filter_map(|catalog_item| { if catalog_item .categories .iter() @@ -176,16 +184,30 @@ impl ProviderActions for Epic { let mut game = OwnedGame::new(&catalog_item.id, *Self::ID, &catalog_item.title); - game.set_install_command(ProviderCommand::String(format!( - "com.epicgames.launcher://apps/{}%3A{}%3A{}?action=install", - catalog_item.namespace, - catalog_item.id, - catalog_item - .release_info - .first() - .map(|release_info| release_info.app_id.clone()) - .unwrap_or_default(), - ))); + game.add_provider_command( + ProviderCommandAction::Install, + ProviderCommand::String(format!( + "com.epicgames.launcher://apps/{}%3A{}%3A{}?action=install", + catalog_item.namespace, + catalog_item.id, + catalog_item + .release_info + .first() + .map(|release_info| release_info.app_id.clone()) + .unwrap_or_default(), + )), + ) + .add_provider_command( + ProviderCommandAction::OpenInBrowser, + ProviderCommand::String(format!( + "https://store.epicgames.com/browse?{}", + serde_urlencoded::to_string([ + ("sortBy", "relevancy"), + ("q", &catalog_item.title) + ]) + .ok()?, + )), + ); if let Some(thumbnail_url) = catalog_item.get_thumbnail_url() { game.set_thumbnail_url(&thumbnail_url); @@ -195,37 +217,43 @@ impl ProviderActions for Epic { game.set_release_date(release_date); } - if let Some(engine) = get_engine(&catalog_item.title, &self.engine_cache).await { - game.set_engine(engine); - } - Some(game) - })) - .await - .into_iter() - .flatten(); - - Self::try_save_engine_cache( - &owned_games - .clone() - .map(|owned_game| (owned_game.name.clone(), owned_game.engine)) - .collect(), - ); + }); Ok(owned_games.collect()) } -} -async fn get_engine(title: &str, cache: &provider::EngineCache) -> Option { - if let Some(cached_engine) = cache.get(title) { - return cached_engine.clone(); - } + async fn get_remote_games(&self) -> Result> { + let remote_games: Vec = + futures::future::join_all(self.catalog.iter().map(|catalog_item| async { + let mut remote_game = RemoteGame::new(*Self::ID, &catalog_item.id); - pc_gaming_wiki::get_engine_from_game_title(title).await + if let Some(cached_remote_game) = self.remote_game_cache.get(&remote_game.id) { + return cached_remote_game.clone(); + } + + match pc_gaming_wiki::get_engine_from_game_title(&catalog_item.title).await { + Ok(Some(engine)) => { + remote_game.set_engine(engine); + } + Ok(None) => {} + Err(_) => { + remote_game.set_skip_cache(true); + } + } + + remote_game + })) + .await; + + Self::try_save_remote_game_cache(&remote_games); + + Ok(remote_games) + } } -fn read_manifest(path_result: std::result::Result) -> Result { - let json = fs::read_to_string(path_result?)?; +fn read_manifest(path: &PathBuf) -> Result { + let json = fs::read_to_string(path)?; let manifest = serde_json::from_str::(&json)?; Ok(manifest) } diff --git a/backend/src/providers/gog_provider.rs b/backend/src/providers/gog_provider.rs index ff54abb9..4de14993 100644 --- a/backend/src/providers/gog_provider.rs +++ b/backend/src/providers/gog_provider.rs @@ -1,3 +1,5 @@ +#![cfg(target_os = "windows")] + use std::path::PathBuf; use async_trait::async_trait; @@ -13,14 +15,13 @@ use winreg::{ }; use super::{ - provider::{ - self, - ProviderId, + provider::ProviderId, + provider_command::{ + ProviderCommand, + ProviderCommandAction, }, - provider_command::ProviderCommand, }; use crate::{ - game_engines::game_engine::GameEngine, installed_game::InstalledGame, owned_game::OwnedGame, paths, @@ -29,10 +30,15 @@ use crate::{ ProviderActions, ProviderStatic, }, + remote_game::{ + self, + RemoteGame, + }, serializable_struct, Result, }; +#[derive(Clone)] struct GogDbEntry { id: String, title: String, @@ -41,8 +47,9 @@ struct GogDbEntry { executable_path: Option, } +#[derive(Clone)] pub struct Gog { - engine_cache: provider::EngineCache, + remote_game_cache: remote_game::Map, database: Vec, launcher_path: PathBuf, } @@ -55,7 +62,7 @@ impl ProviderStatic for Gog { Self: Sized, { Ok(Self { - engine_cache: Self::try_get_engine_cache(), + remote_game_cache: Self::try_get_remote_game_cache(), database: get_database()?, launcher_path: get_launcher_path()?, }) @@ -94,53 +101,67 @@ impl ProviderActions for Gog { .collect()) } - async fn get_owned_games(&self) -> Result> { - let owned_games = futures::future::join_all(self.database.iter().map(|db_entry| async { - let mut game = OwnedGame::new(&db_entry.id, *Self::ID, &db_entry.title); - - game.set_show_library_command(ProviderCommand::Path( - self.launcher_path.clone(), - [ - "/command=launch".to_string(), - format!("/gameId={}", db_entry.id), - ] - .to_vec(), - )); - - if let Some(thumbnail_url) = db_entry.image_url.clone() { - game.set_thumbnail_url(&thumbnail_url); - } - - if let Some(release_date) = db_entry.release_date { - game.set_release_date(release_date.into()); - } + fn get_owned_games(&self) -> Result> { + Ok(self + .database + .iter() + .map(|db_entry| { + let mut game = OwnedGame::new(&db_entry.id, *Self::ID, &db_entry.title); - if let Some(engine) = get_engine(&db_entry.id, &self.engine_cache).await { - game.set_engine(engine); - } + game.add_provider_command( + ProviderCommandAction::ShowInLibrary, + ProviderCommand::Path( + self.launcher_path.clone(), + [ + "/command=launch".to_string(), + format!("/gameId={}", db_entry.id), + ] + .to_vec(), + ), + ); - game - })) - .await; + if let Some(thumbnail_url) = db_entry.image_url.clone() { + game.set_thumbnail_url(&thumbnail_url); + } - Self::try_save_engine_cache( - &owned_games - .clone() - .into_iter() - .map(|owned_game| (owned_game.name.clone(), owned_game.engine)) - .collect(), - ); + if let Some(release_date) = db_entry.release_date { + game.set_release_date(release_date.into()); + } - Ok(owned_games) + game + }) + .collect()) } -} -async fn get_engine(gog_id: &str, cache: &provider::EngineCache) -> Option { - if let Some(cached_engine) = cache.get(gog_id) { - return cached_engine.clone(); - } + async fn get_remote_games(&self) -> Result> { + let remote_games: Vec = + futures::future::join_all(self.database.iter().map(|db_entry| async { + let mut remote_game = RemoteGame::new(*Self::ID, &db_entry.id); - pc_gaming_wiki::get_engine(&format!("GOGcom_ID%20HOLDS%20%22{gog_id}%22")).await + if let Some(cached_remote_game) = self.remote_game_cache.get(&remote_game.id) { + return cached_remote_game.clone(); + } + + match pc_gaming_wiki::get_engine(&format!("GOGcom_ID HOLDS \"{}\"", db_entry.id)) + .await + { + Ok(Some(engine)) => { + remote_game.set_engine(engine); + } + Ok(None) => {} + Err(_) => { + remote_game.set_skip_cache(true); + } + } + + remote_game + })) + .await; + + Self::try_save_remote_game_cache(&remote_games); + + Ok(remote_games) + } } serializable_struct!(GogDbEntryTitle { title: Option }); @@ -155,23 +176,23 @@ fn get_database() -> Result> { let mut statement = connection.prepare( r"SELECT - P.id, - MAX(CASE WHEN GPT.type = 'originalTitle' THEN GP.value END) AS title, - MAX(CASE WHEN GPT.type = 'originalImages' THEN GP.value END) AS images, - MAX(CASE WHEN GPT.type = 'originalMeta' THEN GP.value END) AS meta, - MAX(PTLP.executablePath) AS executablePath + Products.id, + MAX(CASE WHEN GamePieceTypes.type = 'originalTitle' THEN GamePieces.value END) AS title, + MAX(CASE WHEN GamePieceTypes.type = 'originalImages' THEN GamePieces.value END) AS images, + MAX(CASE WHEN GamePieceTypes.type = 'originalMeta' THEN GamePieces.value END) AS meta, + MAX(PlayTaskLaunchParameters.executablePath) AS executablePath FROM - Products P + Products JOIN - GamePieces GP ON P.id = substr(GP.releaseKey, 5) AND GP.releaseKey GLOB 'gog_*' + GamePieces ON GamePieces.releaseKey = 'gog_' || Products.id LEFT JOIN - PlayTasks PT ON GP.releaseKey = PT.gameReleaseKey + PlayTasks ON GamePieces.releaseKey = PlayTasks.gameReleaseKey LEFT JOIN - PlayTaskLaunchParameters PTLP ON PT.id = PTLP.playTaskId + PlayTaskLaunchParameters ON PlayTasks.id = PlayTaskLaunchParameters.playTaskId JOIN - GamePieceTypes GPT ON GP.gamePieceTypeId = GPT.id + GamePieceTypes ON GamePieces.gamePieceTypeId = GamePieceTypes.id GROUP BY - P.id;", + Products.id;", )?; let rows: Vec = statement diff --git a/backend/src/providers/itch_provider.rs b/backend/src/providers/itch_provider.rs new file mode 100644 index 00000000..150714ef --- /dev/null +++ b/backend/src/providers/itch_provider.rs @@ -0,0 +1,242 @@ +use std::path::{ + Path, + PathBuf, +}; + +use async_trait::async_trait; +use chrono::DateTime; +use log::error; +use rusqlite::{ + Connection, + OpenFlags, +}; + +use super::provider_command::{ + ProviderCommand, + ProviderCommandAction, +}; +use crate::{ + installed_game::InstalledGame, + owned_game::OwnedGame, + pc_gaming_wiki, + provider::{ + ProviderActions, + ProviderId, + ProviderStatic, + }, + remote_game::{ + self, + RemoteGame, + }, + serializable_struct, + Error, + Result, +}; + +#[derive(Clone)] +pub struct Itch { + database: ItchDatabase, + remote_game_cache: remote_game::Map, +} + +impl ProviderStatic for Itch { + const ID: &'static ProviderId = &ProviderId::Itch; + + fn new() -> Result + where + Self: Sized, + { + let app_data_path = directories::BaseDirs::new() + .ok_or_else(Error::AppDataNotFound)? + .config_dir() + .join("itch"); + + Ok(Self { + database: get_database(&app_data_path)?, + remote_game_cache: Self::try_get_remote_game_cache(), + }) + } +} + +serializable_struct!(ItchDatabaseGame { + id: i32, + title: String, + url: Option, + published_at: Option, + cover_url: Option, +}); + +serializable_struct!(ItchDatabaseCave { + id: i32, + verdict: Option, + title: String, + cover_url: Option, +}); + +serializable_struct!(ItchDatabaseVerdict { + base_path: PathBuf, + candidates: Vec +}); + +serializable_struct!(ItchDatabaseCandidate { path: PathBuf }); + +serializable_struct!(ItchDatabase { + games: Vec, + caves: Vec, +}); + +#[async_trait] +impl ProviderActions for Itch { + fn get_installed_games(&self) -> Result> { + Ok(self + .database + .caves + .iter() + .filter_map(|cave| { + let verdict = cave.verdict.as_ref()?; + let exe_path = verdict.base_path.join(&verdict.candidates.first()?.path); + let mut game = InstalledGame::new(&exe_path, &cave.title, *Self::ID)?; + if let Some(cover_url) = &cave.cover_url { + game.set_thumbnail_url(cover_url); + } + game.set_provider_game_id(&cave.id.to_string()); + + Some(game) + }) + .collect()) + } + + fn get_owned_games(&self) -> Result> { + Ok(self + .database + .games + .iter() + .map(|row| { + let mut game = OwnedGame::new(&row.id.to_string(), *Self::ID, &row.title); + + if let Some(thumbnail_url) = &row.cover_url { + game.set_thumbnail_url(thumbnail_url); + } + if let Some(date_time) = row + .published_at + .as_ref() + .and_then(|published_at| DateTime::parse_from_rfc3339(published_at).ok()) + { + game.set_release_date(date_time.timestamp()); + } + game.add_provider_command( + ProviderCommandAction::ShowInLibrary, + ProviderCommand::String(format!("itch://games/{}", row.id)), + ) + .add_provider_command( + ProviderCommandAction::Install, + ProviderCommand::String(format!("itch://install?game_id={}", row.id)), + ); + + game + }) + .collect()) + } + + async fn get_remote_games(&self) -> Result> { + let remote_games: Vec = + futures::future::join_all(self.database.games.iter().map(|db_item| async { + let mut remote_game = RemoteGame::new(*Self::ID, &db_item.id.to_string()); + + if let Some(cached_remote_game) = self.remote_game_cache.get(&remote_game.id) { + return cached_remote_game.clone(); + } + + match pc_gaming_wiki::get_engine_from_game_title(&db_item.title).await { + Ok(Some(engine)) => { + remote_game.set_engine(engine); + } + Ok(None) => {} + Err(_) => { + remote_game.set_skip_cache(true); + } + } + + remote_game + })) + .await; + + Self::try_save_remote_game_cache(&remote_games); + + Ok(remote_games) + } +} + +fn parse_verdict(json_option: &Option) -> Option { + let json = json_option.as_ref()?; + match serde_json::from_str(json) { + Ok(verdict) => Some(verdict), + Err(err) => { + error!("Failed to parse verdict from json `{json}`. Error: {err}"); + None + } + } +} + +fn get_database(app_data_path: &Path) -> Result { + let db_path = app_data_path.join("db").join("butler.db"); + let connection = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + + let mut caves_statement = connection.prepare( + r"SELECT + caves.game_id, caves.verdict, games.title, games.cover_url + FROM + caves + JOIN + games ON caves.game_id = games.id; + ", + )?; + let cave_rows = caves_statement.query_map([], |row| { + Ok(ItchDatabaseCave { + id: row.get("game_id")?, + title: row.get("title")?, + verdict: parse_verdict(&row.get("verdict").ok()), + cover_url: row.get("cover_url").ok(), + }) + })?; + + let mut games_statement = connection.prepare( + r"SELECT + id, title, url, published_at, cover_url + FROM + 'games' + WHERE + type='default' AND classification='game' + ", + )?; + let game_rows = games_statement.query_map([], |row| { + Ok(ItchDatabaseGame { + id: row.get(0)?, + title: row.get(1)?, + url: row.get(2).ok(), + published_at: row.get(3).ok(), + cover_url: row.get(4).ok(), + }) + })?; + + Ok(ItchDatabase { + games: game_rows + .filter_map(|row| match row { + Ok(game) => Some(game), + Err(err) => { + error!("Failed create itch game from database: {err}"); + None + } + }) + .collect(), + caves: cave_rows + .filter_map(|row| match row { + Ok(cave) => Some(cave), + Err(err) => { + error!("Failed create itch game from database: {err}"); + None + } + }) + .collect(), + }) +} diff --git a/backend/src/providers/manual_provider.rs b/backend/src/providers/manual_provider.rs index 4944a929..23ba3b2a 100644 --- a/backend/src/providers/manual_provider.rs +++ b/backend/src/providers/manual_provider.rs @@ -21,6 +21,7 @@ use crate::{ app_data_path, file_name_without_extension, }, + remote_game::RemoteGame, serializable_struct, Error, Result, @@ -54,8 +55,12 @@ impl ProviderActions for Manual { .collect()) } - async fn get_owned_games(&self) -> Result> { - Ok(Vec::new()) + fn get_owned_games(&self) -> Result> { + Ok(Vec::default()) + } + + async fn get_remote_games(&self) -> Result> { + Ok(Vec::default()) } } diff --git a/backend/src/providers/mod.rs b/backend/src/providers/mod.rs index b2523d19..7c69ccca 100644 --- a/backend/src/providers/mod.rs +++ b/backend/src/providers/mod.rs @@ -1,5 +1,6 @@ pub mod epic_provider; pub mod gog_provider; +pub mod itch_provider; pub mod manual_provider; pub mod provider; pub mod provider_command; diff --git a/backend/src/providers/provider.rs b/backend/src/providers/provider.rs index 8b9dacfd..e7e337c6 100644 --- a/backend/src/providers/provider.rs +++ b/backend/src/providers/provider.rs @@ -9,21 +9,26 @@ use async_trait::async_trait; use enum_dispatch::enum_dispatch; use log::error; -use super::{ +#[cfg(target_os = "windows")] +use crate::providers::{ epic_provider::Epic, gog_provider::Gog, xbox_provider::Xbox, }; use crate::{ debug::LoggableInstant, - game_engines::game_engine::GameEngine, installed_game::InstalledGame, owned_game::OwnedGame, paths, providers::{ + itch_provider::Itch, manual_provider::Manual, steam_provider::Steam, }, + remote_game::{ + self, + RemoteGame, + }, serializable_enum, Result, }; @@ -31,17 +36,23 @@ use crate::{ serializable_enum!(ProviderId { Steam, Manual, + Itch, Epic, Gog, Xbox, }); #[enum_dispatch] +#[derive(Clone)] pub enum Provider { Steam, Manual, + Itch, + #[cfg(target_os = "windows")] Epic, + #[cfg(target_os = "windows")] Gog, + #[cfg(target_os = "windows")] Xbox, } @@ -50,10 +61,10 @@ pub enum Provider { pub trait ProviderActions { fn get_installed_games(&self) -> Result>; - async fn get_owned_games(&self) -> Result>; -} + fn get_owned_games(&self) -> Result>; -pub type EngineCache = HashMap>; + async fn get_remote_games(&self) -> Result>; +} pub trait ProviderStatic: ProviderActions { const ID: &'static ProviderId; @@ -72,18 +83,29 @@ pub trait ProviderStatic: ProviderActions { Ok(path) } - fn get_engine_cache_path() -> Result { - Ok(Self::get_folder()?.join("engine-cache.json")) + fn get_remote_game_cache_path() -> Result { + Ok(Self::get_folder()?.join("remote-game-cache.json")) } - fn save_engine_cache(cache: &EngineCache) -> Result { + fn save_remote_game_cache(cache: &remote_game::Map) -> Result { let json = serde_json::to_string_pretty(cache)?; - fs::write(Self::get_engine_cache_path()?, json)?; + fs::write(Self::get_remote_game_cache_path()?, json)?; Ok(()) } - fn try_save_engine_cache(cache: &EngineCache) { - if let Err(err) = Self::save_engine_cache(cache) { + fn try_save_remote_game_cache(remote_games: &[RemoteGame]) { + if let Err(err) = Self::save_remote_game_cache( + &remote_games + .iter() + .filter_map(|remote_game| { + if remote_game.skip_cache { + None + } else { + Some((remote_game.id.clone(), remote_game.clone())) + } + }) + .collect(), + ) { error!( "Failed to save engine cache for provider '{}'. Error: {}", Self::ID, @@ -92,13 +114,13 @@ pub trait ProviderStatic: ProviderActions { } } - fn get_engine_cache() -> Result { - let json = fs::read_to_string(Self::get_engine_cache_path()?)?; - Ok(serde_json::from_str::(&json)?) + fn get_remote_game_cache() -> Result { + let json = fs::read_to_string(Self::get_remote_game_cache_path()?)?; + Ok(serde_json::from_str::(&json)?) } - fn try_get_engine_cache() -> EngineCache { - match Self::get_engine_cache() { + fn try_get_remote_game_cache() -> remote_game::Map { + match Self::get_remote_game_cache() { Ok(pc_gaming_wiki_cache) => pc_gaming_wiki_cache, Err(err) => { error!( @@ -112,7 +134,7 @@ pub trait ProviderStatic: ProviderActions { } } -type Map = HashMap; +pub type Map = HashMap; fn create_map_entry() -> Result<(String, Provider)> where @@ -142,17 +164,22 @@ pub fn get_map() -> Map { add_entry::(&mut map); now.log_next("set up provider (Steam)"); - add_entry::(&mut map); - now.log_next("set up provider (Epic)"); - - add_entry::(&mut map); - now.log_next("set up provider (Gog)"); - - add_entry::(&mut map); - now.log_next("set up provider (Xbox)"); + add_entry::(&mut map); + now.log_next("set up provider (Itch)"); add_entry::(&mut map); now.log_next("set up provider (Manual)"); + #[cfg(target_os = "windows")] + { + add_entry::(&mut map); + now.log_next("set up provider (Epic)"); + + add_entry::(&mut map); + now.log_next("set up provider (Gog)"); + + add_entry::(&mut map); + now.log_next("set up provider (Xbox)"); + } map } diff --git a/backend/src/providers/provider_command.rs b/backend/src/providers/provider_command.rs index 77b2dac2..1d0320b3 100644 --- a/backend/src/providers/provider_command.rs +++ b/backend/src/providers/provider_command.rs @@ -3,7 +3,10 @@ use std::{ process::Command, }; -use crate::Result; +use crate::{ + serializable_enum, + Result, +}; #[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone, PartialEq, Eq, Hash, Debug)] pub enum ProviderCommand { @@ -11,6 +14,14 @@ pub enum ProviderCommand { Path(PathBuf, Vec), } +serializable_enum!(ProviderCommandAction { + Install, + ShowInLibrary, + ShowInStore, + Start, + OpenInBrowser, +}); + impl ProviderCommand { pub fn run(&self) -> Result { match self { diff --git a/backend/src/providers/steam_provider.rs b/backend/src/providers/steam_provider.rs index cfa642e6..fd52e9df 100644 --- a/backend/src/providers/steam_provider.rs +++ b/backend/src/providers/steam_provider.rs @@ -9,11 +9,11 @@ use lazy_regex::BytesRegex; use steamlocate::SteamDir; use super::{ - provider::{ - self, - ProviderId, + provider::ProviderId, + provider_command::{ + ProviderCommand, + ProviderCommandAction, }, - provider_command::ProviderCommand, }; use crate::{ game_engines::game_engine::GameEngine, @@ -29,6 +29,10 @@ use crate::{ ProviderActions, ProviderStatic, }, + remote_game::{ + self, + RemoteGame, + }, steam::{ appinfo::{ self, @@ -41,10 +45,11 @@ use crate::{ Result, }; +#[derive(Clone)] pub struct Steam { steam_dir: SteamDir, app_info_file: SteamAppInfoFile, - engine_cache: provider::EngineCache, + remote_game_cache: remote_game::Map, } impl ProviderStatic for Steam { @@ -56,12 +61,12 @@ impl ProviderStatic for Steam { { let steam_dir = SteamDir::locate()?; let app_info_file = appinfo::read(steam_dir.path())?; - let engine_cache = Self::try_get_engine_cache(); + let remote_game_cache = Self::try_get_remote_game_cache(); Ok(Self { steam_dir, app_info_file, - engine_cache, + remote_game_cache, }) } } @@ -130,10 +135,61 @@ impl ProviderActions for Steam { Ok(games) } - async fn get_owned_games(&self) -> Result> { + async fn get_remote_games(&self) -> Result> { let steam_games = id_lists::get().await?; - let owned_games = futures::future::join_all(self.app_info_file.apps.iter().map( - |(steam_id, app_info)| async { + + let remote_games: Vec = + futures::future::join_all(self.app_info_file.apps.keys().map(|app_id| async { + let id_string = app_id.to_string(); + let mut remote_game = RemoteGame::new(*Self::ID, &id_string); + + if let Some(cached_remote_game) = self.remote_game_cache.get(&remote_game.id) { + return Some(cached_remote_game.clone()); + } + + if let Some(steam_game) = steam_games.get(&id_string) { + match pc_gaming_wiki::get_engine(&format!("Steam_AppID HOLDS \"{id_string}\"")) + .await + { + Ok(Some(pc_gaming_wiki_engine)) => { + remote_game.set_engine(pc_gaming_wiki_engine); + } + Ok(None) => { + remote_game.set_engine(GameEngine { + brand: steam_game.engine, + version: None, + }); + } + Err(_) => { + remote_game.set_skip_cache(true); + } + } + + if let Some(uevr_score) = steam_game.uevr_score { + remote_game.set_uevr_score(uevr_score); + } + + Some(remote_game) + } else { + None + } + })) + .await + .into_iter() + .flatten() + .collect(); + + Self::try_save_remote_game_cache(&remote_games); + + Ok(remote_games) + } + + fn get_owned_games(&self) -> Result> { + let owned_games: Vec = self + .app_info_file + .apps + .iter() + .filter_map(|(steam_id, app_info)| { let id_string = steam_id.to_string(); let os_list: HashSet<_> = app_info .launch_options @@ -183,23 +239,29 @@ impl ProviderActions for Steam { GameMode::Flat }; - // TODO: cache the whole thing, not just the engine version. - let steam_game_option = steam_games.get(&id_string); - let mut game = OwnedGame::new(&id_string, *Self::ID, &app_info.name); game.set_thumbnail_url(&get_steam_thumbnail(&id_string)) .set_os_list(os_list) .set_game_mode(game_mode) - .set_show_library_command(ProviderCommand::String(format!( - "steam://nav/games/details/{id_string}" - ))) - .set_open_page_command(ProviderCommand::String(format!( - "steam://store/{id_string}" - ))) - .set_install_command(ProviderCommand::String(format!( - "steam://install/{id_string}" - ))); + .add_provider_command( + ProviderCommandAction::ShowInLibrary, + ProviderCommand::String(format!("steam://nav/games/details/{id_string}")), + ) + .add_provider_command( + ProviderCommandAction::ShowInStore, + ProviderCommand::String(format!("steam://store/{id_string}")), + ) + .add_provider_command( + ProviderCommandAction::Install, + ProviderCommand::String(format!("steam://install/{id_string}")), + ) + .add_provider_command( + ProviderCommandAction::OpenInBrowser, + ProviderCommand::String(format!( + "https://store.steampowered.com/app/{id_string}" + )), + ); if let Some(release_date) = app_info .original_release_date @@ -208,43 +270,12 @@ impl ProviderActions for Steam { game.set_release_date(release_date.into()); } - if let Some(steam_game) = steam_game_option { - if let Some(uevr_score) = steam_game.uevr_score { - game.set_uevr_score(uevr_score); - } - - game.set_engine(GameEngine { - brand: steam_game.engine, - version: get_engine(&id_string, &self.engine_cache) - .await - .and_then(|info| info.version), - }); - } - Some(game) - }, - )) - .await - .into_iter() - .flatten(); - - Self::try_save_engine_cache( - &owned_games - .clone() - .map(|owned_game| (owned_game.id.clone(), owned_game.engine)) - .collect(), - ); - - Ok(owned_games.collect()) - } -} + }) + .collect(); -async fn get_engine(steam_id: &str, cache: &provider::EngineCache) -> Option { - if let Some(cached_engine) = cache.get(steam_id) { - return cached_engine.clone(); + Ok(owned_games) } - - pc_gaming_wiki::get_engine(&format!("Steam_AppID%20HOLDS%20%22{steam_id}%22")).await } pub fn get_start_command( diff --git a/backend/src/providers/xbox_provider.rs b/backend/src/providers/xbox_provider.rs index 6c23a66f..45728f4a 100644 --- a/backend/src/providers/xbox_provider.rs +++ b/backend/src/providers/xbox_provider.rs @@ -1,3 +1,5 @@ +#![cfg(target_os = "windows")] + use std::path::PathBuf; use async_trait::async_trait; @@ -19,9 +21,11 @@ use crate::{ ProviderActions, ProviderStatic, }, + remote_game::RemoteGame, Result, }; +#[derive(Clone)] pub struct Xbox {} impl ProviderStatic for Xbox { @@ -106,7 +110,11 @@ impl ProviderActions for Xbox { Ok(result) } - async fn get_owned_games(&self) -> Result> { + fn get_owned_games(&self) -> Result> { + Ok(Vec::default()) + } + + async fn get_remote_games(&self) -> Result> { Ok(Vec::default()) } } diff --git a/backend/src/remote_game.rs b/backend/src/remote_game.rs new file mode 100644 index 00000000..039500ca --- /dev/null +++ b/backend/src/remote_game.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; + +use crate::{ + game_engines::game_engine::GameEngine, + owned_game, + providers::provider::ProviderId, + serializable_struct, + steam::id_lists::UevrScore, +}; + +serializable_struct!(RemoteGame { + pub id: String, + pub engine: Option, + pub uevr_score: Option, + pub skip_cache: bool, +}); + +pub type Map = HashMap; + +impl RemoteGame { + pub fn new(provider_id: ProviderId, provider_game_id: &str) -> Self { + Self { + id: owned_game::get_id(provider_id, provider_game_id), + engine: None, + uevr_score: None, + skip_cache: false, + } + } + + pub fn set_engine(&mut self, engine: GameEngine) -> &mut Self { + self.engine = Some(engine); + self + } + + pub fn set_uevr_score(&mut self, uevr_score: UevrScore) -> &mut Self { + self.uevr_score = Some(uevr_score); + self + } + + pub fn set_skip_cache(&mut self, skip_cache: bool) -> &mut Self { + self.skip_cache = skip_cache; + self + } +} diff --git a/backend/src/result.rs b/backend/src/result.rs index 2c24266c..98ffd2c1 100644 --- a/backend/src/result.rs +++ b/backend/src/result.rs @@ -45,13 +45,19 @@ pub enum Error { #[error(transparent)] Env(#[from] env::VarError), + #[error(transparent)] + TaskJoin(#[from] tokio::task::JoinError), + + #[error(transparent)] + UrlEncode(#[from] serde_urlencoded::ser::Error), + #[error("Invalid type `{0}` in binary vdf key/value pair")] InvalidBinaryVdfType(u8), #[error("Failed to find Rai Pal resources folder")] ResourcesNotFound(), - #[error("Failed to find Rai Pal app data folder")] + #[error("Failed to find app data folder")] AppDataNotFound(), #[error("Failed to parse path (possibly because is a non-UTF-8 string) `{0}`")] @@ -97,9 +103,6 @@ pub enum Error { #[error("Operation can't be completed without a `runnable` section in the mod manifest (rai-pal-manifest.json) `{0}`")] RunnableManifestNotFound(String), - - #[error("Can't run command because it isn't defined for this game.")] - CommandNotDefined(), } impl serde::Serialize for Error { diff --git a/backend/src/steam/appinfo.rs b/backend/src/steam/appinfo.rs index a98dcb9a..551381a5 100644 --- a/backend/src/steam/appinfo.rs +++ b/backend/src/steam/appinfo.rs @@ -103,7 +103,7 @@ pub struct App { pub key_values: KeyValue, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SteamAppInfo { pub launch_options: Vec, pub name: String, @@ -112,7 +112,7 @@ pub struct SteamAppInfo { pub is_free: bool, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SteamAppInfoFile { pub version: u32, pub universe: u32, diff --git a/backend/src/windows.rs b/backend/src/windows.rs index ac0a39d3..e7723c28 100644 --- a/backend/src/windows.rs +++ b/backend/src/windows.rs @@ -1,3 +1,5 @@ +#![cfg(target_os = "windows")] + use std::{ ffi::OsStr, os::windows::ffi::OsStrExt, @@ -47,7 +49,7 @@ pub fn error_dialog(error_text: &str) { base_error_dialog(error_text, MB_OK); } -pub fn error_question_dialog(error_text: &str) -> bool { +fn error_question_dialog(error_text: &str) -> bool { base_error_dialog(error_text, MB_YESNO) == IDYES } diff --git a/frontend/api/bindings.ts b/frontend/api/bindings.ts index 8d56c805..2fc3d52a 100644 --- a/frontend/api/bindings.ts +++ b/frontend/api/bindings.ts @@ -11,7 +11,7 @@ declare global { const invoke = () => window.__TAURI_INVOKE__; export function dummyCommand() { - return invoke()<[InstalledGame, AppEvent]>("dummy_command") + return invoke()<[InstalledGame, AppEvent, ProviderCommandAction]>("dummy_command") } export function updateData() { @@ -90,6 +90,10 @@ export function getRemoteMods() { return invoke()<{ [key: string]: RemoteMod }>("get_remote_mods") } +export function getRemoteGames() { + return invoke()<{ [key: string]: RemoteGame }>("get_remote_games") +} + export function openModLoaderFolder(modLoaderId: string) { return invoke()("open_mod_loader_folder", { modLoaderId }) } @@ -102,39 +106,33 @@ export function openLogsFolder() { return invoke()("open_logs_folder") } -export function showGameInLibrary(ownedGameId: string) { - return invoke()("show_game_in_library", { ownedGameId }) -} - -export function installGame(ownedGameId: string) { - return invoke()("install_game", { ownedGameId }) -} - -export function openGamePage(ownedGameId: string) { - return invoke()("open_game_page", { ownedGameId }) +export function runProviderCommand(ownedGameId: string, commandAction: string) { + return invoke()("run_provider_command", { ownedGameId,commandAction }) } export type GameEngineVersion = { major: number; minor: number; patch: number; suffix: string | null; display: string } export type Manifest = { version: string; runnable: RunnableModData | null; engine: GameEngineBrand | null; unityBackend: UnityScriptingBackend | null } -export type ProviderId = "Steam" | "Manual" | "Epic" | "Gog" | "Xbox" +export type CommonModData = { id: string; engine: GameEngineBrand | null; unityBackend: UnityScriptingBackend | null; loaderId: string } export type GameEngineBrand = "Unity" | "Unreal" | "Godot" export type GameMode = "VR" | "Flat" export type ModKind = "Installable" | "Runnable" export type RunnableModData = { path: string; args: string[] } -export type AppEvent = "SyncInstalledGames" | "SyncOwnedGames" | "SyncModLoaders" | "SyncLocalMods" | "SyncRemoteMods" | "ExecutedProviderCommand" | "GameAdded" | "GameRemoved" | "Error" +export type OwnedGame = { id: string; provider: ProviderId; name: string; osList: OperatingSystem[]; releaseDate: BigInt | null; thumbnailUrl: string | null; gameMode: GameMode | null; providerCommands: { [key: string]: ProviderCommand } } +export type AppEvent = "SyncInstalledGames" | "SyncOwnedGames" | "SyncRemoteGames" | "SyncModLoaders" | "SyncLocalMods" | "SyncRemoteMods" | "ExecutedProviderCommand" | "GameAdded" | "GameRemoved" | "Error" export type ModDownload = { id: string; url: string; root: string | null; runnable: RunnableModData | null } -export type OwnedGame = { id: string; provider: ProviderId; name: string; osList: OperatingSystem[]; engine: GameEngine | null; releaseDate: BigInt | null; thumbnailUrl: string | null; gameMode: GameMode | null; uevrScore: UevrScore | null; showLibraryCommand: ProviderCommand | null; openPageCommand: ProviderCommand | null; installCommand: ProviderCommand | null } export type LocalMod = { data: LocalModData; common: CommonModData } +export type RemoteGame = { id: string; engine: GameEngine | null; uevrScore: UevrScore | null; skipCache: boolean } +export type InstalledGame = { id: string; name: string; provider: ProviderId; executable: GameExecutable; installedModVersions: { [key: string]: string }; discriminator: string | null; thumbnailUrl: string | null; ownedGameId: string | null; startCommand: ProviderCommand | null } export type RemoteModData = { title: string; author: string; sourceCode: string; description: string; latestVersion: ModDownload | null } -export type ProviderCommand = { String: string } | { Path: [string, string[]] } export type LocalModData = { path: string; manifest: Manifest | null } export type ModLoaderData = { id: string; path: string; kind: ModKind } export type UnityScriptingBackend = "Il2Cpp" | "Mono" -export type InstalledGame = { id: string; name: string; provider: ProviderId; executable: GameExecutable; installedModVersions: { [key: string]: string | null }; discriminator: string | null; thumbnailUrl: string | null; ownedGameId: string | null; startCommand: ProviderCommand | null } export type GameExecutable = { path: string; name: string; engine: GameEngine | null; architecture: Architecture | null; operatingSystem: OperatingSystem | null; scriptingBackend: UnityScriptingBackend | null } +export type ProviderCommandAction = "Install" | "ShowInLibrary" | "ShowInStore" | "Start" | "OpenInBrowser" +export type ProviderId = "Steam" | "Manual" | "Itch" | "Epic" | "Gog" | "Xbox" export type RemoteMod = { common: CommonModData; data: RemoteModData } export type UevrScore = "A" | "B" | "C" | "D" | "E" export type GameEngine = { brand: GameEngineBrand; version: GameEngineVersion | null } export type OperatingSystem = "Linux" | "Windows" -export type CommonModData = { id: string; engine: GameEngineBrand | null; unityBackend: UnityScriptingBackend | null; loaderId: string } +export type ProviderCommand = { String: string } | { Path: [string, string[]] } export type Architecture = "X64" | "X86" diff --git a/frontend/app.tsx b/frontend/app.tsx index 45201516..0ee99d8f 100644 --- a/frontend/app.tsx +++ b/frontend/app.tsx @@ -12,7 +12,7 @@ import { IconSettings, IconTool, } from "@tabler/icons-react"; -import { DonatePage } from "@components/donate/donate-page"; +import { ThanksPage } from "@components/thanks/thanks-page"; const pages = { installedGames: { @@ -31,9 +31,9 @@ const pages = { component: SettingsPage, icon: , }, - donate: { - title: "Donate", - component: DonatePage, + thanks: { + title: "Thanks", + component: ThanksPage, icon: , }, }; @@ -76,7 +76,7 @@ function App() { > diff --git a/frontend/components/badges/color-coded-badge.tsx b/frontend/components/badges/color-coded-badge.tsx index f1aae757..f09663ba 100644 --- a/frontend/components/badges/color-coded-badge.tsx +++ b/frontend/components/badges/color-coded-badge.tsx @@ -84,6 +84,7 @@ export const ProviderBadge = CreateColorCodedBadge("Unknown", { Epic: "red", Gog: "violet", Xbox: "green", + Itch: "pink", }); export const UevrScoreBadge = CreateColorCodedBadge("-", { diff --git a/frontend/components/donate/donate-page.tsx b/frontend/components/donate/donate-page.tsx deleted file mode 100644 index ea10462d..00000000 --- a/frontend/components/donate/donate-page.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { - Container, - Group, - Stack, - Card, - Image, - Text, - Button, - Divider, -} from "@mantine/core"; -import { - IconBrandGithubFilled, - IconBrandItch, - IconBrandPatreonFilled, - IconBrandPaypalFilled, -} from "@tabler/icons-react"; - -export function DonatePage() { - return ( - - - - - - - - - Raicuparta - - - - - Hello. I made Rai Pal. I also made other VR mods in the past, - and am currently working on a universal VR mod for Unity - games. If you like what I do, and would like to see more, - please consider donating! You can also support me by buying - one of my free mods on itch.io. - - - - - - - - - - - - - - - - - - - Right now, the most likely reason for you to be using Rai Pal - is to make it easier to manage Unreal Engine games for - praydog's UEVR. You should consider supporting praydog on - Patreon if you're excited about UEVR and want to continue - seeing more of praydog's excellent work. - - - - - - praydog - - - - - - - - - - {" "} - - - - - - Other modders - - - Rai Pal is meant to help you manage game modding, and we - can't do that without the tools that other developers have - created. Some of these people don't have donation links, - but I'm extremely grateful for their work. - - - - - - - - - - - - - ); -} diff --git a/frontend/components/filters/filter-button.tsx b/frontend/components/filters/filter-button.tsx new file mode 100644 index 00000000..009a1a40 --- /dev/null +++ b/frontend/components/filters/filter-button.tsx @@ -0,0 +1,36 @@ +import { FilterOption } from "@components/table/table-head"; +import { Button, Checkbox } from "@mantine/core"; +import { useCallback } from "react"; +import { UnknownFilterOption } from "./filter-select"; +import styles from "./filters.module.css"; + +type Props = { + readonly isHidden: boolean; + readonly onClick: (value: TFilterOption | null) => void; + readonly filterOption: UnknownFilterOption | FilterOption; +}; + +export function FilterButton( + props: Props, +) { + const handleClick = useCallback(() => { + props.onClick(props.filterOption.value); + }, [props]); + + return ( + + ); +} diff --git a/frontend/components/filter-menu.tsx b/frontend/components/filters/filter-menu.tsx similarity index 73% rename from frontend/components/filter-menu.tsx rename to frontend/components/filters/filter-menu.tsx index 5559f71e..62070417 100644 --- a/frontend/components/filter-menu.tsx +++ b/frontend/components/filters/filter-menu.tsx @@ -1,4 +1,4 @@ -import { Button, Indicator, Popover } from "@mantine/core"; +import { Button, Group, Indicator, Popover } from "@mantine/core"; import { IconFilter, IconX } from "@tabler/icons-react"; type Props = { @@ -17,16 +17,18 @@ export function FilterMenu(props: Props) { {props.active && ( )} - + - {props.children} + + {props.children} + diff --git a/frontend/components/filters/filter-select.tsx b/frontend/components/filters/filter-select.tsx new file mode 100644 index 00000000..45106699 --- /dev/null +++ b/frontend/components/filters/filter-select.tsx @@ -0,0 +1,101 @@ +import { Button, Group, Stack, Tooltip } from "@mantine/core"; +import { TableColumn } from "../table/table-head"; +import { IconEye, IconEyeClosed, IconRestore } from "@tabler/icons-react"; +import { useCallback, useMemo } from "react"; +import { FilterButton } from "./filter-button"; + +const unknownOption = { + value: null, + label: "Unknown", +}; + +export type UnknownFilterOption = typeof unknownOption; + +type Props = { + readonly column: TableColumn; + readonly visibleColumns: TKey[]; + readonly onChangeVisibleColumns: (visibleColumns: TKey[]) => void; + readonly hiddenValues?: (TFilterOption | null)[]; + readonly onChange: (hiddenValues: (TFilterOption | null)[]) => void; +}; + +const defaultHiddenValues: unknown[] = []; + +export function FilterSelect< + TKey extends string, + TItem, + TFilterOption extends string, +>({ + column, + onChange, + onChangeVisibleColumns, + visibleColumns, + hiddenValues = defaultHiddenValues as TFilterOption[], +}: Props) { + const optionsWithUnknown = useMemo( + () => [unknownOption, ...(column.filterOptions ?? [])], + [column.filterOptions], + ); + + const handleFilterClick = useCallback( + (value: TFilterOption | null) => { + const newValues = + hiddenValues.indexOf(value) === -1 + ? [...hiddenValues, value] + : hiddenValues.filter((id) => id !== value); + + // If all possible values are hidden, it will always yield an empty result list, + // so in that case we just reset this filter. + onChange(newValues.length >= optionsWithUnknown.length ? [] : newValues); + }, + [hiddenValues, onChange, optionsWithUnknown.length], + ); + + const handleReset = () => { + onChange([]); + }; + + if (!column.filterOptions) return null; + + const isColumnVisible = visibleColumns.includes(column.id); + + return ( + + + + + + {optionsWithUnknown.map((filterOption) => ( + + ))} + + + + ); +} diff --git a/frontend/components/filters/filters.module.css b/frontend/components/filters/filters.module.css new file mode 100644 index 00000000..8bfc3a5b --- /dev/null +++ b/frontend/components/filters/filters.module.css @@ -0,0 +1,3 @@ +.checkbox { + pointer-events: none; +} diff --git a/frontend/components/installed-games/game-mod-row.tsx b/frontend/components/installed-games/game-mod-row.tsx index be572fb1..1513122c 100644 --- a/frontend/components/installed-games/game-mod-row.tsx +++ b/frontend/components/installed-games/game-mod-row.tsx @@ -18,7 +18,7 @@ import { } from "@tabler/icons-react"; import { UnifiedMod } from "@hooks/use-unified-mods"; import { getIsOutdated } from "../../util/is-outdated"; -import { OutdatedMarker } from "@components/OutdatedMarker"; +import { OutdatedMarker } from "@components/outdated-marker"; import { ProcessedInstalledGame } from "@hooks/use-processed-installed-games"; import { useCallback } from "react"; import { ItemName } from "@components/item-name"; diff --git a/frontend/components/installed-games/installed-game-modal.tsx b/frontend/components/installed-games/installed-game-modal.tsx index b1321bce..17858e38 100644 --- a/frontend/components/installed-games/installed-game-modal.tsx +++ b/frontend/components/installed-games/installed-game-modal.tsx @@ -9,13 +9,10 @@ import { Tooltip, } from "@mantine/core"; import { - ProviderId, openGameFolder, openGameModsFolder, - openGamePage, refreshGame, removeGame, - showGameInLibrary, startGame, startGameExe, } from "@api/bindings"; @@ -23,20 +20,12 @@ import { useMemo } from "react"; import { ItemName } from "../item-name"; import { CommandButton } from "@components/command-button"; import { - Icon, IconAppWindow, - IconBooks, - IconBrandSteam, - IconBrandXbox, - IconBrowser, - IconCircleLetterG, - IconDeviceGamepad, IconFolder, IconFolderCog, IconFolderOpen, IconPlayerPlay, IconRefresh, - IconSquareLetterE, IconTrash, } from "@tabler/icons-react"; import { ModalImage } from "@components/modal-image"; @@ -51,36 +40,31 @@ import { GameModRow } from "./game-mod-row"; import { TableContainer } from "@components/table/table-container"; import { CommandDropdown } from "@components/command-dropdown"; import { getThumbnailWithFallback } from "../../util/fallback-thumbnail"; +import { ProviderCommandButtons } from "@components/providers/provider-command-dropdown"; +import { ProviderIcon } from "@components/providers/provider-icon"; type Props = { readonly game: ProcessedInstalledGame; readonly onClose: () => void; }; -const providerIcons: Record = { - Manual: IconDeviceGamepad, - Steam: IconBrandSteam, - Epic: IconSquareLetterE, - Gog: IconCircleLetterG, - Xbox: IconBrandXbox, -}; - -function getProviderIcon(providerId: ProviderId) { - return providerIcons[providerId] ?? IconDeviceGamepad; -} - export function InstalledGameModal(props: Props) { const modLoaderMap = useAtomValue(modLoadersAtom); const mods = useUnifiedMods(); const filteredMods = useMemo(() => { return Object.values(mods).filter( - (mod) => mod.common.id in props.game.installedModVersions, + (mod) => + (!mod.common.engine || + mod.common.engine === props.game.executable.engine?.brand) && + (!mod.common.unityBackend || + mod.common.unityBackend === props.game.executable.scriptingBackend), ); - }, [mods, props.game.installedModVersions]); - - const ProviderIcon = getProviderIcon(props.game.provider); - const ownedGame = props.game.ownedGame; + }, [ + mods, + props.game.executable.engine?.brand, + props.game.executable.scriptingBackend, + ]); return ( @@ -129,7 +113,9 @@ export function InstalledGameModal(props: Props) { Start Game Executable } + leftSection={ + + } onClick={() => startGame(props.game.id)} > Start Game via {props.game.provider} @@ -154,28 +140,11 @@ export function InstalledGameModal(props: Props) { Open Installed Mods Folder - {(ownedGame?.openPageCommand || ownedGame?.showLibraryCommand) && ( - } - > - {ownedGame.openPageCommand && ( - } - onClick={() => openGamePage(ownedGame.id)} - > - Open Store Page - - )} - {ownedGame.showLibraryCommand && ( - } - onClick={() => showGameInLibrary(ownedGame.id)} - > - Show in Library - - )} - + {props.game.ownedGame && ( + )} {props.game.provider === "Manual" && ( = { center: true, hidable: true, getSortValue: (game) => game.provider, + getFilterValue: (game) => game.provider, filterOptions: providerFilterOptions, renderCell: (game) => ( @@ -89,8 +90,8 @@ const operatingSystem: TableColumnBase< center: true, hidable: true, getSortValue: (game) => game.executable.operatingSystem, + getFilterValue: (game) => game.executable.operatingSystem, filterOptions: [ - { label: "Any OS", value: "" }, { label: "Windows", value: "Windows" }, { label: "Linux", value: "Linux" }, ], @@ -107,8 +108,8 @@ const architecture: TableColumnBase = { center: true, hidable: true, getSortValue: (game) => game.executable.architecture, + getFilterValue: (game) => game.executable.architecture, filterOptions: [ - { label: "Any architecture", value: "" }, { label: "x64", value: "X64" }, { label: "x86", value: "X86" }, ], @@ -128,8 +129,8 @@ const scriptingBackend: TableColumnBase< center: true, hidable: true, getSortValue: (game) => game.executable.scriptingBackend, + getFilterValue: (game) => game.executable.scriptingBackend, filterOptions: [ - { label: "Any backend", value: "" }, { label: "IL2CPP", value: "Il2Cpp" }, { label: "Mono", value: "Mono" }, ], @@ -146,8 +147,8 @@ const gameMode: TableColumnBase = { center: true, hidable: true, getSortValue: (game) => game.ownedGame?.gameMode, + getFilterValue: (game) => game.ownedGame?.gameMode ?? null, filterOptions: [ - { label: "Any mode", value: "" }, { label: "Flat", value: "Flat" }, { label: "VR", value: "VR" }, ], @@ -165,7 +166,7 @@ const engine: TableColumnBase = { hidable: true, sort: (dataA, dataB) => sortGamesByEngine(dataA.executable.engine, dataB.executable.engine), - getFilterValue: (game) => game.executable.engine?.brand ?? "", + getFilterValue: (game) => game.executable.engine?.brand ?? null, unavailableValues: ["Godot"], filterOptions: engineFilterOptions, renderCell: ({ executable: { engine } }) => ( diff --git a/frontend/components/installed-games/installed-games-page.tsx b/frontend/components/installed-games/installed-games-page.tsx index 3f672075..0eb12e63 100644 --- a/frontend/components/installed-games/installed-games-page.tsx +++ b/frontend/components/installed-games/installed-games-page.tsx @@ -2,9 +2,8 @@ import { Group, Stack } from "@mantine/core"; import { useMemo, useState } from "react"; import { filterGame, includesOneOf } from "../../util/filter"; import { InstalledGameModal } from "./installed-game-modal"; -import { TypedSegmentedControl } from "./typed-segmented-control"; import { useFilteredList } from "@hooks/use-filtered-list"; -import { FilterMenu } from "@components/filter-menu"; +import { FilterMenu } from "@components/filters/filter-menu"; import { VirtualizedTable } from "@components/table/virtualized-table"; import { RefreshButton } from "@components/refresh-button"; import { SearchInput } from "@components/search-input"; @@ -12,19 +11,19 @@ import { InstalledGameColumnsId, installedGamesColumns, } from "./installed-games-columns"; -import { ColumnsSelect } from "@components/columns-select"; import { usePersistedState } from "@hooks/use-persisted-state"; import { AddGame } from "./add-game-button"; import { ProcessedInstalledGame, useProcessedInstalledGames, } from "@hooks/use-processed-installed-games"; +import { FilterSelect } from "@components/filters/filter-select"; -const defaultFilter: Record = {}; +const defaultFilter: Record = {}; function filterInstalledGame( game: ProcessedInstalledGame, - filter: Record, + filter: Record, search: string, ) { return ( @@ -38,6 +37,12 @@ export type TableSortMethod = ( gameB: ProcessedInstalledGame, ) => number; +const defaultColumns: InstalledGameColumnsId[] = [ + "thumbnail", + "engine", + "provider", +]; + export function InstalledGamesPage() { const installedGames = useProcessedInstalledGames(); @@ -53,7 +58,7 @@ export function InstalledGamesPage() { const [visibleColumnIds, setVisibleColumnIds] = usePersistedState< InstalledGameColumnsId[] - >(["thumbnail", "engine"], "installed-visible-columns"); + >(defaultColumns, "installed-visible-columns"); const filteredColumns = useMemo( () => @@ -63,16 +68,25 @@ export function InstalledGamesPage() { [visibleColumnIds], ); - const [filteredGames, sort, setSort, filter, setFilter, search, setSearch] = - useFilteredList( - "installed-games-filter", - filteredColumns, - installedGames, - filterInstalledGame, - defaultFilter, - ); + const [ + filteredGames, + sort, + setSort, + hiddenValues, + setHiddenValues, + search, + setSearch, + ] = useFilteredList( + "installed-games-hidden", + filteredColumns, + installedGames, + filterInstalledGame, + defaultFilter, + ); - const isFilterActive = Object.values(filter).filter(Boolean).length > 0; + const isFilterActive = + Object.values(hiddenValues).filter((filterValue) => filterValue.length > 0) + .length > 0; return ( @@ -84,29 +98,24 @@ export function InstalledGamesPage() { count={filteredGames.length} /> - - - - {installedGamesColumns.map( - (column) => - column.filterOptions && ( - setFilter({ [column.id]: value })} - unavailableValues={column.unavailableValues} - value={filter[column.id]} - /> - ), - )} - + {installedGamesColumns.map( + (column) => + column.filterOptions && ( + + setHiddenValues({ [column.id]: selectedValues }) + } + /> + ), + )} diff --git a/frontend/components/installed-games/typed-segmented-control.tsx b/frontend/components/installed-games/typed-segmented-control.tsx index 3ae01e0f..e0ddbf12 100644 --- a/frontend/components/installed-games/typed-segmented-control.tsx +++ b/frontend/components/installed-games/typed-segmented-control.tsx @@ -27,7 +27,7 @@ export function TypedSegmentedControl( disabled: isUnavailable, label: (
{data.label || data.value}
diff --git a/frontend/components/mods/mod-modal.tsx b/frontend/components/mods/mod-modal.tsx index b1d0a033..e6ee9f23 100644 --- a/frontend/components/mods/mod-modal.tsx +++ b/frontend/components/mods/mod-modal.tsx @@ -1,7 +1,11 @@ import { Modal, Stack } from "@mantine/core"; import { downloadMod, openModFolder } from "@api/bindings"; import { CommandButton } from "@components/command-button"; -import { IconDownload, IconFolderCog } from "@tabler/icons-react"; +import { + IconDownload, + IconFolderCog, + IconRefreshAlert, +} from "@tabler/icons-react"; import { DebugData } from "@components/debug-data"; import { UnifiedMod } from "@hooks/use-unified-mods"; import { ItemName } from "@components/item-name"; @@ -13,6 +17,10 @@ type Props = { export function ModModal(props: Props) { const isDownloadAvailable = Boolean(props.mod.remote?.latestVersion?.url); + const localVersion = props.mod.local?.manifest?.version; + const remoteVersion = props.mod.remote?.latestVersion?.id; + const isOutdated = + localVersion && remoteVersion && remoteVersion !== localVersion; return ( )} - {!isDownloadAvailable && !props.mod.local && ( - } - onClick={() => openModFolder(props.mod.common.id)} - > - Open mod loader folder - - )} {isDownloadAvailable && ( } + leftSection={isOutdated ? : } onClick={() => downloadMod(props.mod.common.id)} > - Download mod + {isOutdated ? "Update mod" : "Download mod"} )} diff --git a/frontend/components/mods/mod-version-badge.tsx b/frontend/components/mods/mod-version-badge.tsx index 07956cdc..5f79eb02 100644 --- a/frontend/components/mods/mod-version-badge.tsx +++ b/frontend/components/mods/mod-version-badge.tsx @@ -1,6 +1,6 @@ import { Badge, DefaultMantineColor, Stack, Tooltip } from "@mantine/core"; import { getIsOutdated } from "../../util/is-outdated"; -import { OutdatedMarker } from "@components/OutdatedMarker"; +import { OutdatedMarker } from "@components/outdated-marker"; type Props = { readonly localVersion?: string; diff --git a/frontend/components/OutdatedMarker.tsx b/frontend/components/outdated-marker.tsx similarity index 100% rename from frontend/components/OutdatedMarker.tsx rename to frontend/components/outdated-marker.tsx diff --git a/frontend/components/owned-games/owned-game-modal.tsx b/frontend/components/owned-games/owned-game-modal.tsx index c0da69ea..cda3733e 100644 --- a/frontend/components/owned-games/owned-game-modal.tsx +++ b/frontend/components/owned-games/owned-game-modal.tsx @@ -1,7 +1,4 @@ import { Group, Modal, Stack } from "@mantine/core"; -import { installGame, openGamePage, showGameInLibrary } from "@api/bindings"; -import { CommandButton } from "@components/command-button"; -import { IconBooks, IconBrowser, IconDownload } from "@tabler/icons-react"; import { ModalImage } from "@components/modal-image"; import { DebugData } from "@components/debug-data"; import { TableItemDetails } from "@components/table/table-item-details"; @@ -9,6 +6,7 @@ import { ownedGamesColumns } from "./owned-games-columns"; import { ItemName } from "@components/item-name"; import { getThumbnailWithFallback } from "../../util/fallback-thumbnail"; import { ProcessedOwnedGame } from "@hooks/use-processed-owned-games"; +import { ProviderCommandButtons } from "../providers/provider-command-dropdown"; type Props = { readonly game: ProcessedOwnedGame; @@ -39,30 +37,10 @@ export function OwnedGameModal(props: Props) { columns={ownedGamesColumns} item={props.game} /> - {props.game.openPageCommand && ( - } - onClick={() => openGamePage(props.game.id)} - > - Open Store Page - - )} - {props.game.showLibraryCommand && ( - } - onClick={() => showGameInLibrary(props.game.id)} - > - Show in Library - - )} - {props.game.installCommand && ( - } - onClick={() => installGame(props.game.id)} - > - Install - - )} +
diff --git a/frontend/components/owned-games/owned-games-columns.tsx b/frontend/components/owned-games/owned-games-columns.tsx index c93f32c6..06a05286 100644 --- a/frontend/components/owned-games/owned-games-columns.tsx +++ b/frontend/components/owned-games/owned-games-columns.tsx @@ -52,6 +52,7 @@ const provider: TableColumnBase = { center: true, hidable: true, getSortValue: (game) => game.provider, + getFilterValue: (game) => game.provider, filterOptions: providerFilterOptions, unavailableValues: ["Manual", "Xbox"], renderCell: (game) => ( @@ -66,16 +67,16 @@ const engine: TableColumnBase = { width: 150, center: true, hidable: true, - sort: (dataA, dataB) => sortGamesByEngine(dataA.engine, dataB.engine), - getFilterValue: (game) => game.engine?.brand ?? "", - unavailableValues: ["Godot"], + sort: (dataA, dataB) => + sortGamesByEngine(dataA.remoteData?.engine, dataB.remoteData?.engine), + getFilterValue: (game) => game.remoteData?.engine?.brand ?? null, filterOptions: engineFilterOptions, - renderCell: ({ engine }) => ( + renderCell: ({ remoteData }) => ( ), @@ -89,7 +90,6 @@ const installed: TableColumnBase = { getSortValue: (game) => game.isInstalled, getFilterValue: (game) => `${game.isInstalled}`, filterOptions: [ - { label: "Any install state", value: "" }, { label: "Installed", value: "true" }, { label: "Not Installed", value: "false" }, ], @@ -104,8 +104,8 @@ const gameMode: TableColumnBase = { center: true, hidable: true, getSortValue: (game) => game.gameMode, + getFilterValue: (game) => game.gameMode, filterOptions: [ - { label: "Any mode", value: "" }, { label: "Flat", value: "Flat" }, { label: "VR", value: "VR" }, ], @@ -121,9 +121,9 @@ const uevrScore: TableColumnBase = { width: 90, center: true, hidable: true, - getSortValue: (game) => game.uevrScore, + getSortValue: (game) => game.remoteData?.uevrScore, + getFilterValue: (game) => game.remoteData?.uevrScore ?? null, filterOptions: [ - { label: "Any UEVR score", value: "" }, { label: "A", value: "A" }, { label: "B", value: "B" }, { label: "C", value: "C" }, @@ -131,7 +131,7 @@ const uevrScore: TableColumnBase = { ], renderCell: (game) => ( - + ), }; diff --git a/frontend/components/owned-games/owned-games-page.tsx b/frontend/components/owned-games/owned-games-page.tsx index 8ff37e60..0658337b 100644 --- a/frontend/components/owned-games/owned-games-page.tsx +++ b/frontend/components/owned-games/owned-games-page.tsx @@ -3,25 +3,30 @@ import { useMemo, useState } from "react"; import { filterGame, includesOneOf } from "../../util/filter"; import { OwnedGameModal } from "./owned-game-modal"; import { useFilteredList } from "@hooks/use-filtered-list"; -import { FilterMenu } from "@components/filter-menu"; +import { FilterMenu } from "@components/filters/filter-menu"; import { VirtualizedTable } from "@components/table/virtualized-table"; import { RefreshButton } from "@components/refresh-button"; import { SearchInput } from "@components/search-input"; import { OwnedGameColumnsId, ownedGamesColumns } from "./owned-games-columns"; -import { ColumnsSelect } from "@components/columns-select"; import { usePersistedState } from "@hooks/use-persisted-state"; -import { TypedSegmentedControl } from "@components/installed-games/typed-segmented-control"; import { FixOwnedGamesButton } from "./fix-owned-games-button"; import { ProcessedOwnedGame, useProcessedOwnedGames, } from "@hooks/use-processed-owned-games"; +import { FilterSelect } from "@components/filters/filter-select"; -const defaultFilter: Record = {}; +const defaultFilter: Record = {}; + +const defaultColumns: OwnedGameColumnsId[] = [ + "thumbnail", + "engine", + "provider", +]; function filterOwnedGame( game: ProcessedOwnedGame, - filter: Record, + filter: Record, search: string, ) { return ( @@ -43,7 +48,7 @@ export function OwnedGamesPage() { const [visibleColumnIds, setVisibleColumnIds] = usePersistedState< OwnedGameColumnsId[] - >(["thumbnail", "engine", "releaseDate"], "owned-visible-columns"); + >(defaultColumns, "owned-visible-columns"); const filteredColumns = useMemo( () => @@ -53,16 +58,23 @@ export function OwnedGamesPage() { [visibleColumnIds], ); - const [filteredGames, sort, setSort, filter, setFilter, search, setSearch] = - useFilteredList( - "owned-games-filter", - filteredColumns, - ownedGames, - filterOwnedGame, - defaultFilter, - ); + const [ + filteredGames, + sort, + setSort, + hiddenValues, + setHiddenValues, + search, + setSearch, + ] = useFilteredList( + "owned-games-hidden", + filteredColumns, + ownedGames, + filterOwnedGame, + defaultFilter, + ); - const isFilterActive = Object.values(filter).filter(Boolean).length > 0; + const isFilterActive = Object.values(hiddenValues).filter(Boolean).length > 0; return ( @@ -80,28 +92,24 @@ export function OwnedGamesPage() { count={filteredGames.length} /> - - - {ownedGamesColumns.map( - (column) => - column.filterOptions && ( - setFilter({ [column.id]: value })} - value={filter[column.id]} - /> - ), - )} - + {ownedGamesColumns.map( + (column) => + column.filterOptions && ( + + setHiddenValues({ [column.id]: selectedValues }) + } + /> + ), + )} diff --git a/frontend/components/providers/provider-command-button.tsx b/frontend/components/providers/provider-command-button.tsx new file mode 100644 index 00000000..bfab53b0 --- /dev/null +++ b/frontend/components/providers/provider-command-button.tsx @@ -0,0 +1,50 @@ +import { + OwnedGame, + ProviderCommandAction, + runProviderCommand, +} from "@api/bindings"; +import { CommandButton } from "@components/command-button"; +import { + Icon, + IconDeviceGamepad, + IconBooks, + IconBrowser, + IconDownload, + IconPlayerPlay, + IconExternalLink, +} from "@tabler/icons-react"; + +type Props = { + readonly game: OwnedGame; + readonly action: ProviderCommandAction; +}; + +const providerCommandActionName: Record = { + Install: "Install", + ShowInLibrary: "Show In Library", + ShowInStore: "Open Store Page", + Start: "Start Game", + OpenInBrowser: "Open In Browser", +}; + +const providerCommandActionIcon: Record = { + Install: IconDownload, + ShowInLibrary: IconBooks, + ShowInStore: IconBrowser, + Start: IconPlayerPlay, + OpenInBrowser: IconExternalLink, +}; + +export function ProviderCommandButton(props: Props) { + const IconComponent = + providerCommandActionIcon[props.action] ?? IconDeviceGamepad; + + return ( + } + onClick={() => runProviderCommand(props.game.id, props.action)} + > + {providerCommandActionName[props.action]} + + ); +} diff --git a/frontend/components/providers/provider-command-dropdown.tsx b/frontend/components/providers/provider-command-dropdown.tsx new file mode 100644 index 00000000..ad0d549e --- /dev/null +++ b/frontend/components/providers/provider-command-dropdown.tsx @@ -0,0 +1,37 @@ +import { ProviderCommandAction, OwnedGame } from "@api/bindings"; +import { CommandDropdown } from "@components/command-dropdown"; +import { ProviderIcon } from "@components/providers/provider-icon"; +import { ProviderCommandButton } from "./provider-command-button"; + +type Props = { + readonly game: OwnedGame; + readonly isInstalled?: boolean; +}; + +export function ProviderCommandButtons(props: Props) { + let providerCommandActions = Object.keys( + props.game.providerCommands, + ) as ProviderCommandAction[]; // TODO need to convert the type here since tauri-specta can't do it. + if (props.isInstalled) { + providerCommandActions = providerCommandActions.filter( + (action) => (action as ProviderCommandAction) != "Install", + ); + } + + if (providerCommandActions.length == 0) return null; + + return ( + } + > + {providerCommandActions.map((providerCommandAction) => ( + + ))} + + ); +} diff --git a/frontend/components/providers/provider-icon.tsx b/frontend/components/providers/provider-icon.tsx new file mode 100644 index 00000000..7c9d737f --- /dev/null +++ b/frontend/components/providers/provider-icon.tsx @@ -0,0 +1,28 @@ +import { ProviderId } from "@api/bindings"; +import { + Icon, + IconDeviceGamepad, + IconBrandSteam, + IconSquareLetterE, + IconCircleLetterG, + IconBrandXbox, + IconBrandItch, +} from "@tabler/icons-react"; + +type Props = { + readonly providerId: ProviderId; +}; + +const providerIcons: Record = { + Manual: IconDeviceGamepad, + Steam: IconBrandSteam, + Epic: IconSquareLetterE, + Gog: IconCircleLetterG, + Xbox: IconBrandXbox, + Itch: IconBrandItch, +}; + +export function ProviderIcon(props: Props) { + const IconComponent = providerIcons[props.providerId] ?? IconDeviceGamepad; + return ; +} diff --git a/frontend/components/refresh-button.tsx b/frontend/components/refresh-button.tsx index f5fb99a3..37d96a9e 100644 --- a/frontend/components/refresh-button.tsx +++ b/frontend/components/refresh-button.tsx @@ -13,7 +13,6 @@ export function RefreshButton() { leftSection={} loading={isLoading} onClick={updateAppData} - style={{ flex: 1, maxWidth: "10em" }} variant="filled" > Refresh diff --git a/frontend/components/table/table-head.tsx b/frontend/components/table/table-head.tsx index 88d7e32f..4342a3c0 100644 --- a/frontend/components/table/table-head.tsx +++ b/frontend/components/table/table-head.tsx @@ -2,7 +2,11 @@ import { Box, Flex, Table } from "@mantine/core"; import classes from "./table.module.css"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { TableSort } from "@hooks/use-table-sort"; -import { SegmentedControlData } from "@components/installed-games/typed-segmented-control"; + +export type FilterOption = { + value: TFilterOption; + label: string; +}; export type TableColumnBase = { label: string; @@ -15,8 +19,8 @@ export type TableColumnBase = { unavailableValues?: TFilterOption[]; sort?: (itemA: TItem, itemB: TItem) => number; getSortValue?: (item: TItem) => unknown; - getFilterValue?: (item: TItem) => TFilterOption | ""; - filterOptions?: SegmentedControlData[]; + getFilterValue?: (item: TItem) => TFilterOption | null; + filterOptions?: FilterOption[]; }; export interface TableColumn< diff --git a/frontend/components/thanks/thanks-link-button.tsx b/frontend/components/thanks/thanks-link-button.tsx new file mode 100644 index 00000000..52f2c1c5 --- /dev/null +++ b/frontend/components/thanks/thanks-link-button.tsx @@ -0,0 +1,19 @@ +import { Button, ButtonProps } from "@mantine/core"; +import styles from "./thanks.module.css"; + +interface Props extends ButtonProps { + readonly href: string; +} + +export function ThanksLinkButton(props: Props) { + return ( +