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:
@@ -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
|
||||
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
|
||||
|
||||
Install Rust, Deno, and `just` first, then bootstrap the project:
|
||||
@@ -33,12 +45,20 @@ Create production bundles:
|
||||
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
|
||||
|
||||
- `just setup` - install the Tauri CLI and frontend dependencies.
|
||||
- `just run` - run the Tauri app in dev mode.
|
||||
- `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 clippy` - lint the Rust workspace.
|
||||
- `just test` - run workspace tests.
|
||||
|
||||
@@ -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
|
||||
current idea: mDNS discovery, QUIC transport, on-demand metadata, and chunked
|
||||
file transfers.
|
||||
The peer uses mDNS discovery, authenticated QUIC transport, responder-owned
|
||||
metadata snapshots, and catalog-authorized file transfers. Wire protocol 8 and
|
||||
ALPN `lanspread/8` are the only supported wire mode; there is no legacy decode,
|
||||
fallback, or compatibility shim.
|
||||
|
||||
## Goals (unchanged)
|
||||
|
||||
@@ -11,136 +12,181 @@ file transfers.
|
||||
- UI drives operations through `PeerCommand`, peers remain headless.
|
||||
- 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
|
||||
|
||||
### 1) Startup and advertise
|
||||
|
||||
- Start QUIC server.
|
||||
- Advertise via mDNS with TXT records:
|
||||
- `peer_id` (stable ID, not tied to IP)
|
||||
- `peer_id` (the canonical SPKI-derived ID)
|
||||
- `proto_ver`
|
||||
- `library_rev` (monotonic local library revision)
|
||||
- optional `hostname`
|
||||
|
||||
### 2) Discovery and handshake
|
||||
|
||||
When a peer is discovered:
|
||||
|
||||
1. Connect and send
|
||||
`Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`.
|
||||
`listen_addr` is mandatory; the QUIC source port is only a temporary
|
||||
transport port and must not be recorded as the peer's listener.
|
||||
2. Receive
|
||||
`HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`.
|
||||
3. If the remote `peer_id` is already known but the address changed, update it.
|
||||
4. If protocol versions are incompatible, drop the peer (and keep mDNS
|
||||
watching).
|
||||
5. If library digests match, do nothing else.
|
||||
6. If digests differ:
|
||||
- If we have a known `library_rev` for that peer, request `LibraryDelta`.
|
||||
- Otherwise request `LibrarySnapshot`.
|
||||
1. Parse `peer_id`, address, and `proto_ver` into a candidate `PeerEndpoint`.
|
||||
Discovery is not authentication and does not add the peer to `PeerGameDB` or
|
||||
emit UI membership events. The mDNS ingress queue and active candidate
|
||||
negotiations are each capped at 64.
|
||||
2. Reserve a candidate negotiation lease before queueing or awaiting work, then
|
||||
establish a TLS-pinned connection to that exact endpoint. Missing or non-v8
|
||||
records are rejected, and neither an ephemeral QUIC source port nor a payload
|
||||
can replace the candidate listener address.
|
||||
3. Send the empty `Hello` pull request. The pinned responder returns a
|
||||
`HelloSnapshot` containing a `PeerStateSnapshot` with `runtime_session_id`,
|
||||
`library`, and `call_to_play`: only that responder's local library and local
|
||||
Call-to-Play author slice.
|
||||
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
|
||||
|
||||
- Any message updates `last_seen`.
|
||||
- Pings run only when idle (or on a longer interval), not every 5 seconds.
|
||||
- Library updates are pushed as deltas, debounced and coalesced.
|
||||
- Call to Play actions are broadcast as immutable, uniquely identified events.
|
||||
- Successful liveness probes update `last_seen` only when both the typed
|
||||
endpoint and its authenticated generation are still current. Stale probes
|
||||
cannot refresh or remove a reauthenticated endpoint.
|
||||
- `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 is transient peer-session state rather than database state. The
|
||||
peer keeps a bounded event history, deduplicated by event ID. Every event and
|
||||
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.
|
||||
Call to Play is transient, responder-owned state. Each peer serves exactly one
|
||||
local author snapshot:
|
||||
|
||||
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
|
||||
and obsolete events need no follow-up. An unknown envelope peer, missing call
|
||||
root, transport failure, or malformed acknowledgement makes the sender perform
|
||||
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.
|
||||
```text
|
||||
CallToPlayAuthorSnapshot { revision, display_name, events }
|
||||
CallToPlayAuthorEvent { id, call_id, at, action }
|
||||
```
|
||||
|
||||
Actors are keyed by the peer's stable ID and carry a separate display name. The
|
||||
origin peer overwrites the actor ID on local actions. A live-event envelope must
|
||||
name a peer already in the receiver's roster, and every enclosed actor ID must
|
||||
match that envelope. This prevents accidental identity mixing and protects
|
||||
creator controls from other normal clients. It is not authentication against a
|
||||
hostile LAN peer: all peers use the shared application TLS identity, and stable
|
||||
peer IDs are self-asserted under the project's trusted-LAN model.
|
||||
The outer `PeerStateSnapshot` owns the runtime-session ID. A `CallId` encodes
|
||||
the creator's typed `PeerId` plus a 128-bit nonce. The UI supplies only a local
|
||||
intent and current display name; the peer core generates call/event nonces and
|
||||
timestamps. Identity and display name are not repeated inside events. Creator
|
||||
actions (`Create`, `Start`, `Cancel`, and `AddTime`) must come from the
|
||||
`CallId.creator`; participant authors may contribute only participant actions.
|
||||
|
||||
`Hello` and `HelloAck` include each side's event history. This lets peers that
|
||||
join after a call was created reconstruct the same nominations, responses,
|
||||
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
||||
deterministically and derives deadlines and check-in phases from timestamps.
|
||||
Those phases compare creator-supplied wall-clock timestamps with each viewer's
|
||||
local wall clock, so LAN machines are assumed to be synchronized closely enough
|
||||
for human-scale minute countdowns; clock skew shifts the displayed boundary by
|
||||
the same amount. There is deliberately no compatibility path for older protocol
|
||||
versions.
|
||||
The receiver assigns author identity from the pinned `PeerEndpoint`, validates
|
||||
and normalizes the whole author slice off-lock, then commits it against that
|
||||
endpoint's current generation and runtime session. For the same session, only a
|
||||
higher revision replaces the slice; an equal identical snapshot is a no-op, an
|
||||
equal different snapshot is a conflict, and a lower revision is stale. A new
|
||||
authenticated session clears the previous slice before accepting valid new
|
||||
state. Invalid same-session state preserves the last valid slice, while invalid
|
||||
new-session state leaves that author absent.
|
||||
|
||||
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
|
||||
|
||||
- Optional `Goodbye { peer_id }` lets others remove the peer quickly.
|
||||
- If a peer vanishes without goodbye, stale timeout + ping removal handle it.
|
||||
- Goodbye is a hint, never required for correctness.
|
||||
- Cancellation stops admission, drains all lexically owned connection, stream,
|
||||
state-sync, operation, and mDNS children, closes the shared endpoints, and
|
||||
joins the supervisor.
|
||||
- There is no `Goodbye` control message. Pinned liveness failure and stale
|
||||
generation-conditional removal are authoritative for departure.
|
||||
|
||||
## 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 } }`
|
||||
- `LibrarySnapshot { peer_id, snapshot: { library_rev, games: Vec<GameSummary> } }`
|
||||
The empty `Hello` request always returns the responder's current complete slice.
|
||||
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 } }`
|
||||
- `removed` is a list of game IDs.
|
||||
- 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.
|
||||
- Only real local availability changes advance the revision and schedule a hint;
|
||||
there is no periodic list broadcast.
|
||||
- Filesystem events are gated per game ID instead of time-debounced:
|
||||
- an active operation lock drops events for that game;
|
||||
- a rescan already running for the ID sets a rescan-pending flag;
|
||||
- the running rescan loops once more when that flag was set.
|
||||
- Local library scans emit `LocalLibraryChanged` only for real library changes,
|
||||
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
|
||||
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
|
||||
|
||||
### Strategy
|
||||
|
||||
1. Maintain a persistent on-disk index (per game):
|
||||
- `manifest_hash`, total size, file list (optional), and a fingerprint
|
||||
(root-level `version.ini` mtime, root-level `.eti` mtime/size, and `local/`
|
||||
directory presence).
|
||||
2. Use filesystem watchers to update only changed games.
|
||||
1. Maintain a persistent revisioned on-disk index of local summaries and cheap
|
||||
per-game fingerprints (root-level `version.ini` contents/mtime, root-level
|
||||
`.eti` name/size/mtime, `local/` presence, and recovery state).
|
||||
2. Poll a bounded non-recursive metadata snapshot once per second and update
|
||||
only changed games.
|
||||
3. Keep a 300-second fallback scan to recover from missed events.
|
||||
|
||||
### Fast-path scanning
|
||||
@@ -150,7 +196,7 @@ versions.
|
||||
- root-level `.eti` file names, sizes, and mtimes
|
||||
- root-level `version.ini` mtime
|
||||
- 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.
|
||||
|
||||
## Local State and Recovery
|
||||
@@ -179,11 +225,14 @@ Reserved per-game paths:
|
||||
flight.
|
||||
- `games/<game_id>/install_intent.json` in the configured state directory is the
|
||||
atomic per-game intent log.
|
||||
- `games/<game_id>/download_ownership.json` in that state directory records the
|
||||
last committed and any pending downloader-owned regular-file set. The record
|
||||
is bound to one canonical configured games directory.
|
||||
- `games/<game_id>/download_ownership/v1-<root_digest>/record.json` in that
|
||||
state directory records the last committed and any pending downloader-owned
|
||||
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
|
||||
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
|
||||
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
|
||||
keeps the game root plus every unknown file or directory.
|
||||
|
||||
Recovery reads app-state `install_intent.json` and combines the recorded 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. With intent `None`, markerless `.local.*` directories are left
|
||||
untouched.
|
||||
The state layout is game-first: root namespaces live under each game ID rather
|
||||
than under a global root tree. A former singleton ownership record is migrated
|
||||
once into its derived namespace with copy-first durable publication. Singleton
|
||||
state is not accepted as a parallel runtime format, and conflicting or split
|
||||
migration evidence fails closed.
|
||||
|
||||
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`,
|
||||
`.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
|
||||
|
||||
- Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated
|
||||
calls can be skipped when unchanged.
|
||||
- The complete remote description 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.
|
||||
- Tauri and the peer CLI inject one immutable `CatalogBundle`: `game.db` defines
|
||||
catalog identity/version, the mandatory compact
|
||||
`manifests/catalog-content-index-v1.jsonl` maps every exact catalog row to its
|
||||
expected `ContentId` and Stream Install capability, and
|
||||
`manifests/<game_id>.json` defines canonical paths, kinds, sizes, 128 MiB
|
||||
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
|
||||
rejected before the sentinel, ownership journal, or payload is mutated.
|
||||
- Download mutation holds a capability handle for the direct catalog game root.
|
||||
Directory components and final files are reopened relative to that handle
|
||||
without following links or Windows reparse points; chunk writes and checks use
|
||||
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
|
||||
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
|
||||
asks for them.
|
||||
- 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
|
||||
and write them directly into the install transaction staging directory.
|
||||
- The receiver verifies every streamed file against the sender archive's file
|
||||
size and RAR CRC32 before the transaction may commit. This catches truncated
|
||||
streams, transport corruption, and provider bugs.
|
||||
- This is not malicious-peer protection: the peer controls both the archive
|
||||
metadata and the streamed bytes. A trusted-content model needs catalog-owned
|
||||
hashes, either for the root archives or for extracted files, and receiver-side
|
||||
SHA-256 verification against those catalog values before commit.
|
||||
- A sender admits Stream Install only for a manifest with verified extracted
|
||||
output and only when its direct regular root `.eti` set exactly equals the
|
||||
catalog archive set. Missing or extra archives stop before the extraction
|
||||
provider receives authority.
|
||||
- The Stream Install request names the exact catalog `ContentId`. All
|
||||
path-bearing frames, including archive names, use `CanonicalCatalogPath` and
|
||||
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
|
||||
|
||||
- Every peer is keyed by `peer_id`, not by IP address.
|
||||
- Peer addresses are listener addresses from mDNS or `Hello`/`HelloAck`, never
|
||||
ephemeral QUIC source ports.
|
||||
- `library_rev` is monotonic and guards against out-of-order updates.
|
||||
- Any mismatch or missing delta falls back to `LibrarySnapshot`.
|
||||
- Loss of goodbye is harmless; stale timeout is authoritative.
|
||||
|
||||
## Roadmap from current design to this one
|
||||
|
||||
1. Protocol updates in `lanspread-proto`:
|
||||
- Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`,
|
||||
`LibraryDelta`, and optional `Goodbye` messages.
|
||||
- Thread `peer_id`, `library_rev`, and `manifest_hash` through all library
|
||||
and manifest-bearing types.
|
||||
- Make `Hello` and `HelloAck` carry the sender's `listen_addr`,
|
||||
`library_rev`, and `library_digest` so both sides can record stable
|
||||
listener addresses and immediately select `LibraryDelta` vs
|
||||
`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.
|
||||
- Every authenticated peer is keyed by `PeerId`, not by IP address.
|
||||
- An authenticated `PeerEndpoint` retains the candidate or explicitly supplied
|
||||
listener address. Payload fields and ephemeral QUIC source ports cannot
|
||||
rewrite it, and no network caller can dial an address without the expected
|
||||
responder ID.
|
||||
- Every successful authentication assigns a fresh endpoint generation. Refresh,
|
||||
liveness update, and stale removal are conditional on the exact endpoint and
|
||||
generation they observed.
|
||||
- Runtime-session IDs and per-domain revisions prevent delayed snapshots from
|
||||
rolling state back across restart or endpoint replacement. Change hints never
|
||||
bypass the pinned pull and generation checks.
|
||||
- A generation-conditional authenticated departure removes that peer's library
|
||||
and Call-to-Play slices and publishes complete replacement views. Ordinary
|
||||
short-lived QUIC connection closure is not roster departure.
|
||||
- Protocol 8 is strict and current-only. Unknown fields, noncanonical typed IDs
|
||||
or paths, duplicate/unsorted library rows, oversized domains, extra control
|
||||
frames, and v7 messages are rejected instead of adapted.
|
||||
|
||||
+169
-78
@@ -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
|
||||
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`,
|
||||
`StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`,
|
||||
`CancelDownload`, `SetGameDir`, and `GetPeerCount`.
|
||||
of the peer crate's platform layer, and the immutable `CatalogBundle` gates
|
||||
which local game roots are announced or served.
|
||||
- `PeerCommand` is the UI-facing control surface for complete library views,
|
||||
exact-content downloads and installs, game-directory changes, direct typed
|
||||
endpoint connections, and locally authored Call-to-Play intents.
|
||||
- `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.
|
||||
wholesale remote-library and Call-to-Play views, download/install/uninstall
|
||||
lifecycle updates, runtime failures, and authenticated peer membership
|
||||
changes.
|
||||
- `PeerGameDB` collects metadata only for TLS-authenticated peer endpoints. It
|
||||
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
|
||||
of the process:
|
||||
Internally the peer runtime owns five coordinated services 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`, `Request::GetGameFileChunk`, and
|
||||
`Request::StreamInstall` by reading from the local game directory.
|
||||
advertises via mDNS, and serves bounded `Ping`, `Hello`, change-hint,
|
||||
exact-content chunk, and Stream Install requests.
|
||||
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.
|
||||
to observe candidate endpoints. A dedicated joinable OS thread owns the
|
||||
blocking browser. Its nonblocking ingress queue and the async set of active
|
||||
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
|
||||
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.
|
||||
`PeerGameDB`. Every probe captures the authenticated endpoint generation;
|
||||
success, failure, and stale removal are conditional on that exact endpoint
|
||||
and generation, so delayed work cannot affect a reauthenticated peer.
|
||||
4. **Local game monitor** (`run_local_game_monitor`) – polls a bounded
|
||||
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
|
||||
`GameDB` and protocol summaries. A game is downloaded only when its root-level
|
||||
@@ -48,65 +56,134 @@ of the process:
|
||||
|
||||
## 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}`.
|
||||
- Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic) with its
|
||||
rustls provider. Every outbound operation requires a typed
|
||||
`PeerEndpoint { peer_id, addr }` and TLS 1.3 pins the responder's complete DER
|
||||
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.
|
||||
`peer::send_game_file_data` sends entire files, while
|
||||
`peer::send_game_file_chunk` services ranged requests.
|
||||
`peer::send_game_file_chunk` services catalog-authorized ranges. The bounded
|
||||
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
|
||||
|
||||
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 requests the download by
|
||||
game ID only. The peer core validates every source manifest before consensus,
|
||||
chooses the complete authoritative description, and constructs one
|
||||
root-confined `ValidatedDownloadManifest` before any filesystem mutation.
|
||||
1. The UI submits only the game ID. The peer core loads that game's immutable
|
||||
`CatalogContentManifest` and constructs one root-confined
|
||||
`ValidatedDownloadManifest` before any filesystem mutation. Remote file
|
||||
descriptions do not choose local paths, sizes, chunks, or hashes.
|
||||
2. Source selection requires a peer to advertise the exact catalog
|
||||
`(game_id, content_id)` pair. Every chunk request repeats that `ContentId`, a
|
||||
typed canonical catalog path, and the exact catalog range; a version string
|
||||
alone never makes a peer eligible.
|
||||
3. `download_game_files` recovers any earlier attempt, parks an old
|
||||
`version.ini` as `.version.ini.discarded`, and durably journals the exact old
|
||||
and proposed downloader-owned file sets before preparing non-sentinel files.
|
||||
It then 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.
|
||||
and proposed downloader-owned file sets and their catalog content IDs before
|
||||
preparing non-sentinel files. It then emits
|
||||
`PeerEvent::DownloadGameFilesBegin` and builds a per-peer plan
|
||||
(`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
|
||||
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.
|
||||
use per-chunk QUIC streams and write into pre-created files. Every chunk,
|
||||
including `version.ini`, must have the catalog length and BLAKE3 digest. One
|
||||
absolute ten-minute deadline covers opening, requesting, receiving, and the
|
||||
post-receive checks for each ordinary chunk.
|
||||
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. 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
|
||||
removed. Payload files and their directories are synced before the new
|
||||
sentinel is committed last via `.version.ini.tmp` followed by an atomic
|
||||
rename. A sentinel rename whose directory sync fails leaves ownership pending
|
||||
for recovery instead of being reported as a durable success. Transfer
|
||||
failures are accumulated and retried (up to `MAX_RETRY_COUNT`) via
|
||||
`retry_failed_chunks`.
|
||||
7. Failure, cancellation, and startup recovery use the journal to remove only
|
||||
for recovery instead of being reported as a durable success.
|
||||
8. Failure, cancellation, and startup recovery use the journal to remove only
|
||||
exact downloader-owned files. Unknown user files, `local/`, and install
|
||||
transaction state are preserved. A regular `version.ini` beside a pending
|
||||
journal proves that the final rename landed; otherwise recovery aborts the
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal
|
||||
archive download pipeline. The peer core owns the whole operation: it refreshes
|
||||
file metadata from catalog-version peers, runs the same majority file-size
|
||||
validation used by normal downloads, selects a validated peer list, and emits
|
||||
the regular download/install lifecycle events while streaming archive-expanded
|
||||
bytes directly into a `StreamedInstallTransaction`.
|
||||
archive download pipeline. The peer core loads the catalog's extracted-file
|
||||
manifest, rejects games that do not have one, selects sources advertising the
|
||||
exact catalog `ContentId`, and emits the regular download/install lifecycle
|
||||
events while streaming archive-expanded bytes directly into an isolated
|
||||
`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
|
||||
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
|
||||
validated peer. A transaction that created a previously missing game root
|
||||
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.
|
||||
|
||||
`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
|
||||
when intent or marker ownership proves they belong to Lanspread.
|
||||
|
||||
Download provenance is stored separately at
|
||||
`games/<game_id>/download_ownership.json` in the peer state directory. It is
|
||||
bound to the canonical configured games directory so switching library roots
|
||||
cannot make an old record authorize deletion in a different tree. Downloaded-
|
||||
Download provenance is stored separately under
|
||||
`games/<game_id>/download_ownership/v1-<root_digest>/record.json` in the peer
|
||||
state directory. Each namespace is derived from the canonical configured games
|
||||
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
|
||||
in-flight roots, journals an empty pending generation, and deletes only the
|
||||
regular sentinel plus paths proven by the last committed ownership set. Unknown
|
||||
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
|
||||
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
||||
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`:
|
||||
|
||||
- `LanSpreadState` holds onto the peer control channel, the latest aggregated
|
||||
`GameDB`, per-game operation state, the catalog set, and the user-selected
|
||||
game directory.
|
||||
`GameDB`, per-game operation state, the immutable catalog bundle, 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.
|
||||
into `PeerCommand`s. Tauri loads and validates the packaged `game.db` plus
|
||||
companion manifests once during setup; `update_game_directory` validates the
|
||||
filesystem path before storing it, starts the peer runtime on demand with that
|
||||
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
|
||||
Tauri publish/subscribe events (`games-list-updated`, `game-download-*`,
|
||||
`game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only
|
||||
@@ -170,14 +258,17 @@ The Tauri application embeds this crate in
|
||||
|
||||
## 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.
|
||||
- Outbound TLS authenticates the exact responder endpoint. Inbound hints carry
|
||||
no authority, state snapshots are accepted only from a pinned pull, and file
|
||||
requests are admitted against the sender's local catalog and exact
|
||||
`ContentId`. There is no address-only dial or repository-shared certificate.
|
||||
- mDNS is a link-local source of bounded candidates, not roster authority. A
|
||||
candidate reaches peer/UI/library state only after the pinned outbound
|
||||
handshake commits its still-current negotiation lease.
|
||||
- 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
|
||||
|
||||
@@ -185,7 +276,7 @@ The Tauri application embeds this crate in
|
||||
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.
|
||||
throughput into account when distributing initial work.
|
||||
|
||||
Refer to the source (particularly `src/lib.rs`) for the exact message shapes and
|
||||
state machines.
|
||||
|
||||
+21
-14
@@ -1,5 +1,8 @@
|
||||
# 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,
|
||||
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,
|
||||
@@ -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
|
||||
scheduled for later collecting RSVPs).
|
||||
|
||||
The production launcher uses the peer's existing QUIC control channel. Each
|
||||
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
||||
is an immutable, uniquely identified event. Connected peers receive new events
|
||||
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
||||
history so a late joiner reconstructs every event and chat message for active
|
||||
calls. Running and Cancelled calls retain their complete history for 15 minutes
|
||||
so late joiners can see the outcome, roster, and chat, then compact to a Start
|
||||
or Cancel tombstone for the rest of the peer session. Unresolved calls are
|
||||
removed after the five-minute post-deadline recovery period. The frontend
|
||||
reducer turns that event history into the `Nomination` state above, derives
|
||||
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
|
||||
identify actors and enforce creator controls; `settings.username` is only the
|
||||
display name. These deadlines use event wall-clock timestamps, so LAN clocks are
|
||||
assumed to be reasonably close; no clock-synchronization protocol is attempted.
|
||||
The production launcher sends only local intents over the app boundary. The peer
|
||||
core generates each immutable event's call/event nonces and timestamp, then
|
||||
publishes a bounded revision-change hint to connected peers. A hint is only a
|
||||
liveness optimization: receivers perform an authenticated, identity-pinned
|
||||
`Hello` pull and replace that author's complete bounded slice. Peers do not
|
||||
relay another author's history, so a late joiner must discover, pin, and pull
|
||||
every live author before its wholesale view contains the union of creator,
|
||||
participant, and chat events.
|
||||
|
||||
Running and Cancelled calls retain their complete author-owned events for 15
|
||||
minutes so late joiners can see the outcome, roster, and chat, then expire from
|
||||
the view. Unresolved calls are removed after the five-minute post-deadline
|
||||
recovery period. The frontend reducer derives the `Nomination` state and
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user