09709cc0084fa6c3aa8c970f6a492256c97be939
135 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
e06a887da1
|
fix(ui): strip literal br tags from game descriptions
Some ETI game descriptions include the literal string <br> in metadata. React renders descriptions as text, so the marker appears to users instead of being treated as a line break. Strip only the exact <br> token at the detail modal boundary. This keeps the fix UI-only and avoids treating descriptions as HTML or normalizing any other markup-like text. Test Plan: - just frontend-test - git diff --check Refs: none |
||
|
|
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> |
||
|
|
2e7a0cff2f
|
feat(ui): move game folder picker into settings
The design update moved game-folder configuration out of launcher chrome and into Settings > Library. Follow that contract in the runtime UI without changing the existing storage or Tauri directory commands. The top bar now leaves its right edge for the kebab menu. Settings owns a new Game folder row that shows a valid selected path with a neutral Change button, or the red Not set state with a stronger Choose button when no accessible directory is configured. Both the empty-library state and the Settings row still use the existing native directory picker, so existing saved paths and rescans keep their current behavior. Keep useGameDirectory as the directory-state owner and expose the shared hasGameDirectory boolean from that hook so the grid and Settings field agree on what counts as configured. Test Plan: - git diff --cached --check - just frontend-test - just build Refs: 62b409f4bfc4995c25461776107d28f52b24f30e |
||
|
|
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> |
||
|
|
a7d99261cf
|
fix(ui): reserve search clear button space
Typing the first character into the launcher search field used to insert the clear button into the flex row, which widened the field after focus. The clear button now stays mounted in the row so its slot is part of the empty field's layout, while the empty state hides and disables it. Users now get the wider steady-state search width immediately, and the control does not jump when the clear affordance becomes visible. Test Plan: - git diff --check - just frontend-test - just build Refs: user-reported search field jump |
||
|
|
debb1c0c49
|
feat(ui): focus search with Ctrl+F
Ctrl+F is a common search shortcut and should open the launcher search just like the existing slash shortcut. Handle it in the same SearchField keydown listener so the behavior stays scoped to the topbar search component. The shortcut is ignored while the user is already typing in another input or textarea, matching the existing slash behavior. When handled, it prevents the webview's browser find UI and focuses the app search field instead. Test Plan: - `just frontend-test` Refs: none |
||
|
|
0151d7a16c
|
style(ui): match game-folder topbar design
Follow the latest top-bar spec for the Game Folder control. The button stays as only the folder icon and short label, with the full path in tooltip and aria text, and now sits in the app-level control group with the kebab menu on the far-right edge of the wide top bar. This keeps Sort as the right-zone lead control next to the centered search cluster while treating Game Folder and the kebab menu as a tight trailing pair. The narrow fallback still flows in source order: sort, game folder, kebab. Test Plan: - git diff --check - rg -n "dirbtn-status-dot|status dot|green status|red status" crates/lanspread-tauri-deno-ts/src - just frontend-test - just build |
||
|
|
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 |
||
|
|
059c1e7720
|
feat(ui): redesign game-folder button as icon + label + dot
Implements the v2 design's game-folder button in the Tauri launcher. The previous control squeezed the full game-directory path into the top-bar button as truncated monospace (e.g. `…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading ellipsis rarely showed the meaningful part of the path, ate horizontal space the new 3-zone top bar needs for its primary controls, and competed with the filter / search / sort cluster for attention. The button now communicates the *state* of the configuration at a glance — an icon + short label + colored status dot — while the full path moves into the native tooltip and `aria-label`, where it stays one mouseover (and screen-reader friendly) away. Two visual states, both 36 px tall and sharing the surface of the other top-bar controls: - **Set & valid** (`.dirbtn-set`) — label `Game folder`, green dot (`--ok`) with a soft glow, default border, tooltip = full path. - **Not set / invalid** (`.dirbtn-unset`) — label `Set game folder`, red dot (`--danger`) with a soft glow, a red-tinted border, and a faint red wash on hover so the bad state reads as "this is what you need to fix". Tooltip = `Please select a game folder`. `DirectoryButton` now takes `path: string | null` and picks the state from `!!(path && path.trim())`. The `aria-label` carries the full state in words (`Game folder: <path>` / `Set game folder`) so screen readers don't have to interpret the colored dot. Note: in the current `MainWindow`, `gameDir` is gated upstream — if no directory is selected, `NoDirectoryState` is shown instead of the top bar, so the unset state will only surface here if we later validate disk existence and clear `gameDir`. The button accepts a nullable path anyway, so it's ready when that check lands. `truncatePath` in `lib/format.ts` was the only caller-less helper left behind and is removed. Test Plan - `npx tsc --noEmit` from the frontend crate — clean. - `just frontend-test` — passes. - Manual: `just run`, pick a valid game directory, and confirm the top-bar button reads `Game folder` with a green dot; hovering it reveals the full path in the OS tooltip. Inspect the DOM and confirm `aria-label` reads `Game folder: <path>`. (The unset variant currently isn't surfaced by `MainWindow`; eyeball it via DevTools by toggling the `dirbtn-set` class to `dirbtn-unset`.) |
||
|
|
095bc9b9ff
|
feat(ui): center search in a 3-zone top bar
Implements the v2 design's top-bar reorganization in the Tauri launcher. The bar was previously a flat flex row that let the search field drift left or right depending on filter / sort widths; now it's a 3-column CSS grid with the search field pinned to the geometric center of the window. - `.topbar` becomes `display: grid` with `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` and a 16 px column gap. The middle (auto) column holds only the search, capped at `flex: 0 1 360px` so it cannot push into the side columns. - The left zone is `flex; justify-content: space-between`: brand pins far-left, filter pills hug the search. The filter pills are now grouped with the search semantically (they scope it) instead of floating next to the brand. - The right zone mirrors that: sort hugs the search, kebab pins far-right, with the directory button between them. - A `@container launcher (max-width: 1100px)` rule collapses the layout back to a single nowrap flex row at narrow widths — the geometric centering doesn't read at small widths and would force awkward truncation, so we abandon it rather than fight it. The launcher root opts into container queries via `container-type: inline-size; container-name: launcher`. `TopBar.tsx` now wraps the existing children in `.topbar-left`, `.topbar-center`, `.topbar-right` (plus `.topbar-left-trail` / `.topbar-right-lead` for the inner space-between alignment), but each control component is otherwise untouched. Test Plan - `just frontend-test` — passes. - `npx tsc --noEmit` from the frontend crate — clean. - Manual: run `just run`, confirm the search input's horizontal center matches the window's horizontal center across the standard launcher width. Shrink the window below 1100 px and confirm the row collapses to a single left-to-right strip with no overlap or wrapping. |
||
|
|
8d96d99160
|
fix(ui): keep file viewer visible while installing
The detail view already exposes View Files while a game is downloaded, installed, or actively downloading. During the download-to-install handoff, the backend can report Installing before the local downloaded/installed flags have settled in the next snapshot, which briefly hid the file viewer button. Treat Installing as another state where the game root should remain reachable. This keeps the detail view stable during the handoff without changing backend file-opening behavior. Test Plan: - just frontend-test |
||
|
|
a913e4c776
|
feat(settings): show automatic build number
The launcher settings dialog now shows a Build-Nr value in its footer so users can identify the exact application build without manually updating a number before release. The value is generated by Vite when the frontend bundle is built and injected as an import.meta.env constant. Using the current millisecond timestamp keeps the number numeric and monotonic for normal builds without a checked-in counter file or any extra build-state mutation. The tradeoff is that the value follows the build machine clock rather than a central sequence service. The footer layout now keeps the build number on the lower left and the Done button on the lower right. Test Plan: - just frontend-test - just build - git diff --cached --check Refs: local user request |
||
|
|
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 |
||
|
|
12a0d7abe9
|
feat(ui): align peer count chip with design reference
Mirror the design-doc update in the actual download progress component so the GUI matches the trimmed chip. Previously the large progress panel rendered `[icon] from N peer(s)` inline; now it renders just `[icon] N`, with the full "Downloading from N peers on the LAN" sentence retained as the `title` tooltip for discoverability. Changes: - `DownloadProgress.tsx` (lg variant): drop the "from" / unit text from the inline span, keeping only the count. `peerUnit` stays in scope because the tooltip still needs singular/plural. - `launcher.css`: collapse `.dl-peers` and `.dl-peers strong` into a single rule that puts the t-1 colour, 600 weight and tabular-nums directly on the chip (the inner `<strong>` no longer exists). Gap drops from 5px to 4px to match the tighter icon+number layout. - Container queries: peers drops at <=240px and ETA drops at <=320px, matching the new thresholds in the design reference. The narrower chip simply fits in smaller modals, so the old 300/380 cutoffs were hiding stats that would have rendered fine. Test Plan - `just frontend-test` (passes) - `just run`, start a download, confirm the chip reads `[icon] N`, hover shows the tooltip, and narrowing the window collapses ETA before peers at the new breakpoints. |
||
|
|
8acb6dc246
|
feat: show active download peer count
Render the active peer count already carried by download progress events in the large download progress control. The peer chip appears between speed and ETA, uses singular/plural copy, and hides after the ETA when the detail modal gets very narrow. This keeps the UI aligned with the design reference without changing backend state ownership or download progress plumbing. Test Plan: - git diff --check - git diff --cached --check - just frontend-test - just build |
||
|
|
b56f4e2757
|
feat(peer): expose active download peer count
The launcher needs design work later for showing how many peers are currently feeding an active download. Surface that data now on the existing progress payload so UI state can consume it without a separate event stream or rendering change. The peer download tracker now treats each live chunk receive as peer activity and reports the number of unique peers with in-flight streams. This is a live transfer count, not the number of peers that advertised the game or received a plan. Multiple chunks from one peer count once, and the count falls as chunk streams finish. Tauri already forwards DownloadGameFilesProgress, so no bridge event was added. The TypeScript model accepts active_peer_count under download_progress and preserves it with the same reducer path that keeps bytes and speed while the backend says the game is still downloading. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - just frontend-test - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Refs: none |
||
|
|
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
|
||
|
|
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 |
||
|
|
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/
|
||
|
|
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> |
||
|
|
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 |
||
|
|
c795e9de02
|
deps: deno and cargo update | ||
|
|
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 |
||
|
|
0d2520fd16
|
fix(ui): stop showing manually deleted games as installed
A game that the user deletes from disk while the launcher is running stayed
visible as "Installed" in the UI indefinitely, both as a status label and as
a member of the Installed tab. After a restart the Install button reappeared
but the game still wrongly showed up under Installed. The backend rescan
(`set_all_uninstalled` + `update_game_installation_state` in
src-tauri/src/lib.rs) was already producing the correct `installed: false`
on each refresh; the React store was just refusing to honour it.
Two independent UI bugs were in play:
1. The `games-list-updated` listener merged each update with
`previous?.install_status ?? ...`, which preserved a prior `Installed`
value regardless of what the backend now reported. The fix introduces
`mergeGameUpdate`: the backend `installed` flag wins for settled state
(Installed vs NotInstalled), while genuine in-progress states
(CheckingPeers / Downloading / Unpacking) are preserved across refreshes
so concurrent backend ticks cannot blow away an active download UI.
`status_message` and `status_level` are cleared only when the local
`installed` / `downloaded` flags actually flip, so a transient error
("No peers currently have this game.") survives a cosmetic refresh but
is wiped once the underlying state changes.
2. The Installed tab filter was `installed || downloaded`, which leaked
downloaded-but-not-yet-installed games into a tab whose label promises
only ready-to-play titles. It now filters on `installed` alone, matching
`getActionLabel`'s own definition of when "Install" appears.
While the install-state semantics were being sorted out, the filter
taxonomy was clarified to match what users actually mean:
| Button | Filter |
|------------|---------------------------------------------------|
| All Games | installed || downloaded || peer_count > 0 |
| Local | installed || downloaded |
| Installed | installed |
The "Available" button was renamed "Local" because users do not think of
themselves as a peer; Local means "on my system, whether the archive is
still packed or already installed". "All Games" previously surfaced every
row in the bundled game.db, including catalogue entries that no peer on
the LAN holds — confusing, since those games cannot be acted on. It now
scopes to LAN-reachable games. The `isUnavailable` helper and its
`Unavailable` action label are left in place: with this filter no
displayed game can hit that state today, but the helper is cheap to keep
as a safety net for transient peer-count flips and for a possible future
"also show catalogue-only entries" toggle.
Tooltips were rewritten to a consistent `Show games … on your system` /
`Show all games available on the LAN` pattern, all phrased from the user's
point of view (no "peer" jargon in user-facing strings; doc/code comments
still use "peer" where it reflects the actual protocol).
Two stale comments were dropped along the way: a note on
`getInitialGameDir` that claimed it only sets the directory if not already
set (the function unconditionally calls `setGameDir` when a value is
persisted), and a leftover `// Rest of your component remains the same`
marker from an earlier scaffold.
Test plan:
- `npm --prefix crates/lanspread-tauri-deno-ts exec tsc -- --noEmit`
passes (run as part of this change).
- `just run`, point the launcher at a game directory holding two installed
games, then manually `rm -rf` each game's local folder. Within one
refresh cycle the Installed tab should empty and each game's action
button should flip to Install / Download as appropriate, without
needing a restart.
- Start a download and verify the UI does not regress to NotInstalled
when the next `games-list-updated` arrives mid-flight.
- Cycle through All Games / Local / Installed and confirm membership
matches the table above; in particular, a game whose archive is
downloaded but not installed appears under Local and All Games but not
Installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e7bb14dc7c
|
deps: deno update --latest | ||
|
|
2e3d6a9abb
|
update CLAUDE.md, README.md and justfile | ||
|
|
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>
|