Compare commits
No commits in common. "adf6f9d7574a337405d60321cd2da352386501da" and "d1eb185498e55315e682addaa96d0c110cb5b2b6" have entirely different histories.
adf6f9d757
...
d1eb185498
1805
Cargo.lock
generated
1805
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.14"
|
itertools = "0.13"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
mdns-sd = "0.13"
|
mdns-sd = "0.12"
|
||||||
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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[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 as QuicClient, Connection, client::Connect, provider::limits::Limits};
|
use s2n_quic::{client::Connect, provider::limits::Limits, Client as QuicClient, Connection};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::AsyncWriteExt,
|
io::AsyncWriteExt,
|
||||||
sync::{
|
sync::{
|
||||||
Mutex,
|
|
||||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||||
|
Mutex,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,7 +72,9 @@ 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().with_max_handshake_duration(Duration::from_secs(3))?;
|
let limits = Limits::default()
|
||||||
|
.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)?
|
||||||
@ -171,7 +173,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::ZERO)?;
|
.with_max_idle_timeout(Duration::from_secs(1))?;
|
||||||
|
|
||||||
let client = QuicClient::builder()
|
let client = QuicClient::builder()
|
||||||
.with_tls(CERT_PEM)?
|
.with_tls(CERT_PEM)?
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lanspread-compat"
|
name = "lanspread-compat"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[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,6 +1,5 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -46,21 +45,3 @@ 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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[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, path::Path};
|
use std::{collections::HashMap, fmt};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -93,17 +93,6 @@ 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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
24
crates/lanspread-server/src/assets.rs
Normal file
24
crates/lanspread-server/src/assets.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
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,54 +1,304 @@
|
|||||||
mod cli;
|
#![allow(clippy::doc_markdown)]
|
||||||
mod quic;
|
|
||||||
mod req;
|
|
||||||
|
|
||||||
use std::{convert::Into, net::SocketAddr};
|
use std::{
|
||||||
|
fs::File,
|
||||||
use clap::Parser as _;
|
io::Read as _,
|
||||||
use cli::Cli;
|
net::{IpAddr, SocketAddr},
|
||||||
use lanspread_compat::eti;
|
path::{Path, PathBuf},
|
||||||
use lanspread_db::db::{Game, GameDB};
|
sync::Arc,
|
||||||
use lanspread_mdns::{
|
|
||||||
DaemonEvent, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, MdnsAdvertiser,
|
|
||||||
};
|
};
|
||||||
use quic::run_server;
|
|
||||||
|
use assets::Thumbnails;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use clap::Parser;
|
||||||
|
use lanspread_compat::eti::{self, EtiGame};
|
||||||
|
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||||
|
use lanspread_mdns::{
|
||||||
|
DaemonEvent,
|
||||||
|
MdnsAdvertiser,
|
||||||
|
LANSPREAD_INSTANCE_NAME,
|
||||||
|
LANSPREAD_SERVICE_TYPE,
|
||||||
|
};
|
||||||
|
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 tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
fn spawn_mdns_task(server_addr: SocketAddr) -> eyre::Result<()> {
|
mod assets;
|
||||||
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 {
|
||||||
while let Ok(event) = mdns.monitor.recv() {
|
tracing::info!("{} connected", conn_ctx.remote_addr);
|
||||||
tracing::info!("mDNS: {:?}", &event);
|
|
||||||
if let DaemonEvent::Error(e) = event {
|
while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await {
|
||||||
tracing::error!("mDNS: {e}");
|
let (mut rx, mut tx) = stream.split();
|
||||||
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_game_db(cli: &Cli) -> eyre::Result<GameDB> {
|
fn get_relative_path(base: &Path, deep_path: &Path) -> std::io::Result<PathBuf> {
|
||||||
// build games from ETI database
|
let base_canonical = base.canonicalize()?;
|
||||||
let mut games: Vec<Game> = eti::get_games(&cli.db)
|
let full_canonical = deep_path.canonicalize()?;
|
||||||
.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()
|
||||||
.map(Into::into)
|
.filter_map(std::result::Result::ok)
|
||||||
.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(),
|
||||||
|
};
|
||||||
|
|
||||||
// filter out games that the server does not have in game_dir
|
tracing::debug!("Found game file: {:?}", game_file_description);
|
||||||
games.retain(|game| cli.game_dir.join(&game.id).is_dir());
|
|
||||||
|
|
||||||
let mut game_db = GameDB::from(games);
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
game_db.add_thumbnails(&cli.thumbs_dir);
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Prepared game database with {} games", game_db.games.len());
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
Ok(game_db)
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@ -59,19 +309,57 @@ async fn main() -> eyre::Result<()> {
|
|||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
assert!(
|
#[allow(clippy::manual_assert)]
|
||||||
cli.game_dir.exists(),
|
if !cli.folder.exists() {
|
||||||
|
panic!(
|
||||||
"Games folder does not exist: {}",
|
"Games folder does not exist: {}",
|
||||||
cli.game_dir.to_str().expect("Invalid path")
|
cli.folder.to_str().expect("Invalid path")
|
||||||
);
|
);
|
||||||
|
}
|
||||||
let server_addr = SocketAddr::from((cli.ip, cli.port));
|
|
||||||
|
let eti_games = eti::get_games(&cli.db).await?;
|
||||||
// spawn mDNS listener task
|
let mut games: Vec<Game> = eti_games.into_iter().map(eti_game_to_game).collect();
|
||||||
spawn_mdns_task(server_addr)?;
|
|
||||||
|
// Filter games based on existing folders
|
||||||
let game_db = prepare_game_db(&cli).await?;
|
games.retain(|game| {
|
||||||
|
let game_folder = cli.folder.join(&game.id);
|
||||||
tracing::info!("Server listening on {server_addr}");
|
let exists = game_folder.exists() && game_folder.is_dir();
|
||||||
run_server(server_addr, game_db, cli.game_dir).await
|
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
|
||||||
}
|
}
|
||||||
|
@ -1,152 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
@ -1,129 +0,0 @@
|
|||||||
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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
# 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::{LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, discover_service};
|
use lanspread_mdns::{discover_service, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE};
|
||||||
use tauri::{AppHandle, Emitter as _, Manager};
|
use tauri::{AppHandle, Emitter as _, Manager};
|
||||||
use tauri_plugin_shell::{ShellExt, process::Command};
|
use tauri_plugin_shell::{process::Command, ShellExt};
|
||||||
use tokio::sync::{Mutex, mpsc::UnboundedSender};
|
use tokio::sync::{mpsc::UnboundedSender, Mutex};
|
||||||
|
|
||||||
// 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 = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! maybe_addr {
|
macro_rules! maybe_addr {
|
||||||
($addr:expr) => {
|
($addr:expr) => {
|
||||||
$addr.map_or(Arc::new("<unknown>".to_string()), |addr| {
|
$addr.map_or("<unknown>".to_string(), |addr| addr.to_string())
|
||||||
Arc::new(addr.to_string())
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user