37899da18a95caca7b9aba3ab185f755fcc01a89
113
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9171560ad4 |
fix(launcher): authorize window destruction after close drains
A native WM_DELETE_WINDOW reproduction completed the frontend drain and then failed with "window.destroy not allowed". All three windows lacked the destroy permission used by Tauri's onCloseRequested wrapper. Removing the listener had previously let a second click bypass that denied IPC; retaining the listener made every click fail. The frontend was not stuck waiting on its own listener. Grant window destruction to the three configured application windows. Keep the existing frontend drains, early peer cancellation, and final runtime/task joins. Test the application's actual generated RuntimeAuthority, including expanded plugin defaults, instead of assuming a mocked successful destroy proves access. Explicitly include generated capabilities and ACL manifests in the context constructor's rustc dependencies. The configured compiler cache reused the old library after a permission-only edit because Tauri's macro reads these files without recording compiler dependencies. A remove/restore native probe now rebuilds with the correct permission in both directions. Document the complete ownership chain and failure evidence. Add a PID-checked X11 WM_DELETE_WINDOW helper for repeatable close-button probes without killing the process or bypassing the frontend boundary. Test Plan: - Native baseline: destroy denied and process remained alive after one request. - Resolved-ACL regression failed before the permission fix and passes afterward. - Native main/companion close probes: one request per window; normal process exit. - Permission-only rebuild with compiler cache: normal exit in 197 ms. - Production executable: normal exit in 43 ms; QUIC port released and rebound. - just test: 797 passed on unchanged rerun after one initial subprocess fixture startup-marker timeout, before that test exercised cancellation. - just frontend-test: 99 passed. - just fmt, just clippy, just build, and git diff --cached --check: passed. - Native helper compiled with -Wall -Wextra -Werror. - Native probes ran on Linux X11/XWayland; Windows/macOS were not measured. |
||
|
|
0dfacfa661 |
fix(tauri): cancel peer work at native close boundary
The launcher previously began peer cancellation only after the frontend had finished draining its pending invokes and the Tauri process emitted Exit. A pending acknowledgement could therefore keep the close handler waiting while the peer still owned the work needed to complete that acknowledgement. Close admission and unrar cancellation now begin on the native main-window CloseRequested event. A tracked application task signals the currently owned peer runtime early, while the existing Exit handler still waits for all admitted invokes, takes and joins the runtime, and drains background tasks. An atomic latch keeps repeated close and exit notifications from scheduling multiple shutdown requests, and a runtime that is being created remains owned by its in-flight invoke until the final join boundary. Test Plan: - `just fmt` -- passed - `just test` -- passed (796 Rust tests) - `just frontend-test` -- passed (98 tests) - `just clippy` -- passed - `just build` -- passed - `git diff --check` -- passed - Native window-close timing was not available in the automated UI surface. |
||
|
|
6b65a66465 |
feat(catalog): separate generated production and local test authority
The launcher previously defaulted to fixtures and production generation used the source database directly. Generate a separate database and manifest set from package directories, validating staged output with the application loader before installation. Keep strict selection by default, with independent opt-ins for missing games and package version overrides in the copied database. Share database filtering and staging with the fixture publisher, and include source metadata and generation modes in the publication cache. Make normal runs consume existing production authority. Add an explicit local test recipe with separate resources, app settings, and a compiled startup game directory. Build-time gates exclude local authority from production and require Tauri development mode. Document the generation and launch workflows. Clear generated catalog copies before Tauri copies the selected resource tree so mode switches and reduced catalogs cannot retain stale manifests. Watch the copied files to repair deletion and preserve prior output mtimes only when the bytes are unchanged, allowing subsequent builds to become fresh. Test Plan: - `just fmt` -- passed. - `just clippy` -- passed with warnings denied. - `just test` -- workspace tests passed using fixture authority. - `just frontend-test` -- 94 passed. - `python3 -m unittest discover -s tools -p 'test_catalog_source_cache.py'` -- 4 passed. - `git diff --cached --check` -- passed. - Interactive GUI launches and production bundles were not exercised. |
||
|
|
6b9d8561de |
fix(launcher): preserve local game state after directory changes
The accepted root transition already publishes the replacement local snapshot before the transfer-status fence completes. Clearing local flags again when committing the folder erased that snapshot, and the subsequent ListGames refresh only restored remote availability. Keep the accepted local state. Test Plan: - `just fmt`, `just clippy`, and `just test` -- passed on the full worktree. - `just frontend-test` -- 94 passed on the full worktree. - `git diff --cached --check` -- passed. - GUI directory switching was not exercised interactively. |
||
|
|
67ea355599 |
fix(tauri): bind elevated scripts to catalog authority
Preserve required UAC elevation for game_setup.cmd, game_start.cmd, and server_start.cmd through a fixed-role elevated launcher worker. The worker reloads and matches embedded catalog authority, verifies the exact script from a no-follow locked handle, resolves System32 cmd.exe, and transfers path locks into the command process. Setup still waits for completion; game and server return after the verified handoff while their command process retains the locks. Unmanifested, changed, reparse-backed, markerless, and streamed-only scripts fail closed. Test Plan: - just test - just clippy - just frontend-test - just build-fixture - nine Linux-visible authority/parser/digest tests - Windows-only lock-transfer test added but not run (no Windows target/runtime available) - git diff --check |
||
|
|
37420e26ed |
fix(peer): coalesce full UI view publications
Keep one queued and one replaceable pending snapshot for remote-library and Call-to-Play views while preserving lifecycle-event FIFO delivery. Generation barriers and fenced drains prevent stale nonempty views from crossing disable or acknowledgement boundaries. Test Plan: - just test - just clippy - focused burst, lifecycle ordering, fence, repeated-barrier, and stale-view tests - independent ordering review - git diff --check |
||
|
|
0ed04f64e9 |
fix(tauri): remove unused frontend shell-open authority
The main webview retained `shell:allow-open` even though the frontend never uses the shell plugin directly. File-manager opening already flows through the `open_game_files` command, which checks catalog membership and containment. Remove only the frontend permission. The Rust shell plugin and dependency stay available for the checked backend open operation and bundled unrar sidecar. Test Plan: - `just build-fixture` -- passed, including the frontend build and Tauri capability validation. - `git diff --cached --check` -- passed. |
||
|
|
0a38dfbb19 |
fix(peer): reject unsafe game IDs in state marker paths
Scanner finding #12 ("state marker path escape"). The per-game state helpers in `state_paths.rs` joined a raw game ID below `<state_dir>/games/`. The public `setup_done_path` was therefore usable with an absolute or parent-containing ID by an embedding caller, and the legacy migration discovered IDs from directory names in the user's games folder and joined them unconditionally. Every shipping caller today validates its ID or takes it from the catalog, so this was a footgun rather than an exploited hole, but the fix is small and removes the reliance on every future caller remembering the rule. Add `validate_game_state_id`, which rejects separators and NUL and then delegates to `lanspread_db::content_manifest::validate_portable_component` (the catalog's own rules: no `.`/`..`, no trailing dot or space, no control or Windows-reserved characters, no Windows device names). Reusing the catalog validator rather than a private copy guarantees that any ID the catalog can publish is accepted here and that the two cannot drift apart. `setup_done_path` now returns `eyre::Result<PathBuf>`; it is the only state path the embedding application calls with an ID that may originate from UI input. `launch_settings_applied_path` leaves the public API and becomes `pub(crate)`; the two public launch-settings entry points (`apply_launch_settings_once`, `mark_launch_settings_applied`) validate the ID before any filesystem work. `game_state_dir` carries a `debug_assert!` documenting the contract for internal callers without turning a bad ID into a release-build panic; the migration test suite exercises that assertion in debug builds. Behaviour changes: - Legacy migration logs a warning, counts a failure and leaves the legacy marker in place for a games-folder directory whose name is not a portable game ID, instead of creating state below it. A new test covers a trailing-dot directory name. - The Windows launcher ignores a run request whose ID `setup_done_path` rejects, with a warning, mirroring the existing invalid-ID early return. Tests cover catalog-valid IDs that must remain accepted (embedded dots, spaces, `console.txt`, `com10`, non-ASCII) and unsafe IDs that must be rejected (empty, `.`, `..`, separators, NUL, trailing dot or space, device names, `a:b`). This ports the fallible API from the parallel security branch (lanspread2 commits 4146a0e and 9f26c63) onto the validator this branch already exports from `lanspread-db`. Test plan: - `cargo test -p lanspread-peer --lib`: 491 passed. - `just clippy`: clean. - On Windows, launch a game with a valid ID and confirm the setup marker is still written under `<app-data>/games/<id>/setup_done`. Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA |
||
|
|
a86a2d1a0a
|
fix(tauri): strip cmd.exe metacharacters from launch usernames
Security audit finding SEC-IPC-01 (parameter part). The username is passed to game_setup/game_start/server_start batch scripts as a quoted `cmd.exe` argument. Quoting protects the launcher's own command line, but batch scripts expand `%~4` textually into their own statements, so a name such as `foo & calc` would run `calc` from `set NAME=%~4`. Since the setup script runs elevated, that matters even though the value is the local user's own input. `sanitize_username` previously removed control characters, `"` and `%`; it now also removes `& | < > ^`. Spaces, punctuation such as `!` and non-ASCII letters remain allowed so ordinary gamer tags are not mangled. The audit's stricter `[A-Za-z0-9_-]` allowlist was rejected for that reason. The elevated execution of catalog scripts itself is intentional: the shared games need administrator setup and the archives that carry the scripts are BLAKE3-verified against the bundled catalog. Test plan: `just test` (extended sanitizer test). Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg |
||
|
|
f9c64d7c18
|
fix(tauri): enable a Content Security Policy for the webview
Security audit finding SEC-IPC-02. `tauri.conf.json` set `"csp": null`, which disables Tauri's CSP injection entirely. The audit found no script-injection route in the frontend, so this is defense in depth: should an XSS ever land through peer-supplied text, a CSP stops it from loading remote scripts, exfiltrating over fetch/WebSocket or framing the app, and confines IPC to Tauri's own channel. Production policy (`csp`): - default/script-src 'self': only the bundled Vite output runs. Tauri adds hashes for the init scripts it injects. - style-src 'self' 'unsafe-inline' plus fonts.googleapis.com: React inline `style` props and the Bebas Neue stylesheet that index.html already links. - font-src 'self' data: fonts.gstatic.com: the font files behind that stylesheet. - img-src 'self' data: asset: http://asset.localhost: thumbnails arrive as base64 data URLs from get_game_thumbnail. - connect-src ipc: http://ipc.localhost: Tauri does not append these itself; without them every `invoke` would be blocked. - object-src/frame-src/form-action 'none', base-uri 'none'. Development policy (`devCsp`): Vite's dev server injects the React refresh preamble as an inline script and needs eval and a WebSocket to localhost:1420 for HMR, so `just run` uses a permissive policy that still forbids frames, plugins and form submission. Verification here was limited to a static check: the production bundle built by `deno task build` contains only external module scripts and stylesheets, and `cargo tauri` parses the new config. The policy has not been exercised in a running webview on this machine; if the app shows a blank window or missing fonts/thumbnails after this change, the WebView console will name the blocked directive. Test plan: `just run` (dev) and `just build` then launch the binary; confirm the library renders, thumbnails and the display font load, IPC-backed actions (settings, log windows, Call to Play) work, and the webview console shows no CSP violations. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg |
||
|
|
84cbabfeba
|
fix(install): skip and reject links when extracting .eti archives
Security audit findings EXP2-SEC-01, SEC-IPC-04 and Codex #3 (symlink redirection through externally extracted archives). The ordinary install path hands every root `.eti` archive to an external `unrar x` process and promotes the resulting staging directory to `local/` as soon as the extractor exits 0. Nothing inspected what unrar materialised. A symbolic link (or, on Windows, a junction) inside an archive would survive promotion and later redirect the launch-time settings rewrite in `apply_launch_settings_once`, the uninstall path, or any script the game ships. The archives themselves are BLAKE3 verified against the bundled catalog before they can be installed, so a hostile link would have to be published by the catalog operator; this is defense in depth rather than a live remote exploit. Two independent layers now guard promotion: - Both `unrar` invocations (Tauri sidecar and peer-cli external unpacker) pass `-ol-`, which makes unrar 7.x skip symbolic-link entries entirely. The bundled 7.10 sidecar was checked against a fixture archive. - `install_inner`/`update_inner` walk the staging tree without following links and refuse to promote it if any entry is a symlink or (on Windows) carries the reparse-point attribute. The normal rollback then removes staging and clears the install intent. The audit's `-sl-` suggestion does not exist in unrar; `-sl<size>` is a size filter. Post-extraction digest verification of every extracted file remains out of scope for the ordinary path; Stream Install already verifies each output entry. Test plan: `just test` (new unix test installs with a fake unpacker that plants a symlink and asserts install fails, `local/` is absent and the intent is cleared; the peer-cli controlled-unrar test checks the new argument position). Manual: install a fixture game via peer-cli. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg |
||
|
|
a080f93ec1
|
fix(tauri): validate game id in get_game_thumbnail and drop dbg!
Security audit finding SEC-IPC-03. The thumbnail IPC command resolved
`assets/{game_id}.jpg` from the resource directory without checking the
ID, unlike every other command that maps a game ID to a path. A crafted
ID containing path separators could therefore read any `.jpg` reachable
from the resource root. The handler also still carried a `dbg!` that
printed the resolved path to stderr in release builds.
The command now rejects anything that is not a single normal path
component with an `InvalidInput` I/O error, using the same
`is_single_component_game_id` gate as run_game and start_server. The
frontend already treats a failed thumbnail request as "no thumbnail".
Test plan: `just clippy`, `just test`. In the app, thumbnails for
catalog games still load; an invoke with game_id "../x" is rejected.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
|
||
|
|
a49b51d3d8 |
fix(tauri): make game-directory changes observable
Game-directory selection could reach update_game_directory and then fail before peer startup, while the frontend only logged the rejected invoke. Preserve the last accepted root until peer acknowledgement, surface backend rejection in the settings and main-window UI, and avoid holding the published control lock across runtime replies. Startup preflight remains fail-closed; synchronous setup stays lexically owned through scoped_blocking so cancellation cannot strand a partially published runtime. Add peer-cli scenario S50 to verify invalid changes preserve the existing library and valid changes acknowledge and refresh the library in both directions. Modernize the fixed-size hex decoders to satisfy the current workspace Clippy lint without changing their behavior. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed - `deno task build` -- passed - `just peer-cli-tests S50` -- passed - `just build` -- passed - `git diff --cached --check` -- passed |
||
|
|
60fd7ba0c2
|
feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed |
||
|
|
a6ed60a538
|
feat(peer): validate manifests before download mutation
Why: - Remote and UI-echoed file descriptions could reach transaction and storage code one entry at a time, so a hostile late path could mutate earlier files. - Per-file consensus also accepted malformed peer lists and let duplicate rows inflate a source's vote. What: - Add a complete protocol-7 manifest adapter with catalog-root confinement, portable path and alias rules, reserved-path protection, shape and size caps, symlink/reparse inspection, and zero-mutation tests. - Keep download selection in the peer core, validate every peer manifest before consensus, and pass only the validated manifest into storage/orchestration. - Canonicalize locally advertised paths, cap exact chunk receives, and preserve the local-only install fast path. - Record the chosen safety limits and follow-up ownership/catalog decisions. Test Plan: - just clippy - just test - just frontend-test - just build - just fmt (Rust/TOML/Prettier completed; rumdl reports 39 pre-existing issues) - git diff --cached --check |
||
|
|
4b7725db16
|
fix(call-to-play): compact terminal histories
The 4,096-event store retained every completed call forever and local commands only reported that they reached the queue. Once the bound was reached, GUI actions could therefore fail with no user-visible result. The CLI snapshot wait could also be satisfied by an unrelated live event. Keep the complete event and chat history for every active call so late joiners receive full context. When the creator starts or cancels a call, replace its history with a single terminal tombstone; this bounds retained payload while still healing peers that missed the live terminal action. Publish commands now reply with the actual store result, and CLI snapshots use a direct reply. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed, including full-cap terminal compaction - `just build` -- passed - `just peer-cli-tests S48` -- passed - `git diff --cached --check` -- passed |
||
|
|
29eacabcc0
|
fix(call-to-play): key actors by stable peer identity
Participant maps and creator authorization previously used display names, so two peers left at the default Commander name collapsed into one participant and could exercise each other's creator controls through the normal client. Carry a stable actor_id separately from actor_name. The peer overwrites actor_id on every local publish, and live event envelopes are accepted only when the known peer, source, and event actor match. The frontend keys participants and authorization by actor_id while retaining actor_name for display. This follows the trusted-LAN model and is not cryptographic authentication against a hostile peer. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 21 tests - `just build` -- passed - `just peer-cli-tests S48` -- passed - `git diff --cached --check` -- passed |
||
|
|
0f53bc4b78
|
feat(call-to-play)!: coordinate game sessions across peers
Implement the launcher design as a production peer-to-peer feature. Call to Play actions are immutable, validated events broadcast over the existing QUIC control channel, deduplicated in a bounded in-memory history, and exchanged in Hello/HelloAck so late joiners reconstruct current calls. Add the Tauri bridge and modular launcher surfaces for play-now and scheduled calls, check-in, readiness buffers, role-aware controls, chat, tickers, and actual caller launch. A deterministic frontend reducer derives presentation state from replicated history. Extend the JSONL peer harness with publish/list commands and a three-peer live-delivery and late-join scenario. This intentionally raises the only supported wire protocol from version 5 to version 6; older builds are not supported. Document the transport architecture and exclude generated peer-test state from Docker build contexts. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 20 tests - `just build` -- passed - `just peer-cli-tests S2 S48` -- passed - `git diff --cached --check` -- passed |
||
|
|
d376983296
|
fix: terminate unrar sidecars when the launcher closes mid-install
Bug report: unrar.exe kept running after closing the launcher during a game install. The orphaned process kept extracting in the background and held file handles on the staging directory. Root cause (regular install path): run_unrar_sidecar ran the unrar sidecar via tauri-plugin-shell's Command::output(). That helper spawns the process on a detached SharedChild OS thread and immediately drops the CommandChild; there is no Drop impl that kills the process. On app exit only shutdown_peer_runtime ran, and Windows does not cascade-kill child processes, so closing the launcher left unrar running. Fix: - run_unrar_sidecar now uses .spawn() instead of .output(), keeping a killable CommandChild. It registers the child in a new LanSpreadState.active_unrar_children registry and an RAII UnrarChildGuard deregisters it on every return path. The CommandEvent stream is drained to reproduce the exact stdout/stderr (NEWLINE_BYTE is b'\n'), status code, success flag, and UnpackLogEntry the old .output() produced, so logging behavior is unchanged. - kill_active_unrar_children() runs in the RunEvent::Exit handler before shutdown_peer_runtime, killing every in-progress unrar. Killing first also lets the install task unwind so the runtime stops promptly. Two concurrency hazards were closed in the registry design: - Children are keyed by a monotonic id, not pid. A pid key let a finishing install's guard deregister a different install's child after the OS recycled the pid, which could re-orphan a live child. - A shutting_down latch lives in the registry under the same mutex as the kill sweep. The sweep is a one-shot drain, so a child registered after it (a task caught between spawn() and registration, or a later archive in a multi-archive install -- unpack_archives does not observe the shutdown token) would be missed. Registration now checks the latch under that mutex and kills the child immediately instead of inserting it. Since registration and the sweep serialize on one mutex, every interleaving kills the child. Also hardened the streamed-install sender path (ExternalUnrarStream Provider) with kill_on_drop(true) on its tokio unrar spawns, so a dropped or aborted producer task cannot orphan unrar there either. Known limitation: a hard force-kill or crash of the launcher (e.g. Task Manager -> End Task) bypasses RunEvent::Exit and is not covered. Making that bulletproof would require a Windows Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; the reported "close the launcher" case is fully fixed. Test Plan: - just clippy (pedantic, -D warnings): clean. - just fmt: no changes. - just test: all suites pass (incl. the 20 Tauri-lib unit tests). - Manual (Windows): start a game install, close the launcher mid-extract, confirm no unrar.exe remains in Task Manager. Repeat with two concurrent installs and with a multi-archive game. |
||
|
|
14b30fe2da
|
design: first impl of logo, still with glitch | ||
|
|
0b8e1e7f92
|
refactor: prune unconsumed peer lifecycle events
An emit-vs-listen audit of the event surface showed the GUI is state-as-source-of-truth: useGames renders the complete `games-list` snapshot (full library + active_operations) and reconstructs status from it, not from a stream of granular events. Several PeerEvents were emitted but had no consumer at all -- no frontend `listen()` and no peer-cli scenario assertion -- so they were pure dead weight that made the backend look event-driven when it no longer is. This prunes that dead surface in two parts. 1. Remove three PeerEvent variants with no consumer: InstallGameBegin, UninstallGameBegin, and RemoveDownloadedGameBegin. The operation-start transition is still observable via ActiveOperationsChanged (the snapshot already carries the Installing/Updating/Uninstalling/ RemovingDownload kind), so nothing is lost. This drops their emit sites in handlers.rs, the begin-event assertions in the peer's lifecycle unit tests (the asserted sequence is now ActiveOperationsChanged(kind) -> LocalLibraryChanged -> ActiveOperationsChanged([]) -> *Finished), the peer-cli JSONL mappings (install-begin/uninstall-begin/remove-download-begin) plus the now-orphaned install_operation_name helper and InstallOperation import, and the matching Tauri handler arms. 2. Drop Tauri webview emits that no frontend listener consumed: peer-local-ready, game-download-begin, game-download-pre, game-download-finished, game-uninstall-finished, and peer-connected/-disconnected/-discovered/-lost. The log lines and all real side effects are kept (handle_got_game_files still forwards PeerCommand::DownloadGameFiles). The orphaned emit_peer_addr_event and handle_download_finished helpers and the now-unused SocketAddr import are removed. peer-runtime-failed is kept pending a decision on surfacing runtime failures in the GUI. Why not re-wire instead: under state-as-source-of-truth, per-event UI state is exactly the pattern this project abandoned. Live progress already flows via game-download-progress, and the peer-cli's chunk, timing-trigger, and transition assertions read events that are retained (download-begin, download-chunk-finished, the *-finished/*-failed terminals), so test coverage is unchanged. Behavior change: none functional. The Tauri backend no longer emits events nothing listened to; the GUI is unchanged. The peer-cli no longer emits the three *-begin JSONL events. PeerEvent is a workspace-internal UI-reporting type, not a wire-protocol type, so there is no protocol or version impact and all consumers are updated in this commit. Docs: PEER_CLI_SCENARIOS.md S39 no longer lists install-begin (with a note that the start transition is visible via active-operations-changed), and a dated Run Log entry records the removal. The historical 2026-05-18 run-log note is left intact as a dated observation. Test Plan: - just test: pass (incl. peer lifecycle event-sequence tests). - just clippy: pass (-D warnings, all targets). - just frontend-test: pass (11/11, incl. streamed-install gating/labels). - just build: pass (release, no bundle). - Not run: the Docker S39-S47 matrix (run_extended_scenarios.py); those scenarios never asserted the removed *-begin events, so coverage is unaffected. just fmt's tombi step needs network and was skipped; no TOML changed. Refs: peer event-surface emit-vs-listen audit; no external consumers of the removed events. |
||
|
|
80c50c0db3
|
clippy: just fix | ||
|
|
66c7d5912b
|
fix(peer): harden streamed install lifecycle
Claude Fable 5's branch review found that receiver cancellation or a QUIC send failure could leave the sender-side archive producer blocked on the bounded frame channel. That kept the outbound transfer guard alive and could block later installs or updates of the same game. Route archive frames through a cancellable StreamInstallFrameSink instead of exposing the raw channel sender to providers. The QUIC forwarder now cancels and closes the receive side before awaiting the producer, so a blocked send wakes and the transfer guard can drop normally. Make PeerCommand::StreamInstallGame own its peer metadata preflight inside the peer core. The Tauri layer now sends the command directly, and the peer runtime fetches file details from catalog-version peers before running the existing majority validation and retry logic. This removes the UI-only pending streamed install set and gives PeerEvent::GotGameFiles one meaning again: continue a normal archive download. Tighten the receiver transaction edge cases too. Rollback removes a newly created empty game root, but preserves pre-existing roots. Once streamed staging has been promoted to local/, intent or launch-settings cleanup failures are logged for startup recovery instead of reporting a failed install for bytes that are already committed. Accept missing RAR CRC32 metadata for zero-byte files as CRC32 00000000 while still requiring CRC32 metadata for non-empty files. Update the peer README, scenario docs, and next-steps handoff so the documented ownership and remaining trust limitation match the implementation. Test Plan: - just fmt - just test - just frontend-test - just clippy - git diff --check - python3 -m py_compile \ crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image Refs: streamed-install review handoff from Claude Fable 5 |
||
|
|
40697a73e5
|
feat(tauri): add low-disk streamed install action
NEXT_STEPS item 1 called out that streamed install was still CLI-only because the Tauri app started the peer with no stream provider. Users can now choose an explicit "Low disk install" action from the game detail modal for remote-only games instead of taking the default archive-preserving download path. The GUI command queues a normal peer detail fetch first so the peer database has the file metadata needed for source validation. A small pending handoff in Tauri routes the resulting GotGameFiles event into StreamInstallGame instead of DownloadGameFiles, and clears that pending state on no-peer or download failure events. This keeps the existing download continuation untouched for the default action. The external unrar stream provider moved from the CLI harness into lanspread-peer so CLI and Tauri use the same implementation. Tauri resolves the bundled unrar sidecar path and injects that provider at peer startup; falling back to the noop provider keeps peer startup alive if the sidecar cannot be resolved, while the streamed install operation still fails safely. Test Plan: - just fmt - just test - just frontend-test - just clippy - just build - git diff --check Refs: NEXT_STEPS.md item 1 |
||
|
|
373def6d44
|
feat(peer): prototype streamed installs
Add a streamed-install prototype that can receive archive-derived install bytes straight into local/ without first storing the peer-owned root archive payload. This is intended for low-disk clients that want to install a game but opt out of becoming a downloadable peer source for that game. The protocol gains a current-version-only StreamInstall request and framed StreamInstallFrame responses. The peer core owns the generic transport, transaction, path validation, size checks, CRC32 verification, and lifecycle state. The archive-specific work is hidden behind StreamInstallProvider so the prototype can use unrar while the final implementation can swap in a better provider without rewriting the peer command path. The receiver writes into .local.installing and only promotes to local/ after the full stream verifies. It deliberately does not write the root version.ini or archive files, so the settled local state is installed=true, downloaded=false, and availability=LocalOnly. That preserves the existing rule that local/ is not served to peers and makes streamed receivers non-sources by construction. The CLI is the only caller for now. It exposes stream-install and provides the prototype unrar implementation with unrar lt for entry metadata and unrar p for file bytes. This is simple and good enough to prove non-solid archive streaming, but it is not the production provider shape for solid archives because per-file unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes stream_install_provider: None, so the GUI behavior stays unchanged until a real product path is designed. Document the production-readiness work in NEXT_STEPS.md. The main follow-up is to make the provider abstraction final-ish and replace the per-file CLI unrar provider with a one-pass archive provider, then wire a deliberate GUI low-disk mode, retry semantics, and broader failure scenarios. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 --build-image - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Follow-up: NEXT_STEPS.md |
||
|
|
9d14e63613
|
fix: harden application log viewer
Add an Application Logs window backed by a bounded persistent main log file. The viewer loads history from lanspread.log, subscribes to live INFO/WARN/ERROR log events, supports filtering/copy/pause controls, and keeps the menu/window routing separate from the unpack log viewer. The backend sink now owns serialized access to the log file. History reads and append-time trimming use the same sink lock, so opening the logs window cannot race with a concurrent write and rewrite away a freshly appended line. The sink also keeps a persistent file handle instead of reopening the file for each captured event. Live log events carry sink-local sequence ids. The frontend uses the history watermark plus returned history line counts to suppress live events that were already included in the history response, while preserving buffered rows that were trimmed out of the history file. Auto-scroll now follows the last visible row identity, so it continues following after the in-memory cap keeps the row count stable. No timestamp code change was needed. On the Linux dev host, a temporary probe showed time::OffsetDateTime::now_local() returning +02:00 while UTC was +00:00, matching the host CEST offset. Test Plan: - just fmt - just frontend-test - just test - just clippy - just build - git diff --cached --check - temporary Linux probe of OffsetDateTime::now_local() showed local +02:00 Refs: none |
||
|
|
febde452fb
|
fmt: add tombi format, run just fmt again | ||
|
|
7e40cf4bfb
|
fix(ui): coalesce outbound transfer list refreshes
Every outbound transfer start and finish can arrive on a hot path while a peer is serving many file chunks. The Tauri event handler used to rebuild and emit the full games list for each edge, cloning all games and probing per-game server script files repeatedly during an active serve. Batch outbound-transfer count changes behind a short scheduled refresh. The peer still records exact counts in shared state, and the delayed refresh reads that state once per burst. A generation counter keeps changes that arrive while an emit is already scheduled from being lost; they trigger one follow-up emit with the latest counts. Test Plan: - just test - just clippy - git diff --check Refs: Claude review finding #2 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
c5dfbf99a0
|
feat(ui): delegate install lifecycle to the peer
Remove the Tauri-side whole-game backup and unpack flow. The Tauri shell now provides an injected unrar sidecar implementation and lets the peer own install, update, uninstall, rollback, and recovery decisions. Route install commands by local state: missing version.ini fetches from peers, downloaded archives without local/ send InstallGame directly, and already installed games are left to the Play action. Updates request a fresh download and uninstalls forward UninstallGame. The UI mirrors peer operation events for downloading, installing, updating, and uninstalling. Render installed-but-not-downloaded games as LocalOnly and surface the local version for downloaded-but-not-installed games. Add a secondary uninstall affordance that does not change the main Install/Open action. Test Plan: - just fmt - just clippy - just test - just build Refs: PLAN.md |
||
|
|
2bbd2ac869
|
refactor(peer): adopt structured concurrency with supervised shutdown
Replace the detached tokio::spawn pattern in the peer runtime with a
supervised model built on tokio_util's CancellationToken and TaskTracker.
Long-lived services and child tasks now have an explicit parent, a
cancellation path, and a join point. Tauri can request a clean shutdown
on app exit instead of leaking work into process termination.
Background
~~~~~~~~~~
start_peer() previously returned only a command sender. The four startup
services (QUIC server, mDNS discovery, peer liveness, local library
monitor) and their child tasks (ping workers, handshake jobs, download
workers, announcement fan-outs, connection/stream handlers) were spawned
with raw tokio::spawn and detached. Closing the command channel sent
Goodbye notifications but did not stop those services. The mDNS blocking
worker had no cancellation path at all. Active downloads were stored as
JoinHandle<()> and force-aborted, which could interrupt file writes
mid-chunk.
Supervisor
~~~~~~~~~~
The runtime now owns a CancellationToken and a TaskTracker, threaded
through Ctx and PeerCtx. Each long-lived service is spawned through a
small supervisor (spawn_supervised_service) that wraps the service in
catch_unwind and enforces an explicit SupervisionPolicy:
QuicServer: Required (fatal; cancels the runtime if it dies)
Discovery: Restart(5s) (matches the prior self-restart loop)
Liveness: Restart(5s)
LocalMonitor: BestEffort (logs and exits, no restart)
A Required failure emits a new RuntimeFailed { component, error } event
to the UI and cancels the runtime; the command loop and goodbye
notifications still run to completion. The Tauri layer forwards the
event as "peer-runtime-failed" so a future UI can surface it.
mDNS cancellation
~~~~~~~~~~~~~~~~~
MdnsBrowser previously blocked on receiver.recv() forever. It now
exposes next_service_timeout(Duration) returning an MdnsServicePoll
enum (Service/Timeout/Closed) via recv_timeout(). The discovery worker
polls at 250ms and checks the shutdown flag between ticks, so
cancellation reaches the blocking thread within one poll interval
instead of waiting for the next mDNS event.
Downloads
~~~~~~~~~
active_downloads is now HashMap<String, CancellationToken>. Each
download gets a child token of the runtime shutdown, checked at chunk
and peer-attempt boundaries (never inside file writes). When all peers
with a game disappear, liveness cancels the token and emits
DownloadGameFilesAllPeersGone; the download exits Ok(()) without
emitting a duplicate Failed event.
DownloadStateGuard (context.rs) is held inside the download task and
clears downloading_games + active_downloads on Drop, covering the happy
path, error returns, cancellation, and task abort. Drop falls back to
spawning the cleanup if write-lock contention prevents try_write.
Public API and Tauri integration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
start_peer() now returns PeerRuntimeHandle exposing:
fn sender(&self) -> UnboundedSender<PeerCommand>
fn shutdown(&self)
async fn wait_stopped(&mut self)
The Tauri layer stores the handle in managed state and switches its
main loop from .run(ctx) to .build(ctx).run(|h, e| ...). On
RunEvent::Exit it calls handle.shutdown() and blocks up to 2s on
wait_stopped(), giving services time to cancel and Goodbye packets time
to flush over a healthy LAN while staying short enough not to delay
process exit noticeably on a dead network.
The command loop distinguishes graceful shutdown from unexpected
channel closure: if recv() returns None and shutdown.is_cancelled() is
set, the loop returns Ok(()) silently. Only an unexpected close (no
cancellation observed) still emits RuntimeFailed. This avoids a
spurious failure event on every normal app close.
User-visible behavior changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Closing the app no longer leaks services into process termination;
Goodbye notifications are reliably attempted before exit.
- Downloads cancel cleanly (between chunks) instead of force-aborting
mid-write.
- A new "peer-runtime-failed" Tauri event fires when a Required service
cannot recover. No frontend handler exists yet — that is a follow-up.
Tradeoffs
~~~~~~~~~
- Workspace tokio-util now requires the "rt" feature for TaskTracker.
- The mDNS worker still runs in spawn_blocking and may stay parked
briefly between 250ms polls — acceptable for a desktop app.
- The 2s shutdown timeout on app exit is a deliberate compromise.
Tests
~~~~~
New unit tests:
- DownloadStateGuard clears tracking on completion, cancellation, and
parent-task abort (context.rs).
- Required failure cancels the runtime and emits RuntimeFailed
(startup.rs).
- Restart policy restarts until shutdown is requested (startup.rs).
- PeerRuntimeHandle.shutdown() observable via wait_stopped()
(startup.rs).
- Peers-gone cancellation emits only PeersGone, no duplicate Failed
(services/liveness.rs).
Test plan
~~~~~~~~~
cargo test --workspace
cargo clippy --workspace --all-targets
Manual smoke test on two peers on the same LAN:
1. Start a download, verify chunks transfer.
2. Close the receiving app mid-download — verify the sending peer
logs a Goodbye, not a connection-reset error.
3. Stop the sending peer mid-download — verify the receiver emits
DownloadGameFilesAllPeersGone, not Failed.
Follow-ups
~~~~~~~~~~
- Frontend handler for "peer-runtime-failed".
- Consider exposing the runtime handle's stopped watch to the frontend
for a reconnecting indicator on Required failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|