[improve] set game dir on client -> updates Play/Install button based on games existing

This commit is contained in:
2024-11-14 19:41:55 +01:00
parent c00b7dbe9c
commit 942eb8003e
12 changed files with 873 additions and 53 deletions

View File

@ -1,10 +1,12 @@
#![allow(clippy::missing_errors_doc)]
use std::{net::SocketAddr, time::Duration};
use bytes::{Bytes, BytesMut};
use lanspread_db::db::Game;
use lanspread_proto::{Message as _, Request, Response};
use lanspread_utils::maybe_addr;
use s2n_quic::{client::Connect, provider::limits::Limits, Client as QuicClient};
use s2n_quic::{client::Connect, provider::limits::Limits, Client as QuicClient, Connection};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem"));
@ -21,7 +23,39 @@ pub enum ClientCommand {
ServerAddr(SocketAddr),
}
/// # Errors
async fn initial_server_alive_check(conn: &mut Connection) -> bool {
let stream = match conn.open_bidirectional_stream().await {
Ok(stream) => stream,
Err(e) => {
log::error!("failed to open stream: {e}");
return false;
}
};
let (mut rx, mut tx) = stream.split();
// send ping
if let Err(e) = tx.send(Request::Ping.encode()).await {
log::error!("failed to send ping to server: {e}");
return false;
}
let _ = tx.close().await;
// receive pong
if let Ok(Some(response)) = rx.receive().await {
let response = Response::decode(response);
if let Response::Pong = response {
log::info!("server is alive");
return true;
}
log::error!("server sent invalid response to ping: {response:?}");
}
false
}
#[allow(clippy::too_many_lines)]
pub async fn run(
mut rx_control: UnboundedReceiver<ClientCommand>,
tx_notify_ui: UnboundedSender<ClientEvent>,
@ -58,6 +92,10 @@ pub async fn run(
conn.keep_alive(true)?;
if !initial_server_alive_check(&mut conn).await {
continue;
}
log::info!(
"connected: (server: {}) (client: {})",
maybe_addr!(conn.remote_addr()),
@ -131,6 +169,7 @@ pub async fn run(
String::from_utf8_lossy(&data)
);
}
Response::Pong => (), // ignore (should never happen)
}
if let Err(err) = tx.close().await {

View File

@ -29,6 +29,8 @@ pub struct Game {
pub size: u64,
/// thumbnail image
pub thumbnail: Option<Bytes>,
/// only relevant for client (yeah... I know)
pub installed: bool,
}
impl fmt::Debug for Game {
@ -76,7 +78,7 @@ pub struct GameDB {
impl GameDB {
#[must_use]
pub fn new() -> Self {
pub fn empty() -> Self {
GameDB {
games: HashMap::new(),
}
@ -84,7 +86,7 @@ impl GameDB {
#[must_use]
pub fn from(games: Vec<Game>) -> Self {
let mut db = GameDB::new();
let mut db = GameDB::empty();
for game in games {
db.games.insert(game.id.clone(), game);
}
@ -99,6 +101,14 @@ impl GameDB {
self.games.get(id.as_ref())
}
#[must_use]
pub fn get_mut_game_by_id<S>(&mut self, id: S) -> Option<&mut Game>
where
S: AsRef<str>,
{
self.games.get_mut(id.as_ref())
}
#[must_use]
pub fn get_game_by_name(&self, name: &str) -> Option<&Game> {
self.games.values().find(|game| game.name == name)
@ -110,10 +120,16 @@ impl GameDB {
games.sort_by(|a, b| a.name.cmp(&b.name));
games
}
pub fn set_all_uninstalled(&mut self) {
for game in self.games.values_mut() {
game.installed = false;
}
}
}
impl Default for GameDB {
fn default() -> Self {
Self::new()
Self::empty()
}
}

View File

@ -5,6 +5,7 @@ use tracing::error;
#[derive(Debug, Serialize, Deserialize)]
pub enum Request {
Ping,
ListGames,
GetGame { id: String },
Invalid(Bytes, String),
@ -12,6 +13,7 @@ pub enum Request {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Response {
Pong,
Games(Vec<Game>),
Game(Game),
GameNotFound(String),

View File

@ -116,6 +116,7 @@ impl RequestHandler {
async fn handle_request(&self, request: Request) -> Response {
match request {
Request::Ping => Response::Pong,
Request::ListGames => {
let db = self.db.lock().await;
Response::Games(db.all_games().into_iter().cloned().collect())
@ -172,6 +173,7 @@ fn eti_game_to_game(eti_game: EtiGame) -> Game {
genre: eti_game.genre_de,
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
thumbnail: None,
installed: false,
}
}

View File

@ -3,7 +3,9 @@
"specifiers": {
"npm:@tauri-apps/api@2": "2.1.1",
"npm:@tauri-apps/cli@2": "2.1.0",
"npm:@tauri-apps/plugin-dialog@~2.0.1": "2.0.1",
"npm:@tauri-apps/plugin-shell@2": "2.0.1",
"npm:@tauri-apps/plugin-store@2.1": "2.1.0",
"npm:@types/react-dom@^18.2.7": "18.3.1",
"npm:@types/react@^18.2.15": "18.3.12",
"npm:@vitejs/plugin-react@^4.2.1": "4.3.3_vite@5.4.11_@babel+core@7.26.0",
@ -348,12 +350,24 @@
"@tauri-apps/cli-win32-x64-msvc"
]
},
"@tauri-apps/plugin-dialog@2.0.1": {
"integrity": "sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==",
"dependencies": [
"@tauri-apps/api"
]
},
"@tauri-apps/plugin-shell@2.0.1": {
"integrity": "sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==",
"dependencies": [
"@tauri-apps/api"
]
},
"@tauri-apps/plugin-store@2.1.0": {
"integrity": "sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw==",
"dependencies": [
"@tauri-apps/api"
]
},
"@types/babel__core@7.20.5": {
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dependencies": [
@ -604,7 +618,9 @@
"dependencies": [
"npm:@tauri-apps/api@2",
"npm:@tauri-apps/cli@2",
"npm:@tauri-apps/plugin-dialog@~2.0.1",
"npm:@tauri-apps/plugin-shell@2",
"npm:@tauri-apps/plugin-store@2.1",
"npm:@types/react-dom@^18.2.7",
"npm:@types/react@^18.2.15",
"npm:@vitejs/plugin-react@^4.2.1",

View File

@ -10,6 +10,8 @@
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/plugin-dialog": "~2.0.1",
"@tauri-apps/plugin-store": "~2.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tauri-apps/api": "^2",

View File

@ -32,4 +32,6 @@ tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { workspace = true }
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"

View File

@ -2,9 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"shell:allow-open"
"shell:allow-open",
"dialog:default",
"store:default"
]
}
}

View File

@ -1,8 +1,13 @@
use std::net::SocketAddr;
use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use lanspread_client::{ClientCommand, ClientEvent};
use lanspread_db::db::{Game, GameDB};
use lanspread_mdns::{discover_service, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE};
use tauri::{AppHandle, Emitter as _, Listener as _, Manager};
use tauri::{AppHandle, Emitter as _, Manager};
use tokio::sync::{mpsc::UnboundedSender, Mutex};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
@ -10,6 +15,7 @@ use tokio::sync::{mpsc::UnboundedSender, Mutex};
struct LanSpreadState {
server_addr: Mutex<Option<SocketAddr>>,
client_ctrl: UnboundedSender<ClientCommand>,
games: Arc<Mutex<GameDB>>,
}
#[tauri::command]
@ -40,6 +46,67 @@ fn run_game_backend(id: String, state: tauri::State<LanSpreadState>) -> String {
// }
}
fn set_game_install_state_from_path(game_db: &mut GameDB, path: &Path, installed: bool) {
if let Some(file_name) = path.file_name() {
if let Some(file_name) = file_name.to_str() {
if let Some(game) = game_db.get_mut_game_by_id(file_name) {
if installed {
log::error!("Game is installed: {game}");
} else {
log::error!("Game is missing: {game}");
}
game.installed = installed;
}
}
}
}
#[tauri::command]
fn update_game_directory(app_handle: tauri::AppHandle, path: String) {
let path = PathBuf::from(path);
if !path.exists() {
log::error!("game dir {path:?} does not exist");
}
let entries = match path.read_dir() {
Ok(entries) => entries,
Err(e) => {
log::error!("Failed to read game dir: {e}");
return;
}
};
tauri::async_runtime::spawn(async move {
let mut game_db = app_handle
.state::<LanSpreadState>()
.inner()
.games
.lock()
.await;
// Reset all games to uninstalled
game_db.set_all_uninstalled();
// update game_db with installed games from real game directory
entries.into_iter().for_each(|entry| {
if let Ok(entry) = entry {
if let Ok(path_type) = entry.file_type() {
if path_type.is_dir() {
let path = entry.path();
if path.join(".softlan_game_installed").exists() {
set_game_install_state_from_path(&mut game_db, &path, true);
}
}
}
}
});
if let Err(e) = app_handle.emit("games-list-updated", Some(game_db.all_games())) {
log::error!("Failed to emit games-list-updated event: {e}");
}
});
}
async fn find_server(app: AppHandle) {
log::info!("Looking for server...");
@ -57,6 +124,7 @@ async fn find_server(app: AppHandle) {
.client_ctrl
.send(ClientCommand::ServerAddr(server_addr))
.unwrap();
request_games(state);
break;
}
Err(e) => {
@ -66,6 +134,25 @@ async fn find_server(app: AppHandle) {
}
}
async fn update_game_db(games: Vec<Game>, app: AppHandle) {
for game in &games {
log::trace!("client event ListGames iter: {game:?}");
}
let state = app.state::<LanSpreadState>();
// Store games list
let mut state_games = state.games.lock().await;
*state_games = GameDB::from(games.clone());
// Tell Frontend about new games list
if let Err(e) = app.emit("games-list-updated", Some(games)) {
log::error!("Failed to emit games-list-updated event: {e}");
} else {
log::info!("Emitted games-list-updated event");
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let tauri_logger_builder = tauri_plugin_log::Builder::new()
@ -89,12 +176,19 @@ pub fn run() {
let lanspread_state = LanSpreadState {
server_addr: Mutex::new(None),
client_ctrl: tx_client_control,
games: Arc::new(Mutex::new(GameDB::empty())),
};
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_logger_builder.build())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![run_game_backend, request_games])
.invoke_handler(tauri::generate_handler![
run_game_backend,
request_games,
update_game_directory
])
.manage(lanspread_state)
.setup(|app| {
let app_handle = app.handle().clone();
@ -110,16 +204,7 @@ pub fn run() {
match event {
ClientEvent::ListGames(games) => {
log::debug!("Received client event: ListGames");
for game in &games {
log::trace!("client event ListGames iter: {game:?}");
}
if let Err(e) = app_handle.emit("games-list-updated", Some(games)) {
log::error!("Failed to emit games-list-updated event: {e}");
} else {
log::info!("Emitted games-list-updated event");
}
update_game_db(games, app_handle.clone()).await;
}
}
}

View File

@ -102,13 +102,6 @@ h1.align-center {
box-shadow: 0 8px 15px rgba(0, 191, 255, 0.2);
}
/* .play-button:hover {
background: linear-gradient(45deg, #09305a, #4866b9);
/* box-shadow: 0 15px 20px rgba(0, 191, 255, 0.4); */
/* box-shadow: 0 20px 25px rgba(0, 191, 255, 0.6), 0 0 15px rgba(0, 191, 255, 0.5);
transform: translateY(-5px);
} */
.play-button:hover {
background: linear-gradient(45deg, #09305a, #4866b9);
box-shadow: 0 20px 25px rgba(0, 191, 255, 0.6);
@ -124,7 +117,6 @@ h1.align-center {
}
.search-container {
padding: 20px;
display: flex;
justify-content: center;
}
@ -150,3 +142,45 @@ h1.align-center {
.search-input::placeholder {
color: #8892b0;
}
.search-settings-wrapper {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 20px;
}
.settings-container {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 15px;
}
.settings-button {
padding: 8px 16px;
background: linear-gradient(45deg, #09305a, #37529c);
color: #D5DBFE;
border: 1px solid transparent;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2);
}
.settings-button:hover {
background: linear-gradient(45deg, #09305a, #4866b9);
box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4);
border: 1px solid rgba(0, 191, 255, 0.6);
transform: translateY(-2px);
}
.settings-text {
color: #8892b0;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}

View File

@ -1,40 +1,78 @@
import {useEffect, useState} from 'react';
import {invoke} from '@tauri-apps/api/core';
import {listen} from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import { load } from '@tauri-apps/plugin-store';
import "./App.css";
const FILE_STORAGE = 'launcher-settings.json';
const GAME_DIR_KEY = 'game-directory';
interface Game {
id: string;
name: string;
description: string;
size: number;
thumbnail: Uint8Array;
installed: boolean;
}
const App = () => {
const [gameItems, setGameItems] = useState<Game[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [gameDir, setGameDir] = useState('');
const filteredGames = gameItems.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const getInitialGameDir = async () => {
// update game directory from storage (if exists)
// only if it's not already set
await new Promise(resolve => setTimeout(resolve, 1000));
const store = await load(FILE_STORAGE, { autoSave: true });
const savedGameDir = await store.get<string>(GAME_DIR_KEY);
if (savedGameDir) {
setGameDir(savedGameDir);
}
};
useEffect(() => {
if (gameDir) {
// store game directory in persistent storage
const updateStorage = async (game_dir: string) => {
try {
const store = await load(FILE_STORAGE, { autoSave: true });
await store.set(GAME_DIR_KEY, game_dir);
console.info(`📦 Storage updated with game directory: ${game_dir}`);
} catch (error) {
console.error('❌ Error updating storage:', error);
}
};
updateStorage(gameDir);
console.log(`📂 Game directory changed to: ${gameDir}`);
invoke('update_game_directory', { path: gameDir })
.catch(error => console.error('❌ Error updating game directory:', error));
}
}, [gameDir]);
useEffect(() => {
console.log('🔵 Effect starting - setting up listener and requesting games');
let isSubscribed = true; // For tracking if component is still mounted
const setupEventListener = async () => {
try {
const unlisten = await listen('games-list-updated', (event) => {
if (!isSubscribed) return; // Don't update state if unmounted
console.log('📥 Received games-list-updated event');
// Listen for events that update the game list
const unlisten_games = await listen('games-list-updated', (event) => {
console.log('🗲 Received games-list-updated event');
const games = event.payload as Game[];
games.forEach(game => {
console.log(`🎮 game: ${JSON.stringify(game.id)}`);
});
console.log(`🎮 ${games.length} Games received`);
setGameItems(games);
getInitialGameDir();
});
// Initial request for games
@ -44,8 +82,7 @@ const App = () => {
// Cleanup function
return () => {
console.log('🧹 Cleaning up - removing listener');
isSubscribed = false;
unlisten();
unlisten_games();
};
} catch (error) {
console.error('❌ Error in setup:', error);
@ -57,7 +94,6 @@ const App = () => {
// Cleanup
return () => {
console.log('🚫 Effect cleanup - component unmounting');
isSubscribed = false;
};
}, []); // Empty dependency array means this runs once on mount
@ -71,20 +107,38 @@ const App = () => {
}
};
const dialogGameDir = async () => {
const file = await open({
multiple: false,
directory: true,
});
if (file) {
setGameDir(file);
}
};
// Rest of your component remains the same
return (
<main className="container">
<div className="fixed-header">
<h1 className="align-center">SoftLAN Launcher</h1>
<div className="main-header">
<div className="search-container">
<input
type="text"
placeholder="Search games..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<div className="search-settings-wrapper">
<div></div>
<div className="search-container">
<input
type="text"
placeholder="Search games..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
<div className="settings-container">
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
<span className="settings-text">{gameDir}</span>
</div>
</div>
</div>
</div>
@ -101,7 +155,7 @@ const App = () => {
<span className="desc-text">{item.description.slice(0, 10)}</span>
<span className="size-text">{item.size.toString()}</span>
</div>
<div className="play-button">Play</div>
<div className="play-button">{item.installed ? 'Play' : 'Install'}</div>
</div>
);
})}