Compare commits
3 Commits
d1eb185498
...
adf6f9d757
Author | SHA1 | Date | |
---|---|---|---|
adf6f9d757 | |||
bcf9ad68ad | |||
b21091c247 |
1811
Cargo.lock
generated
1811
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -15,9 +15,9 @@ resolver = "2"
|
||||
bytes = { version = "1.9", features = ["serde"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
eyre = "0.6"
|
||||
itertools = "0.13"
|
||||
itertools = "0.14"
|
||||
log = "0.4"
|
||||
mdns-sd = "0.12"
|
||||
mdns-sd = "0.13"
|
||||
s2n-quic = { version = "1.51", features = ["provider-event-tracing"] }
|
||||
semver = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lanspread-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
@ -6,12 +6,12 @@ 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::Connect, provider::limits::Limits, Client as QuicClient, Connection};
|
||||
use s2n_quic::{Client as QuicClient, Connection, client::Connect, provider::limits::Limits};
|
||||
use tokio::{
|
||||
io::AsyncWriteExt,
|
||||
sync::{
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
Mutex,
|
||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
},
|
||||
};
|
||||
|
||||
@ -72,9 +72,7 @@ async fn download_game_files(
|
||||
server_addr: SocketAddr,
|
||||
tx_notify_ui: UnboundedSender<ClientEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
let limits = Limits::default()
|
||||
.with_max_handshake_duration(Duration::from_secs(3))?
|
||||
.with_max_idle_timeout(Duration::from_secs(1))?;
|
||||
let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?;
|
||||
|
||||
let client = QuicClient::builder()
|
||||
.with_tls(CERT_PEM)?
|
||||
@ -173,7 +171,7 @@ pub async fn run(
|
||||
loop {
|
||||
let limits = Limits::default()
|
||||
.with_max_handshake_duration(Duration::from_secs(3))?
|
||||
.with_max_idle_timeout(Duration::from_secs(1))?;
|
||||
.with_max_idle_timeout(Duration::ZERO)?;
|
||||
|
||||
let client = QuicClient::builder()
|
||||
.with_tls(CERT_PEM)?
|
||||
|
@ -1,9 +1,12 @@
|
||||
[package]
|
||||
name = "lanspread-compat"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# local
|
||||
lanspread-db = { path = "../lanspread-db" }
|
||||
|
||||
eyre = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
@ -1,5 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use lanspread_db::db::Game;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
|
||||
@ -45,3 +46,21 @@ pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
|
||||
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
impl From<EtiGame> for Game {
|
||||
fn from(eti_game: EtiGame) -> Self {
|
||||
Self {
|
||||
id: eti_game.game_id,
|
||||
name: eti_game.game_title,
|
||||
description: eti_game.game_readme_de,
|
||||
release_year: eti_game.game_release,
|
||||
publisher: eti_game.game_publisher,
|
||||
max_players: eti_game.game_maxplayers,
|
||||
version: eti_game.game_version,
|
||||
genre: eti_game.genre_de,
|
||||
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
||||
thumbnail: None,
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lanspread-db"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
@ -1,7 +1,7 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::{collections::HashMap, fmt};
|
||||
use std::{collections::HashMap, fmt, path::Path};
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -93,6 +93,17 @@ impl GameDB {
|
||||
db
|
||||
}
|
||||
|
||||
pub fn add_thumbnails(&mut self, thumbs_dir: &Path) {
|
||||
for game in self.games.values_mut() {
|
||||
let asset = thumbs_dir.join(format!("{}.jpg", game.id));
|
||||
if let Ok(data) = std::fs::read(&asset) {
|
||||
game.thumbnail = Some(Bytes::from(data));
|
||||
} else {
|
||||
tracing::warn!("Thumbnail missing: {}", game.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_game_by_id<S>(&self, id: S) -> Option<&Game>
|
||||
where
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lanspread-mdns"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mdns-sd = { workspace = true }
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lanspread-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lanspread-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
@ -1,24 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
pub(crate) struct Thumbnails {
|
||||
folder: PathBuf,
|
||||
}
|
||||
|
||||
impl Thumbnails {
|
||||
pub(crate) fn new(folder: PathBuf) -> Thumbnails {
|
||||
Thumbnails { folder }
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, path: &str) -> Option<Bytes> {
|
||||
let asset = self.folder.join(format!("{path}.jpg"));
|
||||
|
||||
if let Ok(data) = std::fs::read(asset) {
|
||||
return Some(Bytes::from(data));
|
||||
}
|
||||
|
||||
tracing::warn!("Thumbnail not found: {path}");
|
||||
None
|
||||
}
|
||||
}
|
23
crates/lanspread-server/src/cli.rs
Normal file
23
crates/lanspread-server/src/cli.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use std::{net::IpAddr, path::PathBuf};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[allow(clippy::doc_markdown)]
|
||||
#[derive(Debug, Parser)]
|
||||
pub(crate) struct Cli {
|
||||
/// IP address to bind to.
|
||||
#[clap(long)]
|
||||
pub(crate) ip: IpAddr,
|
||||
/// Listen port.
|
||||
#[clap(long)]
|
||||
pub(crate) port: u16,
|
||||
/// Game database path (SQLite).
|
||||
#[clap(long)]
|
||||
pub(crate) db: PathBuf,
|
||||
/// Games folder.
|
||||
#[clap(long)]
|
||||
pub(crate) game_dir: PathBuf,
|
||||
/// Thumbnails folder.
|
||||
#[clap(long)]
|
||||
pub(crate) thumbs_dir: PathBuf,
|
||||
}
|
@ -1,304 +1,54 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
mod cli;
|
||||
mod quic;
|
||||
mod req;
|
||||
|
||||
use std::{
|
||||
fs::File,
|
||||
io::Read as _,
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{convert::Into, net::SocketAddr};
|
||||
|
||||
use assets::Thumbnails;
|
||||
use bytes::Bytes;
|
||||
use clap::Parser;
|
||||
use lanspread_compat::eti::{self, EtiGame};
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use clap::Parser as _;
|
||||
use cli::Cli;
|
||||
use lanspread_compat::eti;
|
||||
use lanspread_db::db::{Game, GameDB};
|
||||
use lanspread_mdns::{
|
||||
DaemonEvent,
|
||||
MdnsAdvertiser,
|
||||
LANSPREAD_INSTANCE_NAME,
|
||||
LANSPREAD_SERVICE_TYPE,
|
||||
DaemonEvent, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, MdnsAdvertiser,
|
||||
};
|
||||
use lanspread_proto::{Message as _, Request, Response};
|
||||
use lanspread_utils::maybe_addr;
|
||||
use s2n_quic::Server as QuicServer;
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
use quic::run_server;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
mod assets;
|
||||
fn spawn_mdns_task(server_addr: SocketAddr) -> eyre::Result<()> {
|
||||
let mdns = MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, LANSPREAD_INSTANCE_NAME, server_addr)?;
|
||||
|
||||
static KEY_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../key.pem"));
|
||||
static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem"));
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ServerCtx {
|
||||
handler: RequestHandler,
|
||||
games_folder: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ConnectionCtx {
|
||||
server_ctx: Arc<ServerCtx>,
|
||||
remote_addr: String,
|
||||
}
|
||||
|
||||
async fn run(addr: SocketAddr, db: GameDB, games_folder: PathBuf) -> eyre::Result<()> {
|
||||
let mut server = QuicServer::builder()
|
||||
.with_tls((CERT_PEM, KEY_PEM))?
|
||||
.with_io(addr)?
|
||||
.start()?;
|
||||
|
||||
let server_ctx = Arc::new(ServerCtx {
|
||||
handler: RequestHandler::new(db),
|
||||
games_folder,
|
||||
});
|
||||
|
||||
while let Some(mut connection) = server.accept().await {
|
||||
let conn_ctx = Arc::new(ConnectionCtx {
|
||||
server_ctx: server_ctx.clone(),
|
||||
remote_addr: maybe_addr!(connection.remote_addr()),
|
||||
});
|
||||
// spawn a new task for the connection
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("{} connected", conn_ctx.remote_addr);
|
||||
|
||||
while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await {
|
||||
let (mut rx, mut tx) = stream.split();
|
||||
|
||||
let conn_ctx = conn_ctx.clone();
|
||||
// spawn a new task for the stream
|
||||
tokio::spawn(async move {
|
||||
tracing::trace!("{} stream opened", conn_ctx.remote_addr);
|
||||
|
||||
// handle streams
|
||||
while let Ok(Some(data)) = rx.receive().await {
|
||||
tracing::trace!(
|
||||
"{} msg: (raw): {}",
|
||||
conn_ctx.remote_addr,
|
||||
String::from_utf8_lossy(&data)
|
||||
);
|
||||
|
||||
let request = Request::decode(data);
|
||||
tracing::debug!("{} msg: {:?}", conn_ctx.remote_addr, request);
|
||||
|
||||
if let Request::GetGameFileData(game_file_desc) = &request {
|
||||
tracing::debug!(
|
||||
"{} client requested game file data: {:?}",
|
||||
conn_ctx.remote_addr,
|
||||
game_file_desc
|
||||
);
|
||||
|
||||
// deliver file data to client
|
||||
let path = conn_ctx
|
||||
.server_ctx
|
||||
.games_folder
|
||||
.join(&game_file_desc.relative_path);
|
||||
|
||||
if let Ok(mut file) = File::open(&path) {
|
||||
let mut buf = vec![0; 64 * 1024];
|
||||
|
||||
while let Ok(n) = file.read(&mut buf) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Err(e) = tx.write_all(&buf[..n]).await {
|
||||
tracing::error!(
|
||||
"{} failed to send file data: {}",
|
||||
conn_ctx.remote_addr,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// if let Err(e) = tokio::io::copy(&mut file, &mut tx).await {
|
||||
// tracing::error!(
|
||||
// "{} failed to send file data: {}",
|
||||
// conn_ctx.remote_addr,
|
||||
// e
|
||||
// );
|
||||
// }
|
||||
} else {
|
||||
tracing::error!(
|
||||
"{} failed to open file: {}",
|
||||
conn_ctx.remote_addr,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = tx.close().await {
|
||||
tracing::error!(
|
||||
"{} failed to close stream: {}",
|
||||
conn_ctx.remote_addr,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
while let Ok(event) = mdns.monitor.recv() {
|
||||
tracing::info!("mDNS: {:?}", &event);
|
||||
if let DaemonEvent::Error(e) = event {
|
||||
tracing::error!("mDNS: {e}");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = conn_ctx
|
||||
.server_ctx
|
||||
.handler
|
||||
.handle_request(request, &conn_ctx)
|
||||
.await;
|
||||
|
||||
tracing::trace!("{} server response: {:?}", conn_ctx.remote_addr, response);
|
||||
let raw_response = response.encode();
|
||||
tracing::trace!(
|
||||
"{} server response (raw): {}",
|
||||
conn_ctx.remote_addr,
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_relative_path(base: &Path, deep_path: &Path) -> std::io::Result<PathBuf> {
|
||||
let base_canonical = base.canonicalize()?;
|
||||
let full_canonical = deep_path.canonicalize()?;
|
||||
|
||||
full_canonical
|
||||
.strip_prefix(&base_canonical)
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Path is not within base directory",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RequestHandler {
|
||||
db: Arc<Mutex<GameDB>>,
|
||||
}
|
||||
|
||||
impl RequestHandler {
|
||||
fn new(games: GameDB) -> RequestHandler {
|
||||
RequestHandler {
|
||||
db: Arc::new(Mutex::new(games)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(&self, request: Request, conn_ctx: &ConnectionCtx) -> Response {
|
||||
match request {
|
||||
Request::Ping => Response::Pong,
|
||||
Request::ListGames => {
|
||||
let db = self.db.lock().await;
|
||||
Response::Games(db.all_games().into_iter().cloned().collect())
|
||||
}
|
||||
Request::GetGame { id } => {
|
||||
if self.db.lock().await.get_game_by_id(&id).is_none() {
|
||||
tracing::error!("Game not found in DB: {id}");
|
||||
return Response::GameNotFound(id);
|
||||
}
|
||||
|
||||
let games_folder = &conn_ctx.server_ctx.games_folder;
|
||||
|
||||
let game_dir = games_folder.join(&id);
|
||||
if !game_dir.exists() {
|
||||
tracing::error!("Game folder does not exist: {}", game_dir.display());
|
||||
return Response::GameNotFound(id);
|
||||
}
|
||||
|
||||
let mut game_files_descs: Vec<GameFileDescription> = vec![];
|
||||
|
||||
for entry in WalkDir::new(&game_dir)
|
||||
async fn prepare_game_db(cli: &Cli) -> eyre::Result<GameDB> {
|
||||
// build games from ETI database
|
||||
let mut games: Vec<Game> = eti::get_games(&cli.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(std::result::Result::ok)
|
||||
{
|
||||
match get_relative_path(games_folder, entry.path()) {
|
||||
Ok(relative_path) => match relative_path.to_str() {
|
||||
Some(relative_path) => {
|
||||
let game_file_description = GameFileDescription {
|
||||
game_id: id.clone(),
|
||||
relative_path: relative_path.to_string(),
|
||||
is_dir: entry.file_type().is_dir(),
|
||||
};
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
tracing::debug!("Found game file: {:?}", game_file_description);
|
||||
// filter out games that the server does not have in game_dir
|
||||
games.retain(|game| cli.game_dir.join(&game.id).is_dir());
|
||||
|
||||
game_files_descs.push(game_file_description);
|
||||
}
|
||||
None => {
|
||||
tracing::error!("Failed to get relative path: {relative_path:?}",);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get relative path: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut game_db = GameDB::from(games);
|
||||
|
||||
Response::GetGame(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
game_db.add_thumbnails(&cli.thumbs_dir);
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {
|
||||
/// IP address to bind to.
|
||||
#[clap(long)]
|
||||
ip: IpAddr,
|
||||
/// Listen port.
|
||||
#[clap(long)]
|
||||
port: u16,
|
||||
/// Game database path (SQLite).
|
||||
#[clap(long)]
|
||||
db: PathBuf,
|
||||
/// Games folder.
|
||||
#[clap(long)]
|
||||
folder: PathBuf,
|
||||
/// Thumbnails folder.
|
||||
#[clap(long)]
|
||||
thumbnails: PathBuf,
|
||||
}
|
||||
tracing::info!("Prepared game database with {} games", game_db.games.len());
|
||||
|
||||
fn eti_game_to_game(eti_game: EtiGame) -> Game {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
Game {
|
||||
id: eti_game.game_id,
|
||||
name: eti_game.game_title,
|
||||
description: eti_game.game_readme_de,
|
||||
release_year: eti_game.game_release,
|
||||
publisher: eti_game.game_publisher,
|
||||
max_players: eti_game.game_maxplayers,
|
||||
version: eti_game.game_version,
|
||||
genre: eti_game.genre_de,
|
||||
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
||||
thumbnail: None,
|
||||
installed: false,
|
||||
}
|
||||
Ok(game_db)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@ -309,57 +59,19 @@ async fn main() -> eyre::Result<()> {
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
#[allow(clippy::manual_assert)]
|
||||
if !cli.folder.exists() {
|
||||
panic!(
|
||||
assert!(
|
||||
cli.game_dir.exists(),
|
||||
"Games folder does not exist: {}",
|
||||
cli.folder.to_str().expect("Invalid path")
|
||||
cli.game_dir.to_str().expect("Invalid path")
|
||||
);
|
||||
}
|
||||
|
||||
let eti_games = eti::get_games(&cli.db).await?;
|
||||
let mut games: Vec<Game> = eti_games.into_iter().map(eti_game_to_game).collect();
|
||||
|
||||
// Filter games based on existing folders
|
||||
games.retain(|game| {
|
||||
let game_folder = cli.folder.join(&game.id);
|
||||
let exists = game_folder.exists() && game_folder.is_dir();
|
||||
if !exists {
|
||||
tracing::debug!("Skipping game {}: folder not found", game.id);
|
||||
}
|
||||
exists
|
||||
});
|
||||
|
||||
let thumbnails = Thumbnails::new(cli.thumbnails);
|
||||
|
||||
// add thumbnails to games
|
||||
for game in &mut games {
|
||||
if let Some(thumbnail) = thumbnails.get(&game.id) {
|
||||
game.thumbnail = Some(thumbnail);
|
||||
} else {
|
||||
tracing::warn!("No thumbnail found: {}", game.id);
|
||||
}
|
||||
}
|
||||
|
||||
let game_db = GameDB::from(games);
|
||||
|
||||
let mdns = MdnsAdvertiser::new(
|
||||
LANSPREAD_SERVICE_TYPE,
|
||||
LANSPREAD_INSTANCE_NAME,
|
||||
(cli.ip, cli.port).into(),
|
||||
)?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = mdns.monitor.recv() {
|
||||
tracing::info!("mDNS: {:?}", &event);
|
||||
if let DaemonEvent::Error(e) = event {
|
||||
tracing::error!("mDNS: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("Server listening on {}:{}", cli.ip, cli.port);
|
||||
|
||||
run(SocketAddr::from((cli.ip, cli.port)), game_db, cli.folder).await
|
||||
|
||||
let server_addr = SocketAddr::from((cli.ip, cli.port));
|
||||
|
||||
// spawn mDNS listener task
|
||||
spawn_mdns_task(server_addr)?;
|
||||
|
||||
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
|
||||
}
|
||||
|
152
crates/lanspread-server/src/quic.rs
Normal file
152
crates/lanspread-server/src/quic.rs
Normal file
@ -0,0 +1,152 @@
|
||||
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 crate::req::{RequestHandler, send_game_file_data};
|
||||
|
||||
static KEY_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../key.pem"));
|
||||
static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem"));
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ServerCtx {
|
||||
handler: RequestHandler,
|
||||
games_folder: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ConnectionCtx {
|
||||
server_ctx: Arc<ServerCtx>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StreamCtx {
|
||||
conn_ctx: Arc<ConnectionCtx>,
|
||||
}
|
||||
|
||||
async fn handle_bidi_stream(
|
||||
mut rx: ReceiveStream,
|
||||
mut tx: SendStream,
|
||||
ctx: Arc<StreamCtx>,
|
||||
) -> 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)
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
mut connection: Connection,
|
||||
ctx: Arc<ConnectionCtx>,
|
||||
) -> 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 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 {
|
||||
tracing::error!("{remote_addr} stream error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn run_server(
|
||||
addr: SocketAddr,
|
||||
db: GameDB,
|
||||
games_folder: PathBuf,
|
||||
) -> eyre::Result<()> {
|
||||
let limits = Limits::default()
|
||||
.with_max_idle_timeout(Duration::ZERO)?
|
||||
.with_max_handshake_duration(Duration::from_secs(3))?;
|
||||
|
||||
let mut server = Server::builder()
|
||||
.with_tls((CERT_PEM, KEY_PEM))?
|
||||
.with_io(addr)?
|
||||
.with_limits(limits)?
|
||||
.start()?;
|
||||
|
||||
let server_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(),
|
||||
});
|
||||
|
||||
// spawn a new task for the connection
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(connection, conn_ctx).await {
|
||||
tracing::error!("Connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
129
crates/lanspread-server/src/req.rs
Normal file
129
crates/lanspread-server/src/req.rs
Normal file
@ -0,0 +1,129 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use lanspread_proto::{Request, Response};
|
||||
use lanspread_utils::maybe_addr;
|
||||
use s2n_quic::stream::SendStream;
|
||||
use tokio::sync::Mutex;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RequestHandler {
|
||||
db: Arc<Mutex<GameDB>>,
|
||||
}
|
||||
|
||||
impl RequestHandler {
|
||||
pub(crate) fn new(games: GameDB) -> RequestHandler {
|
||||
RequestHandler {
|
||||
db: Arc::new(Mutex::new(games)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_request(&self, request: Request, games_folder: &Path) -> Response {
|
||||
match request {
|
||||
Request::Ping => Response::Pong,
|
||||
Request::ListGames => {
|
||||
let db = self.db.lock().await;
|
||||
Response::Games(db.all_games().into_iter().cloned().collect())
|
||||
}
|
||||
Request::GetGame { id } => {
|
||||
if self.db.lock().await.get_game_by_id(&id).is_none() {
|
||||
tracing::error!("Game not found in DB: {id}");
|
||||
return Response::GameNotFound(id);
|
||||
}
|
||||
|
||||
let game_dir = games_folder.join(&id);
|
||||
if !game_dir.exists() {
|
||||
tracing::error!("Game folder does not exist: {}", game_dir.display());
|
||||
return Response::GameNotFound(id);
|
||||
}
|
||||
|
||||
let mut game_files_descs: Vec<GameFileDescription> = vec![];
|
||||
|
||||
for entry in WalkDir::new(&game_dir)
|
||||
.into_iter()
|
||||
.filter_map(std::result::Result::ok)
|
||||
{
|
||||
match get_relative_path(games_folder, entry.path()) {
|
||||
Ok(relative_path) => match relative_path.to_str() {
|
||||
Some(relative_path) => {
|
||||
let game_file_description = GameFileDescription {
|
||||
game_id: id.clone(),
|
||||
relative_path: relative_path.to_string(),
|
||||
is_dir: entry.file_type().is_dir(),
|
||||
};
|
||||
|
||||
tracing::debug!("Found game file: {:?}", game_file_description);
|
||||
|
||||
game_files_descs.push(game_file_description);
|
||||
}
|
||||
None => {
|
||||
tracing::error!("Failed to get relative path: {relative_path:?}",);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get relative path: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Response::GetGame(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn send_game_file_data(
|
||||
game_file_desc: &GameFileDescription,
|
||||
tx: &mut SendStream,
|
||||
game_dir: &Path,
|
||||
) {
|
||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||
|
||||
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);
|
||||
|
||||
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}",);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("{remote_addr} failed to open file: {}", path.display());
|
||||
}
|
||||
|
||||
if let Err(e) = tx.close().await {
|
||||
tracing::error!("{remote_addr} failed to close stream: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn get_relative_path(base: &Path, deep_path: &Path) -> std::io::Result<PathBuf> {
|
||||
let base_canonical = base.canonicalize()?;
|
||||
let full_canonical = deep_path.canonicalize()?;
|
||||
|
||||
full_canonical
|
||||
.strip_prefix(&base_canonical)
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Path is not within base directory",
|
||||
)
|
||||
})
|
||||
}
|
@ -3,7 +3,7 @@ name = "lanspread-tauri-deno-ts"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
@ -9,10 +9,10 @@ use std::{
|
||||
use eyre::bail;
|
||||
use lanspread_client::{ClientCommand, ClientEvent};
|
||||
use lanspread_db::db::{Game, GameDB};
|
||||
use lanspread_mdns::{discover_service, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE};
|
||||
use lanspread_mdns::{LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, discover_service};
|
||||
use tauri::{AppHandle, Emitter as _, Manager};
|
||||
use tauri_plugin_shell::{process::Command, ShellExt};
|
||||
use tokio::sync::{mpsc::UnboundedSender, Mutex};
|
||||
use tauri_plugin_shell::{ShellExt, process::Command};
|
||||
use tokio::sync::{Mutex, mpsc::UnboundedSender};
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lanspread-utils"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
@ -1,6 +1,8 @@
|
||||
#[macro_export]
|
||||
macro_rules! maybe_addr {
|
||||
($addr:expr) => {
|
||||
$addr.map_or("<unknown>".to_string(), |addr| addr.to_string())
|
||||
$addr.map_or(Arc::new("<unknown>".to_string()), |addr| {
|
||||
Arc::new(addr.to_string())
|
||||
})
|
||||
};
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user