738095235f
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
73 lines
2.4 KiB
Rust
73 lines
2.4 KiB
Rust
use std::path::Path;
|
|
|
|
use lanspread_db::db::{Availability, Game};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct EtiGame {
|
|
pub game_id: String,
|
|
pub game_title: String,
|
|
pub game_key: String,
|
|
pub game_release: String,
|
|
pub game_publisher: String,
|
|
pub game_size: f64,
|
|
pub game_readme_de: String,
|
|
pub game_readme_en: String,
|
|
pub game_readme_fr: String,
|
|
pub game_maxplayers: u32,
|
|
pub game_master_req: i32,
|
|
pub genre_de: String,
|
|
pub game_version: String,
|
|
}
|
|
|
|
/// # Errors
|
|
pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
|
|
let options = SqliteConnectOptions::new().filename(db).read_only(true);
|
|
let pool = SqlitePoolOptions::new().connect_with(options).await?;
|
|
|
|
let mut games = sqlx::query_as::<_, EtiGame>(
|
|
"SELECT
|
|
g.game_id, g.game_title, g.game_key, g.game_release,
|
|
g.game_publisher, CAST(g.game_size AS REAL) as game_size, g.game_readme_de,
|
|
g.game_readme_en, g.game_readme_fr, CAST(g.game_maxplayers AS INTEGER) as game_maxplayers,
|
|
g.game_master_req, ge.genre_de, g.game_version
|
|
FROM games g
|
|
JOIN genre ge ON g.genre_id = ge.genre_id",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await?;
|
|
|
|
games.sort_by(|a, b| a.game_title.cmp(&b.game_title));
|
|
|
|
tracing::info!("Found {} games in game.db", games.len());
|
|
for game in &games {
|
|
tracing::debug!("{}: {}", game.game_id, game.game_title);
|
|
}
|
|
|
|
Ok(games)
|
|
}
|
|
|
|
impl From<EtiGame> for Game {
|
|
fn from(eti_game: EtiGame) -> Self {
|
|
Self {
|
|
id: eti_game.game_id,
|
|
name: eti_game.game_title,
|
|
description: eti_game.game_readme_de,
|
|
release_year: eti_game.game_release,
|
|
publisher: eti_game.game_publisher,
|
|
max_players: eti_game.game_maxplayers,
|
|
version: eti_game.game_version.clone(),
|
|
genre: eti_game.genre_de,
|
|
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
|
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
|
downloaded: false,
|
|
installed: false,
|
|
availability: Availability::LocalOnly,
|
|
eti_game_version: Some(eti_game.game_version),
|
|
local_version: None,
|
|
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
|
|
}
|
|
}
|
|
}
|