47e2bbd4542b4bfab80cf1c8dc3f9e40e37bd49b
254 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
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 |
||
|
|
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:
|
||
|
|
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
|
||
|
|
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:
|
||
|
|
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. |
||
|
|
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). |
||
|
|
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). |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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` |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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/
|
||
|
|
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)
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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>
|
||
|
|
ce51d92df0
|
refactor(peer): tighten listener-addr handshake invariant
Follow-up hardening for |
||
|
|
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. |
||
|
|
642463d7eb
|
certs fixed | ||
|
|
c795e9de02
|
deps: deno and cargo update | ||
|
|
48b1d2821f
|
build: production profile, justfile adjustments | ||
|
|
d891a14a46
|
deps: cargo update | ||
|
|
adaf0028af
|
Cargo.toml: new resolver version | ||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
9a677947ed
|
testing: peer CLI pre-defined peers | ||
|
|
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 |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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>
|