docs(peer): document authenticated sharing architecture

Update user and developer documentation for the protocol-8 system: persistent
SPKI-derived identities, exact catalog ContentId authority, pinned responder
pulls, structured runtime ownership, direct-author Call to Play, and the global
local-network sharing switch.

Remove active descriptions of repository-wide certificates, pushed deltas,
relayed histories, and metadata consensus. Keep operational and UI boundaries
aligned with the implementation, including the fail-closed production catalog
gate.

Test Plan:
- `just fmt` (passed)
- `git diff --cached --check` (passed)
This commit is contained in:
2026-08-10 14:05:00 +02:00
parent 4a1b08db98
commit e0eafa6e33
4 changed files with 465 additions and 260 deletions
+21 -1
View File
@@ -4,6 +4,18 @@ Peer-to-peer game library sharing for LAN parties. Peers discover each other on
the local network via mDNS, exchange library metadata over QUIC, and let users the local network via mDNS, exchange library metadata over QUIC, and let users
browse and download games from each other. Ships as a Tauri desktop app. browse and download games from each other. Ships as a Tauri desktop app.
## Peer identity and protocol status
Each desktop installation creates and reuses one Ed25519 identity in its Tauri
app-data directory. Its `PeerId` is lowercase unpadded RFC 4648 base32 of BLAKE3
over the certificate's complete canonical DER SubjectPublicKeyInfo; certificates
and private keys are never distributed from the repository. Outbound QUIC
connections authenticate the responder against the exact expected `PeerId`. Wire
protocol 8 (`lanspread/8`) is the only supported protocol. Peers exchange
responder-owned, revisioned snapshots over identity-pinned endpoints; file and
streamed-install requests name the catalog's exact `ContentId`. There are no
legacy wire fallbacks or repository-shared identity keys.
## Build / install ## Build / install
Install Rust, Deno, and `just` first, then bootstrap the project: Install Rust, Deno, and `just` first, then bootstrap the project:
@@ -33,12 +45,20 @@ Create production bundles:
just bundle just bundle
``` ```
Development commands use the checked-in peer-CLI fixture catalog only after its
database and manifests pass the catalog checker. Production bundles never use
that fixture authority: `just bundle` validates the production `game.db` and
requires a generated manifest for every catalog game before Tauri packaging can
start. This checkout does not include the canonical production package corpus,
so maintainers must generate and independently check those manifests beside the
production packages before a bundle can succeed.
## Important just commands ## Important just commands
- `just setup` - install the Tauri CLI and frontend dependencies. - `just setup` - install the Tauri CLI and frontend dependencies.
- `just run` - run the Tauri app in dev mode. - `just run` - run the Tauri app in dev mode.
- `just build` - build the app without bundling. - `just build` - build the app without bundling.
- `just bundle` - create production bundles. - `just bundle` - validate the complete production catalog and create bundles.
- `just fmt` - format Rust, TOML, and the justfile. - `just fmt` - format Rust, TOML, and the justfile.
- `just clippy` - lint the Rust workspace. - `just clippy` - lint the Rust workspace.
- `just test` - run workspace tests. - `just test` - run workspace tests.
+254 -167
View File
@@ -1,8 +1,9 @@
# lanspread-peer proposed protocol and architecture # lanspread-peer architecture
This document proposes a tighter, more fault-tolerant protocol while keeping the The peer uses mDNS discovery, authenticated QUIC transport, responder-owned
current idea: mDNS discovery, QUIC transport, on-demand metadata, and chunked metadata snapshots, and catalog-authorized file transfers. Wire protocol 8 and
file transfers. ALPN `lanspread/8` are the only supported wire mode; there is no legacy decode,
fallback, or compatibility shim.
## Goals (unchanged) ## Goals (unchanged)
@@ -11,136 +12,181 @@ file transfers.
- UI drives operations through `PeerCommand`, peers remain headless. - UI drives operations through `PeerCommand`, peers remain headless.
- Peers can appear/disappear at any time without data loss. - Peers can appear/disappear at any time without data loss.
## Installation identity and QUIC authentication
- One installation-local Ed25519 key signs one self-issued TLS certificate.
`PeerId` is BLAKE3 over the certificate's complete canonical DER
SubjectPublicKeyInfo, encoded as 52 lowercase unpadded RFC 4648 base32
characters. It is not a UUID, display name, IP address, or wire assertion.
- The desktop app stores a strict version-1 record at `peer-identity-v1.json` in
its Tauri app-data directory. The record binds the Ed25519 certificate,
private key, SPKI-derived `PeerId`, and exact SAN. Reads and publication do
not follow symlinks or reparse points, creation is no-clobber, corrupt bytes
are quarantined before replacement, and private material is kept at mode 0600
where the platform supports Unix modes.
- Persistence failure produces a typed `Ephemeral` outcome and a fresh identity
for that runtime; it never falls back to another peer-ID scheme. The peer CLI
can instead select an existing record with `--identity-file`; that path is
loaded strictly and is never repaired, replaced, or generated implicitly.
- Identity state assumes one normal application owner for its app-data directory
and deliberately has no cross-process identity lease. Retained no-follow
handles contain path traversal and external-target mutation, and publication
does not overwrite an existing winner. A concurrent same-user writer can still
cause availability or continuity loss during quarantine; it is outside the
supported ownership model.
- QUIC uses the rustls provider with TLS 1.3 only. Every outbound operation
carries a `PeerEndpoint { peer_id, addr }`; the expected ID is encoded in an
exact SNI name, checked against the responder certificate SPKI, and the real
TLS 1.3 CertificateVerify signature is delegated to rustls. ALPN is
`lanspread/8`. Client resumption and 0-RTT are disabled; the server issues no
tickets and sends no early or half-RTT data.
## Peer lifecycle and message flow ## Peer lifecycle and message flow
### 1) Startup and advertise ### 1) Startup and advertise
- Start QUIC server. - Start QUIC server.
- Advertise via mDNS with TXT records: - Advertise via mDNS with TXT records:
- `peer_id` (stable ID, not tied to IP) - `peer_id` (the canonical SPKI-derived ID)
- `proto_ver` - `proto_ver`
- `library_rev` (monotonic local library revision)
- optional `hostname` - optional `hostname`
### 2) Discovery and handshake ### 2) Discovery and handshake
When a peer is discovered: When a peer is discovered:
1. Connect and send 1. Parse `peer_id`, address, and `proto_ver` into a candidate `PeerEndpoint`.
`Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`. Discovery is not authentication and does not add the peer to `PeerGameDB` or
`listen_addr` is mandatory; the QUIC source port is only a temporary emit UI membership events. The mDNS ingress queue and active candidate
transport port and must not be recorded as the peer's listener. negotiations are each capped at 64.
2. Receive 2. Reserve a candidate negotiation lease before queueing or awaiting work, then
`HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`. establish a TLS-pinned connection to that exact endpoint. Missing or non-v8
3. If the remote `peer_id` is already known but the address changed, update it. records are rejected, and neither an ephemeral QUIC source port nor a payload
4. If protocol versions are incompatible, drop the peer (and keep mDNS can replace the candidate listener address.
watching). 3. Send the empty `Hello` pull request. The pinned responder returns a
5. If library digests match, do nothing else. `HelloSnapshot` containing a `PeerStateSnapshot` with `runtime_session_id`,
6. If digests differ: `library`, and `call_to_play`: only that responder's local library and local
- If we have a known `library_rev` for that peer, request `LibraryDelta`. Call-to-Play author slice.
- Otherwise request `LibrarySnapshot`. 4. Decode the strict bounded wire shape, then validate the library and
Call-to-Play domains independently. Commit the endpoint and each valid domain
only if the candidate lease is still current for both peer ID and address.
This domain isolation lets an invalid remote Call-to-Play slice be cleared or
preserved according to its session without discarding a valid library.
5. Assign a fresh endpoint generation to every successful authentication.
Dropped, failed, or superseded work releases its candidate claims, and a late
result cannot mutate a newer generation.
### 3) Steady state ### 3) Steady state
- Any message updates `last_seen`. - Successful liveness probes update `last_seen` only when both the typed
- Pings run only when idle (or on a longer interval), not every 5 seconds. endpoint and its authenticated generation are still current. Stale probes
- Library updates are pushed as deltas, debounced and coalesced. cannot refresh or remove a reauthenticated endpoint.
- Call to Play actions are broadcast as immutable, uniquely identified events. - `Ping` receives a `Pong` carrying `PeerRevisions` with the runtime-session ID
and both domain revisions. A session or revision mismatch schedules a full
pinned pull, so idle liveness heals dropped change hints.
- Local library or Call-to-Play changes send cheap revision hints. Hints are
untrusted and lossy: unknown claims are ignored, and a known claim can only
coalesce a pull from the claimed peer's already authenticated endpoint.
- The state-sync scheduler caps its ingress and tracked peers at 64, coalesces
work for five seconds, and runs at most eight pinned pulls and eight hint
sends concurrently. Child work is drained lexically on shutdown.
- The server admits at most 64 unauthenticated handshakes, 64 established
connection scopes, and 32 control streams per connection. Each control stream
accepts one frame followed by request EOF and sends at most one response; the
frame cap is 8 MiB and control I/O has ten-second deadlines.
### Call to Play replication ### Call to Play replication
Call to Play is transient peer-session state rather than database state. The Call to Play is transient, responder-owned state. Each peer serves exactly one
peer keeps a bounded event history, deduplicated by event ID. Every event and local author snapshot:
chat message remains in the snapshot for the full lifetime of an active call, so
a peer joining mid-call receives the complete context. A creator's `Start` or
`Cancel` makes the call terminal and read-only, but its complete roster and chat
history remain in snapshots for 15 minutes so late joiners can see the outcome.
After that display window the history compacts to the Start or Cancel tombstone
for the rest of the peer session. Active and recently terminal calls are never
partially trimmed. If genuinely active history reaches the bound, local
publishes return an error to the caller instead of appearing to succeed;
terminal histories and tombstones do not consume that active-history capacity. A
call whose deadline elapses remains available for five minutes so the creator
can start or extend it, then its unresolved history is evicted as a unit. A
local action is applied to that history, sent to the UI, and broadcast to every
currently known peer. An incoming live event is applied once and sent to the UI
without being rebroadcast, which prevents forwarding loops.
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate, ```text
and obsolete events need no follow-up. An unknown envelope peer, missing call CallToPlayAuthorSnapshot { revision, display_name, events }
root, transport failure, or malformed acknowledgement makes the sender perform CallToPlayAuthorEvent { id, call_id, at, action }
one normal `Hello` / `HelloAck` exchange with that peer. The handshake carries ```
the full retained history in both directions, so a transient request failure
heals without waiting for mDNS rediscovery or a later reconnect. A rejected
event is logged without retry. Local publication remains successful while this
healing happens asynchronously, so an offline peer cannot block an action.
Actors are keyed by the peer's stable ID and carry a separate display name. The The outer `PeerStateSnapshot` owns the runtime-session ID. A `CallId` encodes
origin peer overwrites the actor ID on local actions. A live-event envelope must the creator's typed `PeerId` plus a 128-bit nonce. The UI supplies only a local
name a peer already in the receiver's roster, and every enclosed actor ID must intent and current display name; the peer core generates call/event nonces and
match that envelope. This prevents accidental identity mixing and protects timestamps. Identity and display name are not repeated inside events. Creator
creator controls from other normal clients. It is not authentication against a actions (`Create`, `Start`, `Cancel`, and `AddTime`) must come from the
hostile LAN peer: all peers use the shared application TLS identity, and stable `CallId.creator`; participant authors may contribute only participant actions.
peer IDs are self-asserted under the project's trusted-LAN model.
`Hello` and `HelloAck` include each side's event history. This lets peers that The receiver assigns author identity from the pinned `PeerEndpoint`, validates
join after a call was created reconstruct the same nominations, responses, and normalizes the whole author slice off-lock, then commits it against that
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream endpoint's current generation and runtime session. For the same session, only a
deterministically and derives deadlines and check-in phases from timestamps. higher revision replaces the slice; an equal identical snapshot is a no-op, an
Those phases compare creator-supplied wall-clock timestamps with each viewer's equal different snapshot is a conflict, and a lower revision is stale. A new
local wall clock, so LAN machines are assumed to be synchronized closely enough authenticated session clears the previous slice before accepting valid new
for human-scale minute countdowns; clock skew shifts the displayed boundary by state. Invalid same-session state preserves the last valid slice, while invalid
the same amount. There is deliberately no compatibility path for older protocol new-session state leaves that author absent.
versions.
Participant events are retained but hidden until the creator's direct author
slice supplies the call root. Removing a participant removes only that author's
rows; removing the creator hides the entire call. Because every snapshot is
served only by its author and attributed from the pinned responder, there is no
union merge, relay, live-event push, acknowledgement, or author claim on the
wire. The UI receives a deterministic complete replacement view.
Unresolved call history is removed as a whole after the deadline plus five
minutes; terminal history is removed as a whole after `Start` or `Cancel` plus
15 minutes. No tombstone survives pruning. Pruning a local slice advances its
revision before a snapshot or `Pong` is served, allowing peers to discover the
removal. The store caps total authors at 64, events per author at 4,096, and one
encoded author snapshot at 4 MiB, with reserved capacity for terminal actions.
### 4) Shutdown ### 4) Shutdown
- Optional `Goodbye { peer_id }` lets others remove the peer quickly. - Cancellation stops admission, drains all lexically owned connection, stream,
- If a peer vanishes without goodbye, stale timeout + ping removal handle it. state-sync, operation, and mDNS children, closes the shared endpoints, and
- Goodbye is a hint, never required for correctness. joins the supervisor.
- There is no `Goodbye` control message. Pinned liveness failure and stale
generation-conditional removal are authoritative for departure.
## Library sync protocol ## Library sync protocol
### Summary and snapshot The responder's full library slice is
`LibrarySnapshot { revision, games: Vec<GameAvailability> }`, where each sorted,
unique availability row contains only `game_id` and the catalog-derived exact
`ContentId`. A snapshot contains at most 4,096 games. It does not carry titles,
versions, paths, sizes, file lists, hashes chosen by the peer, installed state,
or another peer's availability.
- `LibrarySummary { peer_id, summary: { library_rev, library_digest, game_count } }` The empty `Hello` request always returns the responder's current complete slice.
- `LibrarySnapshot { peer_id, snapshot: { library_rev, games: Vec<GameSummary> } }` For the same runtime session, a higher revision atomically replaces the cached
slice; lower or equal stale work cannot roll it back. A new authenticated
runtime session resets the old domain before its candidate snapshot is
considered. `LibraryChanged(ChangeHint)` carries only an untrusted claimed
identity, runtime session, and revision. It never mutates a library directly and
has no delta payload. A pinned `Pong` revision mismatch schedules the same full
pull.
### Delta updates ## Local library publication
- `LibraryDelta { peer_id, delta: { from_rev, to_rev, added, updated, removed } }` - Only real local availability changes advance the revision and schedule a hint;
- `removed` is a list of game IDs. there is no periodic list broadcast.
- Deltas are idempotent; ignore if `to_rev` <= known rev.
### GameSummary (concept)
- `id`, `name`, `eti_version`, `size`, `downloaded`, `installed`
- `manifest_hash` (hash of file list + sizes)
- `availability` (e.g., `ready`, `downloading`, `local_only`)
## When peers broadcast their game list
- Only on changes, not on a timer.
- Filesystem events are gated per game ID instead of time-debounced: - Filesystem events are gated per game ID instead of time-debounced:
- an active operation lock drops events for that game; - an active operation lock drops events for that game;
- a rescan already running for the ID sets a rescan-pending flag; - a rescan already running for the ID sets a rescan-pending flag;
- the running rescan loops once more when that flag was set. - the running rescan loops once more when that flag was set.
- Local library scans emit `LocalLibraryChanged` only for real library changes, - Local library scans emit `LocalLibraryChanged` only for real library changes,
except that accepted game-directory changes can force a UI snapshot for the except that accepted game-directory changes can force a UI snapshot for the
new path without sending a peer delta. new path without changing peer availability.
- Active operation mutations emit `ActiveOperationsChanged` from the mutation - Active operation mutations emit `ActiveOperationsChanged` from the mutation
path instead of riding on local library scans. path instead of riding on local library scans.
- Send `LibraryDelta` to known peers; send `LibrarySummary` on new connections. - The remote UI projection is a wholesale replacement derived from all current
authenticated per-peer slices; two peers offering the same game remain
distinct exact-content sources.
## Local game scanning: fast and low cost ## Local game scanning: fast and low cost
### Strategy ### Strategy
1. Maintain a persistent on-disk index (per game): 1. Maintain a persistent revisioned on-disk index of local summaries and cheap
- `manifest_hash`, total size, file list (optional), and a fingerprint per-game fingerprints (root-level `version.ini` contents/mtime, root-level
(root-level `version.ini` mtime, root-level `.eti` mtime/size, and `local/` `.eti` name/size/mtime, `local/` presence, and recovery state).
directory presence). 2. Poll a bounded non-recursive metadata snapshot once per second and update
2. Use filesystem watchers to update only changed games. only changed games.
3. Keep a 300-second fallback scan to recover from missed events. 3. Keep a 300-second fallback scan to recover from missed events.
### Fast-path scanning ### Fast-path scanning
@@ -150,7 +196,7 @@ versions.
- root-level `.eti` file names, sizes, and mtimes - root-level `.eti` file names, sizes, and mtimes
- root-level `version.ini` mtime - root-level `version.ini` mtime
- presence of `local/` as a directory - presence of `local/` as a directory
- If fingerprint unchanged, reuse cached size and manifest hash. - If the fingerprint is unchanged, reuse the cached local summary.
- Only run a recursive scan for new or changed games. - Only run a recursive scan for new or changed games.
## Local State and Recovery ## Local State and Recovery
@@ -179,11 +225,14 @@ Reserved per-game paths:
flight. flight.
- `games/<game_id>/install_intent.json` in the configured state directory is the - `games/<game_id>/install_intent.json` in the configured state directory is the
atomic per-game intent log. atomic per-game intent log.
- `games/<game_id>/download_ownership.json` in that state directory records the - `games/<game_id>/download_ownership/v1-<root_digest>/record.json` in that
last committed and any pending downloader-owned regular-file set. The record state directory records the last committed and any pending downloader-owned
is bound to one canonical configured games directory. regular-file set and its exact catalog content ID. The namespace is derived
from one canonical configured games directory, and the record retains the full
lossless root identity. Same-ID state for different configured roots therefore
remains independent.
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership - `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
when the current intent is `None`. when no active intent independently proves transaction ownership.
Downloaded-file removal is not an uninstall transaction. For a catalog ID that Downloaded-file removal is not an uninstall transaction. For a catalog ID that
is a single direct child of the configured game directory, it requires a valid is a single direct child of the configured game directory, it requires a valid
@@ -192,12 +241,18 @@ ownership record and regular root-level `version.ini`, and refuses `local/`,
generation, deletes only committed downloader-owned files and the sentinel, and generation, deletes only committed downloader-owned files and the sentinel, and
keeps the game root plus every unknown file or directory. keeps the game root plus every unknown file or directory.
Recovery reads app-state `install_intent.json` and combines the recorded intent The state layout is game-first: root namespaces live under each game ID rather
with the observed `local/`, `.local.installing/`, and `.local.backup/` state. than under a global root tree. A former singleton ownership record is migrated
Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of once into its derived namespace with copy-first durable publication. Singleton
the corresponding reserved directories even if the marker was not flushed before state is not accepted as a parallel runtime format, and conflicting or split
a crash. With intent `None`, markerless `.local.*` directories are left migration evidence fails closed.
untouched.
Recovery reads app-state `install_intent.json` and combines the recorded active
intent with the observed `local/`, `.local.installing/`, and `.local.backup/`
state. Intent states `Installing`, `Updating`, and `Uninstalling` prove
ownership of the corresponding reserved directories even if the marker was not
flushed before a crash. Settled state is represented by absence of the intent;
markerless `.local.*` directories are then left untouched.
Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`, Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`,
`.softlan_game_installed`, and `local/.softlan_first_start_done` files are `.softlan_game_installed`, and `local/.softlan_first_start_done` files are
@@ -210,21 +265,49 @@ Most scans become O(number of game dirs), with full recursion only when needed.
## File manifests and downloads ## File manifests and downloads
- Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated - Tauri and the peer CLI inject one immutable `CatalogBundle`: `game.db` defines
calls can be skipped when unchanged. catalog identity/version, the mandatory compact
- The complete remote description is converted into a `manifests/catalog-content-index-v1.jsonl` maps every exact catalog row to its
`ValidatedDownloadManifest` before any destination mutation. It contains expected `ContentId` and Stream Install capability, and
canonical game-root-relative paths and rejects aliases, reserved state, shape `manifests/<game_id>.json` defines canonical paths, kinds, sizes, 128 MiB
conflicts, and bounded-size violations as one unit. chunks, and BLAKE3 values. The index has exact database coverage and is loaded
once; manifest bodies remain on demand and must recompute both indexed fields.
Disk body loads and full validation recheck the durable publication marker
before and after their work. Runtime peers cannot add catalog entries or
replace that byte authority.
- Authenticated remote-availability joins use the compact index only. Unknown or
mismatched `(game_id, content_id)` pairs are rejected without filesystem I/O
or manifest parsing; actual serving, download planning, and Stream Install
still load and validate the full body.
- The local catalog artifact is converted into a `ValidatedDownloadManifest`
before any destination mutation. It contains canonical game-root-relative
paths and rejects aliases, reserved state, shape conflicts, and bounded-size
violations as one unit. Remote peers never define the storage plan.
- Source selection requires the exact catalog `(game_id, content_id)` pair from
an authenticated peer's current library slice. Every ordinary chunk request
repeats the exact `ContentId`, `CanonicalCatalogPath`, offset, and length.
There is no version-only or address-only eligibility adapter.
- A new manifest target that already exists without prior committed ownership is - A new manifest target that already exists without prior committed ownership is
rejected before the sentinel, ownership journal, or payload is mutated. rejected before the sentinel, ownership journal, or payload is mutated.
- Download mutation holds a capability handle for the direct catalog game root. - Download mutation holds a capability handle for the direct catalog game root.
Directory components and final files are reopened relative to that handle Directory components and final files are reopened relative to that handle
without following links or Windows reparse points; chunk writes and checks use without following links or Windows reparse points; chunk writes and checks use
the same opened file handle. the same opened file handle.
- Downloads remain chunked QUIC streams with the existing integrity checks. - Downloads hash every chunk, including the buffered `version.ini`, as it is
received and require the catalog BLAKE3 value, exact length, and exact end of
stream. One absolute ten-minute application deadline covers the complete
open/request/receive/check lifecycle of each ordinary chunk.
- An integrity failure quarantines `(PeerId, content_id)` in shared memory for
the rest of that peer runtime, independent of address changes. Transport
failures remain retryable without quarantine. Each failed chunk may try every
distinct eligible peer identity once; there is no separate numeric retry cap.
- A no-transfer local shortcut is allowed only when settled ownership records
the exact expected committed content ID. A matching `version.ini` alone, or a
legacy/pre-content-ID record, is not catalog-content proof.
- A game is transferable only when its ID is in the catalog, no operation is - A game is transferable only when its ID is in the catalog, no operation is
active for that ID, and the root-level `version.ini` sentinel exists. active for that ID, and the root-level `version.ini` sentinel exists.
- The sender admits only an exact catalog path and, for ranged requests, an
exact catalog chunk range before opening the retained no-follow file handle.
- `local/` paths are never served, even if a stale or malicious manifest request - `local/` paths are never served, even if a stale or malicious manifest request
asks for them. asks for them.
- Cancelling or recovering a download removes only paths named by its durable - Cancelling or recovering a download removes only paths named by its durable
@@ -236,65 +319,69 @@ Most scans become O(number of game dirs), with full recursion only when needed.
- Low-disk streamed installs request archive-derived file bytes from one peer - Low-disk streamed installs request archive-derived file bytes from one peer
and write them directly into the install transaction staging directory. and write them directly into the install transaction staging directory.
- The receiver verifies every streamed file against the sender archive's file - A sender admits Stream Install only for a manifest with verified extracted
size and RAR CRC32 before the transaction may commit. This catches truncated output and only when its direct regular root `.eti` set exactly equals the
streams, transport corruption, and provider bugs. catalog archive set. Missing or extra archives stop before the extraction
- This is not malicious-peer protection: the peer controls both the archive provider receives authority.
metadata and the streamed bytes. A trusted-content model needs catalog-owned - The Stream Install request names the exact catalog `ContentId`. All
hashes, either for the root archives or for extracted files, and receiver-side path-bearing frames, including archive names, use `CanonicalCatalogPath` and
SHA-256 verification against those catalog values before commit. the fallible checked decoder distinguishes malformed framing from an explicit
sender `Error` frame.
- The receiver independently requires the exact catalog archive set and the
complete extracted path/kind/size set. It hashes every regular file with
BLAKE3 while writing and verifies every expected entry before commit. Regular
files may occur only once across the entire archive set; repeated directory
entries are allowed because they carry no bytes. RAR CRC32 remains only an
early corruption signal, not an authority boundary.
- Integrity failures quarantine the source for that catalog content and retry a
fresh transaction from another eligible peer. Transport failures retry without
quarantine; local I/O and cancellation stop. Every failed attempt rolls back
before the next source begins.
- After complete catalog verification, account/language/persona settings are
rewritten in staging before promotion. The one-shot launch-settings marker is
written only after successful promotion; if it cannot be written, first play
safely retries the rewrite.
- A game without a verified extracted-file manifest neither offers nor accepts
Stream Install. There is no CRC32-only fallback.
## Catalog publication and packaging
- `lanspread-catalog-publisher` generates production manifests and their
complete compact identity index beside the canonical packages, independently
rebuilds/verifies each selected artifact, and uses a durable corpus marker so
an interrupted body/index publication fails closed. Incremental generation
requires an existing exact indexed corpus and atomically republishes the full
index after the selected bodies. After it owns the marker, it freshly
validates every unselected body/index pair before deriving that mixed index;
no pre-marker snapshot can roll another completed publication backward.
- `lanspread-fixture-catalog` is a separate test-only generator. It derives
reduced peer-CLI `game.db` files and manifests from explicitly selected
fixtures; those outputs are development and acceptance-test authority only.
- Tauri fixture builds require both the exact committed development resource map
and `LANSPREAD_USE_FIXTURE_CATALOG=1`. Every other build mode defaults to the
production resource map, and the custom production profile cannot be
downgraded to fixtures.
- Production packaging requires the complete production `game.db`/manifest
corpus to pass `check --all`. The canonical production packages and their 186
generated manifests are not present in this checkout, so that gate remains
intentionally blocked; fixture success is not production completion.
## Fault tolerance rules ## Fault tolerance rules
- Every peer is keyed by `peer_id`, not by IP address. - Every authenticated peer is keyed by `PeerId`, not by IP address.
- Peer addresses are listener addresses from mDNS or `Hello`/`HelloAck`, never - An authenticated `PeerEndpoint` retains the candidate or explicitly supplied
ephemeral QUIC source ports. listener address. Payload fields and ephemeral QUIC source ports cannot
- `library_rev` is monotonic and guards against out-of-order updates. rewrite it, and no network caller can dial an address without the expected
- Any mismatch or missing delta falls back to `LibrarySnapshot`. responder ID.
- Loss of goodbye is harmless; stale timeout is authoritative. - Every successful authentication assigns a fresh endpoint generation. Refresh,
liveness update, and stale removal are conditional on the exact endpoint and
## Roadmap from current design to this one generation they observed.
- Runtime-session IDs and per-domain revisions prevent delayed snapshots from
1. Protocol updates in `lanspread-proto`: rolling state back across restart or endpoint replacement. Change hints never
- Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`, bypass the pinned pull and generation checks.
`LibraryDelta`, and optional `Goodbye` messages. - A generation-conditional authenticated departure removes that peer's library
- Thread `peer_id`, `library_rev`, and `manifest_hash` through all library and Call-to-Play slices and publishes complete replacement views. Ordinary
and manifest-bearing types. short-lived QUIC connection closure is not roster departure.
- Make `Hello` and `HelloAck` carry the sender's `listen_addr`, - Protocol 8 is strict and current-only. Unknown fields, noncanonical typed IDs
`library_rev`, and `library_digest` so both sides can record stable or paths, duplicate/unsorted library rows, oversized domains, extra control
listener addresses and immediately select `LibraryDelta` vs frames, and v7 messages are rejected instead of adapted.
`LibrarySnapshot`.
2. Peer identity:
- Persist a stable `peer_id` (UUID) in the peer config and inject it into
`PeerInfo` and `PeerGameDB` at startup.
- Track `peer_id -> SocketAddr` in the discovery table and update the address
on any incoming handshake or mDNS refresh.
3. Discovery handshake:
- Publish `peer_id` and `library_rev` in mDNS TXT records to avoid immediate
TCP/QUIC roundtrips when nothing changed.
- Add a lightweight handshake in `run_peer_discovery` that exchanges
`Hello`/`HelloAck` before any library sync.
- Ignore peers that do not advertise the current protocol version.
4. Library revisioning:
- Store a monotonic `library_rev` locally and increment only after a
successful index refresh completes.
- Apply `LibraryDelta` when `library_rev` matches; reject stale or future
revisions and request `LibrarySnapshot` instead.
- Cache the last accepted `manifest_hash` per peer to short-circuit manifest
requests when unchanged.
5. Local index + scan optimizations:
- Use the cached `local_library/index.json` file in the configured state
directory to store per-root fingerprints and computed manifests.
- Use filesystem watchers with a debounce window to collect changes and
incrementally update the cache.
- Schedule a low-frequency full scan to reconcile missed watcher events.
6. Announce updates:
- Broadcast `LibraryDelta` updates keyed by `library_rev`.
- Send `LibrarySummary` on new connections to seed the delta flow.
7. File manifest caching:
- Store per-game `manifest_hash` and only fetch details when changed.
8. Liveness:
- Reduce ping frequency; update `last_seen` on any message.
- Add optional `Goodbye` on shutdown paths.
9. Tests:
- Delta apply/merge, rev ordering, manifest hashing, and scan cache behavior.
+169 -78
View File
@@ -10,37 +10,45 @@ It is designed to run headless other crates (most notably
- `start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)` boots the - `start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)` boots the
asynchronous runtime in the background and returns a `PeerRuntimeHandle` whose asynchronous runtime in the background and returns a `PeerRuntimeHandle` whose
sender controls the peer. The injected `Unpacker` keeps archive extraction out 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 of the peer crate's platform layer, and the immutable `CatalogBundle` gates
roots are announced or served. which local game roots are announced or served.
- `PeerCommand` represents the small control surface exposed to the UI layer: - `PeerCommand` is the UI-facing control surface for complete library views,
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`, exact-content downloads and installs, game-directory changes, direct typed
`StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, endpoint connections, and locally authored Call-to-Play intents.
`CancelDownload`, `SetGameDir`, and `GetPeerCount`.
- `PeerEvent` enumerates everything the peer runtime reports back to the UI: - `PeerEvent` enumerates everything the peer runtime reports back to the UI:
library snapshots, download/install/uninstall lifecycle updates, runtime wholesale remote-library and Call-to-Play views, download/install/uninstall
failures, and peer membership changes. lifecycle updates, runtime failures, and authenticated peer membership
- `PeerGameDB` collects remote peer metadata. It aggregates discovered peers changes.
`Game` definitions, tracks the latest ETI version per title, and keeps the - `PeerGameDB` collects metadata only for TLS-authenticated peer endpoints. It
last seen list of `GameFileDescription` entries for each peer. records the endpoint generation, runtime session, per-domain revisions, and
exact `GameAvailability { game_id, content_id }` rows supplied by that pinned
responder. It does not accept remote file manifests or descriptions.
Internally the peer runtime owns four long-lived tasks that run for the lifetime Internally the peer runtime owns five coordinated services that run for the
of the process: lifetime of the process:
1. **Server component** (`run_server_component`) listens for QUIC connections, 1. **Server component** (`run_server_component`) listens for QUIC connections,
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`, advertises via mDNS, and serves bounded `Ping`, `Hello`, change-hint,
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and exact-content chunk, and Stream Install requests.
`Request::StreamInstall` by reading from the local game directory.
2. **Discovery loop** (`run_peer_discovery`) uses the `lanspread-mdns` helper 2. **Discovery loop** (`run_peer_discovery`) uses the `lanspread-mdns` helper
to discover other peers. The blocking mDNS work is executed on a dedicated to observe candidate endpoints. A dedicated joinable OS thread owns the
thread via `tokio::task::spawn_blocking` so that the Tokio runtime remains blocking browser. Its nonblocking ingress queue and the async set of active
responsive. candidate negotiations are each bounded at 64; repeated observations that
overflow ingress are coalesced by dropping the hint.
3. **Ping service** (`run_ping_service`) periodically issues QUIC ping 3. **Ping service** (`run_ping_service`) periodically issues QUIC ping
requests to keep peer liveness up to date and prunes stale entries from requests to keep peer liveness up to date and prunes stale entries from
`PeerGameDB`. `PeerGameDB`. Every probe captures the authenticated endpoint generation;
4. **Local game monitor** (`run_local_game_monitor`) watches the configured success, failure, and stale removal are conditional on that exact endpoint
game directory and each game root non-recursively, gates per-ID rescans while and generation, so delayed work cannot affect a reauthenticated peer.
operations are active, emits local-library changes separately from active 4. **Local game monitor** (`run_local_game_monitor`) polls a bounded
operation snapshots, and runs a 300-second fallback scan for missed events. non-recursive metadata snapshot of the configured game directory and its
direct game roots once per second, 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 broader reconciliation.
5. **State-sync scheduler** (`run_state_sync`) coalesces lossy revision hints,
compares authenticated `Pong` revisions, and performs bounded `Hello` pulls
from exact pinned responders. Its queue and peer table are each capped at 64
entries, with at most eight pulls and eight hint sends running concurrently.
`scan_local_library` maintains a lightweight on-disk index and produces both a `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 `GameDB` and protocol summaries. A game is downloaded only when its root-level
@@ -48,65 +56,134 @@ of the process:
## Networking and File Transfer ## Networking and File Transfer
- Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic); TLS - Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic) with its
cert/key material is compiled in from the repository root. rustls provider. Every outbound operation requires a typed
- Protocol messages are JSON-encoded structures defined in `PeerEndpoint { peer_id, addr }` and TLS 1.3 pins the responder's complete DER
`lanspread-proto::{Request, Response}`. SPKI to that expected ID before delegating the real CertificateVerify
signature to rustls. ALPN is `lanspread/8`; resumption, tickets, 0-RTT, and
half-RTT data are disabled.
- Protocol messages are strict, fallible JSON structures defined in
`lanspread-proto::{Request, Response}`. Protocol 8 is the only accepted wire
shape. A control stream carries exactly one request frame followed by EOF and
at most one response; frames are capped at 8 MiB and control I/O is bounded by
ten-second deadlines.
- File transfers stream raw bytes over dedicated bidirectional QUIC streams. - 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 catalog-authorized ranges. The bounded
`peer::send_game_file_chunk` services ranged requests. server admits at most 64 unauthenticated handshakes, 64 established connection
scopes, and 32 control-stream tasks per connection.
Each installation owns one Ed25519 key and one self-issued certificate. `PeerId`
is lowercase unpadded RFC 4648 base32 of BLAKE3 over the certificate's complete
canonical DER SPKI. The desktop app loads or creates the strict version-1
`peer-identity-v1.json` record in Tauri's app-data directory. Record loading
checks the exact version, algorithm, canonical base64, size bounds,
certificate/key match, derived SPKI ID, and exact SAN. Opens and publication do
not follow symlinks or reparse points, creation does not clobber an existing
winner, corrupt bytes are preserved in quarantine before replacement, and Unix
private material is mode 0600.
Read, permission, quarantine, or write failure returns a typed `Ephemeral`
persistence outcome and uses a fresh identity for that runtime. The peer CLI's
`--identity-file` selects an existing record strictly and never repairs,
quarantines, replaces, or generates that explicit path. Identity persistence
supports one normal app-data owner and has no cross-process lease; a concurrent
same-user writer can cause availability or continuity loss, but retained
no-follow handles contain traversal and publication never overwrites a
concurrent winner.
### Responder-owned state and Call to Play
`Hello` is an empty pull request. A successful pinned responder returns only its
own `PeerStateSnapshot`: one runtime-session ID, a revisioned local library
slice, and a revisioned local Call-to-Play author slice. The receiver validates
the library and Call-to-Play domains independently and commits them only while
the authenticated endpoint generation is still current. A peer never relays
another peer's library or Call-to-Play state.
Local changes send cheap `LibraryChanged` or `CallToPlayChanged` revision hints.
Hints are untrusted and lossy: an unknown claimed peer is ignored, and a known
hint can only coalesce a full pull from that peer's pinned endpoint. `Pong`
carries the responder's runtime session and both revisions, so idle liveness
also heals a dropped hint.
Call-to-Play is stored as bounded per-author slices. The core, not the UI,
creates event/call nonces and timestamps. Wire events contain a typed `CallId`,
event ID, timestamp, and action; author identity comes from the pinned responder
and the display name belongs to the author snapshot. Whole snapshots replace a
newer revision from the same runtime session. Participant events may be retained
but remain hidden until the call creator's direct author slice is present; no
event push, acknowledgement, union merge, relay, or tombstone path exists.
### Download Pipeline ### Download Pipeline
When the UI asks to download a game: When the UI asks to download a game:
1. The UI first issues `PeerCommand::GetGame` for a new download, or 1. The UI submits only the game ID. The peer core loads that game's immutable
`PeerCommand::FetchLatestFromPeers` for an update that must bypass local `CatalogContentManifest` and constructs one root-confined
archives. The selected peers are queried via `ValidatedDownloadManifest` before any filesystem mutation. Remote file
`request_game_details_from_peer`, and their file manifests are merged inside descriptions do not choose local paths, sizes, chunks, or hashes.
`PeerGameDB`. 2. Source selection requires a peer to advertise the exact catalog
2. Once the UI receives `PeerEvent::GotGameFiles`, it requests the download by `(game_id, content_id)` pair. Every chunk request repeats that `ContentId`, a
game ID only. The peer core validates every source manifest before consensus, typed canonical catalog path, and the exact catalog range; a version string
chooses the complete authoritative description, and constructs one alone never makes a peer eligible.
root-confined `ValidatedDownloadManifest` before any filesystem mutation.
3. `download_game_files` recovers any earlier attempt, parks an old 3. `download_game_files` recovers any earlier attempt, parks an old
`version.ini` as `.version.ini.discarded`, and durably journals the exact old `version.ini` as `.version.ini.discarded`, and durably journals the exact old
and proposed downloader-owned file sets before preparing non-sentinel files. and proposed downloader-owned file sets and their catalog content IDs before
It then emits `PeerEvent::DownloadGameFilesBegin` and builds a per-peer plan preparing non-sentinel files. It then emits
(`build_peer_plans`) that round-robins file chunks across the available peers `PeerEvent::DownloadGameFilesBegin` and builds a per-peer plan
that advertise the latest version. (`build_peer_plans`) that round-robins catalog chunks across the eligible
peers.
4. Each plan is executed in its own task (`download_from_peer`). Chunk requests 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 use per-chunk QUIC streams and write into pre-created files. Every chunk,
keeps existing data intact and only truncates when we intentionally fall back including `version.ini`, must have the catalog length and BLAKE3 digest. One
to a full file transfer, which prevents corruption when multiple peers fill absolute ten-minute deadline covers opening, requesting, receiving, and the
different regions of the same file. post-receive checks for each ordinary chunk.
5. `DownloadProgressTracker` samples byte counters, transfer speed, and the 5. `DownloadProgressTracker` samples byte counters, transfer speed, and the
number of unique peers that are actively streaming chunks. The Tauri UI sees number of unique peers that are actively streaming chunks. The Tauri UI sees
those values together through the regular download-progress event. those values together through the regular download-progress event.
6. `version.ini` chunks are buffered in memory. After transfer, paths owned by 6. Integrity failures quarantine the runtime-local `(PeerId, content_id)` pair;
changing the peer's address does not clear it. Transport failures remain
retryable and do not create durable trust state. A failed chunk is tried at
most once against every distinct eligible peer identity, with no separate
numeric retry cap.
7. `version.ini` chunks are buffered in memory. After transfer, paths owned by
the previous successful download but absent from the new manifest are the previous successful download but absent from the new manifest are
removed. Payload files and their directories are synced before the new removed. Payload files and their directories are synced before the new
sentinel is committed last via `.version.ini.tmp` followed by an atomic sentinel is committed last via `.version.ini.tmp` followed by an atomic
rename. A sentinel rename whose directory sync fails leaves ownership pending rename. A sentinel rename whose directory sync fails leaves ownership pending
for recovery instead of being reported as a durable success. Transfer for recovery instead of being reported as a durable success.
failures are accumulated and retried (up to `MAX_RETRY_COUNT`) via 8. Failure, cancellation, and startup recovery use the journal to remove only
`retry_failed_chunks`.
7. Failure, cancellation, and startup recovery use the journal to remove only
exact downloader-owned files. Unknown user files, `local/`, and install exact downloader-owned files. Unknown user files, `local/`, and install
transaction state are preserved. A regular `version.ini` beside a pending transaction state are preserved. A regular `version.ini` beside a pending
journal proves that the final rename landed; otherwise recovery aborts the journal proves that the final rename landed; otherwise recovery aborts the
incomplete payload. incomplete payload.
8. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is 9. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is
emitted and the peer auto-runs the install transaction. emitted and the peer auto-runs the install transaction.
The no-transfer local shortcut requires both a catalog-matching local sentinel
and settled ownership whose committed content ID exactly matches the requested
catalog manifest. A version string or pre-content-ID ownership record alone is
never treated as verified local content.
### Streamed Install Pipeline ### Streamed Install Pipeline
Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal
archive download pipeline. The peer core owns the whole operation: it refreshes archive download pipeline. The peer core loads the catalog's extracted-file
file metadata from catalog-version peers, runs the same majority file-size manifest, rejects games that do not have one, selects sources advertising the
validation used by normal downloads, selects a validated peer list, and emits exact catalog `ContentId`, and emits the regular download/install lifecycle
the regular download/install lifecycle events while streaming archive-expanded events while streaming archive-expanded bytes directly into an isolated
bytes directly into a `StreamedInstallTransaction`. `StreamedInstallTransaction`. Every Stream Install control-frame path, including
archive names, is a `CanonicalCatalogPath`; malformed framing is distinct from
an explicit sender error.
The sender admits Stream Install only when its catalog authorizes the feature
and the direct regular root `.eti` set exactly matches the catalog archive set;
missing, extra, or unsupported archives are rejected before provider work
begins. The receiver independently requires the exact archive set and the
complete catalog path/kind/size set, hashes every regular extracted file with
BLAKE3 while writing, and rejects any missing, unknown, repeated, or mismatched
file. Repeated directory entries are harmless and may appear across archives,
but a regular extracted path may appear only once globally.
The sender-side `StreamInstallProvider` writes control and chunk frames through The sender-side `StreamInstallProvider` writes control and chunk frames through
a cancellable `StreamInstallFrameSink`. If the QUIC writer fails because the a cancellable `StreamInstallFrameSink`. If the QUIC writer fails because the
@@ -116,7 +193,10 @@ bounded frame channel and lets the transfer guard drop normally.
Each failed peer attempt rolls back its staging directory before trying the next Each failed peer attempt rolls back its staging directory before trying the next
validated peer. A transaction that created a previously missing game root validated peer. A transaction that created a previously missing game root
removes that root again when rollback leaves it empty. Once staging has been removes that root again when rollback leaves it empty. Once staging has been
renamed to `local/`, post-promote intent or launch-settings cleanup failures are fully verified, the account, language, and persona settings are rewritten in
that staging tree before it is promoted to `local/`. The one-shot settings
marker is written only after successful promotion; if that marker write fails,
first play safely retries the rewrite. Post-promote intent cleanup failures are
logged for startup recovery rather than reported as a failed install. logged for startup recovery rather than reported as a failed install.
`PeerCommand::CancelDownload` cancels the tracked download token for an active `PeerCommand::CancelDownload` cancels the tracked download token for an active
@@ -135,15 +215,22 @@ directories marked by `.lanspread_owned`. Startup recovery combines the recorded
intent with the observed filesystem state and only deletes reserved directories intent with the observed filesystem state and only deletes reserved directories
when intent or marker ownership proves they belong to Lanspread. when intent or marker ownership proves they belong to Lanspread.
Download provenance is stored separately at Download provenance is stored separately under
`games/<game_id>/download_ownership.json` in the peer state directory. It is `games/<game_id>/download_ownership/v1-<root_digest>/record.json` in the peer
bound to the canonical configured games directory so switching library roots state directory. Each namespace is derived from the canonical configured games
cannot make an old record authorize deletion in a different tree. Downloaded- directory, while the record retains the full lossless root identity for
collision and binding checks. Switching roots therefore preserves independent
recovery and removal authority for the same game ID in each tree. Downloaded-
file removal is deliberately separate from uninstall: it refuses installed or file removal is deliberately separate from uninstall: it refuses installed or
in-flight roots, journals an empty pending generation, and deletes only the in-flight roots, journals an empty pending generation, and deletes only the
regular sentinel plus paths proven by the last committed ownership set. Unknown regular sentinel plus paths proven by the last committed ownership set. Unknown
files, directories, and the game root remain untouched. files, directories, and the game root remain untouched.
Ownership schema 2 binds committed and pending file generations to their exact
catalog content IDs. A former singleton ownership record is migrated once into
its derived root namespace using copy-first durable publication; it is not kept
as a second live format. Split or conflicting migration evidence fails closed.
Legacy launcher-owned files in game directories are migrated by a dedicated Legacy launcher-owned files in game directories are migrated by a dedicated
pre-start phase. Normal install, recovery, scan, and transfer paths use only the pre-start phase. Normal install, recovery, scan, and transfer paths use only the
configured state directory for launcher-owned metadata. configured state directory for launcher-owned metadata.
@@ -154,14 +241,15 @@ The Tauri application embeds this crate in
`crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`: `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`:
- `LanSpreadState` holds onto the peer control channel, the latest aggregated - `LanSpreadState` holds onto the peer control channel, the latest aggregated
`GameDB`, per-game operation state, the catalog set, and the user-selected `GameDB`, per-game operation state, the immutable catalog bundle, and the
game directory. user-selected game directory.
- The Tauri commands (`request_games`, `install_game`, `update_game`, - The Tauri commands (`request_games`, `install_game`, `update_game`,
`remove_downloaded_game`, and `update_game_directory`) translate UI actions `remove_downloaded_game`, and `update_game_directory`) translate UI actions
into `PeerCommand`s. In particular, `update_game_directory` validates the into `PeerCommand`s. Tauri loads and validates the packaged `game.db` plus
filesystem path before storing it, loads the bundled catalog on first use, companion manifests once during setup; `update_game_directory` validates the
kicks off the peer runtime on demand, and mirrors the installed/uninstalled filesystem path before storing it, starts the peer runtime on demand with that
state into the UI-facing database. same bundle, and mirrors installed/uninstalled state into the separate
UI-facing database.
- A background task consumes `PeerEvent`s and fans them out to the front-end via - A background task consumes `PeerEvent`s and fans them out to the front-end via
Tauri publish/subscribe events (`games-list-updated`, `game-download-*`, Tauri publish/subscribe events (`games-list-updated`, `game-download-*`,
`game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only `game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only
@@ -170,14 +258,17 @@ The Tauri application embeds this crate in
## Security & Operational Notes ## Security & Operational Notes
- All QUIC connections are TLS encrypted; the shipped certificates are suitable - Outbound TLS authenticates the exact responder endpoint. Inbound hints carry
for local-network trust but should be rotated for production deployments. no authority, state snapshots are accepted only from a pinned pull, and file
- Peer discovery is restricted to the local link via mDNS. requests are admitted against the sender's local catalog and exact
- Long-running blocking mDNS calls are isolated on dedicated threads which keeps `ContentId`. There is no address-only dial or repository-shared certificate.
the async runtime responsive even when discovery takes a long time. - mDNS is a link-local source of bounded candidates, not roster authority. A
- File writes are chunk-safe: partial chunk downloads open files without candidate reaches peer/UI/library state only after the pinned outbound
truncating existing data, and root-level `version.ini` is written only after handshake commits its still-current negotiation lease.
the rest of the download has succeeded. - Long-running blocking mDNS calls are isolated on an owned worker thread whose
shutdown is explicitly joined.
- File writes are catalog-verified per chunk, and root-level `version.ini` is
buffered and written only after the rest of the download has succeeded.
## Known Limitations ## Known Limitations
@@ -185,7 +276,7 @@ The Tauri application embeds this crate in
If the UI needs to surface titles that only exist locally, additional merging If the UI needs to surface titles that only exist locally, additional merging
with the locally scanned `GameDB` will be required. with the locally scanned `GameDB` will be required.
- The download planner uses a simple round-robin and does not yet take per-peer - The download planner uses a simple round-robin and does not yet take per-peer
throughput or failures into account when distributing work. throughput into account when distributing initial work.
Refer to the source (particularly `src/lib.rs`) for the exact message shapes and Refer to the source (particularly `src/lib.rs`) for the exact message shapes and
state machines. state machines.
+21 -14
View File
@@ -1,5 +1,8 @@
# Handoff: SoftLAN Launcher redesign # Handoff: SoftLAN Launcher redesign
<!-- Exact layout diagrams and CSS literals intentionally remain unwrapped. -->
<!-- rumdl-disable MD013 -->
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, A modern, gamer-friendly redesign of the SoftLAN local-network game launcher,
replacing the current basic UI with a Steam-inspired dark layout that keeps high replacing the current basic UI with a Steam-inspired dark layout that keeps high
usability while adding cover art, state-coded actions, a game-detail overlay, usability while adding cover art, state-coded actions, a game-detail overlay,
@@ -945,20 +948,24 @@ also seeds a few representative calls on mount (a live call with chat, a fresh
call you started, a scheduled call whose check-in window just opened, and one call you started, a scheduled call whose check-in window just opened, and one
scheduled for later collecting RSVPs). scheduled for later collecting RSVPs).
The production launcher uses the peer's existing QUIC control channel. Each The production launcher sends only local intents over the app boundary. The peer
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action core generates each immutable event's call/event nonces and timestamp, then
is an immutable, uniquely identified event. Connected peers receive new events publishes a bounded revision-change hint to connected peers. A hint is only a
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event liveness optimization: receivers perform an authenticated, identity-pinned
history so a late joiner reconstructs every event and chat message for active `Hello` pull and replace that author's complete bounded slice. Peers do not
calls. Running and Cancelled calls retain their complete history for 15 minutes relay another author's history, so a late joiner must discover, pin, and pull
so late joiners can see the outcome, roster, and chat, then compact to a Start every live author before its wholesale view contains the union of creator,
or Cancel tombstone for the rest of the peer session. Unresolved calls are participant, and chat events.
removed after the five-minute post-deadline recovery period. The frontend
reducer turns that event history into the `Nomination` state above, derives Running and Cancelled calls retain their complete author-owned events for 15
time-based phase changes locally, and prunes retired raw events. Stable peer IDs minutes so late joiners can see the outcome, roster, and chat, then expire from
identify actors and enforce creator controls; `settings.username` is only the the view. Unresolved calls are removed after the five-minute post-deadline
display name. These deadlines use event wall-clock timestamps, so LAN clocks are recovery period. The frontend reducer derives the `Nomination` state and
assumed to be reasonably close; no clock-synchronization protocol is attempted. time-based phase changes from each full replacement. Stable peer IDs identify
authors and enforce creator controls; `settings.username` is a bounded display
name that can update an author's snapshot without creating an event. These
deadlines use event wall-clock timestamps, so LAN clocks are assumed to be
reasonably close; no clock-synchronization protocol is attempted.
--- ---