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"] }
|
bytes = { version = "1.9", features = ["serde"] }
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
eyre = "0.6"
|
eyre = "0.6"
|
||||||
itertools = "0.13"
|
itertools = "0.14"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
mdns-sd = "0.12"
|
mdns-sd = "0.13"
|
||||||
s2n-quic = { version = "1.51", features = ["provider-event-tracing"] }
|
s2n-quic = { version = "1.51", features = ["provider-event-tracing"] }
|
||||||
semver = "1.0"
|
semver = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-client"
|
name = "lanspread-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
@ -6,12 +6,12 @@ use bytes::BytesMut;
|
|||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameFileDescription};
|
||||||
use lanspread_proto::{Message as _, Request, Response};
|
use lanspread_proto::{Message as _, Request, Response};
|
||||||
use lanspread_utils::maybe_addr;
|
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::{
|
use tokio::{
|
||||||
io::AsyncWriteExt,
|
io::AsyncWriteExt,
|
||||||
sync::{
|
sync::{
|
||||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
|
||||||
Mutex,
|
Mutex,
|
||||||
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,9 +72,7 @@ async fn download_game_files(
|
|||||||
server_addr: SocketAddr,
|
server_addr: SocketAddr,
|
||||||
tx_notify_ui: UnboundedSender<ClientEvent>,
|
tx_notify_ui: UnboundedSender<ClientEvent>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let limits = Limits::default()
|
let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?;
|
||||||
.with_max_handshake_duration(Duration::from_secs(3))?
|
|
||||||
.with_max_idle_timeout(Duration::from_secs(1))?;
|
|
||||||
|
|
||||||
let client = QuicClient::builder()
|
let client = QuicClient::builder()
|
||||||
.with_tls(CERT_PEM)?
|
.with_tls(CERT_PEM)?
|
||||||
@ -173,7 +171,7 @@ pub async fn run(
|
|||||||
loop {
|
loop {
|
||||||
let limits = Limits::default()
|
let limits = Limits::default()
|
||||||
.with_max_handshake_duration(Duration::from_secs(3))?
|
.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()
|
let client = QuicClient::builder()
|
||||||
.with_tls(CERT_PEM)?
|
.with_tls(CERT_PEM)?
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-compat"
|
name = "lanspread-compat"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# local
|
||||||
|
lanspread-db = { path = "../lanspread-db" }
|
||||||
|
|
||||||
eyre = { workspace = true }
|
eyre = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use lanspread_db::db::Game;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::sqlite::SqlitePool;
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
@ -45,3 +46,21 @@ pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
|
|||||||
|
|
||||||
Ok(games)
|
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]
|
[package]
|
||||||
name = "lanspread-db"
|
name = "lanspread-db"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
#![allow(clippy::missing_errors_doc)]
|
#![allow(clippy::missing_errors_doc)]
|
||||||
#![allow(clippy::doc_markdown)]
|
#![allow(clippy::doc_markdown)]
|
||||||
|
|
||||||
use std::{collections::HashMap, fmt};
|
use std::{collections::HashMap, fmt, path::Path};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -93,6 +93,17 @@ impl GameDB {
|
|||||||
db
|
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]
|
#[must_use]
|
||||||
pub fn get_game_by_id<S>(&self, id: S) -> Option<&Game>
|
pub fn get_game_by_id<S>(&self, id: S) -> Option<&Game>
|
||||||
where
|
where
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-mdns"
|
name = "lanspread-mdns"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mdns-sd = { workspace = true }
|
mdns-sd = { workspace = true }
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-proto"
|
name = "lanspread-proto"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-server"
|
name = "lanspread-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
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::{
|
use std::{convert::Into, net::SocketAddr};
|
||||||
fs::File,
|
|
||||||
io::Read as _,
|
|
||||||
net::{IpAddr, SocketAddr},
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use assets::Thumbnails;
|
use clap::Parser as _;
|
||||||
use bytes::Bytes;
|
use cli::Cli;
|
||||||
use clap::Parser;
|
use lanspread_compat::eti;
|
||||||
use lanspread_compat::eti::{self, EtiGame};
|
use lanspread_db::db::{Game, GameDB};
|
||||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
|
||||||
use lanspread_mdns::{
|
use lanspread_mdns::{
|
||||||
DaemonEvent,
|
DaemonEvent, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, MdnsAdvertiser,
|
||||||
MdnsAdvertiser,
|
|
||||||
LANSPREAD_INSTANCE_NAME,
|
|
||||||
LANSPREAD_SERVICE_TYPE,
|
|
||||||
};
|
};
|
||||||
use lanspread_proto::{Message as _, Request, Response};
|
use quic::run_server;
|
||||||
use lanspread_utils::maybe_addr;
|
|
||||||
use s2n_quic::Server as QuicServer;
|
|
||||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
|
||||||
use tracing_subscriber::EnvFilter;
|
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 {
|
tokio::spawn(async move {
|
||||||
tracing::info!("{} connected", conn_ctx.remote_addr);
|
while let Ok(event) = mdns.monitor.recv() {
|
||||||
|
tracing::info!("mDNS: {:?}", &event);
|
||||||
while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await {
|
if let DaemonEvent::Error(e) = event {
|
||||||
let (mut rx, mut tx) = stream.split();
|
tracing::error!("mDNS: {e}");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_relative_path(base: &Path, deep_path: &Path) -> std::io::Result<PathBuf> {
|
async fn prepare_game_db(cli: &Cli) -> eyre::Result<GameDB> {
|
||||||
let base_canonical = base.canonicalize()?;
|
// build games from ETI database
|
||||||
let full_canonical = deep_path.canonicalize()?;
|
let mut games: Vec<Game> = eti::get_games(&cli.db)
|
||||||
|
.await?
|
||||||
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)
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(std::result::Result::ok)
|
.map(Into::into)
|
||||||
{
|
.collect();
|
||||||
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);
|
// 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);
|
let mut game_db = GameDB::from(games);
|
||||||
}
|
|
||||||
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)
|
game_db.add_thumbnails(&cli.thumbs_dir);
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
tracing::info!("Prepared game database with {} games", game_db.games.len());
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn eti_game_to_game(eti_game: EtiGame) -> Game {
|
Ok(game_db)
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@ -309,57 +59,19 @@ async fn main() -> eyre::Result<()> {
|
|||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
#[allow(clippy::manual_assert)]
|
assert!(
|
||||||
if !cli.folder.exists() {
|
cli.game_dir.exists(),
|
||||||
panic!(
|
|
||||||
"Games folder does not exist: {}",
|
"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 server_addr = SocketAddr::from((cli.ip, cli.port));
|
||||||
let mut games: Vec<Game> = eti_games.into_iter().map(eti_game_to_game).collect();
|
|
||||||
|
|
||||||
// Filter games based on existing folders
|
// spawn mDNS listener task
|
||||||
games.retain(|game| {
|
spawn_mdns_task(server_addr)?;
|
||||||
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);
|
let game_db = prepare_game_db(&cli).await?;
|
||||||
|
|
||||||
// add thumbnails to games
|
tracing::info!("Server listening on {server_addr}");
|
||||||
for game in &mut games {
|
run_server(server_addr, game_db, cli.game_dir).await
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# 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 eyre::bail;
|
||||||
use lanspread_client::{ClientCommand, ClientEvent};
|
use lanspread_client::{ClientCommand, ClientEvent};
|
||||||
use lanspread_db::db::{Game, GameDB};
|
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::{AppHandle, Emitter as _, Manager};
|
||||||
use tauri_plugin_shell::{process::Command, ShellExt};
|
use tauri_plugin_shell::{ShellExt, process::Command};
|
||||||
use tokio::sync::{mpsc::UnboundedSender, Mutex};
|
use tokio::sync::{Mutex, mpsc::UnboundedSender};
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-utils"
|
name = "lanspread-utils"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! maybe_addr {
|
macro_rules! maybe_addr {
|
||||||
($addr:expr) => {
|
($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