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/
The original unpack logs window was a flat, monolithic scroll of every
unrar invocation glued together as one continuous textfield. That is
fine for a sanity check but hostile to actually finding a failed
extraction in a session with 30+ games: empty lines from unrar bloated
the view, there was no way to focus on a single game, no filtering, and
no way to narrow in on the entries that actually failed.
This rewrites the viewer to be a proper debugging surface while keeping
the backend untouched -- it still consumes the existing
`get_unpack_logs` command and `unpack-logs-updated` event.
User-visible changes:
* Empty / whitespace-only lines are stripped from stdout and stderr
before rendering, so unrar's padding no longer drowns out real output.
* Two-pane layout: a sidebar lists every captured invocation (badge,
archive basename, finish time); the right pane shows the selected
entry's metadata, stdout and stderr.
* "Errors only" checkbox filters the sidebar to entries whose `success`
flag is false (sidecar exit != 0 or one of the pre-spawn failure
paths). This is the primary affordance for "find the unpack that
broke".
* Regex input filters lines (not entries) -- both per-log when viewing
one, and across the list: entries that contribute zero matching lines
are hidden, and the remaining ones display a per-entry match counter
next to the badge. Regex is case-insensitive; a bad pattern reddens
the input and renders the parser error inline rather than silently
dropping all matches.
* Prev / Next buttons plus arrow keys (and j/k) step through the
filtered list one entry at a time, with the active row auto-scrolled
into view. Selection is tracked by the entry's index in the full log
ring so it survives filter toggles and live appends.
Code organization:
The component, its types, helpers (`basename`, `nonEmptyLines`,
`formatLogTime`, `isUnpackLogsView`) and its CSS are moved out of
`App.tsx` / `App.css` into a dedicated `UnpackLogsWindow.tsx` +
`UnpackLogsWindow.css` pair. The viewer has no shared state with the
main window and lives behind its own `?view=unpack-logs` route, so
keeping ~200 lines of debug-UI plumbing inside `App.tsx` was just
noise. `App.tsx` now imports `UnpackLogsWindow` and `isUnpackLogsView`
and otherwise looks the same as before.
Intentionally out of scope:
* No backend changes. The Rust side already records everything needed;
this is purely a presentation improvement.
* No "view all logs concatenated" mode. The flat view was what we just
replaced -- if it is ever wanted back, it can be added as a third
pane mode.
* Regex is applied to displayed lines only, not to archive paths or
meta. Filtering by archive name is easy enough via the basename in
the sidebar; adding a second filter for it now would be premature.
* Logs are still process-local and capped at `MAX_UNPACK_LOGS` (100)
in the Rust state -- unchanged from b35755f.
Test plan:
* `tsc --noEmit` and `vite build` are clean.
* Manual: trigger several successful and failed unpacks (rename one
archive between handshake and extraction to force a canonicalize
failure), open Unpack Logs, and verify:
- empty lines are gone from stdout/stderr,
- sidebar lists every entry with the right OK/FAIL badge,
- "Errors only" hides the OK rows,
- typing a regex narrows lines in the open entry, hides entries
with no matches, and shows the per-entry match counts,
- an invalid regex (e.g. `[`) reddens the field and shows the
parser error rather than crashing,
- arrow keys / j / k step through the filtered list and the
active row scrolls into view,
- new entries arriving via `unpack-logs-updated` while the window
is open keep the current selection rather than jumping.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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
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
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
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
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>
- more robust client <-> server connection
- new client event: DownloadGameFilesFailed
- 3 seconds to reconnect
- retry forever if server is gone and never lose a UI request
- code cleanup here and there (mostly server)