feat(peer): coordinate outbound transfers with local game mutations
Updating or removing a local game rewrites its on-disk files. Peers that were mid-download of that game would keep streaming bytes from files that are being deleted or replaced, handing them a corrupt or stale copy. There was also no authoritative notion of which game version a peer should serve or accept, so a peer could serve whatever happened to be on disk and downloaders could aggregate files from peers running mismatched versions. This introduces a reader-writer coordination scheme between outbound file transfers (readers) and local mutation operations (writers), and gates both serving and downloading on an authoritative game catalog version. Reader-writer coordination: - Track active outbound transfers per game in a shared `OutboundTransfers` map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and registered by a `TransferGuard` in the stream service. The guard is registered *before* the serve-eligibility check to close a TOCTOU window where a writer could miss an in-flight reader. - `stream_file_bytes` now honors a cancellation token at every await point (file read, network send, stream close) via `tokio::select!`, so a transfer aborts promptly instead of hanging on a stalled receiver. - `begin_operation` marks a game active first, then cancels its outbound transfers and waits for the count to reach zero before any Updating/RemovingDownload work touches the filesystem. - Active games are now hidden from library snapshots entirely while an operation is in flight, instead of freezing their last announced state, so peers stop discovering a game that is being mutated. Authoritative version catalog: - Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each game id to its expected version (from the bundled game.db / ETI data). - Serving requires the local `version.ini` to match the catalog version (`local_download_matches_catalog`); peer selection, file aggregation, and majority size validation all filter on the expected version (`peers_with_expected_version`, `aggregated_game_files`, and friends). User-visible changes: - The GUI shows confirmation dialogs before Update and Remove, and surfaces a sharing-status indicator on game cards and the detail modal. - A new `OutboundTransferCountChanged` event lets the UI reflect live outbound transfer activity. Test Plan: - just test - just frontend-test - just clippy
This commit is contained in:
Generated
+1
@@ -2217,6 +2217,7 @@ dependencies = [
|
|||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ impl From<EtiGame> for Game {
|
|||||||
release_year: eti_game.game_release,
|
release_year: eti_game.game_release,
|
||||||
publisher: eti_game.game_publisher,
|
publisher: eti_game.game_publisher,
|
||||||
max_players: eti_game.game_maxplayers,
|
max_players: eti_game.game_maxplayers,
|
||||||
version: eti_game.game_version,
|
version: eti_game.game_version.clone(),
|
||||||
genre: eti_game.genre_de,
|
genre: eti_game.genre_de,
|
||||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||||
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
||||||
downloaded: false,
|
downloaded: false,
|
||||||
installed: false,
|
installed: false,
|
||||||
availability: Availability::LocalOnly,
|
availability: Availability::LocalOnly,
|
||||||
eti_game_version: None,
|
eti_game_version: Some(eti_game.game_version),
|
||||||
local_version: None,
|
local_version: None,
|
||||||
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
|
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub struct Game {
|
|||||||
/// Backend-reported availability state for this game's local or peer summary.
|
/// Backend-reported availability state for this game's local or peer summary.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub availability: Availability,
|
pub availability: Availability,
|
||||||
/// ETI game version from version.ini (YYYYMMDD format) (server)
|
/// Authoritative ETI game version from the bundled game.db (YYYYMMDD format).
|
||||||
pub eti_game_version: Option<String>,
|
pub eti_game_version: Option<String>,
|
||||||
/// Local game version from version.ini (YYYYMMDD format)
|
/// Local game version from version.ini (YYYYMMDD format)
|
||||||
pub local_version: Option<String>,
|
pub local_version: Option<String>,
|
||||||
@@ -198,6 +198,60 @@ impl Default for GameDB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct GameCatalog {
|
||||||
|
expected_versions: HashMap<String, Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameCatalog {
|
||||||
|
#[must_use]
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
expected_versions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_game_db(game_db: &GameDB) -> Self {
|
||||||
|
Self {
|
||||||
|
expected_versions: game_db
|
||||||
|
.games
|
||||||
|
.values()
|
||||||
|
.map(|game| (game.id.clone(), game.eti_game_version.clone()))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_ids(ids: impl IntoIterator<Item = String>) -> Self {
|
||||||
|
Self {
|
||||||
|
expected_versions: ids.into_iter().map(|id| (id, None)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, id: String, expected_version: Option<String>) {
|
||||||
|
self.expected_versions.insert(id, expected_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn contains<S>(&self, id: S) -> bool
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
self.expected_versions.contains_key(id.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn expected_version<S>(&self, id: S) -> Option<&str>
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
self.expected_versions
|
||||||
|
.get(id.as_ref())
|
||||||
|
.and_then(Option::as_deref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct GameFileDescription {
|
pub struct GameFileDescription {
|
||||||
pub game_id: String,
|
pub game_id: String,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::{
|
|||||||
|
|
||||||
use eyre::Context;
|
use eyre::Context;
|
||||||
use lanspread_compat::eti::get_games;
|
use lanspread_compat::eti::get_games;
|
||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||||
use lanspread_peer::{
|
use lanspread_peer::{
|
||||||
ActiveOperation,
|
ActiveOperation,
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
@@ -30,6 +30,7 @@ use lanspread_peer::{
|
|||||||
use lanspread_peer_cli::{
|
use lanspread_peer_cli::{
|
||||||
CliCommand,
|
CliCommand,
|
||||||
CommandEnvelope,
|
CommandEnvelope,
|
||||||
|
DEFAULT_FIXTURE_VERSION,
|
||||||
ExternalUnrarUnpacker,
|
ExternalUnrarUnpacker,
|
||||||
FixtureSeed,
|
FixtureSeed,
|
||||||
FixtureUnpacker,
|
FixtureUnpacker,
|
||||||
@@ -114,7 +115,7 @@ struct DownloadMeasurement {
|
|||||||
struct SharedState {
|
struct SharedState {
|
||||||
state: RwLock<CliState>,
|
state: RwLock<CliState>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
notify: Notify,
|
notify: Notify,
|
||||||
games_dir: PathBuf,
|
games_dir: PathBuf,
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
@@ -146,6 +147,7 @@ async fn main() -> eyre::Result<()> {
|
|||||||
catalog.clone(),
|
catalog.clone(),
|
||||||
PeerStartOptions {
|
PeerStartOptions {
|
||||||
state_dir: Some(args.state_dir.clone()),
|
state_dir: Some(args.state_dir.clone()),
|
||||||
|
active_outbound_transfers: None,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
let sender = handle.sender();
|
let sender = handle.sender();
|
||||||
@@ -303,15 +305,8 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
|
|||||||
|
|
||||||
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||||
let state = shared.state.read().await;
|
let state = shared.state.read().await;
|
||||||
let catalog = shared.catalog.read().await.clone();
|
let catalog = shared.catalog.read().await;
|
||||||
let remote = shared
|
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
|
||||||
.peer_game_db
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get_all_games()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|game| catalog.contains(&game.id))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"local": state.local_games.clone(),
|
"local": state.local_games.clone(),
|
||||||
"remote": remote,
|
"remote": remote,
|
||||||
@@ -434,6 +429,7 @@ async fn event_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) {
|
async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) {
|
||||||
match event {
|
match event {
|
||||||
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
||||||
@@ -458,6 +454,7 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
|||||||
state.local_games.clone_from(&games);
|
state.local_games.clone_from(&games);
|
||||||
("local-library-changed", json!({ "games": games }))
|
("local-library-changed", json!({ "games": games }))
|
||||||
}
|
}
|
||||||
|
PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})),
|
||||||
PeerEvent::ActiveOperationsChanged { active_operations } => {
|
PeerEvent::ActiveOperationsChanged { active_operations } => {
|
||||||
let mut state = shared.state.write().await;
|
let mut state = shared.state.write().await;
|
||||||
state.active_operations.clone_from(&active_operations);
|
state.active_operations.clone_from(&active_operations);
|
||||||
@@ -668,18 +665,27 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
|
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
|
||||||
let mut catalog = HashSet::new();
|
let mut catalog = GameCatalog::empty();
|
||||||
if let Some(path) = catalog_db
|
if let Some(path) = catalog_db
|
||||||
&& path.exists()
|
&& path.exists()
|
||||||
{
|
{
|
||||||
match get_games(path).await {
|
match get_games(path).await {
|
||||||
Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)),
|
Ok(games) => {
|
||||||
|
for game in games {
|
||||||
|
catalog.insert(game.game_id, Some(game.game_version));
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
|
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone()));
|
for seed in fixtures {
|
||||||
|
catalog.insert(
|
||||||
|
seed.game_id.clone(),
|
||||||
|
Some(DEFAULT_FIXTURE_VERSION.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
catalog
|
catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
//! Shared context types for the peer system.
|
//! Shared context types for the peer system.
|
||||||
|
|
||||||
use std::{
|
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
net::SocketAddr,
|
|
||||||
path::PathBuf,
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use lanspread_db::db::GameDB;
|
use lanspread_db::db::{GameCatalog, GameDB};
|
||||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
|
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
|
||||||
|
|
||||||
|
/// Thread-safe map of active outbound file transfers grouped by game ID.
|
||||||
|
pub type OutboundTransfers = Arc<RwLock<HashMap<String, Vec<(u64, CancellationToken)>>>>;
|
||||||
|
|
||||||
|
|
||||||
/// Mutating filesystem operation currently in flight for a game root.
|
/// Mutating filesystem operation currently in flight for a game root.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum OperationKind {
|
pub enum OperationKind {
|
||||||
@@ -40,10 +39,11 @@ pub struct Ctx {
|
|||||||
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||||
pub active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
pub active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||||
pub unpacker: Arc<dyn Unpacker>,
|
pub unpacker: Arc<dyn Unpacker>,
|
||||||
pub catalog: Arc<RwLock<HashSet<String>>>,
|
pub catalog: Arc<RwLock<GameCatalog>>,
|
||||||
pub peer_id: Arc<String>,
|
pub peer_id: Arc<String>,
|
||||||
pub shutdown: CancellationToken,
|
pub shutdown: CancellationToken,
|
||||||
pub task_tracker: TaskTracker,
|
pub task_tracker: TaskTracker,
|
||||||
|
pub active_outbound_transfers: OutboundTransfers,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context for peer connection handling.
|
/// Context for peer connection handling.
|
||||||
@@ -55,11 +55,12 @@ pub struct PeerCtx {
|
|||||||
pub local_peer_addr: Arc<RwLock<Option<SocketAddr>>>,
|
pub local_peer_addr: Arc<RwLock<Option<SocketAddr>>>,
|
||||||
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||||
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
|
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
pub catalog: Arc<RwLock<HashSet<String>>>,
|
pub catalog: Arc<RwLock<GameCatalog>>,
|
||||||
pub peer_id: Arc<String>,
|
pub peer_id: Arc<String>,
|
||||||
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
|
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
|
||||||
pub shutdown: CancellationToken,
|
pub shutdown: CancellationToken,
|
||||||
pub task_tracker: TaskTracker,
|
pub task_tracker: TaskTracker,
|
||||||
|
pub active_outbound_transfers: OutboundTransfers,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for PeerCtx {
|
impl std::fmt::Debug for PeerCtx {
|
||||||
@@ -84,7 +85,8 @@ impl Ctx {
|
|||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
task_tracker: TaskTracker,
|
task_tracker: TaskTracker,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
|
active_outbound_transfers: OutboundTransfers,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
game_dir: Arc::new(RwLock::new(game_dir)),
|
game_dir: Arc::new(RwLock::new(game_dir)),
|
||||||
@@ -100,6 +102,7 @@ impl Ctx {
|
|||||||
peer_id: Arc::new(peer_id),
|
peer_id: Arc::new(peer_id),
|
||||||
shutdown,
|
shutdown,
|
||||||
task_tracker,
|
task_tracker,
|
||||||
|
active_outbound_transfers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +123,7 @@ impl Ctx {
|
|||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
shutdown: self.shutdown.clone(),
|
shutdown: self.shutdown.clone(),
|
||||||
task_tracker: self.task_tracker.clone(),
|
task_tracker: self.task_tracker.clone(),
|
||||||
|
active_outbound_transfers: self.active_outbound_transfers.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -65,9 +66,13 @@ fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
|
|||||||
|
|
||||||
pub async fn emit_peer_game_list(
|
pub async fn emit_peer_game_list(
|
||||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||||
|
catalog: &Arc<RwLock<GameCatalog>>,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
) {
|
) {
|
||||||
let games = { peer_game_db.read().await.get_all_games() };
|
let games = {
|
||||||
|
let catalog = catalog.read().await;
|
||||||
|
peer_game_db.read().await.get_catalog_games(&catalog)
|
||||||
|
};
|
||||||
send(tx_notify_ui, PeerEvent::ListGames(games));
|
send(tx_notify_ui, PeerEvent::ListGames(games));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use crate::{
|
|||||||
game_from_summary,
|
game_from_summary,
|
||||||
get_game_file_descriptions,
|
get_game_file_descriptions,
|
||||||
local_dir_is_directory,
|
local_dir_is_directory,
|
||||||
local_download_available,
|
local_download_matches_catalog,
|
||||||
rescan_local_game,
|
rescan_local_game,
|
||||||
scan_local_library,
|
scan_local_library,
|
||||||
version_ini_is_regular_file,
|
version_ini_is_regular_file,
|
||||||
@@ -41,7 +41,7 @@ use crate::{
|
|||||||
/// Handles the `ListGames` command.
|
/// Handles the `ListGames` command.
|
||||||
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||||
log::info!("ListGames command received");
|
log::info!("ListGames command received");
|
||||||
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
|
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to serve a game from local files.
|
/// Tries to serve a game from local files.
|
||||||
@@ -54,7 +54,7 @@ async fn try_serve_local_game(
|
|||||||
|
|
||||||
let active_operations = ctx.active_operations.read().await;
|
let active_operations = ctx.active_operations.read().await;
|
||||||
let catalog = ctx.catalog.read().await;
|
let catalog = ctx.catalog.read().await;
|
||||||
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
|
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
drop(active_operations);
|
drop(active_operations);
|
||||||
@@ -90,9 +90,10 @@ pub(crate) async fn handle_get_game_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Requesting game from peers: {id}");
|
log::info!("Requesting game from peers: {id}");
|
||||||
|
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||||
let peers = {
|
let peers = {
|
||||||
let peer_game_db = ctx.peer_game_db.read().await;
|
let peer_game_db = ctx.peer_game_db.read().await;
|
||||||
source.select_peers(&peer_game_db, &id)
|
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
|
||||||
};
|
};
|
||||||
if peers.is_empty() {
|
if peers.is_empty() {
|
||||||
log::warn!("No peers have game {id}");
|
log::warn!("No peers have game {id}");
|
||||||
@@ -107,6 +108,7 @@ pub(crate) async fn handle_get_game_command(
|
|||||||
ctx.task_tracker.spawn(fetch_game_details_from_peers(
|
ctx.task_tracker.spawn(fetch_game_details_from_peers(
|
||||||
peers,
|
peers,
|
||||||
id,
|
id,
|
||||||
|
expected_version,
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
|peer_addr, game_id, peer_game_db| async move {
|
|peer_addr, game_id, peer_game_db| async move {
|
||||||
@@ -126,10 +128,16 @@ impl GameDetailSource {
|
|||||||
matches!(self, Self::LocalOrPeers)
|
matches!(self, Self::LocalOrPeers)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_peers(self, peer_game_db: &PeerGameDB, id: &str) -> Vec<SocketAddr> {
|
fn select_peers(
|
||||||
|
self,
|
||||||
|
peer_game_db: &PeerGameDB,
|
||||||
|
id: &str,
|
||||||
|
expected_version: Option<&str>,
|
||||||
|
) -> Vec<SocketAddr> {
|
||||||
match self {
|
match self {
|
||||||
Self::LocalOrPeers => peer_game_db.peers_with_game(id),
|
Self::LocalOrPeers | Self::LatestPeersOnly => {
|
||||||
Self::LatestPeersOnly => peer_game_db.peers_with_latest_version(id),
|
peer_game_db.peers_with_expected_version(id, expected_version)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +162,7 @@ async fn request_game_details_and_update(
|
|||||||
async fn fetch_game_details_from_peers<F, Fut>(
|
async fn fetch_game_details_from_peers<F, Fut>(
|
||||||
peers: Vec<SocketAddr>,
|
peers: Vec<SocketAddr>,
|
||||||
id: String,
|
id: String,
|
||||||
|
expected_version: Option<String>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||||
mut fetch_details: F,
|
mut fetch_details: F,
|
||||||
@@ -175,7 +184,12 @@ async fn fetch_game_details_from_peers<F, Fut>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fetched_any {
|
if fetched_any {
|
||||||
let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };
|
let aggregated_files = {
|
||||||
|
peer_game_db
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.aggregated_game_files(&id, expected_version.as_deref())
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
|
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -210,6 +224,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let games_folder = { ctx.game_dir.read().await.clone() };
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||||
|
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||||
|
|
||||||
// Use majority validation to get trusted file descriptions and peer whitelist
|
// Use majority validation to get trusted file descriptions and peer whitelist
|
||||||
let (validated_descriptions, peer_whitelist, file_peer_map) = {
|
let (validated_descriptions, peer_whitelist, file_peer_map) = {
|
||||||
@@ -217,7 +232,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
.peer_game_db
|
.peer_game_db
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.validate_file_sizes_majority(&id)
|
.validate_file_sizes_majority(&id, expected_version.as_deref())
|
||||||
{
|
{
|
||||||
Ok((files, peers, file_peer_map)) => {
|
Ok((files, peers, file_peer_map)) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -260,7 +275,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
let local_dl_available = {
|
let local_dl_available = {
|
||||||
let active_operations = ctx.active_operations.read().await;
|
let active_operations = ctx.active_operations.read().await;
|
||||||
let catalog = ctx.catalog.read().await;
|
let catalog = ctx.catalog.read().await;
|
||||||
local_download_available(&games_folder, &id, &active_operations, &catalog).await
|
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await
|
||||||
};
|
};
|
||||||
|
|
||||||
if peer_whitelist.is_empty() {
|
if peer_whitelist.is_empty() {
|
||||||
@@ -732,11 +747,41 @@ async fn begin_operation(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if started {
|
if !started {
|
||||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
started
|
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||||
|
|
||||||
|
if operation == OperationKind::Updating || operation == OperationKind::RemovingDownload {
|
||||||
|
// Cancel all active outbound transfers for this game
|
||||||
|
let mut tokens_to_cancel = Vec::new();
|
||||||
|
{
|
||||||
|
let active = ctx.active_outbound_transfers.read().await;
|
||||||
|
if let Some(transfers) = active.get(id) {
|
||||||
|
for (_, token) in transfers {
|
||||||
|
tokens_to_cancel.push(token.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for token in tokens_to_cancel {
|
||||||
|
token.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until active outbound transfers drop to 0
|
||||||
|
loop {
|
||||||
|
let count = {
|
||||||
|
let active = ctx.active_outbound_transfers.read().await;
|
||||||
|
active.get(id).map_or(0, Vec::len)
|
||||||
|
};
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn transition_download_to_install(
|
async fn transition_download_to_install(
|
||||||
@@ -818,6 +863,14 @@ async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
|
|||||||
ctx.catalog.read().await.contains(id)
|
ctx.catalog.read().await.contains(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
|
||||||
|
ctx.catalog
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.expected_version(id)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles the `SetGameDir` command.
|
/// Handles the `SetGameDir` command.
|
||||||
pub async fn handle_set_game_dir_command(
|
pub async fn handle_set_game_dir_command(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
@@ -1008,14 +1061,9 @@ async fn update_and_announce_games_with_policy(
|
|||||||
active_operation_ids.remove(id);
|
active_operation_ids.remove(id);
|
||||||
}
|
}
|
||||||
if !active_operation_ids.is_empty() {
|
if !active_operation_ids.is_empty() {
|
||||||
let previous = ctx.local_library.read().await.games.clone();
|
|
||||||
for id in &active_operation_ids {
|
for id in &active_operation_ids {
|
||||||
if let Some(summary) = previous.get(id.as_str()) {
|
|
||||||
summaries.insert(id.clone(), summary.clone());
|
|
||||||
} else {
|
|
||||||
summaries.remove(id);
|
summaries.remove(id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,13 +1116,14 @@ async fn update_and_announce_games_with_policy(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::HashMap,
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use lanspread_proto::{Availability, GameSummary};
|
use lanspread_proto::{Availability, GameSummary};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
@@ -1115,7 +1164,8 @@ mod tests {
|
|||||||
Arc::new(FakeUnpacker),
|
Arc::new(FakeUnpacker),
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
|
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
|
||||||
|
Arc::new(RwLock::new(HashMap::new())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,7 +1270,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_source_selects_latest_ready_peer_manifest() {
|
fn update_source_selects_expected_ready_peer_manifest() {
|
||||||
let old_addr = addr(12_000);
|
let old_addr = addr(12_000);
|
||||||
let new_addr = addr(12_001);
|
let new_addr = addr(12_001);
|
||||||
let local_only_addr = addr(12_002);
|
let local_only_addr = addr(12_002);
|
||||||
@@ -1242,13 +1292,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game"),
|
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
|
||||||
vec![new_addr]
|
vec![new_addr]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_fetch_emits_fresh_manifest_from_latest_peer() {
|
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
|
||||||
let old_addr = addr(12_010);
|
let old_addr = addr(12_010);
|
||||||
let new_addr = addr(12_011);
|
let new_addr = addr(12_011);
|
||||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||||
@@ -1267,12 +1317,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let peers = {
|
let peers = {
|
||||||
let db = peer_game_db.read().await;
|
let db = peer_game_db.read().await;
|
||||||
GameDetailSource::LatestPeersOnly.select_peers(&db, "game")
|
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
|
||||||
};
|
};
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
fetch_game_details_from_peers(peers, "game".to_string(), peer_game_db.clone(), tx, {
|
fetch_game_details_from_peers(
|
||||||
|
peers,
|
||||||
|
"game".to_string(),
|
||||||
|
Some("20250101".to_string()),
|
||||||
|
peer_game_db.clone(),
|
||||||
|
tx,
|
||||||
|
{
|
||||||
let fetched_peers = fetched_peers.clone();
|
let fetched_peers = fetched_peers.clone();
|
||||||
move |peer_addr, game_id, peer_game_db| {
|
move |peer_addr, game_id, peer_game_db| {
|
||||||
let fetched_peers = fetched_peers.clone();
|
let fetched_peers = fetched_peers.clone();
|
||||||
@@ -1293,7 +1349,8 @@ mod tests {
|
|||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1314,7 +1371,7 @@ mod tests {
|
|||||||
file_descriptions
|
file_descriptions
|
||||||
.iter()
|
.iter()
|
||||||
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
|
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
|
||||||
"latest peer manifest should be emitted to the download path"
|
"expected-version peer manifest should be emitted to the download path"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,6 +1386,7 @@ mod tests {
|
|||||||
fetch_game_details_from_peers(
|
fetch_game_details_from_peers(
|
||||||
vec![first_addr, second_addr],
|
vec![first_addr, second_addr],
|
||||||
"game".to_string(),
|
"game".to_string(),
|
||||||
|
Some("20250101".to_string()),
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
tx.clone(),
|
tx.clone(),
|
||||||
{
|
{
|
||||||
@@ -1362,7 +1420,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_request_skips_local_manifest_even_when_download_exists() {
|
async fn update_request_skips_local_manifest_even_when_download_exists() {
|
||||||
let temp = TempDir::new("lanspread-handler-latest-peer");
|
let temp = TempDir::new("lanspread-handler-expected-peer");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("version.ini"), b"20240101");
|
write_file(&root.join("version.ini"), b"20240101");
|
||||||
write_file(&root.join("game.eti"), b"old archive");
|
write_file(&root.join("game.eti"), b"old archive");
|
||||||
@@ -1385,23 +1443,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_library_scan_freezes_active_game_state() {
|
async fn local_library_scan_hides_active_game_state() {
|
||||||
let temp = TempDir::new("lanspread-handler-active-freeze");
|
let temp = TempDir::new("lanspread-handler-active-hide");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("game.eti"), b"archive");
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
|
|
||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
|
|
||||||
|
// 1. Initial scan: the game is ready and announced
|
||||||
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
|
.await
|
||||||
|
.expect("scan should succeed");
|
||||||
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
|
|
||||||
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||||
|
panic!("expected LocalLibraryChanged");
|
||||||
|
};
|
||||||
|
assert_eq!(games.len(), 1);
|
||||||
|
assert_eq!(games[0].id, "game");
|
||||||
|
|
||||||
|
// 2. Set the game as active/in-progress and scan again
|
||||||
ctx.active_operations
|
ctx.active_operations
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.insert("game".to_string(), OperationKind::Installing);
|
.insert("game".to_string(), OperationKind::Installing);
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
|
||||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("scan should succeed");
|
.expect("scan should succeed");
|
||||||
|
|
||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
|
|
||||||
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||||
@@ -1409,7 +1481,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
games.is_empty(),
|
games.is_empty(),
|
||||||
"active game should keep its previous announced state"
|
"active game should be hidden/unannounced during operations"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,12 @@ mod test_support;
|
|||||||
// Public re-exports
|
// Public re-exports
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
|
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
||||||
pub use error::PeerError;
|
pub use error::PeerError;
|
||||||
pub use install::{UnpackFuture, Unpacker};
|
pub use install::{UnpackFuture, Unpacker};
|
||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||||
pub use migration::{MigrationReport, migrate_legacy_state};
|
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||||
pub use peer_db::{
|
pub use peer_db::{
|
||||||
MajorityValidationResult,
|
MajorityValidationResult,
|
||||||
@@ -153,6 +153,8 @@ pub enum PeerEvent {
|
|||||||
PeerCountUpdated(usize),
|
PeerCountUpdated(usize),
|
||||||
/// The local library contents changed after a scan.
|
/// The local library contents changed after a scan.
|
||||||
LocalLibraryChanged { games: Vec<Game> },
|
LocalLibraryChanged { games: Vec<Game> },
|
||||||
|
/// The number of active outbound transfers changed.
|
||||||
|
OutboundTransferCountChanged,
|
||||||
/// The set of in-progress local operations changed.
|
/// The set of in-progress local operations changed.
|
||||||
ActiveOperationsChanged {
|
ActiveOperationsChanged {
|
||||||
active_operations: Vec<ActiveOperation>,
|
active_operations: Vec<ActiveOperation>,
|
||||||
@@ -262,6 +264,7 @@ pub enum PeerCommand {
|
|||||||
pub struct PeerStartOptions {
|
pub struct PeerStartOptions {
|
||||||
/// Directory used for peer identity and other state.
|
/// Directory used for peer identity and other state.
|
||||||
pub state_dir: Option<PathBuf>,
|
pub state_dir: Option<PathBuf>,
|
||||||
|
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -286,7 +289,7 @@ pub fn start_peer(
|
|||||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
) -> eyre::Result<PeerRuntimeHandle> {
|
) -> eyre::Result<PeerRuntimeHandle> {
|
||||||
start_peer_with_options(
|
start_peer_with_options(
|
||||||
game_dir,
|
game_dir,
|
||||||
@@ -305,12 +308,17 @@ pub fn start_peer_with_options(
|
|||||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
options: PeerStartOptions,
|
options: PeerStartOptions,
|
||||||
) -> eyre::Result<PeerRuntimeHandle> {
|
) -> eyre::Result<PeerRuntimeHandle> {
|
||||||
let PeerStartOptions { state_dir } = options;
|
let PeerStartOptions {
|
||||||
|
state_dir,
|
||||||
|
active_outbound_transfers,
|
||||||
|
} = options;
|
||||||
let state_dir = resolve_state_dir(state_dir.as_deref());
|
let state_dir = resolve_state_dir(state_dir.as_deref());
|
||||||
let game_dir = game_dir.into();
|
let game_dir = game_dir.into();
|
||||||
|
let active_outbound_transfers = active_outbound_transfers
|
||||||
|
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
|
||||||
log::info!(
|
log::info!(
|
||||||
"Starting peer system with game directory: {}",
|
"Starting peer system with game directory: {}",
|
||||||
game_dir.display()
|
game_dir.display()
|
||||||
@@ -329,6 +337,7 @@ pub fn start_peer_with_options(
|
|||||||
state_dir,
|
state_dir,
|
||||||
unpacker,
|
unpacker,
|
||||||
catalog,
|
catalog,
|
||||||
|
active_outbound_transfers,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +353,8 @@ async fn run_peer(
|
|||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
task_tracker: TaskTracker,
|
task_tracker: TaskTracker,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
|
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let ctx = Ctx::new(
|
let ctx = Ctx::new(
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
@@ -355,6 +365,7 @@ async fn run_peer(
|
|||||||
shutdown,
|
shutdown,
|
||||||
task_tracker,
|
task_tracker,
|
||||||
catalog,
|
catalog,
|
||||||
|
active_outbound_transfers,
|
||||||
);
|
);
|
||||||
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
|
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
|
||||||
log::error!("Failed to load initial local game database: {err}");
|
log::error!("Failed to load initial local game database: {err}");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::{
|
|||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
|
||||||
use lanspread_proto::{Availability, GameSummary};
|
use lanspread_proto::{Availability, GameSummary};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||||
@@ -51,7 +51,7 @@ pub async fn local_download_available(
|
|||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
active_operations: &HashMap<String, OperationKind>,
|
active_operations: &HashMap<String, OperationKind>,
|
||||||
catalog: &HashSet<String>,
|
catalog: &GameCatalog,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !catalog.contains(game_id) {
|
if !catalog.contains(game_id) {
|
||||||
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
|
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
|
||||||
@@ -67,6 +67,40 @@ pub async fn local_download_available(
|
|||||||
version_ini_is_regular_file(game_path.as_path()).await
|
version_ini_is_regular_file(game_path.as_path()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if a local game may be served to peers under the authoritative catalog version.
|
||||||
|
pub async fn local_download_matches_catalog(
|
||||||
|
game_dir: &Path,
|
||||||
|
game_id: &str,
|
||||||
|
active_operations: &HashMap<String, OperationKind>,
|
||||||
|
catalog: &GameCatalog,
|
||||||
|
) -> bool {
|
||||||
|
if !local_download_available(game_dir, game_id, active_operations, catalog).await {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(expected_version) = catalog.expected_version(game_id) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let game_path = game_dir.join(game_id);
|
||||||
|
match lanspread_db::db::read_version_from_ini(&game_path) {
|
||||||
|
Ok(Some(local_version)) if local_version == expected_version => true,
|
||||||
|
Ok(Some(local_version)) => {
|
||||||
|
log::debug!(
|
||||||
|
"Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(None) => false,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Local library index and scanning
|
// Local library index and scanning
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -468,7 +502,7 @@ struct IndexUpdate {
|
|||||||
async fn update_index_for_game(
|
async fn update_index_for_game(
|
||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
catalog: &HashSet<String>,
|
catalog: &GameCatalog,
|
||||||
index: &mut LibraryIndex,
|
index: &mut LibraryIndex,
|
||||||
) -> eyre::Result<IndexUpdate> {
|
) -> eyre::Result<IndexUpdate> {
|
||||||
if !catalog.contains(game_id) {
|
if !catalog.contains(game_id) {
|
||||||
@@ -557,7 +591,7 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
|
|||||||
pub async fn scan_local_library(
|
pub async fn scan_local_library(
|
||||||
game_dir: impl AsRef<Path>,
|
game_dir: impl AsRef<Path>,
|
||||||
state_dir: impl AsRef<Path>,
|
state_dir: impl AsRef<Path>,
|
||||||
catalog: &HashSet<String>,
|
catalog: &GameCatalog,
|
||||||
) -> eyre::Result<LocalLibraryScan> {
|
) -> eyre::Result<LocalLibraryScan> {
|
||||||
let game_path = game_dir.as_ref();
|
let game_path = game_dir.as_ref();
|
||||||
let state_path = state_dir.as_ref();
|
let state_path = state_dir.as_ref();
|
||||||
@@ -645,7 +679,7 @@ pub async fn scan_local_library(
|
|||||||
pub async fn rescan_local_game(
|
pub async fn rescan_local_game(
|
||||||
game_dir: impl AsRef<Path>,
|
game_dir: impl AsRef<Path>,
|
||||||
state_dir: impl AsRef<Path>,
|
state_dir: impl AsRef<Path>,
|
||||||
catalog: &HashSet<String>,
|
catalog: &GameCatalog,
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
) -> eyre::Result<LocalLibraryScan> {
|
) -> eyre::Result<LocalLibraryScan> {
|
||||||
let game_path = game_dir.as_ref();
|
let game_path = game_dir.as_ref();
|
||||||
@@ -682,10 +716,7 @@ pub async fn get_game_file_descriptions(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{collections::HashMap, path::Path};
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
path::Path,
|
|
||||||
};
|
|
||||||
|
|
||||||
use lanspread_proto::Availability;
|
use lanspread_proto::Availability;
|
||||||
|
|
||||||
@@ -776,7 +807,7 @@ mod tests {
|
|||||||
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
||||||
let temp = TempDir::new("lanspread-local-games");
|
let temp = TempDir::new("lanspread-local-games");
|
||||||
let state = TempDir::new("lanspread-local-games-state");
|
let state = TempDir::new("lanspread-local-games-state");
|
||||||
let catalog = HashSet::from([
|
let catalog = GameCatalog::from_ids([
|
||||||
"ready".to_string(),
|
"ready".to_string(),
|
||||||
"local-only".to_string(),
|
"local-only".to_string(),
|
||||||
"eti-only".to_string(),
|
"eti-only".to_string(),
|
||||||
@@ -830,7 +861,7 @@ mod tests {
|
|||||||
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
||||||
let temp = TempDir::new("lanspread-local-games");
|
let temp = TempDir::new("lanspread-local-games");
|
||||||
let state = TempDir::new("lanspread-local-games-state");
|
let state = TempDir::new("lanspread-local-games-state");
|
||||||
let catalog = HashSet::from(["game".to_string()]);
|
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||||
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
||||||
.expect("local install dir should be created");
|
.expect("local install dir should be created");
|
||||||
|
|
||||||
@@ -864,7 +895,7 @@ mod tests {
|
|||||||
async fn concurrent_rescans_preserve_both_index_updates() {
|
async fn concurrent_rescans_preserve_both_index_updates() {
|
||||||
let temp = TempDir::new("lanspread-local-games-concurrent");
|
let temp = TempDir::new("lanspread-local-games-concurrent");
|
||||||
let state = TempDir::new("lanspread-local-games-state");
|
let state = TempDir::new("lanspread-local-games-state");
|
||||||
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]);
|
||||||
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
||||||
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
||||||
|
|
||||||
@@ -909,7 +940,7 @@ mod tests {
|
|||||||
let game_root = temp.path().join("game");
|
let game_root = temp.path().join("game");
|
||||||
write_file(&game_root.join("version.ini"), b"20250101");
|
write_file(&game_root.join("version.ini"), b"20250101");
|
||||||
|
|
||||||
let catalog = HashSet::from(["game".to_string()]);
|
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
||||||
let no_operations = HashMap::new();
|
let no_operations = HashMap::new();
|
||||||
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
|
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
|
||||||
|
|
||||||
@@ -917,8 +948,29 @@ mod tests {
|
|||||||
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
|
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
|
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
|
||||||
|
.await
|
||||||
);
|
);
|
||||||
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
|
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_download_matches_catalog_requires_expected_version() {
|
||||||
|
let temp = TempDir::new("lanspread-local-games");
|
||||||
|
let game_root = temp.path().join("game");
|
||||||
|
write_file(&game_root.join("version.ini"), b"20260101");
|
||||||
|
|
||||||
|
let mut catalog = GameCatalog::empty();
|
||||||
|
catalog.insert("game".to_string(), Some("20250101".to_string()));
|
||||||
|
let no_operations = HashMap::new();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
||||||
|
);
|
||||||
|
|
||||||
|
catalog.insert("game".to_string(), Some("20260101".to_string()));
|
||||||
|
assert!(
|
||||||
|
local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ use tokio::{
|
|||||||
|
|
||||||
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
|
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
async fn stream_file_bytes(
|
async fn stream_file_bytes(
|
||||||
tx: &mut SendStream,
|
tx: &mut SendStream,
|
||||||
base_dir: &Path,
|
base_dir: &Path,
|
||||||
relative_path: &str,
|
relative_path: &str,
|
||||||
offset: u64,
|
offset: u64,
|
||||||
length: Option<u64>,
|
length: Option<u64>,
|
||||||
|
cancel_token: tokio_util::sync::CancellationToken,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||||
|
|
||||||
@@ -45,13 +47,32 @@ async fn stream_file_bytes(
|
|||||||
let mut buf = vec![0u8; FILE_TRANSFER_BUFFER_SIZE];
|
let mut buf = vec![0u8; FILE_TRANSFER_BUFFER_SIZE];
|
||||||
|
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
log::info!(
|
||||||
|
"{remote_addr} transfer cancelled for {}",
|
||||||
|
validated_path.display()
|
||||||
|
);
|
||||||
|
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||||
|
}
|
||||||
|
|
||||||
let read_len = std::cmp::min(remaining, buf.len() as u64);
|
let read_len = std::cmp::min(remaining, buf.len() as u64);
|
||||||
let read_len: usize = read_len.try_into().unwrap_or(usize::MAX);
|
let read_len: usize = read_len.try_into().unwrap_or(usize::MAX);
|
||||||
if read_len == 0 {
|
if read_len == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes_read = file.read(&mut buf[..read_len]).await?;
|
let bytes_read = tokio::select! {
|
||||||
|
() = cancel_token.cancelled() => {
|
||||||
|
log::info!(
|
||||||
|
"{remote_addr} transfer cancelled for {}",
|
||||||
|
validated_path.display()
|
||||||
|
);
|
||||||
|
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||||
|
}
|
||||||
|
res = file.read(&mut buf[..read_len]) => {
|
||||||
|
res?
|
||||||
|
}
|
||||||
|
};
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
if !expect_exact {
|
if !expect_exact {
|
||||||
transfer_complete = true;
|
transfer_complete = true;
|
||||||
@@ -59,7 +80,18 @@ async fn stream_file_bytes(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.send(Bytes::copy_from_slice(&buf[..bytes_read])).await?;
|
tokio::select! {
|
||||||
|
() = cancel_token.cancelled() => {
|
||||||
|
log::info!(
|
||||||
|
"{remote_addr} transfer cancelled for {}",
|
||||||
|
validated_path.display()
|
||||||
|
);
|
||||||
|
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||||
|
}
|
||||||
|
res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => {
|
||||||
|
res?;
|
||||||
|
}
|
||||||
|
}
|
||||||
remaining = remaining.saturating_sub(bytes_read as u64);
|
remaining = remaining.saturating_sub(bytes_read as u64);
|
||||||
total_bytes += bytes_read as u64;
|
total_bytes += bytes_read as u64;
|
||||||
|
|
||||||
@@ -97,13 +129,21 @@ async fn stream_file_bytes(
|
|||||||
validated_path.display()
|
validated_path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
match tx.close().await {
|
tokio::select! {
|
||||||
|
() = cancel_token.cancelled() => {
|
||||||
|
log::info!("{remote_addr} transfer cancelled while closing stream");
|
||||||
|
return Err(eyre::eyre!("File transfer cancelled by user"));
|
||||||
|
}
|
||||||
|
res = tx.close() => {
|
||||||
|
match res {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
|
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
|
||||||
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
|
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +161,18 @@ pub async fn send_game_file_data(
|
|||||||
game_file_desc: &GameFileDescription,
|
game_file_desc: &GameFileDescription,
|
||||||
tx: &mut SendStream,
|
tx: &mut SendStream,
|
||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
|
cancel_token: tokio_util::sync::CancellationToken,
|
||||||
) {
|
) {
|
||||||
if let Err(e) = stream_file_bytes(tx, game_dir, &game_file_desc.relative_path, 0, None).await {
|
if let Err(e) = stream_file_bytes(
|
||||||
|
tx,
|
||||||
|
game_dir,
|
||||||
|
&game_file_desc.relative_path,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
cancel_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||||
log::error!(
|
log::error!(
|
||||||
"{remote_addr} failed to stream file {}: {e}",
|
"{remote_addr} failed to stream file {}: {e}",
|
||||||
@@ -138,8 +188,18 @@ pub async fn send_game_file_chunk(
|
|||||||
length: u64,
|
length: u64,
|
||||||
tx: &mut SendStream,
|
tx: &mut SendStream,
|
||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
|
cancel_token: tokio_util::sync::CancellationToken,
|
||||||
) {
|
) {
|
||||||
if let Err(e) = stream_file_bytes(tx, game_dir, relative_path, offset, Some(length)).await {
|
if let Err(e) = stream_file_bytes(
|
||||||
|
tx,
|
||||||
|
game_dir,
|
||||||
|
relative_path,
|
||||||
|
offset,
|
||||||
|
Some(length),
|
||||||
|
cancel_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
let remote_addr = maybe_addr!(tx.connection().remote_addr());
|
||||||
log::error!(
|
log::error!(
|
||||||
"{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}"
|
"{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use lanspread_db::db::{Availability, Game, GameFileDescription};
|
use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription};
|
||||||
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
|
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
|
||||||
|
|
||||||
use crate::library::compute_library_digest;
|
use crate::library::compute_library_digest;
|
||||||
@@ -357,6 +357,54 @@ impl PeerGameDB {
|
|||||||
games
|
games
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns catalog games aggregated from peers that advertise the expected catalog version.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_catalog_games(&self, catalog: &GameCatalog) -> Vec<Game> {
|
||||||
|
let mut aggregated: HashMap<String, Game> = HashMap::new();
|
||||||
|
let mut peer_counts: HashMap<String, u32> = HashMap::new();
|
||||||
|
|
||||||
|
for peer in self.peers.values() {
|
||||||
|
for game in peer.games.values().filter(|game| {
|
||||||
|
catalog.contains(&game.id)
|
||||||
|
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
|
||||||
|
}) {
|
||||||
|
*peer_counts.entry(game.id.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for peer in self.peers.values() {
|
||||||
|
for game in peer.games.values().filter(|game| {
|
||||||
|
catalog.contains(&game.id)
|
||||||
|
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
|
||||||
|
}) {
|
||||||
|
aggregated
|
||||||
|
.entry(game.id.clone())
|
||||||
|
.and_modify(|existing| {
|
||||||
|
existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
|
||||||
|
if game.size > existing.size {
|
||||||
|
existing.size = game.size;
|
||||||
|
}
|
||||||
|
existing.set_downloaded(true);
|
||||||
|
if game.installed {
|
||||||
|
existing.installed = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert_with(|| {
|
||||||
|
let mut game_clone = summary_to_game(game);
|
||||||
|
if let Some(expected_version) = catalog.expected_version(&game.id) {
|
||||||
|
game_clone.eti_game_version = Some(expected_version.to_string());
|
||||||
|
}
|
||||||
|
game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
|
||||||
|
game_clone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut games: Vec<Game> = aggregated.into_values().collect();
|
||||||
|
games.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
games
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the latest version of a game across all peers.
|
/// Returns the latest version of a game across all peers.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_latest_version_for_game(&self, game_id: &str) -> Option<String> {
|
pub fn get_latest_version_for_game(&self, game_id: &str) -> Option<String> {
|
||||||
@@ -451,6 +499,24 @@ impl PeerGameDB {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns addresses of peers that have the expected catalog version of a game.
|
||||||
|
#[must_use]
|
||||||
|
pub fn peers_with_expected_version(
|
||||||
|
&self,
|
||||||
|
game_id: &str,
|
||||||
|
expected_version: Option<&str>,
|
||||||
|
) -> Vec<SocketAddr> {
|
||||||
|
self.peers
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, peer)| {
|
||||||
|
peer.games
|
||||||
|
.get(game_id)
|
||||||
|
.is_some_and(|game| game_matches_expected_version(game, expected_version))
|
||||||
|
})
|
||||||
|
.map(|(_, peer)| peer.addr)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns addresses of peers that have the latest version of a game.
|
/// Returns addresses of peers that have the latest version of a game.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn peers_with_latest_version(&self, game_id: &str) -> Vec<SocketAddr> {
|
pub fn peers_with_latest_version(&self, game_id: &str) -> Vec<SocketAddr> {
|
||||||
@@ -514,11 +580,33 @@ impl PeerGameDB {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns file descriptions from peers that advertise the expected catalog version.
|
||||||
|
#[must_use]
|
||||||
|
pub fn expected_version_game_files_for(
|
||||||
|
&self,
|
||||||
|
game_id: &str,
|
||||||
|
expected_version: Option<&str>,
|
||||||
|
) -> Vec<(SocketAddr, Vec<GameFileDescription>)> {
|
||||||
|
let expected_peers = self.peers_with_expected_version(game_id, expected_version);
|
||||||
|
if expected_peers.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.game_files_for(game_id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(addr, _)| expected_peers.contains(addr))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns aggregated file descriptions for a game across all peers.
|
/// Returns aggregated file descriptions for a game across all peers.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn aggregated_game_files(&self, game_id: &str) -> Vec<GameFileDescription> {
|
pub fn aggregated_game_files(
|
||||||
|
&self,
|
||||||
|
game_id: &str,
|
||||||
|
expected_version: Option<&str>,
|
||||||
|
) -> Vec<GameFileDescription> {
|
||||||
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
|
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
|
||||||
for (_, files) in self.latest_game_files_for(game_id) {
|
for (_, files) in self.expected_version_game_files_for(game_id, expected_version) {
|
||||||
for file in files {
|
for file in files {
|
||||||
seen.entry(file.relative_path.clone()).or_insert(file);
|
seen.entry(file.relative_path.clone()).or_insert(file);
|
||||||
}
|
}
|
||||||
@@ -559,8 +647,9 @@ impl PeerGameDB {
|
|||||||
pub fn validate_file_sizes_majority(
|
pub fn validate_file_sizes_majority(
|
||||||
&self,
|
&self,
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
|
expected_version: Option<&str>,
|
||||||
) -> eyre::Result<MajorityValidationResult> {
|
) -> eyre::Result<MajorityValidationResult> {
|
||||||
let game_files = self.latest_game_files_for(game_id);
|
let game_files = self.expected_version_game_files_for(game_id, expected_version);
|
||||||
if game_files.is_empty() {
|
if game_files.is_empty() {
|
||||||
return Ok((Vec::new(), Vec::new(), HashMap::new()));
|
return Ok((Vec::new(), Vec::new(), HashMap::new()));
|
||||||
}
|
}
|
||||||
@@ -813,6 +902,14 @@ fn game_is_ready(summary: &GameSummary) -> bool {
|
|||||||
summary.availability == Availability::Ready
|
summary.availability == Availability::Ready
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn game_matches_expected_version(summary: &GameSummary, expected_version: Option<&str>) -> bool {
|
||||||
|
if !game_is_ready(summary) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_version.is_none_or(|expected| summary.eti_version.as_deref() == Some(expected))
|
||||||
|
}
|
||||||
|
|
||||||
fn summary_to_game(summary: &GameSummary) -> Game {
|
fn summary_to_game(summary: &GameSummary) -> Game {
|
||||||
let eti_game_version = game_is_ready(summary)
|
let eti_game_version = game_is_ready(summary)
|
||||||
.then(|| summary.eti_version.clone())
|
.then(|| summary.eti_version.clone())
|
||||||
@@ -925,6 +1022,41 @@ mod tests {
|
|||||||
assert!(db.peers_with_latest_version("game").is_empty());
|
assert!(db.peers_with_latest_version("game").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_aggregation_counts_only_expected_version_peers() {
|
||||||
|
let old_addr = addr(12003);
|
||||||
|
let expected_addr = addr(12004);
|
||||||
|
let newer_addr = addr(12005);
|
||||||
|
let mut db = PeerGameDB::new();
|
||||||
|
db.upsert_peer("old".to_string(), old_addr);
|
||||||
|
db.upsert_peer("expected".to_string(), expected_addr);
|
||||||
|
db.upsert_peer("newer".to_string(), newer_addr);
|
||||||
|
db.update_peer_games(
|
||||||
|
&"old".to_string(),
|
||||||
|
vec![summary("game", "20240101", Availability::Ready)],
|
||||||
|
);
|
||||||
|
db.update_peer_games(
|
||||||
|
&"expected".to_string(),
|
||||||
|
vec![summary("game", "20250101", Availability::Ready)],
|
||||||
|
);
|
||||||
|
db.update_peer_games(
|
||||||
|
&"newer".to_string(),
|
||||||
|
vec![summary("game", "20260101", Availability::Ready)],
|
||||||
|
);
|
||||||
|
let mut catalog = GameCatalog::empty();
|
||||||
|
catalog.insert("game".to_string(), Some("20250101".to_string()));
|
||||||
|
|
||||||
|
let games = db.get_catalog_games(&catalog);
|
||||||
|
|
||||||
|
assert_eq!(games.len(), 1);
|
||||||
|
assert_eq!(games[0].peer_count, 1);
|
||||||
|
assert_eq!(games[0].eti_game_version.as_deref(), Some("20250101"));
|
||||||
|
assert_eq!(
|
||||||
|
db.peers_with_expected_version("game", Some("20250101")),
|
||||||
|
vec![expected_addr]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_addr_matches_known_peer_on_ephemeral_port() {
|
fn transport_addr_matches_known_peer_on_ephemeral_port() {
|
||||||
let advertised = ip_addr([10, 66, 0, 2], 40000);
|
let advertised = ip_addr([10, 66, 0, 2], 40000);
|
||||||
@@ -979,7 +1111,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validation_uses_latest_version_file_metadata() {
|
fn validation_uses_expected_version_file_metadata() {
|
||||||
let old_addr = addr(12003);
|
let old_addr = addr(12003);
|
||||||
let new_addr = addr(12004);
|
let new_addr = addr(12004);
|
||||||
let mut db = PeerGameDB::new();
|
let mut db = PeerGameDB::new();
|
||||||
@@ -1010,21 +1142,21 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let aggregated = db.aggregated_game_files("game");
|
let aggregated = db.aggregated_game_files("game", Some("20250101"));
|
||||||
let archive = aggregated
|
let archive = aggregated
|
||||||
.iter()
|
.iter()
|
||||||
.find(|desc| desc.relative_path == "game/archive.eti")
|
.find(|desc| desc.relative_path == "game/archive.eti")
|
||||||
.expect("latest archive should be present");
|
.expect("expected-version archive should be present");
|
||||||
assert_eq!(archive.size, 20);
|
assert_eq!(archive.size, 20);
|
||||||
|
|
||||||
let (validated, peers, file_peer_map) = db
|
let (validated, peers, file_peer_map) = db
|
||||||
.validate_file_sizes_majority("game")
|
.validate_file_sizes_majority("game", Some("20250101"))
|
||||||
.expect("old-version file metadata should not create ambiguity");
|
.expect("old-version file metadata should not create ambiguity");
|
||||||
assert_eq!(peers, vec![new_addr]);
|
assert_eq!(peers, vec![new_addr]);
|
||||||
let archive = validated
|
let archive = validated
|
||||||
.iter()
|
.iter()
|
||||||
.find(|desc| desc.relative_path == "game/archive.eti")
|
.find(|desc| desc.relative_path == "game/archive.eti")
|
||||||
.expect("latest archive should validate");
|
.expect("expected-version archive should validate");
|
||||||
assert_eq!(archive.size, 20);
|
assert_eq!(archive.size, 20);
|
||||||
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
|
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
|
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
|
||||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ pub(crate) struct HandshakeCtx {
|
|||||||
local_library: Arc<RwLock<LocalLibraryState>>,
|
local_library: Arc<RwLock<LocalLibraryState>>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||||
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HandshakeCtx {
|
impl HandshakeCtx {
|
||||||
@@ -32,6 +34,7 @@ impl HandshakeCtx {
|
|||||||
local_library: ctx.local_library.clone(),
|
local_library: ctx.local_library.clone(),
|
||||||
peer_game_db: ctx.peer_game_db.clone(),
|
peer_game_db: ctx.peer_game_db.clone(),
|
||||||
tx_notify_ui: tx_notify_ui.clone(),
|
tx_notify_ui: tx_notify_ui.clone(),
|
||||||
|
catalog: ctx.catalog.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ impl HandshakeCtx {
|
|||||||
local_library: ctx.local_library.clone(),
|
local_library: ctx.local_library.clone(),
|
||||||
peer_game_db: ctx.peer_game_db.clone(),
|
peer_game_db: ctx.peer_game_db.clone(),
|
||||||
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
||||||
|
catalog: ctx.catalog.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,7 @@ pub(crate) async fn perform_handshake_with_peer(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
after_peer_library_recorded(&ctx, upsert, record_addr).await;
|
after_peer_library_recorded(&ctx, upsert, record_addr).await;
|
||||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -156,7 +160,7 @@ pub(super) async fn accept_inbound_hello(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
after_peer_library_recorded(&handshake_ctx, upsert, addr).await;
|
after_peer_library_recorded(&handshake_ctx, upsert, addr).await;
|
||||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||||
|
|
||||||
build_hello_ack(ctx).await
|
build_hello_ack(ctx).await
|
||||||
}
|
}
|
||||||
@@ -201,12 +205,13 @@ async fn after_peer_library_recorded(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::HashMap,
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
@@ -242,6 +247,7 @@ mod tests {
|
|||||||
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
|
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +307,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn inbound_hello_applies_remote_library_snapshot() {
|
async fn inbound_hello_applies_remote_library_snapshot() {
|
||||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||||
|
let mut catalog = GameCatalog::empty();
|
||||||
|
catalog.insert("remote-game".to_string(), Some("20250101".to_string()));
|
||||||
let ctx = Ctx::new(
|
let ctx = Ctx::new(
|
||||||
peer_game_db.clone(),
|
peer_game_db.clone(),
|
||||||
"local-peer".to_string(),
|
"local-peer".to_string(),
|
||||||
@@ -309,7 +317,8 @@ mod tests {
|
|||||||
Arc::new(NoopUnpacker),
|
Arc::new(NoopUnpacker),
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
Arc::new(RwLock::new(HashSet::new())),
|
Arc::new(RwLock::new(catalog)),
|
||||||
|
Arc::new(RwLock::new(HashMap::new())),
|
||||||
);
|
);
|
||||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ use crate::{
|
|||||||
pub async fn run_ping_service(
|
pub async fn run_ping_service(
|
||||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||||
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
@@ -40,6 +42,7 @@ pub async fn run_ping_service(
|
|||||||
|
|
||||||
ping_idle_peers(
|
ping_idle_peers(
|
||||||
&peer_game_db,
|
&peer_game_db,
|
||||||
|
&catalog,
|
||||||
&active_operations,
|
&active_operations,
|
||||||
&active_downloads,
|
&active_downloads,
|
||||||
&tx_notify_ui,
|
&tx_notify_ui,
|
||||||
@@ -50,6 +53,7 @@ pub async fn run_ping_service(
|
|||||||
|
|
||||||
prune_stale_peers(
|
prune_stale_peers(
|
||||||
&peer_game_db,
|
&peer_game_db,
|
||||||
|
&catalog,
|
||||||
&active_operations,
|
&active_operations,
|
||||||
&active_downloads,
|
&active_downloads,
|
||||||
&tx_notify_ui,
|
&tx_notify_ui,
|
||||||
@@ -60,6 +64,7 @@ pub async fn run_ping_service(
|
|||||||
|
|
||||||
async fn ping_idle_peers(
|
async fn ping_idle_peers(
|
||||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||||
|
catalog: &Arc<RwLock<GameCatalog>>,
|
||||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
@@ -75,6 +80,7 @@ async fn ping_idle_peers(
|
|||||||
|
|
||||||
let tx_notify_ui = tx_notify_ui.clone();
|
let tx_notify_ui = tx_notify_ui.clone();
|
||||||
let peer_game_db = peer_game_db.clone();
|
let peer_game_db = peer_game_db.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
let active_operations = active_operations.clone();
|
let active_operations = active_operations.clone();
|
||||||
let active_downloads = active_downloads.clone();
|
let active_downloads = active_downloads.clone();
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
@@ -93,6 +99,7 @@ async fn ping_idle_peers(
|
|||||||
log::warn!("Peer {peer_addr} failed ping check");
|
log::warn!("Peer {peer_addr} failed ping check");
|
||||||
remove_peer_and_refresh(
|
remove_peer_and_refresh(
|
||||||
&peer_game_db,
|
&peer_game_db,
|
||||||
|
&catalog,
|
||||||
&active_operations,
|
&active_operations,
|
||||||
&active_downloads,
|
&active_downloads,
|
||||||
&tx_notify_ui,
|
&tx_notify_ui,
|
||||||
@@ -105,6 +112,7 @@ async fn ping_idle_peers(
|
|||||||
log::error!("Failed to ping peer {peer_addr}: {err}");
|
log::error!("Failed to ping peer {peer_addr}: {err}");
|
||||||
remove_peer_and_refresh(
|
remove_peer_and_refresh(
|
||||||
&peer_game_db,
|
&peer_game_db,
|
||||||
|
&catalog,
|
||||||
&active_operations,
|
&active_operations,
|
||||||
&active_downloads,
|
&active_downloads,
|
||||||
&tx_notify_ui,
|
&tx_notify_ui,
|
||||||
@@ -120,6 +128,7 @@ async fn ping_idle_peers(
|
|||||||
|
|
||||||
async fn prune_stale_peers(
|
async fn prune_stale_peers(
|
||||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||||
|
catalog: &Arc<RwLock<GameCatalog>>,
|
||||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
@@ -137,7 +146,7 @@ async fn prune_stale_peers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if removed_any {
|
if removed_any {
|
||||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||||
handle_active_downloads_without_peers(
|
handle_active_downloads_without_peers(
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
active_operations,
|
active_operations,
|
||||||
@@ -150,6 +159,7 @@ async fn prune_stale_peers(
|
|||||||
|
|
||||||
async fn remove_peer_and_refresh(
|
async fn remove_peer_and_refresh(
|
||||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||||
|
catalog: &Arc<RwLock<GameCatalog>>,
|
||||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
@@ -157,7 +167,7 @@ async fn remove_peer_and_refresh(
|
|||||||
log_label: &str,
|
log_label: &str,
|
||||||
) {
|
) {
|
||||||
if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await {
|
if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await {
|
||||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
|
||||||
handle_active_downloads_without_peers(
|
handle_active_downloads_without_peers(
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
active_operations,
|
active_operations,
|
||||||
|
|||||||
@@ -336,12 +336,12 @@ fn should_ignore_game_child(name: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use notify::{
|
use notify::{
|
||||||
EventKind,
|
EventKind,
|
||||||
event::{AccessKind, AccessMode},
|
event::{AccessKind, AccessMode},
|
||||||
@@ -373,7 +373,7 @@ mod tests {
|
|||||||
std::fs::write(path, bytes).expect("file should be written");
|
std::fs::write(path, bytes).expect("file should be written");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> Ctx {
|
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
|
||||||
Ctx::new(
|
Ctx::new(
|
||||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||||
"peer".to_string(),
|
"peer".to_string(),
|
||||||
@@ -383,6 +383,7 @@ mod tests {
|
|||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
Arc::new(RwLock::new(catalog)),
|
Arc::new(RwLock::new(catalog)),
|
||||||
|
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +446,7 @@ mod tests {
|
|||||||
let temp = TempDir::new("lanspread-local-monitor");
|
let temp = TempDir::new("lanspread-local-monitor");
|
||||||
let ctx = test_ctx(
|
let ctx = test_ctx(
|
||||||
temp.path().to_path_buf(),
|
temp.path().to_path_buf(),
|
||||||
HashSet::from(["game".to_string()]),
|
GameCatalog::from_ids(["game".to_string()]),
|
||||||
);
|
);
|
||||||
ctx.active_operations
|
ctx.active_operations
|
||||||
.write()
|
.write()
|
||||||
@@ -480,7 +481,7 @@ mod tests {
|
|||||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||||
let ctx = test_ctx(
|
let ctx = test_ctx(
|
||||||
temp.path().to_path_buf(),
|
temp.path().to_path_buf(),
|
||||||
HashSet::from(["game".to_string()]),
|
GameCatalog::from_ids(["game".to_string()]),
|
||||||
);
|
);
|
||||||
let gate = RescanGate::default();
|
let gate = RescanGate::default();
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
@@ -515,7 +516,7 @@ mod tests {
|
|||||||
write_file(&game_root.join("version.ini"), b"20250101");
|
write_file(&game_root.join("version.ini"), b"20250101");
|
||||||
let ctx = test_ctx(
|
let ctx = test_ctx(
|
||||||
temp.path().to_path_buf(),
|
temp.path().to_path_buf(),
|
||||||
HashSet::from(["game".to_string()]),
|
GameCatalog::from_ids(["game".to_string()]),
|
||||||
);
|
);
|
||||||
let gate = RescanGate::default();
|
let gate = RescanGate::default();
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
@@ -551,7 +552,7 @@ mod tests {
|
|||||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||||
let ctx = test_ctx(
|
let ctx = test_ctx(
|
||||||
temp.path().to_path_buf(),
|
temp.path().to_path_buf(),
|
||||||
HashSet::from(["game".to_string()]),
|
GameCatalog::from_ids(["game".to_string()]),
|
||||||
);
|
);
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
@@ -575,7 +576,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let ctx = test_ctx(
|
let ctx = test_ctx(
|
||||||
temp.path().to_path_buf(),
|
temp.path().to_path_buf(),
|
||||||
HashSet::from(["game".to_string()]),
|
GameCatalog::from_ids(["game".to_string()]),
|
||||||
);
|
);
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
context::PeerCtx,
|
context::PeerCtx,
|
||||||
error::PeerError,
|
error::PeerError,
|
||||||
events,
|
events,
|
||||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
|
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
|
||||||
peer::{send_game_file_chunk, send_game_file_data},
|
peer::{send_game_file_chunk, send_game_file_data},
|
||||||
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
|
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
|
||||||
};
|
};
|
||||||
@@ -162,7 +162,7 @@ async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelt
|
|||||||
};
|
};
|
||||||
|
|
||||||
if applied {
|
if applied {
|
||||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||||
} else {
|
} else {
|
||||||
let addr = {
|
let addr = {
|
||||||
let db = ctx.peer_game_db.read().await;
|
let db = ctx.peer_game_db.read().await;
|
||||||
@@ -209,7 +209,7 @@ async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
|
|||||||
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
|
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
|
||||||
let active_operations = ctx.active_operations.read().await;
|
let active_operations = ctx.active_operations.read().await;
|
||||||
let catalog = ctx.catalog.read().await;
|
let catalog = ctx.catalog.read().await;
|
||||||
local_download_available(game_dir, game_id, &active_operations, &catalog).await
|
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn can_dispatch_file_transfer(
|
async fn can_dispatch_file_transfer(
|
||||||
@@ -232,6 +232,67 @@ fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct TransferGuard {
|
||||||
|
game_id: String,
|
||||||
|
id: u64,
|
||||||
|
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||||
|
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransferGuard {
|
||||||
|
async fn new(
|
||||||
|
game_id: String,
|
||||||
|
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||||
|
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
|
||||||
|
shutdown: &tokio_util::sync::CancellationToken,
|
||||||
|
) -> (Self, tokio_util::sync::CancellationToken) {
|
||||||
|
let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let token = shutdown.child_token();
|
||||||
|
{
|
||||||
|
let mut active = active_outbound_transfers.write().await;
|
||||||
|
active
|
||||||
|
.entry(game_id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push((id, token.clone()));
|
||||||
|
}
|
||||||
|
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
game_id,
|
||||||
|
id,
|
||||||
|
active_outbound_transfers,
|
||||||
|
tx_notify_ui,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TransferGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let game_id = self.game_id.clone();
|
||||||
|
let id = self.id;
|
||||||
|
let active_outbound_transfers = self.active_outbound_transfers.clone();
|
||||||
|
let tx_notify_ui = self.tx_notify_ui.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
{
|
||||||
|
let mut active = active_outbound_transfers.write().await;
|
||||||
|
if let Some(tokens) = active.get_mut(&game_id) {
|
||||||
|
tokens.retain(|(tid, _)| *tid != id);
|
||||||
|
if tokens.is_empty() {
|
||||||
|
active.remove(&game_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_file_data_request(
|
async fn handle_file_data_request(
|
||||||
ctx: &PeerCtx,
|
ctx: &PeerCtx,
|
||||||
desc: GameFileDescription,
|
desc: GameFileDescription,
|
||||||
@@ -242,6 +303,14 @@ async fn handle_file_data_request(
|
|||||||
desc.relative_path
|
desc.relative_path
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let (guard, cancel_token) = TransferGuard::new(
|
||||||
|
desc.game_id.clone(),
|
||||||
|
ctx.active_outbound_transfers.clone(),
|
||||||
|
ctx.tx_notify_ui.clone(),
|
||||||
|
&ctx.shutdown,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut tx = framed_tx.into_inner();
|
let mut tx = framed_tx.into_inner();
|
||||||
let game_dir = ctx.game_dir.read().await.clone();
|
let game_dir = ctx.game_dir.read().await.clone();
|
||||||
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|
||||||
@@ -249,11 +318,13 @@ async fn handle_file_data_request(
|
|||||||
"Declining GetGameFileData for {} because the game is not currently transferable",
|
"Declining GetGameFileData for {} because the game is not currently transferable",
|
||||||
desc.relative_path
|
desc.relative_path
|
||||||
);
|
);
|
||||||
|
drop(guard);
|
||||||
let _ = tx.close().await;
|
let _ = tx.close().await;
|
||||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
send_game_file_data(&desc, &mut tx, &game_dir).await;
|
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
|
||||||
|
drop(guard);
|
||||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,17 +340,36 @@ async fn handle_file_chunk_request(
|
|||||||
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
|
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let (guard, cancel_token) = TransferGuard::new(
|
||||||
|
game_id.clone(),
|
||||||
|
ctx.active_outbound_transfers.clone(),
|
||||||
|
ctx.tx_notify_ui.clone(),
|
||||||
|
&ctx.shutdown,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut tx = framed_tx.into_inner();
|
let mut tx = framed_tx.into_inner();
|
||||||
let game_dir = ctx.game_dir.read().await.clone();
|
let game_dir = ctx.game_dir.read().await.clone();
|
||||||
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
||||||
);
|
);
|
||||||
|
drop(guard);
|
||||||
let _ = tx.close().await;
|
let _ = tx.close().await;
|
||||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
send_game_file_chunk(&game_id, &relative_path, offset, length, &mut tx, &game_dir).await;
|
send_game_file_chunk(
|
||||||
|
&game_id,
|
||||||
|
&relative_path,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
&mut tx,
|
||||||
|
&game_dir,
|
||||||
|
cancel_token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
drop(guard);
|
||||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,17 +379,17 @@ async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id
|
|||||||
let Some(peer) = removed else { return };
|
let Some(peer) = removed else { return };
|
||||||
|
|
||||||
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
|
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
|
||||||
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
|
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
@@ -327,7 +417,7 @@ mod tests {
|
|||||||
std::fs::write(path, bytes).expect("file should be written");
|
std::fs::write(path, bytes).expect("file should be written");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
|
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
|
||||||
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
|
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
|
||||||
Ctx::new(
|
Ctx::new(
|
||||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||||
@@ -338,6 +428,7 @@ mod tests {
|
|||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
Arc::new(RwLock::new(catalog)),
|
Arc::new(RwLock::new(catalog)),
|
||||||
|
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
)
|
)
|
||||||
.to_peer_ctx(tx_notify_ui)
|
.to_peer_ctx(tx_notify_ui)
|
||||||
}
|
}
|
||||||
@@ -360,17 +451,19 @@ mod tests {
|
|||||||
b"20250101",
|
b"20250101",
|
||||||
);
|
);
|
||||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||||
|
write_file(
|
||||||
|
&temp.path().join("wrong-version").join("version.ini"),
|
||||||
|
b"20260101",
|
||||||
|
);
|
||||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||||
.expect("missing sentinel root should be created");
|
.expect("missing sentinel root should be created");
|
||||||
|
|
||||||
let ctx = test_ctx(
|
let mut catalog = GameCatalog::empty();
|
||||||
temp.path().to_path_buf(),
|
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||||
HashSet::from([
|
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||||
"ready".to_string(),
|
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||||
"active".to_string(),
|
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||||
"missing-sentinel".to_string(),
|
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||||
]),
|
|
||||||
);
|
|
||||||
ctx.active_operations
|
ctx.active_operations
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
@@ -388,6 +481,10 @@ mod tests {
|
|||||||
get_game_response(&ctx, "active".to_string()).await,
|
get_game_response(&ctx, "active".to_string()).await,
|
||||||
Response::GameNotFound(id) if id == "active"
|
Response::GameNotFound(id) if id == "active"
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
get_game_response(&ctx, "wrong-version".to_string()).await,
|
||||||
|
Response::GameNotFound(id) if id == "wrong-version"
|
||||||
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
get_game_response(&ctx, "missing-sentinel".to_string()).await,
|
get_game_response(&ctx, "missing-sentinel".to_string()).await,
|
||||||
Response::GameNotFound(id) if id == "missing-sentinel"
|
Response::GameNotFound(id) if id == "missing-sentinel"
|
||||||
@@ -403,17 +500,19 @@ mod tests {
|
|||||||
b"20250101",
|
b"20250101",
|
||||||
);
|
);
|
||||||
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
|
||||||
|
write_file(
|
||||||
|
&temp.path().join("wrong-version").join("version.ini"),
|
||||||
|
b"20260101",
|
||||||
|
);
|
||||||
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
|
||||||
.expect("missing sentinel root should be created");
|
.expect("missing sentinel root should be created");
|
||||||
|
|
||||||
let ctx = test_ctx(
|
let mut catalog = GameCatalog::empty();
|
||||||
temp.path().to_path_buf(),
|
catalog.insert("ready".to_string(), Some("20250101".to_string()));
|
||||||
HashSet::from([
|
catalog.insert("active".to_string(), Some("20250101".to_string()));
|
||||||
"ready".to_string(),
|
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
|
||||||
"active".to_string(),
|
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
|
||||||
"missing-sentinel".to_string(),
|
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
|
||||||
]),
|
|
||||||
);
|
|
||||||
ctx.active_operations
|
ctx.active_operations
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
@@ -432,6 +531,15 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
|
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
!can_dispatch_file_transfer(
|
||||||
|
&ctx,
|
||||||
|
temp.path(),
|
||||||
|
"wrong-version",
|
||||||
|
"wrong-version/version.ini",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!can_dispatch_file_transfer(
|
!can_dispatch_file_transfer(
|
||||||
&ctx,
|
&ctx,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use futures::FutureExt as _;
|
use futures::FutureExt as _;
|
||||||
|
use lanspread_db::db::GameCatalog;
|
||||||
use tokio::sync::{
|
use tokio::sync::{
|
||||||
RwLock,
|
RwLock,
|
||||||
mpsc::{UnboundedReceiver, UnboundedSender},
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
||||||
@@ -84,7 +85,8 @@ pub(crate) fn spawn_peer_runtime(
|
|||||||
game_dir: PathBuf,
|
game_dir: PathBuf,
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
|
active_outbound_transfers: crate::context::OutboundTransfers,
|
||||||
) -> PeerRuntimeHandle {
|
) -> PeerRuntimeHandle {
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
let task_tracker = TaskTracker::new();
|
let task_tracker = TaskTracker::new();
|
||||||
@@ -104,6 +106,7 @@ pub(crate) fn spawn_peer_runtime(
|
|||||||
runtime_shutdown.clone(),
|
runtime_shutdown.clone(),
|
||||||
runtime_tracker.clone(),
|
runtime_tracker.clone(),
|
||||||
catalog,
|
catalog,
|
||||||
|
active_outbound_transfers,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -190,6 +193,7 @@ fn spawn_peer_discovery_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEv
|
|||||||
fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||||
let tx_notify_ui = tx_notify_ui.clone();
|
let tx_notify_ui = tx_notify_ui.clone();
|
||||||
let peer_game_db = ctx.peer_game_db.clone();
|
let peer_game_db = ctx.peer_game_db.clone();
|
||||||
|
let catalog = ctx.catalog.clone();
|
||||||
let active_operations = ctx.active_operations.clone();
|
let active_operations = ctx.active_operations.clone();
|
||||||
let active_downloads = ctx.active_downloads.clone();
|
let active_downloads = ctx.active_downloads.clone();
|
||||||
let shutdown = ctx.shutdown.clone();
|
let shutdown = ctx.shutdown.clone();
|
||||||
@@ -207,6 +211,7 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
|||||||
move || {
|
move || {
|
||||||
let tx_notify_ui = tx_notify_ui.clone();
|
let tx_notify_ui = tx_notify_ui.clone();
|
||||||
let peer_game_db = peer_game_db.clone();
|
let peer_game_db = peer_game_db.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
let active_operations = active_operations.clone();
|
let active_operations = active_operations.clone();
|
||||||
let active_downloads = active_downloads.clone();
|
let active_downloads = active_downloads.clone();
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
@@ -215,6 +220,7 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
|||||||
run_ping_service(
|
run_ping_service(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
|
catalog,
|
||||||
active_operations,
|
active_operations,
|
||||||
active_downloads,
|
active_downloads,
|
||||||
shutdown,
|
shutdown,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ tauri-plugin-shell = { workspace = true }
|
|||||||
tauri-plugin-dialog = { workspace = true }
|
tauri-plugin-dialog = { workspace = true }
|
||||||
tauri-plugin-store = { workspace = true }
|
tauri-plugin-store = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tokio-util = { workspace = true }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::{
|
|||||||
|
|
||||||
use eyre::bail;
|
use eyre::bail;
|
||||||
use lanspread_compat::eti::get_games;
|
use lanspread_compat::eti::get_games;
|
||||||
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
|
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
|
||||||
use lanspread_peer::{
|
use lanspread_peer::{
|
||||||
ActiveOperation,
|
ActiveOperation,
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
@@ -31,6 +31,10 @@ use tokio::sync::{
|
|||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
|
|
||||||
|
type OutboundTransfers = Arc<
|
||||||
|
RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>,
|
||||||
|
>;
|
||||||
|
|
||||||
/// Tauri-managed runtime state shared by commands and setup tasks.
|
/// Tauri-managed runtime state shared by commands and setup tasks.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct LanSpreadState {
|
struct LanSpreadState {
|
||||||
@@ -40,9 +44,10 @@ struct LanSpreadState {
|
|||||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||||
games_folder: Arc<RwLock<String>>,
|
games_folder: Arc<RwLock<String>>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<GameCatalog>>,
|
||||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||||
state_dir: OnceLock<PathBuf>,
|
state_dir: OnceLock<PathBuf>,
|
||||||
|
active_outbound_transfers: OutboundTransfers,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -79,6 +84,7 @@ struct LauncherGame {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
game: Game,
|
game: Game,
|
||||||
can_host_server: bool,
|
can_host_server: bool,
|
||||||
|
active_outbound_transfers: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||||
@@ -829,6 +835,24 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
|
||||||
|
// Peer events update availability, but catalog metadata stays anchored to game.db.
|
||||||
|
for game in game_db.games.values_mut() {
|
||||||
|
game.peer_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for peer_game in peer_games {
|
||||||
|
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
||||||
|
existing.peer_count = peer_game.peer_count;
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
||||||
|
id = peer_game.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn clear_all_local_game_states(game_db: &mut GameDB) {
|
fn clear_all_local_game_states(game_db: &mut GameDB) {
|
||||||
for game in game_db.games.values_mut() {
|
for game in game_db.games.values_mut() {
|
||||||
clear_local_game_state(game);
|
clear_local_game_state(game);
|
||||||
@@ -847,17 +871,24 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let active_transfers = state.active_outbound_transfers.read().await;
|
||||||
|
|
||||||
let games_to_emit = game_db
|
let games_to_emit = game_db
|
||||||
.all_games()
|
.all_games()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|game| LauncherGame {
|
.map(|game| {
|
||||||
|
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
|
||||||
|
LauncherGame {
|
||||||
can_host_server: game_can_host_server(&games_folder, &game),
|
can_host_server: game_can_host_server(&games_folder, &game),
|
||||||
|
active_outbound_transfers,
|
||||||
game,
|
game,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<LauncherGame>>();
|
.collect::<Vec<LauncherGame>>();
|
||||||
|
|
||||||
drop(game_db);
|
drop(game_db);
|
||||||
|
drop(active_transfers);
|
||||||
|
|
||||||
let active_operations = {
|
let active_operations = {
|
||||||
let active_operations = state.active_operations.read().await;
|
let active_operations = state.active_operations.read().await;
|
||||||
@@ -996,36 +1027,7 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut game_db = state.games.write().await;
|
let mut game_db = state.games.write().await;
|
||||||
|
apply_peer_remote_games(&mut game_db, games);
|
||||||
// Reset peer counts up front. Presence/metadata stay anchored to the baked game.db.
|
|
||||||
for game in game_db.games.values_mut() {
|
|
||||||
game.peer_count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for peer_game in games {
|
|
||||||
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
|
||||||
existing.peer_count = peer_game.peer_count;
|
|
||||||
|
|
||||||
if let Some(peer_version) = &peer_game.eti_game_version {
|
|
||||||
match &existing.eti_game_version {
|
|
||||||
Some(current_version) if current_version >= peer_version => {}
|
|
||||||
_ => {
|
|
||||||
existing.eti_game_version = Some(peer_version.clone());
|
|
||||||
log::debug!(
|
|
||||||
"Updated eti_game_version for {} to {} based on peer data",
|
|
||||||
peer_game.id,
|
|
||||||
peer_version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::debug!(
|
|
||||||
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
|
||||||
id = peer_game.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_games_list(&app).await;
|
emit_games_list(&app).await;
|
||||||
@@ -1399,7 +1401,7 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
|
|||||||
|
|
||||||
if needs_load {
|
if needs_load {
|
||||||
let game_db = load_bundled_game_db(app_handle).await;
|
let game_db = load_bundled_game_db(app_handle).await;
|
||||||
let catalog = game_db.games.keys().cloned().collect::<HashSet<_>>();
|
let catalog = GameCatalog::from_game_db(&game_db);
|
||||||
*state.games.write().await = game_db;
|
*state.games.write().await = game_db;
|
||||||
*state.catalog.write().await = catalog;
|
*state.catalog.write().await = catalog;
|
||||||
}
|
}
|
||||||
@@ -1432,6 +1434,7 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
|||||||
state.catalog.clone(),
|
state.catalog.clone(),
|
||||||
PeerStartOptions {
|
PeerStartOptions {
|
||||||
state_dir: Some(state_dir),
|
state_dir: Some(state_dir),
|
||||||
|
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
@@ -1495,6 +1498,10 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
emit_games_list(app_handle).await;
|
emit_games_list(app_handle).await;
|
||||||
}
|
}
|
||||||
|
PeerEvent::OutboundTransferCountChanged => {
|
||||||
|
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
||||||
|
emit_games_list(app_handle).await;
|
||||||
|
}
|
||||||
PeerEvent::GotGameFiles {
|
PeerEvent::GotGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
@@ -1747,6 +1754,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame {
|
||||||
|
lanspread_compat::eti::EtiGame {
|
||||||
|
game_id: game_id.to_string(),
|
||||||
|
game_title: "Catalog Game".to_string(),
|
||||||
|
game_key: "catalog-game".to_string(),
|
||||||
|
game_release: "2000".to_string(),
|
||||||
|
game_publisher: "publisher".to_string(),
|
||||||
|
game_size: 1.0,
|
||||||
|
game_readme_de: "description".to_string(),
|
||||||
|
game_readme_en: "description".to_string(),
|
||||||
|
game_readme_fr: "description".to_string(),
|
||||||
|
game_maxplayers: 4,
|
||||||
|
game_master_req: 0,
|
||||||
|
genre_de: "genre".to_string(),
|
||||||
|
game_version: game_version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() {
|
||||||
|
let game = Game::from(eti_game_fixture("alpha", "20200721"));
|
||||||
|
|
||||||
|
assert_eq!(game.version, "20200721");
|
||||||
|
assert_eq!(game.eti_game_version.as_deref(), Some("20200721"));
|
||||||
|
assert_eq!(game.local_version, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
|
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
|
||||||
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
|
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
|
||||||
@@ -2048,6 +2082,42 @@ mod tests {
|
|||||||
|
|
||||||
assert!(game_db.get_game_by_id("unknown").is_none());
|
assert!(game_db.get_game_by_id("unknown").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() {
|
||||||
|
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||||
|
alpha.size = 999;
|
||||||
|
alpha.eti_game_version = Some("20200721".to_string());
|
||||||
|
|
||||||
|
let mut beta = game_fixture("beta", "Catalog Beta");
|
||||||
|
beta.peer_count = 2;
|
||||||
|
beta.eti_game_version = Some("20200101".to_string());
|
||||||
|
|
||||||
|
let mut game_db = GameDB::from(vec![alpha, beta]);
|
||||||
|
|
||||||
|
let mut peer_alpha = game_fixture("alpha", "Peer Alpha");
|
||||||
|
peer_alpha.size = 42;
|
||||||
|
peer_alpha.peer_count = 3;
|
||||||
|
peer_alpha.eti_game_version = Some("20990101".to_string());
|
||||||
|
|
||||||
|
let mut unknown = game_fixture("unknown", "Unknown");
|
||||||
|
unknown.peer_count = 1;
|
||||||
|
unknown.eti_game_version = Some("20990101".to_string());
|
||||||
|
|
||||||
|
apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]);
|
||||||
|
|
||||||
|
let alpha = game_db.get_game_by_id("alpha").expect("alpha remains");
|
||||||
|
assert_eq!(alpha.name, "Catalog Alpha");
|
||||||
|
assert_eq!(alpha.size, 999);
|
||||||
|
assert_eq!(alpha.peer_count, 3);
|
||||||
|
assert_eq!(alpha.eti_game_version.as_deref(), Some("20200721"));
|
||||||
|
|
||||||
|
let beta = game_db.get_game_by_id("beta").expect("beta remains");
|
||||||
|
assert_eq!(beta.peer_count, 0);
|
||||||
|
assert_eq!(beta.eti_game_version.as_deref(), Some("20200101"));
|
||||||
|
|
||||||
|
assert!(game_db.get_game_by_id("unknown").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::missing_panics_doc)]
|
#[allow(clippy::missing_panics_doc)]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JSX, KeyboardEvent } from 'react';
|
|||||||
import { Game } from '../../lib/types';
|
import { Game } from '../../lib/types';
|
||||||
import { CoverAspect } from '../../hooks/useSettings';
|
import { CoverAspect } from '../../hooks/useSettings';
|
||||||
import { formatBytes } from '../../lib/format';
|
import { formatBytes } from '../../lib/format';
|
||||||
|
import { hasNewerLocalVersion } from '../../lib/gameState';
|
||||||
|
|
||||||
import { GameCover } from './GameCover';
|
import { GameCover } from './GameCover';
|
||||||
import { StateChip } from '../StateChip';
|
import { StateChip } from '../StateChip';
|
||||||
@@ -42,6 +43,14 @@ export const GameCard = ({
|
|||||||
onOpen(game);
|
onOpen(game);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const newerThanExpected = hasNewerLocalVersion(game);
|
||||||
|
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||||
|
const statusMessage = hasOutbound
|
||||||
|
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
|
||||||
|
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
|
||||||
|
const statusLevel = hasOutbound
|
||||||
|
? 'info'
|
||||||
|
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -66,8 +75,8 @@ export const GameCard = ({
|
|||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||||
{game.status_message ?? ''}
|
{statusMessage}
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
game={game}
|
game={game}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
|
|||||||
import { ActionButton } from '../ActionButton';
|
import { ActionButton } from '../ActionButton';
|
||||||
|
|
||||||
import { Game, InstallStatus } from '../../lib/types';
|
import { Game, InstallStatus } from '../../lib/types';
|
||||||
import { deriveState, isInProgress } from '../../lib/gameState';
|
import { deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -59,6 +59,18 @@ export const GameDetailModal = ({
|
|||||||
|| game.installed
|
|| game.installed
|
||||||
|| game.install_status === InstallStatus.Downloading
|
|| game.install_status === InstallStatus.Downloading
|
||||||
|| game.install_status === InstallStatus.Installing;
|
|| game.install_status === InstallStatus.Installing;
|
||||||
|
const newerThanExpected = hasNewerLocalVersion(game);
|
||||||
|
const newerStatus = newerThanExpected
|
||||||
|
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||||
|
: undefined;
|
||||||
|
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||||
|
const outboundStatus = hasOutbound
|
||||||
|
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
||||||
|
: undefined;
|
||||||
|
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
||||||
|
const statusLevel = hasOutbound
|
||||||
|
? 'info'
|
||||||
|
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={onClose}>
|
||||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||||
@@ -95,7 +107,7 @@ export const GameDetailModal = ({
|
|||||||
<div className="meta-cell">
|
<div className="meta-cell">
|
||||||
<div className="meta-label">Version</div>
|
<div className="meta-label">Version</div>
|
||||||
<div className="meta-value meta-mono">
|
<div className="meta-value meta-mono">
|
||||||
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
|
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-cell">
|
<div className="meta-cell">
|
||||||
@@ -108,9 +120,9 @@ export const GameDetailModal = ({
|
|||||||
<p className="modal-desc">{description}</p>
|
<p className="modal-desc">{description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{game.status_message && (
|
{statusMessage && (
|
||||||
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||||
{game.status_message}
|
{statusMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { ask } from '@tauri-apps/plugin-dialog';
|
||||||
|
|
||||||
import { type UseGamesResult } from './useGames';
|
import { type UseGamesResult } from './useGames';
|
||||||
import { type UISettings } from './useSettings';
|
import { type UISettings } from './useSettings';
|
||||||
@@ -69,6 +70,14 @@ export const useGameActions = (
|
|||||||
|
|
||||||
const update = useCallback(async (id: string) => {
|
const update = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
const game = games.games.find(item => item.id === id);
|
||||||
|
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||||
|
const confirmed = await ask(
|
||||||
|
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
||||||
|
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
const success = await invoke<boolean>('update_game', {
|
const success = await invoke<boolean>('update_game', {
|
||||||
id,
|
id,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
@@ -90,11 +99,19 @@ export const useGameActions = (
|
|||||||
|
|
||||||
const removeDownload = useCallback(async (id: string) => {
|
const removeDownload = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
const game = games.games.find(item => item.id === id);
|
||||||
|
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||||
|
const confirmed = await ask(
|
||||||
|
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
||||||
|
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
await invoke('remove_downloaded_game', { id });
|
await invoke('remove_downloaded_game', { id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('remove_downloaded_game failed:', err);
|
console.error('remove_downloaded_game failed:', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [games]);
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (id: string) => {
|
const cancelDownload = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -88,17 +88,30 @@ export const isUnavailable = (game: Game): boolean =>
|
|||||||
&& game.peer_count === 0
|
&& game.peer_count === 0
|
||||||
&& game.install_status === InstallStatus.NotInstalled;
|
&& game.install_status === InstallStatus.NotInstalled;
|
||||||
|
|
||||||
|
const parseVersionStamp = (version: string | undefined): number | null => {
|
||||||
|
if (!version || !/^\d{8}$/.test(version)) return null;
|
||||||
|
const parsed = parseInt(version, 10);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compareVersionStamps = (
|
||||||
|
left: string | undefined,
|
||||||
|
right: string | undefined,
|
||||||
|
): number | null => {
|
||||||
|
const parsedLeft = parseVersionStamp(left);
|
||||||
|
const parsedRight = parseVersionStamp(right);
|
||||||
|
if (parsedLeft === null || parsedRight === null) return null;
|
||||||
|
return parsedLeft - parsedRight;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasNewerLocalVersion = (game: Game): boolean =>
|
||||||
|
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
||||||
|
|
||||||
export const needsUpdate = (game: Game): boolean => {
|
export const needsUpdate = (game: Game): boolean => {
|
||||||
if (!game.installed) return false;
|
if (!game.installed) return false;
|
||||||
const peer = game.eti_game_version;
|
if (game.peer_count <= 0) return false;
|
||||||
const local = game.local_version;
|
if (!game.local_version && game.eti_game_version) return true;
|
||||||
if (!local && peer) return true;
|
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
||||||
if (local && peer) {
|
|
||||||
const l = parseInt(local, 10);
|
|
||||||
const p = parseInt(peer, 10);
|
|
||||||
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What pressing the card's main action button should do, given the state. */
|
/** What pressing the card's main action button should do, given the state. */
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export enum ActiveOperationKind {
|
|||||||
RemovingDownload = 'RemovingDownload',
|
RemovingDownload = 'RemovingDownload',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StatusLevel = 'info' | 'error';
|
export type StatusLevel = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
export interface DownloadProgress {
|
export interface DownloadProgress {
|
||||||
downloaded_bytes: number;
|
downloaded_bytes: number;
|
||||||
@@ -59,6 +59,7 @@ export interface Game {
|
|||||||
download_progress?: DownloadProgress;
|
download_progress?: DownloadProgress;
|
||||||
peer_count: number;
|
peer_count: number;
|
||||||
can_host_server?: boolean;
|
can_host_server?: boolean;
|
||||||
|
active_outbound_transfers?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveOperation {
|
export interface ActiveOperation {
|
||||||
|
|||||||
@@ -739,6 +739,12 @@
|
|||||||
.card-status.is-error {
|
.card-status.is-error {
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
.card-status.is-warning {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.card-status.is-info {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.density-compact .card-body {
|
.density-compact .card-body {
|
||||||
padding: 9px 10px 10px;
|
padding: 9px 10px 10px;
|
||||||
@@ -1383,6 +1389,16 @@
|
|||||||
border-color: rgba(239, 68, 68, 0.4);
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(239, 68, 68, 0.08);
|
||||||
}
|
}
|
||||||
|
.modal-status.is-warning {
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
}
|
||||||
|
.modal-status.is-info {
|
||||||
|
color: #60a5fa;
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
background: rgba(96, 165, 250, 0.08);
|
||||||
|
}
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user