Files
lanspread/crates/lanspread-peer/src/events.rs
T
ddidderr 60fd7ba0c2 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
2026-08-10 13:59:18 +02:00

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");
}
}