#![allow(clippy::missing_errors_doc)] use std::{fs::File, io::Write as _, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use bytes::BytesMut; use lanspread_db::db::{Game, GameFileDescription}; use lanspread_proto::{Message as _, Request, Response}; use lanspread_utils::maybe_addr; use s2n_quic::{Client as QuicClient, Connection, client::Connect, provider::limits::Limits}; use tokio::{ io::AsyncWriteExt, sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, }, }; static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem")); #[derive(Debug)] pub enum ClientEvent { ListGames(Vec), GotGameFiles { id: String, file_descriptions: Vec, }, DownloadGameFilesBegin { id: String, }, DownloadGameFilesFinished { id: String, }, DownloadGameFilesFailed { id: String, }, } #[derive(Debug)] pub enum ClientCommand { ListGames, GetGame(String), DownloadGameFiles { id: String, file_descriptions: Vec, }, ServerAddr(SocketAddr), SetGameDir(String), } async fn initial_server_alive_check(conn: &mut Connection) -> bool { let stream = match conn.open_bidirectional_stream().await { Ok(stream) => stream, Err(e) => { log::error!("failed to open stream: {e}"); return false; } }; let (mut rx, mut tx) = stream.split(); // send ping if let Err(e) = tx.send(Request::Ping.encode()).await { log::error!("failed to send ping to server: {e}"); return false; } let _ = tx.close().await; // receive pong if let Ok(Some(response)) = rx.receive().await { let response = Response::decode(response); if let Response::Pong = response { log::info!("server is alive"); return true; } log::error!("server sent invalid response to ping: {response:?}"); } 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_folder: String, server_addr: SocketAddr, tx_notify_ui: UnboundedSender, ) -> eyre::Result<()> { let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?; let client = QuicClient::builder() .with_tls(CERT_PEM)? .with_io("0.0.0.0:0")? .with_limits(limits)? .start()?; let conn = Connect::new(server_addr).with_server_name("localhost"); let mut conn = client.connect(conn).await?; conn.keep_alive(true)?; let game_files = game_file_descs .iter() .filter(|desc| !desc.is_dir) .filter(|desc| !desc.is_version_ini()) .collect::>(); if game_files.is_empty() { eyre::bail!("game_file_descs empty: no game files to download"); } tx_notify_ui.send(ClientEvent::DownloadGameFilesBegin { id: game_id.to_string(), })?; // 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.to_string(), })?; Ok(()) } struct Ctx { game_dir: Arc>>, } #[allow(clippy::too_many_lines)] pub async fn run( mut rx_control: UnboundedReceiver, tx_notify_ui: UnboundedSender, ) -> eyre::Result<()> { // blocking wait for remote address log::debug!("waiting for server address"); let server_addr = loop { if let Some(ClientCommand::ServerAddr(addr)) = rx_control.recv().await { log::info!("got server address: {addr}"); break addr; } }; // client context let ctx = Ctx { game_dir: Arc::new(RwLock::new(None)), }; loop { let limits = Limits::default() .with_max_handshake_duration(Duration::from_secs(3))? .with_max_idle_timeout(Duration::from_secs(3))?; let client = QuicClient::builder() .with_tls(CERT_PEM)? .with_io("0.0.0.0:0")? .with_limits(limits)? .start()?; 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}"); tokio::time::sleep(Duration::from_secs(3)).await; continue; } }; conn.keep_alive(true)?; if !initial_server_alive_check(&mut conn).await { continue; } log::info!( "connected: (server: {}) (client: {})", maybe_addr!(conn.remote_addr()), maybe_addr!(conn.local_addr()) ); // tx while let Some(cmd) = rx_control.recv().await { let request = match cmd { ClientCommand::ListGames => Request::ListGames, ClientCommand::GetGame(id) => { log::info!("requesting game from server: {id}"); Request::GetGame { id } } ClientCommand::ServerAddr(_) => { log::warn!("unexpected ServerAddr command from UI client"); continue; } ClientCommand::SetGameDir(game_dir) => { *ctx.game_dir.write().await = Some(game_dir.clone()); continue; } ClientCommand::DownloadGameFiles { id, file_descriptions, } => { log::info!("got ClientCommand::DownloadGameFiles"); let games_folder = { ctx.game_dir.read().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( &id, file_descriptions, games_folder, server_addr, 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: 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::trace!("encoded data: {}", String::from_utf8_lossy(&data)); let stream = match conn.open_bidirectional_stream().await { Ok(stream) => stream, Err(e) => { log::error!("failed to open stream: {e}"); break; } }; let (mut rx, mut tx) = stream.split(); if let Err(e) = tx.send(data).await { log::error!("failed to send request to server {e:?}"); } let mut data = BytesMut::new(); while let Ok(Some(bytes)) = rx.receive().await { data.extend_from_slice(&bytes); } log::debug!("{} bytes received from server", data.len()); log::trace!("msg: (raw): {}", String::from_utf8_lossy(&data)); let response = Response::decode(data.freeze()); log::trace!("msg: {response:?}"); match response { Response::ListGames(games) => { for game in &games { log::trace!("{game}"); } if let Err(e) = tx_notify_ui.send(ClientEvent::ListGames(games)) { log::error!("failed to send ClientEvent::ListGames to client {e:?}"); } } Response::GetGame { id, file_descriptions, } => { log::info!( "got {} game file descriptions from server", file_descriptions.len() ); let games_folder = { ctx.game_dir.read().await.clone() }; 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}" ); } } None => { log::error!( "Cannot handle game file descriptions: game_dir is not set" ); } } } Response::GameNotFound(id) => log::debug!("game not found {id}"), Response::InvalidRequest(request_bytes, err) => log::error!( "server says our request was invalid (error: {}): {}", err, String::from_utf8_lossy(&request_bytes) ), Response::EncodingError(err) => { log::error!("server encoding error: {err}"); } Response::DecodingError(data, err) => { log::error!( "response decoding error: {} (data: {})", err, String::from_utf8_lossy(&data) ); } Response::Pong => (), // ignore (should never happen) } if let Err(err) = tx.close().await { log::error!("failed to close stream: {err}"); } } } }