From e752e77c793698b5c2d4f1d2aa3e0328ecbb6cb1 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 9 Feb 2026 16:06:31 +1100 Subject: [PATCH 01/14] refactor: split umu launcher --- src-tauri/process/src/process_handlers.rs | 95 ++++++++++++++++------- src-tauri/process/src/process_manager.rs | 19 +++-- 2 files changed, 74 insertions(+), 40 deletions(-) diff --git a/src-tauri/process/src/process_handlers.rs b/src-tauri/process/src/process_handlers.rs index c6d555d..91b8f3e 100644 --- a/src-tauri/process/src/process_handlers.rs +++ b/src-tauri/process/src/process_handlers.rs @@ -25,15 +25,15 @@ impl ProcessHandler for NativeGameLauncher { } } -pub struct UMULauncher; -impl ProcessHandler for UMULauncher { +pub struct UMUNativeLauncher; +impl ProcessHandler for UMUNativeLauncher { fn create_launch_process( &self, meta: &DownloadableMetadata, launch_command: String, game_version: &GameVersion, _current_dir: &str, - database: &Database, + _database: &Database, ) -> Result { let umu_id_override = game_version .launches @@ -52,39 +52,74 @@ impl ProcessHandler for UMULauncher { let pfx_dir = pfx_dir.join(meta.id.clone()); create_dir_all(&pfx_dir)?; - let no_proton = match meta.target_platform { - Platform::Linux => Some("UMU_NO_PROTON=1"), - _ => None, + Ok(format!( + "GAMEID={game_id} UMU_NO_PROTON=1 WINEPREFIX={} {umu:?} {launch}", + pfx_dir.to_string_lossy(), + umu = UMU_LAUNCHER_EXECUTABLE + .as_ref() + .expect("Failed to get UMU_LAUNCHER_EXECUTABLE as ref"), + launch = launch_command, + )) + } + + fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool { + let Some(compat_info) = &*COMPAT_INFO else { + return false; }; + compat_info.umu_installed + } +} - let proton_env = if no_proton.is_none() { - let proton_path = game_version - .user_configuration - .override_proton_path - .as_ref() - .or(database.applications.default_proton_path.as_ref()) - .ok_or(ProcessError::NoCompat)?; - - #[cfg(target_os = "linux")] - let proton_valid = crate::compat::read_proton_path(PathBuf::from(proton_path)) - .ok() - .flatten() - .is_some(); - #[cfg(not(target_os = "linux"))] - let proton_valid = false; - if !proton_valid { - return Err(ProcessError::NoCompat); - } - Some(format!("PROTONPATH={}", proton_path)) +pub struct UMUCompatLauncher; +impl ProcessHandler for UMUCompatLauncher { + fn create_launch_process( + &self, + meta: &DownloadableMetadata, + launch_command: String, + game_version: &GameVersion, + _current_dir: &str, + database: &Database, + ) -> Result { + let umu_id_override = game_version + .launches + .iter() + .find(|v| v.platform == meta.target_platform) + .and_then(|v| v.umu_id_override.as_ref()) + .map_or("", |v| v); + + let game_id = if umu_id_override.is_empty() { + &game_version.version_id } else { - None + umu_id_override }; + let pfx_dir = DATA_ROOT_DIR.join("pfx"); + let pfx_dir = pfx_dir.join(meta.id.clone()); + create_dir_all(&pfx_dir)?; + + let proton_path = game_version + .user_configuration + .override_proton_path + .as_ref() + .or(database.applications.default_proton_path.as_ref()) + .ok_or(ProcessError::NoCompat)?; + + #[cfg(target_os = "linux")] + let proton_valid = crate::compat::read_proton_path(PathBuf::from(proton_path)) + .ok() + .flatten() + .is_some(); + #[cfg(not(target_os = "linux"))] + let proton_valid = false; + if !proton_valid { + return Err(ProcessError::NoCompat); + } + let proton_env = format!("PROTONPATH={}", proton_path); + Ok(format!( - "GAMEID={game_id} {} WINEPREFIX={} {} {umu:?} {launch}", - proton_env.unwrap_or(String::new()), + "GAMEID={game_id} {} WINEPREFIX={} {umu:?} {launch}", + proton_env, pfx_dir.to_string_lossy(), - no_proton.unwrap_or(""), umu = UMU_LAUNCHER_EXECUTABLE .as_ref() .expect("Failed to get UMU_LAUNCHER_EXECUTABLE as ref"), @@ -110,7 +145,7 @@ impl ProcessHandler for AsahiMuvmLauncher { current_dir: &str, database: &Database, ) -> Result { - let umu_launcher = UMULauncher {}; + let umu_launcher = UMUCompatLauncher {}; let umu_string = umu_launcher.create_launch_process( meta, launch_command, diff --git a/src-tauri/process/src/process_manager.rs b/src-tauri/process/src/process_manager.rs index 1562005..377e342 100644 --- a/src-tauri/process/src/process_manager.rs +++ b/src-tauri/process/src/process_manager.rs @@ -26,7 +26,9 @@ use crate::{ error::ProcessError, format::DropFormatArgs, parser::{LaunchParameters, ParsedCommand}, - process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher}, + process_handlers::{ + AsahiMuvmLauncher, NativeGameLauncher, UMUCompatLauncher, UMUNativeLauncher, + }, }; pub struct RunningProcess { @@ -75,7 +77,7 @@ impl ProcessManager<'_> { ), ( (Platform::Linux, Platform::Linux), - &UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), + &UMUNativeLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), ), ( (Platform::macOS, Platform::macOS), @@ -87,7 +89,7 @@ impl ProcessManager<'_> { ), ( (Platform::Linux, Platform::Windows), - &UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), + &UMUCompatLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), ), ], app_handle, @@ -407,8 +409,6 @@ impl ProcessManager<'_> { *v = v.replace("{rom}", &target_command.command); }); - - process_handler.create_launch_process( emulator_metadata, exe_command.reconstruct(), @@ -417,8 +417,6 @@ impl ProcessManager<'_> { &db_lock, )? } else { - - process_handler.create_launch_process( &meta, target_command.reconstruct(), @@ -474,9 +472,10 @@ impl ProcessManager<'_> { .map(|e| e.split("=").map(|v| v.to_string()).collect::>()) { if let Some(key) = parts.first() - && let Some(value) = parts.get(1) { - command.env(key, value); - } + && let Some(value) = parts.get(1) + { + command.env(key, value); + } } command }; From d1f1904dcaa1d063a1dc9e229fdda8a132d5cdc6 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 10 Feb 2026 17:18:52 +1100 Subject: [PATCH 02/14] feat: latest version picker + fixes --- src-tauri/Cargo.lock | 7 ++- src-tauri/Cargo.toml | 3 +- src-tauri/client/src/app_state.rs | 3 +- src-tauri/database/src/models.rs | 25 ++++++--- .../games/src/downloads/download_agent.rs | 13 +++-- src-tauri/games/src/downloads/drop_data.rs | 20 ++++++- src-tauri/games/src/library.rs | 53 +++++++++--------- src-tauri/games/src/scan.rs | 7 ++- src-tauri/process/src/process_manager.rs | 47 +++++++--------- src-tauri/src/collections.rs | 4 +- src-tauri/src/downloads.rs | 17 +++--- src-tauri/src/games.rs | 27 ++++----- src-tauri/src/lib.rs | 26 +++------ src-tauri/src/scheduler.rs | 55 +++++++++++++++++++ 14 files changed, 187 insertions(+), 120 deletions(-) create mode 100644 src-tauri/src/scheduler.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9fdc2ec..c9c494d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -54,9 +54,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arc-swap" @@ -1465,9 +1465,10 @@ dependencies = [ name = "drop-app" version = "0.4.0" dependencies = [ + "anyhow", + "async-trait", "atomic-instant-full", "bitcode", - "boxcar", "bytes", "cacache 13.1.0", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ffe328..bd0630c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,9 +29,10 @@ rustflags = ["-C", "target-feature=+aes,+sse2"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.101" +async-trait = "0.1.89" atomic-instant-full = "0.1.0" bitcode = "0.6.6" -boxcar = "0.2.7" bytes = "1.10.1" cacache = "13.1.0" chrono = "0.4.38" diff --git a/src-tauri/client/src/app_state.rs b/src-tauri/client/src/app_state.rs index 63b8332..7a9268d 100644 --- a/src-tauri/client/src/app_state.rs +++ b/src-tauri/client/src/app_state.rs @@ -2,10 +2,11 @@ use serde::Serialize; use crate::{app_status::AppStatus, user::User}; -#[derive(Clone, Serialize)] +#[derive(Clone, Serialize, PartialEq, Eq)] pub enum UmuState { NotNeeded, NotInstalled, + NoDefault, Installed, } diff --git a/src-tauri/database/src/models.rs b/src-tauri/database/src/models.rs index 9580b96..390cf9c 100644 --- a/src-tauri/database/src/models.rs +++ b/src-tauri/database/src/models.rs @@ -12,6 +12,7 @@ pub mod data { pub type DatabaseAuth = v1::DatabaseAuth; pub type GameDownloadStatus = v1::GameDownloadStatus; + pub type InstalledGameType = v1::InstalledGameType; pub type ApplicationTransientStatus = v1::ApplicationTransientStatus; /** * Need to be universally accessible by the ID, and the version is just a couple sprinkles on top @@ -156,25 +157,28 @@ pub mod data { } } + #[derive(Serialize, Clone, Deserialize, Debug)] + pub enum InstalledGameType { + SetupRequired, + Installed, + PartiallyInstalled, + } + #[derive(Serialize, Clone, Deserialize, Debug)] #[serde(tag = "type")] pub enum GameDownloadStatus { Remote {}, - SetupRequired { - version_name: String, - install_dir: String, - }, Installed { - version_name: String, - install_dir: String, - }, - PartiallyInstalled { - version_name: String, + install_type: InstalledGameType, + version_id: String, install_dir: String, + enable_updates: bool, + update_available: bool, }, } // Stuff that shouldn't be synced to disk #[derive(Clone, Serialize, Deserialize, Debug)] + #[serde(tag = "type")] pub enum ApplicationTransientStatus { Queued { version_id: String }, Downloading { version_id: String }, @@ -208,6 +212,7 @@ pub mod data { pub id: String, pub version: String, pub target_platform: Platform, + pub enable_updates: bool, pub download_type: DownloadType, } impl DownloadableMetadata { @@ -216,12 +221,14 @@ pub mod data { version: String, target_platform: Platform, download_type: DownloadType, + enable_updates: bool, ) -> Self { Self { id, version, target_platform, download_type, + enable_updates, } } } diff --git a/src-tauri/games/src/downloads/download_agent.rs b/src-tauri/games/src/downloads/download_agent.rs index 541ffd9..d84abc3 100644 --- a/src-tauri/games/src/downloads/download_agent.rs +++ b/src-tauri/games/src/downloads/download_agent.rs @@ -89,7 +89,9 @@ impl GameDownloadAgent { // Don't run by default let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop); - let game_name = get_cached_object::(&format!("game/{}", metadata.id)).map(|v| v.library_path).unwrap_or(metadata.id.clone()); + let game_name = get_cached_object::(&format!("game/{}", metadata.id)) + .map(|v| v.library_path) + .unwrap_or(metadata.id.clone()); let base_dir_path = Path::new(&base_dir); info!("base dir {}", base_dir_path.display()); @@ -101,6 +103,7 @@ impl GameDownloadAgent { metadata.version.clone(), metadata.target_platform, data_base_dir_path.clone(), + metadata.enable_updates, ); let result = Self { @@ -279,7 +282,7 @@ impl GameDownloadAgent { }; let chunk_len = manifests_chunks.iter().map(|v| v.1.len()).sum::(); let mut max_download_threads = borrow_db_checked().settings.max_download_threads; - if max_download_threads <= 0 { + if max_download_threads <= 0 { max_download_threads = 1; } @@ -310,10 +313,8 @@ impl GameDownloadAgent { self.download_progress.get(index), self.download_progress.clone(), ); - let disk_progress_handle = ProgressHandle::new( - self.disk_progress.get(index), - self.disk_progress.clone(), - ); + let disk_progress_handle = + ProgressHandle::new(self.disk_progress.get(index), self.disk_progress.clone()); index += 1; let chunk_length = chunk_data.files.iter().map(|v| v.length).sum(); diff --git a/src-tauri/games/src/downloads/drop_data.rs b/src-tauri/games/src/downloads/drop_data.rs index 9d7208a..d54f206 100644 --- a/src-tauri/games/src/downloads/drop_data.rs +++ b/src-tauri/games/src/downloads/drop_data.rs @@ -27,28 +27,42 @@ pub mod v1 { pub game_id: String, pub game_version: String, pub target_platform: Platform, + pub enable_updates: bool, pub contexts: Mutex>, pub base_path: PathBuf, } impl DropData { - pub fn new(game_id: String, game_version: String, target_platform: Platform, base_path: PathBuf) -> Self { + pub fn new( + game_id: String, + game_version: String, + target_platform: Platform, + base_path: PathBuf, + enable_updates: bool, + ) -> Self { Self { base_path, game_id, game_version, target_platform, contexts: Mutex::new(HashMap::new()), + enable_updates, } } } } impl DropData { - pub fn generate(game_id: String, game_version: String, target_platform: Platform, base_path: PathBuf) -> Self { + pub fn generate( + game_id: String, + game_version: String, + target_platform: Platform, + base_path: PathBuf, + enable_updates: bool, + ) -> Self { match DropData::read(&base_path) { Ok(v) => v, - Err(_) => DropData::new(game_id, game_version, target_platform, base_path), + Err(_) => DropData::new(game_id, game_version, target_platform, base_path, enable_updates), } } pub fn read(base_path: &Path) -> Result { diff --git a/src-tauri/games/src/library.rs b/src-tauri/games/src/library.rs index b00a40e..d1b02a3 100644 --- a/src-tauri/games/src/library.rs +++ b/src-tauri/games/src/library.rs @@ -1,7 +1,7 @@ use bitcode::{Decode, Encode}; use database::{ ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion, - borrow_db_checked, borrow_db_mut_checked, + borrow_db_checked, borrow_db_mut_checked, models::data::InstalledGameType, }; use log::{debug, error, warn}; use remote::{ @@ -89,9 +89,12 @@ pub fn set_partially_installed_db( db_lock.applications.transient_statuses.remove(meta); db_lock.applications.game_statuses.insert( meta.id.clone(), - GameDownloadStatus::PartiallyInstalled { - version_name: meta.version.clone(), + GameDownloadStatus::Installed { + install_type: InstalledGameType::PartiallyInstalled, + version_id: meta.version.clone(), install_dir, + enable_updates: meta.enable_updates, + update_available: false, }, ); db_lock @@ -135,16 +138,11 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) if let Some((_, install_dir)) = match previous_state { GameDownloadStatus::Installed { - version_name, - install_dir, - } => Some((version_name, install_dir)), - GameDownloadStatus::SetupRequired { - version_name, - install_dir, - } => Some((version_name, install_dir)), - GameDownloadStatus::PartiallyInstalled { - version_name, + install_type: _, + version_id: version_name, install_dir, + enable_updates: _, + update_available: _, } => Some((version_name, install_dir)), _ => None, } { @@ -230,16 +228,16 @@ pub async fn on_game_complete( .iter() .find(|v| v.platform == meta.target_platform); - let status = if setup_configuration.is_none() { - GameDownloadStatus::Installed { - version_name: meta.version.clone(), - install_dir, - } - } else { - GameDownloadStatus::SetupRequired { - version_name: meta.version.clone(), - install_dir, - } + let status = GameDownloadStatus::Installed { + version_id: meta.version.clone(), + install_dir, + install_type: if setup_configuration.is_none() { + InstalledGameType::Installed + } else { + InstalledGameType::SetupRequired + }, + enable_updates: meta.enable_updates, + update_available: false, }; let mut db_handle = borrow_db_mut_checked(); @@ -247,10 +245,7 @@ pub async fn on_game_complete( .applications .game_statuses .insert(meta.id.clone(), status.clone()); - db_handle - .applications - .transient_statuses - .remove(meta); + db_handle.applications.transient_statuses.remove(meta); drop(db_handle); app_emit!( app_handle, @@ -273,8 +268,10 @@ pub fn push_game_update( version: Option, status: GameStatusWithTransient, ) { - if let Some(GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }) = - &status.0 + if let Some(GameDownloadStatus::Installed { + install_type: InstalledGameType::Installed | InstalledGameType::SetupRequired, + .. + }) = &status.0 && version.is_none() { panic!("pushed game for installed game that doesn't have version information"); diff --git a/src-tauri/games/src/scan.rs b/src-tauri/games/src/scan.rs index 488fb1a..9466e2f 100644 --- a/src-tauri/games/src/scan.rs +++ b/src-tauri/games/src/scan.rs @@ -26,7 +26,11 @@ pub fn scan_install_dirs() { ); continue; }; - if db_lock.applications.game_statuses.contains_key(&drop_data.game_id) { + if db_lock + .applications + .game_statuses + .contains_key(&drop_data.game_id) + { continue; } @@ -35,6 +39,7 @@ pub fn scan_install_dirs() { drop_data.game_version, drop_data.target_platform, DownloadType::Game, + drop_data.enable_updates, ); set_partially_installed_db( &mut db_lock, diff --git a/src-tauri/process/src/process_manager.rs b/src-tauri/process/src/process_manager.rs index 377e342..7cd3eb8 100644 --- a/src-tauri/process/src/process_manager.rs +++ b/src-tauri/process/src/process_manager.rs @@ -11,7 +11,8 @@ use std::{ use database::{ ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion, - borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, platform::Platform, + borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, models::data::InstalledGameType, + platform::Platform, }; use dynfmt::Format; use dynfmt::SimpleCurlyFormat; @@ -147,21 +148,15 @@ impl ProcessManager<'_> { .unwrap_or_else(|| panic!("Could not get installed version of {}", &game_id)); db_handle.applications.transient_statuses.remove(&meta); - let current_state = db_handle.applications.game_statuses.get(&game_id).cloned(); - if let Some(GameDownloadStatus::SetupRequired { - version_name, - install_dir, + let current_state = db_handle.applications.game_statuses.get_mut(&game_id); + if let Some(GameDownloadStatus::Installed { + install_type, + .. }) = current_state && let Ok(exit_code) = result && exit_code.success() { - db_handle.applications.game_statuses.insert( - game_id.clone(), - GameDownloadStatus::Installed { - version_name: version_name.to_string(), - install_dir: install_dir.to_string(), - }, - ); + *install_type = InstalledGameType::Installed; } let elapsed = process.start.elapsed().unwrap_or(Duration::ZERO); @@ -270,12 +265,10 @@ impl ProcessManager<'_> { let (version_name, install_dir) = match game_status { GameDownloadStatus::Installed { - version_name, - install_dir, - } => (version_name, install_dir), - GameDownloadStatus::SetupRequired { - version_name, + version_id: version_name, install_dir, + install_type: InstalledGameType::Installed | InstalledGameType::SetupRequired, + .. } => (version_name, install_dir), _ => return Err(ProcessError::NotInstalled), }; @@ -325,8 +318,8 @@ impl ProcessManager<'_> { let (target_command, emulator) = match game_status { GameDownloadStatus::Installed { - version_name: _, - install_dir: _, + install_type: InstalledGameType::Installed, + .. } => { let (_, launch_config) = game_version .launches @@ -340,9 +333,9 @@ impl ProcessManager<'_> { launch_config.emulator.as_ref(), ) } - GameDownloadStatus::SetupRequired { - version_name: _, - install_dir: _, + GameDownloadStatus::Installed { + install_type: InstalledGameType::SetupRequired, + .. } => { let setup_config = game_version .setups @@ -377,12 +370,12 @@ impl ProcessManager<'_> { let emulator_install_dir = match emulator_game_status { GameDownloadStatus::Installed { - version_name: _, - install_dir, + install_type: InstalledGameType::Installed, + .. } => Ok(install_dir), - GameDownloadStatus::SetupRequired { - version_name: _, - install_dir: _, + GameDownloadStatus::Installed { + install_type: InstalledGameType::SetupRequired, + .. } => todo!(), _ => Err(err.clone()), }?; diff --git a/src-tauri/src/collections.rs b/src-tauri/src/collections.rs index 4904dee..635f3de 100644 --- a/src-tauri/src/collections.rs +++ b/src-tauri/src/collections.rs @@ -1,7 +1,7 @@ use std::sync::nonpoison::Mutex; use client::app_state::AppState; -use database::{GameDownloadStatus, borrow_db_checked}; +use database::{GameDownloadStatus, borrow_db_checked, models::data::InstalledGameType}; use games::collections::collection::Collections; use remote::{ cache::{cache_object, get_cached_object}, @@ -57,7 +57,7 @@ pub async fn fetch_collections_offline( .game_statuses .get(&v.game_id) .unwrap_or(&GameDownloadStatus::Remote {}), - GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. } + GameDownloadStatus::Installed { install_type: InstalledGameType::Installed | InstalledGameType::SetupRequired, .. } ) }); } diff --git a/src-tauri/src/downloads.rs b/src-tauri/src/downloads.rs index 59e27a1..a32157e 100644 --- a/src-tauri/src/downloads.rs +++ b/src-tauri/src/downloads.rs @@ -1,7 +1,8 @@ use std::{path::PathBuf, sync::Arc}; use database::{ - DownloadType, DownloadableMetadata, GameDownloadStatus, borrow_db_checked, platform::Platform, + DownloadType, DownloadableMetadata, GameDownloadStatus, borrow_db_checked, + models::data::InstalledGameType, platform::Platform, }; use download_manager::{ DOWNLOAD_MANAGER, downloadable::Downloadable, error::ApplicationDownloadError, @@ -14,6 +15,7 @@ pub async fn download_game( version_id: String, target_platform: Platform, install_dir: usize, + enable_updates: bool, ) -> Result<(), ApplicationDownloadError> { { let db = borrow_db_checked(); @@ -35,6 +37,7 @@ pub async fn download_game( version: version_id, target_platform, download_type: DownloadType::Game, + enable_updates, }; let game_download_agent = GameDownloadAgent::new_from_index( @@ -75,12 +78,12 @@ pub async fn resume_download(game_id: String) -> Result<(), ApplicationDownloadE .clone(); let install_dir = match status { - GameDownloadStatus::Remote {} => Err(ApplicationDownloadError::InvalidCommand), - GameDownloadStatus::SetupRequired { .. } => { - Err(ApplicationDownloadError::InvalidCommand) - } - GameDownloadStatus::Installed { .. } => Err(ApplicationDownloadError::InvalidCommand), - GameDownloadStatus::PartiallyInstalled { install_dir, .. } => Ok(install_dir), + GameDownloadStatus::Installed { + install_type: InstalledGameType::PartiallyInstalled, + install_dir, + .. + } => Ok(install_dir), + _ => Err(ApplicationDownloadError::InvalidCommand), }?; (meta, install_dir) }; diff --git a/src-tauri/src/games.rs b/src-tauri/src/games.rs index 05c93ec..78e66f2 100644 --- a/src-tauri/src/games.rs +++ b/src-tauri/src/games.rs @@ -3,7 +3,7 @@ use std::sync::nonpoison::Mutex; use bitcode::{Decode, Encode}; use database::{ DownloadableMetadata, GameDownloadStatus, borrow_db_checked, borrow_db_mut_checked, - platform::Platform, + models::data::InstalledGameType, platform::Platform, }; use games::{ collections::collection::Collection, @@ -168,25 +168,20 @@ pub async fn fetch_library_logic_offline( .game_statuses .get(game.id()) .unwrap_or(&GameDownloadStatus::Remote {}), - GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. } + GameDownloadStatus::Installed { + install_type: InstalledGameType::Installed | InstalledGameType::SetupRequired, + .. + } ) }; response.library.retain(retain_filter); response.other.retain(retain_filter); response.missing.retain(retain_filter); - response.collections.iter_mut().for_each(|k| { - k.entries.retain(|object| { - matches!( - &db_handle - .applications - .game_statuses - .get(object.game.id()) - .unwrap_or(&GameDownloadStatus::Remote {}), - GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. } - ) - }) - }); + response + .collections + .iter_mut() + .for_each(|k| k.entries.retain(|object| retain_filter(&object.game))); Ok(response) } @@ -408,7 +403,9 @@ pub fn update_game_configuration( // Add more options in here existing_configuration.user_configuration.launch_template = options.launch_string; - existing_configuration.user_configuration.override_proton_path = options.override_proton_path; + existing_configuration + .user_configuration + .override_proton_path = options.override_proton_path; // Add no more options past here diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 782ef05..39bba57 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,7 @@ mod downloads; mod games; mod process; mod remote; +mod scheduler; mod settings; use client::*; @@ -67,6 +68,8 @@ use process::*; use remote::*; use settings::*; +use crate::scheduler::scheduler_task; + async fn setup(handle: AppHandle) -> AppState { let logfile = FileAppender::builder() .encoder(Box::new(PatternEncoder::new( @@ -101,6 +104,9 @@ async fn setup(handle: AppHandle) -> AppState { ProcessManagerWrapper::init(handle.clone()); DownloadManagerWrapper::init(handle.clone()); + debug!("checking if database is set up"); + let is_set_up = DB.database_is_set_up(); + #[cfg(not(target_os = "linux"))] let umu_state = UmuState::NotNeeded; @@ -110,9 +116,6 @@ async fn setup(handle: AppHandle) -> AppState { false => UmuState::NotInstalled, }; - debug!("checking if database is set up"); - let is_set_up = DB.database_is_set_up(); - scan_install_dirs(); if !is_set_up { @@ -136,20 +139,7 @@ async fn setup(handle: AppHandle) -> AppState { for (game_id, status) in statuses { match status { GameDownloadStatus::Remote {} => {} - GameDownloadStatus::PartiallyInstalled { .. } => {} - GameDownloadStatus::SetupRequired { - version_name: _, - install_dir, - } => { - let install_dir_path = Path::new(&install_dir); - if !install_dir_path.exists() { - missing_games.push(game_id); - } - } - GameDownloadStatus::Installed { - version_name: _, - install_dir, - } => { + GameDownloadStatus::Installed { install_dir, .. } => { let install_dir_path = Path::new(&install_dir); if !install_dir_path.exists() { missing_games.push(game_id); @@ -416,6 +406,8 @@ pub fn run() { .show(|_| {}); } } + + tokio::spawn(async move { scheduler_task().await }); }); Ok(()) diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs new file mode 100644 index 0000000..0d7de65 --- /dev/null +++ b/src-tauri/src/scheduler.rs @@ -0,0 +1,55 @@ +use std::time::Duration; + +use async_trait::async_trait; +use log::{info, warn}; +use tokio::time; + +#[async_trait] +trait ScheduleTask { + /// Returns how many minutes between calls + fn timeframe(&mut self) -> usize; + async fn call(&mut self) -> Result<(), anyhow::Error>; +} + +struct Test; + +#[async_trait] +impl ScheduleTask for Test { + fn timeframe(&mut self) -> usize { + 1 + } + + async fn call(&mut self) -> Result<(), anyhow::Error> { + info!("ran background task"); + Ok(()) + } +} + +struct TaskData { + task: Box, + updates_since_call: usize, +} + +pub async fn scheduler_task() -> ! { + let mut interval = time::interval(Duration::from_mins(1)); + interval.tick().await; + + let mut tasks = vec![TaskData { + task: Box::new(Test {}), + updates_since_call: 0, + }]; + + loop { + for task in &mut tasks { + task.updates_since_call += 1; + if task.task.timeframe() <= task.updates_since_call { + let result = task.task.call().await; + if let Err(err) = result { + warn!("background task returned error: {err:?}"); + } + task.updates_since_call = 0; + } + } + interval.tick().await; + } +} From 774d86d4ee4737b4a78adc90ddc92345bd76d07d Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 12 Feb 2026 12:49:30 +1100 Subject: [PATCH 03/14] feat: frontend latest changes --- main/components/GameStatusButton.vue | 156 ++++++++++-------- main/components/HeaderProtonSupportWidget.vue | 5 +- main/components/LibrarySearch.vue | 98 ++++++----- main/composables/game.ts | 39 ++--- main/composables/proton.ts | 32 ++++ main/package.json | 3 +- main/pages/library/[id]/index.vue | 143 ++++++++++++---- main/pages/settings/compat.vue | 21 ++- main/types.ts | 47 ++++-- 9 files changed, 354 insertions(+), 190 deletions(-) create mode 100644 main/composables/proton.ts diff --git a/main/components/GameStatusButton.vue b/main/components/GameStatusButton.vue index 1dea40b..1e49e37 100644 --- a/main/components/GameStatusButton.vue +++ b/main/components/GameStatusButton.vue @@ -3,19 +3,19 @@
- +
+ No games in this category @@ -138,7 +141,8 @@ import { } from "@heroicons/vue/20/solid"; import { invoke } from "@tauri-apps/api/core"; import { - GameStatusEnum, + type EmptyGameStatusEnum, + InstalledType, type Collection as Collection, type Game, type GameStatus, @@ -147,31 +151,44 @@ import { TransitionGroup } from "vue"; import { listen } from "@tauri-apps/api/event"; // Style information -const gameStatusTextStyle: { [key in GameStatusEnum]: string } = { - [GameStatusEnum.Installed]: "text-green-500", - [GameStatusEnum.Downloading]: "text-zinc-400", - [GameStatusEnum.Validating]: "text-blue-300", - [GameStatusEnum.Running]: "text-blue-500", - [GameStatusEnum.Remote]: "text-zinc-700", - [GameStatusEnum.Queued]: "text-zinc-400", - [GameStatusEnum.Updating]: "text-zinc-400", - [GameStatusEnum.Uninstalling]: "text-zinc-100", - [GameStatusEnum.SetupRequired]: "text-yellow-500", - [GameStatusEnum.PartiallyInstalled]: "text-gray-400", +const gameStatusTextStyle: { [key in EmptyGameStatusEnum]: string } = { + Downloading: "text-zinc-400", + Validating: "text-blue-300", + Running: "text-blue-500", + Remote: "text-zinc-700", + Queued: "text-zinc-400", + Updating: "text-zinc-400", + Uninstalling: "text-zinc-100", }; -const gameStatusText: { [key in GameStatusEnum]: string } = { - [GameStatusEnum.Remote]: "Not installed", - [GameStatusEnum.Queued]: "Queued", - [GameStatusEnum.Downloading]: "Downloading...", - [GameStatusEnum.Validating]: "Validating...", - [GameStatusEnum.Installed]: "Installed", - [GameStatusEnum.Updating]: "Updating...", - [GameStatusEnum.Uninstalling]: "Uninstalling...", - [GameStatusEnum.SetupRequired]: "Setup required", - [GameStatusEnum.Running]: "Running", - [GameStatusEnum.PartiallyInstalled]: "Partially installed", +const gameStatusText: { [key in EmptyGameStatusEnum]: string } = { + Remote: "Not installed", + Queued: "Queued", + Downloading: "Downloading...", + Validating: "Validating...", + Updating: "Updating...", + Uninstalling: "Uninstalling...", + Running: "Running", }; +function getGameStatusStyleText(status: GameStatus): [string, string] { + if (status.type === "Installed") { + if (status.install_type === InstalledType.Installed) { + return ["text-green-500", "Installed"]; + } + if (status.install_type === InstalledType.PartiallyInstalled) { + return ["text-gray-400", "Partially installed"]; + } + if (status.install_type === InstalledType.SetupRequired) { + return ["text-yellow-500", "Setup required"]; + } + throw ( + "Non-exhaustive installed type, missing: " + + JSON.stringify(status.install_type) + ); + } + return [gameStatusTextStyle[status.type], gameStatusText[status.type]]; +} + const router = useRouter(); const searchQuery = ref(""); @@ -277,25 +294,22 @@ await new Promise((r) => { const navigation = computed(() => collections.value.map((collection) => { - const items = collection.entries - .map(({ game }) => { - const status = games[game.id].status; + const items = collection.entries.map(({ game }) => { + const status = games[game.id].status; - const isInstalled = computed( - () => status.value.type != GameStatusEnum.Remote, - ); + const isInstalled = computed(() => status.value.type != "Remote"); - const item = { - label: game.mName, - route: `/library/${game.id}`, - prefix: `/library/${game.id}`, - icon: game.mIconObjectId, - isInstalled, - id: game.id, - type: game.type, - }; - return item; - }); + const item = { + label: game.mName, + route: `/library/${game.id}`, + prefix: `/library/${game.id}`, + icon: game.mIconObjectId, + isInstalled, + id: game.id, + type: game.type, + }; + return item; + }); return { id: collection.id, diff --git a/main/composables/game.ts b/main/composables/game.ts index e06009e..26034de 100644 --- a/main/composables/game.ts +++ b/main/composables/game.ts @@ -1,39 +1,34 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import type { Game, GameStatus, GameStatusEnum, GameVersion } from "~/types"; +import type { + Game, + GameStatus, + GameStatusEnum, + GameVersion, + RawGameStatus, +} from "~/types"; const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } = {}; const gameStatusRegistry: { [key: string]: Ref } = {}; -type OptionGameStatus = { [key in GameStatusEnum]: { version_name?: string } }; -export type SerializedGameStatus = [ - { type: GameStatusEnum }, - OptionGameStatus | null, -]; - -export const parseStatus = (status: SerializedGameStatus): GameStatus => { +export const parseStatus = (status: RawGameStatus): GameStatus => { + console.log(status[0]); if (status[0]) { - return { - type: status[0].type, - }; - } else if (status[1]) { - const [[gameStatus, options]] = Object.entries(status[1]); - return { - type: gameStatus as GameStatusEnum, - ...options, - }; - } else { - throw new Error("No game status"); + return status[0]; } + if (status[1]) { + return status[1]; + } + throw new Error("No game status: " + JSON.stringify(status)); }; export const useGame = async (gameId: string) => { if (!gameRegistry[gameId]) { const data: { game: Game; - status: SerializedGameStatus; + status: RawGameStatus; version?: GameVersion; } = await invoke("fetch_game", { gameId, @@ -44,7 +39,7 @@ export const useGame = async (gameId: string) => { listen(`update_game/${gameId}`, (event) => { const payload: { - status: SerializedGameStatus; + status: RawGameStatus; version?: GameVersion; } = event.payload as any; gameStatusRegistry[gameId].value = parseStatus(payload.status); @@ -102,4 +97,4 @@ export type VersionOption = { export type ProtonPath = { path: string; name: string; -}; \ No newline at end of file +}; diff --git a/main/composables/proton.ts b/main/composables/proton.ts new file mode 100644 index 0000000..eb91d3a --- /dev/null +++ b/main/composables/proton.ts @@ -0,0 +1,32 @@ +import { invoke } from "@tauri-apps/api/core"; + +interface ProtonPaths { + data: Ref<{ + autodiscovered: ProtonPath[]; + custom: ProtonPath[]; + default?: string; + }>; + refresh: () => Promise; +} + +const protonPaths = useState( + "proton_paths", + undefined, +); + +export const useProtonPaths = async (): Promise => { + const refresh = async () => { + protonPaths.value = await invoke("fetch_proton_paths"); + }; + if (protonPaths.value) + return { + data: protonPaths, + refresh, + }; + + await refresh(); + return { + data: protonPaths, + refresh, + }; +}; diff --git a/main/package.json b/main/package.json index e7ea042..3ec188a 100644 --- a/main/package.json +++ b/main/package.json @@ -7,7 +7,8 @@ "build": "nuxt generate", "dev": "nuxt dev", "postinstall": "nuxt prepare", - "tauri": "tauri" + "tauri": "tauri", + "typecheck": "nuxt typecheck" }, "dependencies": { "@headlessui/vue": "^1.7.23", diff --git a/main/pages/library/[id]/index.vue b/main/pages/library/[id]/index.vue index b89085e..dc5ccf3 100644 --- a/main/pages/library/[id]/index.vue +++ b/main/pages/library/[id]/index.vue @@ -174,11 +174,7 @@
-
+
Version - {{ - currentVersionOption.displayName || - currentVersionOption.versionPath - }} - on - {{ currentVersionOption.platform }} ({{ - formatKilobytes( - currentVersionOption.size.installSize / 1024, - ) - }}B) + {{ + formatVersionOptionText(installVersionIndex) + }} @@ -209,6 +196,48 @@ +
+
+
+
+
+

+ "Latest" will notify you when there is a new version + available. Choose another version to pin this game's + version. +

+
+
+
+
+
+
+
+
+

+ This game will be pinned to "{{ + currentVersionOption?.displayName || + currentVersionOption?.versionPath + }}" +

+
+
+
+ + +
  • + {{ formatVersionOptionText(-1) }} + + + +
  • +
    + {{ version.displayName || version.versionPath }} on - {{ version.platform }} ({{ - formatKilobytes( - versionOptions[installVersionIndex].size - .installSize / 1024, - ) - }}B){{ formatVersionOptionText(versionIdx) }} @@ -559,12 +619,14 @@ import { ArrowsPointingOutIcon, PhotoIcon, PlayIcon, + InformationCircleIcon, } from "@heroicons/vue/20/solid"; import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline"; import { MinusIcon, ServerIcon, XCircleIcon } from "@heroicons/vue/24/solid"; import { invoke } from "@tauri-apps/api/core"; import { micromark } from "micromark"; -import { GameStatusEnum } from "~/types"; +import { version } from "typescript"; +import { InstalledType } from "~/types"; const route = useRoute(); const router = useRouter(); @@ -610,18 +672,17 @@ async function installFlow() { const installLoading = ref(false); const installError = ref(); -const installVersionIndex = ref(0); +const installVersionIndex = ref(-1); const installDir = ref(0); const installDepsDisabled = ref<{ [key: string]: boolean }>({}); -const currentVersionOption = computed( - () => versionOptions.value?.[installVersionIndex.value], -); async function install() { try { if (!versionOptions.value) throw new Error("Versions have not been loaded"); installLoading.value = true; - const versionOption = versionOptions.value[installVersionIndex.value]; + const versionOption = + versionOptions.value[Math.max(installVersionIndex.value, 0)]; + const isLatest = installVersionIndex.value == -1; const games = [ { gameId: game.value.id, versionId: versionOption.versionId }, @@ -636,6 +697,7 @@ async function install() { versionId: game.versionId, installDir: installDir.value, targetPlatform: versionOption.platform, + enableUpdates: isLatest, }); } @@ -647,6 +709,20 @@ async function install() { installLoading.value = false; } +const currentVersionOption = computed( + () => versionOptions.value?.[Math.max(installVersionIndex.value, 0)], +); + +function formatVersionOptionText(index: number) { + if (!versionOptions.value) return undefined; + const versionOption = versionOptions.value[Math.max(index, 0)]; + const template = `${versionOption.displayName || versionOption.versionPath} on ${versionOption.platform}, ${formatKilobytes(versionOption.size.installSize / 1024)}B`; + if (index == -1) { + return `Latest (${template})`; + } + return template; +} + async function resumeDownload() { try { await invoke("resume_download", { gameId: game.value.id }); @@ -659,7 +735,10 @@ const launchOptions = ref | undefined>(undefined); const launchOptionsOpen = computed(() => launchOptions.value !== undefined); async function launch() { - if (status.value.type == GameStatusEnum.SetupRequired) { + if ( + status.value.type == "Installed" && + status.value.install_type == InstalledType.SetupRequired + ) { await launchIndex(0); return; } diff --git a/main/pages/settings/compat.vue b/main/pages/settings/compat.vue index 582dc74..650a504 100644 --- a/main/pages/settings/compat.vue +++ b/main/pages/settings/compat.vue @@ -45,18 +45,22 @@
    -
    +
    -
    -

    +

    No default Proton layer

    -
    +

    - You won't be able to launch any Windows games without overriding their Proton layer in game settings. Please select a default layer below using the stars. + You won't be able to launch any Windows games without overriding + their Proton layer in game settings. Please select a default layer + below using the stars.

    @@ -290,12 +294,7 @@ import { open } from "@tauri-apps/plugin-dialog"; const appState = useAppState(); -const paths = useAsyncData<{ - autodiscovered: ProtonPath[]; - custom: ProtonPath[]; - default?: string; -}>("proton_paths", async () => await invoke("fetch_proton_paths")); - +const paths = await useProtonPaths(); const pickLayerModal = ref(false); const pickError = ref(null); diff --git a/main/types.ts b/main/types.ts index e85506c..2a59f4e 100644 --- a/main/types.ts +++ b/main/types.ts @@ -67,24 +67,41 @@ export enum AppStatus { ServerUnavailable = "ServerUnavailable", } -export enum GameStatusEnum { - Remote = "Remote", - Queued = "Queued", - Downloading = "Downloading", - Validating = "Validating", - Installed = "Installed", - Updating = "Updating", - Uninstalling = "Uninstalling", - SetupRequired = "SetupRequired", - Running = "Running", +export type EmptyGameStatusEnum = + | "Remote" + | "Queued" + | "Downloading" + | "Validating" + | "Updating" + | "Uninstalling" + | "Running"; + +export enum InstalledType { PartiallyInstalled = "PartiallyInstalled", + SetupRequired = "SetupRequired", + Installed = "Installed", } -export type GameStatus = { - type: GameStatusEnum; - version_name?: string; - install_dir?: string; -}; +export interface InstalledGameStatusData { + install_type: InstalledType; + version_name: string; + install_dir: string; + enable_updates: boolean; + update_available: boolean; +} + +export type GameStatus = + | { + type: EmptyGameStatusEnum; + } + | ({ + type: "Installed"; + } & InstalledGameStatusData); + +export type GameStatusEnum = GameStatus["type"]; + +export type RawGameStatus = + | [GameStatus | null, GameStatus | null]; export enum DownloadableType { Game = "Game", From 6e3a50efa10ddced0b3127112600624cc945d934 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 15 Feb 2026 16:57:43 +1100 Subject: [PATCH 04/14] feat: game update detection w/ setting --- main/components/GameOptionsModal.vue | 1 + main/components/GameStatusButton.vue | 25 +++- main/components/LibrarySearch.vue | 6 +- main/pages/library/[id]/index.vue | 6 +- main/pages/settings/compat.vue | 3 +- main/tailwind.config.js | 4 +- main/types.ts | 2 +- src-tauri/Cargo.lock | 24 +++- src-tauri/database/src/models.rs | 19 ++- src-tauri/games/Cargo.toml | 4 +- .../games/src/downloads/download_agent.rs | 11 +- src-tauri/games/src/downloads/drop_data.rs | 28 +++-- src-tauri/games/src/library.rs | 16 +-- src-tauri/games/src/scan.rs | 18 +-- src-tauri/remote/src/utils.rs | 35 +++--- src-tauri/src/downloads.rs | 18 ++- src-tauri/src/games.rs | 8 +- src-tauri/src/lib.rs | 1 + src-tauri/src/scheduler.rs | 24 +--- src-tauri/src/updates.rs | 117 ++++++++++++++++++ 20 files changed, 271 insertions(+), 99 deletions(-) create mode 100644 src-tauri/src/updates.rs diff --git a/main/components/GameOptionsModal.vue b/main/components/GameOptionsModal.vue index 8850363..9c52ecd 100644 --- a/main/components/GameOptionsModal.vue +++ b/main/components/GameOptionsModal.vue @@ -30,6 +30,7 @@
    + {{ configuration }} {{ fetchStatusStyleData($props.status).buttonName }} + (); const emit = defineEmits<{ @@ -121,7 +134,7 @@ interface StatusStyleData { function fetchStatusStyleData(status: GameStatus): StatusStyleData { if (status.type === "Installed") { - if (status.install_type === InstalledType.Installed) { + if (status.install_type.type === InstalledType.Installed) { return { style: "bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500", @@ -130,7 +143,7 @@ function fetchStatusStyleData(status: GameStatus): StatusStyleData { action: () => emit("launch"), }; } - if (status.install_type === InstalledType.SetupRequired) { + if (status.install_type.type === InstalledType.SetupRequired) { return { style: "bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500", @@ -139,7 +152,7 @@ function fetchStatusStyleData(status: GameStatus): StatusStyleData { action: () => emit("launch"), }; } - if (status.install_type === InstalledType.PartiallyInstalled) { + if (status.install_type.type === InstalledType.PartiallyInstalled) { return { style: "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500", @@ -158,7 +171,11 @@ function fetchStatusStyleData(status: GameStatus): StatusStyleData { }; } -const showDropdown = computed(() => props.status.type === "Installed" && props.status.install_type !== InstalledType.PartiallyInstalled); +const showDropdown = computed( + () => + props.status.type === "Installed" && + props.status.install_type.type !== InstalledType.PartiallyInstalled, +); const styles: { [key in EmptyGameStatusEnum]: string } = { Remote: diff --git a/main/components/LibrarySearch.vue b/main/components/LibrarySearch.vue index 77c9de7..73cfdc9 100644 --- a/main/components/LibrarySearch.vue +++ b/main/components/LibrarySearch.vue @@ -172,13 +172,13 @@ const gameStatusText: { [key in EmptyGameStatusEnum]: string } = { function getGameStatusStyleText(status: GameStatus): [string, string] { if (status.type === "Installed") { - if (status.install_type === InstalledType.Installed) { + if (status.install_type.type === InstalledType.Installed) { return ["text-green-500", "Installed"]; } - if (status.install_type === InstalledType.PartiallyInstalled) { + if (status.install_type.type === InstalledType.PartiallyInstalled) { return ["text-gray-400", "Partially installed"]; } - if (status.install_type === InstalledType.SetupRequired) { + if (status.install_type.type === InstalledType.SetupRequired) { return ["text-yellow-500", "Setup required"]; } throw ( diff --git a/main/pages/library/[id]/index.vue b/main/pages/library/[id]/index.vue index dc5ccf3..93d9c72 100644 --- a/main/pages/library/[id]/index.vue +++ b/main/pages/library/[id]/index.vue @@ -522,8 +522,8 @@ launchOptions.value !== undefined); async function launch() { if ( status.value.type == "Installed" && - status.value.install_type == InstalledType.SetupRequired + status.value.install_type.type == InstalledType.SetupRequired ) { await launchIndex(0); return; diff --git a/main/pages/settings/compat.vue b/main/pages/settings/compat.vue index 650a504..8de7dff 100644 --- a/main/pages/settings/compat.vue +++ b/main/pages/settings/compat.vue @@ -47,7 +47,8 @@
    + class="mt-4 rounded-md bg-red-500/15 p-4 outline outline-red-500/25" + >
    -
    +

    {{ item.label }}

    diff --git a/main/composables/game.ts b/main/composables/game.ts index 26034de..5a29f69 100644 --- a/main/composables/game.ts +++ b/main/composables/game.ts @@ -8,7 +8,7 @@ import type { RawGameStatus, } from "~/types"; -const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } = +const gameRegistry: { [key: string]: { game: Game; version: Ref } } = {}; const gameStatusRegistry: { [key: string]: Ref } = {}; @@ -33,7 +33,7 @@ export const useGame = async (gameId: string) => { } = await invoke("fetch_game", { gameId, }); - gameRegistry[gameId] = { game: data.game, version: data.version }; + gameRegistry[gameId] = { game: data.game, version: ref(data.version) }; if (!gameStatusRegistry[gameId]) { gameStatusRegistry[gameId] = ref(parseStatus(data.status)); @@ -52,7 +52,7 @@ export const useGame = async (gameId: string) => { * on transient state updates. */ if (payload.version) { - gameRegistry[gameId].version = payload.version; + gameRegistry[gameId].version.value = payload.version; } }); } diff --git a/main/pages/library/[id]/index.vue b/main/pages/library/[id]/index.vue index 93d9c72..f3a089e 100644 --- a/main/pages/library/[id]/index.vue +++ b/main/pages/library/[id]/index.vue @@ -16,14 +16,45 @@
    -
    +

    {{ game.mName }}

    +
    +
    + Version pinned + + + +
    +
    + Up to date +
    +
    + Update available +
    +
    -
    +
    + +
    @@ -68,19 +105,22 @@ Game Images
    -
    +
    @@ -92,7 +132,7 @@ >
    @@ -622,30 +663,27 @@ import { InformationCircleIcon, } from "@heroicons/vue/20/solid"; import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline"; -import { MinusIcon, ServerIcon, XCircleIcon } from "@heroicons/vue/24/solid"; +import { + ArrowDownTrayIcon, + CheckCircleIcon, + MapPinIcon, + MinusIcon, + ServerIcon, + XCircleIcon, +} from "@heroicons/vue/24/solid"; import { invoke } from "@tauri-apps/api/core"; import { micromark } from "micromark"; -import { version } from "typescript"; import { InstalledType } from "~/types"; const route = useRoute(); const router = useRouter(); const id = route.params.id.toString(); -const { game: rawGame, status } = await useGame(id); -const game = ref(rawGame); +const { game, status, version } = await useGame(id); -const bannerUrl = await useObject(game.value.mBannerObjectId); +const bannerUrl = await useObject(game.mBannerObjectId); -// Get all available images -const mediaUrls = await Promise.all( - game.value.mImageCarouselObjectIds.map(async (v) => { - const src = await useObject(v); - return src; - }), -); - -const htmlDescription = micromark(game.value.mDescription); +const htmlDescription = micromark(game.mDescription); const installFlowOpen = ref(false); const versionOptions = ref>(); @@ -661,7 +699,7 @@ async function installFlow() { try { versionOptions.value = await invoke("fetch_game_version_options", { - gameId: game.value.id, + gameId: game.id, }); installDirs.value = await invoke("fetch_download_dir_stats"); } catch (error) { @@ -685,7 +723,7 @@ async function install() { const isLatest = installVersionIndex.value == -1; const games = [ - { gameId: game.value.id, versionId: versionOption.versionId }, + { gameId: game.id, versionId: versionOption.versionId }, ...versionOption.requiredContent .filter((v) => !installDepsDisabled.value[v.versionId]) .map((v) => ({ gameId: v.gameId, versionId: v.versionId })), @@ -725,7 +763,7 @@ function formatVersionOptionText(index: number) { async function resumeDownload() { try { - await invoke("resume_download", { gameId: game.value.id }); + await invoke("resume_download", { gameId: game.id }); } catch (e) { console.error(e); } @@ -745,7 +783,7 @@ async function launch() { try { const fetchedLaunchOptions = await invoke>( "get_launch_options", - { id: game.value.id }, + { id: game.id }, ); if (fetchedLaunchOptions.length == 1) { await launchIndex(0); @@ -756,8 +794,8 @@ async function launch() { createModal( ModalType.Notification, { - title: `Couldn't run "${game.value.mName}"`, - description: `Drop failed to launch "${game.value.mName}": ${e}`, + title: `Couldn't run "${game.mName}"`, + description: `Drop failed to launch "${game.mName}": ${e}`, buttonText: "Close", }, (e, c) => c(), @@ -774,7 +812,7 @@ async function launchIndex(index: number) { launchOptions.value = undefined; try { const result = await invoke("launch_game", { - id: game.value.id, + id: game.id, index, }); if (result.result == "InstallRequired") { @@ -787,8 +825,8 @@ async function launchIndex(index: number) { createModal( ModalType.Notification, { - title: `Couldn't run "${game.value.mName}"`, - description: `Drop failed to launch "${game.value.mName}": ${e}`, + title: `Couldn't run "${game.mName}"`, + description: `Drop failed to launch "${game.mName}": ${e}`, buttonText: "Close", }, (e, c) => c(), @@ -801,18 +839,18 @@ async function queue() { } async function uninstall() { - await invoke("uninstall_game", { gameId: game.value.id }); + await invoke("uninstall_game", { gameId: game.id }); } async function kill() { try { - await invoke("kill_game", { gameId: game.value.id }); + await invoke("kill_game", { gameId: game.id }); } catch (e) { createModal( ModalType.Notification, { - title: `Couldn't stop "${game.value.mName}"`, - description: `Drop failed to stop "${game.value.mName}": ${e}`, + title: `Couldn't stop "${game.mName}"`, + description: `Drop failed to stop "${game.mName}": ${e}`, buttonText: "Close", }, (e, c) => c(), @@ -822,12 +860,14 @@ async function kill() { } function nextImage() { - currentImageIndex.value = (currentImageIndex.value + 1) % mediaUrls.length; + currentImageIndex.value = + (currentImageIndex.value + 1) % game.mImageCarouselObjectIds.length; } function previousImage() { currentImageIndex.value = - (currentImageIndex.value - 1 + mediaUrls.length) % mediaUrls.length; + (currentImageIndex.value - 1 + game.mImageCarouselObjectIds.length) % + game.mImageCarouselObjectIds.length; } const fullscreenImage = ref(null); diff --git a/main/types.ts b/main/types.ts index 664cb8f..7945dd0 100644 --- a/main/types.ts +++ b/main/types.ts @@ -53,6 +53,7 @@ export type GameVersion = { userConfiguration: { launchTemplate: string; overrideProtonPath: string; + enableUpdates: boolean }; setups: Array<{ platform: string }>; launches: Array<{ platform: string }>; @@ -83,10 +84,9 @@ export enum InstalledType { } export interface InstalledGameStatusData { - install_type: {type: InstalledType}; - version_name: string; + install_type: { type: InstalledType }; + version_id: string; install_dir: string; - enable_updates: boolean; update_available: boolean; } @@ -100,8 +100,7 @@ export type GameStatus = export type GameStatusEnum = GameStatus["type"]; -export type RawGameStatus = - | [GameStatus | null, GameStatus | null]; +export type RawGameStatus = [GameStatus | null, GameStatus | null]; export enum DownloadableType { Game = "Game", diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs index 8b20e41..a22b5b8 100644 --- a/src-tauri/src/updates.rs +++ b/src-tauri/src/updates.rs @@ -110,8 +110,6 @@ impl ScheduleTask for GameUpdater { }; } - info!("checked for updates."); - Ok(()) } } From ad3be7c367490c573338a12fb2d91ce9a396f50c Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 17 Feb 2026 12:30:29 +1100 Subject: [PATCH 06/14] fix: windows ui --- main/components/GameStatusButton.vue | 2 +- main/components/HeaderProtonSupportWidget.vue | 23 ++++++++----- main/pages/library.vue | 1 + main/pages/library/[id]/index.vue | 1 + .../games/src/downloads/download_agent.rs | 15 --------- src-tauri/src/downloads.rs | 32 +++++++++---------- 6 files changed, 34 insertions(+), 40 deletions(-) diff --git a/main/components/GameStatusButton.vue b/main/components/GameStatusButton.vue index 2cc377a..381a8fe 100644 --- a/main/components/GameStatusButton.vue +++ b/main/components/GameStatusButton.vue @@ -100,7 +100,7 @@ import { } from "~/types.js"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline"; -import { ArrowUpTrayIcon } from "@heroicons/vue/24/solid"; +import { ArrowsRightLeftIcon, ArrowUpTrayIcon } from "@heroicons/vue/24/solid"; const props = defineProps<{ status: GameStatus }>(); const emit = defineEmits<{ diff --git a/main/components/HeaderProtonSupportWidget.vue b/main/components/HeaderProtonSupportWidget.vue index d35461e..59ddec4 100644 --- a/main/components/HeaderProtonSupportWidget.vue +++ b/main/components/HeaderProtonSupportWidget.vue @@ -1,17 +1,24 @@ diff --git a/main/pages/library.vue b/main/pages/library.vue index ca8b876..64baa17 100644 --- a/main/pages/library.vue +++ b/main/pages/library.vue @@ -6,6 +6,7 @@ >
    +
    diff --git a/main/pages/library/[id]/index.vue b/main/pages/library/[id]/index.vue index f3a089e..5b2250f 100644 --- a/main/pages/library/[id]/index.vue +++ b/main/pages/library/[id]/index.vue @@ -69,6 +69,7 @@ diff --git a/src-tauri/games/src/downloads/download_agent.rs b/src-tauri/games/src/downloads/download_agent.rs index 7f559f0..25862bf 100644 --- a/src-tauri/games/src/downloads/download_agent.rs +++ b/src-tauri/games/src/downloads/download_agent.rs @@ -68,21 +68,6 @@ impl Debug for GameDownloadAgent { } impl GameDownloadAgent { - pub async fn new_from_index( - metadata: DownloadableMetadata, - target_download_dir: usize, - sender: Sender, - depot_manager: Arc, - configuration: UserConfiguration, - ) -> Result { - let base_dir = { - let db_lock = borrow_db_checked(); - - db_lock.applications.install_dirs[target_download_dir].clone() - }; - - Self::new(metadata, base_dir, sender, depot_manager, configuration).await - } pub async fn new( metadata: DownloadableMetadata, base_dir: PathBuf, diff --git a/src-tauri/src/downloads.rs b/src-tauri/src/downloads.rs index 7147b01..7ace8ad 100644 --- a/src-tauri/src/downloads.rs +++ b/src-tauri/src/downloads.rs @@ -19,19 +19,6 @@ pub async fn download_game( install_dir: usize, enable_updates: bool, ) -> Result<(), ApplicationDownloadError> { - { - let db = borrow_db_checked(); - let status = db - .applications - .game_statuses - .get(&game_id) - .unwrap_or(&GameDownloadStatus::Remote {}); - - if matches!(status, GameDownloadStatus::Installed { .. }) { - return Ok(()); - } - }; - let sender = { DOWNLOAD_MANAGER.get_sender().clone() }; let meta = DownloadableMetadata { @@ -41,14 +28,27 @@ pub async fn download_game( download_type: DownloadType::Game, }; + { + let db = borrow_db_checked(); + let status = db.applications.transient_statuses.get(&meta); + + if status.is_some() { + return Ok(()); + } + }; + let mut configuration = UserConfiguration::default(); configuration.enable_updates = enable_updates; - info!("created configuration: {:?}", configuration); + let base_dir = { + let db_lock = borrow_db_checked(); + + db_lock.applications.install_dirs[install_dir].clone() + }; - let game_download_agent = GameDownloadAgent::new_from_index( + let game_download_agent = GameDownloadAgent::new( meta, - install_dir, + base_dir, sender, DOWNLOAD_MANAGER.clone_depot_manager(), configuration, From a6fd097a0ad4a3eaa31ea9d5d2635aac538f25cb Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 17 Feb 2026 12:36:20 +1100 Subject: [PATCH 07/14] fix: deps --- main/pnpm-workspace.yaml | 14 ++++++++++++++ src-tauri/Cargo.lock | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/main/pnpm-workspace.yaml b/main/pnpm-workspace.yaml index 012c404..02c446f 100644 --- a/main/pnpm-workspace.yaml +++ b/main/pnpm-workspace.yaml @@ -1,3 +1,17 @@ onlyBuiltDependencies: - '@parcel/watcher' - esbuild + +overrides: + devalue@>=5.1.0 <5.6.2: '>=5.6.2' + devalue@>=5.3.0 <=5.6.1: '>=5.6.2' + diff@>=6.0.0 <8.0.3: '>=8.0.3' + h3@<=1.15.4: '>=1.15.5' + lodash@>=4.0.0 <=4.17.22: '>=4.17.23' + markdown-it@>=13.0.0 <14.1.1: '>=14.1.1' + node-forge@<1.3.2: '>=1.3.2' + seroval@<1.4.1: '>=1.4.1' + seroval@<=1.4.0: '>=1.4.1' + tar@<7.5.7: '>=7.5.7' + tar@<=7.5.2: '>=7.5.3' + tar@<=7.5.3: '>=7.5.4' diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1b4e941..52ac599 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -607,9 +607,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] From c82899034cf3b7fc4a28289bef402e9ab0cd8fd6 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 18 Feb 2026 13:57:34 +1100 Subject: [PATCH 08/14] feat: update modifications --- src-tauri/download_manager/src/error.rs | 10 +++-- .../games/src/downloads/download_agent.rs | 45 ++++++++++++++++++- .../games/src/downloads/download_logic.rs | 4 +- src-tauri/games/src/downloads/drop_data.rs | 18 +++++++- src-tauri/src/games.rs | 11 ++++- src-tauri/src/updates.rs | 23 +++++++--- 6 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src-tauri/download_manager/src/error.rs b/src-tauri/download_manager/src/error.rs index e82d320..abc3064 100644 --- a/src-tauri/download_manager/src/error.rs +++ b/src-tauri/download_manager/src/error.rs @@ -1,8 +1,6 @@ use humansize::{BINARY, format_size}; use std::{ - fmt::{Display, Formatter}, - io, - sync::{Arc, mpsc::SendError}, + fmt::{Display, Formatter}, io, path::StripPrefixError, sync::{Arc, mpsc::SendError} }; use remote::error::RemoteAccessError; @@ -85,4 +83,10 @@ impl From for ApplicationDownloadError { fn from(value: RemoteAccessError) -> Self { ApplicationDownloadError::Communication(value) } +} + +impl From for ApplicationDownloadError { + fn from(value: StripPrefixError) -> Self { + ApplicationDownloadError::IoError(Arc::new(io::Error::other(value))) + } } \ No newline at end of file diff --git a/src-tauri/games/src/downloads/download_agent.rs b/src-tauri/games/src/downloads/download_agent.rs index 25862bf..941e8d1 100644 --- a/src-tauri/games/src/downloads/download_agent.rs +++ b/src-tauri/games/src/downloads/download_agent.rs @@ -23,7 +23,9 @@ use remote::utils::DROP_CLIENT_ASYNC; use serde::Deserialize; use std::collections::HashMap; use std::fmt::Debug; -use std::path::{Path, PathBuf}; +use std::fs::remove_file; +use std::io; +use std::path::{Path, PathBuf, StripPrefixError}; use std::sync::{Arc, Mutex}; use std::time::Instant; use tauri::AppHandle; @@ -95,6 +97,12 @@ impl GameDownloadAgent { configuration.clone(), ); + let previous_version = borrow_db_checked() + .applications + .installed_game_version + .get(&metadata.id) + .map(|e| e.version.clone()); + let result = Self { metadata, control_flag, @@ -134,6 +142,21 @@ impl GameDownloadAgent { Ok(result) } + fn scan_filetree(&self, path: &Path) -> Result, io::Error> { + if !path.is_dir() { + return Ok(vec![path.into()]); + }; + + let subdirs = path.read_dir()?; + let mut results = Vec::new(); + for subdir in subdirs { + let subdir = subdir?; + let subfiles = self.scan_filetree(&subdir.path())?; + results.extend(subfiles); + } + Ok(results) + } + // Blocking pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> { let mut db_lock = borrow_db_mut_checked(); @@ -192,6 +215,13 @@ impl GameDownloadAgent { &[ ("id", &self.metadata.id), ("version", &self.metadata.version), + ( + "previous", + self.dropdata + .previously_installed_version + .as_ref() + .map_or("", |v| v), + ), ], ) .map_err(ApplicationDownloadError::Communication)?; @@ -270,6 +300,7 @@ impl GameDownloadAgent { let completed_chunks = lock!(self.dropdata.contexts); completed_chunks.clone() }; + info!("started with {} existing chunks", completed_chunks.len()); let chunk_len = manifests_chunks.iter().map(|v| v.1.len()).sum::(); let mut max_download_threads = borrow_db_checked().settings.max_download_threads; if max_download_threads <= 0 { @@ -277,6 +308,17 @@ impl GameDownloadAgent { } let file_list = &file_list; + let base_path = &self.dropdata.base_path; + let current_file_tree = self.scan_filetree(base_path)?; + + for file in current_file_tree { + let filename = file.strip_prefix(&base_path)?.to_string_lossy().to_string(); + let needed = file_list.contains_key(&filename) || filename == ".dropdata"; + if !needed { + info!("deleted {}", file.display()); + remove_file(file)?; + } + } let local_completed_chunks = completed_chunks.clone(); @@ -335,7 +377,6 @@ impl GameDownloadAgent { } chunk_completions.push(async move { for i in 0..RETRY_COUNT { - let base_path = self.dropdata.base_path.clone(); match download_game_chunk( &self.metadata.id, &local_version_id, diff --git a/src-tauri/games/src/downloads/download_logic.rs b/src-tauri/games/src/downloads/download_logic.rs index 8f2cd73..828fc08 100644 --- a/src-tauri/games/src/downloads/download_logic.rs +++ b/src-tauri/games/src/downloads/download_logic.rs @@ -3,7 +3,7 @@ use std::fs::{Permissions, set_permissions}; use std::io::SeekFrom; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -37,7 +37,7 @@ pub async fn download_game_chunk( key: &[u8; 16], chunk_data: &ChunkData, file_list: &HashMap, - base_path: PathBuf, + base_path: &Path, control_flag: &DownloadThreadControl, // How much we're downloading download_progress: &ProgressHandle, diff --git a/src-tauri/games/src/downloads/drop_data.rs b/src-tauri/games/src/downloads/drop_data.rs index bda652d..b956c69 100644 --- a/src-tauri/games/src/downloads/drop_data.rs +++ b/src-tauri/games/src/downloads/drop_data.rs @@ -28,6 +28,7 @@ pub mod v1 { pub configuration: UserConfiguration, pub contexts: Mutex>, pub base_path: PathBuf, + pub previously_installed_version: Option, } impl DropData { @@ -37,6 +38,7 @@ pub mod v1 { target_platform: Platform, base_path: PathBuf, configuration: UserConfiguration, + previously_installed_version: Option, ) -> Self { Self { base_path, @@ -45,6 +47,7 @@ pub mod v1 { target_platform, contexts: Mutex::new(HashMap::new()), configuration, + previously_installed_version, } } } @@ -59,13 +62,26 @@ impl DropData { configuration: UserConfiguration, ) -> Self { match DropData::read(&base_path) { - Ok(v) => v, + Ok(v) => { + if v.game_id != game_id || v.game_version != game_version { + return DropData::new( + game_id, + game_version, + target_platform, + base_path, + configuration, + Some(v.game_version), + ); + } + v + } Err(_) => DropData::new( game_id, game_version, target_platform, base_path, configuration, + None, ), } } diff --git a/src-tauri/src/games.rs b/src-tauri/src/games.rs index acb8b2e..b3a81c2 100644 --- a/src-tauri/src/games.rs +++ b/src-tauri/src/games.rs @@ -288,7 +288,16 @@ pub async fn fetch_game_version_options_logic( ) -> Result, RemoteAccessError> { let client = DROP_CLIENT_ASYNC.clone(); - let response = generate_url(&["/api/v1/client/game", &game_id, "versions"], &[])?; + let previous_id = borrow_db_checked() + .applications + .installed_game_version + .get(&game_id) + .map(|v| v.version.clone()); + + let response = generate_url( + &["/api/v1/client/game", &game_id, "versions"], + &[("previous", &previous_id.unwrap_or(String::new()))], + )?; let response = client .get(response) .header("Authorization", generate_authorization_header()) diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs index a22b5b8..97dd8b2 100644 --- a/src-tauri/src/updates.rs +++ b/src-tauri/src/updates.rs @@ -58,13 +58,26 @@ impl ScheduleTask for GameUpdater { let to_check: Vec = { let db_lock = borrow_db_checked(); - db_lock + let games = db_lock .applications - .game_versions + .game_statuses .values() - .filter(|v| v.user_configuration.enable_updates) - .map(|v| v.clone()) - .collect() + .map(|v| match v { + GameDownloadStatus::Installed { version_id, .. } => Some(version_id), + _ => None, + }) + .map(|v| { + v.map(|version_id| db_lock.applications.game_versions.get(version_id)) + .flatten() + }) + .filter(|v| { + v.map(|v| v.user_configuration.enable_updates) + .unwrap_or(false) + }) + .map(|v| v.cloned().unwrap()) + .collect(); + + games }; for version in to_check { From c24fc077ab3194db93768f2bdc39bb793adcf408 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 18 Feb 2026 15:10:57 +1100 Subject: [PATCH 09/14] feat: missing ui and lock update --- main/components/GameStatusButton.vue | 15 ++++ main/pages/library/[id]/index.vue | 5 +- main/pnpm-lock.yaml | 108 ++++++++++++++++----------- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/main/components/GameStatusButton.vue b/main/components/GameStatusButton.vue index 381a8fe..c23bc55 100644 --- a/main/components/GameStatusButton.vue +++ b/main/components/GameStatusButton.vue @@ -46,6 +46,21 @@ class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none" >
    + + + +