A valid remote view could make the renderer reduce and mount thousands of calls
every second. A single long chat also re-sorted its growing message array after
every append during every reduction.
Keep only the highest-ranked 128 nominations and latest 256 messages for UI
projection. Events are sorted once before folding, so chat messages remain in
stable order without repeated growing-array sorts. Backend author history and
creator authority are unchanged.
Test Plan:
- `just frontend-test` -- passed; 94 frontend tests passed.
- `just fmt` -- TypeScript formatting completed; the recipe then hit the
pre-existing generated security-report Markdown-lint failures.
- `git diff --cached --check` -- passed.
Security audit finding Codex #14 ("cross-author event-ID collisions can
suppress Call-to-Play entries"). The Rust side guarantees that an event
nonce is unique within one authenticated author's history and preserves
`author_id` on every projected event, but the frontend reducer
deduplicated the merged view with `new Map(events.map(e => [e.id, e]))`
and tracked chat messages by `event.id` alone. A peer could therefore
publish, say, a Respond event reusing the nonce of another user's
Create event and make that call vanish from every viewer, or shadow
other users' chat messages.
`eventKeyOf` now builds `author_id + NUL + id` and is used for view
deduplication, event ordering ties, message deduplication and the
message id that CtpChat uses as its React key. Nomination ids
(`call_id`) were already creator scoped and are unchanged.
Test plan: `just frontend-test`. The new test feeds a Create from
Alice and a Respond from Bob sharing one nonce and expects both to
apply, then two same-nonce messages from different authors and expects
two distinct messages.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Game-directory selection could reach update_game_directory and then fail before
peer startup, while the frontend only logged the rejected invoke. Preserve the
last accepted root until peer acknowledgement, surface backend rejection in
the settings and main-window UI, and avoid holding the published control lock
across runtime replies. Startup preflight remains fail-closed; synchronous
setup stays lexically owned through scoped_blocking so cancellation cannot
strand a partially published runtime.
Add peer-cli scenario S50 to verify invalid changes preserve the existing
library and valid changes acknowledge and refresh the library in both
directions. Modernize the fixed-size hex decoders to satisfy the current
workspace Clippy lint without changing their behavior.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed
- `deno task build` -- passed
- `just peer-cli-tests S50` -- passed
- `just build` -- passed
- `git diff --cached --check` -- passed
Add a durable, acknowledged Local network sharing switch with fail-closed
hydration, serialized mutation, and redacted ephemeral-identity diagnostics.
Keep local Call-to-Play state available while gating every network action on the
effective sharing generation.
Render revisioned verification, invalid-source retry, and sticky source
exhaustion states. Preserve opaque attempt IDs through progress delivery so
out-of-order webview events cannot attach stale bytes to a successor transfer,
and keep terminal exhaustion visible after the last source departs.
Own listeners, native invokes, persistence, dialogs, and companion-window
creation through webview close. Late creation is settled and cleaned before the
parent realm is destroyed.
Test Plan:
- `just frontend-test` -- passed (91/91)
- `just build` -- passed with TypeScript, Vite, and release Tauri compilation
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `git diff --cached --check` -- passed
Treat an unavailable actor ID or an explicit not-ready result as normal peer
startup and tell the user that LAN connection is still in progress. Clear that
message when snapshot registration succeeds.
Map obsolete, missing-history, and active-history-limit store failures to
distinct guidance without marking a healthy transport unavailable. Preserve a
generic message for unexpected publish failures.
Test Plan:
- just frontend-test
- just build
- git diff --cached --check
Pass the effective nomination deadline into the Add time action and extend
from whichever is later: that deadline or the current time. This preserves
remaining time when a call becomes ready early while still giving an overdue
call a fresh five-minute window.
Test Plan:
- just fmt
- just frontend-test
- just build
- git diff --cached --check
Keep complete running and cancelled histories visible for fifteen minutes so
peers retain the roster, chat, and outcome long enough to understand what
happened. Compact them to terminal tombstones afterward without charging
settled calls against the active-history limit.
Model running and cancelled as durable read-only frontend states, exclude
them from active badges, prune retired raw events, and document the lifecycle.
Add peer scenario S49 to prove a late joiner reconstructs a terminal call with
its roster and chat intact.
Test Plan:
- just fmt
- just clippy
- just test
- just frontend-test
- just build
- just peer-cli-tests S48 S49
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --cached --check
Deadline completion previously shared the green Ready presentation with a full
roster and remained visible forever. An abandoned call therefore looked ready
to launch and required its creator to return and cancel it.
Give elapsed calls a distinct Time's up state and a five-minute grace period in
which the creator can start or extend them. After that, both the reducer and
peer store remove the call as a unit. Filled calls remain ready until their
deadline, and active calls continue to retain complete history for late joiners.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just frontend-test` -- passed, 22 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
Calls for a game missing from the local catalog still contributed to the badge
but were discarded by both the ticker and overlay. Opening the badge could
therefore show a blank modal with no explanation.
Render those calls with an unavailable-game label, the sender and game ID, the
normal roster and chat, and safe coordination actions. The creator can mark the
match started, but automatic launch remains disabled until catalog data exists.
Test Plan:
- `just fmt` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
Participant maps and creator authorization previously used display names, so
two peers left at the default Commander name collapsed into one participant
and could exercise each other's creator controls through the normal client.
Carry a stable actor_id separately from actor_name. The peer overwrites actor_id
on every local publish, and live event envelopes are accepted only when the
known peer, source, and event actor match. The frontend keys participants and
authorization by actor_id while retaining actor_name for display. This follows
the trusted-LAN model and is not cryptographic authentication against a hostile
peer.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
Implement the launcher design as a production peer-to-peer feature. Call to
Play actions are immutable, validated events broadcast over the existing QUIC
control channel, deduplicated in a bounded in-memory history, and exchanged in
Hello/HelloAck so late joiners reconstruct current calls.
Add the Tauri bridge and modular launcher surfaces for play-now and scheduled
calls, check-in, readiness buffers, role-aware controls, chat, tickers, and
actual caller launch. A deterministic frontend reducer derives presentation
state from replicated history. Extend the JSONL peer harness with publish/list
commands and a three-peer live-delivery and late-join scenario.
This intentionally raises the only supported wire protocol from version 5 to
version 6; older builds are not supported. Document the transport architecture
and exclude generated peer-test state from Docker build contexts.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 20 tests
- `just build` -- passed
- `just peer-cli-tests S2 S48` -- passed
- `git diff --cached --check` -- passed
NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be
clear to users. Keep streamed installs in the installed visual state so
sorting, filters, and the primary Play action stay unchanged, but make the
sharing limitation visible in the UI.
Cards now label that state as `Not shareable`, while the detail modal
status says `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` wording.
Test Plan:
- just frontend-test
- just build
- git diff --check
- git diff --cached --check
Refs: NEXT_STEPS.md item 7
NEXT_STEPS item 1 called out that streamed install was still CLI-only
because the Tauri app started the peer with no stream provider. Users can now
choose an explicit "Low disk install" action from the game detail modal for
remote-only games instead of taking the default archive-preserving download
path.
The GUI command queues a normal peer detail fetch first so the peer database
has the file metadata needed for source validation. A small pending handoff in
Tauri routes the resulting GotGameFiles event into StreamInstallGame instead
of DownloadGameFiles, and clears that pending state on no-peer or download
failure events. This keeps the existing download continuation untouched for
the default action.
The external unrar stream provider moved from the CLI harness into
lanspread-peer so CLI and Tauri use the same implementation. Tauri resolves
the bundled unrar sidecar path and injects that provider at peer startup;
falling back to the noop provider keeps peer startup alive if the sidecar
cannot be resolved, while the streamed install operation still fails safely.
Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- just build
- git diff --check
Refs: NEXT_STEPS.md item 1
Add an Application Logs window backed by a bounded persistent main log file.
The viewer loads history from lanspread.log, subscribes to live INFO/WARN/ERROR
log events, supports filtering/copy/pause controls, and keeps the menu/window
routing separate from the unpack log viewer.
The backend sink now owns serialized access to the log file. History reads and
append-time trimming use the same sink lock, so opening the logs window cannot
race with a concurrent write and rewrite away a freshly appended line. The sink
also keeps a persistent file handle instead of reopening the file for each
captured event.
Live log events carry sink-local sequence ids. The frontend uses the history
watermark plus returned history line counts to suppress live events that were
already included in the history response, while preserving buffered rows that
were trimmed out of the history file. Auto-scroll now follows the last visible
row identity, so it continues following after the in-memory cap keeps the row
count stable.
No timestamp code change was needed. On the Linux dev host, a temporary probe
showed time::OffsetDateTime::now_local() returning +02:00 while UTC was +00:00,
matching the host CEST offset.
Test Plan:
- just fmt
- just frontend-test
- just test
- just clippy
- just build
- git diff --cached --check
- temporary Linux probe of OffsetDateTime::now_local() showed local +02:00
Refs: none
Updating or removing a local game rewrites its on-disk files. Peers that
were mid-download of that game would keep streaming bytes from files that
are being deleted or replaced, handing them a corrupt or stale copy.
There was also no authoritative notion of which game version a peer
should serve or accept, so a peer could serve whatever happened to be on
disk and downloaders could aggregate files from peers running mismatched
versions.
This introduces a reader-writer coordination scheme between outbound file
transfers (readers) and local mutation operations (writers), and gates
both serving and downloading on an authoritative game catalog version.
Reader-writer coordination:
- Track active outbound transfers per game in a shared `OutboundTransfers`
map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and
registered by a `TransferGuard` in the stream service. The guard is
registered *before* the serve-eligibility check to close a TOCTOU window
where a writer could miss an in-flight reader.
- `stream_file_bytes` now honors a cancellation token at every await point
(file read, network send, stream close) via `tokio::select!`, so a
transfer aborts promptly instead of hanging on a stalled receiver.
- `begin_operation` marks a game active first, then cancels its outbound
transfers and waits for the count to reach zero before any
Updating/RemovingDownload work touches the filesystem.
- Active games are now hidden from library snapshots entirely while an
operation is in flight, instead of freezing their last announced state,
so peers stop discovering a game that is being mutated.
Authoritative version catalog:
- Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each
game id to its expected version (from the bundled game.db / ETI data).
- Serving requires the local `version.ini` to match the catalog version
(`local_download_matches_catalog`); peer selection, file aggregation,
and majority size validation all filter on the expected version
(`peers_with_expected_version`, `aggregated_game_files`, and friends).
User-visible changes:
- The GUI shows confirmation dialogs before Update and Remove, and
surfaces a sharing-status indicator on game cards and the detail modal.
- A new `OutboundTransferCountChanged` event lets the UI reflect live
outbound transfer activity.
Test Plan:
- just test
- just frontend-test
- just clippy
Some games include a language.txt marker in the unpacked local tree, similar
in spirit to account_name.txt. Installs and updates now carry the launcher
language alongside the account name so those game-provided marker files are
rewritten before staged files are promoted into local/.
The Tauri command boundary keeps the UI setting vocabulary as de/en, then maps
it to the file vocabulary expected by games: german or english. Unknown values
continue through the existing DEFAULT_LANGUAGE path, so the marker file falls
back to english just like script launch arguments fall back to en.
The transaction layer deliberately reuses the same first-match traversal helper
for both marker files. The searches stay independent, so games may place
account_name.txt and language.txt in different directories if their archive
layout requires that.
Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- deno task build
- git diff --check
Refs: none
Some ETI game descriptions include the literal string <br> in metadata.
React renders descriptions as text, so the marker appears to users instead
of being treated as a line break.
Strip only the exact <br> token at the detail modal boundary. This keeps
the fix UI-only and avoids treating descriptions as HTML or normalizing any
other markup-like text.
Test Plan:
- just frontend-test
- git diff --check
Refs: none
Some games ship an `account_name.txt` file somewhere under the unpacked
`local/` tree (location varies per game). After install or update, write
the configured username into the first such file we find so the game
launches under the user's account instead of whatever default the
archive contains.
The search is a deterministic alphabetical DFS rooted at the install
staging dir (`.local.installing/`, which becomes `local/` on rename),
stopping at the first regular-file match. Symlinks named
`account_name.txt` are skipped (`is_file()` is false for symlinks on
Linux), so a hostile archive can't redirect the write outside the game
tree. If no `account_name.txt` exists anywhere in the install, the step
is a no-op. If the write fails, the existing install rollback (cleanup
of staging on fresh installs, restore from backup on updates) handles
it — no partial state is left behind.
The username flows from the Tauri layer, where it is already sanitized
by `sanitize_username`, down through `PeerCommand` variants
(`InstallGame`, `DownloadGameFiles`, `DownloadGameFilesWithOptions`)
into `install`/`update`, which now take an `Option<&str> account_name`.
For the "install game that isn't downloaded yet" path the username has
to bridge the async gap between the `GetGame` / `FetchLatestFromPeers`
request and the eventual `GotGameFiles` event; we park it in a
per-game-id map on `LanSpreadState` and pop it when forwarding the
download command. The map is also cleared defensively on
`cancel_download`, `DownloadGameFilesFailed`, and
`DownloadGameFilesAllPeersGone` so a stale entry can't bleed into a
subsequent install with a different username.
`PeerCommand` is the in-process command channel, not the wire protocol;
no on-wire types changed, so the "one wire version" policy is
preserved. The peer-cli harness keeps passing `account_name: None`
since it tests peer interop, not user-facing settings.
# Test Plan
Unit tests in `crates/lanspread-peer/src/install/transaction.rs`:
- `install_overwrites_first_account_name_file` — unpacker creates
`a/account_name.txt` and `z/account_name.txt`; after install with
username "Alice", `a/` is overwritten and `z/` is left untouched,
pinning the sorted-DFS "first match wins" behavior.
- `install_account_name_missing_file_is_noop` — install with a
username but no `account_name.txt` anywhere in the archive
succeeds and creates no spurious file.
Manual GUI check: in Settings, set a username; install a game whose
archive contains `account_name.txt`; open `local/` and confirm the
file now holds the configured username. Repeat for the update flow
(install, change username, click update).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The design update moved game-folder configuration out of launcher chrome and
into Settings > Library. Follow that contract in the runtime UI without
changing the existing storage or Tauri directory commands.
The top bar now leaves its right edge for the kebab menu. Settings owns a new
Game folder row that shows a valid selected path with a neutral Change button,
or the red Not set state with a stronger Choose button when no accessible
directory is configured. Both the empty-library state and the Settings row
still use the existing native directory picker, so existing saved paths and
rescans keep their current behavior.
Keep useGameDirectory as the directory-state owner and expose the shared
hasGameDirectory boolean from that hook so the grid and Settings field agree on
what counts as configured.
Test Plan:
- git diff --cached --check
- just frontend-test
- just build
Refs: 62b409f4bfc4995c25461776107d28f52b24f30e
Unpack logs lived only in memory, so closing the app dropped history.
Unrar progress also flooded stdout with carriage-return redraws, which
made the log viewer noisy and hard to search.
Persist the last twenty entries to unpack-logs.json under the app data
directory, load them on startup, and rewrite stdout/stderr through a
small terminal-sequence cleaner (CR/LF, backspace, control chars) before
storage and display. Sort the unpack-logs window newest-first by finish
or start time.
Test plan:
- cargo test -p lanspread-tauri-deno-ts -- terminal_log unpack_log
- Run an unpack, restart the app, open unpack logs: prior entries remain
- Confirm progress lines collapse to final text instead of spam
Co-authored-by: Cursor <cursoragent@cursor.com>
Typing the first character into the launcher search field used to insert the
clear button into the flex row, which widened the field after focus. The clear
button now stays mounted in the row so its slot is part of the empty field's
layout, while the empty state hides and disables it.
Users now get the wider steady-state search width immediately, and the control
does not jump when the clear affordance becomes visible.
Test Plan:
- git diff --check
- just frontend-test
- just build
Refs: user-reported search field jump
Ctrl+F is a common search shortcut and should open the launcher search just
like the existing slash shortcut. Handle it in the same SearchField keydown
listener so the behavior stays scoped to the topbar search component.
The shortcut is ignored while the user is already typing in another input or
textarea, matching the existing slash behavior. When handled, it prevents the
webview's browser find UI and focuses the app search field instead.
Test Plan:
- `just frontend-test`
Refs: none
Follow the latest top-bar spec for the Game Folder control. The button stays
as only the folder icon and short label, with the full path in tooltip and aria
text, and now sits in the app-level control group with the kebab menu on the
far-right edge of the wide top bar.
This keeps Sort as the right-zone lead control next to the centered search
cluster while treating Game Folder and the kebab menu as a tight trailing pair.
The narrow fallback still flows in source order: sort, game folder, kebab.
Test Plan:
- git diff --check
- rg -n "dirbtn-status-dot|status dot|green status|red status" crates/lanspread-tauri-deno-ts/src
- just frontend-test
- just build
Validate the persisted game directory before sending it to the backend or
showing library content for it. When the saved path no longer exists, the
launcher keeps the top bar visible but shows the folder picker empty state
and labels the Game Folder button as an unset folder.
This keeps stale local data from being presented as the active library when
an old path is deleted or disconnected.
Test Plan:
- git diff --check
- just frontend-test
- just build
Implements the v2 design's game-folder button in the Tauri launcher.
The previous control squeezed the full game-directory path into the
top-bar button as truncated monospace (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading
ellipsis rarely showed the meaningful part of the path, ate
horizontal space the new 3-zone top bar needs for its primary
controls, and competed with the filter / search / sort cluster for
attention.
The button now communicates the *state* of the configuration at a
glance — an icon + short label + colored status dot — while the full
path moves into the native tooltip and `aria-label`, where it stays
one mouseover (and screen-reader friendly) away.
Two visual states, both 36 px tall and sharing the surface of the
other top-bar controls:
- **Set & valid** (`.dirbtn-set`) — label `Game folder`, green dot
(`--ok`) with a soft glow, default border, tooltip = full path.
- **Not set / invalid** (`.dirbtn-unset`) — label `Set game folder`,
red dot (`--danger`) with a soft glow, a red-tinted border, and a
faint red wash on hover so the bad state reads as "this is what
you need to fix". Tooltip = `Please select a game folder`.
`DirectoryButton` now takes `path: string | null` and picks the
state from `!!(path && path.trim())`. The `aria-label` carries the
full state in words (`Game folder: <path>` / `Set game folder`) so
screen readers don't have to interpret the colored dot.
Note: in the current `MainWindow`, `gameDir` is gated upstream — if
no directory is selected, `NoDirectoryState` is shown instead of the
top bar, so the unset state will only surface here if we later
validate disk existence and clear `gameDir`. The button accepts a
nullable path anyway, so it's ready when that check lands.
`truncatePath` in `lib/format.ts` was the only caller-less helper
left behind and is removed.
Test Plan
- `npx tsc --noEmit` from the frontend crate — clean.
- `just frontend-test` — passes.
- Manual: `just run`, pick a valid game directory, and confirm the
top-bar button reads `Game folder` with a green dot; hovering it
reveals the full path in the OS tooltip. Inspect the DOM and
confirm `aria-label` reads `Game folder: <path>`. (The unset
variant currently isn't surfaced by `MainWindow`; eyeball it via
DevTools by toggling the `dirbtn-set` class to `dirbtn-unset`.)
Implements the v2 design's top-bar reorganization in the Tauri
launcher. The bar was previously a flat flex row that let the search
field drift left or right depending on filter / sort widths; now it's
a 3-column CSS grid with the search field pinned to the geometric
center of the window.
- `.topbar` becomes `display: grid` with
`grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` and a
16 px column gap. The middle (auto) column holds only the search,
capped at `flex: 0 1 360px` so it cannot push into the side columns.
- The left zone is `flex; justify-content: space-between`: brand
pins far-left, filter pills hug the search. The filter pills are
now grouped with the search semantically (they scope it) instead of
floating next to the brand.
- The right zone mirrors that: sort hugs the search, kebab pins
far-right, with the directory button between them.
- A `@container launcher (max-width: 1100px)` rule collapses the
layout back to a single nowrap flex row at narrow widths — the
geometric centering doesn't read at small widths and would force
awkward truncation, so we abandon it rather than fight it. The
launcher root opts into container queries via
`container-type: inline-size; container-name: launcher`.
`TopBar.tsx` now wraps the existing children in `.topbar-left`,
`.topbar-center`, `.topbar-right` (plus `.topbar-left-trail` /
`.topbar-right-lead` for the inner space-between alignment), but each
control component is otherwise untouched.
Test Plan
- `just frontend-test` — passes.
- `npx tsc --noEmit` from the frontend crate — clean.
- Manual: run `just run`, confirm the search input's horizontal center
matches the window's horizontal center across the standard launcher
width. Shrink the window below 1100 px and confirm the row collapses
to a single left-to-right strip with no overlap or wrapping.
The detail view already exposes View Files while a game is downloaded,
installed, or actively downloading. During the download-to-install handoff,
the backend can report Installing before the local downloaded/installed flags
have settled in the next snapshot, which briefly hid the file viewer button.
Treat Installing as another state where the game root should remain reachable.
This keeps the detail view stable during the handoff without changing backend
file-opening behavior.
Test Plan:
- just frontend-test
The launcher settings dialog now shows a Build-Nr value in its footer so users
can identify the exact application build without manually updating a number
before release.
The value is generated by Vite when the frontend bundle is built and injected
as an import.meta.env constant. Using the current millisecond timestamp keeps
the number numeric and monotonic for normal builds without a checked-in counter
file or any extra build-state mutation. The tradeoff is that the value follows
the build machine clock rather than a central sequence service.
The footer layout now keeps the build number on the lower left and the Done
button on the lower right.
Test Plan:
- just frontend-test
- just build
- git diff --cached --check
Refs: local user request
Add launcher profile settings for username and language, then thread those
values into the Windows script launch path. The game setup, game start, and
server start scripts now share the same argument shape:
- game path: local
- game id
- language: en or de
- player name
Expose a local can_host_server flag in the games payload by checking for
server_start.cmd in an installed game's root directory. The detail modal uses
that flag to show Start Server only for installed games with the script, and
the new start_server command invokes server_start.cmd with the same sanitized
settings used by game_setup.cmd and game_start.cmd.
Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check
Refs: design/README.md
Mirror the design-doc update in the actual download progress component
so the GUI matches the trimmed chip. Previously the large progress
panel rendered `[icon] from N peer(s)` inline; now it renders just
`[icon] N`, with the full "Downloading from N peers on the LAN"
sentence retained as the `title` tooltip for discoverability.
Changes:
- `DownloadProgress.tsx` (lg variant): drop the "from" / unit text from
the inline span, keeping only the count. `peerUnit` stays in scope
because the tooltip still needs singular/plural.
- `launcher.css`: collapse `.dl-peers` and `.dl-peers strong` into a
single rule that puts the t-1 colour, 600 weight and tabular-nums
directly on the chip (the inner `<strong>` no longer exists). Gap
drops from 5px to 4px to match the tighter icon+number layout.
- Container queries: peers drops at <=240px and ETA drops at <=320px,
matching the new thresholds in the design reference. The narrower
chip simply fits in smaller modals, so the old 300/380 cutoffs were
hiding stats that would have rendered fine.
Test Plan
- `just frontend-test` (passes)
- `just run`, start a download, confirm the chip reads `[icon] N`,
hover shows the tooltip, and narrowing the window collapses ETA
before peers at the new breakpoints.
Render the active peer count already carried by download progress events in the
large download progress control. The peer chip appears between speed and ETA,
uses singular/plural copy, and hides after the ETA when the detail modal gets
very narrow.
This keeps the UI aligned with the design reference without changing backend
state ownership or download progress plumbing.
Test Plan:
- git diff --check
- git diff --cached --check
- just frontend-test
- just build
The launcher needs design work later for showing how many peers are currently
feeding an active download. Surface that data now on the existing progress
payload so UI state can consume it without a separate event stream or rendering
change.
The peer download tracker now treats each live chunk receive as peer activity
and reports the number of unique peers with in-flight streams. This is a live
transfer count, not the number of peers that advertised the game or received a
plan. Multiple chunks from one peer count once, and the count falls as chunk
streams finish.
Tauri already forwards DownloadGameFilesProgress, so no bridge event was added.
The TypeScript model accepts active_peer_count under download_progress and
preserves it with the same reducer path that keeps bytes and speed while the
backend says the game is still downloading.
Test Plan:
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- just frontend-test
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- git diff --check
- git diff --cached --check
Refs: none
Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.
Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.
Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check
Refs: design reference e308009a08
Previously the action button only said "Downloading…" with no indication of
how far along the transfer was or how fast it was going. With multi-gigabyte
game payloads on a LAN this gave the user no signal whether the download had
stalled, was hitting the wire fast, or was about to finish.
Wire a sampled byte-level progress channel from the download pipeline up to
the action button:
- New `DownloadProgressTracker` in `crates/lanspread-peer/src/download/progress.rs`
holds the total expected bytes plus two atomic counters: `downloaded_bytes`
(deduplicated per `(relative_path, offset)` chunk key, used for the bar) and
`transferred_bytes` (raw cumulative, used for the speed sample). The dedup
prevents a retried chunk from double-counting toward completion while still
letting speed reflect actual wire activity including retry waste, which is
the more useful metric for "is the link doing anything right now?".
- `sample_download_progress` wraps the transfer future, emits an initial 0 B/s
snapshot, then samples on a 500 ms interval (`MissedTickBehavior::Skip` so a
stalled downloader does not generate a thundering herd of catch-up ticks)
and emits one final snapshot when the future resolves, so the UI sees the
closing state before `DownloadGameFilesFinished` arrives.
- New `PeerEvent::DownloadGameFilesProgress(DownloadProgress)` variant carries
`{ id, downloaded_bytes, total_bytes, bytes_per_second }`. The Tauri shell
forwards it as `game-download-progress`; the JSONL harness emits it as
`download-progress`.
- Orchestrator and retry paths refactored to thread a single shared
`Arc<DownloadProgressTracker>` through both the initial transfer and any
retry attempts. New `TransferContext`, `RetryContext`, and `ChunkPlanContext`
structs absorb the parameter-list growth that came with adding the tracker.
Frontend rendering honors the snapshot-is-authoritative decision from commit
`5df82aa` ("fix(ui): derive operation status from snapshots"):
- `Game.download_progress` is an ephemeral overlay carried alongside the card,
not a status field. `mergeGameUpdate` preserves it only while
`install_status === Downloading` and otherwise clears it on the next
snapshot, so the games-list snapshot remains the single authority for when
the bar should disappear.
- The `game-download-progress` listener writes ONLY `download_progress` — it
does not touch `install_status`, `status_message`, or `status_level`. This
preserves the rule that lifecycle events never mutate card status.
- No `game-download-finished` listener; snapshot reconciliation clears the
overlay automatically when status leaves Downloading.
- `ActionButton` renders a percentage fill behind the icon/label via a
`--download-progress` CSS custom property; the existing `.act-busy` spinner
is layered above the fill with `z-index: 1`. `act-downloading` widens the
button to avoid label jitter as the speed number changes (tabular-nums).
- `actionLabel` for the Downloading status now appends a formatted speed
("Downloading… 12.5 MB/s") via the new `formatBytesPerSecond` helper.
Test Plan:
- `just test` — Rust workspace tests including new progress tracker unit tests
(`tracker_counts_only_new_bytes_for_a_retried_chunk`,
`tracker_clamps_reported_bytes_to_total`).
- `just frontend-test` — Deno tests including
`download progress is preserved only while actively downloading` and
`downloading action label includes current speed`.
- `just clippy` — clean.
- Manual: download a multi-GB game from a peer and watch the action button
fill, speed update on the half-second, and reset cleanly on completion.
Refs: download progress visibility, snapshot-authoritative UI architecture
The launcher was mixing lifecycle event handlers with the games-list snapshot
when deciding the card status. That left multiple writers for the same
install_status field and made event ordering visible in React.
Make games-list-updated active_operations the authoritative source for busy
status. Lifecycle events no longer mutate the card status; they only keep their
non-status side effects such as rescans and error messages. The only remaining
optimistic status is CheckingPeers before the backend emits its next snapshot.
Add a frontend reducer test that proves an install stays in Installing while an
active install snapshot exists, then settles to Installed only after the active
operation clears with installed local state.
Test Plan:
- git diff --check
- just fmt
- just frontend-test
- just build
Refs: local install/download status snapshot cleanup
Address backend early-return paths that were silently exiting without emitting a
terminal event to the UI, and align the UI transition to "Downloading" with the
actual start of the chunk transfer.
- Added `DownloadGameFilesFailed` event emissions to `handlers.rs` in the
unhandled early-return branches (when resolved file descriptions are empty or
when no trusted peers are found without a local copy). This prevents the UI
from getting stuck in a checking state.
- Updated the frontend `'game-download-pre'` listener to keep the status in
`CheckingPeers` during peer majority size validation, and let the UI switch
to `Downloading` only upon `'game-download-begin'`.
- Added clarifying comments explaining the safety and semantic roles of both
listeners.
Test Plan:
- Run all unit tests to ensure no regressions: `just test`
- Compile and build the Tauri project: `just build`
Downloading a game could keep showing "Checking peers" while the backend was
already transferring files. The frontend owned a five-second fallback that could
invent a no-peers error during a valid long download, then return the action to
Download until install began.
Remove that frontend timer and make the peer lifecycle authoritative instead.
The UI now treats CheckingPeers as only an optimistic click response, ignores it
if a real operation is already in progress, and switches to Downloading when the
existing game-download-pre bridge reports that peer metadata was found.
A review found one backend path that previously had no terminal event: candidate
peers existed, but every peer detail request failed before GotGameFiles. That
path now emits DownloadGameFilesFailed so the UI can leave CheckingPeers without
falling back to a frontend guess.
Test Plan:
- just fmt
- just clippy
- just test
- just build
- git diff --check
Refs: local review P2
The library sort setting used `size` for largest-first sorting while the
ascending option used `sizeAsc`. That made the pair asymmetric and left the
current settings model carrying a legacy-looking key.
Rename the current descending key to `sizeDesc` in the type, menu, and sort
logic. Stored `size` values are normalized to `sizeDesc` on read, so existing
users keep the same largest-first behavior while new writes use the explicit
key.
Test Plan:
- deno task build
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
- git diff --check
Refs: local review feedback
Confirming removal from the game detail modal used to clear only the
confirmation modal state. The detail modal remained open for the same game while
the removal operation was in flight, which could show stale removing or
post-removal state around the closed confirmation dialog.
Close the detail modal when it is showing the game whose downloaded copy is being
removed. Other open detail state is left alone so the change stays scoped to the
confirmed removal flow.
Test Plan:
- deno task build
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
- git diff --check
Refs: local review feedback
Downloaded but uninstalled games can still occupy significant disk space. Add a
separate removal path for that state instead of overloading uninstall, which is
reserved for deleting only `local/` installs.
The peer runtime now exposes `RemoveDownloadedGame` with matching lifecycle
and active-operation events. The filesystem delete is intentionally strict: the
id must be a catalog game and a single path component, the target must be a
direct child of the configured game directory, the root must not be a symlink,
it must have a regular root-level `version.ini`, and it must not contain
`local/`, `.local.installing/`, or `.local.backup/`. Only then do we recursively
remove the game root.
The Tauri bridge exposes this as `remove_downloaded_game`, the frontend shows a
matching danger action only for downloaded-but-uninstalled games, and a
confirmation dialog warns that re-downloading can take a long time.
Test Plan:
- git diff --check
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
Refs: user redesign nitpick about removing downloaded uninstalled games
The redesigned action hook marked every accepted install command as Checking
Peers. That is correct while the launcher is asking peers for file details, but
it is wrong for a game that is already downloaded and only needs local archive
installation.
Track the already-downloaded path separately and optimistically show Installing
until the backend install lifecycle event arrives. Peer-backed downloads keep the
existing Checking Peers state.
Test Plan:
- git diff --check
Refs: user redesign nitpick about install button state
Search now exposes a small icon-only clear button whenever a query is present.
Clicking it clears the term in one step and returns focus to the input so users
can immediately type a replacement.
The button uses the existing topbar styling language and a compact circled-x
icon alongside the keyboard hint.
Test Plan:
- git diff --check
Refs: user redesign nitpick about one-click search clearing
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