Async downloader, better Proton support (#183)

* feat: async downloader + other fixes

* feat: windows command parsing + use library path for install path

* feat: better proton support

* feat: style fixes and store button now uses in-app

* feat: emulator rename + umu emulator fix

* feat: bring process creation inline with docs

* fix: clippy
This commit is contained in:
DecDuck
2026-02-06 23:24:14 +11:00
committed by GitHub
parent 1f74d35bdc
commit 16ef83228b
45 changed files with 1453 additions and 381 deletions
+26
View File
@@ -3148,6 +3148,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "keyvalues-parser"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1d591f0c9482810347586bd2ae842bbd1fc4e0849283c611244930ff44e90f"
dependencies = [
"pest",
]
[[package]]
name = "known-folders"
version = "1.4.0"
@@ -4328,6 +4337,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -4688,6 +4707,7 @@ dependencies = [
"database",
"dynfmt",
"games",
"keyvalues-parser",
"log",
"page_size",
"serde",
@@ -7245,6 +7265,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.1.0"
+10 -2
View File
@@ -2,9 +2,17 @@ use serde::Serialize;
use crate::{app_status::AppStatus, user::User};
#[derive(Clone, Serialize)]
pub enum UmuState {
NotNeeded,
NotInstalled,
Installed,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppState {
pub status: AppStatus,
pub user: Option<User>
}
pub user: Option<User>,
pub umu_state: UmuState,
}
@@ -184,6 +184,8 @@ fn handle_invalid_database(
e
)
});
fs::remove_dir_all(cache_dir.clone())?;
fs::create_dir_all(cache_dir.clone())?;
let db = Database::new(games_base_dir, Some(new_path), cache_dir);
+23 -8
View File
@@ -36,7 +36,7 @@ pub mod data {
#[derive(Serialize, Deserialize)]
enum DatabaseVersionEnum {
V1 { database: v1::Database },
V0_4_0 { database: v1::Database },
}
pub struct DatabaseVersionSerializable(pub(crate) Database);
@@ -47,7 +47,7 @@ pub mod data {
S: serde::Serializer,
{
// Always serialize to latest version
DatabaseVersionEnum::V1 {
DatabaseVersionEnum::V0_4_0 {
database: self.0.clone(),
}
.serialize(serializer)
@@ -60,7 +60,7 @@ pub mod data {
D: serde::Deserializer<'de>,
{
Ok(match DatabaseVersionEnum::deserialize(deserializer)? {
DatabaseVersionEnum::V1 { database } => DatabaseVersionSerializable(database),
DatabaseVersionEnum::V0_4_0 { database } => DatabaseVersionSerializable(database),
})
}
}
@@ -73,8 +73,18 @@ pub mod data {
use super::{Deserialize, Serialize};
fn default_template() -> String {
"{}".to_owned()
fn default_template() -> UserConfiguration {
UserConfiguration {
launch_template: "{}".to_owned(),
override_proton_path: None,
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UserConfiguration {
pub launch_template: String,
pub override_proton_path: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -92,7 +102,7 @@ pub mod data {
pub delta: bool,
#[serde(default = "default_template")]
pub launch_template: String,
pub user_configuration: UserConfiguration,
pub launches: Vec<LaunchConfiguration>,
pub setups: Vec<SetupConfiguration>,
@@ -108,7 +118,7 @@ pub mod data {
pub platform: Platform,
pub umu_id_override: Option<String>,
pub executor: Option<LaunchConfigurationExecutor>,
pub emulator: Option<LaunchConfigurationEmulator>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -116,7 +126,7 @@ pub mod data {
/**
* This is intended to be used to look up the actual launch configuration that we store elsewhere
*/
pub struct LaunchConfigurationExecutor {
pub struct LaunchConfigurationEmulator {
pub launch_id: String,
pub game_id: String,
pub version_id: String,
@@ -227,6 +237,9 @@ pub mod data {
pub game_versions: HashMap<String, GameVersion>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
pub additional_proton_paths: Vec<String>,
pub default_proton_path: Option<String>,
#[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
}
@@ -258,6 +271,8 @@ pub mod data {
game_versions: HashMap::new(),
installed_game_version: HashMap::new(),
transient_statuses: HashMap::new(),
additional_proton_paths: Vec::new(),
default_proton_path: None,
},
prev_database,
base_url: String::new(),
@@ -1,7 +1,7 @@
use std::{
collections::HashMap,
sync::RwLock,
time::{Duration, Instant},
time::{Duration, Instant}, usize,
};
use futures_util::StreamExt;
@@ -48,6 +48,12 @@ struct ServersideDepot {
const SPEEDTEST_TIMEOUT: Duration = Duration::from_secs(4);
impl Default for DepotManager {
fn default() -> Self {
Self::new()
}
}
impl DepotManager {
pub fn new() -> Self {
Self {
@@ -77,11 +83,7 @@ impl DepotManager {
}
let elapsed = start.elapsed().as_millis() as usize;
let speed = if elapsed == 0 {
usize::MAX
} else {
(total_length / elapsed) * 1000
};
let speed = total_length.checked_div(elapsed).unwrap_or(usize::MAX);
depot.latest_speed.replace(speed);
Ok(())
@@ -17,7 +17,7 @@ use crate::{
download_manager_frontend::DownloadStatus,
error::ApplicationDownloadError,
frontend_updates::{
DiskStatsUpdateEvent, DownloadStatsUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData,
DownloadStatsUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData,
},
};
@@ -80,3 +80,9 @@ impl From<io::Error> for ApplicationDownloadError {
ApplicationDownloadError::IoError(Arc::new(value))
}
}
impl From<RemoteAccessError> for ApplicationDownloadError {
fn from(value: RemoteAccessError) -> Self {
ApplicationDownloadError::Communication(value)
}
}
@@ -1,6 +1,6 @@
use std::{
sync::{
Arc, Mutex,
Arc, LazyLock, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Instant,
@@ -10,7 +10,7 @@ use atomic_instant_full::AtomicInstant;
use tokio::sync::mpsc::Sender;
use utils::{lock, send};
use crate::{download_manager_frontend::DownloadManagerSignal, util::progress_object};
use crate::download_manager_frontend::DownloadManagerSignal;
use super::rolling_progress_updates::RollingProgressWindow;
@@ -27,8 +27,6 @@ pub struct ProgressObject {
progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>,
start: Arc<Mutex<Instant>>,
sender: Sender<DownloadManagerSignal>,
//last_update: Arc<RwLock<Instant>>,
last_update_time: Arc<AtomicInstant>,
bytes_last_update: Arc<AtomicUsize>,
rolling: RollingProgressWindow<1000>,
}
@@ -39,6 +37,8 @@ pub struct ProgressHandle {
progress_object: Arc<ProgressObject>,
}
static LAST_UPDATE_TIME: LazyLock<AtomicInstant> = LazyLock::new(AtomicInstant::now);
impl ProgressHandle {
pub fn new(progress: Arc<AtomicUsize>, progress_object: Arc<ProgressObject>) -> Self {
Self {
@@ -79,7 +79,6 @@ impl ProgressObject {
start: Arc::new(Mutex::new(Instant::now())),
sender,
last_update_time: Arc::new(AtomicInstant::now()),
bytes_last_update: Arc::new(AtomicUsize::new(0)),
rolling: RollingProgressWindow::new(),
progress_type,
@@ -125,7 +124,7 @@ impl ProgressObject {
}
pub fn spawn_update(progress: &Arc<ProgressObject>) {
let last_update_time = progress.last_update_time.load(Ordering::SeqCst);
let last_update_time = LAST_UPDATE_TIME.load(Ordering::SeqCst);
let time_since_last_update = Instant::now()
.duration_since(last_update_time)
.as_millis_f64();
@@ -136,9 +135,7 @@ pub fn spawn_update(progress: &Arc<ProgressObject>) {
}
pub async fn calculate_update(progress: Arc<ProgressObject>, time_since_last_update: f64) {
progress
.last_update_time
.swap(Instant::now(), Ordering::SeqCst);
LAST_UPDATE_TIME.swap(Instant::now(), Ordering::SeqCst);
let current_bytes_downloaded = progress.sum();
let max = progress.get_max();
@@ -4,6 +4,12 @@ pub struct SyncSemaphore {
inner: Arc<AtomicUsize>
}
impl Default for SyncSemaphore {
fn default() -> Self {
Self::new()
}
}
impl SyncSemaphore {
pub fn new() -> Self {
Self { inner: Arc::new(AtomicUsize::new(0)) }
@@ -11,15 +11,17 @@ use download_manager::util::download_thread_control_flag::{
};
use download_manager::util::progress_object::{ProgressHandle, ProgressObject, ProgressType};
use droplet_rs::manifest::{ChunkData, Manifest};
use futures_util::StreamExt;
use futures_util::stream::FuturesUnordered;
use log::{debug, error, info, warn};
use remote::auth::generate_authorization_header;
use remote::cache::get_cached_object;
use remote::error::RemoteAccessError;
use remote::requests::generate_url;
use remote::utils::DROP_CLIENT_ASYNC;
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt::Debug;
use std::mem;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
@@ -28,7 +30,7 @@ use tokio::sync::mpsc::Sender;
use utils::{app_emit, lock, send};
use crate::downloads::utils::get_disk_available;
use crate::library::{on_game_complete, push_game_update, set_partially_installed};
use crate::library::{Game, on_game_complete, push_game_update, set_partially_installed};
use crate::state::GameStatusManager;
use super::download_logic::download_game_chunk;
@@ -87,9 +89,11 @@ impl GameDownloadAgent {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let game_name = get_cached_object::<Game>(&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());
let data_base_dir_path = base_dir_path.join(metadata.id.clone());
let data_base_dir_path = base_dir_path.join(game_name);
info!("data dir path {}", data_base_dir_path.display());
let stored_manifest = DropData::generate(
@@ -166,10 +170,7 @@ impl GameDownloadAgent {
info!("beginning download for {}...", self.metadata().id);
let res = self
.run()
.await
.map_err(ApplicationDownloadError::Communication);
let res = self.run().await;
debug!(
"{} took {}ms to download",
@@ -244,17 +245,16 @@ impl GameDownloadAgent {
self.download_progress
.set_max(dl_info.download_size.try_into().unwrap());
self.download_progress
.set_size(total_chunks);
self.download_progress.set_size(total_chunks);
self.download_progress.reset();
self.disk_progress.set_max(dl_info.install_size.try_into().unwrap());
self.disk_progress
.set_size(total_chunks);
.set_max(dl_info.install_size.try_into().unwrap());
self.disk_progress.set_size(total_chunks);
self.disk_progress.reset();
}
async fn run(&self) -> Result<bool, RemoteAccessError> {
async fn run(&self) -> Result<bool, ApplicationDownloadError> {
self.depot_manager.sync_depots().await?;
info!("synced depots");
self.setup_progress();
@@ -278,124 +278,119 @@ impl GameDownloadAgent {
completed_chunks.clone()
};
let chunk_len = manifests_chunks.iter().map(|v| v.1.len()).sum::<usize>();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
let mut max_download_threads = borrow_db_checked().settings.max_download_threads;
if max_download_threads <= 0 {
max_download_threads = 1;
}
let (sender, recv) = crossbeam_channel::bounded(16);
// SAFETY: I pinky-promise
// (the scope keeps these in scope)
let unsafe_self: &'static GameDownloadAgent = unsafe { mem::transmute(self) };
let file_list: &'static HashMap<String, String> = unsafe { mem::transmute(&file_list) };
let file_list = &file_list;
let local_completed_chunks = completed_chunks.clone();
let download_join_handle = tauri::async_runtime::spawn_blocking(move || {
let thread_pool = rayon::ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
.unwrap();
thread_pool.scope(move |s| {
let mut index = 0;
for (version_id, chunks, key) in manifests_chunks.into_iter() {
let version_id = &version_id;
for (chunk_id, chunk_data) in chunks.into_iter() {
let local_sender = sender.clone();
let download_progress_handle = ProgressHandle::new(
unsafe_self.download_progress.get(index),
unsafe_self.download_progress.clone(),
);
let disk_progress_handle = ProgressHandle::new(
unsafe_self.disk_progress.get(index),
unsafe_self.disk_progress.clone(),
);
index += 1;
let chunk_length = chunk_data.files.iter().map(|v| v.length).sum();
if *local_completed_chunks.get(&chunk_id).unwrap_or(&false) {
download_progress_handle.skip(chunk_length);
continue;
}
let sender = unsafe_self.sender.clone();
let (depot, permit) = match unsafe_self
.depot_manager
.next_depot(&unsafe_self.metadata.id, &unsafe_self.metadata.version)
{
Ok(v) => v,
Err(err) => {
tauri::async_runtime::spawn(async move {
send!(
sender,
DownloadManagerSignal::Error(
ApplicationDownloadError::Communication(err)
)
);
});
return;
}
};
let local_version_id = version_id.clone();
s.spawn(move |_| {
for i in 0..RETRY_COUNT {
let base_path = unsafe_self.dropdata.base_path.clone();
match download_game_chunk(
&unsafe_self.metadata.id,
&local_version_id,
&chunk_id,
&depot,
&key,
&chunk_data,
file_list,
base_path,
&unsafe_self.control_flag,
&download_progress_handle,
&disk_progress_handle,
) {
Ok(true) => {
local_sender.send(chunk_id.clone()).unwrap();
drop(permit); // Take ownership
return;
}
Ok(false) => return,
Err(e) => {
warn!("got error for chunk id {}: {e:?}", chunk_id);
let retry = true; /*matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
| ApplicationDownloadError::IoError(_)
);*/
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
tauri::async_runtime::spawn(async move {
send!(sender, DownloadManagerSignal::Error(e));
});
return;
}
}
}
}
});
}
}
drop(sender);
});
});
let mut chunk_completions = FuturesUnordered::new();
let mut outputs = Vec::new();
while let Ok(chunk_id) = recv.recv() {
outputs.push(chunk_id);
let mut handle_output =
|value: Result<Option<String>, ApplicationDownloadError>| match value {
Ok(value) => {
if let Some(chunk_id) = value {
outputs.push(chunk_id);
}
Ok(())
}
Err(err) => return Err(err),
};
let mut index = 0;
for (version_id, chunks, key) in manifests_chunks.into_iter() {
let version_id = &version_id;
for (chunk_id, chunk_data) in chunks.into_iter() {
let download_progress_handle = ProgressHandle::new(
self.download_progress.get(index),
self.download_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();
if *local_completed_chunks.get(&chunk_id).unwrap_or(&false) {
download_progress_handle.skip(chunk_length);
continue;
}
let (depot, permit) = match self
.depot_manager
.next_depot(&self.metadata.id, &self.metadata.version)
{
Ok(v) => v,
Err(err) => {
return Err(err.into());
}
};
let local_version_id = version_id.clone();
while chunk_completions.len() >= max_download_threads {
handle_output(
chunk_completions
.next()
.await
.expect("max download threads is zero?"),
)?;
}
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,
&chunk_id,
&depot,
&key,
&chunk_data,
file_list,
base_path,
&self.control_flag,
&download_progress_handle,
&disk_progress_handle,
)
.await
{
Ok(true) => {
drop(permit);
return Ok(Some(chunk_id.clone()));
}
Ok(false) => return Ok(None),
Err(e) => {
warn!("got error for chunk id {}: {e:?}", chunk_id);
let retry = true; /*matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
| ApplicationDownloadError::IoError(_)
);*/
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
return Err(e);
}
}
}
}
Ok(None)
});
}
}
download_join_handle
.await
.expect("failed to complete download");
while let Some(value) = chunk_completions.next().await {
handle_output(value)?
}
for completed_chunk in outputs {
completed_chunks.insert(completed_chunk, true);
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::fs::{Permissions, set_permissions};
use std::io::{Read, Seek as _, SeekFrom, Write as _};
use std::io::SeekFrom;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
@@ -14,19 +14,22 @@ use download_manager::util::download_thread_control_flag::{
};
use download_manager::util::progress_object::ProgressHandle;
use droplet_rs::manifest::ChunkData;
use futures_util::StreamExt as _;
use log::{debug, info};
use remote::auth::generate_authorization_header;
use remote::error::{DropServerError, RemoteAccessError};
use remote::utils::DROP_CLIENT_SYNC;
use remote::utils::DROP_CLIENT_ASYNC;
use sha2::Digest;
use tauri::Url;
use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _};
use tokio_util::io::StreamReader;
const READ_BUF_LEN: usize = 1024 * 1024;
type Aes128Ctr64LE = ctr::Ctr64LE<aes::Aes128>;
#[allow(clippy::too_many_arguments)]
pub fn download_game_chunk(
pub async fn download_game_chunk(
game_id: &str,
version_id: &str,
chunk_id: &str,
@@ -57,15 +60,16 @@ pub fn download_game_chunk(
.join(&format!("content/{}/{}/{}", game_id, version_id, chunk_id))
.map_err(|v| ApplicationDownloadError::DownloadError(v.into()))?;
let response = DROP_CLIENT_SYNC
let response = DROP_CLIENT_ASYNC
.get(url)
.header("Authorization", header)
.send()
.await
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
info!("chunk request got status code: {}", response.status());
let raw_res = response.text().map_err(|e| {
let raw_res = response.text().await.map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::FetchErrorLegacy(e.into()))
})?;
info!("{raw_res}");
@@ -89,11 +93,11 @@ pub fn download_game_chunk(
debug!("took {}ms to start downloading", timestep);
/*let stream = response
let stream = response
.bytes_stream()
.map(|v| v.map_err(|err| std::io::Error::other(err)));
let mut stream_reader = StreamReader::new(stream);*/
let mut stream_reader = response;
let mut stream_reader = StreamReader::new(stream);
//let mut stream_reader = response;
let mut hasher = sha2::Sha256::new();
let mut cipher = Aes128Ctr64LE::new(key.into(), &chunk_data.iv.into());
@@ -108,13 +112,14 @@ pub fn download_game_chunk(
std::fs::create_dir_all(parent)?;
}
let mut file_handle = if should_write {
let mut file_handle = std::fs::OpenOptions::new()
let mut file_handle = tokio::fs::OpenOptions::new()
.truncate(false)
.write(true)
.append(false)
.create(true)
.open(&path)?;
file_handle.seek(SeekFrom::Start(file.start.try_into().unwrap()))?;
.open(&path)
.await?;
file_handle.seek(SeekFrom::Start(file.start.try_into().unwrap())).await?;
Some(file_handle)
} else {
None
@@ -122,14 +127,14 @@ pub fn download_game_chunk(
let mut remaining = file.length;
while remaining > 0 {
let amount = stream_reader.read(&mut read_buf[0..remaining.min(READ_BUF_LEN)])?;
let amount = stream_reader.read(&mut read_buf[0..remaining.min(READ_BUF_LEN)]).await?;
download_progress.add(amount);
remaining -= amount;
cipher.apply_keystream(&mut read_buf[0..amount]);
//hasher.update(&read_buf[0..amount]);
hasher.update(&read_buf[0..amount]);
if let Some(file_handle) = &mut file_handle {
file_handle.write_all(&read_buf[0..amount])?;
file_handle.write_all(&read_buf[0..amount]).await?;
disk_progress.add(amount);
}
}
@@ -155,7 +160,7 @@ pub fn download_game_chunk(
let digest = hex::encode(hasher.finalize());
if digest != chunk_data.checksum {
//return Err(ApplicationDownloadError::Checksum);
return Err(ApplicationDownloadError::Checksum);
}
Ok(true)
+7 -7
View File
@@ -49,6 +49,7 @@ pub struct Game {
pub m_cover_object_id: String,
pub m_image_library_object_ids: Vec<String>,
pub m_image_carousel_object_ids: Vec<String>,
pub library_path: String,
}
impl Game {
pub fn id(&self) -> &String {
@@ -246,6 +247,10 @@ pub async fn on_game_complete(
.applications
.game_statuses
.insert(meta.id.clone(), status.clone());
db_handle
.applications
.transient_statuses
.remove(meta);
drop(db_handle);
app_emit!(
app_handle,
@@ -289,11 +294,6 @@ pub fn push_game_update(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendGameOptions {
launch_string: String,
}
impl FrontendGameOptions {
pub fn launch_string(&self) -> &String {
&self.launch_string
}
pub launch_string: String,
pub override_proton_path: Option<String>,
}
+1 -2
View File
@@ -19,7 +19,6 @@ pub fn scan_install_dirs() {
if !drop_data_file.exists() {
continue;
}
let game_id = game.file_name().display().to_string();
let Ok(drop_data) = DropData::read(&game.path()) else {
warn!(
".dropdata exists for {}, but couldn't read it. is it corrupted?",
@@ -27,7 +26,7 @@ pub fn scan_install_dirs() {
);
continue;
};
if db_lock.applications.game_statuses.contains_key(&game_id) {
if db_lock.applications.game_statuses.contains_key(&drop_data.game_id) {
continue;
}
+1
View File
@@ -9,6 +9,7 @@ client = { path = "../client", version = "0.1.0" }
database = { path = "../database", version = "0.1.0" }
dynfmt = { version = "0.1.5", features = ["curly"] }
games = { path = "../games", version = "0.1.0" }
keyvalues-parser = "0.2.3"
log = "0.4.28"
page_size = "0.6.0"
serde = "1.0.228"
+174
View File
@@ -0,0 +1,174 @@
// Linux-only file
use std::{
fs::{DirEntry, read_dir, read_to_string},
io,
path::PathBuf,
sync::LazyLock,
};
use database::{borrow_db_checked, borrow_db_mut_checked};
use log::warn;
use serde::Serialize;
static SEARCH_PATHS: LazyLock<Vec<String>> = LazyLock::new(|| {
let mut paths = vec!["/usr/share/steam/compatibilitytools.d/".to_owned()];
if let Some(home_dir) = std::env::home_dir() {
paths.push(
home_dir
.join(".steam/root/compatibilitytools.d/")
.to_string_lossy()
.to_string(),
);
}
paths
});
pub fn read_proton_path(proton_path: PathBuf) -> Result<Option<ProtonPath>, io::Error> {
let read_dir = read_dir(&proton_path)?
.flatten()
.collect::<Vec<DirEntry>>();
let has_proton_path = read_dir
.iter()
.find(|v| v.file_name().to_string_lossy() == "proton")
.is_some();
if !has_proton_path {
return Ok(None);
};
let compat_vdf = read_dir
.iter()
.find(|v| v.file_name().to_string_lossy() == "compatibilitytool.vdf");
let compat_vdf = match compat_vdf {
Some(v) => v,
None => return Ok(None),
};
let compat_vdf = read_to_string(compat_vdf.path())?;
let compat_vdf = keyvalues_parser::parse(&compat_vdf)
.inspect_err(|err| warn!("failed to parse vdf: {:?}", err))
.map_err(|err| io::Error::other(err.to_string()))?;
// Function was made with a lot of trial and error
// Not intended to be readable
let get_display_name = || -> Option<String> {
let compat_tools = compat_vdf.value.unwrap_obj();
let compat_tools = compat_tools.values().next()?.iter().next()?;
let compat_tools = compat_tools.get_obj().unwrap();
let compat_tools = compat_tools.values().next()?.iter().next()?.get_obj()?;
let display_name = compat_tools.get("display_name")?.iter().next()?.get_str()?;
Some(display_name.to_string())
};
if let Some(display_name) = get_display_name() {
return Ok(Some(ProtonPath {
path: proton_path.to_string_lossy().to_string(),
name: display_name,
}));
}
Ok(None)
}
pub fn discover_proton_paths() -> Result<Vec<ProtonPath>, io::Error> {
let mut results = Vec::new();
for search_path in &*SEARCH_PATHS {
if let Ok(potential_dirs) = read_dir(search_path) {
for proton_path in potential_dirs {
if let Some(proton) = read_proton_path(proton_path?.path())? {
results.push(proton);
}
}
}
}
Ok(results)
}
#[derive(Serialize)]
pub struct ProtonPath {
pub path: String,
pub name: String,
}
#[derive(Serialize)]
pub struct ProtonPaths {
pub autodiscovered: Vec<ProtonPath>,
pub custom: Vec<ProtonPath>,
pub default: Option<String>,
}
#[tauri::command]
pub async fn fetch_proton_paths() -> Result<ProtonPaths, String> {
let autodiscovered = discover_proton_paths().map_err(|v| v.to_string())?;
let db_lock = borrow_db_checked();
let custom = db_lock
.applications
.additional_proton_paths
.iter()
.flat_map(|v| read_proton_path(PathBuf::from(v)))
.flatten()
.collect::<Vec<ProtonPath>>();
let default = db_lock.applications.default_proton_path.clone();
Ok(ProtonPaths {
autodiscovered,
custom,
default,
})
}
#[tauri::command]
pub fn add_proton_layer(path: String) -> Result<(), String> {
let path = PathBuf::from(path);
let proton_layer = read_proton_path(path)
.map_err(|err| err.to_string())?
.ok_or("Unable to detect Proton at selected path.".to_owned())?;
let mut db = borrow_db_mut_checked();
db.applications
.additional_proton_paths
.push(proton_layer.path);
Ok(())
}
#[tauri::command]
pub async fn remove_proton_layer(index: usize) {
let mut db = borrow_db_mut_checked();
let deleted = db.applications.additional_proton_paths.try_remove(index);
if let Some(deleted) = deleted
&& let Some(default_path) = &db.applications.default_proton_path
&& *default_path == deleted {
db.applications.default_proton_path = None;
}
}
#[tauri::command]
pub async fn set_default(path: String) -> Result<(), String> {
let proton_paths = fetch_proton_paths().await?;
let valid = proton_paths
.autodiscovered
.iter()
.find(|v| v.path == path)
.or(proton_paths.custom.iter().find(|v| v.path == path))
.is_some();
if !valid {
return Err("Invalid default Proton path.".to_string());
}
let mut db_lock = borrow_db_mut_checked();
db_lock.applications.default_proton_path = Some(path);
Ok(())
}
+8 -2
View File
@@ -1,4 +1,8 @@
use std::{fmt::Display, io::{self, Error}, sync::Arc};
use std::{
fmt::Display,
io::{self, Error},
sync::Arc,
};
use serde_with::SerializeDisplay;
@@ -15,6 +19,7 @@ pub enum ProcessError {
OpenerError(Arc<tauri_plugin_opener::Error>),
InvalidArguments(String),
FailedLaunch(String),
NoCompat,
}
impl Display for ProcessError {
@@ -38,6 +43,7 @@ impl Display for ProcessError {
"Missing a required dependency to launch this game: {} {}",
game_id, version_id
),
ProcessError::NoCompat => "No Proton compatibility layer could be found for this tool. Add an override or set your global default in settings.",
};
write!(f, "{s}")
}
@@ -47,4 +53,4 @@ impl From<io::Error> for ProcessError {
fn from(value: io::Error) -> Self {
ProcessError::IOError(Arc::new(value))
}
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ impl DropFormatArgs {
map.insert("abs_exe", absolute_executable_name);
if let Some(original) = original {
map.insert("executor", original);
map.insert("rom", original);
}
Self { positional, map }
+4 -1
View File
@@ -1,6 +1,7 @@
#![feature(nonpoison_mutex)]
#![feature(sync_nonpoison)]
#![feature(extend_one)]
#![feature(vec_try_remove)]
use std::{
ops::Deref,
@@ -13,11 +14,13 @@ use crate::process_manager::ProcessManager;
pub static PROCESS_MANAGER: ProcessManagerWrapper = ProcessManagerWrapper::new();
#[cfg(target_os = "linux")]
pub mod compat;
pub mod error;
pub mod format;
mod parser;
pub mod process_handlers;
pub mod process_manager;
mod parser;
pub struct ProcessManagerWrapper(OnceLock<Mutex<ProcessManager<'static>>>);
impl ProcessManagerWrapper {
+2 -2
View File
@@ -43,8 +43,8 @@ impl ParsedCommand {
v.extend(self.env);
v.extend_one(self.command);
v.extend(self.args);
v.join(" ")
shell_words::join(v)
}
}
pub struct LaunchParameters(pub String, pub PathBuf);
pub struct LaunchParameters(pub ParsedCommand, pub PathBuf);
@@ -1,4 +1,4 @@
use std::fs::create_dir_all;
use std::{fs::create_dir_all, path::PathBuf};
use client::compat::{COMPAT_INFO, UMU_LAUNCHER_EXECUTABLE};
use database::{
@@ -15,6 +15,7 @@ impl ProcessHandler for NativeGameLauncher {
launch_command: String,
_game_version: &GameVersion,
_current_dir: &str,
_database: &Database,
) -> Result<String, ProcessError> {
Ok(format!("\"{}\"", launch_command))
}
@@ -32,33 +33,55 @@ impl ProcessHandler for UMULauncher {
launch_command: String,
game_version: &GameVersion,
_current_dir: &str,
database: &Database,
) -> Result<String, ProcessError> {
let launch_config = game_version
let umu_id_override = game_version
.launches
.iter()
.find(|v| v.platform == meta.target_platform)
.ok_or(ProcessError::NotInstalled)?;
.and_then(|v| v.umu_id_override.as_ref())
.map_or("", |v| v);
let game_id = match &launch_config.umu_id_override {
Some(game_override) => {
if game_override.is_empty() {
game_version.version_id.clone()
} else {
game_override.clone()
}
}
None => game_version.version_id.clone(),
let game_id = if umu_id_override.is_empty() {
&game_version.version_id
} else {
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 no_proton = match meta.target_platform {
Platform::Linux => Some("UMU_NO_PROTON=1"),
_ => None,
};
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)?;
let proton_valid = crate::compat::read_proton_path(PathBuf::from(proton_path))
.ok()
.flatten()
.is_some();
if !proton_valid {
return Err(ProcessError::NoCompat);
}
Some(format!("PROTONPATH={}", proton_path))
} else {
None
};
Ok(format!(
"GAMEID={game_id} WINEPREFIX={} {} {umu:?} {launch}",
"GAMEID={game_id} {} WINEPREFIX={} {} {umu:?} {launch}",
proton_env.unwrap_or(String::new()),
pfx_dir.to_string_lossy(),
match meta.target_platform {
Platform::Linux => "UMU_NO_PROTON=1",
_ => "",
},
no_proton.unwrap_or(""),
umu = UMU_LAUNCHER_EXECUTABLE
.as_ref()
.expect("Failed to get UMU_LAUNCHER_EXECUTABLE as ref"),
@@ -82,6 +105,7 @@ impl ProcessHandler for AsahiMuvmLauncher {
launch_command: String,
game_version: &GameVersion,
current_dir: &str,
database: &Database,
) -> Result<String, ProcessError> {
let umu_launcher = UMULauncher {};
let umu_string = umu_launcher.create_launch_process(
@@ -89,6 +113,7 @@ impl ProcessHandler for AsahiMuvmLauncher {
launch_command,
game_version,
current_dir,
database,
)?;
let mut args_cmd = umu_string
.split("umu-run")
@@ -321,7 +321,7 @@ impl ProcessManager<'_> {
let process_handler = self.fetch_process_handler(&db_lock, &target_platform)?;
let (target_command, executor) = match game_status {
let (target_command, emulator) = match game_status {
GameDownloadStatus::Installed {
version_name: _,
install_dir: _,
@@ -335,7 +335,7 @@ impl ProcessManager<'_> {
.ok_or(ProcessError::NotInstalled)?;
(
launch_config.command.clone(),
launch_config.executor.as_ref(),
launch_config.emulator.as_ref(),
)
}
GameDownloadStatus::SetupRequired {
@@ -353,27 +353,27 @@ impl ProcessManager<'_> {
_ => unreachable!("Game registered as 'Partially Installed'"),
};
let target_command = ParsedCommand::parse(target_command)?;
let mut target_command = ParsedCommand::parse(target_command)?;
let launch_parameters = if let Some(executor) = executor {
let target_launch_string = if let Some(emulator) = emulator {
let err = ProcessError::RequiredDependency(
executor.game_id.clone(),
executor.version_id.clone(),
emulator.game_id.clone(),
emulator.version_id.clone(),
);
let executor_metadata = db_lock
let emulator_metadata = db_lock
.applications
.installed_game_version
.get(&executor.game_id)
.get(&emulator.game_id)
.ok_or(err.clone())?;
let executor_game_status = db_lock
let emulator_game_status = db_lock
.applications
.game_statuses
.get(&executor.game_id)
.get(&emulator.game_id)
.ok_or(err.clone())?;
let executor_install_dir = match executor_game_status {
let emulator_install_dir = match emulator_game_status {
GameDownloadStatus::Installed {
version_name: _,
install_dir,
@@ -385,86 +385,101 @@ impl ProcessManager<'_> {
_ => Err(err.clone()),
}?;
let executor_game_version = db_lock
let emulator_game_version = db_lock
.applications
.game_versions
.get(&executor.version_id)
.get(&emulator.version_id)
.ok_or(err.clone())?;
let executor_launch_config = executor_game_version
let emulator_launch_config = emulator_game_version
.launches
.iter()
.find(|v| v.launch_id == executor.launch_id)
.find(|v| v.launch_id == emulator.launch_id)
.ok_or(err)?;
println!("{}", executor_launch_config.command);
let mut exe_command = ParsedCommand::parse(executor_launch_config.command.clone())?;
println!("{:?}", exe_command);
exe_command.env.extend(target_command.env);
exe_command.make_absolute(executor_install_dir.into());
let mut exe_command = ParsedCommand::parse(emulator_launch_config.command.clone())?;
exe_command.env.extend(target_command.env.clone());
exe_command.make_absolute(emulator_install_dir.into());
target_command.make_absolute(PathBuf::from(install_dir.clone()));
exe_command.args.iter_mut().for_each(|v| {
*v = v.replace("{executor}", &target_command.command);
*v = v.replace("{rom}", &target_command.command);
});
let executor_launch_string = process_handler.create_launch_process(
executor_metadata,
exe_command.reconstruct(),
executor_game_version,
install_dir,
)?;
LaunchParameters(executor_launch_string, install_dir.into())
process_handler.create_launch_process(
emulator_metadata,
exe_command.reconstruct(),
emulator_game_version,
install_dir,
&db_lock,
)?
} else {
let target_launch_string = process_handler.create_launch_process(
process_handler.create_launch_process(
&meta,
target_command.reconstruct(),
game_version,
install_dir,
)?;
let mut parsed_launch = ParsedCommand::parse(target_launch_string.clone())?;
let executable_name = parsed_launch.command.clone();
parsed_launch.make_absolute(install_dir.into());
let format_args = DropFormatArgs::new(
target_launch_string,
install_dir,
&executable_name,
parsed_launch.command,
None,
);
let target_launch_string = SimpleCurlyFormat
.format(&game_version.launch_template, &format_args)
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
let target_launch_string = SimpleCurlyFormat
.format(&target_launch_string, format_args)
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
LaunchParameters(target_launch_string, install_dir.into())
&db_lock,
)?
};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
let mut command = Command::new("cmd");
#[cfg(target_os = "windows")]
command.raw_arg(format!("/C \"{}\"", &launch_parameters.0));
let mut parsed_launch = ParsedCommand::parse(target_launch_string.clone())?;
let executable_name = parsed_launch.command.clone();
parsed_launch.make_absolute(install_dir.into());
let format_args = DropFormatArgs::new(
target_launch_string,
install_dir,
&executable_name,
parsed_launch.command,
None,
);
let target_launch_string = SimpleCurlyFormat
.format(
&game_version.user_configuration.launch_template,
&format_args,
)
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
let target_launch_string = SimpleCurlyFormat
.format(&target_launch_string, format_args)
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
let launch_parameters = LaunchParameters(
ParsedCommand::parse(target_launch_string)?,
install_dir.into(),
);
info!(
"launching (in {}): {}",
"launching (in {}): {:?}",
launch_parameters.1.to_string_lossy(),
launch_parameters.0
);
#[cfg(unix)]
let mut command: Command = Command::new("sh");
#[cfg(unix)]
command.args(vec!["-c", &launch_parameters.0]);
let mut command = {
let mut command = Command::new(launch_parameters.0.command);
command.args(launch_parameters.0.args);
for parts in launch_parameters
.0
.env
.into_iter()
.map(|e| e.split("=").map(|v| v.to_string()).collect::<Vec<String>>())
{
if let Some(key) = parts.first()
&& let Some(value) = parts.get(1) {
command.env(key, value);
}
}
command
};
command
.stderr(error_file)
@@ -474,8 +489,7 @@ impl ProcessManager<'_> {
let child = command.spawn()?;
let launch_process_handle =
Arc::new(SharedChild::new(child)?);
let launch_process_handle = Arc::new(SharedChild::new(child)?);
db_lock
.applications
@@ -518,6 +532,7 @@ pub trait ProcessHandler: Send + 'static {
launch_command: String,
game_version: &GameVersion,
current_dir: &str,
database: &Database,
) -> Result<String, ProcessError>;
fn valid_for_platform(&self, db: &Database, target: &Platform) -> bool;
-2
View File
@@ -4,10 +4,8 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use chrono::Utc;
use client::{app_status::AppStatus, user::User};
use database::{DatabaseAuth, interface::borrow_db_checked};
use droplet_rs::ssl::sign_nonce;
use gethostname::gethostname;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use log::{error, warn};
+7 -2
View File
@@ -35,6 +35,7 @@ pub enum RemoteAccessError {
Cache(std::io::Error),
CorruptedState,
NoDepots,
FailedDownload,
}
impl Display for RemoteAccessError {
@@ -104,8 +105,12 @@ impl Display for RemoteAccessError {
f,
"Drop encountered a corrupted internal state. Please report this to the developers, with details of reproduction."
),
RemoteAccessError::NoDepots => write!(f, "There are no download depots configured on the server. Contact your server admin."),
}
RemoteAccessError::NoDepots => write!(
f,
"There are no download depots configured on the server. Contact your server admin."
),
RemoteAccessError::FailedDownload => write!(f, "Failed to download."),
}
}
}
+23 -11
View File
@@ -44,21 +44,33 @@ impl Middleware for AutoOfflineMiddleware {
extensions: &mut Extensions,
next: Next<'_>,
) -> Result<Response> {
let url = req.url().clone();
let res = next.run(req, extensions).await;
match res {
Ok(res) => {
tauri::async_runtime::spawn(async move {
let lock = DROP_APP_HANDLE.lock().await;
if let Some(app_handle) = &*lock {
let state = app_handle.state::<std::sync::nonpoison::Mutex<AppState>>();
let mut state_lock = state.lock();
if state_lock.status == AppStatus::Offline {
state_lock.status = AppStatus::SignedIn;
app_handle
.emit("update_state", &*state_lock)
.expect("failed to emit state update");
}
};
let lock = DROP_APP_HANDLE.try_lock();
if let Ok(lock) = lock {
if let Some(app_handle) = &*lock {
let state = app_handle.state::<std::sync::nonpoison::Mutex<AppState>>();
let state_lock = state.try_lock();
if let Ok(mut state_lock) = state_lock {
if state_lock.status == AppStatus::Offline {
state_lock.status = AppStatus::SignedIn;
app_handle
.emit("update_state", &*state_lock)
.expect("failed to emit state update");
}
} else {
warn!("failed to lock app state - {}", url.as_str());
}
};
} else {
warn!(
"failed to lock app handle for offline/online middleware - {}",
url.as_str()
);
}
});
Ok(res)
+16 -5
View File
@@ -48,6 +48,7 @@ pub struct FetchLibraryResponse {
library: Vec<Game>,
collections: Vec<Collection>,
other: Vec<Game>,
missing: Vec<Game>,
}
pub async fn fetch_library_logic(
@@ -60,11 +61,11 @@ pub async fn fetch_library_logic(
return Ok(library);
}
let client = DROP_CLIENT_ASYNC.clone();
let response = generate_url(&["/api/v1/client/user/library"], &[])?;
let response = client
let auth_header = generate_authorization_header();
let response = DROP_CLIENT_ASYNC
.get(response)
.header("Authorization", generate_authorization_header())
.header("Authorization", auth_header)
.send()
.await?;
@@ -111,6 +112,7 @@ pub async fn fetch_library_logic(
// Add games that are installed but no longer in library
let mut other = Vec::new();
let mut missing = Vec::new();
for meta in installed_metas {
if all_games.iter().any(|e| *e.id() == meta.id) {
continue;
@@ -132,13 +134,18 @@ pub async fn fetch_library_logic(
continue;
}
};
other.push(game);
if game.game_type == "Game" {
missing.push(game);
} else {
other.push(game);
}
}
let response = FetchLibraryResponse {
library,
collections,
other,
missing,
};
cache_object("library", &response)?;
@@ -167,6 +174,7 @@ pub async fn fetch_library_logic_offline(
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!(
@@ -253,6 +261,7 @@ pub async fn fetch_game_logic(
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VersionDownloadOptionRequiredContent {
game_id: String,
version_id: String,
name: String,
icon_object_id: String,
@@ -263,6 +272,7 @@ struct VersionDownloadOptionRequiredContent {
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VersionDownloadOption {
game_id: String,
version_id: String,
display_name: Option<String>,
version_path: String,
@@ -397,7 +407,8 @@ pub fn update_game_configuration(
.clone();
// Add more options in here
existing_configuration.launch_template = options.launch_string().clone();
existing_configuration.user_configuration.launch_template = options.launch_string;
existing_configuration.user_configuration.override_proton_path = options.override_proton_path;
// Add no more options past here
+28 -4
View File
@@ -12,7 +12,12 @@ use std::{
sync::nonpoison::Mutex, time::SystemTime,
};
use ::client::{app_state::AppState, app_status::AppStatus, autostart::sync_autostart_on_startup};
use ::client::{
app_state::{AppState, UmuState},
app_status::AppStatus,
autostart::sync_autostart_on_startup,
compat::UMU_LAUNCHER_EXECUTABLE,
};
use ::download_manager::DownloadManagerWrapper;
use ::games::scan::scan_install_dirs;
use ::process::ProcessManagerWrapper;
@@ -96,6 +101,15 @@ async fn setup(handle: AppHandle) -> AppState {
ProcessManagerWrapper::init(handle.clone());
DownloadManagerWrapper::init(handle.clone());
#[cfg(not(target_os = "linux"))]
let umu_state = UmuState::NotNeeded;
#[cfg(target_os = "linux")]
let umu_state = match UMU_LAUNCHER_EXECUTABLE.is_some() {
true => UmuState::Installed,
false => UmuState::NotInstalled,
};
debug!("checking if database is set up");
let is_set_up = DB.database_is_set_up();
@@ -105,6 +119,7 @@ async fn setup(handle: AppHandle) -> AppState {
return AppState {
status: AppStatus::NotConfigured,
user: None,
umu_state,
};
}
@@ -166,6 +181,7 @@ async fn setup(handle: AppHandle) -> AppState {
AppState {
status: app_status,
user,
umu_state,
}
}
@@ -252,7 +268,15 @@ pub fn run() {
toggle_autostart,
get_autostart_enabled,
open_process_logs,
get_launch_options
get_launch_options,
#[cfg(target_os = "linux")]
::process::compat::fetch_proton_paths,
#[cfg(target_os = "linux")]
::process::compat::add_proton_layer,
#[cfg(target_os = "linux")]
::process::compat::remove_proton_layer,
#[cfg(target_os = "linux")]
::process::compat::set_default
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
@@ -296,7 +320,7 @@ pub fn run() {
main_window
.add_child(
WebviewBuilder::new("frontned", WebviewUrl::App("main".into()))
WebviewBuilder::new("frontend", WebviewUrl::App("main".into()))
.auto_resize(),
LogicalPosition::new(0., 0.),
LogicalSize::new(width, height),
@@ -355,7 +379,7 @@ pub fn run() {
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
app.webview_windows()
.get("main")
.get("frontend")
.expect("Failed to get webview")
.show()
.expect("Failed to show window");