Compare commits

..

No commits in common. "393f8b5fab2b66adf9a218f2f7d6b7b818577df7" and "45a4b9218f898e838c843ba65e39d6d00f1b3ef1" have entirely different histories.

17 changed files with 1004 additions and 1104 deletions

906
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,33 +12,26 @@ members = [
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
bytes = { version = "1", features = ["serde"] } bytes = { version = "1.9", features = ["serde"] }
chrono = "0.4" clap = { version = "4.5", features = ["derive"] }
clap = { version = "4", features = ["derive"] }
eyre = "0.6" eyre = "0.6"
gethostname = "1"
itertools = "0.14" itertools = "0.14"
log = "0.4" log = "0.4"
mdns-sd = "0.13" mdns-sd = "0.13"
s2n-quic = { version = "1", features = ["provider-event-tracing"] } s2n-quic = { version = "1.51", features = ["provider-event-tracing"] }
semver = "1" semver = "1.0"
serde = { version = "1", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1" serde_json = "1.0"
sqlx = { version = "0.8", default-features = false, features = [ sqlx = { version = "0.8", default-features = false, features = ["derive", "runtime-tokio", "sqlite"] }
"derive", tauri = { version = "2.1", features = [] }
"runtime-tokio", tauri-plugin-log = "2.0"
"sqlite", tauri-plugin-shell = "2.0"
] } tauri-plugin-dialog = "2.0"
tauri = { version = "2", features = [] } tauri-plugin-store = "2.1"
tauri-plugin-log = "2" tokio = { version = "1.42", features = ["full"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tokio = { version = "1", features = ["full"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v7"] } walkdir = "2.5"
walkdir = "2"
[profile.release] [profile.release]
debug = true debug = true

View File

@ -10,7 +10,16 @@ Simple server and GUI for LAN parties.
### Prerequisites ### Prerequisites
```bash ```bash
# install Tauri CLI # install Tauri CLI
cargo install tauri-cli cargo install tauri-cli --version "^2.0.0"
# install trunk (Build, bundle & ship your Rust WASM application to the web)
cargo install trunk
# alternatively if you have problems (i.e. on Windows)
cargo install cargo-binstall
cargo binstall trunk
# install Rust WASM target
rustup target add wasm32-unknown-unknown
``` ```
### Build ### Build
@ -19,23 +28,14 @@ cargo install tauri-cli
# Development # Development
cargo tauri dev # prefix with RUST_LOG=your_module=debug or similary for more verbose output cargo tauri dev # prefix with RUST_LOG=your_module=debug or similary for more verbose output
# Production but for testing and without bundling
cargo tauri build --no-bundle
# Production # Production
cargo tauri build --profile release-lto # also bundles everything into a nice platform-specific installer cargo tauri build --profile release-lto # also bundles everything into a nice platform-specific installer
# on wayland you probably need to set this env var
WEBKIT_DISABLE_DMABUF_RENDERER=1
# update frontend dependencies
deno outdated --update --latest
``` ```
#### Backend #### Backend
```bash ```bash
# Development # Development
./server.sh [options...] # prefix with RUST_LOG=your_module=debug or similary for more verbose output ./server.sh # prefix with RUST_LOG=your_module=debug or similary for more verbose output
# Production # Production
cargo build --profile release-lto cargo build --profile release-lto

7
client.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
export RUST_LOG=info,lanspread_client=debug,lanspread_proto=debug
#export RUST_LOG=error
exec cargo run -p lanspread-client -- "$@" < <(while sleep 0.1; do echo "list"; sleep 0.1; echo "get 1"; sleep 0.1; echo "get 25"; done)
#RUST_LOG=info exec cargo run --profile release-lto -p lanspread-client < <(while sleep 0.1; do echo "list"; sleep 0.1; echo "get 1"; sleep 0.1; echo "get 25"; done)

View File

@ -20,29 +20,16 @@ static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../..
#[derive(Debug)] #[derive(Debug)]
pub enum ClientEvent { pub enum ClientEvent {
ListGames(Vec<Game>), ListGames(Vec<Game>),
GotGameFiles { GotGameFiles(Vec<GameFileDescription>),
id: String, DownloadGameFilesBegin { id: String },
file_descriptions: Vec<GameFileDescription>, DownloadGameFilesFinished { id: String },
},
DownloadGameFilesBegin {
id: String,
},
DownloadGameFilesFinished {
id: String,
},
DownloadGameFilesFailed {
id: String,
},
} }
#[derive(Debug)] #[derive(Debug)]
pub enum ClientCommand { pub enum ClientCommand {
ListGames, ListGames,
GetGame(String), GetGame(String),
DownloadGameFiles { DownloadGameFiles(Vec<GameFileDescription>),
id: String,
file_descriptions: Vec<GameFileDescription>,
},
ServerAddr(SocketAddr), ServerAddr(SocketAddr),
SetGameDir(String), SetGameDir(String),
} }
@ -79,39 +66,9 @@ async fn initial_server_alive_check(conn: &mut Connection) -> bool {
false false
} }
async fn receive_game_file(
conn: &mut Connection,
desc: &GameFileDescription,
games_folder: &str,
) -> eyre::Result<()> {
log::info!("downloading: {desc:?}");
let stream = conn.open_bidirectional_stream().await?;
let (mut rx, mut tx) = stream.split();
let request = Request::GetGameFileData(desc.clone());
// request file
tx.write_all(&request.encode()).await?;
// create file
let path = PathBuf::from(&games_folder).join(&desc.relative_path);
let mut file = File::create(&path)?;
// receive file contents
while let Some(data) = rx.receive().await? {
file.write_all(&data)?;
}
log::debug!("file download complete: {}", path.display());
tx.close().await?;
Ok(())
}
async fn download_game_files( async fn download_game_files(
game_id: &str,
game_file_descs: Vec<GameFileDescription>, game_file_descs: Vec<GameFileDescription>,
games_folder: String, games_dir: String,
server_addr: SocketAddr, server_addr: SocketAddr,
tx_notify_ui: UnboundedSender<ClientEvent>, tx_notify_ui: UnboundedSender<ClientEvent>,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
@ -127,37 +84,64 @@ async fn download_game_files(
let mut conn = client.connect(conn).await?; let mut conn = client.connect(conn).await?;
conn.keep_alive(true)?; conn.keep_alive(true)?;
let game_files = game_file_descs let game_file_descs = game_file_descs
.iter() .into_iter()
.filter(|desc| !desc.is_dir) .filter(|desc| !desc.is_dir)
.filter(|desc| !desc.is_version_ini())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if game_files.is_empty() { if game_file_descs.is_empty() {
eyre::bail!("game_file_descs empty: no game files to download"); log::error!("game_file_descs empty: no game files to download");
return Ok(());
} }
let game_id = game_file_descs
.first()
.expect("game_file_descs empty: 2nd case CANNOT HAPPEN")
.game_id
.clone();
tx_notify_ui.send(ClientEvent::DownloadGameFilesBegin { tx_notify_ui.send(ClientEvent::DownloadGameFilesBegin {
id: game_id.to_string(), id: game_id.clone(),
})?; })?;
// receive all game files for file_desc in game_file_descs {
for file_desc in game_files { log::info!("downloading file: {}", file_desc.relative_path);
receive_game_file(&mut conn, file_desc, &games_folder).await?;
let stream = conn.open_bidirectional_stream().await?;
let (mut rx, mut tx) = stream.split();
let request = Request::GetGameFileData(file_desc.clone());
if let Ok(()) = tx.write_all(&request.encode()).await {
let path = PathBuf::from(&games_dir).join(&file_desc.relative_path);
let mut file = match File::create(&path) {
Ok(file) => file,
Err(e) => {
log::error!("failed to create file: {e}");
continue;
}
};
// if let Err(e) = tokio::io::copy(&mut rx, &mut file).await {
// log::error!("failed to download file: {e}");
// continue;
// }
while let Ok(Some(data)) = rx.receive().await {
if let Err(e) = file.write_all(&data) {
log::error!("failed to write to file: {e}");
break;
}
}
log::error!("file download complete: {}", path.display());
}
if let Err(e) = tx.close().await {
log::error!("failed to close stream: {e}");
}
} }
let version_file_desc = game_file_descs
.iter()
.find(|desc| desc.is_version_ini())
.ok_or_else(|| eyre::eyre!("version.ini not found"))?;
// receive version.ini
receive_game_file(&mut conn, version_file_desc, &games_folder).await?;
log::info!("all files downloaded for game: {game_id}"); log::info!("all files downloaded for game: {game_id}");
tx_notify_ui.send(ClientEvent::DownloadGameFilesFinished { tx_notify_ui.send(ClientEvent::DownloadGameFilesFinished { id: game_id })?;
id: game_id.to_string(),
})?;
Ok(()) Ok(())
} }
@ -187,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(3))?; .with_max_idle_timeout(Duration::ZERO)?;
let client = QuicClient::builder() let client = QuicClient::builder()
.with_tls(CERT_PEM)? .with_tls(CERT_PEM)?
@ -195,8 +179,8 @@ pub async fn run(
.with_limits(limits)? .with_limits(limits)?
.start()?; .start()?;
let connection = Connect::new(server_addr).with_server_name("localhost"); let conn = Connect::new(server_addr).with_server_name("localhost");
let mut conn = match client.connect(connection.clone()).await { let mut conn = match client.connect(conn).await {
Ok(conn) => conn, Ok(conn) => conn,
Err(e) => { Err(e) => {
log::error!("failed to connect to server: {e}"); log::error!("failed to connect to server: {e}");
@ -222,7 +206,7 @@ pub async fn run(
let request = match cmd { let request = match cmd {
ClientCommand::ListGames => Request::ListGames, ClientCommand::ListGames => Request::ListGames,
ClientCommand::GetGame(id) => { ClientCommand::GetGame(id) => {
log::info!("requesting game from server: {id}"); log::debug!("requesting game from server: {id}");
Request::GetGame { id } Request::GetGame { id }
} }
ClientCommand::ServerAddr(_) => { ClientCommand::ServerAddr(_) => {
@ -233,73 +217,34 @@ pub async fn run(
*ctx.game_dir.lock().await = Some(game_dir.clone()); *ctx.game_dir.lock().await = Some(game_dir.clone());
continue; continue;
} }
ClientCommand::DownloadGameFiles { ClientCommand::DownloadGameFiles(game_file_descs) => {
id,
file_descriptions,
} => {
log::info!("got ClientCommand::DownloadGameFiles"); log::info!("got ClientCommand::DownloadGameFiles");
let games_folder = { ctx.game_dir.lock().await.clone() }; let games_dir = { ctx.game_dir.lock().await.clone() };
if let Some(games_folder) = games_folder { if let Some(games_dir) = games_dir {
let tx_notify_ui = tx_notify_ui.clone(); let tx_notify_ui = tx_notify_ui.clone();
tokio::task::spawn(async move { tokio::task::spawn(async move {
if let Err(e) = download_game_files( if let Err(e) = download_game_files(
&id, game_file_descs,
file_descriptions, games_dir,
games_folder,
server_addr, server_addr,
tx_notify_ui.clone(), tx_notify_ui,
) )
.await .await
{ {
log::error!("failed to download game files: {e}"); log::error!("failed to download game files: {e}");
if let Err(e) =
tx_notify_ui.send(ClientEvent::DownloadGameFilesFailed { id })
{
log::error!(
"failed to send DownloadGameFilesFailed event: {e}"
);
}
} }
}); });
} else { } else {
log::error!( log::error!("Cannot handle game file descriptions: game_dir is not set");
"Cannot handle game file descriptions: games_folder is not set"
);
} }
continue; continue;
} }
}; };
// we got a command from the UI client
// but it is possible that we lost the connection to the server
// so we check and reconnect if needed
let mut retries = 0;
loop {
if initial_server_alive_check(&mut conn).await {
log::info!("server is back alive! 😊");
break;
}
if retries == 0 {
log::warn!("server connection lost, reconnecting...");
}
retries += 1;
conn = match client.connect(connection.clone()).await {
Ok(conn) => conn,
Err(e) => {
log::warn!("failed to connect to server: {e}");
log::warn!("retrying in 3 seconds...");
tokio::time::sleep(Duration::from_secs(3)).await;
continue;
}
};
}
let data = request.encode(); let data = request.encode();
log::trace!("encoded data: {}", String::from_utf8_lossy(&data)); log::debug!("encoded data: {}", String::from_utf8_lossy(&data));
let stream = match conn.open_bidirectional_stream().await { let stream = match conn.open_bidirectional_stream().await {
Ok(stream) => stream, Ok(stream) => stream,
@ -326,43 +271,36 @@ pub async fn run(
log::trace!("msg: {response:?}"); log::trace!("msg: {response:?}");
match response { match response {
Response::ListGames(games) => { Response::Games(games) => {
for game in &games { for game in &games {
log::trace!("{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::error!("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::GetGame { Response::GetGame(game_file_descs) => {
id,
file_descriptions,
} => {
log::info!( log::info!(
"got {} game file descriptions from server", "got {} game file descriptions from server",
file_descriptions.len() game_file_descs.len()
); );
let games_folder = { ctx.game_dir.lock().await.clone() }; let games_dir = { ctx.game_dir.lock().await.clone() };
match games_folder { match games_dir {
Some(games_folder) => { Some(games_dir) => {
// create all directories before receiving the actual files game_file_descs.iter().filter(|f| f.is_dir).for_each(|dir| {
file_descriptions let path = PathBuf::from(&games_dir).join(&dir.relative_path);
.iter() if let Err(e) = std::fs::create_dir_all(path) {
.filter(|f| f.is_dir) log::error!("failed to create directory: {e}");
.for_each(|dir| { }
let path = });
PathBuf::from(&games_folder).join(&dir.relative_path); if let Err(e) =
if let Err(e) = std::fs::create_dir_all(path) { tx_notify_ui.send(ClientEvent::GotGameFiles(game_file_descs))
log::error!("failed to create directory: {e}"); {
}
});
if let Err(e) = tx_notify_ui.send(ClientEvent::GotGameFiles {
id,
file_descriptions,
}) {
log::error!( log::error!(
"failed to send ClientEvent::GotGameFiles to client: {e}" "failed to send ClientEvent::GotGameFiles to client: {e}"
); );

View File

@ -152,22 +152,13 @@ pub struct GameFileDescription {
pub is_dir: bool, pub is_dir: bool,
} }
impl GameFileDescription {
#[must_use]
pub fn is_version_ini(&self) -> bool {
self.relative_path.ends_with("/version.ini")
}
}
impl fmt::Debug for GameFileDescription { impl fmt::Debug for GameFileDescription {
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!( write!(
f, f,
"{}: [{}] path:{}", "game:{} path:{} dir:{}",
self.game_id, self.game_id, self.relative_path, self.is_dir
if self.is_dir { 'D' } else { 'F' },
self.relative_path,
) )
} }
} }

View File

@ -1,6 +1,7 @@
use bytes::Bytes; use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription}; use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::error;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum Request { pub enum Request {
@ -14,11 +15,8 @@ pub enum Request {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Response { pub enum Response {
Pong, Pong,
ListGames(Vec<Game>), Games(Vec<Game>),
GetGame { GetGame(Vec<GameFileDescription>),
id: String,
file_descriptions: Vec<GameFileDescription>,
},
GameNotFound(String), GameNotFound(String),
InvalidRequest(Bytes, String), InvalidRequest(Bytes, String),
EncodingError(String), EncodingError(String),
@ -37,7 +35,11 @@ impl Message for Request {
match serde_json::from_slice(&bytes) { match serde_json::from_slice(&bytes) {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::error!(?e, "Request decoding error"); tracing::error!(
"got invalid request from client (error: {}): {}",
e,
String::from_utf8_lossy(&bytes)
);
Request::Invalid(bytes, e.to_string()) Request::Invalid(bytes, e.to_string())
} }
} }
@ -47,7 +49,7 @@ impl Message for Request {
match serde_json::to_vec(self) { match serde_json::to_vec(self) {
Ok(s) => Bytes::from(s), Ok(s) => Bytes::from(s),
Err(e) => { Err(e) => {
tracing::error!(?e, "Request encoding error"); error!(?e, "Request encoding error");
Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#)) Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#))
} }
} }
@ -59,10 +61,7 @@ impl Message for Response {
fn decode(bytes: Bytes) -> 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) => Response::DecodingError(bytes, e.to_string()),
tracing::error!(?e, "Response decoding error");
Response::DecodingError(bytes, e.to_string())
}
} }
} }
@ -70,7 +69,7 @@ impl Message for Response {
match serde_json::to_vec(self) { match serde_json::to_vec(self) {
Ok(s) => Bytes::from(s), Ok(s) => Bytes::from(s),
Err(e) => { Err(e) => {
tracing::error!(?e, "Response encoding error"); error!(?e, "Response encoding error");
Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#)) Bytes::from(format!(r#"{{"error": "encoding error: {e}"}}"#))
} }
} }

View File

@ -21,10 +21,8 @@ lanspread-utils = { path = "../lanspread-utils" }
# external # external
bytes = { workspace = true } bytes = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
eyre = { workspace = true } eyre = { workspace = true }
gethostname = { workspace = true }
itertools = { workspace = true } itertools = { workspace = true }
s2n-quic = { workspace = true } s2n-quic = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
@ -32,5 +30,4 @@ semver = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true } walkdir = { workspace = true }

View File

@ -2,61 +2,27 @@ mod cli;
mod quic; mod quic;
mod req; mod req;
use std::{convert::Into, net::SocketAddr, time::Duration}; use std::{convert::Into, net::SocketAddr};
use chrono::{DateTime, Local};
use clap::Parser as _; use clap::Parser as _;
use cli::Cli; use cli::Cli;
use gethostname::gethostname;
use lanspread_compat::eti; use lanspread_compat::eti;
use lanspread_db::db::{Game, GameDB}; use lanspread_db::db::{Game, GameDB};
use lanspread_mdns::{ use lanspread_mdns::{
DaemonEvent, DaemonEvent, LANSPREAD_INSTANCE_NAME, LANSPREAD_SERVICE_TYPE, MdnsAdvertiser,
LANSPREAD_INSTANCE_NAME,
LANSPREAD_SERVICE_TYPE,
MdnsAdvertiser,
}; };
use quic::run_server;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use uuid::Uuid;
fn spawn_mdns_task(server_addr: SocketAddr) -> eyre::Result<()> { fn spawn_mdns_task(server_addr: SocketAddr) -> eyre::Result<()> {
let combined_str = if 1 == 2 { let mdns = MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, LANSPREAD_INSTANCE_NAME, server_addr)?;
let peer_id = Uuid::now_v7().simple().to_string();
let uidddd = Uuid::now_v7();
// TODO
let uidddd = uidddd
.get_timestamp()
.expect("failed to get timestamp from UUID")
.to_unix();
let local_datetime: DateTime<Local> =
DateTime::from_timestamp(i64::try_from(uidddd.0).unwrap_or(0), uidddd.1)
.expect("Failed to create DateTime from uuid unix timestamp")
.into();
dbg!(local_datetime);
let hostname = gethostname();
let mut hostname = hostname.to_str().unwrap_or("");
if hostname.len() + peer_id.len() > 63 {
hostname = &hostname[..63 - peer_id.len()];
}
format!("{hostname}-{peer_id}")
} else {
String::from(LANSPREAD_INSTANCE_NAME)
};
let mdns = MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, &combined_str, server_addr)?;
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(event) = mdns.monitor.recv() { while let Ok(event) = mdns.monitor.recv() {
tracing::debug!("mDNS: {:?}", &event); tracing::info!("mDNS: {:?}", &event);
if let DaemonEvent::Error(e) = event { if let DaemonEvent::Error(e) = event {
tracing::error!("mDNS: {e}"); tracing::error!("mDNS: {e}");
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue; continue;
} }
} }
@ -80,9 +46,6 @@ async fn prepare_game_db(cli: &Cli) -> eyre::Result<GameDB> {
game_db.add_thumbnails(&cli.thumbs_dir); game_db.add_thumbnails(&cli.thumbs_dir);
game_db.all_games().iter().for_each(|game| {
tracing::debug!("Found game: {game}");
});
tracing::info!("Prepared game database with {} games", game_db.games.len()); tracing::info!("Prepared game database with {} games", game_db.games.len());
Ok(game_db) Ok(game_db)
@ -110,5 +73,5 @@ async fn main() -> eyre::Result<()> {
let game_db = prepare_game_db(&cli).await?; let game_db = prepare_game_db(&cli).await?;
tracing::info!("Server listening on {server_addr}"); tracing::info!("Server listening on {server_addr}");
crate::quic::run_server(server_addr, game_db, cli.game_dir).await run_server(server_addr, game_db, cli.game_dir).await
} }

View File

@ -3,7 +3,12 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use lanspread_db::db::GameDB; use lanspread_db::db::GameDB;
use lanspread_proto::{Message as _, Request}; use lanspread_proto::{Message as _, Request};
use lanspread_utils::maybe_addr; use lanspread_utils::maybe_addr;
use s2n_quic::{Connection, Server, provider::limits::Limits, stream::BidirectionalStream}; use s2n_quic::{
Connection, Server,
provider::limits::Limits,
stream::{ReceiveStream, SendStream},
};
use tokio::io::AsyncWriteExt as _;
use crate::req::{RequestHandler, send_game_file_data}; use crate::req::{RequestHandler, send_game_file_data};
@ -16,65 +21,92 @@ struct ServerCtx {
games_folder: PathBuf, games_folder: PathBuf,
} }
async fn handle_bidi_stream(stream: BidirectionalStream, ctx: Arc<ServerCtx>) -> eyre::Result<()> { #[derive(Clone, Debug)]
let (mut rx, mut tx) = stream.split(); 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()); let remote_addr = maybe_addr!(rx.connection().remote_addr());
tracing::trace!("{remote_addr} stream opened"); tracing::trace!("{remote_addr} stream opened");
// handle streams // handle streams
loop { while let Ok(Some(data)) = rx.receive().await {
match rx.receive().await { tracing::trace!(
Ok(Some(data)) => { "{remote_addr} msg: (raw): {}",
tracing::trace!( String::from_utf8_lossy(&data)
"{remote_addr} msg: (raw): {}", );
String::from_utf8_lossy(&data)
);
let request = Request::decode(data); let request = Request::decode(data);
tracing::debug!("{remote_addr} msg: {request:?}"); tracing::debug!("{remote_addr} msg: {request:?}");
// special case for now (send game file data to client) // special case for now (send game file data to client)
if let Request::GetGameFileData(game_file_desc) = &request { if let Request::GetGameFileData(game_file_desc) = &request {
send_game_file_data(game_file_desc, &mut tx, &ctx.games_folder).await; send_game_file_data(
continue; game_file_desc,
} &mut tx,
&ctx.conn_ctx.server_ctx.games_folder,
)
.await;
continue;
}
// normal case (handle request) let response = ctx
if let Err(e) = ctx .conn_ctx
.handler .server_ctx
.handle_request(request, &ctx.games_folder, &mut tx) .handler
.await .handle_request(request, &ctx.conn_ctx.server_ctx.games_folder)
{ .await;
tracing::error!(?e, "{remote_addr} error handling request");
} tracing::trace!("{remote_addr} server response: {response:?}");
} let raw_response = response.encode();
Ok(None) => { tracing::trace!(
tracing::trace!("{remote_addr} stream closed"); "{remote_addr} server response (raw): {}",
break; String::from_utf8_lossy(&raw_response)
} );
Err(e) => {
tracing::error!("{remote_addr} stream error: {e}"); // write response back to client
break; 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 handle_connection(mut connection: Connection, ctx: Arc<ServerCtx>) -> eyre::Result<()> { async fn handle_connection(
mut connection: Connection,
ctx: Arc<ConnectionCtx>,
) -> eyre::Result<()> {
let remote_addr = maybe_addr!(connection.remote_addr()); let remote_addr = maybe_addr!(connection.remote_addr());
tracing::info!("{remote_addr} connected"); tracing::info!("{remote_addr} connected");
// handle streams // handle streams
while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await { while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await {
let ctx = ctx.clone();
let remote_addr = remote_addr.clone(); 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 // spawn a new task for the stream
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_bidi_stream(stream, ctx).await { if let Err(e) = handle_bidi_stream(rx, tx, ctx).await {
tracing::error!("{remote_addr} stream error: {e}"); tracing::error!("{remote_addr} stream error: {e}");
} }
}); });
@ -89,8 +121,8 @@ pub(crate) async fn run_server(
games_folder: PathBuf, games_folder: PathBuf,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let limits = Limits::default() let limits = Limits::default()
.with_max_handshake_duration(Duration::from_secs(3))? .with_max_idle_timeout(Duration::ZERO)?
.with_max_idle_timeout(Duration::from_secs(3))?; .with_max_handshake_duration(Duration::from_secs(3))?;
let mut server = Server::builder() let mut server = Server::builder()
.with_tls((CERT_PEM, KEY_PEM))? .with_tls((CERT_PEM, KEY_PEM))?
@ -98,22 +130,23 @@ pub(crate) async fn run_server(
.with_limits(limits)? .with_limits(limits)?
.start()?; .start()?;
let ctx = Arc::new(ServerCtx { let server_ctx = Arc::new(ServerCtx {
handler: RequestHandler::new(db), handler: RequestHandler::new(db),
games_folder, games_folder,
}); });
while let Some(connection) = server.accept().await { while let Some(connection) = server.accept().await {
let ctx = ctx.clone(); let conn_ctx = Arc::new(ConnectionCtx {
server_ctx: server_ctx.clone(),
});
// spawn a new task for the connection // spawn a new task for the connection
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_connection(connection, ctx).await { if let Err(e) = handle_connection(connection, conn_ctx).await {
tracing::error!("Connection error: {}", e); tracing::error!("Connection error: {}", e);
} }
}); });
} }
tracing::info!("Server shutting down");
Ok(()) Ok(())
} }

View File

@ -3,56 +3,35 @@ use std::{
sync::Arc, sync::Arc,
}; };
use bytes::{Bytes, BytesMut}; use bytes::Bytes;
use lanspread_db::db::{GameDB, GameFileDescription}; use lanspread_db::db::{GameDB, GameFileDescription};
use lanspread_proto::{Message as _, Request, Response}; use lanspread_proto::{Request, Response};
use lanspread_utils::maybe_addr; use lanspread_utils::maybe_addr;
use s2n_quic::stream::SendStream; use s2n_quic::stream::SendStream;
use tokio::{io::AsyncReadExt, sync::RwLock, time::Instant}; use tokio::sync::Mutex;
use walkdir::WalkDir; use walkdir::WalkDir;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct RequestHandler { pub(crate) struct RequestHandler {
db: Arc<RwLock<GameDB>>, db: Arc<Mutex<GameDB>>,
} }
impl RequestHandler { impl RequestHandler {
pub(crate) fn new(games: GameDB) -> RequestHandler { pub(crate) fn new(games: GameDB) -> RequestHandler {
RequestHandler { RequestHandler {
db: Arc::new(RwLock::new(games)), db: Arc::new(Mutex::new(games)),
} }
} }
pub(crate) async fn handle_request( pub(crate) async fn handle_request(&self, request: Request, games_folder: &Path) -> Response {
&self,
request: Request,
games_folder: &Path,
tx: &mut SendStream,
) -> eyre::Result<()> {
let remote_addr = maybe_addr!(tx.connection().remote_addr());
// process request and generate response
let response = self.process_request(request, games_folder).await;
tracing::trace!("{remote_addr} server response: {response:?}");
// write response back to client
tx.send(response.encode()).await?;
// close the stream
tx.close().await?;
Ok(())
}
pub(crate) async fn process_request(&self, request: Request, games_folder: &Path) -> Response {
match request { match request {
Request::Ping => Response::Pong, Request::Ping => Response::Pong,
Request::ListGames => { Request::ListGames => {
let db = self.db.read().await; let db = self.db.lock().await;
Response::ListGames(db.all_games().into_iter().cloned().collect()) Response::Games(db.all_games().into_iter().cloned().collect())
} }
Request::GetGame { id } => { Request::GetGame { id } => {
if self.db.read().await.get_game_by_id(&id).is_none() { if self.db.lock().await.get_game_by_id(&id).is_none() {
tracing::error!("Game not found in DB: {id}"); tracing::error!("Game not found in DB: {id}");
return Response::GameNotFound(id); return Response::GameNotFound(id);
} }
@ -92,15 +71,19 @@ impl RequestHandler {
} }
} }
Response::GetGame { Response::GetGame(game_files_descs)
id,
file_descriptions: game_files_descs,
}
} }
Request::GetGameFileData(_) => { Request::GetGameFileData(_) => {
Response::InvalidRequest(Bytes::new(), "Not implemented".to_string()) Response::InvalidRequest(Bytes::new(), "Not implemented".to_string())
} }
Request::Invalid(data, err_msg) => Response::InvalidRequest(data, err_msg), 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)
}
} }
} }
} }
@ -115,48 +98,14 @@ pub(crate) async fn send_game_file_data(
tracing::debug!("{remote_addr} client requested game file data: {game_file_desc:?}",); tracing::debug!("{remote_addr} client requested game file data: {game_file_desc:?}",);
// deliver file data to client // deliver file data to client
let game_file = game_dir.join(&game_file_desc.relative_path); let path = game_dir.join(&game_file_desc.relative_path);
let mut total_bytes = 0; if let Ok(mut file) = tokio::fs::File::open(&path).await {
let mut last_total_bytes = 0; if let Err(e) = tokio::io::copy(&mut file, tx).await {
let mut timestamp = Instant::now(); tracing::error!("{remote_addr} failed to send file data: {e}",);
if let Ok(mut f) = tokio::fs::File::open(&game_file).await {
let mut buf = BytesMut::with_capacity(64 * 1024);
while let Ok(bytes_read) = f.read_buf(&mut buf).await {
if bytes_read == 0 {
break;
}
total_bytes += bytes_read;
if last_total_bytes + 10_000_000 < total_bytes {
let elapsed = timestamp.elapsed();
let diff_bytes = total_bytes - last_total_bytes;
if elapsed.as_secs_f64() >= 1.0 {
#[allow(clippy::cast_precision_loss)]
let mb_per_s = (diff_bytes as f64) / (elapsed.as_secs_f64() * 1000.0 * 1000.0);
tracing::debug!(
"{remote_addr} sending file data: {game_file:?}, MB/s: {mb_per_s:.2}",
);
last_total_bytes = total_bytes;
timestamp = Instant::now();
}
}
if let Err(e) = tx.send(buf.split_to(bytes_read).freeze()).await {
tracing::error!("{remote_addr} failed to send file data: {e}",);
break;
}
} }
tracing::debug!(
"{remote_addr} finished sending file data: {game_file:?}, total_bytes: {total_bytes}",
);
} else { } else {
tracing::error!("{remote_addr} failed to open file: {}", game_file.display()); tracing::error!("{remote_addr} failed to open file: {}", path.display());
} }
if let Err(e) = tx.close().await { if let Err(e) = tx.close().await {

View File

@ -1,19 +1,18 @@
{ {
"version": "4", "version": "4",
"specifiers": { "specifiers": {
"npm:@tauri-apps/api@^2.3.0": "2.3.0", "npm:@tauri-apps/api@2": "2.1.1",
"npm:@tauri-apps/cli@^2.3.1": "2.3.1", "npm:@tauri-apps/cli@2": "2.1.0",
"npm:@tauri-apps/plugin-dialog@^2.2.0": "2.2.0", "npm:@tauri-apps/plugin-dialog@~2.0.1": "2.0.1",
"npm:@tauri-apps/plugin-shell@^2.2.0": "2.2.0", "npm:@tauri-apps/plugin-shell@2": "2.0.1",
"npm:@tauri-apps/plugin-store@^2.2.0": "2.2.0", "npm:@tauri-apps/plugin-store@2.1": "2.1.0",
"npm:@types/react-dom@^19.0.4": "19.0.4_@types+react@19.0.10", "npm:@types/react-dom@^18.2.7": "18.3.1",
"npm:@types/react@^19.0.10": "19.0.10", "npm:@types/react@^18.2.15": "18.3.12",
"npm:@types/react@^19.0.12": "19.0.12", "npm:@vitejs/plugin-react@^4.2.1": "4.3.3_vite@5.4.11_@babel+core@7.26.0",
"npm:@vitejs/plugin-react@^4.3.4": "4.3.4_vite@6.2.2_@babel+core@7.26.10", "npm:react-dom@^18.2.0": "18.3.1_react@18.3.1",
"npm:react-dom@19": "19.0.0_react@19.0.0", "npm:react@^18.2.0": "18.3.1",
"npm:react@19": "19.0.0", "npm:typescript@^5.2.2": "5.6.3",
"npm:typescript@^5.8.2": "5.8.2", "npm:vite@^5.3.1": "5.4.11"
"npm:vite@^6.2.2": "6.2.2"
}, },
"npm": { "npm": {
"@ampproject/remapping@2.3.0": { "@ampproject/remapping@2.3.0": {
@ -31,11 +30,11 @@
"picocolors" "picocolors"
] ]
}, },
"@babel/compat-data@7.26.8": { "@babel/compat-data@7.26.2": {
"integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==" "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg=="
}, },
"@babel/core@7.26.10": { "@babel/core@7.26.0": {
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
"dependencies": [ "dependencies": [
"@ampproject/remapping", "@ampproject/remapping",
"@babel/code-frame", "@babel/code-frame",
@ -54,8 +53,8 @@
"semver" "semver"
] ]
}, },
"@babel/generator@7.26.10": { "@babel/generator@7.26.2": {
"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==",
"dependencies": [ "dependencies": [
"@babel/parser", "@babel/parser",
"@babel/types", "@babel/types",
@ -64,8 +63,8 @@
"jsesc" "jsesc"
] ]
}, },
"@babel/helper-compilation-targets@7.26.5": { "@babel/helper-compilation-targets@7.25.9": {
"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==",
"dependencies": [ "dependencies": [
"@babel/compat-data", "@babel/compat-data",
"@babel/helper-validator-option", "@babel/helper-validator-option",
@ -81,7 +80,7 @@
"@babel/types" "@babel/types"
] ]
}, },
"@babel/helper-module-transforms@7.26.0_@babel+core@7.26.10": { "@babel/helper-module-transforms@7.26.0_@babel+core@7.26.0": {
"integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
@ -90,8 +89,8 @@
"@babel/traverse" "@babel/traverse"
] ]
}, },
"@babel/helper-plugin-utils@7.26.5": { "@babel/helper-plugin-utils@7.25.9": {
"integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==" "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw=="
}, },
"@babel/helper-string-parser@7.25.9": { "@babel/helper-string-parser@7.25.9": {
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="
@ -102,43 +101,43 @@
"@babel/helper-validator-option@7.25.9": { "@babel/helper-validator-option@7.25.9": {
"integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==" "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="
}, },
"@babel/helpers@7.26.10": { "@babel/helpers@7.26.0": {
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
"dependencies": [ "dependencies": [
"@babel/template", "@babel/template",
"@babel/types" "@babel/types"
] ]
}, },
"@babel/parser@7.26.10": { "@babel/parser@7.26.2": {
"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==",
"dependencies": [ "dependencies": [
"@babel/types" "@babel/types"
] ]
}, },
"@babel/plugin-transform-react-jsx-self@7.25.9_@babel+core@7.26.10": { "@babel/plugin-transform-react-jsx-self@7.25.9_@babel+core@7.26.0": {
"integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
"@babel/helper-plugin-utils" "@babel/helper-plugin-utils"
] ]
}, },
"@babel/plugin-transform-react-jsx-source@7.25.9_@babel+core@7.26.10": { "@babel/plugin-transform-react-jsx-source@7.25.9_@babel+core@7.26.0": {
"integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
"@babel/helper-plugin-utils" "@babel/helper-plugin-utils"
] ]
}, },
"@babel/template@7.26.9": { "@babel/template@7.25.9": {
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
"dependencies": [ "dependencies": [
"@babel/code-frame", "@babel/code-frame",
"@babel/parser", "@babel/parser",
"@babel/types" "@babel/types"
] ]
}, },
"@babel/traverse@7.26.10": { "@babel/traverse@7.25.9": {
"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==",
"dependencies": [ "dependencies": [
"@babel/code-frame", "@babel/code-frame",
"@babel/generator", "@babel/generator",
@ -149,90 +148,84 @@
"globals" "globals"
] ]
}, },
"@babel/types@7.26.10": { "@babel/types@7.26.0": {
"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==",
"dependencies": [ "dependencies": [
"@babel/helper-string-parser", "@babel/helper-string-parser",
"@babel/helper-validator-identifier" "@babel/helper-validator-identifier"
] ]
}, },
"@esbuild/aix-ppc64@0.25.1": { "@esbuild/aix-ppc64@0.21.5": {
"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==" "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="
}, },
"@esbuild/android-arm64@0.25.1": { "@esbuild/android-arm64@0.21.5": {
"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==" "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="
}, },
"@esbuild/android-arm@0.25.1": { "@esbuild/android-arm@0.21.5": {
"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==" "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="
}, },
"@esbuild/android-x64@0.25.1": { "@esbuild/android-x64@0.21.5": {
"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==" "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="
}, },
"@esbuild/darwin-arm64@0.25.1": { "@esbuild/darwin-arm64@0.21.5": {
"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==" "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="
}, },
"@esbuild/darwin-x64@0.25.1": { "@esbuild/darwin-x64@0.21.5": {
"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==" "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="
}, },
"@esbuild/freebsd-arm64@0.25.1": { "@esbuild/freebsd-arm64@0.21.5": {
"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==" "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="
}, },
"@esbuild/freebsd-x64@0.25.1": { "@esbuild/freebsd-x64@0.21.5": {
"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==" "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="
}, },
"@esbuild/linux-arm64@0.25.1": { "@esbuild/linux-arm64@0.21.5": {
"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==" "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="
}, },
"@esbuild/linux-arm@0.25.1": { "@esbuild/linux-arm@0.21.5": {
"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==" "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="
}, },
"@esbuild/linux-ia32@0.25.1": { "@esbuild/linux-ia32@0.21.5": {
"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==" "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="
}, },
"@esbuild/linux-loong64@0.25.1": { "@esbuild/linux-loong64@0.21.5": {
"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==" "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="
}, },
"@esbuild/linux-mips64el@0.25.1": { "@esbuild/linux-mips64el@0.21.5": {
"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==" "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="
}, },
"@esbuild/linux-ppc64@0.25.1": { "@esbuild/linux-ppc64@0.21.5": {
"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==" "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="
}, },
"@esbuild/linux-riscv64@0.25.1": { "@esbuild/linux-riscv64@0.21.5": {
"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==" "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="
}, },
"@esbuild/linux-s390x@0.25.1": { "@esbuild/linux-s390x@0.21.5": {
"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==" "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="
}, },
"@esbuild/linux-x64@0.25.1": { "@esbuild/linux-x64@0.21.5": {
"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==" "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="
}, },
"@esbuild/netbsd-arm64@0.25.1": { "@esbuild/netbsd-x64@0.21.5": {
"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==" "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="
}, },
"@esbuild/netbsd-x64@0.25.1": { "@esbuild/openbsd-x64@0.21.5": {
"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==" "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="
}, },
"@esbuild/openbsd-arm64@0.25.1": { "@esbuild/sunos-x64@0.21.5": {
"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==" "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="
}, },
"@esbuild/openbsd-x64@0.25.1": { "@esbuild/win32-arm64@0.21.5": {
"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==" "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="
}, },
"@esbuild/sunos-x64@0.25.1": { "@esbuild/win32-ia32@0.21.5": {
"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==" "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="
}, },
"@esbuild/win32-arm64@0.25.1": { "@esbuild/win32-x64@0.21.5": {
"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==" "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="
}, },
"@esbuild/win32-ia32@0.25.1": { "@jridgewell/gen-mapping@0.3.5": {
"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==" "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
},
"@esbuild/win32-x64@0.25.1": {
"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="
},
"@jridgewell/gen-mapping@0.3.8": {
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dependencies": [ "dependencies": [
"@jridgewell/set-array", "@jridgewell/set-array",
"@jridgewell/sourcemap-codec", "@jridgewell/sourcemap-codec",
@ -255,98 +248,95 @@
"@jridgewell/sourcemap-codec" "@jridgewell/sourcemap-codec"
] ]
}, },
"@rollup/rollup-android-arm-eabi@4.36.0": { "@rollup/rollup-android-arm-eabi@4.26.0": {
"integrity": "sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w==" "integrity": "sha512-gJNwtPDGEaOEgejbaseY6xMFu+CPltsc8/T+diUTTbOQLqD+bnrJq9ulH6WD69TqwqWmrfRAtUv30cCFZlbGTQ=="
}, },
"@rollup/rollup-android-arm64@4.36.0": { "@rollup/rollup-android-arm64@4.26.0": {
"integrity": "sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg==" "integrity": "sha512-YJa5Gy8mEZgz5JquFruhJODMq3lTHWLm1fOy+HIANquLzfIOzE9RA5ie3JjCdVb9r46qfAQY/l947V0zfGJ0OQ=="
}, },
"@rollup/rollup-darwin-arm64@4.36.0": { "@rollup/rollup-darwin-arm64@4.26.0": {
"integrity": "sha512-JQ1Jk5G4bGrD4pWJQzWsD8I1n1mgPXq33+/vP4sk8j/z/C2siRuxZtaUA7yMTf71TCZTZl/4e1bfzwUmFb3+rw==" "integrity": "sha512-ErTASs8YKbqTBoPLp/kA1B1Um5YSom8QAc4rKhg7b9tyyVqDBlQxy7Bf2wW7yIlPGPg2UODDQcbkTlruPzDosw=="
}, },
"@rollup/rollup-darwin-x64@4.36.0": { "@rollup/rollup-darwin-x64@4.26.0": {
"integrity": "sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA==" "integrity": "sha512-wbgkYDHcdWW+NqP2mnf2NOuEbOLzDblalrOWcPyY6+BRbVhliavon15UploG7PpBRQ2bZJnbmh8o3yLoBvDIHA=="
}, },
"@rollup/rollup-freebsd-arm64@4.36.0": { "@rollup/rollup-freebsd-arm64@4.26.0": {
"integrity": "sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg==" "integrity": "sha512-Y9vpjfp9CDkAG4q/uwuhZk96LP11fBz/bYdyg9oaHYhtGZp7NrbkQrj/66DYMMP2Yo/QPAsVHkV891KyO52fhg=="
}, },
"@rollup/rollup-freebsd-x64@4.36.0": { "@rollup/rollup-freebsd-x64@4.26.0": {
"integrity": "sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ==" "integrity": "sha512-A/jvfCZ55EYPsqeaAt/yDAG4q5tt1ZboWMHEvKAH9Zl92DWvMIbnZe/f/eOXze65aJaaKbL+YeM0Hz4kLQvdwg=="
}, },
"@rollup/rollup-linux-arm-gnueabihf@4.36.0": { "@rollup/rollup-linux-arm-gnueabihf@4.26.0": {
"integrity": "sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==" "integrity": "sha512-paHF1bMXKDuizaMODm2bBTjRiHxESWiIyIdMugKeLnjuS1TCS54MF5+Y5Dx8Ui/1RBPVRE09i5OUlaLnv8OGnA=="
}, },
"@rollup/rollup-linux-arm-musleabihf@4.36.0": { "@rollup/rollup-linux-arm-musleabihf@4.26.0": {
"integrity": "sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==" "integrity": "sha512-cwxiHZU1GAs+TMxvgPfUDtVZjdBdTsQwVnNlzRXC5QzIJ6nhfB4I1ahKoe9yPmoaA/Vhf7m9dB1chGPpDRdGXg=="
}, },
"@rollup/rollup-linux-arm64-gnu@4.36.0": { "@rollup/rollup-linux-arm64-gnu@4.26.0": {
"integrity": "sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==" "integrity": "sha512-4daeEUQutGRCW/9zEo8JtdAgtJ1q2g5oHaoQaZbMSKaIWKDQwQ3Yx0/3jJNmpzrsScIPtx/V+1AfibLisb3AMQ=="
}, },
"@rollup/rollup-linux-arm64-musl@4.36.0": { "@rollup/rollup-linux-arm64-musl@4.26.0": {
"integrity": "sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==" "integrity": "sha512-eGkX7zzkNxvvS05ROzJ/cO/AKqNvR/7t1jA3VZDi2vRniLKwAWxUr85fH3NsvtxU5vnUUKFHKh8flIBdlo2b3Q=="
}, },
"@rollup/rollup-linux-loongarch64-gnu@4.36.0": { "@rollup/rollup-linux-powerpc64le-gnu@4.26.0": {
"integrity": "sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==" "integrity": "sha512-Odp/lgHbW/mAqw/pU21goo5ruWsytP7/HCC/liOt0zcGG0llYWKrd10k9Fj0pdj3prQ63N5yQLCLiE7HTX+MYw=="
}, },
"@rollup/rollup-linux-powerpc64le-gnu@4.36.0": { "@rollup/rollup-linux-riscv64-gnu@4.26.0": {
"integrity": "sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==" "integrity": "sha512-MBR2ZhCTzUgVD0OJdTzNeF4+zsVogIR1U/FsyuFerwcqjZGvg2nYe24SAHp8O5sN8ZkRVbHwlYeHqcSQ8tcYew=="
}, },
"@rollup/rollup-linux-riscv64-gnu@4.36.0": { "@rollup/rollup-linux-s390x-gnu@4.26.0": {
"integrity": "sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==" "integrity": "sha512-YYcg8MkbN17fMbRMZuxwmxWqsmQufh3ZJFxFGoHjrE7bv0X+T6l3glcdzd7IKLiwhT+PZOJCblpnNlz1/C3kGQ=="
}, },
"@rollup/rollup-linux-s390x-gnu@4.36.0": { "@rollup/rollup-linux-x64-gnu@4.26.0": {
"integrity": "sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==" "integrity": "sha512-ZuwpfjCwjPkAOxpjAEjabg6LRSfL7cAJb6gSQGZYjGhadlzKKywDkCUnJ+KEfrNY1jH5EEoSIKLCb572jSiglA=="
}, },
"@rollup/rollup-linux-x64-gnu@4.36.0": { "@rollup/rollup-linux-x64-musl@4.26.0": {
"integrity": "sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==" "integrity": "sha512-+HJD2lFS86qkeF8kNu0kALtifMpPCZU80HvwztIKnYwym3KnA1os6nsX4BGSTLtS2QVAGG1P3guRgsYyMA0Yhg=="
}, },
"@rollup/rollup-linux-x64-musl@4.36.0": { "@rollup/rollup-win32-arm64-msvc@4.26.0": {
"integrity": "sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==" "integrity": "sha512-WUQzVFWPSw2uJzX4j6YEbMAiLbs0BUysgysh8s817doAYhR5ybqTI1wtKARQKo6cGop3pHnrUJPFCsXdoFaimQ=="
}, },
"@rollup/rollup-win32-arm64-msvc@4.36.0": { "@rollup/rollup-win32-ia32-msvc@4.26.0": {
"integrity": "sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==" "integrity": "sha512-D4CxkazFKBfN1akAIY6ieyOqzoOoBV1OICxgUblWxff/pSjCA2khXlASUx7mK6W1oP4McqhgcCsu6QaLj3WMWg=="
}, },
"@rollup/rollup-win32-ia32-msvc@4.36.0": { "@rollup/rollup-win32-x64-msvc@4.26.0": {
"integrity": "sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ==" "integrity": "sha512-2x8MO1rm4PGEP0xWbubJW5RtbNLk3puzAMaLQd3B3JHVw4KcHlmXcO+Wewx9zCoo7EUFiMlu/aZbCJ7VjMzAag=="
}, },
"@rollup/rollup-win32-x64-msvc@4.36.0": { "@tauri-apps/api@2.1.1": {
"integrity": "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw==" "integrity": "sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A=="
}, },
"@tauri-apps/api@2.3.0": { "@tauri-apps/cli-darwin-arm64@2.1.0": {
"integrity": "sha512-33Z+0lX2wgZbx1SPFfqvzI6su63hCBkbzv+5NexeYjIx7WA9htdOKoRR7Dh3dJyltqS5/J8vQFyybiRoaL0hlA==" "integrity": "sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw=="
}, },
"@tauri-apps/cli-darwin-arm64@2.3.1": { "@tauri-apps/cli-darwin-x64@2.1.0": {
"integrity": "sha512-TOhSdsXYt+f+asRU+Dl+Wufglj/7+CX9h8RO4hl5k7D6lR4L8yTtdhpS7btaclOMmjYC4piNfJE70GoxhOoYWw==" "integrity": "sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA=="
}, },
"@tauri-apps/cli-darwin-x64@2.3.1": { "@tauri-apps/cli-linux-arm-gnueabihf@2.1.0": {
"integrity": "sha512-LDwGg3AuBQ3aCeMAFaFwt0MSGOVFoXuXEe0z4QxQ7jZE5tdAOhKABaq4i569V5lShCgQZ6nLD/tmA5+GipvHnA==" "integrity": "sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w=="
}, },
"@tauri-apps/cli-linux-arm-gnueabihf@2.3.1": { "@tauri-apps/cli-linux-arm64-gnu@2.1.0": {
"integrity": "sha512-hu3HpbbtJBvHXw5i54QHwLxOUoXWqhf7CL2YYSPOrWEEQo10NKddulP61L5gfr5z+bSSaitfLwqgTidgnaNJCA==" "integrity": "sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ=="
}, },
"@tauri-apps/cli-linux-arm64-gnu@2.3.1": { "@tauri-apps/cli-linux-arm64-musl@2.1.0": {
"integrity": "sha512-mEGgwkiGSKYXWHhGodo7zU9PCd2I/d6KkR+Wp1nzK+DxsCrEK6yJ5XxYLSQSDcKkM4dCxpVEPUiVMbDhmn08jg==" "integrity": "sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA=="
}, },
"@tauri-apps/cli-linux-arm64-musl@2.3.1": { "@tauri-apps/cli-linux-x64-gnu@2.1.0": {
"integrity": "sha512-tqQkafikGfnc7ISnGjSYkbpnzJKEyO8XSa0YOXTAL3J8R5Pss5ZIZY7G8kq1mwQSR/dPVR1ZLTVXgZGuysjP8w==" "integrity": "sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA=="
}, },
"@tauri-apps/cli-linux-x64-gnu@2.3.1": { "@tauri-apps/cli-linux-x64-musl@2.1.0": {
"integrity": "sha512-I3puDJ2wGEauXlXbzIHn2etz78TaWs1cpN6zre02maHr6ZR7nf7euTCOGPhhfoMG0opA5mT/eLuYpVw648/VAA==" "integrity": "sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw=="
}, },
"@tauri-apps/cli-linux-x64-musl@2.3.1": { "@tauri-apps/cli-win32-arm64-msvc@2.1.0": {
"integrity": "sha512-rbWiCOBuQN7tPySkUyBs914uUikE3mEUOqV/IFospvKESw4UC3G1DL5+ybfXH7Orb8/in3JpJuVzYQjo+OSbBA==" "integrity": "sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg=="
}, },
"@tauri-apps/cli-win32-arm64-msvc@2.3.1": { "@tauri-apps/cli-win32-ia32-msvc@2.1.0": {
"integrity": "sha512-PdTmUzSeTHjJuBpCV7L+V29fPhPtToU+NZU46slHKSA1aT38MiFDXBZ/6P5Zudrt9QPMfIubqnJKbK8Ivvv7Ww==" "integrity": "sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg=="
}, },
"@tauri-apps/cli-win32-ia32-msvc@2.3.1": { "@tauri-apps/cli-win32-x64-msvc@2.1.0": {
"integrity": "sha512-K/Xa97kspWT4UWj3t26lL2D3QsopTAxS7kWi5kObdqtAGn3qD52qBi24FH38TdvHYz4QlnLIb30TukviCgh4gw==" "integrity": "sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw=="
}, },
"@tauri-apps/cli-win32-x64-msvc@2.3.1": { "@tauri-apps/cli@2.1.0": {
"integrity": "sha512-RgwzXbP8gAno3kQEsybMtgLp6D1Z1Nec2cftryYbPTJmoMJs6e4qgtxuTSbUz5SKnHe8rGgMiFSvEGoHvbG72Q==" "integrity": "sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==",
},
"@tauri-apps/cli@2.3.1": {
"integrity": "sha512-xewcw/ZsCqgilTy2h7+pp2Baxoy7zLR2wXOV7SZLzkb6SshHVbm1BFAjn8iFATURRW85KLzl6wSGJ2dQHjVHqw==",
"dependencies": [ "dependencies": [
"@tauri-apps/cli-darwin-arm64", "@tauri-apps/cli-darwin-arm64",
"@tauri-apps/cli-darwin-x64", "@tauri-apps/cli-darwin-x64",
@ -360,20 +350,20 @@
"@tauri-apps/cli-win32-x64-msvc" "@tauri-apps/cli-win32-x64-msvc"
] ]
}, },
"@tauri-apps/plugin-dialog@2.2.0": { "@tauri-apps/plugin-dialog@2.0.1": {
"integrity": "sha512-6bLkYK68zyK31418AK5fNccCdVuRnNpbxquCl8IqgFByOgWFivbiIlvb79wpSXi0O+8k8RCSsIpOquebusRVSg==", "integrity": "sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==",
"dependencies": [ "dependencies": [
"@tauri-apps/api" "@tauri-apps/api"
] ]
}, },
"@tauri-apps/plugin-shell@2.2.0": { "@tauri-apps/plugin-shell@2.0.1": {
"integrity": "sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==", "integrity": "sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==",
"dependencies": [ "dependencies": [
"@tauri-apps/api" "@tauri-apps/api"
] ]
}, },
"@tauri-apps/plugin-store@2.2.0": { "@tauri-apps/plugin-store@2.1.0": {
"integrity": "sha512-hJTRtuJis4w5fW1dkcgftsYxKXK0+DbAqurZ3CURHG5WkAyyZgbxpeYctw12bbzF9ZbZREXZklPq8mocCC3Sgg==", "integrity": "sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw==",
"dependencies": [ "dependencies": [
"@tauri-apps/api" "@tauri-apps/api"
] ]
@ -410,26 +400,24 @@
"@types/estree@1.0.6": { "@types/estree@1.0.6": {
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
}, },
"@types/react-dom@19.0.4_@types+react@19.0.10": { "@types/prop-types@15.7.13": {
"integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="
},
"@types/react-dom@18.3.1": {
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
"dependencies": [ "dependencies": [
"@types/react@19.0.10" "@types/react"
] ]
}, },
"@types/react@19.0.10": { "@types/react@18.3.12": {
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"dependencies": [ "dependencies": [
"@types/prop-types",
"csstype" "csstype"
] ]
}, },
"@types/react@19.0.12": { "@vitejs/plugin-react@4.3.3_vite@5.4.11_@babel+core@7.26.0": {
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==",
"dependencies": [
"csstype"
]
},
"@vitejs/plugin-react@4.3.4_vite@6.2.2_@babel+core@7.26.10": {
"integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
"@babel/plugin-transform-react-jsx-self", "@babel/plugin-transform-react-jsx-self",
@ -439,8 +427,8 @@
"vite" "vite"
] ]
}, },
"browserslist@4.24.4": { "browserslist@4.24.2": {
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
"dependencies": [ "dependencies": [
"caniuse-lite", "caniuse-lite",
"electron-to-chromium", "electron-to-chromium",
@ -448,8 +436,8 @@
"update-browserslist-db" "update-browserslist-db"
] ]
}, },
"caniuse-lite@1.0.30001706": { "caniuse-lite@1.0.30001680": {
"integrity": "sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==" "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA=="
}, },
"convert-source-map@2.0.0": { "convert-source-map@2.0.0": {
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
@ -457,17 +445,17 @@
"csstype@3.1.3": { "csstype@3.1.3": {
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
}, },
"debug@4.4.0": { "debug@4.3.7": {
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": [ "dependencies": [
"ms" "ms"
] ]
}, },
"electron-to-chromium@1.5.122": { "electron-to-chromium@1.5.57": {
"integrity": "sha512-EML1wnwkY5MFh/xUnCvY8FrhUuKzdYhowuZExZOfwJo+Zu9OsNCI23Cgl5y7awy7HrUHSwB1Z8pZX5TI34lsUg==" "integrity": "sha512-xS65H/tqgOwUBa5UmOuNSLuslDo7zho0y/lgQw35pnrqiZh7UOWHCeL/Bt6noJATbA6tpQJGCifsFsIRZj1Fqg=="
}, },
"esbuild@0.25.1": { "esbuild@0.21.5": {
"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dependencies": [ "dependencies": [
"@esbuild/aix-ppc64", "@esbuild/aix-ppc64",
"@esbuild/android-arm", "@esbuild/android-arm",
@ -486,9 +474,7 @@
"@esbuild/linux-riscv64", "@esbuild/linux-riscv64",
"@esbuild/linux-s390x", "@esbuild/linux-s390x",
"@esbuild/linux-x64", "@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64", "@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64", "@esbuild/openbsd-x64",
"@esbuild/sunos-x64", "@esbuild/sunos-x64",
"@esbuild/win32-arm64", "@esbuild/win32-arm64",
@ -511,12 +497,18 @@
"js-tokens@4.0.0": { "js-tokens@4.0.0": {
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
}, },
"jsesc@3.1.0": { "jsesc@3.0.2": {
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==" "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="
}, },
"json5@2.2.3": { "json5@2.2.3": {
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
}, },
"loose-envify@1.4.0": {
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dependencies": [
"js-tokens"
]
},
"lru-cache@5.1.1": { "lru-cache@5.1.1": {
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dependencies": [ "dependencies": [
@ -526,26 +518,27 @@
"ms@2.1.3": { "ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"nanoid@3.3.11": { "nanoid@3.3.7": {
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
}, },
"node-releases@2.0.19": { "node-releases@2.0.18": {
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g=="
}, },
"picocolors@1.1.1": { "picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"postcss@8.5.3": { "postcss@8.4.49": {
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"dependencies": [ "dependencies": [
"nanoid", "nanoid",
"picocolors", "picocolors",
"source-map-js" "source-map-js"
] ]
}, },
"react-dom@19.0.0_react@19.0.0": { "react-dom@18.3.1_react@18.3.1": {
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dependencies": [ "dependencies": [
"loose-envify",
"react", "react",
"scheduler" "scheduler"
] ]
@ -553,11 +546,14 @@
"react-refresh@0.14.2": { "react-refresh@0.14.2": {
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="
}, },
"react@19.0.0": { "react@18.3.1": {
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==" "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dependencies": [
"loose-envify"
]
}, },
"rollup@4.36.0": { "rollup@4.26.0": {
"integrity": "sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q==", "integrity": "sha512-ilcl12hnWonG8f+NxU6BlgysVA0gvY2l8N0R84S1HcINbW20bvwuCngJkkInV6LXhwRpucsW5k1ovDwEdBVrNg==",
"dependencies": [ "dependencies": [
"@rollup/rollup-android-arm-eabi", "@rollup/rollup-android-arm-eabi",
"@rollup/rollup-android-arm64", "@rollup/rollup-android-arm64",
@ -569,7 +565,6 @@
"@rollup/rollup-linux-arm-musleabihf", "@rollup/rollup-linux-arm-musleabihf",
"@rollup/rollup-linux-arm64-gnu", "@rollup/rollup-linux-arm64-gnu",
"@rollup/rollup-linux-arm64-musl", "@rollup/rollup-linux-arm64-musl",
"@rollup/rollup-linux-loongarch64-gnu",
"@rollup/rollup-linux-powerpc64le-gnu", "@rollup/rollup-linux-powerpc64le-gnu",
"@rollup/rollup-linux-riscv64-gnu", "@rollup/rollup-linux-riscv64-gnu",
"@rollup/rollup-linux-s390x-gnu", "@rollup/rollup-linux-s390x-gnu",
@ -582,8 +577,11 @@
"fsevents" "fsevents"
] ]
}, },
"scheduler@0.25.0": { "scheduler@0.23.2": {
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dependencies": [
"loose-envify"
]
}, },
"semver@6.3.1": { "semver@6.3.1": {
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="
@ -591,19 +589,19 @@
"source-map-js@1.2.1": { "source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
}, },
"typescript@5.8.2": { "typescript@5.6.3": {
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==" "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="
}, },
"update-browserslist-db@1.1.3_browserslist@4.24.4": { "update-browserslist-db@1.1.1_browserslist@4.24.2": {
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
"dependencies": [ "dependencies": [
"browserslist", "browserslist",
"escalade", "escalade",
"picocolors" "picocolors"
] ]
}, },
"vite@6.2.2": { "vite@5.4.11": {
"integrity": "sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ==", "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
"dependencies": [ "dependencies": [
"esbuild", "esbuild",
"fsevents", "fsevents",
@ -618,18 +616,18 @@
"workspace": { "workspace": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@tauri-apps/api@^2.3.0", "npm:@tauri-apps/api@2",
"npm:@tauri-apps/cli@^2.3.1", "npm:@tauri-apps/cli@2",
"npm:@tauri-apps/plugin-dialog@^2.2.0", "npm:@tauri-apps/plugin-dialog@~2.0.1",
"npm:@tauri-apps/plugin-shell@^2.2.0", "npm:@tauri-apps/plugin-shell@2",
"npm:@tauri-apps/plugin-store@^2.2.0", "npm:@tauri-apps/plugin-store@2.1",
"npm:@types/react-dom@^19.0.4", "npm:@types/react-dom@^18.2.7",
"npm:@types/react@^19.0.12", "npm:@types/react@^18.2.15",
"npm:@vitejs/plugin-react@^4.3.4", "npm:@vitejs/plugin-react@^4.2.1",
"npm:react-dom@19", "npm:react-dom@^18.2.0",
"npm:react@19", "npm:react@^18.2.0",
"npm:typescript@^5.8.2", "npm:typescript@^5.2.2",
"npm:vite@^6.2.2" "npm:vite@^5.3.1"
] ]
} }
} }

View File

@ -10,19 +10,19 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-dialog": "~2.0.1",
"@tauri-apps/plugin-store": "^2.2.0", "@tauri-apps/plugin-store": "~2.1.0",
"react": "^19.0.0", "react": "^18.2.0",
"react-dom": "^19.0.0", "react-dom": "^18.2.0",
"@tauri-apps/api": "^2.3.0", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2.2.0" "@tauri-apps/plugin-shell": "^2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.12", "@types/react": "^18.2.15",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.8.2", "typescript": "^5.2.2",
"vite": "^6.2.2", "vite": "^5.3.1",
"@tauri-apps/cli": "^2.3.1" "@tauri-apps/cli": "^2"
} }
} }

View File

@ -21,7 +21,7 @@ struct LanSpreadState {
client_ctrl: UnboundedSender<ClientCommand>, client_ctrl: UnboundedSender<ClientCommand>,
games: Arc<Mutex<GameDB>>, games: Arc<Mutex<GameDB>>,
games_in_download: Arc<Mutex<HashSet<String>>>, games_in_download: Arc<Mutex<HashSet<String>>>,
games_folder: Arc<Mutex<String>>, games_dir: Arc<Mutex<String>>,
} }
#[tauri::command] #[tauri::command]
@ -35,9 +35,11 @@ fn request_games(state: tauri::State<LanSpreadState>) {
#[tauri::command] #[tauri::command]
fn install_game(id: String, state: tauri::State<LanSpreadState>) -> bool { fn install_game(id: String, state: tauri::State<LanSpreadState>) -> bool {
log::error!("Running game with id {id}");
let already_in_download = tauri::async_runtime::block_on(async { let already_in_download = tauri::async_runtime::block_on(async {
if state.inner().games_in_download.lock().await.contains(&id) { if state.inner().games_in_download.lock().await.contains(&id) {
log::warn!("Game is already downloading: {id}"); log::error!("Game is already downloading: {id}");
return true; return true;
} }
false false
@ -60,16 +62,16 @@ fn run_game(id: String, state: tauri::State<LanSpreadState>) {
log::error!("run_game {id}"); log::error!("run_game {id}");
let games_folder = let games_dir =
tauri::async_runtime::block_on(async { state.inner().games_folder.lock().await.clone() }); tauri::async_runtime::block_on(async { state.inner().games_dir.lock().await.clone() });
let games_folder = PathBuf::from(games_folder); let games_dir = PathBuf::from(games_dir);
if !games_folder.exists() { if !games_dir.exists() {
log::error!("games_folder {games_folder:?} does not exist"); log::error!("games_dir {games_dir:?} does not exist");
return; return;
} }
let game_path = games_folder.join(id); let game_path = games_dir.join(id);
let game_setup_bin = game_path.join("game_setup.cmd"); let game_setup_bin = game_path.join("game_setup.cmd");
let game_start_bin = game_path.join("game_start.cmd"); let game_start_bin = game_path.join("game_start.cmd");
@ -82,7 +84,7 @@ fn run_game(id: String, state: tauri::State<LanSpreadState>) {
{ {
log::error!("failed to run game_setup.cmd: {e}"); log::error!("failed to run game_setup.cmd: {e}");
return; return;
} else if let Err(e) = File::create(FIRST_START_DONE_FILE) { } else if let Err(e) = std::fs::File::create(FIRST_START_DONE_FILE) {
log::error!("failed to create {first_start_done_file:?}: {e}"); log::error!("failed to create {first_start_done_file:?}: {e}");
} }
} }
@ -102,7 +104,7 @@ fn set_game_install_state_from_path(game_db: &mut GameDB, path: &Path, installed
if let Some(file_name) = file_name.to_str() { if let Some(file_name) = file_name.to_str() {
if let Some(game) = game_db.get_mut_game_by_id(file_name) { if let Some(game) = game_db.get_mut_game_by_id(file_name) {
if installed { if installed {
log::debug!("Game is installed: {game}"); log::info!("Game is installed: {game}");
} else { } else {
log::error!("Game is missing: {game}"); log::error!("Game is missing: {game}");
} }
@ -124,14 +126,14 @@ fn update_game_directory(app_handle: tauri::AppHandle, path: String) {
{ {
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
let mut games_folder = app_handle let mut games_dir = app_handle
.state::<LanSpreadState>() .state::<LanSpreadState>()
.inner() .inner()
.games_folder .games_dir
.lock() .lock()
.await; .await;
*games_folder = path.clone(); *games_dir = path.clone();
}); });
} }
@ -165,7 +167,7 @@ fn update_game_directory(app_handle: tauri::AppHandle, path: String) {
if let Ok(path_type) = entry.file_type() { if let Ok(path_type) = entry.file_type() {
if path_type.is_dir() { if path_type.is_dir() {
let path = entry.path(); let path = entry.path();
if path.join("version.ini").exists() { if path.join(".softlan_game_installed").exists() {
set_game_install_state_from_path(&mut game_db, &path, true); set_game_install_state_from_path(&mut game_db, &path, true);
} }
} }
@ -247,13 +249,9 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R
.to_str() .to_str()
.ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?; .ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?;
log::info!( log::error!("SIDECARE: {:?}", &sidecar);
"unrar game: {} to {}",
rar_file.canonicalize()?.display(),
dest_dir
);
let out = sidecar sidecar
.arg("x") // extract files .arg("x") // extract files
.arg(rar_file.canonicalize()?) .arg(rar_file.canonicalize()?)
.arg("-y") // Assume Yes on all queries .arg("-y") // Assume Yes on all queries
@ -262,10 +260,6 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R
.output() .output()
.await?; .await?;
if !out.status.success() {
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr));
}
return Ok(()); return Ok(());
} else { } else {
log::error!("dest_dir canonicalize failed: {:?}", &dest_dir); log::error!("dest_dir canonicalize failed: {:?}", &dest_dir);
@ -280,13 +274,20 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R
bail!("failed to create directory: {dest_dir:?}"); bail!("failed to create directory: {dest_dir:?}");
} }
async fn unpack_game(id: &str, sidecar: Command, games_folder: String) { async fn unpack_game(id: &str, sidecar: Command, games_dir: String) {
let game_path = PathBuf::from(games_folder).join(id); let game_path = PathBuf::from(games_dir).join(id);
let eti_rar = game_path.join(format!("{id}.eti")); let eti_rar = game_path.join(format!("{id}.eti"));
let local_path = game_path.join("local"); let local_path = game_path.join("local");
if let Err(e) = do_unrar(sidecar, &eti_rar, &local_path).await { if let Err(e) = do_unrar(sidecar, &eti_rar, &local_path).await {
log::error!("{eti_rar:?} -> {local_path:?}: {e}"); log::error!("{eti_rar:?} -> {local_path:?}: {e}");
} else {
let game_installed_file = game_path.join(".softlan_game_installed");
if let Err(e) = File::create(game_installed_file) {
log::error!("failed to create game_installed_file: {e}");
} else {
log::info!("game unpacked: {id}");
}
} }
} }
@ -298,6 +299,8 @@ 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::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);
// channel to pass commands to the client // channel to pass commands to the client
@ -313,7 +316,7 @@ pub fn run() {
client_ctrl: tx_client_control, client_ctrl: tx_client_control,
games: Arc::new(Mutex::new(GameDB::empty())), games: Arc::new(Mutex::new(GameDB::empty())),
games_in_download: Arc::new(Mutex::new(HashSet::new())), games_in_download: Arc::new(Mutex::new(HashSet::new())),
games_folder: Arc::new(Mutex::new("".to_string())), games_dir: Arc::new(Mutex::new("".to_string())),
}; };
tauri::Builder::default() tauri::Builder::default()
@ -344,12 +347,13 @@ pub fn run() {
log::info!("ClientEvent::ListGames received"); log::info!("ClientEvent::ListGames received");
update_game_db(games, app_handle.clone()).await; update_game_db(games, app_handle.clone()).await;
} }
ClientEvent::GotGameFiles { id, file_descriptions } => { ClientEvent::GotGameFiles(game_file_descs) => {
log::info!("ClientEvent::GotGameFiles received"); log::info!("ClientEvent::GotGameFiles received");
if let Some(first_desc_file) = game_file_descs.first() {
if let Err(e) = app_handle.emit( if let Err(e) = app_handle.emit(
"game-download-pre", "game-download-pre",
Some(id.clone()), Some(first_desc_file.game_id.clone()),
) { ) {
log::error!("ClientEvent::GotGameFiles: Failed to emit game-download-pre event: {e}"); log::error!("ClientEvent::GotGameFiles: Failed to emit game-download-pre event: {e}");
} }
@ -358,12 +362,11 @@ pub fn run() {
.state::<LanSpreadState>() .state::<LanSpreadState>()
.inner() .inner()
.client_ctrl .client_ctrl
.send(ClientCommand::DownloadGameFiles{ .send(ClientCommand::DownloadGameFiles(game_file_descs))
id,
file_descriptions,
})
.unwrap(); .unwrap();
} else {
log::error!("ClientEvent::GotGameFiles: Got empty game files list");
}
} }
ClientEvent::DownloadGameFilesBegin { id } => { ClientEvent::DownloadGameFilesBegin { id } => {
log::info!("ClientEvent::DownloadGameFilesBegin received"); log::info!("ClientEvent::DownloadGameFilesBegin received");
@ -395,16 +398,16 @@ pub fn run() {
.remove(&id.clone()); .remove(&id.clone());
let games_folder = app_handle let games_dir = app_handle
.state::<LanSpreadState>() .state::<LanSpreadState>()
.inner() .inner()
.games_folder .games_dir
.lock() .lock()
.await .await
.clone(); .clone();
if let Ok(sidecar) = app_handle.shell().sidecar("unrar") { if let Ok(sidecar) = app_handle.shell().sidecar("unrar") {
unpack_game(&id, sidecar, games_folder).await; unpack_game(&id, sidecar, games_dir).await;
log::info!("ClientEvent::UnpackGameFinished received"); log::info!("ClientEvent::UnpackGameFinished received");
if let Err(e) = app_handle.emit("game-unpack-finished", Some(id.clone())) { if let Err(e) = app_handle.emit("game-unpack-finished", Some(id.clone())) {
@ -412,21 +415,6 @@ pub fn run() {
} }
} }
} }
ClientEvent::DownloadGameFilesFailed { id } => {
log::warn!("ClientEvent::DownloadGameFilesFailed received");
if let Err(e) = app_handle.emit("game-download-failed", Some(id.clone())) {
log::error!("Failed to emit game-download-failed event: {e}");
}
app_handle
.state::<LanSpreadState>()
.inner()
.games_in_download
.lock()
.await
.remove(&id.clone());
},
} }
} }
}); });

View File

@ -48,37 +48,13 @@ const App = () => {
} }
}; };
useEffect(() => {
// Listen for game-download-failed events specifically
const setupDownloadFailedListener = async () => {
const unlisten = await listen('game-download-failed', (event) => {
const game_id = event.payload as string;
console.log(`❌ game-download-failed ${game_id} event received`);
setGameItems(prev => prev.map(item => item.id === game_id
? {...item, install_status: InstallStatus.NotInstalled}
: item));
// Convert to string explicitly and verify it's not empty
const pathString = String(gameDir);
if (!pathString) {
console.error('gameDir is empty before invoke!');
return;
}
invoke('update_game_directory', { path: pathString })
.catch(error => console.error('❌ Error updating game directory:', error));
});
return unlisten;
};
setupDownloadFailedListener();
}, [gameDir]);
useEffect(() => { useEffect(() => {
// Listen for game-unpack-finished events specifically // Listen for game-unpack-finished events specifically
const setupUnpackListener = async () => { const setupUnpackListener = async () => {
const unlisten = await listen('game-unpack-finished', (event) => { const unlisten = await listen('game-unpack-finished', (event) => {
const game_id = event.payload as string; const game_id = event.payload as string;
console.log(`🗲 game-unpack-finished ${game_id} event received`); console.log(`🗲 game-unpack-finished ${game_id} event received`);
console.log('Current gameDir in listener:', gameDir); // Add this log
setGameItems(prev => prev.map(item => item.id === game_id setGameItems(prev => prev.map(item => item.id === game_id
? {...item, install_status: InstallStatus.Installed} ? {...item, install_status: InstallStatus.Installed}
: item)); : item));

View File

@ -2,3 +2,5 @@
export RUST_LOG=info,lanspread=debug export RUST_LOG=info,lanspread=debug
exec cargo run -p lanspread-server -- "$@" exec cargo run -p lanspread-server -- "$@"
#RUST_LOG=info exec cargo run --profile release-lto -p lanspread-server

24
win-client.ps1 Normal file
View File

@ -0,0 +1,24 @@
$Env:RUST_LOG = "info,lanspread_client=debug,lanspread_proto=debug"
# Start the process with redirected standard input
$processInfo = New-Object System.Diagnostics.ProcessStartInfo
$processInfo.FileName = "cargo"
$processInfo.Arguments = "run -p lanspread-client -- $args"
$processInfo.UseShellExecute = $false
$processInfo.RedirectStandardInput = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $processInfo
$process.Start() | Out-Null
# Continuously write commands to the standard input of the process
while (!$process.HasExited) {
Start-Sleep -Milliseconds 100
$process.StandardInput.WriteLine("list")
Start-Sleep -Milliseconds 100
$process.StandardInput.WriteLine("get 1")
Start-Sleep -Milliseconds 100
$process.StandardInput.WriteLine("get 25")
}
$process.WaitForExit()