Commit Graph

11 Commits

Author SHA1 Message Date
ddidderr f62515451b feat(ui): label streamed installs as not shareable
NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be
clear to users. Keep streamed installs in the installed visual state so
sorting, filters, and the primary Play action stay unchanged, but make the
sharing limitation visible in the UI.

Cards now label that state as `Not shareable`, while the detail modal
status says `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` wording.

Test Plan:
- just frontend-test
- just build
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 7
2026-06-07 22:29:26 +02:00
ddidderr 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
2026-06-07 21:39:02 +02:00
ddidderr 738095235f feat(peer): coordinate outbound transfers with local game mutations
Updating or removing a local game rewrites its on-disk files. Peers that
were mid-download of that game would keep streaming bytes from files that
are being deleted or replaced, handing them a corrupt or stale copy.
There was also no authoritative notion of which game version a peer
should serve or accept, so a peer could serve whatever happened to be on
disk and downloaders could aggregate files from peers running mismatched
versions.

This introduces a reader-writer coordination scheme between outbound file
transfers (readers) and local mutation operations (writers), and gates
both serving and downloading on an authoritative game catalog version.

Reader-writer coordination:
- Track active outbound transfers per game in a shared `OutboundTransfers`
  map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and
  registered by a `TransferGuard` in the stream service. The guard is
  registered *before* the serve-eligibility check to close a TOCTOU window
  where a writer could miss an in-flight reader.
- `stream_file_bytes` now honors a cancellation token at every await point
  (file read, network send, stream close) via `tokio::select!`, so a
  transfer aborts promptly instead of hanging on a stalled receiver.
- `begin_operation` marks a game active first, then cancels its outbound
  transfers and waits for the count to reach zero before any
  Updating/RemovingDownload work touches the filesystem.
- Active games are now hidden from library snapshots entirely while an
  operation is in flight, instead of freezing their last announced state,
  so peers stop discovering a game that is being mutated.

Authoritative version catalog:
- Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each
  game id to its expected version (from the bundled game.db / ETI data).
- Serving requires the local `version.ini` to match the catalog version
  (`local_download_matches_catalog`); peer selection, file aggregation,
  and majority size validation all filter on the expected version
  (`peers_with_expected_version`, `aggregated_game_files`, and friends).

User-visible changes:
- The GUI shows confirmation dialogs before Update and Remove, and
  surfaces a sharing-status indicator on game cards and the detail modal.
- A new `OutboundTransferCountChanged` event lets the UI reflect live
  outbound transfer activity.

Test Plan:
- just test
- just frontend-test
- just clippy
2026-05-30 16:36:58 +02:00
ddidderr 47e2bbd454 feat(ui): add download progress controls
Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.

Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.

Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check

Refs: design reference e308009a08
2026-05-20 23:20:53 +02:00
ddidderr 01712f248b feat(ui): show download progress and speed in the action button
Previously the action button only said "Downloading…" with no indication of
how far along the transfer was or how fast it was going. With multi-gigabyte
game payloads on a LAN this gave the user no signal whether the download had
stalled, was hitting the wire fast, or was about to finish.

Wire a sampled byte-level progress channel from the download pipeline up to
the action button:

- New `DownloadProgressTracker` in `crates/lanspread-peer/src/download/progress.rs`
  holds the total expected bytes plus two atomic counters: `downloaded_bytes`
  (deduplicated per `(relative_path, offset)` chunk key, used for the bar) and
  `transferred_bytes` (raw cumulative, used for the speed sample). The dedup
  prevents a retried chunk from double-counting toward completion while still
  letting speed reflect actual wire activity including retry waste, which is
  the more useful metric for "is the link doing anything right now?".
- `sample_download_progress` wraps the transfer future, emits an initial 0 B/s
  snapshot, then samples on a 500 ms interval (`MissedTickBehavior::Skip` so a
  stalled downloader does not generate a thundering herd of catch-up ticks)
  and emits one final snapshot when the future resolves, so the UI sees the
  closing state before `DownloadGameFilesFinished` arrives.
- New `PeerEvent::DownloadGameFilesProgress(DownloadProgress)` variant carries
  `{ id, downloaded_bytes, total_bytes, bytes_per_second }`. The Tauri shell
  forwards it as `game-download-progress`; the JSONL harness emits it as
  `download-progress`.
- Orchestrator and retry paths refactored to thread a single shared
  `Arc<DownloadProgressTracker>` through both the initial transfer and any
  retry attempts. New `TransferContext`, `RetryContext`, and `ChunkPlanContext`
  structs absorb the parameter-list growth that came with adding the tracker.

Frontend rendering honors the snapshot-is-authoritative decision from commit
`5df82aa` ("fix(ui): derive operation status from snapshots"):

- `Game.download_progress` is an ephemeral overlay carried alongside the card,
  not a status field. `mergeGameUpdate` preserves it only while
  `install_status === Downloading` and otherwise clears it on the next
  snapshot, so the games-list snapshot remains the single authority for when
  the bar should disappear.
- The `game-download-progress` listener writes ONLY `download_progress` — it
  does not touch `install_status`, `status_message`, or `status_level`. This
  preserves the rule that lifecycle events never mutate card status.
- No `game-download-finished` listener; snapshot reconciliation clears the
  overlay automatically when status leaves Downloading.
- `ActionButton` renders a percentage fill behind the icon/label via a
  `--download-progress` CSS custom property; the existing `.act-busy` spinner
  is layered above the fill with `z-index: 1`. `act-downloading` widens the
  button to avoid label jitter as the speed number changes (tabular-nums).
- `actionLabel` for the Downloading status now appends a formatted speed
  ("Downloading… 12.5 MB/s") via the new `formatBytesPerSecond` helper.

Test Plan:
- `just test` — Rust workspace tests including new progress tracker unit tests
  (`tracker_counts_only_new_bytes_for_a_retried_chunk`,
  `tracker_clamps_reported_bytes_to_total`).
- `just frontend-test` — Deno tests including
  `download progress is preserved only while actively downloading` and
  `downloading action label includes current speed`.
- `just clippy` — clean.
- Manual: download a multi-GB game from a peer and watch the action button
  fill, speed update on the half-second, and reset cleanly on completion.

Refs: download progress visibility, snapshot-authoritative UI architecture
2026-05-20 22:11:09 +02:00
ddidderr 5df82aa4f3 fix(ui): derive operation status from snapshots
The launcher was mixing lifecycle event handlers with the games-list snapshot
when deciding the card status. That left multiple writers for the same
install_status field and made event ordering visible in React.

Make games-list-updated active_operations the authoritative source for busy
status. Lifecycle events no longer mutate the card status; they only keep their
non-status side effects such as rescans and error messages. The only remaining
optimistic status is CheckingPeers before the backend emits its next snapshot.

Add a frontend reducer test that proves an install stays in Installing while an
active install snapshot exists, then settles to Installed only after the active
operation clears with installed local state.

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

Refs: local install/download status snapshot cleanup
2026-05-20 07:03:38 +02:00
ddidderr ebeee2d90a fix(settings): name descending size sort explicitly
The library sort setting used `size` for largest-first sorting while the
ascending option used `sizeAsc`. That made the pair asymmetric and left the
current settings model carrying a legacy-looking key.

Rename the current descending key to `sizeDesc` in the type, menu, and sort
logic. Stored `size` values are normalized to `sizeDesc` on read, so existing
users keep the same largest-first behavior while new writes use the explicit
key.

Test Plan:
- deno task build
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
- git diff --check

Refs: local review feedback
2026-05-19 21:28:40 +02:00
ddidderr 62ceb063ac feat(peer): remove downloaded game files safely
Downloaded but uninstalled games can still occupy significant disk space. Add a
separate removal path for that state instead of overloading uninstall, which is
reserved for deleting only `local/` installs.

The peer runtime now exposes `RemoveDownloadedGame` with matching lifecycle
and active-operation events. The filesystem delete is intentionally strict: the
id must be a catalog game and a single path component, the target must be a
direct child of the configured game directory, the root must not be a symlink,
it must have a regular root-level `version.ini`, and it must not contain
`local/`, `.local.installing/`, or `.local.backup/`. Only then do we recursively
remove the game root.

The Tauri bridge exposes this as `remove_downloaded_game`, the frontend shows a
matching danger action only for downloaded-but-uninstalled games, and a
confirmation dialog warns that re-downloading can take a long time.

Test Plan:
- git diff --check
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build

Refs: user redesign nitpick about removing downloaded uninstalled games
2026-05-19 21:00:44 +02:00
ddidderr 25f92c9b0b feat(ui): add smallest-first size sort
The redesign only offered a largest-first size sort. Keep the existing `size`
preference value as largest for compatibility with saved settings and add a new
ascending size key for users who want to find small downloads first.

The sort menu now exposes both size directions and the sorter handles the new
smallest-first option directly.

Test Plan:
- git diff --check

Refs: user redesign nitpick about Size (smallest) sort
2026-05-19 20:47:01 +02:00
ddidderr bcaf28dcee fix(ui): count all-games filter from network games
The launcher redesign showed the All Games pill count from the full bundled
catalog. That made the counter report every row in game.db even though the All
Games filter itself only shows games that are visible on the current network or
present locally.

Use the same network-visible predicate for the counter and the filter. The pill
count and results total now describe the displayed network library instead of
the baked catalog size.

Test Plan:
- git diff --check

Refs: user redesign nitpick about All Games counter
2026-05-19 20:46:31 +02:00
ddidderr 640214ec38 feat(tauri): implement Steam-style launcher redesign per design handoff
Replace the previous monolithic 900-line `App.tsx` launcher UI with the
Steam-inspired dark redesign specified in `design/README.md` (handoff
committed in the previous commit). The new UI is split across small,
single-responsibility React modules instead of one file.

What changes from the user's perspective
----------------------------------------

- Dark, gradient-tinted background with sticky 64px top bar (glass blur
  + saturate). Single-row chrome (handoff variant A).
- Pill-style filter toggle (`All Games` / `Local` / `Installed`) with an
  animated thumb that slides between options.
- Search field with magnifying-glass icon and a `/` keyboard shortcut to
  focus it from anywhere outside an input.
- Sort menu (Name A–Z / Size / Status) as a dropdown.
- Game directory button shows the current path with leading-ellipsis
  truncation; clicking it opens the native folder picker.
- Kebab menu hosts Settings, Refresh library, and Unpack logs (existing
  companion window). The standalone Unpack-Logs button is removed from
  the chrome.
- Game grid uses CSS `auto-fill` minmax with three density presets
  (compact / normal / large) and three cover aspects (box / square /
  banner), persisted via the Settings dialog.
- Game cards render with the real thumbnail when the backend has one
  (via `get_game_thumbnail`) and fall back to a procedurally-generated
  gradient + accent-blob placeholder with a Bebas Neue title burned in.
  Each card carries a color-coded state chip (Installed = green,
  Downloaded = amber, busy = pulsing accent), a peers chip when at
  least one peer holds the game, the title, size · genre meta line, a
  status line (errors in red), and a single color-coded primary action
  button: Play (green gradient), Update / Install (accent), Download
  (neutral), animated "busy" spinner during in-flight operations, or a
  disabled "Unavailable" state when no peer has the game.
- Clicking anywhere on a card except the action button opens a detail
  modal: 16:7 hero (uses the thumbnail), state chip, tag pills derived
  from genre/publisher/release_year, large title, 4-cell meta grid
  (size, players from `max_players`, version from `local_version` or
  `eti_game_version` formatted YYYY.MM.DD, status), description, and an
  action row with the primary action plus an Uninstall ghost-danger
  button when the game is installed. Esc, scrim click, and the close
  button all dismiss the modal.
- Settings dialog (opened from the kebab menu) lets the user change the
  accent color (six swatches), background style (flat / gradient /
  animated), grid density, and cover aspect. Changes apply live and
  persist immediately to the Tauri store under `launcher-settings.json`
  (key `ui-settings`); the existing `game-directory` key in the same
  file is unchanged.
- Empty state when no directory is chosen offers a centered prompt with
  a single CTA. Empty state when filters/search match nothing shows a
  distinct "Nothing matches" message.

Why this approach
-----------------

The handoff selected variant A (single-row chrome) explicitly, so only
that variant is implemented; variant B underlined tabs and the
storage-meter widget are intentionally omitted (no free-space data
available from the backend yet).

Real cover art from `get_game_thumbnail` is preferred over the
placeholder generator. When a thumbnail is present, the Bebas Neue
title overlay is suppressed because shipped cover art already has its
own title. When the thumbnail is absent, the placeholder gradient (with
per-id stable hue/blob/angle) plus the burned-in title takes over —
this is the same procedural look as the design reference.

Architecture / file layout
--------------------------

The previous single-file design is decomposed top-down:

```
src/
  main.tsx                    entry; loads tokens + launcher CSS
  App.tsx                     thin router (main vs. unpack-logs view)
  styles/
    tokens.css                CSS custom props + body reset
    launcher.css              port of the design reference styles.css
                              (single-row chrome only)
  windows/
    MainWindow.tsx            composition root: top bar + grid + modals
  lib/
    types.ts                  Game, InstallStatus, GameAvailability,
                              ActiveOperationKind, GameFilter / GameSort,
                              DerivedState
    gameState.ts              derive() + isUnavailable + needsUpdate +
                              primaryActionFor + actionLabel +
                              mergeGameUpdate (event reconciliation) +
                              countByFilter + applyFilterAndSort
    format.ts                 formatBytes, formatEtiVersion (YYYYMMDD),
                              truncatePath, formatPlayers
    cover.ts                  coverColorsFor(id) — stable palette pick +
                              gradient angle + blob position from id
                              hash; titleFontSize
    store.ts                  file + key constants for plugin-store
  hooks/
    useSettings.ts            UISettings + accent/bg/density/aspect/
                              sort/filter, persisted via plugin-store
    useGameDirectory.ts       loads + persists the chosen directory and
                              pushes it to update_game_directory
    useGames.ts               owns the games list; listens to every
                              backend event (games-list-updated,
                              game-download-begin/finished/failed/
                              peers-gone, game-no-peers, game-install-
                              begin/finished/failed, game-uninstall-
                              begin/finished/failed, peer-count-updated);
                              exposes markChecking with a 5s fallback to
                              clear "Checking peers…" when nothing comes
                              back from the backend
    useGameActions.ts         play / install / update / uninstall
                              wrappers around the corresponding invoke
                              commands
    useThumbnails.ts          lazy per-id cache for get_game_thumbnail
  components/
    Icon.tsx                  inline SVG icon set (currentColor)
    Brand.tsx                 brand mark + name + peer-count chip
    Modal.tsx                 generic scrim + panel + Esc handler
    StateChip.tsx             corner pill with state-coded dot
    ActionButton.tsx          color-coded primary action; disabled when
                              unavailable; spinner when busy
    SegmentedRadio.tsx        generic 3-way segmented control
    ColorSwatchPicker.tsx     6-swatch picker with check overlay
    topbar/
      TopBar.tsx              chrome composition
      SegmentedFilters.tsx    All / Local / Installed with sliding thumb
      SearchField.tsx         input + `/` shortcut
      SortMenu.tsx            dropdown sort selector
      DirectoryButton.tsx     folder picker trigger
      KebabMenu.tsx           generic dropdown menu
    grid/
      ResultsBar.tsx          "Showing N of M games"
      GameGrid.tsx            CSS-grid wrapper
      GameCard.tsx            full card composition
      GameCover.tsx           thumbnail OR placeholder cover art
    modals/
      GameDetailModal.tsx     hero + meta grid + actions
      SettingsDialog.tsx      appearance + library preferences
    empty/
      NoDirectoryState.tsx    onboarding CTA
      EmptyResultsState.tsx   "scanning" / "nothing matches"
```

`UnpackLogsWindow.tsx` and its CSS are untouched — the unpack-logs
companion window is rendered as before via the existing `?view=unpack-
logs` route in `App.tsx`.

The previous `App.css` is removed entirely (its styles are superseded
by `styles/launcher.css`).

Bebas Neue is loaded via Google Fonts in `index.html` (preconnect +
swap), used for the brand mark and the placeholder cover-art titles.

Tradeoffs and intentional omissions
-----------------------------------

- Storage meter: omitted. The handoff specifies installed/local/free
  bytes, but no Tauri command currently provides free-space data.
- Variant B (two-row chrome with underline tabs): omitted; the handoff
  picked variant A.
- "View files" action in the detail modal: omitted. The backend doesn't
  expose per-game install paths and `shell.open` of the user-chosen
  root directory would be misleading.
- "Delete from disk" ghost-danger action for `local` games: omitted.
  No backend command currently distinguishes "delete downloaded
  archive" from `uninstall_game`. Only installed games get an Uninstall
  button.
- "Recently Played" sort: omitted (no play-time tracking yet). The sort
  menu offers Name / Size / Status instead.
- Keyboard arrow grid navigation: not yet implemented (out of scope per
  the handoff).
- Per-game progress bar during downloads/installs: not implemented; the
  action button shows a spinner + "Downloading…" / "Installing…" label
  instead, matching the existing event-driven status text.

Persistence
-----------

UI preferences (accent, bg, density, aspect, sort, filter) live in
`launcher-settings.json` under a new `ui-settings` key. The existing
`game-directory` key in the same file is preserved untouched, so users
keep their previously selected directory.

Test plan
---------

Frontend build verified locally:

  cd crates/lanspread-tauri-deno-ts && deno task build
  → `tsc && vite build` completes with no diagnostics; bundle ~228 kB.

Manual verification (recommended once the app boots end-to-end):

- [ ] Launch with no directory set: only the "Pick a game directory"
      empty state is visible; clicking the button opens the native
      folder picker.
- [ ] Pick a directory: top bar appears, grid populates as games arrive.
- [ ] Click the All / Local / Installed pills: the thumb slides; the
      count chips reflect the right subset.
- [ ] Press `/`: focus moves to the search input; type a substring and
      confirm the grid filters live.
- [ ] Open the Sort menu, switch between sorts; the grid reorders.
- [ ] Open the Settings dialog from the kebab: change accent → the
      thumb, brand mark, search-focus ring, and Install button all
      switch color live. Change density → grid card size changes.
      Change cover aspect → cards re-shape (2/3, 1/1, 16/9). Close and
      reopen: choices are remembered.
- [ ] Click anywhere on a card except the action button → detail modal
      opens with the right metadata; Esc / scrim click / close button
      all dismiss it.
- [ ] Click the action button on an `installed` card → game launches.
- [ ] Click the action button on a `local` card → install starts;
      button shows the spinner + "Installing…".
- [ ] Click on a `none` card with peer_count > 0 → download starts; the
      lifecycle events update the button label correctly.
- [ ] Card for a game with peer_count == 0 and not downloaded → button
      reads "Unavailable" and is disabled.
- [ ] Trigger a `game-download-failed` from the backend: the error
      status line appears under the card title in red.
- [ ] Open Unpack Logs from the kebab: the companion window opens
      exactly as before.

Trailer
-------
Refs: design/README.md (canonical handoff), design/design_reference/
2026-05-19 20:12:57 +02:00