The search field should behave like a transient launcher search control. Enter
now blurs the input while preserving the current term, and Escape clears the
term before blurring the input.
Test Plan:
- git diff --check
Refs: user redesign nitpick about search keyboard behavior
The animated background had an animation assigned, but the layers painted at the
viewport size so the background-position changes were effectively static. Give
the ambient light layers larger paint areas and drift them slowly so the
animated option visibly moves without becoming distracting.
Reduced-motion users keep the same static background.
Test Plan:
- git diff --check
Refs: user redesign nitpick about animated background not moving
Fresh launcher profiles should start with square covers when no stored UI
settings exist. Existing stored settings still pass through the normal sanitize
path and keep their selected aspect.
Test Plan:
- git diff --check
Refs: user redesign nitpick about no-config cover aspect
The redesign only offered a largest-first size sort. Keep the existing `size`
preference value as largest for compatibility with saved settings and add a new
ascending size key for users who want to find small downloads first.
The sort menu now exposes both size directions and the sorter handles the new
smallest-first option directly.
Test Plan:
- git diff --check
Refs: user redesign nitpick about Size (smallest) sort
The launcher redesign showed the All Games pill count from the full bundled
catalog. That made the counter report every row in game.db even though the All
Games filter itself only shows games that are visible on the current network or
present locally.
Use the same network-visible predicate for the counter and the filter. The pill
count and results total now describe the displayed network library instead of
the baked catalog size.
Test Plan:
- git diff --check
Refs: user redesign nitpick about All Games counter
Replace the previous monolithic 900-line `App.tsx` launcher UI with the
Steam-inspired dark redesign specified in `design/README.md` (handoff
committed in the previous commit). The new UI is split across small,
single-responsibility React modules instead of one file.
What changes from the user's perspective
----------------------------------------
- Dark, gradient-tinted background with sticky 64px top bar (glass blur
+ saturate). Single-row chrome (handoff variant A).
- Pill-style filter toggle (`All Games` / `Local` / `Installed`) with an
animated thumb that slides between options.
- Search field with magnifying-glass icon and a `/` keyboard shortcut to
focus it from anywhere outside an input.
- Sort menu (Name A–Z / Size / Status) as a dropdown.
- Game directory button shows the current path with leading-ellipsis
truncation; clicking it opens the native folder picker.
- Kebab menu hosts Settings, Refresh library, and Unpack logs (existing
companion window). The standalone Unpack-Logs button is removed from
the chrome.
- Game grid uses CSS `auto-fill` minmax with three density presets
(compact / normal / large) and three cover aspects (box / square /
banner), persisted via the Settings dialog.
- Game cards render with the real thumbnail when the backend has one
(via `get_game_thumbnail`) and fall back to a procedurally-generated
gradient + accent-blob placeholder with a Bebas Neue title burned in.
Each card carries a color-coded state chip (Installed = green,
Downloaded = amber, busy = pulsing accent), a peers chip when at
least one peer holds the game, the title, size · genre meta line, a
status line (errors in red), and a single color-coded primary action
button: Play (green gradient), Update / Install (accent), Download
(neutral), animated "busy" spinner during in-flight operations, or a
disabled "Unavailable" state when no peer has the game.
- Clicking anywhere on a card except the action button opens a detail
modal: 16:7 hero (uses the thumbnail), state chip, tag pills derived
from genre/publisher/release_year, large title, 4-cell meta grid
(size, players from `max_players`, version from `local_version` or
`eti_game_version` formatted YYYY.MM.DD, status), description, and an
action row with the primary action plus an Uninstall ghost-danger
button when the game is installed. Esc, scrim click, and the close
button all dismiss the modal.
- Settings dialog (opened from the kebab menu) lets the user change the
accent color (six swatches), background style (flat / gradient /
animated), grid density, and cover aspect. Changes apply live and
persist immediately to the Tauri store under `launcher-settings.json`
(key `ui-settings`); the existing `game-directory` key in the same
file is unchanged.
- Empty state when no directory is chosen offers a centered prompt with
a single CTA. Empty state when filters/search match nothing shows a
distinct "Nothing matches" message.
Why this approach
-----------------
The handoff selected variant A (single-row chrome) explicitly, so only
that variant is implemented; variant B underlined tabs and the
storage-meter widget are intentionally omitted (no free-space data
available from the backend yet).
Real cover art from `get_game_thumbnail` is preferred over the
placeholder generator. When a thumbnail is present, the Bebas Neue
title overlay is suppressed because shipped cover art already has its
own title. When the thumbnail is absent, the placeholder gradient (with
per-id stable hue/blob/angle) plus the burned-in title takes over —
this is the same procedural look as the design reference.
Architecture / file layout
--------------------------
The previous single-file design is decomposed top-down:
```
src/
main.tsx entry; loads tokens + launcher CSS
App.tsx thin router (main vs. unpack-logs view)
styles/
tokens.css CSS custom props + body reset
launcher.css port of the design reference styles.css
(single-row chrome only)
windows/
MainWindow.tsx composition root: top bar + grid + modals
lib/
types.ts Game, InstallStatus, GameAvailability,
ActiveOperationKind, GameFilter / GameSort,
DerivedState
gameState.ts derive() + isUnavailable + needsUpdate +
primaryActionFor + actionLabel +
mergeGameUpdate (event reconciliation) +
countByFilter + applyFilterAndSort
format.ts formatBytes, formatEtiVersion (YYYYMMDD),
truncatePath, formatPlayers
cover.ts coverColorsFor(id) — stable palette pick +
gradient angle + blob position from id
hash; titleFontSize
store.ts file + key constants for plugin-store
hooks/
useSettings.ts UISettings + accent/bg/density/aspect/
sort/filter, persisted via plugin-store
useGameDirectory.ts loads + persists the chosen directory and
pushes it to update_game_directory
useGames.ts owns the games list; listens to every
backend event (games-list-updated,
game-download-begin/finished/failed/
peers-gone, game-no-peers, game-install-
begin/finished/failed, game-uninstall-
begin/finished/failed, peer-count-updated);
exposes markChecking with a 5s fallback to
clear "Checking peers…" when nothing comes
back from the backend
useGameActions.ts play / install / update / uninstall
wrappers around the corresponding invoke
commands
useThumbnails.ts lazy per-id cache for get_game_thumbnail
components/
Icon.tsx inline SVG icon set (currentColor)
Brand.tsx brand mark + name + peer-count chip
Modal.tsx generic scrim + panel + Esc handler
StateChip.tsx corner pill with state-coded dot
ActionButton.tsx color-coded primary action; disabled when
unavailable; spinner when busy
SegmentedRadio.tsx generic 3-way segmented control
ColorSwatchPicker.tsx 6-swatch picker with check overlay
topbar/
TopBar.tsx chrome composition
SegmentedFilters.tsx All / Local / Installed with sliding thumb
SearchField.tsx input + `/` shortcut
SortMenu.tsx dropdown sort selector
DirectoryButton.tsx folder picker trigger
KebabMenu.tsx generic dropdown menu
grid/
ResultsBar.tsx "Showing N of M games"
GameGrid.tsx CSS-grid wrapper
GameCard.tsx full card composition
GameCover.tsx thumbnail OR placeholder cover art
modals/
GameDetailModal.tsx hero + meta grid + actions
SettingsDialog.tsx appearance + library preferences
empty/
NoDirectoryState.tsx onboarding CTA
EmptyResultsState.tsx "scanning" / "nothing matches"
```
`UnpackLogsWindow.tsx` and its CSS are untouched — the unpack-logs
companion window is rendered as before via the existing `?view=unpack-
logs` route in `App.tsx`.
The previous `App.css` is removed entirely (its styles are superseded
by `styles/launcher.css`).
Bebas Neue is loaded via Google Fonts in `index.html` (preconnect +
swap), used for the brand mark and the placeholder cover-art titles.
Tradeoffs and intentional omissions
-----------------------------------
- Storage meter: omitted. The handoff specifies installed/local/free
bytes, but no Tauri command currently provides free-space data.
- Variant B (two-row chrome with underline tabs): omitted; the handoff
picked variant A.
- "View files" action in the detail modal: omitted. The backend doesn't
expose per-game install paths and `shell.open` of the user-chosen
root directory would be misleading.
- "Delete from disk" ghost-danger action for `local` games: omitted.
No backend command currently distinguishes "delete downloaded
archive" from `uninstall_game`. Only installed games get an Uninstall
button.
- "Recently Played" sort: omitted (no play-time tracking yet). The sort
menu offers Name / Size / Status instead.
- Keyboard arrow grid navigation: not yet implemented (out of scope per
the handoff).
- Per-game progress bar during downloads/installs: not implemented; the
action button shows a spinner + "Downloading…" / "Installing…" label
instead, matching the existing event-driven status text.
Persistence
-----------
UI preferences (accent, bg, density, aspect, sort, filter) live in
`launcher-settings.json` under a new `ui-settings` key. The existing
`game-directory` key in the same file is preserved untouched, so users
keep their previously selected directory.
Test plan
---------
Frontend build verified locally:
cd crates/lanspread-tauri-deno-ts && deno task build
→ `tsc && vite build` completes with no diagnostics; bundle ~228 kB.
Manual verification (recommended once the app boots end-to-end):
- [ ] Launch with no directory set: only the "Pick a game directory"
empty state is visible; clicking the button opens the native
folder picker.
- [ ] Pick a directory: top bar appears, grid populates as games arrive.
- [ ] Click the All / Local / Installed pills: the thumb slides; the
count chips reflect the right subset.
- [ ] Press `/`: focus moves to the search input; type a substring and
confirm the grid filters live.
- [ ] Open the Sort menu, switch between sorts; the grid reorders.
- [ ] Open the Settings dialog from the kebab: change accent → the
thumb, brand mark, search-focus ring, and Install button all
switch color live. Change density → grid card size changes.
Change cover aspect → cards re-shape (2/3, 1/1, 16/9). Close and
reopen: choices are remembered.
- [ ] Click anywhere on a card except the action button → detail modal
opens with the right metadata; Esc / scrim click / close button
all dismiss it.
- [ ] Click the action button on an `installed` card → game launches.
- [ ] Click the action button on a `local` card → install starts;
button shows the spinner + "Installing…".
- [ ] Click on a `none` card with peer_count > 0 → download starts; the
lifecycle events update the button label correctly.
- [ ] Card for a game with peer_count == 0 and not downloaded → button
reads "Unavailable" and is disabled.
- [ ] Trigger a `game-download-failed` from the backend: the error
status line appears under the card title in red.
- [ ] Open Unpack Logs from the kebab: the companion window opens
exactly as before.
Trailer
-------
Refs: design/README.md (canonical handoff), design/design_reference/
The original unpack logs window was a flat, monolithic scroll of every
unrar invocation glued together as one continuous textfield. That is
fine for a sanity check but hostile to actually finding a failed
extraction in a session with 30+ games: empty lines from unrar bloated
the view, there was no way to focus on a single game, no filtering, and
no way to narrow in on the entries that actually failed.
This rewrites the viewer to be a proper debugging surface while keeping
the backend untouched -- it still consumes the existing
`get_unpack_logs` command and `unpack-logs-updated` event.
User-visible changes:
* Empty / whitespace-only lines are stripped from stdout and stderr
before rendering, so unrar's padding no longer drowns out real output.
* Two-pane layout: a sidebar lists every captured invocation (badge,
archive basename, finish time); the right pane shows the selected
entry's metadata, stdout and stderr.
* "Errors only" checkbox filters the sidebar to entries whose `success`
flag is false (sidecar exit != 0 or one of the pre-spawn failure
paths). This is the primary affordance for "find the unpack that
broke".
* Regex input filters lines (not entries) -- both per-log when viewing
one, and across the list: entries that contribute zero matching lines
are hidden, and the remaining ones display a per-entry match counter
next to the badge. Regex is case-insensitive; a bad pattern reddens
the input and renders the parser error inline rather than silently
dropping all matches.
* Prev / Next buttons plus arrow keys (and j/k) step through the
filtered list one entry at a time, with the active row auto-scrolled
into view. Selection is tracked by the entry's index in the full log
ring so it survives filter toggles and live appends.
Code organization:
The component, its types, helpers (`basename`, `nonEmptyLines`,
`formatLogTime`, `isUnpackLogsView`) and its CSS are moved out of
`App.tsx` / `App.css` into a dedicated `UnpackLogsWindow.tsx` +
`UnpackLogsWindow.css` pair. The viewer has no shared state with the
main window and lives behind its own `?view=unpack-logs` route, so
keeping ~200 lines of debug-UI plumbing inside `App.tsx` was just
noise. `App.tsx` now imports `UnpackLogsWindow` and `isUnpackLogsView`
and otherwise looks the same as before.
Intentionally out of scope:
* No backend changes. The Rust side already records everything needed;
this is purely a presentation improvement.
* No "view all logs concatenated" mode. The flat view was what we just
replaced -- if it is ever wanted back, it can be added as a third
pane mode.
* Regex is applied to displayed lines only, not to archive paths or
meta. Filtering by archive name is easy enough via the basename in
the sidebar; adding a second filter for it now would be premature.
* Logs are still process-local and capped at `MAX_UNPACK_LOGS` (100)
in the Rust state -- unchanged from b35755f.
Test plan:
* `tsc --noEmit` and `vite build` are clean.
* Manual: trigger several successful and failed unpacks (rename one
archive between handshake and extraction to force a canonicalize
failure), open Unpack Logs, and verify:
- empty lines are gone from stdout/stderr,
- sidebar lists every entry with the right OK/FAIL badge,
- "Errors only" hides the OK rows,
- typing a regex narrows lines in the open entry, hides entries
with no matches, and shows the per-entry match counts,
- an invalid regex (e.g. `[`) reddens the field and shows the
parser error rather than crashing,
- arrow keys / j / k step through the filtered list and the
active row scrolls into view,
- new entries arriving via `unpack-logs-updated` while the window
is open keep the current selection rather than jumping.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures stdout, stderr, exit status and start/finish timestamps for every
unrar sidecar invocation and exposes them through a dedicated "Unpack Logs"
window. Triggered by the need to debug why a particular game's archive
failed to extract -- previously the only artifact of a failed unpack was a
log line in the Tauri process stdout, which is awkward to inspect on an
end-user machine.
Implementation:
* `LanSpreadState` gains an in-memory ring buffer (`unpack_logs`) capped at
`MAX_UNPACK_LOGS` (100). The previous monolithic `do_unrar` is split into
`prepare_unrar_paths` and `run_unrar_sidecar` so every failure path (mkdir
failure, canonicalize failure, non-UTF-8 destination, sidecar spawn error,
non-zero exit) records an `UnpackLogEntry` before bailing.
* A `get_unpack_logs` Tauri command returns the current snapshot; an
`unpack-logs-updated` event is emitted after every write so the viewer can
refresh without polling.
* The React `App` component now routes on `?view=unpack-logs` and renders a
dedicated `UnpackLogsWindow`. The main window opens the viewer via
`WebviewWindow` with label `unpack-logs`; an existing window is focused
instead of being recreated.
Capability scoping: the new window is given its own capability file
(`capabilities/unpack-logs.json`) granting only `core:default`. The main
capability is unchanged in window scope and only gains the two permissions
the main window itself needs (`core:window:allow-set-focus` to focus an
existing log window, `core:webview:allow-create-webview-window` to spawn
it). Splitting the capability keeps the log window from inheriting
`shell:allow-open`, `dialog:default` and `store:default`, which it has no
reason to use.
Known limitations (intentionally out of scope here):
* Logs are process-local; they vanish on app restart. Persistence can be
added later if it turns out users want to inspect failures across runs.
* Entries are presented as a flat chronological list identified by archive
path. No per-game grouping or filtering yet -- the archive filename is
usually enough to identify the game in practice.
* The `unpack-logs-updated` event carries no payload; the viewer re-fetches
the full snapshot on every notification. Acceptable given the 100-entry
cap, but a payload-bearing event would be cheaper if the cap grows.
Test plan:
* `just clippy` and `just build` are clean.
* Manual: start the GUI, point it at a games directory containing at least
one peer-hosted game, trigger an install, then click "Unpack Logs". The
window should show one entry per unrar invocation with stdout, stderr,
status code and timestamps; stderr/error lines render in the warning
color. Triggering further unpacks should update the open window live via
the `unpack-logs-updated` event without manual refresh.
* Negative path: rename or remove the archive between handshake and
extraction to force a canonicalize failure; confirm a failed entry with
the corresponding stderr appears in the viewer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Merge the S18-S36 scenario ideas into the official peer-cli scenario matrix
and add a Docker-backed runner that now exercises S1-S36 with concrete file
proofs. The runner creates temporary fixtures under .lanspread-peer-cli, drives
JSONL peer containers, checks transferred roots with diff and SHA-256 manifests,
and covers startup, discovery, transfer, failure, mutation, concurrency, mesh,
lifecycle, and catalog edge cases.
The scenarios exposed a few harness/runtime boundary gaps that would otherwise
make the contract ambiguous. The peer CLI now rejects self-connects, rejects
commands for game IDs outside the receiver catalog, filters unknown remote games
from its command/event surface, and reports duplicate active same-game commands
as operation-in-progress errors. The peer core also refuses non-catalog download
commands before transfer, and PeerGameDB has a unit check that address changes
preserve identity and library state.
S12 and S28 remain unit-level invariants because the CLI cannot stably race raw
serve-gate requests or rebind a live listener without restart. The runner treats
those scenarios as covered by just test and checks the expected unit test names
appear in the output.
Test Plan:
- just fmt
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= just peer-cli-build
- just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --check
Refs: PEER_CLI_SCENARIOS.md S1-S36
Replace the `a9f9845` local-update dedup cache with explicit peer event
semantics. Local scans now emit `LocalLibraryChanged` when the library changes,
while operation mutations emit `ActiveOperationsChanged` from the mutation
path. Tauri keeps joining those facts into the existing `games-list-updated`
payload, so the frontend contract stays stable.
This removes the cache/invalidation coupling between scan emission and
operation state. The remaining forced local snapshot is explicit: accepted game
directory changes can refresh the UI for an equivalent new path without sending
a peer library delta.
Operation guard cleanup and liveness cancellation now publish the same active
operation snapshot as normal command-handler transitions. The peer CLI JSONL
events follow the same split with `local-library-changed` and
`active-operations-changed`.
Test Plan:
- `just fmt`
- `CARGO_BUILD_RUSTC_WRAPPER= just test`
- `CARGO_BUILD_RUSTC_WRAPPER= just clippy`
- `git diff --check`
Refs: CLEAN_CODE_PLAN_1.md
Peer A failed to learn Peer B's games. The handshake only carried
library_rev/library_digest metadata, and the post-handshake sync path
compared those revisions against per-peer revision numbers that were
never advanced via this code path, so the games map for the remote
peer stayed empty and the UI never showed them.
The fix is to put the authoritative library data into the handshake
itself. Hello and HelloAck now carry a LibrarySnapshot directly, and
both perform_handshake_with_peer (outbound) and accept_inbound_hello
(inbound) apply that snapshot to the peer DB before emitting the UI
events. The initial peer-game-list event is now driven by the
handshake rather than by a follow-up LibrarySummary/LibrarySnapshot
roundtrip.
Bumps PROTOCOL_VERSION to 4 because the wire layout of Hello/HelloAck
changed. Per CLAUDE.md's protocol policy there is no compatibility
shim; older peers will fail the version check and be ignored.
Cleanups that fall out of the new design:
- The Hello / HelloAck library_rev and library_digest fields were
duplicated by the embedded LibrarySnapshot (which carries its own
library_rev, and whose digest is recomputed on apply). Collapsed
both messages to just `library: LibrarySnapshot` to remove the
foot-gun where the two could diverge.
- Request::LibrarySummary and Request::LibrarySnapshot are now dead
on the sender side and were removed along with their stream.rs
handlers and the LibrarySummary struct. LibraryDelta stays — it
is still sent from handlers.rs when the local library changes.
- record_remote_library previously called update_peer_library and
then apply_library_snapshot, which immediately overwrote the
rev/digest just written. Added update_peer_features and rewired
the call site so each peer-DB field is written exactly once.
update_peer_library is retained because discovery.rs still uses
it for the mDNS TXT-record path, where no snapshot is available.
- Removed the now-unused LibraryUpdate enum, select_library_update,
send_local_library_summary, send_local_library_update_if_needed,
LocalLibraryState::delta_since, build_library_summary,
send_library_summary, and send_library_snapshot.
Behavior change visible to users: when two peers come up on the LAN
they now see each other's full game lists immediately after the
handshake instead of waiting for a follow-up sync that, in the broken
case, never made the games visible at all.
Test Plan
- just clippy (clean for the touched crates)
- just test (workspace: all suites pass, including the two new
handshake tests: outbound_hello_carries_local_library_snapshot
and inbound_hello_applies_remote_library_snapshot, the latter
asserting PeerDiscovered + PeerCountUpdated + ListGames events
fire with the remote game visible)
- Manual: start `just peer-cli-alpha` and `just peer-cli-bravo` in
separate terminals; confirm each peer's game list shows the
other's library entries after discovery completes, without
requiring any additional command.
Refs
- FINDINGS.md: triage note that Claude's review surfaced only
in-scope cleanups (dead variants, duplicated header fields,
redundant DB writes, stale test fixture), all addressed here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up hardening for 348a02c, where `listen_addr` was added to Hello and
HelloAck as `Option<SocketAddr>`. Code review surfaced three concrete problems
that the previous commit left open:
1. Cold-start asymmetry. Discovery and the QUIC/mDNS advertiser are spawned
concurrently. If discovery saw a cached peer advertisement before our own
advertiser had written `ctx.local_peer_addr`, our outbound Hello carried
`listen_addr: None`. The receiver's `peer_record_addr` then returned `None`
and silently dropped the Hello while we still recorded their HelloAck, so
peer A learned about peer B but B never learned about A until a later
handshake happened to win the race.
2. Duplicate game-list pipeline. The previous commit added
`refresh_peer_games`, which post-handshake issued a `ListGames` to fetch
`peer.games`. The library-sync path (`LibrarySnapshot`) already populates
the same field. Both could race on first contact and overwrite each other.
Worse, `refresh_peer_games` was misnamed: a `peer_game_count > 0` guard
turned it into a fetch-once-then-no-op helper, while
`handle_library_summary` independently re-triggered a full handshake when
`previous_count == 0` was observed, producing a redundant ping-pong on
every first contact.
3. Argument explosion. `perform_handshake_with_peer`, `spawn_library_resync`,
and `after_peer_library_recorded` had grown to 6-8 individual parameters
and acquired `#[allow(clippy::too_many_arguments)]` opt-outs. Every caller
was destructuring the same fields out of `Ctx`/`PeerCtx`.
Changes (all in one commit because they jointly enforce the same invariant:
"a peer is only ever recorded by its listener address, and the local
listener address must exist before we participate in the protocol"):
- `Hello.listen_addr` and `HelloAck.listen_addr` are now `SocketAddr`, not
`Option<SocketAddr>`. Wire-incompatible, but PROTOCOL_VERSION already moved
to 3 in 348a02c so no additional version bump is needed.
- `required_listen_addr` reads `ctx.local_peer_addr` and returns an
`eyre::Result`; `build_hello_from_state` and `build_hello_ack` both call
it, so an outbound or inbound Hello can no longer be constructed before
the local QUIC listener is bound. The inbound path maps this into a
`Response::InternalPeerError` so the remote peer fails cleanly instead of
seeing a malformed HelloAck.
- `run_peer_discovery` blocks on `wait_for_local_peer_addr` (25 ms poll,
shutdown-aware) before subscribing to the mDNS browser. This closes the
cold-start race for outbound handshakes at the source.
- `refresh_peer_games`, `request_game_list_from_peer`, and the
`previous_count == 0` re-handshake trigger are removed. The post-handshake
flow now relies solely on `LibrarySummary`/`LibrarySnapshot`/`LibraryDelta`
for peer-library state; `ListGames` survives only for the
`request_game_details_*` paths that fetch per-game file descriptions on
demand.
- New `HandshakeCtx` (with `from_ctx` and `from_peer_ctx` constructors)
replaces the long argument lists. All `too_many_arguments` allow-attrs in
`handshake.rs` are gone, and call sites in `handlers.rs`, `discovery.rs`,
and `stream.rs` collapse to a single clone.
- `handle_library_delta` no longer acquires a read lock on the apply path:
the `peer_addr` lookup moved into the `else` resync branch where it is
actually needed.
- `accept_inbound_hello`'s `remote_addr` parameter is renamed to
`transport_addr`. It is now used only for warn-log formatting, and the
new name signals that this is the ephemeral QUIC source port, never the
authoritative listener address that gets recorded.
User-visible effect: on cold start, peers can no longer end up with an
asymmetric view of each other ("A sees B but B never sees A"). First-contact
library sync now does one handshake plus one snapshot/delta exchange instead
of the previous handshake + ListGames + redundant follow-up handshake. The
direct-connect CLI path (`handle_connect_peer_command`) now fails fast with
"local peer listener address is not ready" if invoked before the QUIC server
has bound; this is intentional - the previous behaviour would have sent a
Hello that the receiver had to silently discard.
Test Plan:
- just fmt
- just clippy
- just test (80 peer + 3 cli + 5 tauri tests pass)
- just build
- Manual: bring up `just peer-cli-alpha`/`bravo`/`charlie`, confirm symmetric
peer discovery and that games show up on every side after one library
digest cycle, with no duplicated ListGames traffic in trace logs.
Refs: Review feedback on commit 348a02c (listener-address handshake fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Peers discovered over mDNS could still attribute later library sync traffic to
temporary QUIC source ports. In a real GUI LAN run this made Host B try to
push its library to Host A's outbound port instead of Host A's advertised
listener, so Host A discovered the peer but never saw its games.
Carry the stable listener address in Hello and HelloAck, and key library sync
messages by peer_id instead of inferring identity from the transport source
address. The handshake path now explicitly refreshes an empty peer library from
the known listener address, matching the reliability of the direct-connect CLI
path without overwriting richer snapshot state when it already arrived.
This changes the current wire protocol, so PROTOCOL_VERSION is bumped to 3 and
all peers must be rebuilt together. The architecture note now documents that
listener addresses come from mDNS or Hello/HelloAck, never from ephemeral QUIC
source ports.
Test Plan:
- just fmt
- just test
- just clippy
- just build
- git diff --check
Refs: Local Linux/Win11 GUI LAN test logs from 2026-05-18.
Add deeper peer CLI coverage for file-transfer integrity and multi-peer
chunking. The alpha fixture now carries a real renamed RAR archive larger
than 100 MB for alienswarm, which gives the chunk planner enough work to
split a single game archive across multiple peers.
Expose completed chunk source details as a peer event and have the CLI print
that event as JSONL. This keeps transfer behavior in lanspread-peer while the
CLI remains a harness that reports what the peer runtime did. The Tauri shell
logs the event at debug level so the shared PeerEvent enum stays exhaustive.
Document the new S13/S14 scenarios and record the manual run evidence,
including SHA-256 manifests and the per-peer byte split for the large archive.
Test Plan:
- just fmt
- just test
- just peer-cli-build
- just clippy
- just peer-cli-image
- unrar t -idq crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm/alienswarm.eti
- Manual peer CLI: bravo -> deep-small-client bfbc2 download with matching SHA-256 manifests
- Manual peer CLI: alpha -> deep-stage-b alienswarm download with matching SHA-256 manifests
- Manual peer CLI: alpha + deep-stage-b -> deep-stage-c alienswarm download with chunk events from both peers and matching SHA-256 manifests
Refs: PEER_CLI_SCENARIOS.md S13 S14
The peer core already emits NoPeersHaveGame when a requested game cannot be
served by any known peer. The JSONL harness still waited for the generic file
detail timeout before returning the download command error, which made the
manual scenario slower and less precise.
Correlate the existing no-peers event with the pending CLI download command so
the harness returns a deterministic error immediately. This is harness
bookkeeping only; game availability and peer behavior remain owned by
lanspread-peer.
Test Plan:
- just fmt
- just test
- just clippy
- just peer-cli-build
- just peer-cli-image
- just peer-cli-alpha, just peer-cli-bravo, just peer-cli-charlie
- In charlie, send {"cmd":"download","game_id":"not-a-game"}; observe
no-peers-have-game followed by error "no peers have game not-a-game"
Refs: PEER_CLI_SCENARIOS.md
After renewing the dev certificate, peers could complete handshakes but then
lost each other during liveness checks. Inbound QUIC streams report the client's
ephemeral source port, while the peer database is supposed to track the peer's
advertised listening address. Recording the ephemeral address created unstable
peer entries that could not be pinged later.
Resolve transport source addresses back to the unique known peer on the same IP,
and keep an existing advertised address when an inbound Hello arrives from that
peer. Goodbye events now report the stored peer address as well.
This keeps the core peer behavior in lanspread-peer; the CLI only observes the
resulting peer snapshots.
Test Plan:
- just fmt
- just test
- just clippy
- just peer-cli-build
- just peer-cli-image
- just peer-cli-alpha, just peer-cli-bravo, just peer-cli-charlie
- list-peers after the ping idle window shows advertised peer addresses with
populated game lists instead of ephemeral-port peers disappearing
Refs: PEER_CLI_SCENARIOS.md
The peer CLI could flood LocalGamesUpdated events when run from the Docker
harness. The local monitor rescans game roots, and some bind-mounted filesystems
report those read/close operations back as notify access events. Treating those
non-mutating events as real library changes queued another rescan, making the
headless CLI unusable for manual peer-to-peer testing.
Ignore access events before mapping paths to game IDs. Create, modify, remove,
and rename events still flow through the existing per-game rescan gate, while
fallback scans continue to reconcile missed writes.
Test Plan:
- just fmt
- just test
- just clippy
Refs: manual peer-cli P2P testing
Add three reusable peer CLI game directory fixtures for local smoke tests.
Each fixture is a complete games root that can be passed directly to
--games-dir, with catalog-backed game IDs, version.ini sentinels, and real RAR
archives renamed to .eti.
The fixtures intentionally overlap in two places so multi-peer tests can cover
shared availability. Alpha and bravo both contain ggoo, while bravo and charlie
both contain cnc4. The archives contain generated random payload files rather
than meaningful game data; this keeps the fixtures fake while still exercising
RAR-backed ETI handling.
The tradeoff is committing roughly 34 MiB of binary fixture data. That is
intentional here because the fixtures need real archives for CLI tests instead
of synthetic text placeholders.
Test Plan:
- Ran git diff --check.
- Ran unrar t -idq over every .eti file in the fixture tree.
Refs: none
The peer runtime previously accepted an `enable_mdns: bool` flag, plumbed
through `PeerStartOptions`, `spawn_peer_runtime`, `run_peer`, `Ctx`, and
`PeerCtx`. The lanspread-peer-cli harness exposed the toggle as
`--no-mdns` so test scenarios could fall back to explicit `connect`
commands when mDNS could not be relied on, in particular when multiple
peers ran inside `--network host` containers and could not advertise
independently.
That host-networking workaround no longer exists: the previous commit
moves harness containers onto a macvlan network, where each peer is a
real LAN device and mDNS just works between them. There is no scenario
left in the codebase where disabling mDNS is desirable. Per the project's
protocol policy in CLAUDE.md ("there is only one wire version, no
compatibility shims, no fallback paths"), an opt-out path with no current
caller is exactly the kind of dead code we should not carry.
Remove the flag and every plumbing point that exists only to support it:
- `PeerStartOptions::enable_mdns` and the custom `Default` impl that set
it to `true`; the struct now derives `Default` and just carries
`state_dir`.
- The `enable_mdns` parameter on `start_peer_with_options`,
`spawn_peer_runtime`, `run_peer`, and `Ctx::new`.
- The `enable_mdns` fields on `Ctx` and `PeerCtx` and the propagation
through `to_peer_ctx`.
- The `if ctx.enable_mdns` guard in `spawn_startup_services`;
`spawn_peer_discovery_service` is now always spawned.
- The `if ctx.enable_mdns { ... } else { ... }` branch in
`run_server_component`: the mDNS advertiser and event monitor are now
unconditionally started, and the no-mDNS-fallback log line that read
"mDNS disabled; direct peer address is ..." is gone. The
`direct_connect_addr` helper is kept because the mDNS-on branch still
uses it as a fallback when `local_peer_addr` has not yet been
populated.
- The internal test helpers in `handlers.rs`, `services/local_monitor.rs`,
and `services/stream.rs` that passed `true` as the trailing
`enable_mdns` arg to `Ctx::new`.
- In `lanspread-peer-cli`: the `--no-mdns` arg parsing, the
`Args::enable_mdns` field, the `mdns` key on the `cli-started` event
payload, and the `--no-mdns` mention in the help text and the crate
README.
The `Args::name` field is wired to the harness identity but is otherwise
untouched. The macvlan network created by `just peer-cli-net` is the
runtime prerequisite for this change to be observable across containers;
on a single workstation, two harness binaries on `127.0.0.1` discover
each other through mDNS on the loopback interface as before.
Test Plan:
- `just fmt`
- `just clippy`
- `just test`
- `just peer-cli-build`
- Two peers on macvlan: `just peer-cli-run alpha` and
`just peer-cli-run beta`; check that each emits `peer-discovered` and
`peer-connected` events without an explicit `connect` JSONL command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agents need a way to exercise multiple peers without launching the Tauri GUI.
Add `lanspread-peer-cli` as a workspace crate that starts the core peer runtime,
reads JSON commands from stdin, and writes result, event, and error records as
JSONL on stdout.
The harness supports status, peer listing, game listing, direct connect,
set-game-dir, download, install, uninstall, wait-peers, and shutdown commands.
It can seed tiny fixture archives that use a fixture unpacker, or delegate real
archives to an external `unrar` program when one is supplied.
Add a Dockerfile, `.dockerignore`, and `just` recipes for building the binary,
building the image, and running named harness containers with state and games
mounted under `target/peer-cli/`. The documentation now lists the crate and the
new test harness commands in the project map, with a crate-local README for the
JSONL protocol.
This commit depends on the non-GUI peer hooks introduced in the previous commit:
startup options, local-ready events, direct connects, snapshots, and explicit
post-download install policy. It does not add old-peer compatibility paths.
Test Plan:
- `git diff --check`
- `just fmt`
- `just clippy`
- `just test`
- `just peer-cli-build`
- Not run: `just peer-cli-image` requires a Docker daemon and base image access.
Depends-on: e711cf3454
Refs: crates/lanspread-peer-cli/README.md
The follow-up backlog had drifted into three settled peer/runtime issues: the
legacy game-list fallback contradicted the one-wire-version policy, the Tauri
shell still re-derived local install state from disk after peer snapshots, and
`Availability::Downloading` existed even though active operations are already
reported through a separate operation table.
Remove the legacy `AnnounceGames` request and fallback service. Discovery now
ignores peers that do not advertise the current protocol and a peer id, and
library changes are sent through the current delta path only. This keeps the
runtime aligned with the documented current-build-only interoperability model.
Make peer `LocalGamesUpdated` snapshots authoritative for local fields in the
Tauri database. The GUI-side catalog still owns static metadata such as names,
sizes, and descriptions, but downloaded, installed, local version, and
availability now come from the peer runtime instead of a second whole-library
filesystem scan. Snapshot reconciliation also pins the missing-begin and
missing-finish lifecycle cases in tests.
Collapse availability back to the settled `Ready` and `LocalOnly` states.
Aggregation now counts only `Ready` peers as download sources, and the frontend
no longer carries a dead `Downloading` enum value.
The core peer also exposes the small non-GUI hooks needed by scripted callers:
startup options for state and mDNS, a local-ready event, direct connection, peer
snapshots, and an explicit post-download install policy. Those hooks reuse the
same current protocol path and do not add compatibility shims.
Test Plan:
- `git diff --check`
- `just fmt`
- `just clippy`
- `just test`
Refs: BACKLOG.md, FINDINGS.md, IMPL_DECISIONS.md
FINDINGS.md identified three merge blockers in the post-plan install/update
flow.
Updates now use FetchLatestFromPeers so the Tauri update command bypasses
local manifest serving and asks peers that advertise the latest version for
fresh file metadata. PeerGameDB now aggregates and validates file descriptions
from latest-version peers, keeping stale cached metadata for older versions
from poisoning chunk planning when filenames stay the same but sizes change.
Download-to-install handoff now performs explicit async state transitions.
The download task mutates Downloading to Installing or Updating under the
active-operation write lock, clears the cancellation token, and then runs the
install transaction. OperationGuard remains armed only as crash or abort
cleanup and is disarmed after normal explicit cleanup, so final refreshes no
longer race a deferred Drop cleanup.
Local library index writers now serialize the load/mutate/save window with one
async mutex. The index fingerprint also includes the root version.ini contents
so a same-length version rewrite in the same mtime second still updates the
reported local version.
The tradeoff is that local index mutations are serialized in-process instead
of moved into a dedicated actor. That keeps the fix small and scoped to the
merge blockers while preserving the existing scanner API.
Test Plan:
- just fmt
- just test
- just clippy
- just build
- git diff --check
Refs:
- FINDINGS.md
The download pipeline had grown into one large file that mixed sentinel
transaction handling, peer planning, transport, retry, and top-level
orchestration. Split it into a download/ module tree with one file per
concern so future lifecycle changes can be reviewed at the right boundary.
The public crate surface remains download::download_game_files. Helper types
and functions are kept pub(super) or private so the refactor does not widen
the API or encourage new callers to depend on internals. The version.ini
transaction helpers stay local to version_ini.rs; the proposed fs_util
extraction is intentionally left for the later atomic-index work, where a
second caller exists.
There is no intended runtime behavior change.
Test Plan:
- just fmt
- just test
- just clippy
- just build
Refs: none
Game::availability used string labels that were carried through persisted
library data, protocol summaries, and the Tauri-facing game payload. That
allowed invalid states to exist and required legacy summary conversion code to
defensively map strings back into protocol availability values.
Move Availability to lanspread-db and re-export it from lanspread-proto so the
persisted Game type and wire GameSummary type share one serde enum. The JSON
spelling stays Ready, Downloading, or LocalOnly, so the serialized shape does
not change for current library indexes or peer payloads.
Add typed helpers for sentinel-derived download state. Game::set_downloaded
keeps downloaded and Ready/LocalOnly in lockstep and intentionally collapses
non-ready local state, including Downloading, back to LocalOnly. That matches
the current local-summary contract where active operations are suppressed
instead of advertised as Downloading. Game::normalized_availability keeps the
legacy Game-to-summary path from publishing an inconsistent Ready value when
downloaded is false.
Update the follow-up status note so typed availability is no longer listed as
open work.
Test Plan:
- just fmt
- just test
- just clippy
- just build
Refs: none
The local library index used tokio::fs::write directly on the canonical
library_index.json path. That truncates the existing index before writing the
new bytes, so a crash or power loss could leave a zero-length or partial cache.
Write the index through a sibling temp file, sync it, rename it over the
canonical path, and sync the parent directory on Unix. Loading the index also
sweeps a stale temp file before parsing the canonical file. That keeps the
existing cache valid after an interrupted write while still letting a normal
scan rebuild from disk if the canonical index is missing or corrupt.
This follows the existing temp-plus-rename pattern used for version.ini and
install intents. It intentionally does not add locking; local library writes
are already serialized by the peer operation flow.
Test Plan:
just fmt
just test
just clippy
Refs: none
Move the repeated test TempDir implementations into a single peer
test_support module. The shared helper keeps the existing automatic cleanup
behavior and uses an atomic suffix plus timestamp so parallel tests do not
collide on the same path.
This is intentionally limited to test hygiene. It does not change the
availability model, split download.rs, or touch production scan/install
behavior beyond importing the shared helper from test modules.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
Add focused serve-side tests for the gates around peer requests. GetGame now has
coverage for the non-catalog, active-operation, and missing-sentinel cases that
should return GameNotFound instead of exposing local files.
The full-file and chunk handlers both depend on the same transfer gate before
touching the QUIC send stream. Extract that gate into a small helper and test
the same cases there, plus the existing local-path exclusion, so both dispatch
paths stay aligned without adding fake QUIC stream plumbing.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
Add dispatch-level tests for the local game monitor paths called out in
FOLLOW_UP_2.md. The new coverage verifies watcher events are dropped while a
game has an active operation, burst events for one game collapse through the
pending set to at most one extra rescan, fallback scans pick up sideloaded
catalog games, and non-catalog roots stay invisible to the library state.
The non-catalog test exposed that an empty local library initialized with
digest zero, while the computed digest for an empty map is nonzero. That made
the first empty scan produce a meaningless empty LibraryDelta. Initialize the
empty state with the computed empty digest so a non-catalog-only scan leaves no
delta behind.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
Add a focused local-library test for the case where a game starts with only a
committed `local/` install and later gains a root `version.ini` sentinel. The
single-game rescan now has coverage showing it promotes the summary from
LocalOnly to Ready while preserving the installed flag and reading the local
version.
This pins the cached-index transition called out in FOLLOW_UP_2.md without
touching scanner dispatch or broader monitor behavior.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
Add coverage for the uninstall branch where `local` has already been moved to
`.local.backup`, but deleting that backup fails. The Unix-gated test makes a
child directory non-writable before uninstall starts, so recursive deletion of
the renamed backup fails without adding production hooks.
The test verifies rollback restores the previous local install, removes the
backup path, and clears the intent. It is gated to Unix because deletion
permission behavior is platform-specific; Windows coverage would need a
different failure mechanism rather than pretending this setup is portable.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
Add a focused transaction test for the branch where update extraction succeeds
but promoting `.local.installing` to `local` fails. The fake unpacker creates a
non-empty `local/` conflict after extraction, so the commit rename fails without
adding production hooks or brittle platform-specific permission tricks.
The assertion verifies the old install is restored from `.local.backup`, the
conflict and staging directories are removed, the backup is consumed, and the
intent is cleared back to None.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
FOLLOW_UP_2.md called out that recovery only covered one intent-driven update
row. Replace that single-case assertion with a table over the ten recovery
rows documented in PLAN.md, spanning Installing, Updating, and Uninstalling
intents across local, staging, and backup directory states.
The cases intentionally use markerless reserved directories while an intent is
present. That pins the contract that the intent log proves Lanspread ownership
during crash recovery, including the crash windows before ownership markers are
dropped. The test still keeps the existing None-intent markerless case separate
so user-owned reserved names remain protected.
Running the larger table in parallel exposed that this module's TempDir helper
could collide on pid plus timestamp paths. Add a local atomic suffix so these
tests stop deleting each other's directories without doing the broader helper
consolidation reserved for the later hygiene phase.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
Local operation spinners were driven by begin, finish, and failure event
history. If one of those lifecycle events was missed, the Tauri bridge could
keep a stale active operation and the React state would keep showing an
in-progress spinner until restart.
Peer local scan updates now carry an authoritative active-operation snapshot.
The peer still suppresses active game roots from peer-facing library deltas,
but it emits LocalGamesUpdated to the UI even when no library delta changed so
the snapshot can clear stale state after rollback or completion. The Tauri
bridge replaces its active-operation map from that snapshot, emits it with the
games-list payload, and the React merge uses it to restore download, install,
update, and uninstall spinners from current peer state rather than event
history alone.
This also enables the Tauri lib unit-test target so the reconciliation helper
can stay covered by the workspace test recipe.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_2.md
The follow-up review found a few stale lifecycle edges around local game
transactions. Recovery could sweep active roots, post-operation refreshes
still re-ran full startup recovery, and the UI kept inferring local-only state
from downloaded and installed flags instead of the backend availability.
This updates the peer lifecycle so startup recovery skips active operations,
install/update/uninstall refresh only the affected game after the operation
guard is dropped, and path-changing game-directory updates are rejected while
operations are active. It also removes the dead UpdateGame command, drops the
unused manifest_hash write field while preserving old JSON reads, renames the
internal install-finished event, and carries availability through the DB,
peer summaries, Tauri refreshes, and the React model.
The included follow-up documents record the review source, implementation
decisions, and the remaining FOLLOW_UP_2.md work so later commits can stay
small instead of reopening the completed plan items.
Test Plan:
- git diff --check
- just fmt
- just clippy
- just test
Follow-up-Plan: FOLLOW_UP_PLAN.md
Update the peer README and architecture notes to match the landed runtime:
version.ini is the download sentinel, local/ is the install predicate, install
state is recovered through .lanspread.json intents, and watcher rescans are
operation-gated rather than time-debounced.
Add IMPL_DECISIONS.md with the implementation-time choices that were not
already prescribed by PLAN.md, including the just test recipe, the UI event
compatibility bridge, reuse of the existing library index for per-ID rescans,
and the split between active operation state and download cancellation tokens.
Test Plan:
- just fmt
- just clippy
- just test
- just build
Refs: PLAN.md
Remove the Tauri-side whole-game backup and unpack flow. The Tauri shell now
provides an injected unrar sidecar implementation and lets the peer own
install, update, uninstall, rollback, and recovery decisions.
Route install commands by local state: missing version.ini fetches from peers,
downloaded archives without local/ send InstallGame directly, and already
installed games are left to the Play action. Updates request a fresh download
and uninstalls forward UninstallGame. The UI mirrors peer operation events for
downloading, installing, updating, and uninstalling.
Render installed-but-not-downloaded games as LocalOnly and surface the local
version for downloaded-but-not-installed games. Add a secondary uninstall
affordance that does not change the main Install/Open action.
Test Plan:
- just fmt
- just clippy
- just test
- just build
Refs: PLAN.md
Implement the peer-owned state model from PLAN.md. A root-level version.ini
is now the download completion sentinel, local/ as a directory is the install
predicate, and exact root-level version.ini detection prevents nested files
from becoming sentinels by accident.
Add the peer operation table that gates downloads, installs, updates, and
uninstalls by game ID. Serving paths now reject non-catalog games, active
operations, missing sentinels, and any request that points under local/.
Remote aggregation treats LocalOnly peers as non-downloadable so they do not
contribute peer counts, candidate source selection, or latest-version checks.
Move install-side filesystem mutation into lanspread-peer::install. The new
module writes atomic .lanspread.json intents, uses .local.installing and
.local.backup with .lanspread_owned markers, and performs startup recovery
from recorded intent plus filesystem state. Downloads now buffer version.ini
chunks in memory and commit the sentinel last through .version.ini.tmp.
Replace the fixed 15-second monitor with notify-backed non-recursive watches,
per-ID rescan gating, and a 300-second fallback scan. The optimized rescan
path updates one cached library-index entry and active operation IDs preserve
their previous summary during scans.
Test Plan:
- just fmt
- just clippy
- just test
- just build
Refs: PLAN.md
A game that the user deletes from disk while the launcher is running stayed
visible as "Installed" in the UI indefinitely, both as a status label and as
a member of the Installed tab. After a restart the Install button reappeared
but the game still wrongly showed up under Installed. The backend rescan
(`set_all_uninstalled` + `update_game_installation_state` in
src-tauri/src/lib.rs) was already producing the correct `installed: false`
on each refresh; the React store was just refusing to honour it.
Two independent UI bugs were in play:
1. The `games-list-updated` listener merged each update with
`previous?.install_status ?? ...`, which preserved a prior `Installed`
value regardless of what the backend now reported. The fix introduces
`mergeGameUpdate`: the backend `installed` flag wins for settled state
(Installed vs NotInstalled), while genuine in-progress states
(CheckingPeers / Downloading / Unpacking) are preserved across refreshes
so concurrent backend ticks cannot blow away an active download UI.
`status_message` and `status_level` are cleared only when the local
`installed` / `downloaded` flags actually flip, so a transient error
("No peers currently have this game.") survives a cosmetic refresh but
is wiped once the underlying state changes.
2. The Installed tab filter was `installed || downloaded`, which leaked
downloaded-but-not-yet-installed games into a tab whose label promises
only ready-to-play titles. It now filters on `installed` alone, matching
`getActionLabel`'s own definition of when "Install" appears.
While the install-state semantics were being sorted out, the filter
taxonomy was clarified to match what users actually mean:
| Button | Filter |
|------------|---------------------------------------------------|
| All Games | installed || downloaded || peer_count > 0 |
| Local | installed || downloaded |
| Installed | installed |
The "Available" button was renamed "Local" because users do not think of
themselves as a peer; Local means "on my system, whether the archive is
still packed or already installed". "All Games" previously surfaced every
row in the bundled game.db, including catalogue entries that no peer on
the LAN holds — confusing, since those games cannot be acted on. It now
scopes to LAN-reachable games. The `isUnavailable` helper and its
`Unavailable` action label are left in place: with this filter no
displayed game can hit that state today, but the helper is cheap to keep
as a safety net for transient peer-count flips and for a possible future
"also show catalogue-only entries" toggle.
Tooltips were rewritten to a consistent `Show games … on your system` /
`Show all games available on the LAN` pattern, all phrased from the user's
point of view (no "peer" jargon in user-facing strings; doc/code comments
still use "peer" where it reflects the actual protocol).
Two stale comments were dropped along the way: a note on
`getInitialGameDir` that claimed it only sets the directory if not already
set (the function unconditionally calls `setGameDir` when a value is
persisted), and a leftover `// Rest of your component remains the same`
marker from an earlier scaffold.
Test plan:
- `npm --prefix crates/lanspread-tauri-deno-ts exec tsc -- --noEmit`
passes (run as part of this change).
- `just run`, point the launcher at a game directory holding two installed
games, then manually `rm -rf` each game's local folder. Within one
refresh cycle the Installed tab should empty and each game's action
button should flip to Install / Download as appropriate, without
needing a restart.
- Start a download and verify the UI does not regress to NotInstalled
when the next `games-list-updated` arrives mid-flight.
- Cycle through All Games / Local / Installed and confirm membership
matches the table above; in particular, a game whose archive is
downloaded but not installed appears under Local and All Games but not
Installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the detached tokio::spawn pattern in the peer runtime with a
supervised model built on tokio_util's CancellationToken and TaskTracker.
Long-lived services and child tasks now have an explicit parent, a
cancellation path, and a join point. Tauri can request a clean shutdown
on app exit instead of leaking work into process termination.
Background
~~~~~~~~~~
start_peer() previously returned only a command sender. The four startup
services (QUIC server, mDNS discovery, peer liveness, local library
monitor) and their child tasks (ping workers, handshake jobs, download
workers, announcement fan-outs, connection/stream handlers) were spawned
with raw tokio::spawn and detached. Closing the command channel sent
Goodbye notifications but did not stop those services. The mDNS blocking
worker had no cancellation path at all. Active downloads were stored as
JoinHandle<()> and force-aborted, which could interrupt file writes
mid-chunk.
Supervisor
~~~~~~~~~~
The runtime now owns a CancellationToken and a TaskTracker, threaded
through Ctx and PeerCtx. Each long-lived service is spawned through a
small supervisor (spawn_supervised_service) that wraps the service in
catch_unwind and enforces an explicit SupervisionPolicy:
QuicServer: Required (fatal; cancels the runtime if it dies)
Discovery: Restart(5s) (matches the prior self-restart loop)
Liveness: Restart(5s)
LocalMonitor: BestEffort (logs and exits, no restart)
A Required failure emits a new RuntimeFailed { component, error } event
to the UI and cancels the runtime; the command loop and goodbye
notifications still run to completion. The Tauri layer forwards the
event as "peer-runtime-failed" so a future UI can surface it.
mDNS cancellation
~~~~~~~~~~~~~~~~~
MdnsBrowser previously blocked on receiver.recv() forever. It now
exposes next_service_timeout(Duration) returning an MdnsServicePoll
enum (Service/Timeout/Closed) via recv_timeout(). The discovery worker
polls at 250ms and checks the shutdown flag between ticks, so
cancellation reaches the blocking thread within one poll interval
instead of waiting for the next mDNS event.
Downloads
~~~~~~~~~
active_downloads is now HashMap<String, CancellationToken>. Each
download gets a child token of the runtime shutdown, checked at chunk
and peer-attempt boundaries (never inside file writes). When all peers
with a game disappear, liveness cancels the token and emits
DownloadGameFilesAllPeersGone; the download exits Ok(()) without
emitting a duplicate Failed event.
DownloadStateGuard (context.rs) is held inside the download task and
clears downloading_games + active_downloads on Drop, covering the happy
path, error returns, cancellation, and task abort. Drop falls back to
spawning the cleanup if write-lock contention prevents try_write.
Public API and Tauri integration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
start_peer() now returns PeerRuntimeHandle exposing:
fn sender(&self) -> UnboundedSender<PeerCommand>
fn shutdown(&self)
async fn wait_stopped(&mut self)
The Tauri layer stores the handle in managed state and switches its
main loop from .run(ctx) to .build(ctx).run(|h, e| ...). On
RunEvent::Exit it calls handle.shutdown() and blocks up to 2s on
wait_stopped(), giving services time to cancel and Goodbye packets time
to flush over a healthy LAN while staying short enough not to delay
process exit noticeably on a dead network.
The command loop distinguishes graceful shutdown from unexpected
channel closure: if recv() returns None and shutdown.is_cancelled() is
set, the loop returns Ok(()) silently. Only an unexpected close (no
cancellation observed) still emits RuntimeFailed. This avoids a
spurious failure event on every normal app close.
User-visible behavior changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Closing the app no longer leaks services into process termination;
Goodbye notifications are reliably attempted before exit.
- Downloads cancel cleanly (between chunks) instead of force-aborting
mid-write.
- A new "peer-runtime-failed" Tauri event fires when a Required service
cannot recover. No frontend handler exists yet — that is a follow-up.
Tradeoffs
~~~~~~~~~
- Workspace tokio-util now requires the "rt" feature for TaskTracker.
- The mDNS worker still runs in spawn_blocking and may stay parked
briefly between 250ms polls — acceptable for a desktop app.
- The 2s shutdown timeout on app exit is a deliberate compromise.
Tests
~~~~~
New unit tests:
- DownloadStateGuard clears tracking on completion, cancellation, and
parent-task abort (context.rs).
- Required failure cancels the runtime and emits RuntimeFailed
(startup.rs).
- Restart policy restarts until shutdown is requested (startup.rs).
- PeerRuntimeHandle.shutdown() observable via wait_stopped()
(startup.rs).
- Peers-gone cancellation emits only PeersGone, no duplicate Failed
(services/liveness.rs).
Test plan
~~~~~~~~~
cargo test --workspace
cargo clippy --workspace --all-targets
Manual smoke test on two peers on the same LAN:
1. Start a download, verify chunks transfer.
2. Close the receiving app mid-download — verify the sending peer
logs a Goodbye, not a connection-reset error.
3. Stop the sending peer mid-download — verify the receiver emits
DownloadGameFilesAllPeersGone, not Failed.
Follow-ups
~~~~~~~~~~
- Frontend handler for "peer-runtime-failed".
- Consider exposing the runtime handle's stopped watch to the frontend
for a reconnecting indicator on Required failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Peer startup used to bootstrap itself by spawning the runtime and immediately
sending a SetGameDir command back through its own control channel. The Tauri
integration then polled shared state until a directory appeared and waited two
seconds before asking peers for games. That made startup ordering implicit and
left a race-prone sleep in the UI bridge.
Install the initial game directory directly into the peer context instead. The
runtime now attempts the initial local-library scan before starting discovery,
then launches the server, discovery, liveness, and local monitor services from
that initialized context. Later directory changes still use SetGameDir, so the
existing UI command surface stays intact.
Use PathBuf and Path references across peer filesystem boundaries so directory
state is represented as a path rather than an optional string. The Tauri layer
now validates a selected game directory before storing it, loads the bundled
catalog on first use, and starts or updates the peer runtime from one helper.
Peer event fan-out is split into named handlers so the Tauri setup closure only
wires state and starts the event loop.
Shutdown goodbye notifications are still best-effort, but they are now awaited
with a short timeout instead of being spawned and forgotten. The tradeoff is a
small bounded wait during peer runtime shutdown in exchange for clearer task
ownership.
Test Plan:
- cargo test -p lanspread-peer
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo +nightly fmt
- git diff --check
Refs: none
Move the required game.db resource resolution and ETI catalog loading out of
Tauri setup into small helpers. The setup closure now describes the startup
flow instead of carrying resource-resolution and conversion details inline.
This keeps the existing fail-fast behavior for a missing or unreadable bundled
catalog, while giving the required resource path and in-memory GameDB conversion
clear names. There is no intended user-visible behavior change.
Test Plan:
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo +nightly fmt
Refs: none
The peer runtime used to spawn each long-running service inline inside
run_peer. That made the startup path harder to scan because service names,
clone setup, and task error handling were interleaved with the command loop.
Move the task wrappers into a startup module and leave run_peer as the
lifecycle overview: create shared context, start services, handle commands,
then send shutdown goodbyes. The spawned services and their error handling are
unchanged; only the ownership plumbing moved into named helpers.
Test Plan:
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo +nightly fmt
Refs: none
LanSpreadState now owns its empty initialization through Default. This keeps
the root runtime state construction in one place instead of building each
Arc<RwLock<_>> value inline before registering it with Tauri.
The setup hook now retrieves peer_game_db from the managed state and clones the
Arc before spawning async peer initialization. That preserves the existing
lifetime boundary while removing the separate outer peer_game_db binding.
There is no user-visible behavior change. The peer database, game list,
download tracking, games folder, and peer control channel still start empty and
are populated through the same setup and command paths.
Test Plan:
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo +nightly fmt
Refs: none