Files
lanspread/crates/lanspread-peer/src/lib.rs
T
ddidderr 9bafd981d7 feat(install): write launcher language marker files
Some games include a language.txt marker in the unpacked local tree, similar
in spirit to account_name.txt. Installs and updates now carry the launcher
language alongside the account name so those game-provided marker files are
rewritten before staged files are promoted into local/.

The Tauri command boundary keeps the UI setting vocabulary as de/en, then maps
it to the file vocabulary expected by games: german or english. Unknown values
continue through the existing DEFAULT_LANGUAGE path, so the marker file falls
back to english just like script launch arguments fall back to en.

The transaction layer deliberately reuses the same first-match traversal helper
for both marker files. The searches stay independent, so games may place
account_name.txt and language.txt in different directories if their archive
layout requires that.

Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- deno task build
- git diff --check

Refs: none
2026-05-21 22:24:59 +02:00

476 lines
15 KiB
Rust

//! Peer-to-peer game distribution system.
//!
//! This crate provides the core P2P networking engine for `LanSpread`, handling:
//! - Peer discovery via mDNS
//! - QUIC-based communication
//! - Game file synchronization with chunked transfers
//! - Consensus validation for file integrity
#![allow(clippy::missing_errors_doc)]
// =============================================================================
// Module declarations
// =============================================================================
mod config;
mod context;
mod download;
mod error;
mod events;
mod handlers;
mod identity;
mod install;
mod library;
mod local_games;
mod migration;
mod network;
mod path_validation;
mod peer;
mod peer_db;
mod remote_peer;
mod services;
mod startup;
mod state_paths;
#[cfg(test)]
mod test_support;
// =============================================================================
// Public re-exports
// =============================================================================
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameFileDescription};
pub use migration::{MigrationReport, migrate_legacy_state};
pub use peer_db::{
MajorityValidationResult,
PeerGameDB,
PeerId,
PeerInfo,
PeerSnapshot,
PeerUpsert,
};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{
context::Ctx,
handlers::{
GameDetailSource,
handle_cancel_download_command,
handle_connect_peer_command,
handle_download_game_files_command,
handle_get_game_command,
handle_get_peer_count_command,
handle_install_game_command,
handle_list_games_command,
handle_remove_downloaded_game_command,
handle_set_game_dir_command,
handle_uninstall_game_command,
load_local_library,
},
state_paths::resolve_state_dir,
};
pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path};
// =============================================================================
// Public API types
// =============================================================================
/// Events sent from the peer system to the UI.
#[derive(Debug, strum::IntoStaticStr)]
pub enum PeerEvent {
/// The local QUIC server is listening and ready to accept peer connections.
LocalPeerReady { peer_id: String, addr: SocketAddr },
/// List of available games from peers.
ListGames(Vec<Game>),
/// File descriptions for a specific game.
GotGameFiles {
id: String,
file_descriptions: Vec<GameFileDescription>,
},
/// Download has started for a game.
DownloadGameFilesBegin { id: String },
/// A file chunk has been downloaded from a peer.
DownloadGameFileChunkFinished {
id: String,
peer_addr: SocketAddr,
relative_path: String,
offset: u64,
length: u64,
},
/// Download progress sampled while game files are being received.
DownloadGameFilesProgress(DownloadProgress),
/// Download has completed successfully.
DownloadGameFilesFinished { id: String },
/// Download has failed.
DownloadGameFilesFailed { id: String },
/// All peers with the game have disconnected during download.
DownloadGameFilesAllPeersGone { id: String },
/// Install or update transaction has started for a game.
InstallGameBegin {
id: String,
operation: InstallOperation,
},
/// Install or update transaction has completed successfully.
InstallGameFinished { id: String },
/// Install or update transaction has failed after rollback.
InstallGameFailed { id: String },
/// Uninstall transaction has started for a game.
UninstallGameBegin { id: String },
/// Uninstall transaction has completed successfully.
UninstallGameFinished { id: String },
/// Uninstall transaction has failed after rollback.
UninstallGameFailed { id: String },
/// Downloaded archive removal has started for an uninstalled game.
RemoveDownloadedGameBegin { id: String },
/// Downloaded archive removal has completed successfully.
RemoveDownloadedGameFinished { id: String },
/// Downloaded archive removal has failed before deleting the game root.
RemoveDownloadedGameFailed { id: String },
/// No peers have the requested game.
NoPeersHaveGame { id: String },
/// A peer has connected.
PeerConnected(SocketAddr),
/// A peer has disconnected.
PeerDisconnected(SocketAddr),
/// A new peer was discovered via mDNS.
PeerDiscovered(SocketAddr),
/// A peer was lost (timed out or disconnected).
PeerLost(SocketAddr),
/// The total peer count has changed.
PeerCountUpdated(usize),
/// The local library contents changed after a scan.
LocalLibraryChanged { games: Vec<Game> },
/// The set of in-progress local operations changed.
ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>,
},
/// A required peer runtime component failed.
RuntimeFailed {
component: PeerRuntimeComponent,
error: String,
},
}
/// Sampled byte progress for one active game download.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct DownloadProgress {
pub id: String,
pub downloaded_bytes: u64,
pub total_bytes: u64,
pub bytes_per_second: u64,
/// Unique peers currently streaming at least one chunk for this download.
pub active_peer_count: usize,
}
/// Long-running peer runtime components reported in failure events.
#[derive(Clone, Copy, Debug, strum::IntoStaticStr)]
pub enum PeerRuntimeComponent {
/// Command/control message loop.
CommandLoop,
/// Inbound QUIC server and its mDNS advertisement.
QuicServer,
/// mDNS peer discovery.
Discovery,
/// Peer liveness monitoring.
Liveness,
/// Local game directory monitor.
LocalMonitor,
}
/// Install-side operation represented in lifecycle events.
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
pub enum InstallOperation {
/// Fresh install into a missing `local/` directory.
Installing,
/// Update that replaces an existing `local/` directory.
Updating,
}
/// In-progress operation snapshot sent when operation state changes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActiveOperation {
pub id: String,
pub operation: ActiveOperationKind,
}
/// Operation kinds visible to UI reconciliation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
pub enum ActiveOperationKind {
/// Downloading or replacing archive files.
Downloading,
/// Extracting into a previously uninstalled game root.
Installing,
/// Replacing an existing `local/` install.
Updating,
/// Removing an existing `local/` install.
Uninstalling,
/// Removing downloaded archive files for an uninstalled game.
RemovingDownload,
}
/// Commands sent to the peer system from the UI.
#[derive(Clone, Debug)]
pub enum PeerCommand {
/// Request a list of all available games.
ListGames,
/// Request file details for a specific game, serving local files when available.
GetGame(String),
/// Request the latest peer-advertised file details for an update.
FetchLatestFromPeers { id: String },
/// Download game files.
DownloadGameFiles {
id: String,
file_descriptions: Vec<GameFileDescription>,
account_name: Option<String>,
language: Option<String>,
},
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
account_name: Option<String>,
language: Option<String>,
},
/// Install already-downloaded archives into `local/`.
InstallGame {
id: String,
account_name: Option<String>,
language: Option<String>,
},
/// Remove only the `local/` install for a game.
UninstallGame { id: String },
/// Remove downloaded archive files for an uninstalled game.
RemoveDownloadedGame { id: String },
/// Cancel an active peer download without emitting a user-facing failure.
CancelDownload { id: String },
/// Set the local game directory.
SetGameDir(PathBuf),
/// Request the current peer count.
GetPeerCount,
/// Connect directly to a peer address without waiting for mDNS discovery.
ConnectPeer(SocketAddr),
}
/// Optional startup settings for non-GUI callers and tests.
#[derive(Clone, Debug, Default)]
pub struct PeerStartOptions {
/// Directory used for peer identity and other state.
pub state_dir: Option<PathBuf>,
}
// =============================================================================
// Public API functions
// =============================================================================
/// Initialize and start the peer system.
///
/// This is the main entry point for the peer system. It starts all background
/// services (server, discovery, ping, local monitor) and returns a handle that
/// owns the command sender plus a shutdown signal callers can use for clean
/// teardown.
///
/// # Arguments
///
/// * `game_dir` - Path to the local game directory
/// * `tx_notify_ui` - Channel for sending events to the UI
/// * `peer_game_db` - Shared peer game database
#[allow(clippy::implicit_hasher)]
pub fn start_peer(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<PeerRuntimeHandle> {
start_peer_with_options(
game_dir,
tx_notify_ui,
peer_game_db,
unpacker,
catalog,
PeerStartOptions::default(),
)
}
/// Initialize and start the peer system with explicit startup settings.
#[allow(clippy::implicit_hasher)]
pub fn start_peer_with_options(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions { state_dir } = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let game_dir = game_dir.into();
log::info!(
"Starting peer system with game directory: {}",
game_dir.display()
);
let peer_id = identity::load_or_create_peer_id(&state_dir)?;
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
Ok(startup::spawn_peer_runtime(
tx_control,
rx_control,
tx_notify_ui,
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
catalog,
))
}
/// Main peer execution loop that handles peer commands and manages the peer system.
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
async fn run_peer(
mut rx_control: UnboundedReceiver<PeerCommand>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
peer_id: String,
game_dir: PathBuf,
state_dir: PathBuf,
unpacker: Arc<dyn Unpacker>,
shutdown: CancellationToken,
task_tracker: TaskTracker,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<()> {
let ctx = Ctx::new(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
shutdown,
task_tracker,
catalog,
);
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
log::error!("Failed to load initial local game database: {err}");
}
startup::spawn_startup_services(&ctx, &tx_notify_ui);
if let Err(err) = handle_peer_commands(&ctx, &tx_notify_ui, &mut rx_control).await {
let error = err.to_string();
log::error!("Peer command loop failed: {error}");
events::send(
&tx_notify_ui,
PeerEvent::RuntimeFailed {
component: PeerRuntimeComponent::CommandLoop,
error,
},
);
ctx.shutdown.cancel();
}
startup::send_goodbye_notifications(&ctx).await;
Ok(())
}
async fn handle_peer_commands(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
rx_control: &mut UnboundedReceiver<PeerCommand>,
) -> eyre::Result<()> {
loop {
let cmd = tokio::select! {
() = ctx.shutdown.cancelled() => return Ok(()),
cmd = rx_control.recv() => cmd,
};
let Some(cmd) = cmd else {
if ctx.shutdown.is_cancelled() {
return Ok(());
}
eyre::bail!("peer command channel closed unexpectedly");
};
match cmd {
PeerCommand::ListGames => {
handle_list_games_command(ctx, tx_notify_ui).await;
}
PeerCommand::GetGame(id) => {
handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LocalOrPeers)
.await;
}
PeerCommand::FetchLatestFromPeers { id } => {
handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LatestPeersOnly)
.await;
}
PeerCommand::DownloadGameFiles {
id,
file_descriptions,
account_name,
language,
} => {
handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
true,
account_name,
language,
)
.await;
}
PeerCommand::DownloadGameFilesWithOptions {
id,
file_descriptions,
install_after_download,
account_name,
language,
} => {
handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
install_after_download,
account_name,
language,
)
.await;
}
PeerCommand::InstallGame {
id,
account_name,
language,
} => {
handle_install_game_command(ctx, tx_notify_ui, id, account_name, language).await;
}
PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::RemoveDownloadedGame { id } => {
handle_remove_downloaded_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::CancelDownload { id } => {
handle_cancel_download_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::SetGameDir(game_dir) => {
handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await;
}
PeerCommand::GetPeerCount => {
handle_get_peer_count_command(ctx, tx_notify_ui).await;
}
PeerCommand::ConnectPeer(addr) => {
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
}
}
}
}