From 765447e6d156a6f5505e4b4c4731393a22c52f00 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 20 Mar 2025 19:39:32 +0100 Subject: [PATCH] [code][fix] improvements for LAN 202503 - more robust client <-> server connection - new client event: DownloadGameFilesFailed - 3 seconds to reconnect - retry forever if server is gone and never lose a UI request - code cleanup here and there (mostly server) --- crates/lanspread-client/src/lib.rs | 226 +++++++++++------- crates/lanspread-db/src/db.rs | 13 +- crates/lanspread-proto/src/lib.rs | 23 +- crates/lanspread-server/Cargo.toml | 3 + crates/lanspread-server/src/main.rs | 51 +++- crates/lanspread-server/src/quic.rs | 121 ++++------ crates/lanspread-server/src/req.rs | 89 +++++-- .../src-tauri/src/lib.rs | 92 +++---- crates/lanspread-tauri-deno-ts/src/App.tsx | 26 +- 9 files changed, 405 insertions(+), 239 deletions(-) diff --git a/crates/lanspread-client/src/lib.rs b/crates/lanspread-client/src/lib.rs index 25d2a0b..7c3e186 100644 --- a/crates/lanspread-client/src/lib.rs +++ b/crates/lanspread-client/src/lib.rs @@ -20,16 +20,29 @@ static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../.. #[derive(Debug)] pub enum ClientEvent { ListGames(Vec), - GotGameFiles(Vec), - DownloadGameFilesBegin { id: String }, - DownloadGameFilesFinished { id: String }, + GotGameFiles { + id: String, + file_descriptions: Vec, + }, + DownloadGameFilesBegin { + id: String, + }, + DownloadGameFilesFinished { + id: String, + }, + DownloadGameFilesFailed { + id: String, + }, } #[derive(Debug)] pub enum ClientCommand { ListGames, GetGame(String), - DownloadGameFiles(Vec), + DownloadGameFiles { + id: String, + file_descriptions: Vec, + }, ServerAddr(SocketAddr), SetGameDir(String), } @@ -66,9 +79,39 @@ async fn initial_server_alive_check(conn: &mut Connection) -> bool { false } +async fn receive_game_file( + conn: &mut Connection, + desc: &GameFileDescription, + games_folder: &str, +) -> eyre::Result<()> { + log::info!("downloading: {desc:?}"); + + let stream = conn.open_bidirectional_stream().await?; + let (mut rx, mut tx) = stream.split(); + + let request = Request::GetGameFileData(desc.clone()); + + // request file + tx.write_all(&request.encode()).await?; + + // create file + let path = PathBuf::from(&games_folder).join(&desc.relative_path); + let mut file = File::create(&path)?; + + // receive file contents + while let Some(data) = rx.receive().await? { + file.write_all(&data)?; + } + log::debug!("file download complete: {}", path.display()); + + tx.close().await?; + Ok(()) +} + async fn download_game_files( + game_id: &str, game_file_descs: Vec, - games_dir: String, + games_folder: String, server_addr: SocketAddr, tx_notify_ui: UnboundedSender, ) -> eyre::Result<()> { @@ -84,64 +127,37 @@ async fn download_game_files( let mut conn = client.connect(conn).await?; conn.keep_alive(true)?; - let game_file_descs = game_file_descs - .into_iter() + let game_files = game_file_descs + .iter() .filter(|desc| !desc.is_dir) + .filter(|desc| !desc.is_version_ini()) .collect::>(); - if game_file_descs.is_empty() { - log::error!("game_file_descs empty: no game files to download"); - return Ok(()); + if game_files.is_empty() { + eyre::bail!("game_file_descs empty: no game files to download"); } - let game_id = game_file_descs - .first() - .expect("game_file_descs empty: 2nd case CANNOT HAPPEN") - .game_id - .clone(); - tx_notify_ui.send(ClientEvent::DownloadGameFilesBegin { - id: game_id.clone(), + id: game_id.to_string(), })?; - for file_desc in game_file_descs { - log::info!("downloading file: {}", file_desc.relative_path); - - let stream = conn.open_bidirectional_stream().await?; - let (mut rx, mut tx) = stream.split(); - - let request = Request::GetGameFileData(file_desc.clone()); - - if let Ok(()) = tx.write_all(&request.encode()).await { - let path = PathBuf::from(&games_dir).join(&file_desc.relative_path); - let mut file = match File::create(&path) { - Ok(file) => file, - Err(e) => { - log::error!("failed to create file: {e}"); - continue; - } - }; - - // if let Err(e) = tokio::io::copy(&mut rx, &mut file).await { - // log::error!("failed to download file: {e}"); - // continue; - // } - - while let Ok(Some(data)) = rx.receive().await { - if let Err(e) = file.write_all(&data) { - log::error!("failed to write to file: {e}"); - break; - } - } - log::error!("file download complete: {}", path.display()); - } - if let Err(e) = tx.close().await { - log::error!("failed to close stream: {e}"); - } + // receive all game files + for file_desc in game_files { + receive_game_file(&mut conn, file_desc, &games_folder).await?; } + let version_file_desc = game_file_descs + .iter() + .find(|desc| desc.is_version_ini()) + .ok_or_else(|| eyre::eyre!("version.ini not found"))?; + + // receive version.ini + receive_game_file(&mut conn, version_file_desc, &games_folder).await?; + log::info!("all files downloaded for game: {game_id}"); - tx_notify_ui.send(ClientEvent::DownloadGameFilesFinished { id: game_id })?; + tx_notify_ui.send(ClientEvent::DownloadGameFilesFinished { + id: game_id.to_string(), + })?; Ok(()) } @@ -171,7 +187,7 @@ pub async fn run( loop { let limits = Limits::default() .with_max_handshake_duration(Duration::from_secs(3))? - .with_max_idle_timeout(Duration::ZERO)?; + .with_max_idle_timeout(Duration::from_secs(3))?; let client = QuicClient::builder() .with_tls(CERT_PEM)? @@ -179,8 +195,8 @@ pub async fn run( .with_limits(limits)? .start()?; - let conn = Connect::new(server_addr).with_server_name("localhost"); - let mut conn = match client.connect(conn).await { + let connection = Connect::new(server_addr).with_server_name("localhost"); + let mut conn = match client.connect(connection.clone()).await { Ok(conn) => conn, Err(e) => { log::error!("failed to connect to server: {e}"); @@ -206,7 +222,7 @@ pub async fn run( let request = match cmd { ClientCommand::ListGames => Request::ListGames, ClientCommand::GetGame(id) => { - log::debug!("requesting game from server: {id}"); + log::info!("requesting game from server: {id}"); Request::GetGame { id } } ClientCommand::ServerAddr(_) => { @@ -217,34 +233,73 @@ pub async fn run( *ctx.game_dir.lock().await = Some(game_dir.clone()); continue; } - ClientCommand::DownloadGameFiles(game_file_descs) => { + ClientCommand::DownloadGameFiles { + id, + file_descriptions, + } => { log::info!("got ClientCommand::DownloadGameFiles"); - let games_dir = { ctx.game_dir.lock().await.clone() }; - if let Some(games_dir) = games_dir { + let games_folder = { ctx.game_dir.lock().await.clone() }; + if let Some(games_folder) = games_folder { let tx_notify_ui = tx_notify_ui.clone(); tokio::task::spawn(async move { if let Err(e) = download_game_files( - game_file_descs, - games_dir, + &id, + file_descriptions, + games_folder, server_addr, - tx_notify_ui, + tx_notify_ui.clone(), ) .await { log::error!("failed to download game files: {e}"); + if let Err(e) = + tx_notify_ui.send(ClientEvent::DownloadGameFilesFailed { id }) + { + log::error!( + "failed to send DownloadGameFilesFailed event: {e}" + ); + } } }); } else { - log::error!("Cannot handle game file descriptions: game_dir is not set"); + log::error!( + "Cannot handle game file descriptions: games_folder is not set" + ); } continue; } }; + // we got a command from the UI client + // but it is possible that we lost the connection to the server + // so we check and reconnect if needed + let mut retries = 0; + loop { + if initial_server_alive_check(&mut conn).await { + log::info!("server is back alive! 😊"); + break; + } + + if retries == 0 { + log::warn!("server connection lost, reconnecting..."); + } + retries += 1; + + conn = match client.connect(connection.clone()).await { + Ok(conn) => conn, + Err(e) => { + log::warn!("failed to connect to server: {e}"); + log::warn!("retrying in 3 seconds..."); + tokio::time::sleep(Duration::from_secs(3)).await; + continue; + } + }; + } + let data = request.encode(); - log::debug!("encoded data: {}", String::from_utf8_lossy(&data)); + log::trace!("encoded data: {}", String::from_utf8_lossy(&data)); let stream = match conn.open_bidirectional_stream().await { Ok(stream) => stream, @@ -271,36 +326,43 @@ pub async fn run( log::trace!("msg: {response:?}"); match response { - Response::Games(games) => { + Response::ListGames(games) => { for game in &games { log::trace!("{game}"); } if let Err(e) = tx_notify_ui.send(ClientEvent::ListGames(games)) { - log::debug!("failed to send ClientEvent::ListGames to client {e:?}"); - } else { - log::info!("sent ClientEvent::ListGames to Tauri client"); + log::error!("failed to send ClientEvent::ListGames to client {e:?}"); } } - Response::GetGame(game_file_descs) => { + Response::GetGame { + id, + file_descriptions, + } => { log::info!( "got {} game file descriptions from server", - game_file_descs.len() + file_descriptions.len() ); - let games_dir = { ctx.game_dir.lock().await.clone() }; + let games_folder = { ctx.game_dir.lock().await.clone() }; - match games_dir { - Some(games_dir) => { - game_file_descs.iter().filter(|f| f.is_dir).for_each(|dir| { - let path = PathBuf::from(&games_dir).join(&dir.relative_path); - if let Err(e) = std::fs::create_dir_all(path) { - log::error!("failed to create directory: {e}"); - } - }); - if let Err(e) = - tx_notify_ui.send(ClientEvent::GotGameFiles(game_file_descs)) - { + match games_folder { + Some(games_folder) => { + // create all directories before receiving the actual files + file_descriptions + .iter() + .filter(|f| f.is_dir) + .for_each(|dir| { + let path = + PathBuf::from(&games_folder).join(&dir.relative_path); + if let Err(e) = std::fs::create_dir_all(path) { + log::error!("failed to create directory: {e}"); + } + }); + if let Err(e) = tx_notify_ui.send(ClientEvent::GotGameFiles { + id, + file_descriptions, + }) { log::error!( "failed to send ClientEvent::GotGameFiles to client: {e}" ); diff --git a/crates/lanspread-db/src/db.rs b/crates/lanspread-db/src/db.rs index cac0dbc..1916aab 100644 --- a/crates/lanspread-db/src/db.rs +++ b/crates/lanspread-db/src/db.rs @@ -152,13 +152,22 @@ pub struct GameFileDescription { pub is_dir: bool, } +impl GameFileDescription { + #[must_use] + pub fn is_version_ini(&self) -> bool { + self.relative_path.ends_with("/version.ini") + } +} + impl fmt::Debug for GameFileDescription { #[allow(clippy::cast_precision_loss)] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "game:{} path:{} dir:{}", - self.game_id, self.relative_path, self.is_dir + "{}: [{}] path:{}", + self.game_id, + if self.is_dir { 'D' } else { 'F' }, + self.relative_path, ) } } diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index 30112f6..d92e2c2 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -1,7 +1,6 @@ use bytes::Bytes; use lanspread_db::db::{Game, GameFileDescription}; use serde::{Deserialize, Serialize}; -use tracing::error; #[derive(Debug, Serialize, Deserialize)] pub enum Request { @@ -15,8 +14,11 @@ pub enum Request { #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Response { Pong, - Games(Vec), - GetGame(Vec), + ListGames(Vec), + GetGame { + id: String, + file_descriptions: Vec, + }, GameNotFound(String), InvalidRequest(Bytes, String), EncodingError(String), @@ -35,11 +37,7 @@ impl Message for Request { match serde_json::from_slice(&bytes) { Ok(t) => t, Err(e) => { - tracing::error!( - "got invalid request from client (error: {}): {}", - e, - String::from_utf8_lossy(&bytes) - ); + tracing::error!(?e, "Request decoding error"); Request::Invalid(bytes, e.to_string()) } } @@ -49,7 +47,7 @@ impl Message for Request { match serde_json::to_vec(self) { Ok(s) => Bytes::from(s), Err(e) => { - error!(?e, "Request encoding error"); + tracing::error!(?e, "Request encoding error"); Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#)) } } @@ -61,7 +59,10 @@ impl Message for Response { fn decode(bytes: Bytes) -> Self { match serde_json::from_slice(&bytes) { Ok(t) => t, - Err(e) => Response::DecodingError(bytes, e.to_string()), + Err(e) => { + tracing::error!(?e, "Response decoding error"); + Response::DecodingError(bytes, e.to_string()) + } } } @@ -69,7 +70,7 @@ impl Message for Response { match serde_json::to_vec(self) { Ok(s) => Bytes::from(s), Err(e) => { - error!(?e, "Response encoding error"); + tracing::error!(?e, "Response encoding error"); Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#)) } } diff --git a/crates/lanspread-server/Cargo.toml b/crates/lanspread-server/Cargo.toml index ae5269f..0039199 100644 --- a/crates/lanspread-server/Cargo.toml +++ b/crates/lanspread-server/Cargo.toml @@ -21,8 +21,10 @@ lanspread-utils = { path = "../lanspread-utils" } # external bytes = { workspace = true } +chrono = { workspace = true } clap = { workspace = true } eyre = { workspace = true } +gethostname = { workspace = true } itertools = { workspace = true } s2n-quic = { workspace = true } serde_json = { workspace = true } @@ -30,4 +32,5 @@ semver = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +uuid = { workspace = true } walkdir = { workspace = true } diff --git a/crates/lanspread-server/src/main.rs b/crates/lanspread-server/src/main.rs index c802729..eb2d8b3 100644 --- a/crates/lanspread-server/src/main.rs +++ b/crates/lanspread-server/src/main.rs @@ -2,27 +2,61 @@ mod cli; mod quic; mod req; -use std::{convert::Into, net::SocketAddr}; +use std::{convert::Into, net::SocketAddr, time::Duration}; +use chrono::{DateTime, Local}; use clap::Parser as _; use cli::Cli; +use gethostname::gethostname; use lanspread_compat::eti; use lanspread_db::db::{Game, GameDB}; use lanspread_mdns::{ - DaemonEvent, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, MdnsAdvertiser, + DaemonEvent, + LANSPREAD_INSTANCE_NAME, + LANSPREAD_SERVICE_TYPE, + MdnsAdvertiser, }; -use quic::run_server; use tracing_subscriber::EnvFilter; +use uuid::Uuid; fn spawn_mdns_task(server_addr: SocketAddr) -> eyre::Result<()> { - let mdns = MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, LANSPREAD_INSTANCE_NAME, server_addr)?; + let combined_str = if 1 == 2 { + let peer_id = Uuid::now_v7().simple().to_string(); + + let uidddd = Uuid::now_v7(); + + // TODO + let uidddd = uidddd + .get_timestamp() + .expect("failed to get timestamp from UUID") + .to_unix(); + + let local_datetime: DateTime = + DateTime::from_timestamp(i64::try_from(uidddd.0).unwrap_or(0), uidddd.1) + .expect("Failed to create DateTime from uuid unix timestamp") + .into(); + + dbg!(local_datetime); + + let hostname = gethostname(); + let mut hostname = hostname.to_str().unwrap_or(""); + + if hostname.len() + peer_id.len() > 63 { + hostname = &hostname[..63 - peer_id.len()]; + } + format!("{hostname}-{peer_id}") + } else { + String::from(LANSPREAD_INSTANCE_NAME) + }; + + let mdns = MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, &combined_str, server_addr)?; tokio::spawn(async move { while let Ok(event) = mdns.monitor.recv() { - tracing::info!("mDNS: {:?}", &event); + tracing::debug!("mDNS: {:?}", &event); if let DaemonEvent::Error(e) = event { tracing::error!("mDNS: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + tokio::time::sleep(Duration::from_secs(1)).await; continue; } } @@ -46,6 +80,9 @@ async fn prepare_game_db(cli: &Cli) -> eyre::Result { game_db.add_thumbnails(&cli.thumbs_dir); + game_db.all_games().iter().for_each(|game| { + tracing::debug!("Found game: {game}"); + }); tracing::info!("Prepared game database with {} games", game_db.games.len()); Ok(game_db) @@ -73,5 +110,5 @@ async fn main() -> eyre::Result<()> { let game_db = prepare_game_db(&cli).await?; tracing::info!("Server listening on {server_addr}"); - run_server(server_addr, game_db, cli.game_dir).await + crate::quic::run_server(server_addr, game_db, cli.game_dir).await } diff --git a/crates/lanspread-server/src/quic.rs b/crates/lanspread-server/src/quic.rs index 89dfa4f..65eefc5 100644 --- a/crates/lanspread-server/src/quic.rs +++ b/crates/lanspread-server/src/quic.rs @@ -3,12 +3,7 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use lanspread_db::db::GameDB; use lanspread_proto::{Message as _, Request}; use lanspread_utils::maybe_addr; -use s2n_quic::{ - Connection, Server, - provider::limits::Limits, - stream::{ReceiveStream, SendStream}, -}; -use tokio::io::AsyncWriteExt as _; +use s2n_quic::{Connection, Server, provider::limits::Limits, stream::BidirectionalStream}; use crate::req::{RequestHandler, send_game_file_data}; @@ -21,92 +16,65 @@ struct ServerCtx { games_folder: PathBuf, } -#[derive(Clone, Debug)] -struct ConnectionCtx { - server_ctx: Arc, -} +async fn handle_bidi_stream(stream: BidirectionalStream, ctx: Arc) -> eyre::Result<()> { + let (mut rx, mut tx) = stream.split(); -#[derive(Clone, Debug)] -struct StreamCtx { - conn_ctx: Arc, -} - -async fn handle_bidi_stream( - mut rx: ReceiveStream, - mut tx: SendStream, - ctx: Arc, -) -> eyre::Result<()> { let remote_addr = maybe_addr!(rx.connection().remote_addr()); tracing::trace!("{remote_addr} stream opened"); // handle streams - while let Ok(Some(data)) = rx.receive().await { - tracing::trace!( - "{remote_addr} msg: (raw): {}", - String::from_utf8_lossy(&data) - ); + loop { + match rx.receive().await { + Ok(Some(data)) => { + tracing::trace!( + "{remote_addr} msg: (raw): {}", + String::from_utf8_lossy(&data) + ); - let request = Request::decode(data); - tracing::debug!("{remote_addr} msg: {request:?}"); + let request = Request::decode(data); + tracing::debug!("{remote_addr} msg: {request:?}"); - // special case for now (send game file data to client) - if let Request::GetGameFileData(game_file_desc) = &request { - send_game_file_data( - game_file_desc, - &mut tx, - &ctx.conn_ctx.server_ctx.games_folder, - ) - .await; - continue; - } + // special case for now (send game file data to client) + if let Request::GetGameFileData(game_file_desc) = &request { + send_game_file_data(game_file_desc, &mut tx, &ctx.games_folder).await; + continue; + } - let response = ctx - .conn_ctx - .server_ctx - .handler - .handle_request(request, &ctx.conn_ctx.server_ctx.games_folder) - .await; - - tracing::trace!("{remote_addr} server response: {response:?}"); - let raw_response = response.encode(); - tracing::trace!( - "{remote_addr} server response (raw): {}", - String::from_utf8_lossy(&raw_response) - ); - - // write response back to client - if let Err(e) = tx.write_all(&raw_response).await { - tracing::error!(?e); - } - - // close the stream - if let Err(e) = tx.close().await { - tracing::error!(?e); + // normal case (handle request) + if let Err(e) = ctx + .handler + .handle_request(request, &ctx.games_folder, &mut tx) + .await + { + tracing::error!(?e, "{remote_addr} error handling request"); + } + } + Ok(None) => { + tracing::trace!("{remote_addr} stream closed"); + break; + } + Err(e) => { + tracing::error!("{remote_addr} stream error: {e}"); + break; + } } } Ok(()) } -async fn handle_connection( - mut connection: Connection, - ctx: Arc, -) -> eyre::Result<()> { +async fn handle_connection(mut connection: Connection, ctx: Arc) -> eyre::Result<()> { let remote_addr = maybe_addr!(connection.remote_addr()); tracing::info!("{remote_addr} connected"); // handle streams while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await { + let ctx = ctx.clone(); let remote_addr = remote_addr.clone(); - let (rx, tx) = stream.split(); - - let ctx = Arc::new(StreamCtx { - conn_ctx: ctx.clone(), - }); // spawn a new task for the stream tokio::spawn(async move { - if let Err(e) = handle_bidi_stream(rx, tx, ctx).await { + if let Err(e) = handle_bidi_stream(stream, ctx).await { tracing::error!("{remote_addr} stream error: {e}"); } }); @@ -121,8 +89,8 @@ pub(crate) async fn run_server( games_folder: PathBuf, ) -> eyre::Result<()> { let limits = Limits::default() - .with_max_idle_timeout(Duration::ZERO)? - .with_max_handshake_duration(Duration::from_secs(3))?; + .with_max_handshake_duration(Duration::from_secs(3))? + .with_max_idle_timeout(Duration::from_secs(3))?; let mut server = Server::builder() .with_tls((CERT_PEM, KEY_PEM))? @@ -130,23 +98,22 @@ pub(crate) async fn run_server( .with_limits(limits)? .start()?; - let server_ctx = Arc::new(ServerCtx { + let ctx = Arc::new(ServerCtx { handler: RequestHandler::new(db), games_folder, }); while let Some(connection) = server.accept().await { - let conn_ctx = Arc::new(ConnectionCtx { - server_ctx: server_ctx.clone(), - }); - + let ctx = ctx.clone(); // spawn a new task for the connection tokio::spawn(async move { - if let Err(e) = handle_connection(connection, conn_ctx).await { + if let Err(e) = handle_connection(connection, ctx).await { tracing::error!("Connection error: {}", e); } }); } + tracing::info!("Server shutting down"); + Ok(()) } diff --git a/crates/lanspread-server/src/req.rs b/crates/lanspread-server/src/req.rs index e56567f..b842106 100644 --- a/crates/lanspread-server/src/req.rs +++ b/crates/lanspread-server/src/req.rs @@ -3,12 +3,12 @@ use std::{ sync::Arc, }; -use bytes::Bytes; +use bytes::{Bytes, BytesMut}; use lanspread_db::db::{GameDB, GameFileDescription}; -use lanspread_proto::{Request, Response}; +use lanspread_proto::{Message as _, Request, Response}; use lanspread_utils::maybe_addr; use s2n_quic::stream::SendStream; -use tokio::sync::RwLock; +use tokio::{io::AsyncReadExt, sync::RwLock, time::Instant}; use walkdir::WalkDir; #[derive(Clone, Debug)] @@ -23,12 +23,33 @@ impl RequestHandler { } } - pub(crate) async fn handle_request(&self, request: Request, games_folder: &Path) -> Response { + pub(crate) async fn handle_request( + &self, + request: Request, + games_folder: &Path, + tx: &mut SendStream, + ) -> eyre::Result<()> { + let remote_addr = maybe_addr!(tx.connection().remote_addr()); + + // process request and generate response + let response = self.process_request(request, games_folder).await; + tracing::trace!("{remote_addr} server response: {response:?}"); + + // write response back to client + tx.send(response.encode()).await?; + + // close the stream + tx.close().await?; + + Ok(()) + } + + pub(crate) async fn process_request(&self, request: Request, games_folder: &Path) -> Response { match request { Request::Ping => Response::Pong, Request::ListGames => { let db = self.db.read().await; - Response::Games(db.all_games().into_iter().cloned().collect()) + Response::ListGames(db.all_games().into_iter().cloned().collect()) } Request::GetGame { id } => { if self.db.read().await.get_game_by_id(&id).is_none() { @@ -71,19 +92,15 @@ impl RequestHandler { } } - Response::GetGame(game_files_descs) + Response::GetGame { + id, + file_descriptions: game_files_descs, + } } Request::GetGameFileData(_) => { Response::InvalidRequest(Bytes::new(), "Not implemented".to_string()) } - Request::Invalid(data, err_msg) => { - tracing::error!( - "got invalid request from client (error: {}): {}", - err_msg, - String::from_utf8_lossy(&data) - ); - Response::InvalidRequest(data, err_msg) - } + Request::Invalid(data, err_msg) => Response::InvalidRequest(data, err_msg), } } } @@ -98,14 +115,48 @@ pub(crate) async fn send_game_file_data( tracing::debug!("{remote_addr} client requested game file data: {game_file_desc:?}",); // deliver file data to client - let path = game_dir.join(&game_file_desc.relative_path); + let game_file = game_dir.join(&game_file_desc.relative_path); - if let Ok(mut file) = tokio::fs::File::open(&path).await { - if let Err(e) = tokio::io::copy(&mut file, tx).await { - tracing::error!("{remote_addr} failed to send file data: {e}",); + let mut total_bytes = 0; + let mut last_total_bytes = 0; + let mut timestamp = Instant::now(); + + if let Ok(mut f) = tokio::fs::File::open(&game_file).await { + let mut buf = BytesMut::with_capacity(64 * 1024); + while let Ok(bytes_read) = f.read_buf(&mut buf).await { + if bytes_read == 0 { + break; + } + + total_bytes += bytes_read; + + if last_total_bytes + 10_000_000 < total_bytes { + let elapsed = timestamp.elapsed(); + let diff_bytes = total_bytes - last_total_bytes; + + if elapsed.as_secs_f64() >= 1.0 { + #[allow(clippy::cast_precision_loss)] + let mb_per_s = (diff_bytes as f64) / (elapsed.as_secs_f64() * 1000.0 * 1000.0); + + tracing::debug!( + "{remote_addr} sending file data: {game_file:?}, MB/s: {mb_per_s:.2}", + ); + last_total_bytes = total_bytes; + timestamp = Instant::now(); + } + } + + if let Err(e) = tx.send(buf.split_to(bytes_read).freeze()).await { + tracing::error!("{remote_addr} failed to send file data: {e}",); + break; + } } + + tracing::debug!( + "{remote_addr} finished sending file data: {game_file:?}, total_bytes: {total_bytes}", + ); } else { - tracing::error!("{remote_addr} failed to open file: {}", path.display()); + tracing::error!("{remote_addr} failed to open file: {}", game_file.display()); } if let Err(e) = tx.close().await { diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index a31ecb3..b112870 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -21,7 +21,7 @@ struct LanSpreadState { client_ctrl: UnboundedSender, games: Arc>, games_in_download: Arc>>, - games_dir: Arc>, + games_folder: Arc>, } #[tauri::command] @@ -35,11 +35,9 @@ fn request_games(state: tauri::State) { #[tauri::command] fn install_game(id: String, state: tauri::State) -> bool { - log::error!("Running game with id {id}"); - let already_in_download = tauri::async_runtime::block_on(async { if state.inner().games_in_download.lock().await.contains(&id) { - log::error!("Game is already downloading: {id}"); + log::warn!("Game is already downloading: {id}"); return true; } false @@ -62,16 +60,16 @@ fn run_game(id: String, state: tauri::State) { log::error!("run_game {id}"); - let games_dir = - tauri::async_runtime::block_on(async { state.inner().games_dir.lock().await.clone() }); + let games_folder = + tauri::async_runtime::block_on(async { state.inner().games_folder.lock().await.clone() }); - let games_dir = PathBuf::from(games_dir); - if !games_dir.exists() { - log::error!("games_dir {games_dir:?} does not exist"); + let games_folder = PathBuf::from(games_folder); + if !games_folder.exists() { + log::error!("games_folder {games_folder:?} does not exist"); return; } - let game_path = games_dir.join(id); + let game_path = games_folder.join(id); let game_setup_bin = game_path.join("game_setup.cmd"); let game_start_bin = game_path.join("game_start.cmd"); @@ -84,7 +82,7 @@ fn run_game(id: String, state: tauri::State) { { log::error!("failed to run game_setup.cmd: {e}"); return; - } else if let Err(e) = std::fs::File::create(FIRST_START_DONE_FILE) { + } else if let Err(e) = File::create(FIRST_START_DONE_FILE) { log::error!("failed to create {first_start_done_file:?}: {e}"); } } @@ -104,7 +102,7 @@ fn set_game_install_state_from_path(game_db: &mut GameDB, path: &Path, installed if let Some(file_name) = file_name.to_str() { if let Some(game) = game_db.get_mut_game_by_id(file_name) { if installed { - log::info!("Game is installed: {game}"); + log::debug!("Game is installed: {game}"); } else { log::error!("Game is missing: {game}"); } @@ -126,14 +124,14 @@ fn update_game_directory(app_handle: tauri::AppHandle, path: String) { { tauri::async_runtime::block_on(async { - let mut games_dir = app_handle + let mut games_folder = app_handle .state::() .inner() - .games_dir + .games_folder .lock() .await; - *games_dir = path.clone(); + *games_folder = path.clone(); }); } @@ -167,7 +165,7 @@ fn update_game_directory(app_handle: tauri::AppHandle, path: String) { if let Ok(path_type) = entry.file_type() { if path_type.is_dir() { let path = entry.path(); - if path.join(".softlan_game_installed").exists() { + if path.join("version.ini").exists() { set_game_install_state_from_path(&mut game_db, &path, true); } } @@ -249,9 +247,13 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R .to_str() .ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?; - log::error!("SIDECARE: {:?}", &sidecar); + log::info!( + "unrar game: {} to {}", + rar_file.canonicalize()?.display(), + dest_dir + ); - sidecar + let out = sidecar .arg("x") // extract files .arg(rar_file.canonicalize()?) .arg("-y") // Assume Yes on all queries @@ -260,6 +262,10 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R .output() .await?; + if !out.status.success() { + log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr)); + } + return Ok(()); } else { log::error!("dest_dir canonicalize failed: {:?}", &dest_dir); @@ -274,20 +280,13 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R bail!("failed to create directory: {dest_dir:?}"); } -async fn unpack_game(id: &str, sidecar: Command, games_dir: String) { - let game_path = PathBuf::from(games_dir).join(id); +async fn unpack_game(id: &str, sidecar: Command, games_folder: String) { + let game_path = PathBuf::from(games_folder).join(id); let eti_rar = game_path.join(format!("{id}.eti")); let local_path = game_path.join("local"); if let Err(e) = do_unrar(sidecar, &eti_rar, &local_path).await { log::error!("{eti_rar:?} -> {local_path:?}: {e}"); - } else { - let game_installed_file = game_path.join(".softlan_game_installed"); - if let Err(e) = File::create(game_installed_file) { - log::error!("failed to create game_installed_file: {e}"); - } else { - log::info!("game unpacked: {id}"); - } } } @@ -299,8 +298,6 @@ pub fn run() { tauri_plugin_log::TargetKind::Stdout, )) .level(log::LevelFilter::Info) - .level_for("lanspread_client", log::LevelFilter::Debug) - .level_for("lanspread_tauri_leptos_lib", log::LevelFilter::Debug) .level_for("mdns_sd::service_daemon", log::LevelFilter::Off); // channel to pass commands to the client @@ -316,7 +313,7 @@ pub fn run() { client_ctrl: tx_client_control, games: Arc::new(Mutex::new(GameDB::empty())), games_in_download: Arc::new(Mutex::new(HashSet::new())), - games_dir: Arc::new(Mutex::new("".to_string())), + games_folder: Arc::new(Mutex::new("".to_string())), }; tauri::Builder::default() @@ -347,13 +344,12 @@ pub fn run() { log::info!("ClientEvent::ListGames received"); update_game_db(games, app_handle.clone()).await; } - ClientEvent::GotGameFiles(game_file_descs) => { + ClientEvent::GotGameFiles { id, file_descriptions } => { log::info!("ClientEvent::GotGameFiles received"); - if let Some(first_desc_file) = game_file_descs.first() { if let Err(e) = app_handle.emit( "game-download-pre", - Some(first_desc_file.game_id.clone()), + Some(id.clone()), ) { log::error!("ClientEvent::GotGameFiles: Failed to emit game-download-pre event: {e}"); } @@ -362,11 +358,12 @@ pub fn run() { .state::() .inner() .client_ctrl - .send(ClientCommand::DownloadGameFiles(game_file_descs)) + .send(ClientCommand::DownloadGameFiles{ + id, + file_descriptions, + }) .unwrap(); - } else { - log::error!("ClientEvent::GotGameFiles: Got empty game files list"); - } + } ClientEvent::DownloadGameFilesBegin { id } => { log::info!("ClientEvent::DownloadGameFilesBegin received"); @@ -398,16 +395,16 @@ pub fn run() { .remove(&id.clone()); - let games_dir = app_handle + let games_folder = app_handle .state::() .inner() - .games_dir + .games_folder .lock() .await .clone(); if let Ok(sidecar) = app_handle.shell().sidecar("unrar") { - unpack_game(&id, sidecar, games_dir).await; + unpack_game(&id, sidecar, games_folder).await; log::info!("ClientEvent::UnpackGameFinished received"); if let Err(e) = app_handle.emit("game-unpack-finished", Some(id.clone())) { @@ -415,6 +412,21 @@ pub fn run() { } } } + ClientEvent::DownloadGameFilesFailed { id } => { + log::warn!("ClientEvent::DownloadGameFilesFailed received"); + + if let Err(e) = app_handle.emit("game-download-failed", Some(id.clone())) { + log::error!("Failed to emit game-download-failed event: {e}"); + } + + app_handle + .state::() + .inner() + .games_in_download + .lock() + .await + .remove(&id.clone()); + }, } } }); diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index 32851ba..b12d83f 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -48,13 +48,37 @@ const App = () => { } }; + useEffect(() => { + // Listen for game-download-failed events specifically + const setupDownloadFailedListener = async () => { + const unlisten = await listen('game-download-failed', (event) => { + const game_id = event.payload as string; + console.log(`❌ game-download-failed ${game_id} event received`); + setGameItems(prev => prev.map(item => item.id === game_id + ? {...item, install_status: InstallStatus.NotInstalled} + : item)); + + // Convert to string explicitly and verify it's not empty + const pathString = String(gameDir); + if (!pathString) { + console.error('gameDir is empty before invoke!'); + return; + } + invoke('update_game_directory', { path: pathString }) + .catch(error => console.error('❌ Error updating game directory:', error)); + }); + return unlisten; + }; + + setupDownloadFailedListener(); + }, [gameDir]); + useEffect(() => { // Listen for game-unpack-finished events specifically const setupUnpackListener = async () => { const unlisten = await listen('game-unpack-finished', (event) => { const game_id = event.payload as string; console.log(`🗲 game-unpack-finished ${game_id} event received`); - console.log('Current gameDir in listener:', gameDir); // Add this log setGameItems(prev => prev.map(item => item.id === game_id ? {...item, install_status: InstallStatus.Installed} : item));