[backup] games from server with images

This commit is contained in:
ddidderr 2024-11-13 23:51:28 +01:00
parent 5d45c4ce4b
commit a6ed6e04fe
Signed by: ddidderr
GPG Key ID: 3841F1C27E6F0E14
13 changed files with 214 additions and 113 deletions

2
Cargo.lock generated
View File

@ -2303,6 +2303,7 @@ dependencies = [
name = "lanspread-client" name = "lanspread-client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytes",
"clap", "clap",
"eyre", "eyre",
"lanspread-db", "lanspread-db",
@ -2330,6 +2331,7 @@ dependencies = [
name = "lanspread-db" name = "lanspread-db"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytes",
"eyre", "eyre",
"semver", "semver",
"serde", "serde",

View File

@ -12,7 +12,7 @@ members = [
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
bytes = "1.8" bytes = { version = "1.8", 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.13"

View File

@ -16,7 +16,9 @@ unwrap_used = "warn"
lanspread-db = { path = "../lanspread-db" } lanspread-db = { path = "../lanspread-db" }
lanspread-proto = { path = "../lanspread-proto" } lanspread-proto = { path = "../lanspread-proto" }
lanspread-utils = { path = "../lanspread-utils" } lanspread-utils = { path = "../lanspread-utils" }
# external # external
bytes = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
eyre = { workspace = true } eyre = { workspace = true }
log = "0.4" log = "0.4"

View File

@ -1,5 +1,6 @@
use std::{net::SocketAddr, time::Duration}; use std::{net::SocketAddr, time::Duration};
use bytes::{Bytes, BytesMut};
use lanspread_db::db::Game; use lanspread_db::db::Game;
use lanspread_proto::{Message as _, Request, Response}; use lanspread_proto::{Message as _, Request, Response};
use lanspread_utils::maybe_addr; use lanspread_utils::maybe_addr;
@ -69,7 +70,7 @@ pub async fn run(
ClientCommand::ListGames => Request::ListGames, ClientCommand::ListGames => Request::ListGames,
ClientCommand::GetGame(id) => Request::GetGame { id }, ClientCommand::GetGame(id) => Request::GetGame { id },
ClientCommand::ServerAddr(_) => Request::Invalid( ClientCommand::ServerAddr(_) => Request::Invalid(
[].into(), Bytes::new(),
"invalid control message (ServerAddr), should not happen".into(), "invalid control message (ServerAddr), should not happen".into(),
), ),
}; };
@ -91,24 +92,26 @@ pub async fn run(
log::error!("failed to send request to server {:?}", e); log::error!("failed to send request to server {:?}", e);
} }
let mut data: Vec<u8> = Vec::new(); let mut data = BytesMut::new();
while let Ok(Some(bytes)) = rx.receive().await { while let Ok(Some(bytes)) = rx.receive().await {
data.extend_from_slice(&bytes); data.extend_from_slice(&bytes);
} }
log::debug!("{} bytes received from server", data.len()); log::debug!("{} bytes received from server", data.len());
log::trace!("server response (RAW): {}", String::from_utf8_lossy(&data)); log::trace!("server response (RAW): {}", String::from_utf8_lossy(&data));
let response = Response::decode(&data); let response = Response::decode(data.freeze());
log::trace!("server response (DECODED): {response:?}"); log::trace!("server response (DECODED): {response:?}");
match response { match response {
Response::Games(games) => { Response::Games(games) => {
for game in &games { for game in &games {
log::debug!("{game}"); log::trace!("{game}");
} }
if let Err(e) = tx_notify_ui.send(ClientEvent::ListGames(games)) { if let Err(e) = tx_notify_ui.send(ClientEvent::ListGames(games)) {
log::debug!("failed to send ClientEvent::ListGames to client {e:?}"); log::debug!("failed to send ClientEvent::ListGames to client {e:?}");
} else {
log::info!("sent ClientEvent::ListGames to Tauri client");
} }
} }
Response::Game(game) => log::debug!("game received: {game:?}"), Response::Game(game) => log::debug!("game received: {game:?}"),

View File

@ -13,6 +13,7 @@ unwrap_used = "warn"
[dependencies] [dependencies]
# external # external
bytes = { workspace = true }
eyre = { workspace = true } eyre = { workspace = true }
semver = { workspace = true } semver = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@ -3,6 +3,7 @@
use std::{collections::HashMap, fmt}; use std::{collections::HashMap, fmt};
use bytes::Bytes;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A game /// A game
@ -26,6 +27,8 @@ pub struct Game {
pub genre: String, pub genre: String,
/// size in bytes: example: 3455063152 /// size in bytes: example: 3455063152
pub size: u64, pub size: u64,
/// thumbnail image
pub thumbnail: Option<Bytes>,
} }
impl fmt::Debug for Game { impl fmt::Debug for Game {
@ -103,7 +106,9 @@ impl GameDB {
#[must_use] #[must_use]
pub fn all_games(&self) -> Vec<&Game> { pub fn all_games(&self) -> Vec<&Game> {
self.games.values().collect() let mut games: Vec<_> = self.games.values().collect();
games.sort_by(|a, b| a.name.cmp(&b.name));
games
} }
} }

View File

@ -7,7 +7,7 @@ use tracing::error;
pub enum Request { pub enum Request {
ListGames, ListGames,
GetGame { id: String }, GetGame { id: String },
Invalid(Vec<u8>, String), Invalid(Bytes, String),
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@ -15,29 +15,29 @@ pub enum Response {
Games(Vec<Game>), Games(Vec<Game>),
Game(Game), Game(Game),
GameNotFound(String), GameNotFound(String),
InvalidRequest(Vec<u8>, String), InvalidRequest(Bytes, String),
EncodingError(String), EncodingError(String),
DecodingError(Vec<u8>, String), DecodingError(Bytes, String),
} }
// Add Message trait // Add Message trait
pub trait Message { pub trait Message {
fn decode(bytes: &[u8]) -> Self; fn decode(bytes: Bytes) -> Self;
fn encode(&self) -> Bytes; fn encode(&self) -> Bytes;
} }
// Implement for Request // Implement for Request
impl Message for Request { impl Message for Request {
fn decode(bytes: &[u8]) -> Self { fn decode(bytes: Bytes) -> Self {
match serde_json::from_slice(bytes) { match serde_json::from_slice(&bytes) {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
"got invalid request from client (error: {}): {}", "got invalid request from client (error: {}): {}",
e, e,
String::from_utf8_lossy(bytes) String::from_utf8_lossy(&bytes)
); );
Request::Invalid(bytes.into(), e.to_string()) Request::Invalid(bytes, e.to_string())
} }
} }
} }
@ -55,10 +55,10 @@ impl Message for Request {
// Implement for Response // Implement for Response
impl Message for Response { impl Message for Response {
fn decode(bytes: &[u8]) -> Self { fn decode(bytes: Bytes) -> Self {
match serde_json::from_slice(bytes) { match serde_json::from_slice(&bytes) {
Ok(t) => t, Ok(t) => t,
Err(e) => Response::DecodingError(bytes.into(), e.to_string()), Err(e) => Response::DecodingError(bytes, e.to_string()),
} }
} }

View 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
}
}

View File

@ -6,6 +6,7 @@ use std::{
sync::Arc, sync::Arc,
}; };
use assets::Thumbnails;
use clap::Parser; use clap::Parser;
use lanspread_compat::eti::{self, EtiGame}; use lanspread_compat::eti::{self, EtiGame};
use lanspread_db::db::{Game, GameDB}; use lanspread_db::db::{Game, GameDB};
@ -21,10 +22,12 @@ use s2n_quic::Server as QuicServer;
use tokio::{io::AsyncWriteExt, sync::Mutex}; use tokio::{io::AsyncWriteExt, sync::Mutex};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
mod assets;
static KEY_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../key.pem")); 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")); static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem"));
pub(crate) struct Server; struct Server;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct ServerCtx { struct ServerCtx {
@ -73,7 +76,7 @@ impl Server {
String::from_utf8_lossy(&data) String::from_utf8_lossy(&data)
); );
let request = Request::decode(&data); let request = Request::decode(data);
tracing::debug!( tracing::debug!(
"{} client request (decoded): {:?}", "{} client request (decoded): {:?}",
conn_ctx.remote_addr, conn_ctx.remote_addr,
@ -160,6 +163,12 @@ struct Cli {
/// Game database path (SQLite). /// Game database path (SQLite).
#[clap(long)] #[clap(long)]
db: PathBuf, db: PathBuf,
/// Games folder.
#[clap(long)]
folder: PathBuf,
/// Thumbnails folder.
#[clap(long)]
thumbnails: PathBuf,
} }
fn eti_game_to_game(eti_game: EtiGame) -> Game { fn eti_game_to_game(eti_game: EtiGame) -> Game {
@ -175,6 +184,7 @@ fn eti_game_to_game(eti_game: EtiGame) -> Game {
version: eti_game.game_version, version: eti_game.game_version,
genre: eti_game.genre_de, genre: eti_game.genre_de,
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64, size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
thumbnail: None,
} }
} }
@ -187,7 +197,18 @@ async fn main() -> eyre::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let eti_games = eti::get_games(&cli.db).await?; let eti_games = eti::get_games(&cli.db).await?;
let games: Vec<Game> = eti_games.into_iter().map(eti_game_to_game).collect(); let mut games: Vec<Game> = eti_games.into_iter().map(eti_game_to_game).collect();
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 game_db = GameDB::from(games);
let mdns = MdnsAdvertiser::new( let mdns = MdnsAdvertiser::new(

View File

@ -2,7 +2,7 @@ use std::net::SocketAddr;
use lanspread_client::{ClientCommand, ClientEvent}; use lanspread_client::{ClientCommand, ClientEvent};
use lanspread_mdns::{discover_service, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE}; use lanspread_mdns::{discover_service, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE};
use tauri::{AppHandle, Emitter as _, Manager}; use tauri::{AppHandle, Emitter as _, Listener as _, Manager};
use tokio::sync::{mpsc::UnboundedSender, Mutex}; 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/
@ -57,7 +57,6 @@ async fn find_server(app: AppHandle) {
.client_ctrl .client_ctrl
.send(ClientCommand::ServerAddr(server_addr)) .send(ClientCommand::ServerAddr(server_addr))
.unwrap(); .unwrap();
request_games(state);
break; break;
} }
Err(e) => { Err(e) => {
@ -75,7 +74,7 @@ pub fn run() {
tauri_plugin_log::TargetKind::Stdout, tauri_plugin_log::TargetKind::Stdout,
)) ))
.level(log::LevelFilter::Info) .level(log::LevelFilter::Info)
.level_for("lanspread_client", log::LevelFilter::Trace) .level_for("lanspread_client", log::LevelFilter::Debug)
.level_for("lanspread_tauri_leptos_lib", log::LevelFilter::Debug) .level_for("lanspread_tauri_leptos_lib", log::LevelFilter::Debug)
.level_for("mdns_sd::service_daemon", log::LevelFilter::Off); .level_for("mdns_sd::service_daemon", log::LevelFilter::Off);
@ -116,7 +115,6 @@ pub fn run() {
log::trace!("client event ListGames iter: {game:?}"); log::trace!("client event ListGames iter: {game:?}");
} }
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Err(e) = app_handle.emit("games-list-updated", Some(games)) { if let Err(e) = app_handle.emit("games-list-updated", Some(games)) {
log::error!("Failed to emit games-list-updated event: {e}"); log::error!("Failed to emit games-list-updated event: {e}");
} else { } else {

View File

@ -6,7 +6,7 @@ body {
.grid-container { .grid-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* Changed from 220px to 140px */
gap: 20px; gap: 20px;
padding: 20px; padding: 20px;
} }
@ -20,6 +20,7 @@ body {
transition: background 0.3s; transition: background 0.3s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
cursor: pointer; cursor: pointer;
/* max-width: 280px; */
} }
.item:hover { .item:hover {
@ -27,9 +28,11 @@ body {
} }
.item img { .item img {
width: 100%; width: 280px; /* Fixed width */
height: 150px; height: 200px; /* Fixed height */
object-fit: cover; object-fit: cover;
display: block; /* Removes any unwanted spacing */
margin: 0 auto; /* Centers the image if container is wider */
} }
.item-name { .item-name {
@ -93,3 +96,31 @@ body {
50% { opacity: 0.8; } 50% { opacity: 0.8; }
100% { opacity: 1; } 100% { opacity: 1; }
} }
.search-container {
padding: 20px;
display: flex;
justify-content: center;
}
.search-input {
width: 100%;
max-width: 400px;
padding: 10px 15px;
font-size: 16px;
color: #D5DBFE;
background: #000938;
border: 1px solid #444;
border-radius: 25px;
outline: none;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: #4866b9;
box-shadow: 0 0 10px rgba(0, 191, 255, 0.2);
}
.search-input::placeholder {
color: #8892b0;
}

View File

@ -1,92 +1,109 @@
// types.ts import {useEffect, useState} from 'react';
import {invoke} from '@tauri-apps/api/core';
import {listen} from '@tauri-apps/api/event';
import "./App.css";
interface Game { interface Game {
id: string; id: string;
name: string; name: string;
description: string; description: string;
size: number; size: number;
thumbnail: Uint8Array;
} }
interface RunGameArgs {
id: string;
}
// App.tsx
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import "./App.css";
const App = () => { const App = () => {
const [gameItems, setGameItems] = useState<Game[]>([]); const [gameItems, setGameItems] = useState<Game[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const filteredGames = gameItems.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => { useEffect(() => {
// Listen for games list updates console.log('🔵 Effect starting - setting up listener and requesting games');
let isSubscribed = true; // For tracking if component is still mounted
const setupEventListener = async () => { const setupEventListener = async () => {
try { try {
const unlisten = await listen('games-list-updated', (event) => { const unlisten = await listen('games-list-updated', (event) => {
console.log('Received games-list-updated event'); if (!isSubscribed) return; // Don't update state if unmounted
console.log('📥 Received games-list-updated event');
const games = event.payload as Game[]; const games = event.payload as Game[];
games.forEach(game => { games.forEach(game => {
console.log(`game: ${JSON.stringify(game)}`); console.log(`🎮 game: ${JSON.stringify(game.id)}`);
}); });
setGameItems(games); setGameItems(games);
}); });
// Cleanup listener on component unmount // Initial request for games
console.log('📤 Requesting initial games list');
await invoke('request_games');
// Cleanup function
return () => { return () => {
console.log('🧹 Cleaning up - removing listener');
isSubscribed = false;
unlisten(); unlisten();
}; };
} catch (error) { } catch (error) {
console.error('Error setting up event listener:', error); console.error('Error in setup:', error);
} }
}; };
setupEventListener(); setupEventListener();
// Uncomment if you want to request games on mount // Cleanup
// const requestGames = async () => { return () => {
// try { console.log('🚫 Effect cleanup - component unmounting');
// await invoke('request_games'); isSubscribed = false;
// } catch (error) { };
// console.error('Error requesting games:', error); }, []); // Empty dependency array means this runs once on mount
// }
// };
// requestGames();
}, []);
const runGame = async (id: string) => { const runGame = async (id: string) => {
console.log(`id=${id}`); console.log(`🎯 Running game with id=${id}`);
try { try {
const result = await invoke('run_game_backend', { id }); const result = await invoke('run_game_backend', {id});
console.log(`id=${result}`); console.log(`✅ Game started, result=${result}`);
} catch (error) { } catch (error) {
console.error('Error running game:', error); console.error('Error running game:', error);
} }
}; };
// Rest of your component remains the same
return ( return (
<main className="container"> <main className="container">
<h1 className="align-center">SoftLAN Launcher</h1> <h1 className="align-center">SoftLAN Launcher</h1>
<div className="main-header">HEADER</div> <div className="main-header">
<div className="grid-container"> {/* Search input */}
{gameItems.map((item) => ( <div className="search-container">
<div <input
key={item.id} type="text"
className="item" placeholder="Search games..."
onClick={() => runGame(item.id)} value={searchTerm}
> onChange={(e) => setSearchTerm(e.target.value)}
<img className="search-input"
src="https://via.placeholder.com/200x150"
alt="Item Image"
/> />
</div></div>
<div className="grid-container">
{filteredGames.map((item) => {
// Convert the thumbnail bytes to base64
const uint8Array = new Uint8Array(item.thumbnail);
const binaryString = uint8Array.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
const thumbnailUrl = `data:image/jpeg;base64,${btoa(binaryString)}`;
return (
<div key={item.id} className="item" onClick={() => runGame(item.id)}>
<img src={thumbnailUrl} alt={`${item.name} thumbnail`} />
<div className="item-name">{item.name}</div> <div className="item-name">{item.name}</div>
<div className="description"> <div className="description">
<span className="desc-text">{item.description}</span> <span className="desc-text">{item.description.slice(0, 10)}</span>
<span className="size-text">{item.size.toString()}</span> <span className="size-text">{item.size.toString()}</span>
</div> </div>
<div className="play-button">Play</div> <div className="play-button">Play</div>
</div> </div>
))} );
})}
</div> </div>
</main> </main>
); );

View File

@ -1,9 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App /> <App />
</React.StrictMode>,
); );