b56f4e2757
The launcher needs design work later for showing how many peers are currently feeding an active download. Surface that data now on the existing progress payload so UI state can consume it without a separate event stream or rendering change. The peer download tracker now treats each live chunk receive as peer activity and reports the number of unique peers with in-flight streams. This is a live transfer count, not the number of peers that advertised the game or received a plan. Multiple chunks from one peer count once, and the count falls as chunk streams finish. Tauri already forwards DownloadGameFilesProgress, so no bridge event was added. The TypeScript model accepts active_peer_count under download_progress and preserves it with the same reducer path that keeps bytes and speed while the backend says the game is still downloading. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - just frontend-test - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Refs: none
152 lines
8.3 KiB
Markdown
152 lines
8.3 KiB
Markdown
# lanspread-peer
|
||
|
||
`lanspread-peer` is the networking runtime that lets Lanspread nodes find each
|
||
other on the local network, exchange library metadata, and transfer game files.
|
||
It is designed to run headless – other crates (most notably
|
||
`lanspread-tauri-deno-ts`) embed it and drive it through a channel-based API.
|
||
|
||
## Runtime Overview
|
||
|
||
- `start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)` boots the
|
||
asynchronous runtime in the background and returns a `PeerRuntimeHandle` whose
|
||
sender controls the peer. The injected `Unpacker` keeps archive extraction out
|
||
of the peer crate's platform layer, and the catalog set gates which local game
|
||
roots are announced or served.
|
||
- `PeerCommand` represents the small control surface exposed to the UI layer:
|
||
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`,
|
||
`InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`,
|
||
`SetGameDir`, and `GetPeerCount`.
|
||
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
|
||
library snapshots, download/install/uninstall lifecycle updates, runtime
|
||
failures, and peer membership changes.
|
||
- `PeerGameDB` collects remote peer metadata. It aggregates discovered peers’
|
||
`Game` definitions, tracks the latest ETI version per title, and keeps the
|
||
last seen list of `GameFileDescription` entries for each peer.
|
||
|
||
Internally the peer runtime owns four long-lived tasks that run for the
|
||
lifetime of the process:
|
||
|
||
1. **Server component** (`run_server_component`) – listens for QUIC connections,
|
||
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`,
|
||
`Request::GetGameFileData`, and `Request::GetGameFileChunk` by reading from
|
||
the local game directory.
|
||
2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns`
|
||
helper to discover other peers. The blocking mDNS work is executed on a
|
||
dedicated thread via `tokio::task::spawn_blocking` so that the Tokio runtime
|
||
remains responsive.
|
||
3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping requests
|
||
to keep peer liveness up to date and prunes stale entries from `PeerGameDB`.
|
||
4. **Local game monitor** (`run_local_game_monitor`) – watches the configured
|
||
game directory and each game root non-recursively, gates per-ID rescans while
|
||
operations are active, emits local-library changes separately from active
|
||
operation snapshots, and runs a 300-second fallback scan for missed events.
|
||
|
||
`scan_local_library` maintains a lightweight on-disk index and produces both a
|
||
`GameDB` and protocol summaries. A game is downloaded only when its root-level
|
||
`version.ini` sentinel exists; `local/` being a directory is the install signal.
|
||
|
||
## Networking and File Transfer
|
||
|
||
- Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic); TLS
|
||
cert/key material is compiled in from the repository root.
|
||
- Protocol messages are JSON-encoded structures defined in
|
||
`lanspread-proto::{Request, Response}`.
|
||
- File transfers stream raw bytes over dedicated bidirectional QUIC streams.
|
||
`peer::send_game_file_data` sends entire files, while
|
||
`peer::send_game_file_chunk` services ranged requests.
|
||
|
||
### Download Pipeline
|
||
|
||
When the UI asks to download a game:
|
||
|
||
1. The UI first issues `PeerCommand::GetGame` for a new download, or
|
||
`PeerCommand::FetchLatestFromPeers` for an update that must bypass local
|
||
archives. The selected peers are queried via `request_game_details_from_peer`,
|
||
and their file manifests are merged inside `PeerGameDB`.
|
||
2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file
|
||
list back with `PeerCommand::DownloadGameFiles`.
|
||
3. `download_game_files` starts a version-sentinel transaction, parks any old
|
||
`version.ini` as `.version.ini.discarded`, prepares non-sentinel files, emits
|
||
`PeerEvent::DownloadGameFilesBegin`, and builds a per-peer plan
|
||
(`build_peer_plans`) that round-robins file chunks across the available peers
|
||
that advertise the latest version.
|
||
4. Each plan is executed in its own task (`download_from_peer`). Chunk requests
|
||
use per-chunk QUIC streams and write into pre-created files. The chunk writer
|
||
keeps existing data intact and only truncates when we intentionally fall back
|
||
to a full file transfer, which prevents corruption when multiple peers fill
|
||
different regions of the same file.
|
||
5. `DownloadProgressTracker` samples byte counters, transfer speed, and the
|
||
number of unique peers that are actively streaming chunks. The Tauri UI sees
|
||
those values together through the regular download-progress event.
|
||
6. `version.ini` chunks are buffered in memory and committed last via
|
||
`.version.ini.tmp` followed by an atomic rename. Failures are accumulated and
|
||
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed downloads
|
||
sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the
|
||
previous sentinel. Cancelled downloads also discard the peer-owned download
|
||
payload while preserving `local/` and install transaction metadata.
|
||
7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
||
is emitted and the peer auto-runs the install transaction.
|
||
|
||
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
||
transfer. The transfer task remains responsible for clearing `active_operations`,
|
||
discarding partial payload files, and refreshing the settled local snapshot, so
|
||
the UI continues to treat active-operation snapshots as the single source of
|
||
truth for whether a download is still running.
|
||
|
||
### Install Transactions
|
||
|
||
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
||
under `src/install/`.
|
||
Each game root has an atomic `.lanspread.json` intent log for install-side
|
||
operations and uses Lanspread-owned `.local.installing/` and `.local.backup/`
|
||
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
|
||
intent with the observed filesystem state and only deletes reserved directories
|
||
when intent or marker ownership proves they belong to Lanspread.
|
||
Downloaded-file removal is deliberately separate from uninstall: it only accepts
|
||
catalog IDs that are direct children of the configured game directory, refuses
|
||
installed or in-flight roots, and deletes the whole game root only after finding
|
||
a regular root-level `version.ini` sentinel.
|
||
|
||
## Integration with `lanspread-tauri-deno-ts`
|
||
|
||
The Tauri application embeds this crate in
|
||
`crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`:
|
||
|
||
- `LanSpreadState` holds onto the peer control channel, the latest aggregated
|
||
`GameDB`, per-game operation state, the catalog set, and the user-selected
|
||
game directory.
|
||
- The Tauri commands (`request_games`, `install_game`, `update_game`,
|
||
`remove_downloaded_game`, and `update_game_directory`) translate UI actions
|
||
into `PeerCommand`s. In
|
||
particular, `update_game_directory` validates the filesystem path before
|
||
storing it, loads the bundled catalog on first use, kicks off the peer runtime
|
||
on demand, and mirrors the installed/uninstalled state into the UI-facing
|
||
database.
|
||
- A background task consumes `PeerEvent`s and fans them out to the front-end via
|
||
Tauri publish/subscribe events (`games-list-updated`, `game-download-*`,
|
||
`game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only
|
||
provides the unrar sidecar through the injected `Unpacker`; rollback and
|
||
cleanup live in the peer transaction code.
|
||
|
||
## Security & Operational Notes
|
||
|
||
- All QUIC connections are TLS encrypted; the shipped certificates are suitable
|
||
for local-network trust but should be rotated for production deployments.
|
||
- Peer discovery is restricted to the local link via mDNS.
|
||
- Long-running blocking mDNS calls are isolated on dedicated threads which keeps
|
||
the async runtime responsive even when discovery takes a long time.
|
||
- File writes are chunk-safe: partial chunk downloads open files without
|
||
truncating existing data, and root-level `version.ini` is written only after
|
||
the rest of the download has succeeded.
|
||
|
||
## Known Limitations
|
||
|
||
- `PeerGameDB` currently models the latest metadata that other peers advertise.
|
||
If the UI needs to surface titles that only exist locally, additional merging
|
||
with the locally scanned `GameDB` will be required.
|
||
- The download planner uses a simple round-robin and does not yet take per-peer
|
||
throughput or failures into account when distributing work.
|
||
|
||
Refer to the source (particularly `src/lib.rs`) for the exact message shapes and
|
||
state machines.
|