feat: Resume button and PartiallyInstalled status

Signed-off-by: quexeky <git@quexeky.dev>
This commit is contained in:
quexeky
2025-06-26 15:57:20 +10:00
parent 8dfb96406f
commit 6a3029473c
7 changed files with 260 additions and 156 deletions
+35 -61
View File
@@ -1,78 +1,52 @@
<template>
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<div class="inline-flex divide-x divide-zinc-900">
<button
type="button"
@click="() => buttonActions[props.status.type]()"
:class="[
styles[props.status.type],
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component
:is="buttonIcons[props.status.type]"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
<button type="button" @click="() => buttonActions[props.status.type]()" :class="[
styles[props.status.type],
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]">
<component :is="buttonIcons[props.status.type]" class="-mr-0.5 size-5" aria-hidden="true" />
{{ buttonNames[props.status.type] }}
</button>
<Menu
v-if="showDropdown"
as="div"
class="relative inline-block text-left grow"
>
<Menu v-if="showDropdown" as="div" class="relative inline-block text-left grow">
<div class="h-full">
<MenuButton
:class="[
styles[props.status.type],
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<MenuButton :class="[
styles[props.status.type],
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]">
<ChevronDownIcon class="size-5" aria-hidden="true" />
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
<MenuItems
class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none"
>
class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none">
<div class="py-1">
<MenuItem v-slot="{ active }">
<button
@click="() => emit('options')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]"
>
Options
<Cog6ToothIcon class="size-5" />
</button>
<button @click="() => emit('options')" :class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]">
Options
<Cog6ToothIcon class="size-5" />
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button
@click="() => emit('uninstall')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]"
>
Uninstall
<TrashIcon class="size-5" />
</button>
<button @click="() => emit('uninstall')" :class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]">
Uninstall
<TrashIcon class="size-5" />
</button>
</MenuItem>
</div>
</MenuItems>
@@ -164,7 +138,7 @@ const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.SetupRequired]: () => emit("launch"),
[GameStatusEnum.Installed]: () => emit("launch"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => {},
[GameStatusEnum.Uninstalling]: () => { },
[GameStatusEnum.Running]: () => emit("kill"),
[GameStatusEnum.PartiallyInstalled]: () => emit("resume")
};
@@ -250,7 +250,9 @@ impl DownloadManagerBuilder {
}
// Ok(false) is for incomplete but exited properly
Ok(false) => {
debug!("Donwload agent finished incomplete");
download_agent.on_incomplete(&app_handle);
}
Err(e) => {
error!("download {:?} has error {}", download_agent.metadata(), &e);
-1
View File
@@ -44,7 +44,6 @@ pub fn fetch_game(
game_id,
state
);
println!("Res: {:?}", &res);
res
}
+47 -73
View File
@@ -12,12 +12,15 @@ use crate::download_manager::util::progress_object::{ProgressHandle, ProgressObj
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::games::library::{on_game_complete, push_game_update, GameUpdateEvent};
use crate::games::library::{
on_game_complete, on_game_incomplete, push_game_update, GameUpdateEvent,
};
use crate::remote::requests::make_request;
use crate::DB;
use log::{debug, error, info};
use rayon::ThreadPoolBuilder;
use slice_deque::SliceDeque;
use std::collections::HashMap;
use std::fs::{create_dir_all, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
@@ -36,7 +39,7 @@ pub struct GameDownloadAgent {
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Mutex<Vec<DropDownloadContext>>,
completed_contexts: Mutex<SliceDeque<String>>,
context_map: Mutex<HashMap<String, bool>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
@@ -78,7 +81,7 @@ impl GameDownloadAgent {
control_flag,
manifest: Mutex::new(None),
contexts: Mutex::new(Vec::new()),
completed_contexts: Mutex::new(SliceDeque::new()),
context_map: Mutex::new(HashMap::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
stored_manifest,
@@ -183,11 +186,15 @@ impl GameDownloadAgent {
}
pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> {
if !self.contexts.lock().unwrap().is_empty() {
return Ok(());
if self.contexts.lock().unwrap().is_empty() {
self.generate_contexts()?;
}
self.generate_contexts()?;
self.context_map
.lock()
.unwrap()
.extend(self.stored_manifest.get_contexts());
Ok(())
}
@@ -199,13 +206,6 @@ impl GameDownloadAgent {
let base_path = Path::new(&self.stored_manifest.base_path);
create_dir_all(base_path).unwrap();
{
let mut completed_contexts_lock = self.completed_contexts.lock().unwrap();
completed_contexts_lock.clear();
completed_contexts_lock
.extend_from_slice(&self.stored_manifest.get_completed_contexts());
}
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
@@ -240,6 +240,14 @@ impl GameDownloadAgent {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
}
}
let existing_contexts = self.stored_manifest.get_completed_contexts();
self.stored_manifest.set_contexts(
&contexts
.iter()
.map(|x| (x.checksum.clone(), existing_contexts.contains(&x.checksum)))
.collect::<Vec<(String, bool)>>(),
);
*self.contexts.lock().unwrap() = contexts;
Ok(())
@@ -258,13 +266,14 @@ impl GameDownloadAgent {
.build()
.unwrap();
let completed_indexes = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_indexes.clone();
let completed_contexts = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_contexts.clone();
let contexts = self.contexts.lock().unwrap();
debug!("{:#?}", contexts);
pool.scope(|scope| {
let client = &reqwest::blocking::Client::new();
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();
@@ -273,12 +282,7 @@ impl GameDownloadAgent {
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
if self
.completed_contexts
.lock()
.unwrap()
.contains(&context.checksum)
{
if Some(&true) == context_map.get(&context.checksum) {
progress_handle.skip(context.length);
continue;
}
@@ -334,23 +338,33 @@ impl GameDownloadAgent {
}
});
let newly_completed = completed_indexes.to_owned();
let newly_completed = completed_contexts.to_owned();
let completed_lock_len = {
let mut completed_contexts_lock = self.completed_contexts.lock().unwrap();
let mut context_map_lock = self.context_map.lock().unwrap();
for (_, item) in newly_completed.iter() {
completed_contexts_lock.push_front(item.clone());
context_map_lock.insert(item.clone(), true);
}
completed_contexts_lock.len()
context_map_lock.values().filter(|x| **x).count()
};
let context_map_lock = self.context_map.lock().unwrap();
let contexts = contexts
.iter()
.map(|x| {
(
x.checksum.clone(),
context_map_lock.get(&x.checksum).cloned().or(Some(false)).unwrap(),
)
})
.collect::<Vec<(String, bool)>>();
drop(context_map_lock);
self.stored_manifest
.set_completed_contexts(self.completed_contexts.lock().unwrap().as_slice());
self.stored_manifest.set_contexts(&contexts);
self.stored_manifest.write();
// If we're not out of contexts, we're not done, so we don't fire completed
if completed_lock_len != contexts.len() {
// If there are any contexts left which are false
if !contexts.iter().all(|x| x.1) {
info!(
"download agent for {} exited without completing ({}/{})",
self.id.clone(),
@@ -359,10 +373,6 @@ impl GameDownloadAgent {
);
return Ok(false);
}
// We've completed
self.sender
.send(DownloadManagerSignal::Completed(self.metadata()))
.unwrap();
Ok(true)
}
@@ -420,47 +430,11 @@ impl Downloadable for GameDownloadAgent {
// TODO: fix this function. It doesn't restart the download properly, nor does it reset the state properly
fn on_incomplete(&self, app_handle: &tauri::AppHandle) {
let meta = self.metadata();
*self.status.lock().unwrap() = DownloadStatus::Queued;
borrow_db_mut_checked().applications.game_statuses.insert(
self.id.clone(),
GameDownloadStatus::PartiallyInstalled {
version_name: self.version.clone(),
install_dir: self.stored_manifest.base_path.to_string_lossy().to_string(),
},
on_game_incomplete(
&self.metadata(),
self.stored_manifest.base_path.to_string_lossy().to_string(),
app_handle,
);
borrow_db_mut_checked()
.applications
.installed_game_version
.insert(self.id.clone(), self.metadata());
borrow_db_mut_checked().applications.game_statuses.insert(
self.id.clone(),
GameDownloadStatus::PartiallyInstalled {
version_name: self.version.clone(),
install_dir: self.stored_manifest.base_path.to_string_lossy().to_string(),
},
);
app_handle
.emit(
&format!("update_game/{}", meta.id),
GameUpdateEvent {
game_id: meta.id.clone(),
status: (
Some(GameDownloadStatus::PartiallyInstalled {
version_name: self.version.clone(),
install_dir: self
.stored_manifest
.base_path
.to_string_lossy()
.to_string(),
}),
None,
),
version: None,
},
)
.unwrap();
}
fn on_cancelled(&self, _app_handle: &tauri::AppHandle) {}
+100 -11
View File
@@ -1,16 +1,23 @@
use crate::download_manager::util::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use crate::download_manager::util::download_thread_control_flag::{
DownloadThreadControl, DownloadThreadControlFlag,
};
use crate::download_manager::util::progress_object::ProgressHandle;
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::drop_data::DropData;
use crate::games::downloads::manifest::DropDownloadContext;
use log::{debug, warn};
use md5::{Context, Digest};
use native_model::Decode;
use reqwest::blocking::{RequestBuilder, Response};
use std::fs::{set_permissions, Permissions};
use std::io::{ErrorKind, Read};
use std::io::{copy, ErrorKind, Read};
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::thread::sleep;
use std::time::Duration;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
@@ -121,7 +128,10 @@ pub fn download_game_chunk(
progress: ProgressHandle,
request: RequestBuilder,
) -> Result<bool, ApplicationDownloadError> {
debug!("Starting download chunk {}, {}, {} #{}", ctx.file_name, ctx.index, ctx.offset, ctx.checksum);
debug!(
"Starting download chunk {}, {}, {} #{}",
ctx.file_name, ctx.index, ctx.offset, ctx.checksum
);
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
@@ -161,13 +171,8 @@ pub fn download_game_chunk(
return Err(ApplicationDownloadError::DownloadError);
}
let mut pipeline = DropDownloadPipeline::new(
response,
destination,
control_flag,
progress,
length,
);
let mut pipeline =
DropDownloadPipeline::new(response, destination, control_flag, progress, length);
let completed = pipeline
.copy()
@@ -192,7 +197,91 @@ pub fn download_game_chunk(
return Err(ApplicationDownloadError::Checksum);
}
debug!("Successfully finished download #{}, copied {} bytes", ctx.checksum, length);
debug!(
"Successfully finished download #{}, copied {} bytes",
ctx.checksum, length
);
Ok(true)
}
pub fn validate(path: PathBuf) -> Result<Vec<String>, ApplicationDownloadError> {
let mut dropdata = File::open(path.join(".dropdata")).unwrap();
let mut buf = Vec::new();
dropdata.read_to_end(&mut buf);
let manifest: DropData = native_model::rmp_serde_1_3::RmpSerde::decode(buf).unwrap();
let completed_contexts = manifest.get_completed_contexts();
todo!()
}
pub fn validate_game_chunk(
ctx: &DropDownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, ApplicationDownloadError> {
debug!(
"Starting chunk validation {}, {}, {} #{}",
ctx.file_name, ctx.index, ctx.offset, ctx.checksum
);
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let mut source = File::open(&ctx.path).unwrap();
if ctx.offset != 0 {
source
.seek(SeekFrom::Start(ctx.offset))
.expect("Failed to seek to file offset");
}
let mut hasher = md5::Context::new();
let completed = validate_copy(&mut source, &mut hasher, control_flag, progress).unwrap();
if !completed {
return Ok(false);
};
let res = hex::encode(hasher.compute().0);
if res != ctx.checksum {
return Err(ApplicationDownloadError::Checksum);
}
debug!(
"Successfully finished verification #{}, copied {} bytes",
ctx.checksum, ctx.length
);
Ok(true)
}
fn validate_copy(
source: &mut File,
dest: &mut Context,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> 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, dest);
loop {
if control_flag.get() == DownloadThreadControlFlag::Stop {
buf_writer.flush()?;
return Ok(false);
}
let bytes_read = source.read(&mut copy_buf)?;
buf_writer.write_all(&copy_buf[0..bytes_read])?;
progress.add(bytes_read);
if bytes_read == 0 {
break;
}
}
buf_writer.flush()?;
Ok(true)
}
+14 -7
View File
@@ -1,6 +1,6 @@
use std::{fs::File, io::{Read, Write}, path::PathBuf};
use log::{error, warn};
use log::{debug, error, info, warn};
use native_model::{Decode, Encode};
pub type DropData = v1::DropData;
@@ -19,7 +19,7 @@ pub mod v1 {
pub struct DropData {
game_id: String,
game_version: String,
pub completed_contexts: Mutex<Vec<String>>,
pub contexts: Mutex<Vec<(String, bool)>>,
pub base_path: PathBuf,
}
@@ -29,7 +29,7 @@ pub mod v1 {
base_path,
game_id,
game_version,
completed_contexts: Mutex::new(Vec::new()),
contexts: Mutex::new(Vec::new()),
}
}
}
@@ -39,7 +39,10 @@ impl DropData {
pub fn generate(game_id: String, game_version: String, base_path: PathBuf) -> Self {
let mut file = match File::open(base_path.join(DROP_DATA_PATH)) {
Ok(file) => file,
Err(_) => return DropData::new(game_id, game_version, base_path),
Err(_) => {
debug!("Generating new dropdata for game {}", game_id);
return DropData::new(game_id, game_version, base_path)
},
};
let mut s = Vec::new();
@@ -78,10 +81,14 @@ impl DropData {
Err(e) => error!("{}", e),
};
}
pub fn set_completed_contexts(&self, completed_contexts: &[String]) {
*self.completed_contexts.lock().unwrap() = completed_contexts.to_owned();
pub fn set_contexts(&self, completed_contexts: &[(String, bool)]) {
*self.contexts.lock().unwrap() = completed_contexts.to_owned();
}
pub fn get_completed_contexts(&self) -> Vec<String> {
self.completed_contexts.lock().unwrap().clone()
self.contexts.lock().unwrap().iter().filter_map(|x| { if x.1 { Some(x.0.clone()) } else { None } }).collect()
}
pub fn get_contexts(&self) -> Vec<(String, bool)> {
info!("Any contexts which are complete? {}", self.contexts.lock().unwrap().iter().any(|x| x.1));
self.contexts.lock().unwrap().clone()
}
}
+62 -3
View File
@@ -4,8 +4,8 @@ use std::thread::spawn;
use log::{debug, error, warn};
use serde::{Deserialize, Serialize};
use tauri::Emitter;
use tauri::AppHandle;
use tauri::Emitter;
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use crate::database::models::data::{
@@ -364,6 +364,66 @@ pub fn get_current_meta(game_id: &String) -> Option<DownloadableMetadata> {
.cloned()
}
pub fn on_game_incomplete(
meta: &DownloadableMetadata,
install_dir: String,
app_handle: &AppHandle,
) -> Result<(), RemoteAccessError> {
// Fetch game version information from remote
if meta.version.is_none() {
return Err(RemoteAccessError::GameNotFound(meta.id.clone()));
}
let client = reqwest::blocking::Client::new();
let response = make_request(
&client,
&["/api/v1/client/game/version"],
&[
("id", &meta.id),
("version", meta.version.as_ref().unwrap()),
],
|f| f.header("Authorization", generate_authorization_header()),
)?
.send()?;
let game_version: GameVersion = response.json()?;
let mut handle = borrow_db_mut_checked();
handle
.applications
.game_versions
.entry(meta.id.clone())
.or_default()
.insert(meta.version.clone().unwrap(), game_version.clone());
handle
.applications
.installed_game_version
.insert(meta.id.clone(), meta.clone());
let status = GameDownloadStatus::PartiallyInstalled {
version_name: meta.version.clone().unwrap(),
install_dir,
};
handle
.applications
.game_statuses
.insert(meta.id.clone(), status.clone());
drop(handle);
app_handle
.emit(
&format!("update_game/{}", meta.id),
GameUpdateEvent {
game_id: meta.id.clone(),
status: (Some(status), None),
version: Some(game_version),
},
)
.unwrap();
Ok(())
}
pub fn on_game_complete(
meta: &DownloadableMetadata,
install_dir: String,
@@ -400,7 +460,7 @@ pub fn on_game_complete(
handle
.applications
.installed_game_version
.insert(meta.id.clone(), meta.clone());
.insert(meta.id.clone(), meta.clone());
drop(handle);
@@ -496,6 +556,5 @@ pub fn update_game_configuration(
.unwrap()
.insert(version.to_string(), existing_configuration);
Ok(())
}