v2 download API and fixes (#112)

* fix: potential download fixes

* fix: show installed games not on remote

* fix: more download_logic error handling

* partial: move to async

* feat: interactivity improvements

* feat: v2 download API

* fix: download seek offsets

* fix: clippy

* fix: apply clippy suggestion

* fix: performance improvements starting up download

* fix: finished bucket file

* fix: ui tweaks and fixes

* fix: revert version to 0.3.2

* fix: clippy
This commit is contained in:
DecDuck
2025-08-09 15:50:21 +10:00
committed by GitHub
parent 3b830e2a44
commit 16365713cf
27 changed files with 859 additions and 603 deletions

2
src-tauri/Cargo.lock generated
View File

@ -1284,7 +1284,7 @@ dependencies = [
[[package]]
name = "drop-app"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"atomic-instant-full",
"bitcode",

View File

@ -1,6 +1,6 @@
[package]
name = "drop-app"
version = "0.3.1"
version = "0.3.2"
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
edition = "2024"

View File

@ -1,3 +1,4 @@
fn main() {
println!("cargo::rustc-link-lib=appindicator3");
tauri_build::build();
}

View File

@ -266,6 +266,7 @@ pub mod data {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,

View File

@ -128,7 +128,7 @@ impl DownloadManagerBuilder {
drop(download_thread_lock);
}
fn stop_and_wait_current_download(&self) {
fn stop_and_wait_current_download(&self) -> bool {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
@ -136,8 +136,10 @@ impl DownloadManagerBuilder {
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
return current_download_thread.join().is_ok();
};
true
}
fn manage_queue(mut self) -> Result<(), ()> {
@ -254,12 +256,16 @@ impl DownloadManagerBuilder {
}
};
// If the download gets cancelled
// If the download gets canceled
// immediately return, on_cancelled gets called for us earlier
if !download_result {
return;
}
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
let validate_result = match download_agent.validate(&app_handle) {
Ok(v) => v,
Err(e) => {
@ -274,6 +280,10 @@ impl DownloadManagerBuilder {
}
};
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
if validate_result {
download_agent.on_complete(&app_handle);
sender
@ -315,6 +325,7 @@ impl DownloadManagerBuilder {
self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata());
self.push_ui_queue_update();
}
self.set_status(DownloadManagerStatus::Error);
}

View File

@ -1,8 +1,8 @@
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
},
time::{Duration, Instant},
};
@ -23,7 +23,7 @@ pub struct ProgressObject {
//last_update: Arc<RwLock<Instant>>,
last_update_time: Arc<AtomicInstant>,
bytes_last_update: Arc<AtomicUsize>,
rolling: RollingProgressWindow<250>,
rolling: RollingProgressWindow<1>,
}
#[derive(Clone)]
@ -128,7 +128,7 @@ pub fn calculate_update(progress: &ProgressObject) {
.bytes_last_update
.swap(current_bytes_downloaded, Ordering::Acquire);
let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update;
let bytes_since_last_update = current_bytes_downloaded.saturating_sub(bytes_at_last_update);
let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1);

View File

@ -11,6 +11,7 @@ use super::remote_access_error::RemoteAccessError;
// TODO: Rename / separate from downloads
#[derive(Debug, SerializeDisplay)]
pub enum ApplicationDownloadError {
NotInitialized,
Communication(RemoteAccessError),
DiskFull(u64, u64),
Checksum,
@ -22,6 +23,7 @@ pub enum ApplicationDownloadError {
impl Display for ApplicationDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApplicationDownloadError::NotInitialized => write!(f, "Download not initalized, did something go wrong?"),
ApplicationDownloadError::DiskFull(required, available) => write!(
f,
"Game requires {}, {} remaining left on disk.",
@ -39,7 +41,7 @@ impl Display for ApplicationDownloadError {
ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"),
ApplicationDownloadError::DownloadError => write!(
f,
"download failed. See Download Manager status for specific error"
"Download failed. See Download Manager status for specific error"
),
}
}

View File

@ -1,109 +1,96 @@
use serde_json::json;
use url::Url;
use crate::{
DB,
database::db::DatabaseImpls,
error::remote_access_error::RemoteAccessError,
remote::{auth::generate_authorization_header, requests::make_request, utils::DROP_CLIENT_SYNC},
remote::{
auth::generate_authorization_header,
requests::{generate_url, make_authenticated_get},
utils::DROP_CLIENT_ASYNC,
},
};
use super::collection::{Collection, Collections};
#[tauri::command]
pub fn fetch_collections() -> Result<Collections, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/collection"], &[], |r| {
r.header("Authorization", generate_authorization_header())
})?
.send()?;
pub async fn fetch_collections() -> Result<Collections, RemoteAccessError> {
let response =
make_authenticated_get(generate_url(&["/api/v1/client/collection"], &[])?).await?;
Ok(response.json()?)
Ok(response.json().await?)
}
#[tauri::command]
pub fn fetch_collection(collection_id: String) -> Result<Collection, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
pub async fn fetch_collection(collection_id: String) -> Result<Collection, RemoteAccessError> {
let response = make_authenticated_get(generate_url(
&["/api/v1/client/collection/", &collection_id],
&[],
|r| r.header("Authorization", generate_authorization_header()),
)?
.send()?;
)?)
.await?;
Ok(response.json()?)
Ok(response.json().await?)
}
#[tauri::command]
pub fn create_collection(name: String) -> Result<Collection, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let base_url = DB.fetch_base_url();
let base_url = Url::parse(&format!("{base_url}api/v1/client/collection/"))?;
pub async fn create_collection(name: String) -> Result<Collection, RemoteAccessError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection"], &[])?;
let response = client
.post(base_url)
.post(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"name": name}))
.send()?;
.send()
.await?;
Ok(response.json()?)
Ok(response.json().await?)
}
#[tauri::command]
pub fn add_game_to_collection(
pub async fn add_game_to_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let url = Url::parse(&format!(
"{}api/v1/client/collection/{}/entry/",
DB.fetch_base_url(),
collection_id
))?;
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection", &collection_id, "entry"], &[])?;
client
.post(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()?;
.send()
.await?;
Ok(())
}
#[tauri::command]
pub fn delete_collection(collection_id: String) -> Result<bool, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let base_url = Url::parse(&format!(
"{}api/v1/client/collection/{}",
DB.fetch_base_url(),
collection_id
))?;
pub async fn delete_collection(collection_id: String) -> Result<bool, RemoteAccessError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection", &collection_id], &[])?;
let response = client
.delete(base_url)
.delete(url)
.header("Authorization", generate_authorization_header())
.send()?;
.send()
.await?;
Ok(response.json()?)
Ok(response.json().await?)
}
#[tauri::command]
pub fn delete_game_in_collection(
pub async fn delete_game_in_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let base_url = Url::parse(&format!(
"{}api/v1/client/collection/{}/entry",
DB.fetch_base_url(),
collection_id
))?;
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection", &collection_id, "entry"], &[])?;
client
.delete(base_url)
.delete(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()?;
.send().await?;
Ok(())
}

View File

@ -18,28 +18,28 @@ use crate::{
use super::{
library::{
FetchGameStruct, Game, fetch_game_logic, fetch_game_verion_options_logic,
FetchGameStruct, Game, fetch_game_logic, fetch_game_version_options_logic,
fetch_library_logic,
},
state::{GameStatusManager, GameStatusWithTransient},
};
#[tauri::command]
pub fn fetch_library(
state: tauri::State<'_, Mutex<AppState>>,
pub async fn fetch_library(
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<Game>, RemoteAccessError> {
offline!(
state,
fetch_library_logic,
fetch_library_logic_offline,
state
)
).await
}
#[tauri::command]
pub fn fetch_game(
pub async fn fetch_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
offline!(
state,
@ -47,7 +47,7 @@ pub fn fetch_game(
fetch_game_logic_offline,
game_id,
state
)
).await
}
#[tauri::command]
@ -68,9 +68,9 @@ pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), Libr
}
#[tauri::command]
pub fn fetch_game_verion_options(
pub async fn fetch_game_version_options(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<GameVersion>, RemoteAccessError> {
fetch_game_verion_options_logic(game_id, state)
fetch_game_version_options_logic(game_id, state).await
}

View File

@ -3,44 +3,47 @@ use std::{
sync::{Arc, Mutex},
};
use crate::{
database::{db::borrow_db_checked, models::data::GameDownloadStatus},
download_manager::
downloadable::Downloadable
,
error::application_download_error::ApplicationDownloadError,
AppState,
database::{
db::borrow_db_checked,
models::data::GameDownloadStatus,
},
download_manager::downloadable::Downloadable,
error::application_download_error::ApplicationDownloadError,
};
use super::download_agent::GameDownloadAgent;
#[tauri::command]
pub fn download_game(
pub async fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<(), ApplicationDownloadError> {
let sender = state.lock().unwrap().download_manager.get_sender();
let game_download_agent = GameDownloadAgent::new_from_index(
game_id,
game_version,
install_dir,
sender,
)?;
let game_download_agent = Arc::new(Box::new(game_download_agent) as Box<dyn Downloadable + Send + Sync>);
let sender = { state.lock().unwrap().download_manager.get_sender().clone() };
let game_download_agent =
GameDownloadAgent::new_from_index(game_id.clone(), game_version.clone(), install_dir, sender).await?;
let game_download_agent =
Arc::new(Box::new(game_download_agent) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent).unwrap();
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent.clone())
.unwrap();
Ok(())
}
#[tauri::command]
pub fn resume_download(
pub async fn resume_download(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<(), ApplicationDownloadError> {
let s = borrow_db_checked()
.applications
@ -62,17 +65,21 @@ pub fn resume_download(
let sender = state.lock().unwrap().download_manager.get_sender();
let parent_dir: PathBuf = install_dir.into();
let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new(
game_id,
version_name.clone(),
parent_dir.parent().unwrap().to_path_buf(),
sender,
)?) as Box<dyn Downloadable + Send + Sync>);
let game_download_agent = Arc::new(Box::new(
GameDownloadAgent::new(
game_id,
version_name.clone(),
parent_dir.parent().unwrap().to_path_buf(),
sender,
)
.await?,
) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent).unwrap();
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent)
.unwrap();
Ok(())
}

View File

@ -11,13 +11,15 @@ use crate::download_manager::util::download_thread_control_flag::{
use crate::download_manager::util::progress_object::{ProgressHandle, ProgressObject};
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::games::downloads::manifest::{
DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody,
};
use crate::games::downloads::validate::validate_game_chunk;
use crate::games::library::{on_game_complete, push_game_update, set_partially_installed};
use crate::games::state::GameStatusManager;
use crate::process::utils::get_disk_available;
use crate::remote::requests::make_request;
use crate::remote::utils::DROP_CLIENT_SYNC;
use crate::remote::requests::generate_url;
use crate::remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
use log::{debug, error, info, warn};
use rayon::ThreadPoolBuilder;
use std::collections::HashMap;
@ -31,16 +33,18 @@ use tauri::{AppHandle, Emitter};
#[cfg(target_os = "linux")]
use rustix::fs::{FallocateFlags, fallocate};
use super::download_logic::download_game_chunk;
use super::download_logic::download_game_bucket;
use super::drop_data::DropData;
static RETRY_COUNT: usize = 3;
const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000;
pub struct GameDownloadAgent {
pub id: String,
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Mutex<Vec<DropDownloadContext>>,
buckets: Mutex<Vec<DownloadBucket>>,
context_map: Mutex<HashMap<String, bool>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
@ -50,19 +54,21 @@ pub struct GameDownloadAgent {
}
impl GameDownloadAgent {
pub fn new_from_index(
pub async fn new_from_index(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Result<Self, ApplicationDownloadError> {
let db_lock = borrow_db_checked();
let base_dir = db_lock.applications.install_dirs[target_download_dir].clone();
drop(db_lock);
let base_dir = {
let db_lock = borrow_db_checked();
Self::new(id, version, base_dir, sender)
db_lock.applications.install_dirs[target_download_dir].clone()
};
Self::new(id, version, base_dir, sender).await
}
pub fn new(
pub async fn new(
id: String,
version: String,
base_dir: PathBuf,
@ -82,7 +88,7 @@ impl GameDownloadAgent {
version,
control_flag,
manifest: Mutex::new(None),
contexts: Mutex::new(Vec::new()),
buckets: Mutex::new(Vec::new()),
context_map: Mutex::new(HashMap::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
@ -90,7 +96,7 @@ impl GameDownloadAgent {
status: Mutex::new(DownloadStatus::Queued),
};
result.ensure_manifest_exists()?;
result.ensure_manifest_exists().await?;
let required_space = result
.manifest
@ -100,8 +106,7 @@ impl GameDownloadAgent {
.unwrap()
.values()
.map(|e| e.lengths.iter().sum::<usize>())
.sum::<usize>()
as u64;
.sum::<usize>() as u64;
let available_space = get_disk_available(data_base_dir_path)? as u64;
@ -117,26 +122,25 @@ impl GameDownloadAgent {
// Blocking
pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> {
self.ensure_manifest_exists()?;
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
// Don't use GameStatusManager because this game isn't installed
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
self.ensure_contexts()?;
if !self.check_manifest_exists() {
return Err(ApplicationDownloadError::NotInitialized);
}
self.ensure_buckets()?;
self.control_flag.set(DownloadThreadControlFlag::Go);
let mut db_lock = borrow_db_mut_checked();
db_lock.applications.transient_statuses.insert(
self.metadata(),
ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
},
);
push_game_update(
app_handle,
&self.metadata().id,
None,
GameStatusManager::fetch_state(&self.metadata().id, &db_lock),
);
Ok(())
}
@ -147,9 +151,7 @@ impl GameDownloadAgent {
info!("beginning download for {}...", self.metadata().id);
let res = self
.run()
.map_err(|()| ApplicationDownloadError::DownloadError);
let res = self.run().map_err(ApplicationDownloadError::Communication);
debug!(
"{} took {}ms to download",
@ -159,37 +161,43 @@ impl GameDownloadAgent {
res
}
pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
pub fn check_manifest_exists(&self) -> bool {
self.manifest.lock().unwrap().is_some()
}
pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
self.download_manifest()
self.download_manifest().await
}
fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
async fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(
&["/api/v1/client/game/manifest"],
&[("id", &self.id), ("version", &self.version)],
|f| f.header("Authorization", header),
)
.map_err(ApplicationDownloadError::Communication)?
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
.map_err(ApplicationDownloadError::Communication)?;
let response = client
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().unwrap(),
response.text().await.unwrap(),
),
));
}
let manifest_download: DropManifest = response.json().unwrap();
let manifest_download: DropManifest = response.json().await.unwrap();
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
@ -201,20 +209,23 @@ impl GameDownloadAgent {
// Sets it up for both download and validate
fn setup_progress(&self) {
let contexts = self.contexts.lock().unwrap();
let buckets = self.buckets.lock().unwrap();
let length = contexts.len();
let chunk_count = buckets.iter().map(|e| e.drops.len()).sum();
let chunk_count = contexts.iter().map(|chunk| chunk.length).sum();
let total_length = buckets
.iter()
.map(|bucket| bucket.drops.iter().map(|e| e.length).sum::<usize>())
.sum();
self.progress.set_max(chunk_count);
self.progress.set_size(length);
self.progress.set_max(total_length);
self.progress.set_size(chunk_count);
self.progress.reset();
}
pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> {
if self.contexts.lock().unwrap().is_empty() {
self.generate_contexts()?;
pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> {
if self.buckets.lock().unwrap().is_empty() {
self.generate_buckets()?;
}
*self.context_map.lock().unwrap() = self.dropdata.get_contexts();
@ -222,14 +233,22 @@ impl GameDownloadAgent {
Ok(())
}
pub fn generate_contexts(&self) -> Result<(), ApplicationDownloadError> {
pub fn generate_buckets(&self) -> Result<(), ApplicationDownloadError> {
let manifest = self.manifest.lock().unwrap().clone().unwrap();
let game_id = self.id.clone();
let mut contexts = Vec::new();
let base_path = Path::new(&self.dropdata.base_path);
create_dir_all(base_path).unwrap();
let mut buckets = Vec::new();
let mut current_bucket = DownloadBucket {
game_id: game_id.clone(),
version: self.version.clone(),
drops: Vec::new(),
};
let mut current_bucket_size = 0;
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
@ -244,42 +263,79 @@ impl GameDownloadAgent {
.truncate(false)
.open(path.clone())
.unwrap();
let mut running_offset = 0;
let mut file_running_offset = 0;
for (index, length) in chunk.lengths.iter().enumerate() {
contexts.push(DropDownloadContext {
file_name: raw_path.to_string(),
version: chunk.version_name.to_string(),
offset: running_offset,
index,
game_id: game_id.to_string(),
path: path.clone(),
checksum: chunk.checksums[index].clone(),
let drop = DownloadDrop {
filename: raw_path.to_string(),
start: file_running_offset,
length: *length,
checksum: chunk.checksums[index].clone(),
permissions: chunk.permissions,
});
running_offset += *length as u64;
path: path.clone(),
index,
};
file_running_offset += *length;
if *length >= TARGET_BUCKET_SIZE {
// They get their own bucket
buckets.push(DownloadBucket {
game_id: game_id.clone(),
version: self.version.clone(),
drops: vec![drop],
});
continue;
}
if current_bucket_size + *length >= TARGET_BUCKET_SIZE
&& !current_bucket.drops.is_empty()
{
// Move current bucket into list and make a new one
buckets.push(current_bucket);
current_bucket = DownloadBucket {
game_id: game_id.clone(),
version: self.version.clone(),
drops: Vec::new(),
};
current_bucket_size = 0;
}
current_bucket.drops.push(drop);
current_bucket_size += *length;
}
#[cfg(target_os = "linux")]
if running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
if file_running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, file_running_offset as u64);
}
}
let existing_contexts = self.dropdata.get_completed_contexts();
if !current_bucket.drops.is_empty() {
buckets.push(current_bucket);
}
info!("buckets: {}", buckets.len());
let existing_contexts = self.dropdata.get_contexts();
self.dropdata.set_contexts(
&contexts
&buckets
.iter()
.map(|x| (x.checksum.clone(), existing_contexts.contains(&x.checksum)))
.flat_map(|x| x.drops.iter().map(|v| v.checksum.clone()))
.map(|x| {
let contains = existing_contexts.get(&x).unwrap_or(&false);
(x, *contains)
})
.collect::<Vec<(String, bool)>>(),
);
*self.contexts.lock().unwrap() = contexts;
*self.buckets.lock().unwrap() = buckets;
Ok(())
}
fn run(&self) -> Result<bool, ()> {
fn run(&self) -> Result<bool, RemoteAccessError> {
self.setup_progress();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
@ -295,78 +351,81 @@ impl GameDownloadAgent {
let completed_contexts = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_contexts.clone();
let contexts = self.contexts.lock().unwrap();
let download_context = DROP_CLIENT_SYNC
.post(generate_url(&["/api/v2/client/context"], &[]).unwrap())
.json(&ManifestBody {
game: self.id.clone(),
version: self.version.clone(),
})
.header("Authorization", generate_authorization_header())
.send()?;
if download_context.status() != 200 {
return Err(RemoteAccessError::InvalidResponse(download_context.json()?));
}
let download_context = &download_context.json::<DownloadContext>()?;
info!("download context: {}", download_context.context);
let buckets = self.buckets.lock().unwrap();
pool.scope(|scope| {
let client = &DROP_CLIENT_SYNC.clone();
let context_map = self.context_map.lock().unwrap();
for (index, context) in contexts.iter().enumerate() {
let client = client.clone();
let completed_indexes = completed_indexes_loop_arc.clone();
for (index, bucket) in buckets.iter().enumerate() {
let mut bucket = (*bucket).clone();
let completed_contexts = completed_indexes_loop_arc.clone();
let progress = self.progress.get(index);
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
// Note to future DecDuck, DropData gets loaded into context_map
if let Some(v) = context_map.get(&context.checksum)
&& *v
{
progress_handle.skip(context.length);
let todo_drops = bucket
.drops
.into_iter()
.filter(|e| {
let todo = !*context_map.get(&e.checksum).unwrap_or(&false);
if !todo {
progress_handle.skip(e.length);
}
todo
})
.collect::<Vec<DownloadDrop>>();
if todo_drops.is_empty() {
continue;
}
};
bucket.drops = todo_drops;
let sender = self.sender.clone();
let request = match make_request(
&client,
&["/api/v1/client/chunk"],
&[
("id", &context.game_id),
("version", &context.version),
("name", &context.file_name),
("chunk", &context.index.to_string()),
],
|r| r,
) {
Ok(request) => request,
Err(e) => {
sender
.send(DownloadManagerSignal::Error(
ApplicationDownloadError::Communication(e),
))
.unwrap();
continue;
}
};
scope.spawn(move |_| {
// 3 attempts
for i in 0..RETRY_COUNT {
let loop_progress_handle = progress_handle.clone();
match download_game_chunk(
context,
match download_game_bucket(
&bucket,
download_context,
&self.control_flag,
loop_progress_handle,
request.try_clone().unwrap(),
) {
Ok(true) => {
completed_indexes.push(context.checksum.clone());
for drop in bucket.drops {
completed_contexts.push(drop.checksum);
}
return;
}
Ok(false) => return,
Err(e) => {
warn!("game download agent error: {e}");
let retry = match &e {
ApplicationDownloadError::Communication(
_remote_access_error,
) => true,
ApplicationDownloadError::Checksum => true,
ApplicationDownloadError::Lock => true,
ApplicationDownloadError::IoError(_error_kind) => false,
ApplicationDownloadError::DownloadError => false,
ApplicationDownloadError::DiskFull(_, _) => false,
};
let retry = matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
);
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
@ -390,14 +449,14 @@ impl GameDownloadAgent {
context_map_lock.values().filter(|x| **x).count()
};
let context_map_lock = self.context_map.lock().unwrap();
let contexts = contexts
let contexts = buckets
.iter()
.flat_map(|x| x.drops.iter().map(|e| e.checksum.clone()))
.map(|x| {
(
x.checksum.clone(),
context_map_lock.get(&x.checksum).copied().unwrap_or(false),
)
let completed = context_map_lock.get(&x).unwrap_or(&false);
(x, *completed)
})
.collect::<Vec<(String, bool)>>();
drop(context_map_lock);
@ -408,10 +467,11 @@ impl GameDownloadAgent {
// If there are any contexts left which are false
if !contexts.iter().all(|x| x.1) {
info!(
"download agent for {} exited without completing ({}/{})",
"download agent for {} exited without completing ({}/{}) ({} buckets)",
self.id.clone(),
completed_lock_len,
contexts.len(),
buckets.len()
);
return Ok(false);
}
@ -442,13 +502,15 @@ impl GameDownloadAgent {
pub fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
self.setup_validate(app_handle);
let contexts = self.contexts.lock().unwrap();
let buckets = self.buckets.lock().unwrap();
let contexts: Vec<DropValidateContext> = buckets
.clone()
.into_iter()
.flat_map(|e| -> Vec<DropValidateContext> { e.into() })
.collect();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
debug!(
"validating game: {} with {} threads",
self.dropdata.game_id, max_download_threads
);
info!("{} validation contexts", contexts.len());
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
@ -549,6 +611,13 @@ impl Downloadable for GameDownloadAgent {
.applications
.transient_statuses
.remove(&self.metadata());
push_game_update(
app_handle,
&self.id,
None,
GameStatusManager::fetch_state(&self.id, &handle),
);
}
fn on_complete(&self, app_handle: &tauri::AppHandle) {

View File

@ -5,13 +5,15 @@ use crate::download_manager::util::progress_object::ProgressHandle;
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::drop_server_error::DropServerError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::DropDownloadContext;
use crate::games::downloads::manifest::{ChunkBody, DownloadBucket, DownloadContext, DownloadDrop};
use crate::remote::auth::generate_authorization_header;
use log::{debug, warn};
use crate::remote::requests::generate_url;
use crate::remote::utils::DROP_CLIENT_SYNC;
use log::{info, warn};
use md5::{Context, Digest};
use reqwest::blocking::{RequestBuilder, Response};
use reqwest::blocking::Response;
use std::fs::{set_permissions, Permissions};
use std::fs::{Permissions, set_permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
@ -21,21 +23,29 @@ use std::{
path::PathBuf,
};
static MAX_PACKET_LENGTH: usize = 4096 * 4;
pub struct DropWriter<W: Write> {
hasher: Context,
destination: W,
destination: BufWriter<W>,
progress: ProgressHandle,
}
impl DropWriter<File> {
fn new(path: PathBuf) -> Self {
let destination = OpenOptions::new().write(true).create(true).truncate(false).open(&path).unwrap();
Self {
destination,
fn new(path: PathBuf, progress: ProgressHandle) -> Result<Self, io::Error> {
let destination = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
Ok(Self {
destination: BufWriter::with_capacity(1024 * 1024, destination),
hasher: Context::new(),
}
progress,
})
}
fn finish(mut self) -> io::Result<Digest> {
self.flush().unwrap();
self.flush()?;
Ok(self.hasher.compute())
}
}
@ -45,7 +55,10 @@ impl Write for DropWriter<File> {
self.hasher
.write_all(buf)
.map_err(|e| io::Error::other(format!("Unable to write to hasher: {e}")))?;
self.destination.write(buf)
let bytes_written = self.destination.write(buf)?;
self.progress.add(bytes_written);
Ok(bytes_written)
}
fn flush(&mut self) -> io::Result<()> {
@ -62,91 +75,103 @@ impl Seek for DropWriter<File> {
pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub source: R,
pub destination: DropWriter<W>,
pub drops: Vec<DownloadDrop>,
pub destination: Vec<DropWriter<W>>,
pub control_flag: &'a DownloadThreadControl,
pub progress: ProgressHandle,
pub size: usize,
}
impl<'a> DropDownloadPipeline<'a, Response, File> {
fn new(
source: Response,
destination: DropWriter<File>,
drops: Vec<DownloadDrop>,
control_flag: &'a DownloadThreadControl,
progress: ProgressHandle,
size: usize,
) -> Self {
Self {
) -> Result<Self, io::Error> {
Ok(Self {
source,
destination,
destination: drops
.iter()
.map(|drop| DropWriter::new(drop.path.clone(), progress.clone()))
.try_collect()?,
drops,
control_flag,
progress,
size,
}
})
}
fn copy(&mut self) -> Result<bool, io::Error> {
let copy_buf_size = 512;
let mut copy_buf = vec![0; copy_buf_size];
let mut buf_writer = BufWriter::with_capacity(1024 * 1024, &mut self.destination);
let mut copy_buffer = [0u8; MAX_PACKET_LENGTH];
for (index, drop) in self.drops.iter().enumerate() {
let destination = self
.destination
.get_mut(index)
.ok_or(io::Error::other("no destination"))
.unwrap();
let mut remaining = drop.length;
if drop.start != 0 {
destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?;
}
loop {
let size = MAX_PACKET_LENGTH.min(remaining);
self.source.read_exact(&mut copy_buffer[0..size])?;
remaining -= size;
destination.write_all(&copy_buffer[0..size])?;
if remaining == 0 {
break;
};
}
let mut current_size = 0;
loop {
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
buf_writer.flush()?;
return Ok(false);
}
let mut bytes_read = self.source.read(&mut copy_buf)?;
current_size += bytes_read;
if current_size > self.size {
let over = current_size - self.size;
warn!("server sent too many bytes... {over} over");
bytes_read -= over;
current_size = self.size;
}
buf_writer.write_all(&copy_buf[0..bytes_read])?;
self.progress.add(bytes_read);
if current_size >= self.size {
debug!(
"finished with final size of {} vs {}",
current_size, self.size
);
break;
}
}
buf_writer.flush()?;
Ok(true)
}
fn finish(self) -> Result<Digest, io::Error> {
let checksum = self.destination.finish()?;
Ok(checksum)
fn finish(self) -> Result<Vec<Digest>, io::Error> {
let checksums = self
.destination
.into_iter()
.map(|e| e.finish())
.try_collect()?;
Ok(checksums)
}
}
pub fn download_game_chunk(
ctx: &DropDownloadContext,
pub fn download_game_bucket(
bucket: &DownloadBucket,
ctx: &DownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
request: RequestBuilder,
) -> Result<bool, ApplicationDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let response = request
.header("Authorization", generate_authorization_header())
let header = generate_authorization_header();
let url = generate_url(&["/api/v2/client/chunk"], &[])
.map_err(ApplicationDownloadError::Communication)?;
let body = ChunkBody::create(ctx, &bucket.drops);
let response = DROP_CLIENT_SYNC
.post(url)
.json(&body)
.header("Authorization", header)
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
debug!("chunk request got status code: {}", response.status());
let raw_res = response.text().unwrap();
info!("chunk request got status code: {}", response.status());
let raw_res = response.text().map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into()))
})?;
info!("{}", raw_res);
if let Ok(err) = serde_json::from_str::<DropServerError>(&raw_res) {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(err),
@ -157,30 +182,35 @@ pub fn download_game_chunk(
));
}
let mut destination = DropWriter::new(ctx.path.clone());
let lengths = response
.headers()
.get("Content-Lengths")
.ok_or(ApplicationDownloadError::Communication(
RemoteAccessError::UnparseableResponse("missing Content-Lengths header".to_owned()),
))?
.to_str()
.unwrap();
if ctx.offset != 0 {
destination
.seek(SeekFrom::Start(ctx.offset))
.expect("Failed to seek to file offset");
}
let content_length = response.content_length();
if content_length.is_none() {
warn!("recieved 0 length content from server");
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(response.json().unwrap()),
));
}
let length = content_length.unwrap().try_into().unwrap();
if length != ctx.length {
return Err(ApplicationDownloadError::DownloadError);
for (i, raw_length) in lengths.split(",").enumerate() {
let length = raw_length.parse::<usize>().unwrap_or(0);
let Some(drop) = bucket.drops.get(i) else {
warn!("invalid number of Content-Lengths recieved: {}, {}", i, lengths);
return Err(ApplicationDownloadError::DownloadError);
};
if drop.length != length {
warn!(
"for {}, expected {}, got {} ({})",
drop.filename, drop.length, raw_length, length
);
return Err(ApplicationDownloadError::DownloadError);
}
}
let mut pipeline =
DropDownloadPipeline::new(response, destination, control_flag, progress, length);
DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress)
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
let completed = pipeline
.copy()
@ -192,23 +222,23 @@ pub fn download_game_chunk(
// If we complete the file, set the permissions (if on Linux)
#[cfg(unix)]
{
let permissions = Permissions::from_mode(ctx.permissions);
set_permissions(ctx.path.clone(), permissions).unwrap();
for drop in bucket.drops.iter() {
let permissions = Permissions::from_mode(drop.permissions);
set_permissions(drop.path.clone(), permissions)
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
}
}
let checksum = pipeline
let checksums = pipeline
.finish()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
let res = hex::encode(checksum.0);
if res != ctx.checksum {
return Err(ApplicationDownloadError::Checksum);
for (index, drop) in bucket.drops.iter().enumerate() {
let res = hex::encode(**checksums.get(index).unwrap());
if res != drop.checksum {
return Err(ApplicationDownloadError::Checksum);
}
}
debug!(
"Successfully finished download #{}, copied {} bytes",
ctx.checksum, length
);
Ok(true)
}

View File

@ -76,14 +76,6 @@ impl DropData {
pub fn set_context(&self, context: String, state: bool) {
self.contexts.lock().unwrap().entry(context).insert_entry(state);
}
pub fn get_completed_contexts(&self) -> Vec<String> {
self.contexts
.lock()
.unwrap()
.iter()
.filter_map(|x| if *x.1 { Some(x.0.clone()) } else { None })
.collect()
}
pub fn get_contexts(&self) -> HashMap<String, bool> {
self.contexts.lock().unwrap().clone()
}

View File

@ -2,6 +2,65 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone)]
// Drops go in buckets
pub struct DownloadDrop {
pub index: usize,
pub filename: String,
pub path: PathBuf,
pub start: usize,
pub length: usize,
pub checksum: String,
pub permissions: u32,
}
#[derive(Debug, Clone)]
pub struct DownloadBucket {
pub game_id: String,
pub version: String,
pub drops: Vec<DownloadDrop>,
}
#[derive(Deserialize)]
pub struct DownloadContext {
pub context: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChunkBodyFile {
filename: String,
chunk_index: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChunkBody {
pub context: String,
pub files: Vec<ChunkBodyFile>,
}
#[derive(Serialize)]
pub struct ManifestBody {
pub game: String,
pub version: String,
}
impl ChunkBody {
pub fn create(context: &DownloadContext, drops: &[DownloadDrop]) -> ChunkBody {
Self {
context: context.context.clone(),
files: drops
.iter()
.map(|e| ChunkBodyFile {
filename: e.filename.clone(),
chunk_index: e.index,
})
.collect(),
}
}
}
pub type DropManifest = HashMap<String, DropChunk>;
#[derive(Serialize, Deserialize, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
@ -14,14 +73,26 @@ pub struct DropChunk {
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DropDownloadContext {
pub file_name: String,
pub version: String,
pub struct DropValidateContext {
pub index: usize,
pub offset: u64,
pub game_id: String,
pub offset: usize,
pub path: PathBuf,
pub checksum: String,
pub length: usize,
pub permissions: u32,
}
impl From<DownloadBucket> for Vec<DropValidateContext> {
fn from(value: DownloadBucket) -> Self {
value
.drops
.into_iter()
.map(|e| DropValidateContext {
index: e.index,
offset: e.start,
path: e.path,
checksum: e.checksum,
length: e.length,
})
.collect()
}
}

View File

@ -7,24 +7,22 @@ use log::debug;
use md5::Context;
use crate::{
download_manager::
util::{
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressHandle,
}
,
download_manager::util::{
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressHandle,
},
error::application_download_error::ApplicationDownloadError,
games::downloads::manifest::DropDownloadContext,
games::downloads::manifest::DropValidateContext,
};
pub fn validate_game_chunk(
ctx: &DropDownloadContext,
ctx: &DropValidateContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, ApplicationDownloadError> {
debug!(
"Starting chunk validation {}, {}, {} #{}",
ctx.file_name, ctx.index, ctx.offset, ctx.checksum
ctx.path.display(), ctx.index, ctx.offset, ctx.checksum
);
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
@ -38,7 +36,7 @@ pub fn validate_game_chunk(
if ctx.offset != 0 {
source
.seek(SeekFrom::Start(ctx.offset))
.seek(SeekFrom::Start(ctx.offset.try_into().unwrap()))
.expect("Failed to seek to file offset");
}

View File

@ -20,7 +20,8 @@ use crate::games::state::{GameStatusManager, GameStatusWithTransient};
use crate::remote::auth::generate_authorization_header;
use crate::remote::cache::cache_object_db;
use crate::remote::cache::{cache_object, get_cached_object, get_cached_object_db};
use crate::remote::requests::make_request;
use crate::remote::requests::generate_url;
use crate::remote::utils::DROP_CLIENT_ASYNC;
use crate::remote::utils::DROP_CLIENT_SYNC;
use bitcode::{Decode, Encode};
@ -76,24 +77,24 @@ pub struct StatsUpdateEvent {
pub time: usize,
}
pub fn fetch_library_logic(
state: tauri::State<'_, Mutex<AppState>>,
pub async fn fetch_library_logic(
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<Game>, RemoteAccessError> {
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/user/library"], &[], |f| {
f.header("Authorization", header)
})?
.send()?;
let client = DROP_CLIENT_ASYNC.clone();
let response = generate_url(&["/api/v1/client/user/library"], &[])?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header())
.send()
.await?;
if response.status() != 200 {
let err = response.json().unwrap();
let err = response.json().await.unwrap();
warn!("{err:?}");
return Err(RemoteAccessError::InvalidResponse(err));
}
let mut games: Vec<Game> = response.json()?;
let mut games: Vec<Game> = response.json().await?;
let mut handle = state.lock().unwrap();
@ -135,73 +136,89 @@ pub fn fetch_library_logic(
Ok(games)
}
pub fn fetch_library_logic_offline(
_state: tauri::State<'_, Mutex<AppState>>,
pub async fn fetch_library_logic_offline(
_state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<Game>, RemoteAccessError> {
let mut games: Vec<Game> = get_cached_object("library")?;
let db_handle = borrow_db_checked();
games.retain(|game| {
db_handle
.applications
.installed_game_version
.contains_key(&game.id)
matches!(
&db_handle
.applications
.game_statuses
.get(&game.id)
.unwrap_or(&GameDownloadStatus::Remote {}),
GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }
)
});
Ok(games)
}
pub fn fetch_game_logic(
pub async fn fetch_game_logic(
id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
let mut state_handle = state.lock().unwrap();
let version = {
let state_handle = state.lock().unwrap();
let db_lock = borrow_db_checked();
let db_lock = borrow_db_checked();
let metadata_option = db_lock.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => db_lock
.applications
.game_versions
.get(&metadata.id)
.map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap())
.cloned(),
};
let game = state_handle.games.get(&id);
if let Some(game) = game {
let status = GameStatusManager::fetch_state(&id, &db_lock);
let data = FetchGameStruct {
game: game.clone(),
status,
version,
let metadata_option = db_lock.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => db_lock
.applications
.game_versions
.get(&metadata.id)
.map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap())
.cloned(),
};
cache_object_db(&id, game, &db_lock)?;
let game = state_handle.games.get(&id);
if let Some(game) = game {
let status = GameStatusManager::fetch_state(&id, &db_lock);
return Ok(data);
}
drop(db_lock);
let data = FetchGameStruct {
game: game.clone(),
status,
version,
};
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/game/", &id], &[], |r| {
r.header("Authorization", generate_authorization_header())
})?
.send()?;
cache_object_db(&id, game, &db_lock)?;
return Ok(data);
}
version
};
let client = DROP_CLIENT_ASYNC.clone();
let response = generate_url(&["/api/v1/client/game/", &id], &[])?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header())
.send()
.await?;
if response.status() == 404 {
let offline_fetch = fetch_game_logic_offline(id.clone(), state).await;
if let Ok(fetch_data) = offline_fetch {
return Ok(fetch_data);
}
return Err(RemoteAccessError::GameNotFound(id));
}
if response.status() != 200 {
let err = response.json().unwrap();
let err = response.json().await.unwrap();
warn!("{err:?}");
return Err(RemoteAccessError::InvalidResponse(err));
}
let game: Game = response.json()?;
let game: Game = response.json().await?;
let mut state_handle = state.lock().unwrap();
state_handle.games.insert(id.clone(), game.clone());
let mut db_handle = borrow_db_mut_checked();
@ -227,24 +244,20 @@ pub fn fetch_game_logic(
Ok(data)
}
pub fn fetch_game_logic_offline(
pub async fn fetch_game_logic_offline(
id: String,
_state: tauri::State<'_, Mutex<AppState>>,
_state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
let db_handle = borrow_db_checked();
let metadata_option = db_handle.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => Some(
db_handle
.applications
.game_versions
.get(&metadata.id)
.unwrap()
.get(metadata.version.as_ref().unwrap())
.unwrap()
.clone(),
),
Some(metadata) => db_handle
.applications
.game_versions
.get(&metadata.id)
.map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap())
.cloned(),
};
let status = GameStatusManager::fetch_state(&id, &db_handle);
@ -259,27 +272,26 @@ pub fn fetch_game_logic_offline(
})
}
pub fn fetch_game_verion_options_logic(
pub async fn fetch_game_version_options_logic(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<GameVersion>, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let client = DROP_CLIENT_ASYNC.clone();
let response = make_request(
&client,
&["/api/v1/client/game/versions"],
&[("id", &game_id)],
|r| r.header("Authorization", generate_authorization_header()),
)?
.send()?;
let response = generate_url(&["/api/v1/client/game/versions"], &[("id", &game_id)])?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header())
.send()
.await?;
if response.status() != 200 {
let err = response.json().unwrap();
let err = response.json().await.unwrap();
warn!("{err:?}");
return Err(RemoteAccessError::InvalidResponse(err));
}
let data: Vec<GameVersion> = response.json()?;
let data: Vec<GameVersion> = response.json().await?;
let state_lock = state.lock().unwrap();
let process_manager_lock = state_lock.process_manager.lock().unwrap();
@ -440,19 +452,18 @@ pub fn on_game_complete(
return Err(RemoteAccessError::GameNotFound(meta.id.clone()));
}
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
let response = generate_url(
&["/api/v1/client/game/version"],
&[
("id", &meta.id),
("version", meta.version.as_ref().unwrap()),
],
|f| f.header("Authorization", header),
)?
.send()?;
)?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header())
.send()?;
let game_version: GameVersion = response.json()?;

View File

@ -1,5 +1,8 @@
#![deny(unused_must_use)]
#![feature(fn_traits)]
#![feature(duration_constructors)]
#![feature(duration_millis_float)]
#![feature(iterator_try_collect)]
#![deny(clippy::all)]
mod database;
@ -38,7 +41,7 @@ use games::collections::commands::{
fetch_collection, fetch_collections,
};
use games::commands::{
fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game,
fetch_game, fetch_game_status, fetch_game_version_options, fetch_library, uninstall_game,
};
use games::downloads::commands::download_game;
use games::library::{Game, update_game_configuration};
@ -134,7 +137,7 @@ pub struct AppState<'a> {
compat_info: Option<CompatInfo>,
}
fn setup(handle: AppHandle) -> AppState<'static> {
async fn setup(handle: AppHandle) -> AppState<'static> {
let logfile = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"{d} | {l} | {f}:{L} - {m}{n}",
@ -189,7 +192,7 @@ fn setup(handle: AppHandle) -> AppState<'static> {
debug!("database is set up");
// TODO: Account for possible failure
let (app_status, user) = auth::setup();
let (app_status, user) = auth::setup().await;
let db_handle = borrow_db_checked();
let mut missing_games = Vec::new();
@ -316,7 +319,7 @@ pub fn run() {
delete_download_dir,
fetch_download_dir_stats,
fetch_game_status,
fetch_game_verion_options,
fetch_game_version_options,
update_game_configuration,
// Collections
fetch_collections,
@ -348,92 +351,99 @@ pub fn run() {
))
.setup(|app| {
let handle = app.handle().clone();
let state = setup(handle);
debug!("initialized drop client");
app.manage(Mutex::new(state));
{
use tauri_plugin_deep_link::DeepLinkExt;
let _ = app.deep_link().register_all();
debug!("registered all pre-defined deep links");
}
tauri::async_runtime::block_on(async move {
let state = setup(handle).await;
info!("initialized drop client");
app.manage(Mutex::new(state));
let handle = app.handle().clone();
{
use tauri_plugin_deep_link::DeepLinkExt;
let _ = app.deep_link().register_all();
debug!("registered all pre-defined deep links");
}
let _main_window = tauri::WebviewWindowBuilder::new(
&handle,
"main", // BTW this is not the name of the window, just the label. Keep this 'main', there are permissions & configs that depend on it
tauri::WebviewUrl::App("main".into()),
)
.title("Drop Desktop App")
.min_inner_size(1000.0, 500.0)
.inner_size(1536.0, 864.0)
.decorations(false)
.shadow(false)
.data_directory(DATA_ROOT_DIR.join(".webview"))
.build()
.unwrap();
let handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
debug!("handling drop:// url");
let binding = event.urls();
let url = binding.first().unwrap();
if url.host_str().unwrap() == "handshake" {
recieve_handshake(handle.clone(), url.path().to_string());
let _main_window = tauri::WebviewWindowBuilder::new(
&handle,
"main", // BTW this is not the name of the window, just the label. Keep this 'main', there are permissions & configs that depend on it
tauri::WebviewUrl::App("main".into()),
)
.title("Drop Desktop App")
.min_inner_size(1000.0, 500.0)
.inner_size(1536.0, 864.0)
.decorations(false)
.shadow(false)
.data_directory(DATA_ROOT_DIR.join(".webview"))
.build()
.unwrap();
app.deep_link().on_open_url(move |event| {
debug!("handling drop:// url");
let binding = event.urls();
let url = binding.first().unwrap();
if url.host_str().unwrap() == "handshake" {
tauri::async_runtime::spawn(recieve_handshake(
handle.clone(),
url.path().to_string(),
));
}
});
let menu = Menu::with_items(
app,
&[
&MenuItem::with_id(app, "open", "Open", true, None::<&str>).unwrap(),
&PredefinedMenuItem::separator(app).unwrap(),
/*
&MenuItem::with_id(app, "show_library", "Library", true, None::<&str>)?,
&MenuItem::with_id(app, "show_settings", "Settings", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
*/
&MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap(),
],
)
.unwrap();
run_on_tray(|| {
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
app.webview_windows().get("main").unwrap().show().unwrap();
}
"quit" => {
cleanup_and_exit(app, &app.state());
}
_ => {
warn!("menu event not handled: {:?}", event.id);
}
})
.build(app)
.expect("error while setting up tray menu");
});
{
let mut db_handle = borrow_db_mut_checked();
if let Some(original) = db_handle.prev_database.take() {
warn!(
"Database corrupted. Original file at {}",
original.canonicalize().unwrap().to_string_lossy()
);
app.dialog()
.message(
"Database corrupted. A copy has been saved at: ".to_string()
+ original.to_str().unwrap(),
)
.title("Database corrupted")
.show(|_| {});
}
}
});
let menu = Menu::with_items(
app,
&[
&MenuItem::with_id(app, "open", "Open", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
/*
&MenuItem::with_id(app, "show_library", "Library", true, None::<&str>)?,
&MenuItem::with_id(app, "show_settings", "Settings", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
*/
&MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?,
],
)?;
run_on_tray(|| {
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
app.webview_windows().get("main").unwrap().show().unwrap();
}
"quit" => {
cleanup_and_exit(app, &app.state());
}
_ => {
warn!("menu event not handled: {:?}", event.id);
}
})
.build(app)
.expect("error while setting up tray menu");
});
{
let mut db_handle = borrow_db_mut_checked();
if let Some(original) = db_handle.prev_database.take() {
warn!(
"Database corrupted. Original file at {}",
original.canonicalize().unwrap().to_string_lossy()
);
app.dialog()
.message(
"Database corrupted. A copy has been saved at: ".to_string()
+ original.to_str().unwrap(),
)
.title("Database corrupted")
.show(|_| {});
}
}
Ok(())
})
.register_asynchronous_uri_scheme_protocol("object", move |_ctx, request, responder| {
@ -441,15 +451,21 @@ pub fn run() {
fetch_object(request, responder).await;
});
})
.register_asynchronous_uri_scheme_protocol("server", move |ctx, request, responder| {
let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state();
offline!(
state,
handle_server_proto,
handle_server_proto_offline,
request,
responder
);
.register_asynchronous_uri_scheme_protocol("server", |ctx, request, responder| {
tauri::async_runtime::block_on(async move {
let state = ctx
.app_handle()
.state::<tauri::State<'_, Mutex<AppState>>>();
offline!(
state,
handle_server_proto,
handle_server_proto_offline,
request,
responder
)
.await;
});
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {

View File

@ -12,12 +12,12 @@ use crate::{
database::{
db::{borrow_db_checked, borrow_db_mut_checked},
models::data::DatabaseAuth,
}, error::{drop_server_error::DropServerError, remote_access_error::RemoteAccessError}, remote::utils::DROP_CLIENT_SYNC, AppState, AppStatus, User
}, error::{drop_server_error::DropServerError, remote_access_error::RemoteAccessError}, remote::{requests::make_authenticated_get, utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC}}, AppState, AppStatus, User
};
use super::{
cache::{cache_object, get_cached_object},
requests::make_request,
requests::generate_url,
};
#[derive(Serialize)]
@ -61,16 +61,10 @@ pub fn generate_authorization_header() -> String {
format!("Nonce {} {} {}", certs.client_id, nonce, signature)
}
pub fn fetch_user() -> Result<User, RemoteAccessError> {
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/user"], &[], |f| {
f.header("Authorization", header)
})?
.send()?;
pub async fn fetch_user() -> Result<User, RemoteAccessError> {
let response = make_authenticated_get(generate_url(&["/api/v1/client/user"], &[])?).await?;
if response.status() != 200 {
let err: DropServerError = response.json()?;
let err: DropServerError = response.json().await?;
warn!("{err:?}");
if err.status_message == "Nonce expired" {
@ -80,10 +74,13 @@ pub fn fetch_user() -> Result<User, RemoteAccessError> {
return Err(RemoteAccessError::InvalidResponse(err));
}
response.json::<User>().map_err(std::convert::Into::into)
response
.json::<User>()
.await
.map_err(std::convert::Into::into)
}
fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAccessError> {
async fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAccessError> {
let path_chunks: Vec<&str> = path.split('/').collect();
if path_chunks.len() != 3 {
app.emit("auth/failed", ()).unwrap();
@ -105,13 +102,13 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
};
let endpoint = base_url.join("/api/v1/client/auth/handshake")?;
let client = DROP_CLIENT_SYNC.clone();
let response = client.post(endpoint).json(&body).send()?;
let client = DROP_CLIENT_ASYNC.clone();
let response = client.post(endpoint).json(&body).send().await?;
debug!("handshake responsded with {}", response.status().as_u16());
if !response.status().is_success() {
return Err(RemoteAccessError::InvalidResponse(response.json()?));
return Err(RemoteAccessError::InvalidResponse(response.json().await?));
}
let response_struct: HandshakeResponse = response.json()?;
let response_struct: HandshakeResponse = response.json().await?;
{
let mut handle = borrow_db_mut_checked();
@ -129,9 +126,10 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
.post(base_url.join("/api/v1/client/user/webtoken").unwrap())
.header("Authorization", header)
.send()
.await
.unwrap();
token.text().unwrap()
token.text().await.unwrap()
};
let mut handle = borrow_db_mut_checked();
@ -141,11 +139,11 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
Ok(())
}
pub fn recieve_handshake(app: AppHandle, path: String) {
pub async fn recieve_handshake(app: AppHandle, path: String) {
// Tell the app we're processing
app.emit("auth/processing", ()).unwrap();
let handshake_result = recieve_handshake_logic(&app, path);
let handshake_result = recieve_handshake_logic(&app, path).await;
if let Err(e) = handshake_result {
warn!("error with authentication: {e}");
app.emit("auth/failed", e.to_string()).unwrap();
@ -153,9 +151,10 @@ pub fn recieve_handshake(app: AppHandle, path: String) {
}
let app_state = app.state::<Mutex<AppState>>();
let mut state_lock = app_state.lock().unwrap();
let (app_status, user) = setup();
let (app_status, user) = setup().await;
let mut state_lock = app_state.lock().unwrap();
state_lock.status = app_status;
state_lock.user = user;
@ -199,13 +198,14 @@ pub fn auth_initiate_logic(mode: String) -> Result<String, RemoteAccessError> {
Ok(response)
}
pub fn setup() -> (AppStatus, Option<User>) {
let data = borrow_db_checked();
let auth = data.auth.clone();
drop(data);
pub async fn setup() -> (AppStatus, Option<User>) {
let auth = {
let data = borrow_db_checked();
data.auth.clone()
};
if auth.is_some() {
let user_result = match fetch_user() {
let user_result = match fetch_user().await {
Ok(data) => data,
Err(RemoteAccessError::FetchError(_)) => {
let user = get_cached_object::<User>("user").unwrap();

View File

@ -11,16 +11,16 @@ use crate::{
};
use bitcode::{Decode, DecodeOwned, Encode};
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
use log::debug;
#[macro_export]
macro_rules! offline {
($var:expr, $func1:expr, $func2:expr, $( $arg:expr ),* ) => {
if $crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == $crate::AppStatus::Offline {
$func2( $( $arg ), *)
async move { if $crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == $crate::AppStatus::Offline {
$func2( $( $arg ), *).await
} else {
$func1( $( $arg ), *)
$func1( $( $arg ), *).await
}
}
}
}
@ -68,18 +68,9 @@ pub fn get_cached_object_db<D: DecodeOwned>(
key: &str,
db: &Database,
) -> Result<D, RemoteAccessError> {
let start = SystemTime::now();
let bytes = read_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?;
let read = start.elapsed().unwrap();
let data =
bitcode::decode::<D>(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?;
let decode = start.elapsed().unwrap();
debug!(
"cache object took: r:{}, d:{}, b:{}",
read.as_millis(),
read.abs_diff(decode).as_millis(),
bytes.len()
);
Ok(data)
}
#[derive(Encode, Decode)]

View File

@ -8,11 +8,14 @@ use tauri::{AppHandle, Emitter, Manager};
use url::Url;
use crate::{
database::db::{borrow_db_checked, borrow_db_mut_checked}, error::remote_access_error::RemoteAccessError, remote::{
AppState, AppStatus,
database::db::{borrow_db_checked, borrow_db_mut_checked},
error::remote_access_error::RemoteAccessError,
remote::{
auth::generate_authorization_header,
requests::make_request,
requests::generate_url,
utils::{DROP_CLIENT_SYNC, DROP_CLIENT_WS_CLIENT},
}, AppState, AppStatus
},
};
use super::{
@ -45,10 +48,11 @@ pub fn gen_drop_url(path: String) -> Result<String, RemoteAccessError> {
#[tauri::command]
pub fn fetch_drop_object(path: String) -> Result<Vec<u8>, RemoteAccessError> {
let _drop_url = gen_drop_url(path.clone())?;
let req = make_request(&DROP_CLIENT_SYNC, &[&path], &[], |r| {
r.header("Authorization", generate_authorization_header())
})?
.send();
let req = generate_url(&[&path], &[])?;
let req = DROP_CLIENT_SYNC
.get(req)
.header("Authorization", generate_authorization_header())
.send();
match req {
Ok(data) => {
@ -83,13 +87,15 @@ pub fn sign_out(app: AppHandle) {
}
#[tauri::command]
pub fn retry_connect(state: tauri::State<'_, Mutex<AppState>>) {
let (app_status, user) = setup();
pub async fn retry_connect(state: tauri::State<'_, Mutex<AppState<'_>>>) -> Result<(), ()> {
let (app_status, user) = setup().await;
let mut guard = state.lock().unwrap();
guard.status = app_status;
guard.user = user;
drop(guard);
Ok(())
}
#[tauri::command]
@ -145,9 +151,7 @@ pub fn auth_initiate_code(app: AppHandle) -> Result<String, RemoteAccessError> {
match response.response_type.as_str() {
"token" => {
let recieve_app = app.clone();
tauri::async_runtime::spawn_blocking(move || {
manual_recieve_handshake(recieve_app, response.value);
});
manual_recieve_handshake(recieve_app, response.value).await.unwrap();
return Ok(());
}
_ => return Err(RemoteAccessError::HandshakeFailed(response.value)),
@ -171,6 +175,8 @@ pub fn auth_initiate_code(app: AppHandle) -> Result<String, RemoteAccessError> {
}
#[tauri::command]
pub fn manual_recieve_handshake(app: AppHandle, token: String) {
recieve_handshake(app, format!("handshake/{token}"));
pub async fn manual_recieve_handshake(app: AppHandle, token: String) -> Result<(), ()> {
recieve_handshake(app, format!("handshake/{token}")).await;
Ok(())
}

View File

@ -1,13 +1,16 @@
use reqwest::blocking::{Client, RequestBuilder};
use url::Url;
use crate::{database::db::DatabaseImpls, error::remote_access_error::RemoteAccessError, DB};
use crate::{
DB,
database::db::DatabaseImpls,
error::remote_access_error::RemoteAccessError,
remote::{auth::generate_authorization_header, utils::DROP_CLIENT_ASYNC},
};
pub fn make_request<T: AsRef<str>, F: FnOnce(RequestBuilder) -> RequestBuilder>(
client: &Client,
pub fn generate_url<T: AsRef<str>>(
path_components: &[T],
query: &[(T, T)],
f: F,
) -> Result<RequestBuilder, RemoteAccessError> {
) -> Result<Url, RemoteAccessError> {
let mut base_url = DB.fetch_base_url();
for endpoint in path_components {
base_url = base_url.join(endpoint.as_ref())?;
@ -18,6 +21,13 @@ pub fn make_request<T: AsRef<str>, F: FnOnce(RequestBuilder) -> RequestBuilder>(
queries.append_pair(param.as_ref(), val.as_ref());
}
}
let response = client.get(base_url);
Ok(f(response))
Ok(base_url)
}
pub async fn make_authenticated_get(url: Url) -> Result<reqwest::Response, reqwest::Error> {
DROP_CLIENT_ASYNC
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await
}

View File

@ -5,7 +5,7 @@ use tauri::UriSchemeResponder;
use crate::{database::db::borrow_db_checked, remote::utils::DROP_CLIENT_SYNC};
pub fn handle_server_proto_offline(_request: Request<Vec<u8>>, responder: UriSchemeResponder) {
pub async fn handle_server_proto_offline(_request: Request<Vec<u8>>, responder: UriSchemeResponder) {
let four_oh_four = Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Vec::new())
@ -13,7 +13,7 @@ pub fn handle_server_proto_offline(_request: Request<Vec<u8>>, responder: UriSch
responder.respond(four_oh_four);
}
pub fn handle_server_proto(request: Request<Vec<u8>>, responder: UriSchemeResponder) {
pub async fn handle_server_proto(request: Request<Vec<u8>>, responder: UriSchemeResponder) {
let db_handle = borrow_db_checked();
let web_token = match &db_handle.auth.as_ref().unwrap().web_token {
Some(e) => e,

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "Drop Desktop Client",
"version": "0.3.1",
"version": "0.3.2",
"identifier": "dev.drop.client",
"build": {
"beforeDevCommand": "yarn --cwd main dev --port 1432",