feat(peer)!: cut over to authenticated catalog sharing
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
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
@@ -14,6 +14,7 @@
|
||||
|
||||
mod call_to_play;
|
||||
mod config;
|
||||
mod content_quarantine;
|
||||
mod context;
|
||||
mod download;
|
||||
mod error;
|
||||
@@ -27,52 +28,82 @@ mod library;
|
||||
mod local_games;
|
||||
mod migration;
|
||||
mod network;
|
||||
mod network_generation;
|
||||
mod path_validation;
|
||||
mod peer;
|
||||
mod peer_db;
|
||||
mod remote_peer;
|
||||
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::PathBuf, sync::Arc};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
||||
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::db::{Game, GameCatalog, GameFileDescription};
|
||||
pub use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||
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::{
|
||||
MajorityValidationResult,
|
||||
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::{
|
||||
GameDetailSource,
|
||||
handle_cancel_download_command,
|
||||
handle_connect_peer_command,
|
||||
handle_download_game_files_command,
|
||||
handle_get_game_command,
|
||||
handle_get_peer_count_command,
|
||||
handle_install_game_command,
|
||||
handle_list_games_command,
|
||||
@@ -81,11 +112,18 @@ use crate::{
|
||||
handle_uninstall_game_command,
|
||||
load_local_library,
|
||||
},
|
||||
network_generation::NetworkManager,
|
||||
state_paths::resolve_state_dir,
|
||||
};
|
||||
pub use crate::{
|
||||
context::OutboundTransfers,
|
||||
launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once},
|
||||
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::{
|
||||
@@ -101,36 +139,204 @@ pub use crate::{
|
||||
// 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 },
|
||||
/// List of available games from peers.
|
||||
ListGames(Vec<Game>),
|
||||
/// File descriptions for a specific game.
|
||||
GotGameFiles {
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
},
|
||||
/// Download has started for a game.
|
||||
DownloadGameFilesBegin { id: String },
|
||||
/// 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,
|
||||
relative_path: String,
|
||||
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 { id: String },
|
||||
/// Download has failed.
|
||||
DownloadGameFilesFailed { id: String },
|
||||
/// All peers with the game have disconnected during download.
|
||||
DownloadGameFilesAllPeersGone { id: String },
|
||||
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.
|
||||
@@ -143,28 +349,27 @@ pub enum PeerEvent {
|
||||
RemoveDownloadedGameFinished { id: String },
|
||||
/// Downloaded archive removal has failed before deleting the game root.
|
||||
RemoveDownloadedGameFailed { id: String },
|
||||
/// No peers have the requested game.
|
||||
NoPeersHaveGame { id: String },
|
||||
/// A peer has connected.
|
||||
PeerConnected(SocketAddr),
|
||||
/// A peer has disconnected.
|
||||
PeerDisconnected(SocketAddr),
|
||||
/// A new peer was discovered via mDNS.
|
||||
PeerDiscovered(SocketAddr),
|
||||
PeerDiscovered(PeerEndpoint),
|
||||
/// A peer was lost (timed out or disconnected).
|
||||
PeerLost(SocketAddr),
|
||||
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,
|
||||
OutboundTransferCountChanged(OutboundTransferChange),
|
||||
/// The set of in-progress local operations changed.
|
||||
ActiveOperationsChanged {
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
},
|
||||
/// New or requested Call to Play events in replication order.
|
||||
CallToPlayEvents(Vec<CallToPlayEvent>),
|
||||
/// Complete Call-to-Play projection; consumers replace their previous view.
|
||||
CallToPlayView(CallToPlayView),
|
||||
/// A required peer runtime component failed.
|
||||
RuntimeFailed {
|
||||
component: PeerRuntimeComponent,
|
||||
@@ -175,7 +380,7 @@ pub enum PeerEvent {
|
||||
/// Sampled byte progress for one active game download.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct DownloadProgress {
|
||||
pub id: String,
|
||||
pub attempt: DownloadAttemptKey,
|
||||
pub downloaded_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
pub bytes_per_second: u64,
|
||||
@@ -234,10 +439,6 @@ pub enum ActiveOperationKind {
|
||||
pub enum PeerCommand {
|
||||
/// Request a list of all available games.
|
||||
ListGames,
|
||||
/// Request file details for a specific game, serving local files when available.
|
||||
GetGame(String),
|
||||
/// Request the latest peer-advertised file details for an update.
|
||||
FetchLatestFromPeers { id: String },
|
||||
/// Download game files.
|
||||
DownloadGameFiles { id: String },
|
||||
/// Download game files with an explicit install policy.
|
||||
@@ -246,7 +447,10 @@ pub enum PeerCommand {
|
||||
install_after_download: bool,
|
||||
},
|
||||
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
|
||||
StreamInstallGame { id: String },
|
||||
StreamInstallGame {
|
||||
id: String,
|
||||
settings: StreamInstallSettings,
|
||||
},
|
||||
/// Install already-downloaded archives into `local/`.
|
||||
InstallGame { id: String },
|
||||
/// Remove only the `local/` install for a game.
|
||||
@@ -255,37 +459,72 @@ pub enum PeerCommand {
|
||||
RemoveDownloadedGame { id: String },
|
||||
/// Cancel an active peer download without emitting a user-facing failure.
|
||||
CancelDownload { id: String },
|
||||
/// Set the local game directory.
|
||||
SetGameDir(PathBuf),
|
||||
/// 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 a peer address without waiting for mDNS discovery.
|
||||
ConnectPeer(SocketAddr),
|
||||
/// Publish one local Call to Play action to this peer and the LAN.
|
||||
PublishCallToPlay {
|
||||
event: CallToPlayEvent,
|
||||
reply: oneshot::Sender<Result<(), String>>,
|
||||
/// 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>>,
|
||||
},
|
||||
/// Request the complete in-memory Call to Play history.
|
||||
GetCallToPlayEvents {
|
||||
reply: Option<oneshot::Sender<Vec<CallToPlayEvent>>>,
|
||||
/// 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, Default)]
|
||||
#[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(|_| "..."),
|
||||
@@ -294,6 +533,7 @@ impl std::fmt::Debug for PeerStartOptions {
|
||||
"stream_install_provider",
|
||||
&self.stream_install_provider.as_ref().map(|_| "..."),
|
||||
)
|
||||
.field("local_network_sharing", &self.local_network_sharing)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -309,6 +549,12 @@ impl std::fmt::Debug for PeerStartOptions {
|
||||
/// 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
|
||||
@@ -320,7 +566,7 @@ pub fn start_peer(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
catalog: Arc<CatalogBundle>,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
start_peer_with_options(
|
||||
game_dir,
|
||||
@@ -339,16 +585,20 @@ pub fn start_peer_with_options(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
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 game_dir = game_dir.into();
|
||||
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 =
|
||||
@@ -357,23 +607,62 @@ pub fn start_peer_with_options(
|
||||
"Starting peer system with game directory: {}",
|
||||
game_dir.display()
|
||||
);
|
||||
let peer_id = identity::load_or_create_peer_id(&state_dir)?;
|
||||
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();
|
||||
|
||||
Ok(startup::spawn_peer_runtime(
|
||||
startup::spawn_peer_runtime(
|
||||
tx_control,
|
||||
rx_control,
|
||||
tx_notify_ui,
|
||||
peer_game_db,
|
||||
peer_id,
|
||||
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.
|
||||
@@ -382,19 +671,21 @@ async fn run_peer(
|
||||
mut rx_control: UnboundedReceiver<PeerCommand>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
peer_id: String,
|
||||
peer_identity: Arc<PeerIdentity>,
|
||||
game_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
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_id,
|
||||
peer_identity,
|
||||
game_dir,
|
||||
state_dir,
|
||||
unpacker,
|
||||
@@ -403,11 +694,19 @@ async fn run_peer(
|
||||
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_startup_services(&ctx, &tx_notify_ui);
|
||||
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}");
|
||||
@@ -420,8 +719,6 @@ async fn run_peer(
|
||||
);
|
||||
ctx.shutdown.cancel();
|
||||
}
|
||||
startup::send_goodbye_notifications(&ctx).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -447,14 +744,6 @@ async fn handle_peer_commands(
|
||||
PeerCommand::ListGames => {
|
||||
handle_list_games_command(ctx, tx_notify_ui).await;
|
||||
}
|
||||
PeerCommand::GetGame(id) => {
|
||||
handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LocalOrPeers)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::FetchLatestFromPeers { id } => {
|
||||
handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LatestPeersOnly)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::DownloadGameFiles { id } => {
|
||||
handle_download_game_files_command(ctx, tx_notify_ui, id, true).await;
|
||||
}
|
||||
@@ -465,8 +754,8 @@ async fn handle_peer_commands(
|
||||
handle_download_game_files_command(ctx, tx_notify_ui, id, install_after_download)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::StreamInstallGame { id } => {
|
||||
handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id).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;
|
||||
@@ -480,26 +769,390 @@ async fn handle_peer_commands(
|
||||
PeerCommand::CancelDownload { id } => {
|
||||
handle_cancel_download_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::SetGameDir(game_dir) => {
|
||||
handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await;
|
||||
PeerCommand::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(addr) => {
|
||||
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
|
||||
PeerCommand::ConnectPeer(endpoint) => {
|
||||
handle_connect_peer_command(ctx, tx_notify_ui, endpoint).await;
|
||||
}
|
||||
PeerCommand::PublishCallToPlay { event, reply } => {
|
||||
let result = call_to_play::publish(ctx, tx_notify_ui, event).await;
|
||||
let _ = reply.send(result);
|
||||
PeerCommand::ApplyCallToPlayIntent {
|
||||
intent,
|
||||
display_name,
|
||||
reply,
|
||||
} => {
|
||||
handle_apply_call_to_play_intent(ctx, tx_notify_ui, intent, display_name, reply)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::GetCallToPlayEvents { reply } => {
|
||||
let events = ctx.call_to_play.write().await.snapshot();
|
||||
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone()));
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(events);
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user