fix(peer): settle current-protocol local state cleanup

The follow-up backlog had drifted into three settled peer/runtime issues: the
legacy game-list fallback contradicted the one-wire-version policy, the Tauri
shell still re-derived local install state from disk after peer snapshots, and
`Availability::Downloading` existed even though active operations are already
reported through a separate operation table.

Remove the legacy `AnnounceGames` request and fallback service. Discovery now
ignores peers that do not advertise the current protocol and a peer id, and
library changes are sent through the current delta path only. This keeps the
runtime aligned with the documented current-build-only interoperability model.

Make peer `LocalGamesUpdated` snapshots authoritative for local fields in the
Tauri database. The GUI-side catalog still owns static metadata such as names,
sizes, and descriptions, but downloaded, installed, local version, and
availability now come from the peer runtime instead of a second whole-library
filesystem scan. Snapshot reconciliation also pins the missing-begin and
missing-finish lifecycle cases in tests.

Collapse availability back to the settled `Ready` and `LocalOnly` states.
Aggregation now counts only `Ready` peers as download sources, and the frontend
no longer carries a dead `Downloading` enum value.

The core peer also exposes the small non-GUI hooks needed by scripted callers:
startup options for state and mDNS, a local-ready event, direct connection, peer
snapshots, and an explicit post-download install policy. Those hooks reuse the
same current protocol path and do not add compatibility shims.

Test Plan:
- `git diff --check`
- `just fmt`
- `just clippy`
- `just test`

Refs: BACKLOG.md, FINDINGS.md, IMPL_DECISIONS.md
This commit is contained in:
2026-05-16 18:32:24 +02:00
parent 6242d64583
commit e711cf3454
23 changed files with 531 additions and 723 deletions
+84 -3
View File
@@ -42,7 +42,14 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameFileDescription};
pub use peer_db::{MajorityValidationResult, PeerGameDB, PeerId, PeerInfo, PeerUpsert};
pub use peer_db::{
MajorityValidationResult,
PeerGameDB,
PeerId,
PeerInfo,
PeerSnapshot,
PeerUpsert,
};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -54,6 +61,7 @@ use crate::{
context::Ctx,
handlers::{
GameDetailSource,
handle_connect_peer_command,
handle_download_game_files_command,
handle_get_game_command,
handle_get_peer_count_command,
@@ -72,6 +80,8 @@ use crate::{
/// Events sent from the peer system to the UI.
#[derive(Debug, strum::IntoStaticStr)]
pub enum PeerEvent {
/// The local QUIC server is listening and ready to accept peer connections.
LocalPeerReady { peer_id: String, addr: SocketAddr },
/// List of available games from peers.
ListGames(Vec<Game>),
/// File descriptions for a specific game.
@@ -184,6 +194,12 @@ pub enum PeerCommand {
id: String,
file_descriptions: Vec<GameFileDescription>,
},
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
},
/// Install already-downloaded archives into `local/`.
InstallGame { id: String },
/// Remove only the `local/` install for a game.
@@ -192,6 +208,26 @@ pub enum PeerCommand {
SetGameDir(PathBuf),
/// Request the current peer count.
GetPeerCount,
/// Connect directly to a peer address without waiting for mDNS discovery.
ConnectPeer(SocketAddr),
}
/// Optional startup settings for non-GUI callers and tests.
#[derive(Clone, Debug)]
pub struct PeerStartOptions {
/// Directory used for peer identity and other state.
pub state_dir: Option<PathBuf>,
/// Whether to advertise and discover peers via mDNS.
pub enable_mdns: bool,
}
impl Default for PeerStartOptions {
fn default() -> Self {
Self {
state_dir: None,
enable_mdns: true,
}
}
}
// =============================================================================
@@ -218,12 +254,36 @@ pub fn start_peer(
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<PeerRuntimeHandle> {
start_peer_with_options(
game_dir,
tx_notify_ui,
peer_game_db,
unpacker,
catalog,
PeerStartOptions::default(),
)
}
/// Initialize and start the peer system with explicit startup settings.
#[allow(clippy::implicit_hasher)]
pub fn start_peer_with_options(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions {
state_dir,
enable_mdns,
} = options;
let game_dir = game_dir.into();
log::info!(
"Starting peer system with game directory: {}",
game_dir.display()
);
let peer_id = identity::load_or_create_peer_id()?;
let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?;
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
@@ -236,6 +296,7 @@ pub fn start_peer(
game_dir,
unpacker,
catalog,
enable_mdns,
))
}
@@ -251,6 +312,7 @@ async fn run_peer(
shutdown: CancellationToken,
task_tracker: TaskTracker,
catalog: Arc<RwLock<HashSet<String>>>,
enable_mdns: bool,
) -> eyre::Result<()> {
let ctx = Ctx::new(
peer_game_db,
@@ -260,6 +322,7 @@ async fn run_peer(
shutdown,
task_tracker,
catalog,
enable_mdns,
);
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
log::error!("Failed to load initial local game database: {err}");
@@ -316,7 +379,22 @@ async fn handle_peer_commands(
id,
file_descriptions,
} => {
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions).await;
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
.await;
}
PeerCommand::DownloadGameFilesWithOptions {
id,
file_descriptions,
install_after_download,
} => {
handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
install_after_download,
)
.await;
}
PeerCommand::InstallGame { id } => {
handle_install_game_command(ctx, tx_notify_ui, id).await;
@@ -330,6 +408,9 @@ async fn handle_peer_commands(
PeerCommand::GetPeerCount => {
handle_get_peer_count_command(ctx, tx_notify_ui).await;
}
PeerCommand::ConnectPeer(addr) => {
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
}
}
}
}