Commit Graph
100 Commits
Author SHA1 Message Date
ddidderr 738095235f feat(peer): coordinate outbound transfers with local game mutations
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
2026-05-30 16:36:58 +02:00
ddidderr 18f21bdf30 fix(launch): stamp first-use settings on every launch path
First-use launch settings moved out of install/update transactions, but three
edge cases could still leave archive stub values in place while the marker said
the settings pass had already happened.

Start Server now runs the same stamping preflight as Play before launching
server_start.cmd. That covers games whose server scripts read account_name.txt,
language.txt, or SmartSteamEmu.ini before the user ever presses Play.

Install and update now reset the per-game marker before committing
InstallIntent::None. Recovery also clears the marker for install/update states
where the new local tree has already landed, so a crash after promotion cannot
publish a clean intent while preserving a stale marker. Rollback recovery keeps
the marker, because the old local tree remains the active install.

SmartSteamEmu.ini stamping now searches every matching file until one contains a
PersonaName line. This keeps a decoy or incomplete INI from permanently blocking
the real one while still preserving early-exit behavior for account_name.txt and
language.txt, which only need the first matching file.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check

Refs: local review findings
2026-05-29 06:52:00 +02:00
ddidderrandClaude Opus 4.8 09709cc008 feat(peer): stamp launcher settings on first play, add PersonaName rewrite
Some games ship a SmartSteamEmu.ini somewhere under their installed
local/ tree with a `PersonaName = ...` line that must carry the player's
configured username. They also ship account_name.txt and language.txt
files that the launcher already overwrote with the username/language.

Previously that account_name.txt/language.txt overwrite happened inside
the install transaction, so it only applied to freshly (re)installed
games — games already installed by an older build never got fixed up,
and the SmartSteamEmu.ini PersonaName line was not handled at all.

This moves all per-user setting application out of install and into a
single one-shot step performed the first time a game is played, gated by
a new per-game marker `games/<id>/launch_settings_applied` under the
state dir. On first play we search the whole local/ tree and stamp:

  - the username into the first account_name.txt,
  - the language into the first language.txt,
  - the username into the first SmartSteamEmu.ini PersonaName line,
    preserving that line's existing line ending (\n or \r\n) and its
    surrounding whitespace, leaving sibling lines untouched.

The marker only records that we *tried*: it is written unconditionally
after the first play, so a game with none of these files is still marked
done and never rescanned. Because already-installed games have no marker
yet, they are fixed up on their next play rather than only on reinstall.

To keep the marker honest across version changes, the install and update
transactions now clear it on success, so a freshly extracted local/ is
re-stamped on the next play.

Behavior changes from the user's perspective:
  - The first time you press Play after this change, your username/
    language are (re)applied to an existing install, including games you
    installed before this feature existed.
  - SmartSteamEmu.ini's PersonaName now reflects the launcher username.

Plumbing: account_name/language are removed from PeerCommand::InstallGame
/DownloadGameFiles[WithOptions] and the whole install handler chain, and
the Tauri pending_install_settings bookkeeping is gone — the launcher now
computes the values at play time in run_game and calls
lanspread_peer::apply_launch_settings_once. The headless harness gains a
`play` command exposing the same step for scripted testing.

Test Plan
  - just test: new lanspread_peer::launch_settings unit tests cover the
    PersonaName rewrite, \n/\r\n preservation, first-match search, the
    unconditional marker, and the no-op-once-applied path; a transaction
    test covers the install marker reset. Whole workspace is green.
  - just clippy clean; the change adds no new clippy warnings (incl.
    --tests).
  - S38 (new in PEER_CLI_SCENARIOS.md): host run of lanspread-peer-cli
    against the new fixture-persona/css RAR .eti (with --unrar) installs
    css, then `play css` stamps the deeply-buried CRLF PersonaName line,
    account_name.txt, and language.txt and creates the marker; a second
    `play` is a no-op even after the values are reset externally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:51:59 +02:00
ddidderr 9bafd981d7 feat(install): write launcher language marker files
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
2026-05-21 22:24:59 +02:00
ddidderr e06a887da1 fix(ui): strip literal br tags from game descriptions
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
2026-05-21 21:56:42 +02:00
ddidderrandClaude Opus 4.7 574acfca45 feat(install): stamp username into account_name.txt after install
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>
2026-05-21 21:32:29 +02:00
ddidderr 2e7a0cff2f feat(ui): move game folder picker into settings
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
2026-05-21 21:32:29 +02:00
ddidderrandCursor eedfc0105d feat(tauri): persist unpack logs and clean sidecar output
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>
2026-05-21 21:32:28 +02:00
ddidderr 19ae1938f6 docs(design): move game folder selection into settings
Move the design contract for choosing the library game folder out of the top
bar and into Settings > Library. The launcher chrome now reserves the right
edge for the kebab/menu controls, while Settings owns the required folder path
and its set/unset states.

The reference mock now persists `gameFolder: string | null` instead of a
boolean, adds an exercisable GameFolderField, and includes matching CSS so the
prototype reflects the documented interaction. This is still design/reference
only; no runtime Tauri settings code changes here.

Test Plan:
- git diff --cached --check

Refs: none
2026-05-21 21:32:28 +02:00
ddidderr a7d99261cf fix(ui): reserve search clear button space
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
2026-05-21 21:32:28 +02:00
ddidderr debb1c0c49 feat(ui): focus search with Ctrl+F
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
2026-05-21 21:32:28 +02:00
ddidderr 0151d7a16c style(ui): match game-folder topbar design
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
2026-05-21 21:32:28 +02:00
ddidderr 31ace174e3 fix(ui): treat missing game folders as unset
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
2026-05-21 21:32:28 +02:00
ddidderr e0efb69bf0 design docs: no green/red light on Game folder 2026-05-21 21:32:28 +02:00
ddidderr 059c1e7720 feat(ui): redesign game-folder button as icon + label + dot
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`.)
2026-05-21 21:32:28 +02:00
ddidderr 095bc9b9ff feat(ui): center search in a 3-zone top bar
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.
2026-05-21 21:32:28 +02:00
ddidderr b169a05c31 docs(design): redesign game-folder button as icon + label + dot
The previous design squeezed the full game-directory path into the
top-bar button as truncated `ui-monospace` (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading-ellipsis
truncation rarely showed the meaningful part of the path on real-world
configurations, ate horizontal space the new 3-zone top bar needs for
its actual primary controls, and competed with the filter / search /
sort cluster for attention.

Replace the inline path with an icon + short label + colored status
dot. The full path moves into the tooltip and `aria-label`, where it's
still one mouseover (and screen-reader friendly) away. The button now
communicates the *state* of the configuration at a glance — which is
what users actually need.

Two visual states, both 36 px tall with the same surface as the other
top-bar controls:

- **Set & valid** — label `Game folder`, green dot (`--ok`) with a
  soft glow, default border, tooltip = full path.
- **Not set / invalid** — 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`.

"Invalid" (a path is stored but doesn't exist on disk) is collapsed
into the same visual state as "not set" — the user's required action
is identical (open the picker, pick a folder), so a third state
isn't worth the visual budget yet. If we later want to surface a
*last-known* path so the user can re-attach an external drive,
introduce a distinct missing state then.

Implementation notes:

- `DirectoryButton` now takes a single `path: string | null` prop and
  picks state from `!!(path && path.trim())`. Children are
  `Icon.folder`, the label, and an 8 px `.dirbtn-status-dot` sibling
  — the dot is an inline flex sibling, not a corner badge, because
  the button is now wider than tall and a corner pin would feel
  misplaced.
- `.dirbtn` is `inline-flex` with `padding: 0 14px 0 12px`, gap 8 px,
  `white-space: nowrap`, and `flex-shrink: 0`. The `max-width: 360px`
  cap from the path-truncation era is gone — the button is now
  intrinsically sized.
- Dot glow uses `box-shadow: 0 0 6px color-mix(...)` so it still
  reads through the launcher's translucent top-bar background.
- The Tweaks panel grows a dev-only `Game folder set` toggle (under
  the new *Library* section) that flips a `gameFolderSet` flag wired
  into the Launcher, so reviewers can see both states without
  fiddling with real filesystem state. The README explicitly calls
  this out as **dev-only** — production state comes from the
  settings store, not a user-facing toggle.

The README gains a new *Game-folder button* section with the full
spec, a state table, and a rationale paragraph; the "Changes since v2"
list and the interactions list are updated to reflect the new label
and behavior.

Test Plan

- Open `design_reference/SoftLAN Launcher.html` and locate the Tweaks
  panel's *Library → Game folder set* toggle.
  - With the toggle **on**: the top-bar button shows `Game folder`
    with a green dot; hovering the button reveals the full mock path
    in the native tooltip.
  - With the toggle **off**: the label switches to `Set game folder`,
    the dot turns red, the border picks up a red tint, and hovering
    the button reveals `Please select a game folder`.
- Inspect the button with a screen reader / DevTools accessibility
  pane: the `aria-label` should read `Game folder: <path>` when set
  and `Set game folder` when unset.
2026-05-21 21:32:28 +02:00
ddidderr 6e28f736e8 docs(design): center search in a 3-zone top bar
The single-row top bar was a flat flex row, which made the search field
drift left or right depending on how wide the surrounding clusters were.
Rework it as a 3-column CSS grid (left zone, search, right zone) so the
search input lands at the geometric center of the window regardless of
the side-zone widths.

Mechanics:

- `.topbar-single` 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, which is
  capped at `flex: 0 1 360px` so it cannot push into the side columns.
- The left and right zones are flex containers with
  `justify-content: space-between`, so brand pins far-left while filter
  pills hug the search, and sort hugs the search while the kebab pins
  far-right. The filter pills are now grouped semantically with the
  search (they scope it) instead of floating next to the brand.
- A `@container launcher (max-width: 1100px)` rule collapses the layout
  back to a single nowrap flex row at narrow widths — the geometric
  centering stops reading at narrow widths and would otherwise 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`.

`launcher.jsx` now wraps the existing children in `.topbar-left`,
`.topbar-center`, `.topbar-right` (plus a `.topbar-left-trail` /
`.topbar-right-lead` for the inner space-between alignment), but each
control component is otherwise untouched.

The README's "Top bar" section is rewritten to spec the new layout, and
a new "Changes since v2" section calls it out at the top of the
handoff. The game-directory button line is left as-is in this commit
and addressed separately.

Test Plan

- Open `design_reference/SoftLAN Launcher.html` in a static server and
  inspect variant A at full width: the search input's horizontal center
  should match the window's horizontal center, regardless of accent /
  density choices in the Tweaks panel.
- Shrink the launcher artboard below 1100 px and confirm the row
  collapses to a single left-to-right strip with no overlap.
2026-05-21 21:32:28 +02:00
ddidderr 8d96d99160 fix(ui): keep file viewer visible while installing
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
2026-05-21 21:32:28 +02:00
ddidderr a913e4c776 feat(settings): show automatic build number
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
2026-05-21 21:32:28 +02:00
ddidderr a5307d3d6a fix: suppress failed event for cancelled downloads
Manual download cancellation uses the same internal error path as transfer
failures after the terminal-event ordering fix. That made the Tauri UI receive
DownloadGameFilesFailed and show a red failure state even though the user had
asked for the cancellation.

Keep a clone of the cancellation token in the download task and check it after
the transfer task returns an error. Cancelled downloads still refresh local
state and clear active operation tracking, but they no longer emit the failed
event. Real, uncancelled errors continue to send DownloadGameFilesFailed.

Add unit coverage for both branches so the UI-facing event contract stays
explicit.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check

Refs: manual cancel regression from app-state follow-up
2026-05-21 21:32:28 +02:00
ddidderr 9835e77e8d feat: store launcher state outside game dirs
Move launcher-owned metadata from game roots into the configured peer state
area. Peer identity, the local library index, install intent logs, and setup
markers now live under app/CLI state instead of being written beside games.
The Tauri shell passes its app data directory into the peer, and the peer CLI
runs the same path through its explicit --state-dir.

Add a dedicated pre-start migration phase for legacy files. It migrates the
old global library index, per-game install intents, and the old first-start
marker into app state, then deletes legacy files only after the replacement
write succeeds. Normal scan, install, recovery, and transfer paths no longer
read legacy state files.

Rename the old first-start meaning to setup_done and only set it after
launching game_setup.cmd. Start/setup scripts keep the shared argument shape,
while server_start.cmd now uses cmd /k and a visible window so server logs stay
open for inspection.

While validating the Docker scenario matrix, make download terminal events
come from the handler after local state refresh and operation cleanup. This
makes download-finished/download-failed safe points for immediate follow-up CLI
commands. Also update the multi-peer chunking scenario to use a sparse archive
large enough to actually span multiple production chunks.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py

Refs: local app-state migration discussion
2026-05-21 21:32:28 +02:00
ddidderr 4f34c4a249 feat: pass profile settings to launch scripts
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
2026-05-21 21:32:28 +02:00
ddidderr 91c709960a docs: update launcher design for profile and server actions
Document the profile settings added to the launcher design and the new
Start Server detail action. The settings contract now includes a persisted
username and language choice, and the game detail overlay shows Start Server
only for installed games that can host a dedicated server.

The reference mock now includes the matching Profile controls, a server icon,
server-capable sample catalog entries, and the updated detail/settings
artboards so implementation can follow the selected design direction.

Test Plan:
- git diff --cached --check

Refs: design/README.md
2026-05-21 21:32:27 +02:00
ddidderr 12a0d7abe9 feat(ui): align peer count chip with design reference
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.
2026-05-21 00:41:10 +02:00
ddidderr b96e8c5747 docs(design): trim peer count chip to icon + bare number
The download progress "peers" chip previously rendered as
`[icon] from N peers` — a full mini-sentence sitting between the speed
and ETA stats. With four groups already separated by middots, the extra
preposition and unit made the row read as prose rather than a stat
strip, and the singular/plural switch added a second source of layout
jitter on top of the digit-width change.

Update the design reference (README, components.jsx, styles.css) so the
chip shows just the users glyph and the count, matching the visual
weight of the other stat groups. The full sentence
("Downloading from N peers on the LAN") moves to the `title` tooltip,
which keeps the affordance discoverable without spending row width on
it. The count adopts `var(--t-1)` + 600 + tabular-nums directly on
`.dl-peers` (no inner `<strong>` needed), so the chip is a single span.

Also tighten the container-query breakpoints. Removing the prose makes
the chip much narrower, so the previous 300px cutoff for hiding peers
and 380px cutoff for hiding ETA were over-eager — both stats now fit
comfortably in narrower modals. Drop them to 240px (peers) and 320px
(ETA). The pct/cancel column still never collapses.

Test Plan
- Visual review of the design reference HTML at component widths
  240px / 320px / 380px to confirm peers and ETA drop at the new
  thresholds rather than the old ones.
- Confirm the chip's tooltip still spells out the full sentence.
2026-05-21 00:40:54 +02:00
ddidderr 8acb6dc246 feat: show active download peer count
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
2026-05-21 00:32:57 +02:00
ddidderr f1e915c379 docs: document download peer count chip
Update the launcher design reference so active downloads show how many LAN
peers are currently seeding the transfer. The reference now places the peer
chip between speed and ETA, describes the singular/plural copy, and records
how the ETA and peer count collapse in narrow modal layouts.

Test Plan:
- git diff --cached --check
2026-05-21 00:30:57 +02:00
ddidderr b56f4e2757 feat(peer): expose active download peer count
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
2026-05-21 00:28:08 +02:00
ddidderr 7e97d6a83a docs(findings): note crash-during-download leaves orphan archives
While reviewing the cancel-cleanup fix (`fix(peer): delete partial files
when a download is cancelled`), it became clear that the new
`discard_cancelled_download` sweep only runs from inside the in-flight
orchestrator. If the process dies mid-download (kill, crash, power loss)
the partial `.eti` archives are left behind: `install::recover_on_startup`
calls `recover_download_transients`, which only removes
`.version.ini.tmp` and `.version.ini.discarded`. The user is left with
a card that looks "downloaded" but with corrupted archives that can only
be cleared via the explicit `Remove files` action.

Closing the gap would mean running the same discard pass during recovery
for any game root whose install intent is `None` and whose `version.ini`
is absent (intent log already distinguishes installed-and-then-broken
from interrupted-download). Not blocking — the user-initiated cancel
button is now correct in its scope; this is the symmetric crash recovery
case captured for a future cleanup pass.

Refs: c380046 (fix(peer): delete partial files when a download is cancelled)
2026-05-21 00:07:24 +02:00
ddidderr c3800461a4 fix(peer): delete partial files when a download is cancelled
Cancelling an in-flight download via `PeerCommand::CancelDownload` previously
torn down the network transfer and cleared `active_downloads`, but left the
partial `.eti` archive(s) sitting in the game root forever. The next library
scan still picked up the half-written files as a "downloaded" game, and the
only escape was the `Remove files` action. This is the symmetric fix to
`62ceb06 feat(peer): remove downloaded game files safely`: the cancel path
must clean up after itself the same way an explicit remove does.

The fix introduces a dedicated `download/storage.rs` module that owns both the
existing pre-allocation step (`prepare_game_storage`, moved out of
`planning.rs` because pure file I/O has no business sitting next to chunk
planning) and a new `discard_cancelled_download` sweep. The orchestrator
calls the sweep at every cancellation exit point, immediately after
`rollback_version_ini_transaction` so the version sentinel transients are
gone before the bulk deletion runs.

The sweep deliberately preserves a known set of names so a cancelled update
of an installed game does not destroy user-extracted files:

  - `local/`                  committed install directory
  - `.local.installing/`,
    `.local.backup/`          in-flight install transaction state, needed by
                              `install::recover_game_root` on next startup
  - `.lanspread.json`         per-game install intent log
  - `.softlan_game_installed` external softlan installer marker
  - `.sync/`                  external sync tooling

Everything else under the game root (the `.eti` archives, any nested payload
directories, partial chunk files) is removed, and the game root itself is
removed if it ends up empty. The set matches `should_ignore_game_child` in
`services/local_monitor.rs` minus the version.ini transients (which the
rollback step removes itself just before the discard runs).

Tradeoff worth knowing: this does NOT restore the pre-update `version.ini`
sentinel. `begin_version_ini_transaction` parks the existing sentinel as
`.version.ini.discarded`, and `rollback_version_ini_transaction` deletes
that file rather than renaming it back. The user-visible consequence is
that cancelling a mid-flight update of an installed game leaves the local
install playable but no longer flagged as "downloaded" — the documented
"settles as local-only" behaviour now recorded in
`crates/lanspread-peer/ARCHITECTURE.md` and `README.md`. Restoring the
sentinel on cancel was considered, but it would mean a cancelled update
keeps advertising the OLD version as Ready, which is worse than the
current outcome.

Two unrelated correctness issues that surfaced while threading cancellation
through the orchestrator are bundled in here because they belong to the
same user-visible "Cancel button works" story:

1. `download_from_peer` now races `connect_to_peer` against
   `cancel_token.cancelled()` (`download/transport.rs:314-322`). Previously
   a cancel arriving while QUIC was still in its connect handshake had to
   wait for the connect timeout to elapse before the cleanup could run.

2. The download task in `handlers.rs` now calls
   `refresh_local_game_for_ending_operation` on every terminal branch —
   success-without-install, install-handoff-failure, and the `Err(e)` /
   cancel branch — before `end_download_operation` clears
   `active_downloads`. Without this, the UI's settled snapshot on the
   cancel path could lag behind the actual file system state because the
   active-operation snapshot was cleared while the discard was still
   running, leaving a brief window where the card showed the pre-cancel
   state.

What this does NOT fix: a crash (process kill, power loss) during a
download still leaves orphan `.eti` files because `recover_download_transients`
in `install/transaction.rs` only sweeps the version.ini transients. Closing
that gap would mean calling the same discard from startup recovery for any
game root whose install intent is None and whose `version.ini` is absent.
Tracked in `FINDINGS.md` as a follow-up.

Test Plan:
- `just clippy && just test` — 102 unit tests pass, no new warnings.
- Two new storage tests:
  - `discard_cancelled_download_removes_peer_owned_payload` exercises the
    fresh-download cancel (no `local/`, root sweeps clean).
  - `discard_cancelled_download_preserves_local_install_state` exercises
    the update cancel (`local/`, `.lanspread.json`, `.local.backup/`
    survive; `version.ini` and `.eti` go away).
- Manual GUI smoke (operator): start a fresh download of a multi-archive
  game from a peer, click Cancel from the detail modal while the progress
  bar is between 5% and 95%. Expect the game root to be empty (or absent)
  afterwards and no orphan `.eti` files. Repeat against an installed game
  by clicking Update, then Cancel mid-download; expect `local/` contents
  intact and the card to drop back to Play (or Update if the newer-version
  peer is still around).
- `lanspread-peer-cli` has no `cancel` command yet, so the headless
  `PEER_CLI_SCENARIOS.md` matrix does not cover this end-to-end. Adding a
  CLI cancel command + scenario is the natural follow-up.

Refs: 62ceb06 (feat(peer): remove downloaded game files safely)
Refs: b7df2de (fix(download): emit failure events on early-returns and update UI transition)
2026-05-21 00:07:12 +02:00
ddidderr 47e2bbd454 feat(ui): add download progress controls
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
2026-05-20 23:20:53 +02:00
ddidderr e308009a08 docs(design): specify download progress treatment
Document and mock the redesigned downloading state for the launcher. The
reference now replaces the action button slot with a dedicated progress
primitive, covers both card and detail-modal layouts, and records the sizing,
number formatting, container-query fallback, and sample-data expectations that
implementation work should follow.

This commit keeps the design package separate from application code so the
next UI/backend changes can be reviewed against a stable reference.

Test Plan:
- git diff --cached --check

Refs: local design reference update
2026-05-20 23:09:46 +02:00
ddidderr 51216b7281 docs(findings): note error handler still writes status fields
While reviewing the download progress bar feature we noticed that
`handleErrorEvent` in `crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts`
still writes `install_status`, `status_message`, `status_level`, and now also
`download_progress` directly from a lifecycle event handler.

This is the same "two sources of truth" pattern that commit `5df82aa`
("fix(ui): derive operation status from snapshots") removed everywhere else.
That commit explicitly carved out error messages as a preserved side effect,
so this is a documented exception rather than a regression — but if we want
strict snapshot-is-truth, the error handler should stop writing status fields
and let the next snapshot reconcile the card, keeping only the error message
overlay (which the snapshot does not carry).

Captured in `FINDINGS.md` under a new "Open" section so a future cleanup pass
can pick it up. Not blocking the progress bar work.

Refs: 5df82aa (fix(ui): derive operation status from snapshots)
2026-05-20 22:11:20 +02:00
ddidderr 01712f248b feat(ui): show download progress and speed in the action button
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
2026-05-20 22:11:09 +02:00
ddidderr 0f10108438 perf(peer): widen LAN bulk-transfer windows and buffers
Centralize the bulk-transfer sizing in config.rs and bump the values used
on both ends of a QUIC connection:

- CHUNK_SIZE: 32 MiB -> 128 MiB
- QUIC_CONNECTION_DATA_WINDOW: 64 MiB -> 256 MiB
- QUIC_STREAM_DATA_WINDOW: 32 MiB -> 128 MiB
- QUIC_MAX_SEND_BUFFER_SIZE: 32 MiB -> 128 MiB
- QUIC_INITIAL_CONGESTION_WINDOW: 1 MiB -> 4 MiB
- FILE_TRANSFER_BUFFER_SIZE: 64 KiB -> 1 MiB (new constant)

The previous 32 MiB stream window was already comfortably above the
bandwidth-delay product of a sub-millisecond LAN at 2.5 GbE. The further
bump is deliberately generous: the goal is to push flow control and
per-syscall overhead far enough out of the way that they cannot be the
suspect when isolating the remaining LAN download bottleneck (disk, NIC,
or s2n-quic platform offload on the sending host). Memory pressure from
the larger windows is not observable on a desktop client moving GB-sized
games.

stream_file_bytes previously read the local file in 64 KiB chunks. At
multi-Gbit/s send rates that produced many thousands of disk reads per
second; bumping to 1 MiB keeps the per-file syscall load modest with no
measurable latency cost on streamed bulk transfers. The buffer size lives
in config.rs as FILE_TRANSFER_BUFFER_SIZE so it stays adjustable from one
place.

Also add a started/MiB-per-second log line at info level when a file
finishes streaming. This matches the S37 measurement methodology already
used in the peer-cli harness and makes per-file send throughput visible in
normal operation.

The peer-cli extended-scenarios harness uses CHUNK_SIZE as the tolerance
bound for chunk-boundary variance in its assertions, so its constant is
bumped to match. The multi-chunk planning unit test is rewritten to
reference CHUNK_SIZE symbolically (CHUNK_SIZE * 3 + CHUNK_SIZE / 2)
instead of a hardcoded 120 MiB; the previous literal would silently
degrade into a single-chunk test at the new chunk size and stop
exercising the spread-across-peers code path.

Test Plan:
- just fmt
- just clippy
- just test
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37 \
  --build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37

Refs: local LAN download performance investigation on 2026-05-20.
Depends-on: d7f7dc737e (QUIC UDP socket buffer sizing).
2026-05-20 21:20:25 +02:00
ddidderr 44e0629926 refactor(peer-cli): split download measurement event handlers
Extract the download-begin and chunk-finished measurement bookkeeping out of the
main peer-cli event reducer. This keeps the S37 throughput reporting behavior
unchanged while bringing the reducer back under the pedantic clippy line-count
threshold.

Test Plan:
- just fmt
- just clippy
- just test

Refs: S37 download throughput measurement harness.
2026-05-20 20:27:00 +02:00
ddidderr d7f7dc737e perf(peer): request larger QUIC UDP socket buffers
Configure the s2n-quic Tokio IO provider on both client and server instead of
using the address-only default provider. The configured provider asks the OS for
4 MiB send and receive buffers on each QUIC UDP socket, which avoids starting
bulk LAN transfers on the tiny default UDP buffer sizes.

I tested a wider version that also raised s2n-quic internal IO queues to 8 MiB,
but that regressed S37 to 710.19 and 736.20 MiB/s in repeat runs. This commit
keeps the narrower socket-buffer request, which measured faster than the prior
flow-control-only tuning while leaving the internal queue defaults intact.

The host used for measurement reports:
- net.core.rmem_max = 16777216
- net.core.wmem_max = 16777216
- net.core.rmem_default = 212992
- net.core.wmem_default = 212992

S37 single-source throughput:
- Step 1: 824.94 MiB/s, 6920.09 Mbit/s, 2.483s
- Step 2 sample A: 848.15 MiB/s, 7114.81 Mbit/s, 2.415s
- Step 2 sample B: 874.06 MiB/s, 7332.12 Mbit/s, 2.343s

Test Plan:
- just fmt
- sysctl net.core.rmem_max net.core.wmem_max net.core.rmem_default \
  net.core.wmem_default
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37 --build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37

Refs: local LAN download performance investigation on 2026-05-20.
Depends-on: cd8bcbfeedfa (QUIC flow-control and BBR tuning).
2026-05-20 20:26:59 +02:00
ddidderr 5b689ec5f4 perf(peer): tune QUIC flow control for LAN downloads
Raise the s2n-quic connection and stream data windows on both the client and
server, increase the max send buffer, and use BBR with a larger initial
congestion window. The download path was already able to pipeline multiple chunk
streams, but those streams still shared small default connection-level budgets
that limited sustained LAN throughput.

The tuning keeps one current wire protocol and does not add fallback behavior.
It is deliberately centralized in the peer networking module so later transport
changes can use the same limits on both sides of the connection.

S37 single-source throughput:
- Before: 733.22 MiB/s, 6150.72 Mbit/s, 2.793s
- After: 824.94 MiB/s, 6920.09 Mbit/s, 2.483s
- Delta: +91.72 MiB/s, +769.37 Mbit/s, about +12.5%

Test Plan:
- just fmt
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37 --build-image

Refs: local LAN download performance investigation on 2026-05-20.
Depends-on: 14e772c5c71a (peer-cli S37 throughput measurement).
2026-05-20 20:26:59 +02:00
ddidderr 8a9f420a06 test(peer-cli): measure single-source download throughput
Add peer-cli accounting for download sessions so terminal download events
report bytes, chunks, elapsed time, MiB/s, and Mbit/s. The extended scenario
runner now has S37, a focused single-source download benchmark that creates a
2 GiB sparse bf1942 archive, downloads it from one peer with install disabled,
and checks the destination archive size and reported byte count.

This gives the QUIC performance work a repeatable measurement below the 5 GiB
limit from the original request. The source file is sparse, so S37 is aimed at
the app, QUIC, and destination-write path rather than raw source-disk reads;
the existing correctness scenarios still cover normal game downloads.

Baseline S37 before QUIC tuning:
- 733.22 MiB/s
- 6150.72 Mbit/s
- 2.793s for 2.00 GiB plus version.ini
- 65 reported chunks

Test Plan:
- just fmt
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37 --build-image

Refs: local LAN download performance investigation on 2026-05-20.
2026-05-20 20:26:58 +02:00
ddidderr 6a90ca951d feat(peer): pipeline chunk downloads over QUIC
Keep several chunk streams in flight per peer connection so a fast LAN download
is no longer forced through a request, wait, request loop. The transport still
uses the current GetGameFileChunk request on normal QUIC bidirectional streams,
so this improves throughput without adding another wire message or compatibility
path.

The peer planner now assigns chunks to the least-loaded eligible peer by planned
bytes. This keeps shared large files balanced across the latest valid sources,
while still respecting per-file source eligibility. Retries are batched by peer
and use the same pipelined transport instead of opening a new connection for one
failed chunk at a time.

Initial peer connection failures are converted into per-chunk failures so the
existing retry logic can move those chunks to another validated source. The dead
whole-file branch was removed from PeerDownloadPlan because nothing populated it
and retrying those entries as zero-length chunks would be a future data-loss
trap.

Test Plan:
- RUSTC_WRAPPER= just fmt
- RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= just peer-cli-build
- RUSTC_WRAPPER= just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S13 S14 S16 S18 S19 S20 S24 S25 S26 S36
- git diff --cached --check

Refs: PEER_CLI_SCENARIOS.md
Review-Notes: addressed Claude review on whole-file retry cleanup
2026-05-20 07:46:44 +02:00
ddidderr e078b12dcf docs(peer-cli): record snapshot status matrix pass
Record the post-fix peer-cli validation run against the rebuilt Docker image.
The run covered S1-S36 after the backend snapshot-ordering fix and frontend
snapshot-status reducer cleanup, including auto-install, exact transfer,
failure, propagation, concurrency, mutation, and latest-version scenarios.

Test Plan:
- git diff --check
- just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py

Refs: local install/download status snapshot cleanup
2026-05-20 07:03:38 +02:00
ddidderr 5df82aa4f3 fix(ui): derive operation status from snapshots
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
2026-05-20 07:03:38 +02:00
ddidderr db03533bd4 fix(peer): settle local state before clearing operations
Install, update, uninstall, and downloaded-file removal used to clear the
active operation before publishing the settled local-library snapshot. That
allowed the UI bridge to emit a snapshot with no active operation but stale
local state, which could briefly make an installing game look not installed.

Refresh the ending game while its operation is still active, but exempt only
that game from the active-operation freeze. Other active games keep the
existing scan-preservation behavior. Lifecycle finished/failed events are now
emitted after the local snapshot and active-operation clear, so the status
snapshot remains the source of truth.

Test Plan:
- git diff --check
- just fmt
- just test

Refs: local install/download status snapshot cleanup
2026-05-20 07:03:36 +02:00
ddidderr b7df2de6a5 fix(download): emit failure events on early-returns and update UI transition
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`
2026-05-19 22:59:36 +02:00
ddidderr 2b3851f837 fix(ui): keep peer-check state backend-driven
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
2026-05-19 22:23:27 +02:00
ddidderr ebeee2d90a fix(settings): name descending size sort explicitly
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
2026-05-19 21:28:40 +02:00
ddidderr 59efe9e2d7 fix(ui): close detail modal when removing downloads
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
2026-05-19 21:28:23 +02:00
ddidderr 62ceb063ac feat(peer): remove downloaded game files safely
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
2026-05-19 21:00:44 +02:00
ddidderr 74d9266723 fix(ui): show installing for downloaded 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
2026-05-19 20:49:22 +02:00
ddidderr 50698f9a7d feat(ui): add search clear button
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
2026-05-19 20:48:46 +02:00
ddidderr a6130fc687 fix(ui): handle enter and escape in search
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
2026-05-19 20:48:12 +02:00
ddidderr 2af55981c3 fix(ui): make animated background drift subtly
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
2026-05-19 20:47:55 +02:00
ddidderr e5235948df fix(ui): default covers to square
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
2026-05-19 20:47:15 +02:00
ddidderr 25f92c9b0b feat(ui): add smallest-first size sort
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
2026-05-19 20:47:01 +02:00
ddidderr bcaf28dcee fix(ui): count all-games filter from network games
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
2026-05-19 20:46:31 +02:00
ddidderr 640214ec38 feat(tauri): implement Steam-style launcher redesign per design handoff
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/
2026-05-19 20:12:57 +02:00
ddidderr 27c71978d2 docs(design): add SoftLAN launcher redesign handoff and references
Add the `design/` directory containing the design handoff document and
HTML/React reference prototypes for the planned Steam-inspired redesign
of the launcher UI.

Contents:

- `design/README.md` — handoff spec. Defines screens (main library,
  game detail overlay, in-app Settings dialog), the game card anatomy,
  interaction behavior, transitions, state shape, design tokens
  (colors, typography, spacing, shadows) and out-of-scope items.
  Selects layout variant A (single-row top bar) as the primary
  direction. High-fidelity: colors / typography / spacing / animations
  are decided, pixel-fidelity to the mock is the goal.

- `design/design_reference/` — Babel-in-browser React prototypes built
  to communicate intended look and behavior. Includes:
  * `SoftLAN Launcher.html` — entry that wires React + Babel and
    mounts the design canvas with all variants side-by-side.
  * `styles.css` — full visual spec as CSS custom properties + named
    component classes (`.topbar`, `.seg`, `.card`, `.modal`, etc.).
  * `data.jsx` — mock game catalog plus filter/sort helpers and a
    mock STORAGE record used by the storage meter.
  * `components.jsx` — reusable building blocks (Icon set, GameCover
    placeholder generator, StateChip, ActionButton, GameCard,
    SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
    StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
    SettingsDialog).
  * `launcher.jsx` — composes top bar + grid + modals into a complete
    launcher screen, in both `single`-row and `two`-row chrome
    variants.

These files are reference material, not production code. They are not
imported by the Vite/Tauri build and ship outside the frontend crate
(`crates/lanspread-tauri-deno-ts/`). They are committed so the design
intent is reviewable in-repo and surviving across implementations.

The accompanying production implementation against this spec is in
follow-up commits.

Trailer
-------
Refs: design/README.md (canonical handoff)
2026-05-19 19:59:36 +02:00
ddidderrandClaude Opus 4.7 ff35f0d95f feat(tauri): make unpack logs viewer usable for debugging
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>
2026-05-19 19:54:50 +02:00
ddidderrandClaude Opus 4.7 b35755f4e6 feat(tauri): add unpack logs viewer for unrar attempts
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>
2026-05-19 17:27:59 +02:00
ddidderr a8edcd7450 test(peer-cli): cover full docker scenario matrix
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
2026-05-19 06:28:16 +02:00
ddidderr 8b3aefd2db test(peer-cli): document full manual scenario pass
Record the May 18 manual Docker pass over PEER_CLI_SCENARIOS.md so the
scenario matrix has current evidence for every row. The run log now covers the
clean direct-connect and aggregation rerun, exact diff checks for downloaded
files, custom renamed-RAR fixtures for conflict and version-skew cases, and the
latest-only transfer behavior for S15-S17.

S12 remains verified by unit tests instead of a CLI race because the raw serving
gates are below the peer-cli command surface. The run log names the exact tests
that cover the non-catalog, missing-sentinel, active-operation, and local-path
serving gates.

Test Plan:
- just peer-cli-image
- RUSTC_WRAPPER= just peer-cli-build
- manual Docker peer-cli runs for S1-S17 using JSONL stdin commands
- diff -r transferred game directories against source fixture directories
- just fmt
- RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= just clippy
- git diff --check

Refs: PEER_CLI_SCENARIOS.md
2026-05-18 22:43:15 +02:00
ddidderr 41e9a0efc1 refactor(peer): split local library and operation UI events
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
2026-05-18 21:25:20 +02:00
ddidderrandClaude Opus 4.7 be00a7a298 fix(peer): exchange full library snapshots during handshake
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>
2026-05-18 19:06:39 +02:00
ddidderrandClaude Opus 4.7 ce51d92df0 refactor(peer): tighten listener-addr handshake invariant
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>
2026-05-18 18:21:19 +02:00
ddidderr 348a02c35f fix(peer): record listener addresses during handshakes
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.
2026-05-18 17:27:15 +02:00
ddidderr 642463d7eb certs fixed 2026-05-18 16:19:27 +02:00
ddidderr c795e9de02 deps: deno and cargo update 2026-05-17 23:16:42 +02:00
ddidderr 48b1d2821f build: production profile, justfile adjustments 2026-05-17 23:10:58 +02:00
ddidderr d891a14a46 deps: cargo update 2026-05-17 14:53:23 +02:00
ddidderr adaf0028af Cargo.toml: new resolver version 2026-05-17 14:53:10 +02:00
ddidderr 274b9d2fd4 test(peer-cli): add large exact-transfer coverage
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
2026-05-17 10:25:26 +02:00
ddidderr 5d58791192 fix(peer-cli): fail missing downloads from peer event
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
2026-05-17 09:34:20 +02:00
ddidderr 10a1f57183 fix(peer): preserve advertised addresses for QUIC peers
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
2026-05-17 09:34:10 +02:00
ddidderr 84f533aeee fix(peer): renew expired QUIC dev certificate
Manual peer CLI runs could discover peer advertisements, but QUIC handshakes did
not complete. The checked-in self-signed certificate expired on March 2, 2026,
so peers running on May 17, 2026 could start listening but could not establish
valid TLS sessions with each other.

Regenerate cert.pem from the existing key.pem, preserving the existing subject
and DNS:localhost SAN. The renewed development certificate is valid until April
23, 2126 and still matches key.pem.

Test Plan:
- openssl x509 -in cert.pem -noout -subject -issuer -dates -ext subjectAltName
- openssl x509 -noout -modulus -in cert.pem | openssl sha256
- openssl rsa -noout -modulus -in key.pem | openssl sha256
- just peer-cli-image
- just peer-cli-alpha, just peer-cli-bravo, just peer-cli-charlie

Refs: PEER_CLI_SCENARIOS.md
2026-05-17 09:34:00 +02:00
ddidderr 31e1a7eb3b fix(peer-cli): use writable Docker config for image builds
The peer CLI Docker recipes failed on this host because Docker Buildx tried to
write activity metadata under /home/pfs/.docker, which is read-only here. Use a
repo-local ignored Docker config by default, while still respecting an explicit
DOCKER_CONFIG from the caller.

This keeps the peer-cli image and network recipes runnable without requiring
host-global Docker client state to be writable.

Test Plan:
- just peer-cli-image

Refs: PEER_CLI_SCENARIOS.md
2026-05-17 09:33:48 +02:00
ddidderr 9a677947ed testing: peer CLI pre-defined peers 2026-05-17 07:54:12 +02:00
ddidderr 3380d137fc fix: ignore local watcher access events
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
2026-05-16 19:50:10 +02:00
ddidderr ed007f7844 test: add peer CLI game directory fixtures
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
2026-05-16 19:30:01 +02:00
ddidderrandClaude Opus 4.7 754afd5621 refactor(peer): drop --no-mdns toggle, mDNS is always on
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>
2026-05-16 18:51:54 +02:00
ddidderrandClaude Opus 4.7 93c6133ea9 feat(peer-cli): run harness containers on a macvlan network
The previous `peer-cli-run` recipe attached containers with `--network host`.
That makes every peer share the host network namespace, which means two
harness containers on the same machine cannot independently advertise mDNS
or look like distinct LAN devices: they all reuse the host's interface, the
mDNS daemon is single-instance per namespace, and any port the peer binds
is a host-wide port. That defeats the whole point of running multiple
peers side-by-side for end-to-end testing.

Switch the recipe to a Docker macvlan network. Each container gets its own
MAC and IP carved out of the real LAN subnet, sends and receives multicast
on the parent NIC, and appears to the rest of the home network as a fresh
device. mDNS discovery then works between peers exactly as it would for
two laptops on the same LAN, with no relay, reflector, or special routing.

Add a `peer-cli-net` recipe that creates the network idempotently (the
`docker network inspect` short-circuits when it already exists), make
`peer-cli-run` depend on it, and parameterise the parent interface,
subnet, and gateway as justfile variables so they can be overridden from
the command line for machines whose LAN does not match the defaults:

    just LANSPREAD_PARENT_IFACE=enp4s0 \
         LANSPREAD_SUBNET=10.0.0.0/24 \
         LANSPREAD_GATEWAY=10.0.0.1 \
         peer-cli-run alpha

The well-known macvlan limitation that the host cannot reach its own
macvlan children over the network is intentionally not worked around:
agents drive each peer through `docker run -i` stdin/stdout, which is the
docker control socket, not the LAN. Host-to-peer connectivity is not part
of the mental model and is not needed for any current test scenario.

Test Plan:
- `just peer-cli-image`
- `docker network create -d macvlan ... lanspread` succeeds on a host with
  the default `eth0` interface (or with overridden variables on others).
- `just peer-cli-run alpha` and `just peer-cli-run beta` in two terminals;
  both containers come up on the LAN with distinct IPs and discover each
  other via mDNS without any `connect` command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:51:28 +02:00
ddidderr dc9e13e6a1 feat(peer-cli): add JSONL peer test harness
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
2026-05-16 18:33:18 +02:00
ddidderr e711cf3454 fix(peer): settle current-protocol local state cleanup
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
2026-05-16 18:32:24 +02:00
ddidderr 6242d64583 fix(peer): repair update lifecycle regressions
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
2026-05-16 14:19:10 +02:00
ddidderr 8890d78642 crazy 2026-05-16 13:15:34 +02:00
ddidderr a251233653 refactor(peer): split download pipeline into modules
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
2026-05-16 12:16:08 +02:00
ddidderr 504ee1bc02 CLAUDE.md: add policy about legacy versions not being supported 2026-05-16 11:50:02 +02:00
ddidderr be196f9e4b refactor: type game availability state
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
2026-05-16 11:49:01 +02:00
ddidderr fdad162240 fix(peer): write local library index atomically
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
2026-05-16 10:01:34 +02:00
ddidderr cc805777d8 docs(peer): sync follow-up status
Update FOLLOW_UP_2.md after the follow-up stack landed. The document now marks
the active-operation reconciliation, recovery matrix, rollback tests,
installed-only rescan, scanner gating, serve gating, and TempDir consolidation
as completed.

The remaining open items are intentionally limited to the deferred hygiene
called out by the plan: typed availability, atomic library-index writes, and a
future download.rs split.

Test Plan:
- git diff --check

Follow-up-Plan: FOLLOW_UP_2.md
2026-05-16 09:23:02 +02:00
ddidderr 894eb5af6a test(peer): consolidate temp dir helper
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
2026-05-16 09:21:43 +02:00
ddidderr 7731a9daa0 test(peer): cover serve gating dispatch
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
2026-05-16 09:16:37 +02:00
ddidderr 2a94445391 test(peer): cover local monitor rescan gating
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
2026-05-16 09:13:38 +02:00
ddidderr c7b7ab7576 test(peer): cover installed-only rescan readiness
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
2026-05-16 09:09:50 +02:00
ddidderr 3abb2e051b test(peer): cover uninstall rollback restore
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
2026-05-16 09:08:28 +02:00
ddidderr bb483f01f6 test(peer): cover update commit rollback
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
2026-05-16 09:06:32 +02:00
ddidderr 47733713ca test(peer): cover install recovery matrix
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
2026-05-16 09:04:53 +02:00
ddidderr 95e70ef520 fix(ui): reconcile active operations from local scans
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
2026-05-16 09:01:17 +02:00
ddidderr b5d20c1e72 fix(peer): refresh settled install state after operations
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
2026-05-16 08:50:51 +02:00
ddidderr fce34c7bd2 docs(peer): document transactional install model
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
2026-05-15 18:21:09 +02:00