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
156 lines
4.8 KiB
Rust
156 lines
4.8 KiB
Rust
//! UI event helpers used by peer command and service code.
|
|
|
|
use std::{
|
|
collections::{BTreeMap, HashMap},
|
|
sync::Arc,
|
|
};
|
|
|
|
use lanspread_db::content_manifest::ContentId;
|
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
|
|
|
use crate::{
|
|
ActiveOperation,
|
|
ActiveOperationKind,
|
|
PeerEvent,
|
|
RemoteGameAvailability,
|
|
RemoteLibraryView,
|
|
context::OperationKind,
|
|
peer_db::PeerGameDB,
|
|
};
|
|
|
|
pub fn send(tx_notify_ui: &UnboundedSender<PeerEvent>, event: PeerEvent) {
|
|
if let Err(err) = tx_notify_ui.send(event) {
|
|
let kind: &'static str = (&err.0).into();
|
|
log::error!("Failed to send {kind} event: channel closed");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn active_operation_snapshot_from_map(
|
|
active_operations: &HashMap<String, OperationKind>,
|
|
) -> Vec<ActiveOperation> {
|
|
let mut snapshot = active_operations
|
|
.iter()
|
|
.map(|(id, operation)| ActiveOperation {
|
|
id: id.clone(),
|
|
operation: active_operation_kind(*operation),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
|
|
snapshot
|
|
}
|
|
|
|
pub(crate) fn send_active_operations_snapshot(
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
active_operations: &HashMap<String, OperationKind>,
|
|
) {
|
|
send(
|
|
tx_notify_ui,
|
|
PeerEvent::ActiveOperationsChanged {
|
|
active_operations: active_operation_snapshot_from_map(active_operations),
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(crate) async fn emit_active_operations(
|
|
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
) {
|
|
let active_operations = active_operations.read().await;
|
|
send_active_operations_snapshot(tx_notify_ui, &active_operations);
|
|
}
|
|
|
|
fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
|
|
match operation {
|
|
OperationKind::Downloading => ActiveOperationKind::Downloading,
|
|
OperationKind::Installing => ActiveOperationKind::Installing,
|
|
OperationKind::Updating => ActiveOperationKind::Updating,
|
|
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
|
|
OperationKind::RemovingDownload => ActiveOperationKind::RemovingDownload,
|
|
}
|
|
}
|
|
|
|
pub async fn emit_peer_game_list(
|
|
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
) {
|
|
let db = peer_game_db.read().await;
|
|
send_remote_library_view_locked(&db, tx_notify_ui);
|
|
}
|
|
|
|
fn send_remote_library_view_locked(
|
|
peer_game_db: &PeerGameDB,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
) {
|
|
send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoteLibraryView(remote_library_view(peer_game_db)),
|
|
);
|
|
}
|
|
|
|
pub(crate) fn remote_library_view(peer_game_db: &PeerGameDB) -> RemoteLibraryView {
|
|
let mut counts = BTreeMap::<(String, ContentId), u32>::new();
|
|
for game in peer_game_db
|
|
.peer_snapshots()
|
|
.into_iter()
|
|
.flat_map(|peer| peer.games)
|
|
{
|
|
let count = counts.entry((game.game_id, game.content_id)).or_default();
|
|
*count = count.saturating_add(1);
|
|
}
|
|
RemoteLibraryView {
|
|
games: counts
|
|
.into_iter()
|
|
.map(
|
|
|((game_id, content_id), peer_count)| RemoteGameAvailability {
|
|
game_id,
|
|
content_id,
|
|
peer_count,
|
|
},
|
|
)
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
pub async fn emit_peer_count(
|
|
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
) {
|
|
let db = peer_game_db.read().await;
|
|
let peer_count = db.peer_endpoints().len();
|
|
send(tx_notify_ui, PeerEvent::PeerCountUpdated(peer_count));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn remote_library_view_is_enqueued_before_a_waiting_writer_can_commit() {
|
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
|
let guard = peer_game_db.read().await;
|
|
let writer_db = Arc::clone(&peer_game_db);
|
|
let writer = tokio::spawn(async move {
|
|
let _guard = writer_db.write().await;
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(!writer.is_finished(), "writer must wait for the read guard");
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
send_remote_library_view_locked(&guard, &tx);
|
|
assert!(matches!(
|
|
rx.try_recv(),
|
|
Ok(PeerEvent::RemoteLibraryView(RemoteLibraryView { games })) if games.is_empty()
|
|
));
|
|
assert!(
|
|
!writer.is_finished(),
|
|
"the view must be enqueued while the read guard still orders writers"
|
|
);
|
|
|
|
drop(guard);
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), writer)
|
|
.await
|
|
.expect("writer should acquire after the view is enqueued")
|
|
.expect("writer task should finish");
|
|
}
|
|
}
|