Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
1159 lines
40 KiB
Rust
1159 lines
40 KiB
Rust
//! Peer-to-peer game distribution system.
|
|
//!
|
|
//! This crate provides the core P2P networking engine for `LanSpread`, handling:
|
|
//! - Peer discovery via mDNS
|
|
//! - QUIC-based communication
|
|
//! - Game file synchronization with chunked transfers
|
|
//! - Consensus validation for file integrity
|
|
|
|
#![allow(clippy::missing_errors_doc)]
|
|
|
|
// =============================================================================
|
|
// Module declarations
|
|
// =============================================================================
|
|
|
|
mod call_to_play;
|
|
mod config;
|
|
mod content_quarantine;
|
|
mod context;
|
|
mod download;
|
|
mod error;
|
|
mod events;
|
|
mod game_paths;
|
|
mod handlers;
|
|
mod identity;
|
|
mod install;
|
|
mod launch_settings;
|
|
mod library;
|
|
mod local_games;
|
|
mod migration;
|
|
mod network;
|
|
mod network_generation;
|
|
mod path_validation;
|
|
mod peer;
|
|
mod peer_db;
|
|
mod quic_runtime;
|
|
mod recovery_quarantine;
|
|
mod scoped_blocking;
|
|
mod scoped_process;
|
|
mod services;
|
|
mod startup;
|
|
mod state_paths;
|
|
mod stream_install;
|
|
#[cfg(test)]
|
|
mod test_support;
|
|
mod tls;
|
|
#[cfg(test)]
|
|
mod tls_identity_spike;
|
|
mod transfer_status;
|
|
|
|
// =============================================================================
|
|
// Public re-exports
|
|
// =============================================================================
|
|
|
|
use std::{
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
|
|
pub use config::CHUNK_SIZE;
|
|
pub use error::PeerError;
|
|
pub use identity::{
|
|
LoadedPeerIdentity,
|
|
PeerIdentity,
|
|
PeerIdentityPersistence,
|
|
PeerIdentityPersistenceFailure,
|
|
PeerIdentityPersistenceFailureKind,
|
|
load_peer_identity,
|
|
};
|
|
pub use install::{UnpackFuture, Unpacker};
|
|
use lanspread_db::{
|
|
content_manifest::{CanonicalCatalogPath, CatalogBundle, ContentId},
|
|
db::Game,
|
|
};
|
|
pub use lanspread_proto::{CallId, CallToPlayAction, EventNonce, PeerEndpoint};
|
|
pub use migration::{MigrationReport, migrate_legacy_state};
|
|
pub use peer_db::{
|
|
PeerEndpointGeneration,
|
|
PeerGameDB,
|
|
PeerId,
|
|
PeerInfo,
|
|
PeerLivenessSnapshot,
|
|
PeerSnapshot,
|
|
PeerUpsert,
|
|
};
|
|
pub use scoped_blocking::scoped_blocking;
|
|
pub use scoped_process::{ScopedProcess, ScopedProcessOutput};
|
|
use tokio::sync::{
|
|
RwLock,
|
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
|
oneshot,
|
|
};
|
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
|
pub use transfer_status::{
|
|
DownloadAttemptId,
|
|
DownloadAttemptKey,
|
|
DownloadFailureReason,
|
|
DownloadVerificationActivity,
|
|
};
|
|
|
|
use crate::{
|
|
context::Ctx,
|
|
handlers::{
|
|
handle_cancel_download_command,
|
|
handle_connect_peer_command,
|
|
handle_download_game_files_command,
|
|
handle_get_peer_count_command,
|
|
handle_install_game_command,
|
|
handle_list_games_command,
|
|
handle_remove_downloaded_game_command,
|
|
handle_set_game_dir_command,
|
|
handle_uninstall_game_command,
|
|
load_local_library,
|
|
},
|
|
network_generation::NetworkManager,
|
|
state_paths::resolve_state_dir,
|
|
};
|
|
pub use crate::{
|
|
context::{OutboundTransferChange, OutboundTransfers},
|
|
launch_settings::{
|
|
LaunchSettingsOutcome,
|
|
StreamInstallSettings,
|
|
apply_launch_settings_once,
|
|
apply_launch_settings_to_verified_tree,
|
|
mark_launch_settings_applied,
|
|
},
|
|
startup::PeerRuntimeHandle,
|
|
state_paths::{launch_settings_applied_path, setup_done_path},
|
|
stream_install::{
|
|
ExternalUnrarStreamProvider,
|
|
NoopStreamInstallProvider,
|
|
StreamInstallFrameSink,
|
|
StreamInstallFuture,
|
|
StreamInstallProvider,
|
|
},
|
|
};
|
|
|
|
// =============================================================================
|
|
// Public API types
|
|
// =============================================================================
|
|
|
|
/// One user-authored Call-to-Play mutation request.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CallToPlayLocalIntent {
|
|
/// Existing call to mutate, or `None` when creating a new call.
|
|
pub call_id: Option<CallId>,
|
|
pub action: CallToPlayLocalAction,
|
|
}
|
|
|
|
/// User-controlled Call-to-Play action fields. Identity, nonces, and time are core-owned.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub enum CallToPlayLocalAction {
|
|
Create {
|
|
game_id: String,
|
|
max_players: u16,
|
|
scheduled_for: Option<i64>,
|
|
deadline: i64,
|
|
},
|
|
Respond {
|
|
ready_at: Option<i64>,
|
|
},
|
|
Rsvp,
|
|
SendMessage {
|
|
text: String,
|
|
},
|
|
Leave,
|
|
Cancel,
|
|
Start,
|
|
AddTime {
|
|
deadline: i64,
|
|
},
|
|
}
|
|
|
|
/// Core-generated identity and revision for an accepted local intent.
|
|
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CallToPlayReceipt {
|
|
pub call_id: CallId,
|
|
pub event_id: EventNonce,
|
|
pub revision: u64,
|
|
}
|
|
|
|
/// Complete Call-to-Play projection. Every delivery replaces the previous projection.
|
|
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CallToPlayView {
|
|
pub events: Vec<CallToPlayViewEvent>,
|
|
}
|
|
|
|
/// One author-attributed event in a complete Call-to-Play projection.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CallToPlayViewEvent {
|
|
pub id: EventNonce,
|
|
pub call_id: CallId,
|
|
pub author_id: PeerId,
|
|
pub author_name: String,
|
|
pub at: i64,
|
|
pub action: CallToPlayAction,
|
|
}
|
|
|
|
/// Exact remotely available catalog content and authenticated source count.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RemoteGameAvailability {
|
|
pub game_id: String,
|
|
pub content_id: ContentId,
|
|
pub peer_count: u32,
|
|
}
|
|
|
|
/// Pure remote availability projection, independent of UI catalog metadata.
|
|
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RemoteLibraryView {
|
|
pub games: Vec<RemoteGameAvailability>,
|
|
}
|
|
|
|
impl From<CallToPlayLocalIntent> for call_to_play::CallToPlayLocalIntent {
|
|
fn from(intent: CallToPlayLocalIntent) -> Self {
|
|
Self {
|
|
call_id: intent.call_id,
|
|
action: intent.action.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<CallToPlayLocalAction> for call_to_play::CallToPlayLocalAction {
|
|
fn from(action: CallToPlayLocalAction) -> Self {
|
|
match action {
|
|
CallToPlayLocalAction::Create {
|
|
game_id,
|
|
max_players,
|
|
scheduled_for,
|
|
deadline,
|
|
} => Self::Create {
|
|
game_id,
|
|
max_players,
|
|
scheduled_for,
|
|
deadline,
|
|
},
|
|
CallToPlayLocalAction::Respond { ready_at } => Self::Respond { ready_at },
|
|
CallToPlayLocalAction::Rsvp => Self::Rsvp,
|
|
CallToPlayLocalAction::SendMessage { text } => Self::SendMessage { text },
|
|
CallToPlayLocalAction::Leave => Self::Leave,
|
|
CallToPlayLocalAction::Cancel => Self::Cancel,
|
|
CallToPlayLocalAction::Start => Self::Start,
|
|
CallToPlayLocalAction::AddTime { deadline } => Self::AddTime { deadline },
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<call_to_play::CallToPlayReceipt> for CallToPlayReceipt {
|
|
fn from(receipt: call_to_play::CallToPlayReceipt) -> Self {
|
|
Self {
|
|
call_id: receipt.call_id,
|
|
event_id: receipt.event_id,
|
|
revision: receipt.revision,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<call_to_play::CallToPlayView> for CallToPlayView {
|
|
fn from(view: call_to_play::CallToPlayView) -> Self {
|
|
Self {
|
|
events: view.events.into_iter().map(Into::into).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<call_to_play::CallToPlayViewEvent> for CallToPlayViewEvent {
|
|
fn from(event: call_to_play::CallToPlayViewEvent) -> Self {
|
|
Self {
|
|
id: event.id,
|
|
call_id: event.call_id,
|
|
author_id: event.author_id,
|
|
author_name: event.author_name,
|
|
at: event.at,
|
|
action: event.action,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Lifecycle state of the process-wide Local network sharing policy.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
pub enum LocalNetworkSharingState {
|
|
Disabled,
|
|
Enabling,
|
|
Enabled { peer_id: String, addr: SocketAddr },
|
|
Disabling,
|
|
}
|
|
|
|
/// Redacted durability outcome for the installation identity used by a runtime.
|
|
///
|
|
/// This deliberately exposes no persistence path, failure category, key
|
|
/// material, or backend error text.
|
|
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, Eq, PartialEq)]
|
|
pub enum PeerIdentityDurability {
|
|
Persistent,
|
|
Ephemeral,
|
|
CallerProvided,
|
|
}
|
|
|
|
/// 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 },
|
|
/// The current lifecycle state of the global Local network sharing policy.
|
|
LocalNetworkSharingStateChanged(LocalNetworkSharingState),
|
|
/// Complete exact-content availability projection from authenticated peers.
|
|
RemoteLibraryView(RemoteLibraryView),
|
|
/// Download has started for one exact command attempt.
|
|
DownloadGameFilesBegin { attempt: DownloadAttemptKey },
|
|
/// A file chunk has been downloaded from a peer.
|
|
DownloadGameFileChunkFinished {
|
|
id: String,
|
|
peer_id: PeerId,
|
|
peer_addr: SocketAddr,
|
|
content_id: ContentId,
|
|
relative_path: CanonicalCatalogPath,
|
|
offset: u64,
|
|
length: u64,
|
|
},
|
|
/// Download progress sampled while game files are being received.
|
|
DownloadGameFilesProgress(DownloadProgress),
|
|
/// Verification-specific activity for one download attempt changed.
|
|
DownloadGameFilesActivityChanged {
|
|
attempt: DownloadAttemptKey,
|
|
activity: Option<DownloadVerificationActivity>,
|
|
},
|
|
/// Download has completed successfully.
|
|
DownloadGameFilesFinished { attempt: DownloadAttemptKey },
|
|
/// Download has failed with a stable user-facing classification.
|
|
DownloadGameFilesFailed {
|
|
attempt: DownloadAttemptKey,
|
|
reason: DownloadFailureReason,
|
|
},
|
|
/// Install or update transaction has completed successfully.
|
|
InstallGameFinished { id: String },
|
|
/// Install or update transaction has failed after rollback.
|
|
InstallGameFailed { id: String },
|
|
/// Uninstall transaction has completed successfully.
|
|
UninstallGameFinished { id: String },
|
|
/// Uninstall transaction has failed after rollback.
|
|
UninstallGameFailed { id: String },
|
|
/// Downloaded archive removal has completed successfully.
|
|
RemoveDownloadedGameFinished { id: String },
|
|
/// Downloaded archive removal has failed before deleting the game root.
|
|
RemoveDownloadedGameFailed { id: String },
|
|
/// A new peer was discovered via mDNS.
|
|
PeerDiscovered(PeerEndpoint),
|
|
/// A peer was lost (timed out or disconnected).
|
|
PeerLost(PeerEndpoint),
|
|
/// The total peer count has changed.
|
|
PeerCountUpdated(usize),
|
|
/// mDNS observed a nearby installation speaking a different wire protocol.
|
|
IncompatibleProtocolDetected {
|
|
observed: Option<u32>,
|
|
expected: u32,
|
|
},
|
|
/// The local library contents changed after a scan.
|
|
LocalLibraryChanged { games: Vec<Game> },
|
|
/// The number of active outbound transfers changed.
|
|
OutboundTransferCountChanged(OutboundTransferChange),
|
|
/// The set of in-progress local operations changed.
|
|
ActiveOperationsChanged {
|
|
active_operations: Vec<ActiveOperation>,
|
|
},
|
|
/// Complete Call-to-Play projection; consumers replace their previous view.
|
|
CallToPlayView(CallToPlayView),
|
|
/// A required peer runtime component failed.
|
|
RuntimeFailed {
|
|
component: PeerRuntimeComponent,
|
|
error: String,
|
|
},
|
|
}
|
|
|
|
/// Sampled byte progress for one active game download.
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
|
pub struct DownloadProgress {
|
|
pub attempt: DownloadAttemptKey,
|
|
pub downloaded_bytes: u64,
|
|
pub total_bytes: u64,
|
|
pub bytes_per_second: u64,
|
|
/// Unique peers currently streaming at least one chunk for this download.
|
|
pub active_peer_count: usize,
|
|
}
|
|
|
|
/// Long-running peer runtime components reported in failure events.
|
|
#[derive(Clone, Copy, Debug, strum::IntoStaticStr)]
|
|
pub enum PeerRuntimeComponent {
|
|
/// Command/control message loop.
|
|
CommandLoop,
|
|
/// Inbound QUIC server and its mDNS advertisement.
|
|
QuicServer,
|
|
/// mDNS peer discovery.
|
|
Discovery,
|
|
/// Peer liveness monitoring.
|
|
Liveness,
|
|
/// Local game directory monitor.
|
|
LocalMonitor,
|
|
}
|
|
|
|
/// Install-side operation represented in lifecycle events.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
|
|
pub enum InstallOperation {
|
|
/// Fresh install into a missing `local/` directory.
|
|
Installing,
|
|
/// Update that replaces an existing `local/` directory.
|
|
Updating,
|
|
}
|
|
|
|
/// In-progress operation snapshot sent when operation state changes.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ActiveOperation {
|
|
pub id: String,
|
|
pub operation: ActiveOperationKind,
|
|
}
|
|
|
|
/// Operation kinds visible to UI reconciliation.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
|
|
pub enum ActiveOperationKind {
|
|
/// Downloading or replacing archive files.
|
|
Downloading,
|
|
/// Extracting into a previously uninstalled game root.
|
|
Installing,
|
|
/// Replacing an existing `local/` install.
|
|
Updating,
|
|
/// Removing an existing `local/` install.
|
|
Uninstalling,
|
|
/// Removing downloaded archive files for an uninstalled game.
|
|
RemovingDownload,
|
|
}
|
|
|
|
/// Commands sent to the peer system from the UI.
|
|
#[derive(Debug)]
|
|
pub enum PeerCommand {
|
|
/// Request a list of all available games.
|
|
ListGames,
|
|
/// Download game files.
|
|
DownloadGameFiles { id: String },
|
|
/// Download game files with an explicit install policy.
|
|
DownloadGameFilesWithOptions {
|
|
id: String,
|
|
install_after_download: bool,
|
|
},
|
|
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
|
|
StreamInstallGame {
|
|
id: String,
|
|
settings: StreamInstallSettings,
|
|
},
|
|
/// Install already-downloaded archives into `local/`.
|
|
InstallGame { id: String },
|
|
/// Remove only the `local/` install for a game.
|
|
UninstallGame { id: String },
|
|
/// Remove downloaded archive files for an uninstalled game.
|
|
RemoveDownloadedGame { id: String },
|
|
/// Cancel an active peer download without emitting a user-facing failure.
|
|
CancelDownload { id: String },
|
|
/// Set the local game directory and acknowledge the canonical path that
|
|
/// the peer accepted.
|
|
SetGameDir {
|
|
path: PathBuf,
|
|
reply: oneshot::Sender<Result<PathBuf, String>>,
|
|
},
|
|
/// Enable or disable all Local network sharing services and await drainage.
|
|
SetLocalNetworkSharing {
|
|
enabled: bool,
|
|
reply: oneshot::Sender<Result<bool, String>>,
|
|
},
|
|
/// Request the current peer count.
|
|
GetPeerCount,
|
|
/// Connect directly to an authenticated peer endpoint without waiting for mDNS discovery.
|
|
ConnectPeer(PeerEndpoint),
|
|
/// Apply one local Call-to-Play intent; core generates all identity and time fields.
|
|
ApplyCallToPlayIntent {
|
|
intent: CallToPlayLocalIntent,
|
|
display_name: String,
|
|
reply: oneshot::Sender<Result<CallToPlayReceipt, String>>,
|
|
},
|
|
/// Update the locally authored display name, including without a new event.
|
|
SetCallToPlayDisplayName {
|
|
display_name: String,
|
|
reply: oneshot::Sender<Result<bool, String>>,
|
|
},
|
|
/// Request the complete in-memory Call-to-Play projection.
|
|
GetCallToPlayView {
|
|
reply: Option<oneshot::Sender<Result<CallToPlayView, String>>>,
|
|
},
|
|
}
|
|
|
|
/// Optional startup settings for non-GUI callers and tests.
|
|
#[derive(Clone)]
|
|
pub struct PeerStartOptions {
|
|
/// Directory used for peer identity and other state.
|
|
pub state_dir: Option<PathBuf>,
|
|
/// Explicit installation identity. When absent, the state directory owns it.
|
|
pub identity: Option<Arc<PeerIdentity>>,
|
|
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
|
|
/// Provider used to stream archive entries for low-disk streamed installs.
|
|
pub stream_install_provider: Option<Arc<dyn StreamInstallProvider>>,
|
|
/// Whether Local network sharing services start enabled.
|
|
pub local_network_sharing: bool,
|
|
}
|
|
|
|
impl Default for PeerStartOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
state_dir: None,
|
|
identity: None,
|
|
active_outbound_transfers: None,
|
|
stream_install_provider: None,
|
|
local_network_sharing: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for PeerStartOptions {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("PeerStartOptions")
|
|
.field("state_dir", &self.state_dir)
|
|
.field(
|
|
"identity",
|
|
&self.identity.as_ref().map(|identity| identity.peer_id()),
|
|
)
|
|
.field(
|
|
"active_outbound_transfers",
|
|
&self.active_outbound_transfers.as_ref().map(|_| "..."),
|
|
)
|
|
.field(
|
|
"stream_install_provider",
|
|
&self.stream_install_provider.as_ref().map(|_| "..."),
|
|
)
|
|
.field("local_network_sharing", &self.local_network_sharing)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Public API functions
|
|
// =============================================================================
|
|
|
|
/// Initialize and start the peer system.
|
|
///
|
|
/// This is the main entry point for the peer system. It starts all background
|
|
/// services (server, discovery, ping, local monitor) and returns a handle that
|
|
/// owns the command sender plus a shutdown signal callers can use for clean
|
|
/// teardown.
|
|
///
|
|
/// A successful return acknowledges construction of the isolated runtime and
|
|
/// outgoing QUIC endpoint. Initial recovery and required-service startup remain
|
|
/// asynchronous. Later events report bootstrap progress and failures, including
|
|
/// [`PeerEvent::LocalPeerReady`] for listener readiness and
|
|
/// [`PeerEvent::RuntimeFailed`] for a required component failure.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `game_dir` - Path to the local game directory
|
|
/// * `tx_notify_ui` - Channel for sending events to the UI
|
|
/// * `peer_game_db` - Shared peer game database
|
|
#[allow(clippy::implicit_hasher)]
|
|
pub fn start_peer(
|
|
game_dir: impl Into<PathBuf>,
|
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
|
unpacker: Arc<dyn Unpacker>,
|
|
catalog: Arc<CatalogBundle>,
|
|
) -> 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<CatalogBundle>,
|
|
options: PeerStartOptions,
|
|
) -> eyre::Result<PeerRuntimeHandle> {
|
|
let PeerStartOptions {
|
|
state_dir,
|
|
identity: explicit_identity,
|
|
active_outbound_transfers,
|
|
stream_install_provider,
|
|
local_network_sharing,
|
|
} = options;
|
|
let state_dir = resolve_state_dir(state_dir.as_deref());
|
|
let requested_game_dir = game_dir.into();
|
|
let game_dir = canonicalize_game_dir(&requested_game_dir)?;
|
|
install::intent::scan_active_install_intents(&state_dir, &game_dir)?;
|
|
let active_outbound_transfers = active_outbound_transfers
|
|
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
|
|
let stream_install_provider =
|
|
stream_install_provider.unwrap_or_else(|| Arc::new(NoopStreamInstallProvider));
|
|
log::info!(
|
|
"Starting peer system with game directory: {}",
|
|
game_dir.display()
|
|
);
|
|
let (peer_identity, identity_durability) = if let Some(identity) = explicit_identity {
|
|
(identity, PeerIdentityDurability::CallerProvided)
|
|
} else {
|
|
let loaded = identity::load_or_generate_peer_identity(&state_dir)?;
|
|
let durability = identity_durability(&loaded.persistence);
|
|
match &loaded.persistence {
|
|
PeerIdentityPersistence::Loaded => {
|
|
log::debug!("Loaded peer identity {}", loaded.identity.peer_id());
|
|
}
|
|
PeerIdentityPersistence::Created => {
|
|
log::info!("Created peer identity {}", loaded.identity.peer_id());
|
|
}
|
|
PeerIdentityPersistence::ReplacedCorrupt { quarantine_path } => {
|
|
log::warn!(
|
|
"Replaced corrupt peer identity; original preserved at {}",
|
|
quarantine_path.display()
|
|
);
|
|
}
|
|
PeerIdentityPersistence::Ephemeral(failure) => {
|
|
log::warn!(
|
|
"Peer identity persistence {:?} failed at {}: {}; using an ephemeral identity for this runtime",
|
|
failure.kind,
|
|
failure.path.display(),
|
|
failure.message
|
|
);
|
|
}
|
|
}
|
|
(Arc::new(loaded.identity), durability)
|
|
};
|
|
|
|
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
|
|
|
|
startup::spawn_peer_runtime(
|
|
tx_control,
|
|
rx_control,
|
|
tx_notify_ui,
|
|
peer_game_db,
|
|
peer_identity,
|
|
identity_durability,
|
|
game_dir,
|
|
state_dir,
|
|
unpacker,
|
|
catalog,
|
|
active_outbound_transfers,
|
|
stream_install_provider,
|
|
local_network_sharing,
|
|
)
|
|
}
|
|
|
|
const fn identity_durability(persistence: &PeerIdentityPersistence) -> PeerIdentityDurability {
|
|
match persistence {
|
|
PeerIdentityPersistence::Loaded
|
|
| PeerIdentityPersistence::Created
|
|
| PeerIdentityPersistence::ReplacedCorrupt { .. } => PeerIdentityDurability::Persistent,
|
|
PeerIdentityPersistence::Ephemeral(_) => PeerIdentityDurability::Ephemeral,
|
|
}
|
|
}
|
|
|
|
/// Main peer execution loop that handles peer commands and manages the peer system.
|
|
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
|
|
async fn run_peer(
|
|
mut rx_control: UnboundedReceiver<PeerCommand>,
|
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
|
peer_identity: Arc<PeerIdentity>,
|
|
game_dir: PathBuf,
|
|
state_dir: PathBuf,
|
|
unpacker: Arc<dyn Unpacker>,
|
|
shutdown: CancellationToken,
|
|
task_tracker: TaskTracker,
|
|
catalog: Arc<CatalogBundle>,
|
|
active_outbound_transfers: crate::context::OutboundTransfers,
|
|
stream_install_provider: Arc<dyn StreamInstallProvider>,
|
|
local_network_sharing: bool,
|
|
) -> eyre::Result<()> {
|
|
let (network_manager, network) = NetworkManager::new();
|
|
let ctx = Ctx::new(
|
|
peer_game_db,
|
|
peer_identity,
|
|
game_dir,
|
|
state_dir,
|
|
unpacker,
|
|
shutdown,
|
|
task_tracker,
|
|
catalog,
|
|
active_outbound_transfers,
|
|
stream_install_provider,
|
|
network,
|
|
)?;
|
|
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
|
|
log::error!("Failed to load initial local game database: {err}");
|
|
}
|
|
startup::spawn_core_services(&ctx, &tx_notify_ui);
|
|
let manager_ctx = ctx.clone();
|
|
let manager_tx = tx_notify_ui.clone();
|
|
ctx.task_tracker.clone().spawn(async move {
|
|
network_manager
|
|
.run(manager_ctx, manager_tx, local_network_sharing)
|
|
.await;
|
|
});
|
|
if let Err(err) = handle_peer_commands(&ctx, &tx_notify_ui, &mut rx_control).await {
|
|
let error = err.to_string();
|
|
log::error!("Peer command loop failed: {error}");
|
|
events::send(
|
|
&tx_notify_ui,
|
|
PeerEvent::RuntimeFailed {
|
|
component: PeerRuntimeComponent::CommandLoop,
|
|
error,
|
|
},
|
|
);
|
|
ctx.shutdown.cancel();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_peer_commands(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
rx_control: &mut UnboundedReceiver<PeerCommand>,
|
|
) -> eyre::Result<()> {
|
|
loop {
|
|
let cmd = tokio::select! {
|
|
() = ctx.shutdown.cancelled() => return Ok(()),
|
|
cmd = rx_control.recv() => cmd,
|
|
};
|
|
|
|
let Some(cmd) = cmd else {
|
|
if ctx.shutdown.is_cancelled() {
|
|
return Ok(());
|
|
}
|
|
eyre::bail!("peer command channel closed unexpectedly");
|
|
};
|
|
|
|
match cmd {
|
|
PeerCommand::ListGames => {
|
|
handle_list_games_command(ctx, tx_notify_ui).await;
|
|
}
|
|
PeerCommand::DownloadGameFiles { id } => {
|
|
handle_download_game_files_command(ctx, tx_notify_ui, id, true).await;
|
|
}
|
|
PeerCommand::DownloadGameFilesWithOptions {
|
|
id,
|
|
install_after_download,
|
|
} => {
|
|
handle_download_game_files_command(ctx, tx_notify_ui, id, install_after_download)
|
|
.await;
|
|
}
|
|
PeerCommand::StreamInstallGame { id, settings } => {
|
|
handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id, settings).await;
|
|
}
|
|
PeerCommand::InstallGame { id } => {
|
|
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
|
}
|
|
PeerCommand::UninstallGame { id } => {
|
|
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
|
}
|
|
PeerCommand::RemoveDownloadedGame { id } => {
|
|
handle_remove_downloaded_game_command(ctx, tx_notify_ui, id).await;
|
|
}
|
|
PeerCommand::CancelDownload { id } => {
|
|
handle_cancel_download_command(ctx, tx_notify_ui, id).await;
|
|
}
|
|
PeerCommand::SetGameDir { path, reply } => {
|
|
let result = handle_set_game_dir_command(ctx, tx_notify_ui, path).await;
|
|
let _ = reply.send(result);
|
|
}
|
|
PeerCommand::SetLocalNetworkSharing { enabled, reply } => {
|
|
let _ = reply.send(ctx.network.set_enabled(enabled).await);
|
|
}
|
|
PeerCommand::GetPeerCount => {
|
|
handle_get_peer_count_command(ctx, tx_notify_ui).await;
|
|
}
|
|
PeerCommand::ConnectPeer(endpoint) => {
|
|
handle_connect_peer_command(ctx, tx_notify_ui, endpoint).await;
|
|
}
|
|
PeerCommand::ApplyCallToPlayIntent {
|
|
intent,
|
|
display_name,
|
|
reply,
|
|
} => {
|
|
handle_apply_call_to_play_intent(ctx, tx_notify_ui, intent, display_name, reply)
|
|
.await;
|
|
}
|
|
PeerCommand::SetCallToPlayDisplayName {
|
|
display_name,
|
|
reply,
|
|
} => {
|
|
handle_set_call_to_play_display_name(ctx, tx_notify_ui, display_name, reply).await;
|
|
}
|
|
PeerCommand::GetCallToPlayView { reply } => {
|
|
handle_get_call_to_play_view(ctx, tx_notify_ui, reply).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_apply_call_to_play_intent(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
intent: CallToPlayLocalIntent,
|
|
display_name: String,
|
|
reply: oneshot::Sender<Result<CallToPlayReceipt, String>>,
|
|
) {
|
|
let _network_permit = match ctx.network.try_acquire() {
|
|
Ok(permit) => permit,
|
|
Err(error) => {
|
|
let _ = reply.send(Err(error.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
let result = {
|
|
let mut store = ctx.call_to_play.write().await;
|
|
match store.publish_local(intent.into(), display_name) {
|
|
Ok((receipt, publication)) => {
|
|
ctx.state_sync
|
|
.publish_call_to_play_revision(publication.local_revision);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)),
|
|
);
|
|
Ok(CallToPlayReceipt::from(receipt))
|
|
}
|
|
Err(error) => Err(error.to_string()),
|
|
}
|
|
};
|
|
let _ = reply.send(result);
|
|
}
|
|
|
|
async fn handle_set_call_to_play_display_name(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
display_name: String,
|
|
reply: oneshot::Sender<Result<bool, String>>,
|
|
) {
|
|
let result = {
|
|
let mut store = ctx.call_to_play.write().await;
|
|
match store.set_local_display_name(display_name) {
|
|
Ok((mutation, publication)) => {
|
|
if publication.local_changed {
|
|
ctx.state_sync
|
|
.publish_call_to_play_revision(publication.local_revision);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::CallToPlayView(CallToPlayView::from(publication.view)),
|
|
);
|
|
}
|
|
Ok(mutation.is_some())
|
|
}
|
|
Err(error) => Err(error.to_string()),
|
|
}
|
|
};
|
|
let _ = reply.send(result);
|
|
}
|
|
|
|
async fn handle_get_call_to_play_view(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
reply: Option<oneshot::Sender<Result<CallToPlayView, String>>>,
|
|
) {
|
|
let result = {
|
|
let mut store = ctx.call_to_play.write().await;
|
|
match store.current_publication() {
|
|
Ok(publication) => {
|
|
if publication.local_changed {
|
|
ctx.state_sync
|
|
.publish_call_to_play_revision(publication.local_revision);
|
|
}
|
|
let view = CallToPlayView::from(publication.view);
|
|
events::send(tx_notify_ui, PeerEvent::CallToPlayView(view.clone()));
|
|
Ok(view)
|
|
}
|
|
Err(error) => Err(error.to_string()),
|
|
}
|
|
};
|
|
if let Some(reply) = reply {
|
|
let _ = reply.send(result);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn canonicalize_game_dir(game_dir: &Path) -> eyre::Result<PathBuf> {
|
|
let canonical = std::fs::canonicalize(game_dir).map_err(|error| {
|
|
eyre::eyre!(
|
|
"failed to canonicalize game directory {}: {error}",
|
|
game_dir.display()
|
|
)
|
|
})?;
|
|
let metadata = std::fs::symlink_metadata(&canonical).map_err(|error| {
|
|
eyre::eyre!(
|
|
"failed to inspect canonical game directory {}: {error}",
|
|
canonical.display()
|
|
)
|
|
})?;
|
|
if !metadata.is_dir() {
|
|
eyre::bail!(
|
|
"configured game directory is not a directory: {}",
|
|
canonical.display()
|
|
);
|
|
}
|
|
Ok(canonical)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod configured_game_dir_tests {
|
|
use std::{
|
|
collections::HashMap,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
use tokio::sync::{RwLock, mpsc};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use super::{
|
|
PeerEvent,
|
|
PeerIdentityDurability,
|
|
PeerStartOptions,
|
|
UnpackFuture,
|
|
Unpacker,
|
|
canonicalize_game_dir,
|
|
identity_durability,
|
|
load_peer_identity,
|
|
start_peer_with_options,
|
|
};
|
|
use crate::{
|
|
NoopStreamInstallProvider,
|
|
PeerIdentityPersistence,
|
|
PeerIdentityPersistenceFailure,
|
|
PeerIdentityPersistenceFailureKind,
|
|
identity::load_or_generate_peer_identity_with_write_failure,
|
|
install::intent::{InstallIntent, InstallIntentState, intent_path, write_intent},
|
|
peer_db::PeerGameDB,
|
|
state_paths::peer_identity_path,
|
|
test_support::{TempDir, empty_catalog_bundle},
|
|
};
|
|
|
|
struct NoopUnpacker;
|
|
|
|
#[test]
|
|
fn peer_start_options_default_to_local_network_sharing() {
|
|
let options = PeerStartOptions::default();
|
|
|
|
assert!(options.local_network_sharing);
|
|
assert!(format!("{options:?}").contains("local_network_sharing: true"));
|
|
}
|
|
|
|
#[test]
|
|
fn identity_durability_redacts_persistence_details() {
|
|
for persistence in [
|
|
PeerIdentityPersistence::Loaded,
|
|
PeerIdentityPersistence::Created,
|
|
PeerIdentityPersistence::ReplacedCorrupt {
|
|
quarantine_path: PathBuf::from("/private/quarantine-path"),
|
|
},
|
|
] {
|
|
assert_eq!(
|
|
identity_durability(&persistence),
|
|
PeerIdentityDurability::Persistent
|
|
);
|
|
}
|
|
|
|
let persistence = PeerIdentityPersistence::Ephemeral(PeerIdentityPersistenceFailure {
|
|
kind: PeerIdentityPersistenceFailureKind::Write,
|
|
path: PathBuf::from("/private/identity-path"),
|
|
message: "private backend detail".to_owned(),
|
|
});
|
|
let durability = identity_durability(&persistence);
|
|
|
|
assert_eq!(durability, PeerIdentityDurability::Ephemeral);
|
|
assert_eq!(
|
|
serde_json::to_string(&durability).expect("durability should serialize"),
|
|
"\"Ephemeral\""
|
|
);
|
|
}
|
|
|
|
impl Unpacker for NoopUnpacker {
|
|
fn unpack<'a>(
|
|
&'a self,
|
|
_archive: &'a Path,
|
|
_dest: &'a Path,
|
|
_cancel_token: CancellationToken,
|
|
) -> UnpackFuture<'a> {
|
|
Box::pin(async { Ok(()) })
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn startup_canonicalizes_lexical_root_alias_before_storage() {
|
|
let target = TempDir::new("lanspread-startup-game-root-target");
|
|
let state = TempDir::new("lanspread-startup-state-root");
|
|
let alias = target.path().join(".");
|
|
let expected = std::fs::canonicalize(target.path()).expect("target should canonicalize");
|
|
let (events, _event_rx) = mpsc::unbounded_channel();
|
|
|
|
let mut handle = start_peer_with_options(
|
|
alias,
|
|
events,
|
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
|
Arc::new(NoopUnpacker),
|
|
empty_catalog_bundle(),
|
|
PeerStartOptions {
|
|
state_dir: Some(state.path().to_path_buf()),
|
|
..PeerStartOptions::default()
|
|
},
|
|
)
|
|
.expect("peer startup should accept a lexical root alias");
|
|
|
|
assert_eq!(handle.accepted_game_dir(), expected);
|
|
assert_eq!(
|
|
handle.identity_durability(),
|
|
PeerIdentityDurability::Persistent
|
|
);
|
|
assert_eq!(
|
|
handle.peer_id(),
|
|
load_peer_identity(&peer_identity_path(state.path()))
|
|
.expect("persisted startup identity should load")
|
|
.peer_id()
|
|
);
|
|
handle.shutdown();
|
|
handle.wait_stopped().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn ephemeral_write_failed_identity_participates_as_local_peer_ready() {
|
|
let games = TempDir::new("lanspread-startup-ephemeral-write-games");
|
|
let state = TempDir::new("lanspread-startup-ephemeral-write-state");
|
|
let loaded = load_or_generate_peer_identity_with_write_failure(state.path())
|
|
.expect("injected persistence failure should retain one identity");
|
|
assert!(matches!(
|
|
&loaded.persistence,
|
|
PeerIdentityPersistence::Ephemeral(failure)
|
|
if failure.kind == PeerIdentityPersistenceFailureKind::Write
|
|
));
|
|
let durability = identity_durability(&loaded.persistence);
|
|
let identity = Arc::new(loaded.identity);
|
|
let expected_peer_id = identity.peer_id();
|
|
let (tx_control, rx_control) = mpsc::unbounded_channel();
|
|
let (tx_events, mut rx_events) = mpsc::unbounded_channel();
|
|
|
|
let mut handle = crate::startup::spawn_peer_runtime(
|
|
tx_control,
|
|
rx_control,
|
|
tx_events,
|
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
|
Arc::clone(&identity),
|
|
durability,
|
|
std::fs::canonicalize(games.path()).expect("games root should canonicalize"),
|
|
state.path().to_path_buf(),
|
|
Arc::new(NoopUnpacker),
|
|
empty_catalog_bundle(),
|
|
Arc::new(RwLock::new(HashMap::new())),
|
|
Arc::new(NoopStreamInstallProvider),
|
|
true,
|
|
)
|
|
.expect("runtime should start with the ephemeral identity");
|
|
|
|
assert!(Arc::ptr_eq(&handle.identity(), &identity));
|
|
assert_eq!(
|
|
handle.identity_durability(),
|
|
PeerIdentityDurability::Ephemeral
|
|
);
|
|
let ready_peer_id = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
match rx_events.recv().await {
|
|
Some(PeerEvent::LocalPeerReady { peer_id, .. }) => break peer_id,
|
|
Some(PeerEvent::RuntimeFailed { component, error }) => {
|
|
panic!("runtime component {component:?} failed before readiness: {error}");
|
|
}
|
|
Some(_) => {}
|
|
None => panic!("runtime event channel closed before readiness"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("sharing-enabled runtime should publish listener readiness");
|
|
assert_eq!(ready_peer_id, expected_peer_id.to_string());
|
|
|
|
handle.shutdown();
|
|
handle.wait_stopped().await;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn startup_canonicalizes_symlink_root_alias_before_storage() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let target = TempDir::new("lanspread-startup-game-root-target");
|
|
let aliases = TempDir::new("lanspread-startup-game-root-alias");
|
|
let alias = aliases.path().join("games");
|
|
symlink(target.path(), &alias).expect("configured-root alias should be created");
|
|
|
|
assert_eq!(
|
|
canonicalize_game_dir(&alias).expect("configured-root alias should be accepted"),
|
|
std::fs::canonicalize(target.path()).expect("target should canonicalize")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_rejects_a_non_directory_root() {
|
|
let target = TempDir::new("lanspread-startup-game-root-file");
|
|
let file = target.path().join("games");
|
|
std::fs::write(&file, b"not a directory").expect("test file should be written");
|
|
|
|
let error = canonicalize_game_dir(&file).expect_err("file root should be rejected");
|
|
|
|
assert!(error.to_string().contains("is not a directory"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn startup_rejects_foreign_intent_for_game_absent_from_candidate_root() {
|
|
let old_games = TempDir::new("lanspread-startup-foreign-intent-old-root");
|
|
let candidate = TempDir::new("lanspread-startup-foreign-intent-candidate");
|
|
let state = TempDir::new("lanspread-startup-foreign-intent-state");
|
|
let intent = InstallIntent::new(
|
|
&old_games.path().join("orphan"),
|
|
"orphan",
|
|
InstallIntentState::Updating,
|
|
None,
|
|
)
|
|
.expect("old-root intent should be valid");
|
|
write_intent(state.path(), "orphan", &intent).expect("old-root intent should be persisted");
|
|
let (events, _event_rx) = mpsc::unbounded_channel();
|
|
|
|
let result = start_peer_with_options(
|
|
candidate.path().to_path_buf(),
|
|
events,
|
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
|
Arc::new(NoopUnpacker),
|
|
empty_catalog_bundle(),
|
|
PeerStartOptions {
|
|
state_dir: Some(state.path().to_path_buf()),
|
|
..PeerStartOptions::default()
|
|
},
|
|
);
|
|
let error = match result {
|
|
Ok(mut handle) => {
|
|
handle.shutdown();
|
|
handle.wait_stopped().await;
|
|
panic!("foreign install intent should reject peer startup");
|
|
}
|
|
Err(error) => error,
|
|
};
|
|
|
|
assert!(error.to_string().contains("different configured games"));
|
|
assert!(!candidate.path().join("orphan").exists());
|
|
assert!(intent_path(state.path(), "orphan").is_file());
|
|
assert!(!peer_identity_path(state.path()).exists());
|
|
}
|
|
}
|