Process manager fixes (#71)

* fix: launching on linux

* feat: #70

* feat: add dummy store page

* feat: add store redir and refresh button to library

* feat: cache first object fetching

* feat: Remove let_chains feature and update to Rust 2024

Signed-off-by: quexeky <git@quexeky.dev>

* feat: Check for if process was manually stopped

Signed-off-by: quexeky <git@quexeky.dev>

* fix: use bitcode instead of serde

* chore: remove logs

* fix: clippy

* fix: clippy 2

* fix: swap to stop icon

---------

Signed-off-by: quexeky <git@quexeky.dev>
Co-authored-by: quexeky <git@quexeky.dev>
This commit is contained in:
DecDuck
2025-07-25 10:44:40 +10:00
committed by GitHub
parent 7aee57038d
commit f0112ec027
26 changed files with 426 additions and 227 deletions
@@ -1,10 +1,10 @@
use std::{
collections::HashMap,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex,
mpsc::{Receiver, Sender, channel},
},
thread::{spawn, JoinHandle},
thread::{JoinHandle, spawn},
};
use log::{debug, error, info, warn};
@@ -212,13 +212,13 @@ impl DownloadManagerBuilder {
if self.current_download_agent.is_some()
&& self.download_queue.read().front().unwrap()
== &self.current_download_agent.as_ref().unwrap().metadata()
{
debug!(
"Current download agent: {:?}",
self.current_download_agent.as_ref().unwrap().metadata()
);
return;
}
{
debug!(
"Current download agent: {:?}",
self.current_download_agent.as_ref().unwrap().metadata()
);
return;
}
debug!("current download queue: {:?}", self.download_queue.read());
@@ -295,11 +295,12 @@ impl DownloadManagerBuilder {
}
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
debug!("got signal Completed");
if let Some(interface) = &self.current_download_agent {
if interface.metadata() == meta {
self.remove_and_cleanup_front_download(&meta);
}
if let Some(interface) = &self.current_download_agent
&& interface.metadata() == meta
{
self.remove_and_cleanup_front_download(&meta);
}
self.push_ui_queue_update();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
@@ -148,9 +148,7 @@ impl DownloadManager {
.unwrap();
}
debug!(
"moving download at index {current_index} to index {new_index}"
);
debug!("moving download at index {current_index} to index {new_index}");
let mut queue = self.edit();
let to_move = queue.remove(current_index).unwrap();
@@ -1,5 +1,5 @@
pub mod commands;
pub mod download_manager_frontend;
pub mod download_manager_builder;
pub mod download_manager_frontend;
pub mod downloadable;
pub mod util;
@@ -5,7 +5,7 @@ use std::{
use serde_with::SerializeDisplay;
use super::{remote_access_error::RemoteAccessError};
use super::remote_access_error::RemoteAccessError;
// TODO: Rename / separate from downloads
#[derive(Debug, SerializeDisplay)]
+6 -4
View File
@@ -13,6 +13,7 @@ pub enum ProcessError {
IOError(Error),
FormatError(String), // String errors supremacy
InvalidPlatform,
OpenerError(tauri_plugin_opener::Error)
}
impl Display for ProcessError {
@@ -22,12 +23,13 @@ impl Display for ProcessError {
ProcessError::NotInstalled => "Game not installed",
ProcessError::AlreadyRunning => "Game already running",
ProcessError::NotDownloaded => "Game not downloaded",
ProcessError::InvalidID => "Invalid Game ID",
ProcessError::InvalidVersion => "Invalid Game version",
ProcessError::InvalidID => "Invalid game ID",
ProcessError::InvalidVersion => "Invalid game version",
ProcessError::IOError(error) => &error.to_string(),
ProcessError::InvalidPlatform => "This Game cannot be played on the current platform",
ProcessError::InvalidPlatform => "This game cannot be played on the current platform",
ProcessError::FormatError(e) => &format!("Failed to format template: {e}"),
};
ProcessError::OpenerError(error) => &format!("Failed to open directory: {error}"),
};
write!(f, "{s}")
}
}
+2 -3
View File
@@ -37,14 +37,13 @@ pub fn fetch_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
let res = offline!(
offline!(
state,
fetch_game_logic,
fetch_game_logic_offline,
game_id,
state
);
res
)
}
#[tauri::command]
@@ -5,7 +5,9 @@ use std::{
use crate::{
database::{db::borrow_db_checked, models::data::GameDownloadStatus},
download_manager::{download_manager_frontend::DownloadManagerSignal, downloadable::Downloadable},
download_manager::{
download_manager_frontend::DownloadManagerSignal, downloadable::Downloadable,
},
error::download_manager_error::DownloadManagerError,
AppState,
};
+2 -1
View File
@@ -19,6 +19,7 @@ use crate::remote::auth::generate_authorization_header;
use crate::remote::cache::{cache_object, get_cached_object, get_cached_object_db};
use crate::remote::requests::make_request;
use crate::AppState;
use bitcode::{Encode, Decode};
#[derive(Serialize, Deserialize, Debug)]
pub struct FetchGameStruct {
@@ -27,7 +28,7 @@ pub struct FetchGameStruct {
version: Option<GameVersion>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[derive(Serialize, Deserialize, Clone, Debug, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct Game {
id: String,
+10 -5
View File
@@ -1,4 +1,5 @@
#![feature(fn_traits)]
#![feature(duration_constructors)]
#![deny(clippy::all)]
mod database;
@@ -10,6 +11,7 @@ mod error;
mod process;
mod remote;
use crate::process::commands::open_process_logs;
use crate::{database::db::DatabaseImpls, games::downloads::commands::resume_download};
use client::commands::fetch_state;
use client::{
@@ -25,8 +27,8 @@ use database::models::data::GameDownloadStatus;
use download_manager::commands::{
cancel_game, move_download_in_queue, pause_downloads, resume_downloads,
};
use download_manager::download_manager_frontend::DownloadManager;
use download_manager::download_manager_builder::DownloadManagerBuilder;
use download_manager::download_manager_frontend::DownloadManager;
use games::collections::commands::{
add_game_to_collection, create_collection, delete_collection, delete_game_in_collection,
fetch_collection, fetch_collections,
@@ -69,6 +71,7 @@ use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, RunEvent, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::DialogExt;
use bitcode::{Encode, Decode};
#[derive(Clone, Copy, Serialize, Eq, PartialEq)]
pub enum AppStatus {
@@ -81,7 +84,7 @@ pub enum AppStatus {
ServerUnavailable,
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct User {
id: String,
@@ -225,7 +228,8 @@ pub fn custom_panic_handler(e: &PanicHookInfo) -> Option<()> {
.as_secs()
));
let mut file = File::create_new(crash_file).ok()?;
file.write_all(format!("Drop crashed with the following panic:\n{e}").as_bytes()).ok()?;
file.write_all(format!("Drop crashed with the following panic:\n{e}").as_bytes())
.ok()?;
drop(file);
Some(())
@@ -235,11 +239,11 @@ pub fn custom_panic_handler(e: &PanicHookInfo) -> Option<()> {
pub fn run() {
panic::set_hook(Box::new(|e| {
let _ = custom_panic_handler(e);
let dft = panic::take_hook();
dft.call((e,));
println!("{e}");
}));
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init());
@@ -299,6 +303,7 @@ pub fn run() {
kill_game,
toggle_autostart,
get_autostart_enabled,
open_process_logs
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
+10
View File
@@ -38,3 +38,13 @@ pub fn kill_game(
.kill_game(game_id)
.map_err(ProcessError::IOError)
}
#[tauri::command]
pub fn open_process_logs(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), ProcessError> {
let state_lock = state.lock().unwrap();
let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
process_manager_lock.open_process_logs(game_id)
}
@@ -1,12 +1,13 @@
use std::{
collections::HashMap,
fs::OpenOptions,
fs::{OpenOptions, create_dir_all},
io::{self},
path::PathBuf,
process::{Command, ExitStatus},
str::FromStr,
sync::{Arc, Mutex},
thread::spawn,
time::{Duration, SystemTime},
};
use dynfmt::Format;
@@ -14,11 +15,13 @@ use dynfmt::SimpleCurlyFormat;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use shared_child::SharedChild;
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_opener::OpenerExt;
use crate::{
AppState, DB,
database::{
db::{borrow_db_mut_checked, DATA_ROOT_DIR},
db::{DATA_ROOT_DIR, borrow_db_mut_checked},
models::data::{
ApplicationTransientStatus, DownloadType, DownloadableMetadata, GameDownloadStatus,
GameVersion,
@@ -26,13 +29,18 @@ use crate::{
},
error::process_error::ProcessError,
games::{library::push_game_update, state::GameStatusManager},
AppState, DB,
};
pub struct RunningProcess {
handle: Arc<SharedChild>,
start: SystemTime,
manually_killed: bool,
}
pub struct ProcessManager<'a> {
current_platform: Platform,
log_output_dir: PathBuf,
processes: HashMap<String, Arc<SharedChild>>,
processes: HashMap<String, RunningProcess>,
app_handle: AppHandle,
game_launchers: HashMap<(Platform, Platform), &'a (dyn ProcessHandler + Sync + Send + 'static)>,
}
@@ -77,10 +85,11 @@ impl ProcessManager<'_> {
}
pub fn kill_game(&mut self, game_id: String) -> Result<(), io::Error> {
match self.processes.get(&game_id) {
Some(child) => {
child.kill()?;
child.wait()?;
match self.processes.get_mut(&game_id) {
Some(process) => {
process.manually_killed = true;
process.handle.kill()?;
process.handle.wait()?;
Ok(())
}
None => Err(io::Error::new(
@@ -90,15 +99,26 @@ impl ProcessManager<'_> {
}
}
pub fn open_process_logs(&mut self, game_id: String) -> Result<(), ProcessError> {
let dir = self.log_output_dir.join(game_id);
self.app_handle
.opener()
.open_path(dir.to_str().unwrap(), None::<&str>)
.map_err(ProcessError::OpenerError)?;
Ok(())
}
fn on_process_finish(&mut self, game_id: String, result: Result<ExitStatus, std::io::Error>) {
if !self.processes.contains_key(&game_id) {
warn!("process on_finish was called, but game_id is no longer valid. finished with result: {result:?}");
warn!(
"process on_finish was called, but game_id is no longer valid. finished with result: {result:?}"
);
return;
}
debug!("process for {:?} exited with {:?}", &game_id, result);
self.processes.remove(&game_id);
let process = self.processes.remove(&game_id).unwrap();
let mut db_handle = borrow_db_mut_checked();
let meta = db_handle
@@ -114,26 +134,32 @@ impl ProcessManager<'_> {
version_name,
install_dir,
}) = current_state
&& let Ok(exit_code) = result
&& exit_code.success()
{
if let Ok(exit_code) = result {
if exit_code.success() {
db_handle.applications.game_statuses.insert(
game_id.clone(),
GameDownloadStatus::Installed {
version_name: version_name.to_string(),
install_dir: install_dir.to_string(),
},
);
}
}
db_handle.applications.game_statuses.insert(
game_id.clone(),
GameDownloadStatus::Installed {
version_name: version_name.to_string(),
install_dir: install_dir.to_string(),
},
);
}
drop(db_handle);
let elapsed = process.start.elapsed().unwrap_or(Duration::ZERO);
// If we started and ended really quickly, something might've gone wrong
// Or if the status isn't 0
// Or if it's an error
if !process.manually_killed
&& (elapsed.as_secs() <= 2 || result.is_err() || !result.unwrap().success())
{
warn!("drop detected that the game {game_id} may have failed to launch properly");
let _ = self.app_handle.emit("launch_external_error", &game_id);
}
let status = GameStatusManager::fetch_state(&game_id);
push_game_update(&self.app_handle, &game_id, None, status);
// TODO better management
}
pub fn valid_platform(&self, platform: &Platform) -> Result<bool, String> {
@@ -156,7 +182,7 @@ impl ProcessManager<'_> {
{
Some(GameDownloadStatus::Installed { version_name, .. }) => version_name,
Some(GameDownloadStatus::SetupRequired { .. }) => {
return Err(ProcessError::SetupRequired)
return Err(ProcessError::SetupRequired);
}
_ => return Err(ProcessError::NotInstalled),
};
@@ -202,18 +228,17 @@ impl ProcessManager<'_> {
.get(version_name)
.ok_or(ProcessError::InvalidVersion)?;
// TODO: refactor this path with open_process_logs
let game_log_folder = &self.log_output_dir.join(game_id);
create_dir_all(game_log_folder).map_err(ProcessError::IOError)?;
let current_time = chrono::offset::Local::now();
let log_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
.create(true)
.open(self.log_output_dir.join(format!(
"{}-{}-{}.log",
&game_id,
&version,
current_time.timestamp()
)))
.open(game_log_folder.join(format!("{}-{}.log", &version, current_time.timestamp())))
.map_err(ProcessError::IOError)?;
let error_file = OpenOptions::new()
@@ -221,9 +246,8 @@ impl ProcessManager<'_> {
.truncate(true)
.read(true)
.create(true)
.open(self.log_output_dir.join(format!(
"{}-{}-{}-error.log",
&game_id,
.open(game_log_folder.join(format!(
"{}-{}-error.log",
&version,
current_time.timestamp()
)))
@@ -282,7 +306,7 @@ impl ProcessManager<'_> {
#[cfg(unix)]
let mut command: Command = Command::new("sh");
#[cfg(unix)]
command.arg("-c").arg(launch_string);
command.args(vec!["-c", &launch_string]);
command
.stderr(error_file)
@@ -325,7 +349,14 @@ impl ProcessManager<'_> {
drop(app_state_handle);
});
self.processes.insert(meta.id, wait_thread_handle);
self.processes.insert(
meta.id,
RunningProcess {
handle: wait_thread_handle,
start: SystemTime::now(),
manually_killed: false,
},
);
Ok(())
}
}
+43 -10
View File
@@ -1,11 +1,15 @@
use std::{
fmt::Display,
time::{Duration, SystemTime},
};
use crate::{
database::{db::borrow_db_checked, models::data::Database},
error::remote_access_error::RemoteAccessError,
};
use bitcode::{Decode, DecodeOwned, Encode};
use cacache::Integrity;
use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder, Response};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_binary::binary_stream::Endian;
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
#[macro_export]
macro_rules! offline {
@@ -19,31 +23,48 @@ macro_rules! offline {
}
}
pub fn cache_object<K: AsRef<str>, D: Serialize + DeserializeOwned>(
pub fn cache_object<K: AsRef<str>, D: Encode>(
key: K,
data: &D,
) -> Result<Integrity, RemoteAccessError> {
let bytes = serde_binary::to_vec(data, Endian::Little).unwrap();
let bytes = bitcode::encode(data);
cacache::write_sync(&borrow_db_checked().cache_dir, key, bytes)
.map_err(RemoteAccessError::Cache)
}
pub fn get_cached_object<K: AsRef<str>, D: Serialize + DeserializeOwned>(
pub fn get_cached_object<K: AsRef<str> + Display, D: Encode + DecodeOwned>(
key: K,
) -> Result<D, RemoteAccessError> {
get_cached_object_db::<K, D>(key, &borrow_db_checked())
}
pub fn get_cached_object_db<K: AsRef<str>, D: Serialize + DeserializeOwned>(
pub fn get_cached_object_db<K: AsRef<str> + Display, D: DecodeOwned>(
key: K,
db: &Database,
) -> Result<D, RemoteAccessError> {
let bytes = cacache::read_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?;
let data = serde_binary::from_slice::<D>(&bytes, Endian::Little).unwrap();
let bytes = cacache::read_sync(&db.cache_dir, &key).map_err(RemoteAccessError::Cache)?;
let data = bitcode::decode::<D>(&bytes).map_err(|_| {
RemoteAccessError::Cache(cacache::Error::EntryNotFound(
db.cache_dir.clone(),
key.to_string(),
))
})?;
Ok(data)
}
#[derive(Serialize, Deserialize)]
#[derive(Encode, Decode)]
pub struct ObjectCache {
content_type: String,
body: Vec<u8>,
expiry: u128,
}
impl ObjectCache {
pub fn has_expired(&self) -> bool {
let duration = Duration::from_millis(self.expiry.try_into().unwrap());
SystemTime::UNIX_EPOCH
.checked_add(duration)
.unwrap()
.elapsed()
.is_err()
}
}
impl From<Response<Vec<u8>>> for ObjectCache {
@@ -57,6 +78,12 @@ impl From<Response<Vec<u8>>> for ObjectCache {
.unwrap()
.to_owned(),
body: value.body().clone(),
expiry: SystemTime::now()
.checked_add(Duration::from_days(1))
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
}
}
}
@@ -66,3 +93,9 @@ impl From<ObjectCache> for Response<Vec<u8>> {
resp_builder.body(value.body).unwrap()
}
}
impl From<&ObjectCache> for Response<Vec<u8>> {
fn from(value: &ObjectCache) -> Self {
let resp_builder = ResponseBuilder::new().header(CONTENT_TYPE, value.content_type.clone());
resp_builder.body(value.body.clone()).unwrap()
}
}
+13 -5
View File
@@ -12,6 +12,14 @@ pub fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeRespond
// Drop leading /
let object_id = &request.uri().path()[1..];
let cache_result = get_cached_object::<&str, ObjectCache>(object_id);
if let Ok(cache_result) = &cache_result
&& !cache_result.has_expired()
{
responder.respond(cache_result.into());
return;
}
let header = generate_authorization_header();
let client: reqwest::blocking::Client = reqwest::blocking::Client::new();
let response = make_request(&client, &["/api/v1/client/object/", object_id], &[], |f| {
@@ -20,10 +28,8 @@ pub fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeRespond
.unwrap()
.send();
if response.is_err() {
let data = get_cached_object::<&str, ObjectCache>(object_id);
match data {
Ok(data) => responder.respond(data.into()),
match cache_result {
Ok(cache_result) => responder.respond(cache_result.into()),
Err(e) => {
warn!("{e}")
}
@@ -38,7 +44,9 @@ pub fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeRespond
);
let data = Vec::from(response.bytes().unwrap());
let resp = resp_builder.body(data).unwrap();
cache_object::<&str, ObjectCache>(object_id, &resp.clone().into()).unwrap();
if cache_result.is_err() || cache_result.unwrap().has_expired() {
cache_object::<&str, ObjectCache>(object_id, &resp.clone().into()).unwrap();
}
responder.respond(resp);
}
+1 -1
View File
@@ -3,6 +3,6 @@ pub mod auth;
pub mod cache;
pub mod commands;
pub mod fetch_object;
pub mod utils;
pub mod requests;
pub mod server_proto;
pub mod utils;
+1 -4
View File
@@ -33,10 +33,7 @@ pub fn handle_server_proto(request: Request<Vec<u8>>, responder: UriSchemeRespon
let whitelist_prefix = ["/store", "/api", "/_", "/fonts"];
if whitelist_prefix
.iter()
.all(|f| !path.starts_with(f))
{
if whitelist_prefix.iter().all(|f| !path.starts_with(f)) {
webbrowser::open(&new_uri.to_string()).unwrap();
return;
}