Compare commits

..

17 Commits

Author SHA1 Message Date
ddidderr 4fa4f8f326 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>
2026-05-21 20:58:32 +02:00
ddidderr 44fee7ff2a 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
2026-05-21 20:46:06 +02:00
ddidderr a375471b94 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>
2026-05-21 20:29:57 +02:00
ddidderr 5cf70f35bd docs(design): move game folder selection into settings
Move the design contract for choosing the library game folder out of the top
bar and into Settings > Library. The launcher chrome now reserves the right
edge for the kebab/menu controls, while Settings owns the required folder path
and its set/unset states.

The reference mock now persists `gameFolder: string | null` instead of a
boolean, adds an exercisable GameFolderField, and includes matching CSS so the
prototype reflects the documented interaction. This is still design/reference
only; no runtime Tauri settings code changes here.

Test Plan:
- git diff --cached --check

Refs: none
2026-05-21 20:14:30 +02:00
ddidderr fff1185079 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
2026-05-21 20:00:05 +02:00
ddidderr d102f19dc1 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
2026-05-21 19:46:27 +02:00
ddidderr a7dc1ba0ff 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
2026-05-21 19:31:23 +02:00
ddidderr e688fd3016 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
2026-05-21 19:17:51 +02:00
ddidderr c567ff8afd design docs: no green/red light on Game folder 2026-05-21 19:00:30 +02:00
ddidderr db41793f3a 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`.)
2026-05-21 18:49:43 +02:00
ddidderr c17e61d0df 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.
2026-05-21 18:30:06 +02:00
ddidderr 065e1586c0 docs(design): redesign game-folder button as icon + label + dot
The previous design squeezed the full game-directory path into the
top-bar button as truncated `ui-monospace` (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading-ellipsis
truncation rarely showed the meaningful part of the path on real-world
configurations, ate horizontal space the new 3-zone top bar needs for
its actual primary controls, and competed with the filter / search /
sort cluster for attention.

Replace the inline path with an icon + short label + colored status
dot. The full path moves into the tooltip and `aria-label`, where it's
still one mouseover (and screen-reader friendly) away. The button now
communicates the *state* of the configuration at a glance — which is
what users actually need.

Two visual states, both 36 px tall with the same surface as the other
top-bar controls:

- **Set & valid** — label `Game folder`, green dot (`--ok`) with a
  soft glow, default border, tooltip = full path.
- **Not set / invalid** — 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`.

"Invalid" (a path is stored but doesn't exist on disk) is collapsed
into the same visual state as "not set" — the user's required action
is identical (open the picker, pick a folder), so a third state
isn't worth the visual budget yet. If we later want to surface a
*last-known* path so the user can re-attach an external drive,
introduce a distinct missing state then.

Implementation notes:

- `DirectoryButton` now takes a single `path: string | null` prop and
  picks state from `!!(path && path.trim())`. Children are
  `Icon.folder`, the label, and an 8 px `.dirbtn-status-dot` sibling
  — the dot is an inline flex sibling, not a corner badge, because
  the button is now wider than tall and a corner pin would feel
  misplaced.
- `.dirbtn` is `inline-flex` with `padding: 0 14px 0 12px`, gap 8 px,
  `white-space: nowrap`, and `flex-shrink: 0`. The `max-width: 360px`
  cap from the path-truncation era is gone — the button is now
  intrinsically sized.
- Dot glow uses `box-shadow: 0 0 6px color-mix(...)` so it still
  reads through the launcher's translucent top-bar background.
- The Tweaks panel grows a dev-only `Game folder set` toggle (under
  the new *Library* section) that flips a `gameFolderSet` flag wired
  into the Launcher, so reviewers can see both states without
  fiddling with real filesystem state. The README explicitly calls
  this out as **dev-only** — production state comes from the
  settings store, not a user-facing toggle.

The README gains a new *Game-folder button* section with the full
spec, a state table, and a rationale paragraph; the "Changes since v2"
list and the interactions list are updated to reflect the new label
and behavior.

Test Plan

- Open `design_reference/SoftLAN Launcher.html` and locate the Tweaks
  panel's *Library → Game folder set* toggle.
  - With the toggle **on**: the top-bar button shows `Game folder`
    with a green dot; hovering the button reveals the full mock path
    in the native tooltip.
  - With the toggle **off**: the label switches to `Set game folder`,
    the dot turns red, the border picks up a red tint, and hovering
    the button reveals `Please select a game folder`.
- Inspect the button with a screen reader / DevTools accessibility
  pane: the `aria-label` should read `Game folder: <path>` when set
  and `Set game folder` when unset.
2026-05-21 18:14:18 +02:00
ddidderr 8402540050 docs(design): center search in a 3-zone top bar
The single-row top bar was a flat flex row, which made the search field
drift left or right depending on how wide the surrounding clusters were.
Rework it as a 3-column CSS grid (left zone, search, right zone) so the
search input lands at the geometric center of the window regardless of
the side-zone widths.

Mechanics:

- `.topbar-single` 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, which is
  capped at `flex: 0 1 360px` so it cannot push into the side columns.
- The left and right zones are flex containers with
  `justify-content: space-between`, so brand pins far-left while filter
  pills hug the search, and sort hugs the search while the kebab pins
  far-right. The filter pills are now grouped semantically with the
  search (they scope it) instead of floating next to the brand.
- A `@container launcher (max-width: 1100px)` rule collapses the layout
  back to a single nowrap flex row at narrow widths — the geometric
  centering stops reading at narrow widths and would otherwise 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`.

`launcher.jsx` now wraps the existing children in `.topbar-left`,
`.topbar-center`, `.topbar-right` (plus a `.topbar-left-trail` /
`.topbar-right-lead` for the inner space-between alignment), but each
control component is otherwise untouched.

The README's "Top bar" section is rewritten to spec the new layout, and
a new "Changes since v2" section calls it out at the top of the
handoff. The game-directory button line is left as-is in this commit
and addressed separately.

Test Plan

- Open `design_reference/SoftLAN Launcher.html` in a static server and
  inspect variant A at full width: the search input's horizontal center
  should match the window's horizontal center, regardless of accent /
  density choices in the Tweaks panel.
- Shrink the launcher artboard below 1100 px and confirm the row
  collapses to a single left-to-right strip with no overlap.
2026-05-21 18:03:14 +02:00
ddidderr 49ca5c04a2 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
2026-05-21 17:48:35 +02:00
ddidderr f9a709466b 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
2026-05-21 17:29:41 +02:00
ddidderr 40e4176246 fix: suppress failed event for cancelled downloads
Manual download cancellation uses the same internal error path as transfer
failures after the terminal-event ordering fix. That made the Tauri UI receive
DownloadGameFilesFailed and show a red failure state even though the user had
asked for the cancellation.

Keep a clone of the cancellation token in the download task and check it after
the transfer task returns an error. Cancelled downloads still refresh local
state and clear active operation tracking, but they no longer emit the failed
event. Real, uncancelled errors continue to send DownloadGameFilesFailed.

Add unit coverage for both branches so the UI-facing event contract stays
explicit.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check

Refs: manual cancel regression from app-state follow-up
2026-05-21 17:21:22 +02:00
ddidderr e8e7d7a93e 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
2026-05-21 17:04:00 +02:00
44 changed files with 2204 additions and 462 deletions
Generated
+1
View File
@@ -2209,6 +2209,7 @@ dependencies = [
"log",
"mimalloc",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
+4 -3
View File
@@ -5,9 +5,10 @@
directly.
- Renamed the frontend success event to `game-install-finished`; the old
unpack name no longer matched the transactional install/update lifecycle.
- Implemented watcher rescans by reusing the existing `.lanspread/library_index.json`
cache and updating a single game entry in that index. This satisfies the
per-ID optimized rescan requirement without adding a second cache format.
- Implemented watcher rescans by reusing the app-state
`local_library/index.json` cache and updating a single game entry in that
index. This satisfies the per-ID optimized rescan requirement without adding a
second cache format.
- Split full startup recovery from ordinary settled refreshes. Startup and real
`SetGameDir` changes run recovery plus a scan; install/update/uninstall
completion only rescans the affected game after operation tracking has been
@@ -592,27 +592,30 @@ class Runner:
return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources"
def s14_large_multi_peer_chunking(self) -> str:
alpha = self.peer("s14-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
game_id = PERF_GAME_ID
source_dir = self.fixture_root / "s14-alpha"
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE + 1024 * 1024)
alpha = self.peer("s14-alpha", games_dir=source_dir)
stage = self.peer("s14-stage")
connect_many(stage, [alpha])
waiter = LineWaiter(len(stage.output))
stage.send({"cmd": "download", "game_id": "alienswarm", "install": False})
stage.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="stage finish", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", stage.host_games_dir / "alienswarm")
stage.send({"cmd": "download", "game_id": game_id, "install": False})
stage.wait_for(event_is("download-finished", game_id), timeout=90, description="stage finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
client = self.peer("s14-client")
connect_many(client, [alpha, stage])
wait_remote_game(client, "alienswarm", peer_count=2)
wait_remote_game(client, game_id, peer_count=2, version="20260520")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client finish", waiter=waiter)
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm")
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
client.send({"cmd": "download", "game_id": game_id, "install": False})
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
if len(totals) < 2:
raise ScenarioError(f"expected chunks from two peers, got {totals}")
values = list(totals.values())
if max(values) - min(values) > CHUNK_SIZE:
raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}")
return f"alienswarm downloaded from two sources, diff matched, chunk totals={totals}"
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
def s15_three_way_version_skew(self) -> str:
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
+5
View File
@@ -24,6 +24,7 @@ use lanspread_peer::{
PeerRuntimeHandle,
PeerSnapshot,
PeerStartOptions,
migrate_legacy_state,
start_peer_with_options,
};
use lanspread_peer_cli::{
@@ -125,6 +126,7 @@ async fn main() -> eyre::Result<()> {
let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?;
let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await;
let migration = migrate_legacy_state(&args.games_dir, &args.state_dir).await;
let (tx_events, rx_events) = mpsc::unbounded_channel();
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
@@ -162,6 +164,7 @@ async fn main() -> eyre::Result<()> {
"name": args.name,
"games_dir": args.games_dir,
"state_dir": args.state_dir,
"migration": migration,
"fixtures": fixture_seeds,
}),
));
@@ -237,6 +240,7 @@ async fn handle_command(
id: game_id.clone(),
file_descriptions: files,
install_after_download: *install_after_download,
account_name: None,
})?;
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
}
@@ -245,6 +249,7 @@ async fn handle_command(
ensure_no_active_operation(shared, game_id).await?;
sender.send(PeerCommand::InstallGame {
id: game_id.clone(),
account_name: None,
})?;
Ok(json!({"queued": true, "game_id": game_id}))
}
+15 -8
View File
@@ -124,7 +124,8 @@ Reserved per-game paths:
- `.local.installing/` is extraction staging.
- `.local.backup/` holds the previous install while an update or uninstall is in
flight.
- `.lanspread.json` is the atomic per-game intent log.
- `games/<game_id>/install_intent.json` in the configured state directory is the
atomic per-game intent log.
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
when the current intent is `None`.
@@ -133,11 +134,17 @@ game root only for a catalog ID that is a single direct child of the configured
game directory, has a regular root-level `version.ini`, and has no `local/`,
`.local.installing/`, or `.local.backup/` path.
Recovery reads `.lanspread.json` and combines the recorded intent with the
observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent
states `Installing`, `Updating`, and `Uninstalling` prove ownership of the
corresponding reserved directories even if the marker was not flushed before a
crash. With intent `None`, markerless `.local.*` directories are left untouched.
Recovery reads app-state `install_intent.json` and combines the recorded intent
with the observed `local/`, `.local.installing/`, and `.local.backup/` state.
Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of
the corresponding reserved directories even if the marker was not flushed before
a crash. With intent `None`, markerless `.local.*` directories are left
untouched.
Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`,
`.softlan_game_installed`, and `local/.softlan_first_start_done` files are
handled only by the dedicated pre-start migration phase. Normal operation does
not read legacy state paths.
### Result
@@ -195,8 +202,8 @@ Most scans become O(number of game dirs), with full recursion only when needed.
- Cache the last accepted `manifest_hash` per peer to short-circuit
manifest requests when unchanged.
5. Local index + scan optimizations:
- Introduce a cached index file (e.g., `.lanspread/index.json`) that stores
per-root fingerprints and computed manifests.
- Use the cached `local_library/index.json` file in the configured state
directory to store per-root fingerprints and computed manifests.
- Use filesystem watchers with a debounce window to collect changes and
incrementally update the cache.
- Schedule a low-frequency full scan to reconcile missed watcher events.
+10 -5
View File
@@ -97,16 +97,21 @@ truth for whether a download is still running.
Install, update, uninstall, downloaded-file removal, and startup recovery live
under `src/install/`.
Each game root has an atomic `.lanspread.json` intent log for install-side
operations and uses Lanspread-owned `.local.installing/` and `.local.backup/`
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
intent with the observed filesystem state and only deletes reserved directories
when intent or marker ownership proves they belong to Lanspread.
Install-side operation intent is stored atomically under the configured peer
state directory, at `games/<game_id>/install_intent.json`. Game roots still use
Lanspread-owned `.local.installing/` and `.local.backup/` directories marked by
`.lanspread_owned`. Startup recovery combines the recorded intent with the
observed filesystem state and only deletes reserved directories when intent or
marker ownership proves they belong to Lanspread.
Downloaded-file removal is deliberately separate from uninstall: it only accepts
catalog IDs that are direct children of the configured game directory, refuses
installed or in-flight roots, and deletes the whole game root only after finding
a regular root-level `version.ini` sentinel.
Legacy launcher-owned files in game directories are migrated by a dedicated
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
configured state directory for launcher-owned metadata.
## Integration with `lanspread-tauri-deno-ts`
The Tauri application embeds this crate in
+3
View File
@@ -32,6 +32,7 @@ pub enum OperationKind {
#[derive(Clone)]
pub struct Ctx {
pub game_dir: Arc<RwLock<PathBuf>>,
pub state_dir: Arc<PathBuf>,
pub local_game_db: Arc<RwLock<Option<GameDB>>>,
pub local_library: Arc<RwLock<LocalLibraryState>>,
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
@@ -79,6 +80,7 @@ impl Ctx {
peer_game_db: Arc<RwLock<PeerGameDB>>,
peer_id: String,
game_dir: PathBuf,
state_dir: PathBuf,
unpacker: Arc<dyn Unpacker>,
shutdown: CancellationToken,
task_tracker: TaskTracker,
@@ -86,6 +88,7 @@ impl Ctx {
) -> Self {
Self {
game_dir: Arc::new(RwLock::new(game_dir)),
state_dir: Arc::new(state_dir),
local_game_db: Arc::new(RwLock::new(None)),
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
peer_game_db,
@@ -43,25 +43,14 @@ pub async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
let (version_desc, transfer_descs) =
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
let (version_desc, transfer_descs) = extract_version_descriptor(game_id, game_file_descs)?;
let version_buffer = match VersionIniBuffer::new(&version_desc) {
Ok(buffer) => Arc::new(buffer),
Err(err) => {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
Err(err) => return Err(err),
};
let game_root = games_folder.join(game_id);
if let Err(err) = begin_version_ini_transaction(&game_root).await {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
begin_version_ini_transaction(&game_root).await?;
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
@@ -73,9 +62,6 @@ pub async fn download_game_files(
discard_cancelled_download_best_effort(&games_folder, game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
if cancel_token.is_cancelled() {
@@ -111,10 +97,6 @@ pub async fn download_game_files(
rollback_version_ini_transaction(&game_root).await;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
} else {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
}
return Err(err);
}
@@ -127,15 +109,9 @@ pub async fn download_game_files(
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
rollback_version_ini_transaction(&game_root).await;
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
log::info!("all files downloaded for game: {game_id}");
tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished {
id: game_id.to_string(),
})?;
Ok(())
}
+4 -12
View File
@@ -1,9 +1,8 @@
use std::{collections::HashMap, net::SocketAddr};
use lanspread_db::db::GameFileDescription;
use tokio::sync::mpsc::UnboundedSender;
use crate::{PeerEvent, config::CHUNK_SIZE};
use crate::config::CHUNK_SIZE;
/// Represents a chunk of a file to be downloaded.
#[derive(Debug, Clone)]
@@ -34,7 +33,6 @@ pub(super) struct ChunkDownloadResult {
pub(super) fn extract_version_descriptor(
game_id: &str,
game_file_descs: Vec<GameFileDescription>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<(GameFileDescription, Vec<GameFileDescription>)> {
let mut version_descs = Vec::new();
let mut transfer_descs = Vec::new();
@@ -47,9 +45,6 @@ pub(super) fn extract_version_descriptor(
}
if version_descs.len() != 1 {
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
});
eyre::bail!(
"expected exactly one root-level version.ini sentinel for {game_id}, found {}",
version_descs.len()
@@ -296,7 +291,6 @@ mod tests {
#[test]
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let nested_decoy = vec![
GameFileDescription {
game_id: "game".to_string(),
@@ -313,26 +307,24 @@ mod tests {
];
let (version, transfer) =
extract_version_descriptor("game", nested_decoy, &tx).expect("only one root sentinel");
extract_version_descriptor("game", nested_decoy).expect("only one root sentinel");
assert_eq!(version.relative_path, "game/version.ini");
assert_eq!(transfer.len(), 2);
}
#[test]
fn version_descriptor_extraction_requires_a_root_version_ini() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let missing = vec![GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/archive.eti".to_string(),
is_dir: false,
size: 1,
}];
assert!(extract_version_descriptor("game", missing, &tx).is_err());
assert!(extract_version_descriptor("game", missing).is_err());
}
#[test]
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let multiple = vec![
GameFileDescription {
game_id: "game".to_string(),
@@ -347,6 +339,6 @@ mod tests {
size: 8,
},
];
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
assert!(extract_version_descriptor("game", multiple).is_err());
}
}
@@ -5,8 +5,6 @@ use tokio::fs::OpenOptions;
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
const INTENT_LOG_FILE: &str = ".lanspread.json";
const SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
const SYNC_DIR: &str = ".sync";
/// Prepares storage for game files by creating directories and pre-allocating files.
@@ -99,11 +97,7 @@ pub(super) async fn discard_cancelled_download(
}
fn should_preserve_on_download_discard(name: &str) -> bool {
is_local_dir_name(name)
|| name.starts_with(".local.")
|| name == INTENT_LOG_FILE
|| name == SOFTLAN_INSTALL_MARKER
|| name == SYNC_DIR
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
}
async fn remove_entry(path: &Path) -> eyre::Result<()> {
@@ -207,7 +201,6 @@ mod tests {
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("archive.eti"), b"partial");
write_file(&root.join("local").join("save.dat"), b"user-data");
write_file(&root.join(".lanspread.json"), b"{\"intent\":\"None\"}");
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
discard_cancelled_download(temp.path(), "game")
@@ -221,7 +214,6 @@ mod tests {
.expect("local install should remain"),
b"user-data"
);
assert!(root.join(".lanspread.json").is_file());
assert!(root.join(".local.backup").is_dir());
}
}
+122 -28
View File
@@ -199,6 +199,7 @@ pub async fn handle_download_game_files_command(
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
account_name: Option<String>,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
if !catalog_contains(ctx, &id).await {
@@ -276,7 +277,7 @@ pub async fn handle_download_game_files_command(
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
}
if install_after_download {
spawn_install_operation(ctx, tx_notify_ui, id.clone());
spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name);
}
} else {
log::error!("No trusted peers available after majority validation for game {id}");
@@ -321,7 +322,7 @@ pub async fn handle_download_game_files_command(
peer_whitelist,
file_peer_map,
tx_notify_ui_clone.clone(),
cancel_token,
cancel_token.clone(),
)
.await;
@@ -341,6 +342,7 @@ pub async fn handle_download_game_files_command(
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
send_download_finished(&tx_notify_ui_clone, &download_id);
return;
};
@@ -354,15 +356,20 @@ pub async fn handle_download_game_files_command(
.await
{
clear_active_download(&ctx_clone, &download_id).await;
send_download_finished(&tx_notify_ui_clone, &download_id);
download_state_guard.disarm();
run_started_install_operation(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
prepared,
account_name,
)
.await;
} else {
clear_active_download(&ctx_clone, &download_id).await;
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
send_download_finished(&tx_notify_ui_clone, &download_id);
}
} else {
if let Err(err) = refresh_local_game_for_ending_operation(
@@ -375,8 +382,9 @@ pub async fn handle_download_game_files_command(
log::error!("Failed to refresh local library after download: {err}");
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
}
download_state_guard.disarm();
send_download_finished(&tx_notify_ui_clone, &download_id);
}
}
Err(e) => {
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
@@ -392,8 +400,18 @@ pub async fn handle_download_game_files_command(
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
let download_was_cancelled = cancel_token.is_cancelled();
if download_was_cancelled {
log::info!("Download cancelled for {download_id}: {e}");
} else {
log::error!("Download failed for {download_id}: {e}");
}
send_download_failed_unless_cancelled(
&tx_notify_ui_clone,
&download_id,
download_was_cancelled,
);
}
}
});
}
@@ -403,8 +421,9 @@ pub async fn handle_install_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: Option<String>,
) {
spawn_install_operation(ctx, tx_notify_ui, id);
spawn_install_operation(ctx, tx_notify_ui, id, account_name);
}
/// Handles the `UninstallGame` command.
@@ -447,15 +466,25 @@ pub async fn handle_cancel_download_command(
cancel_token.cancel();
}
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
fn spawn_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: Option<String>,
) {
let ctx = ctx.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.clone().spawn(async move {
run_install_operation(&ctx, &tx_notify_ui, id).await;
run_install_operation(&ctx, &tx_notify_ui, id, account_name).await;
});
}
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
async fn run_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: Option<String>,
) {
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
return;
};
@@ -465,7 +494,7 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
return;
}
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name).await;
}
struct PreparedInstallOperation {
@@ -517,6 +546,7 @@ async fn run_started_install_operation(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
prepared: PreparedInstallOperation,
account_name: Option<String>,
) {
let PreparedInstallOperation {
game_root,
@@ -538,12 +568,27 @@ async fn run_started_install_operation(
},
);
let state_dir = ctx.state_dir.as_ref();
match operation {
InstallOperation::Installing => {
install::install(&game_root, &id, ctx.unpacker.clone()).await
install::install(
&game_root,
state_dir,
&id,
ctx.unpacker.clone(),
account_name.as_deref(),
)
.await
}
InstallOperation::Updating => {
install::update(&game_root, &id, ctx.unpacker.clone()).await
install::update(
&game_root,
state_dir,
&id,
ctx.unpacker.clone(),
account_name.as_deref(),
)
.await
}
}
};
@@ -601,7 +646,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
PeerEvent::UninstallGameBegin { id: id.clone() },
);
install::uninstall(&game_root, &id).await
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
};
match result {
@@ -767,6 +812,31 @@ async fn clear_active_download(ctx: &Ctx, id: &str) {
ctx.active_downloads.write().await.remove(id);
}
fn send_download_finished(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) {
log::error!("Failed to send DownloadGameFilesFinished event: {err}");
}
}
fn send_download_failed(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) {
log::error!("Failed to send DownloadGameFilesFailed event: {err}");
}
}
fn send_download_failed_unless_cancelled(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
cancelled: bool,
) -> bool {
if cancelled {
return false;
}
send_download_failed(tx_notify_ui, id);
true
}
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
end_operation(ctx, tx_notify_ui, id).await;
clear_active_download(ctx, id).await;
@@ -845,7 +915,7 @@ async fn load_local_library_with_policy(
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
let active_ids = active_operation_ids(ctx).await;
install::recover_on_startup(&game_dir, &active_ids).await?;
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
}
@@ -870,7 +940,7 @@ async fn scan_and_announce_local_library(
event_policy: LocalLibraryEventPolicy,
) -> eyre::Result<()> {
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(game_dir, &catalog).await?;
let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?;
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
Ok(())
}
@@ -884,7 +954,7 @@ async fn refresh_local_game_for_ending_operation(
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
let catalog = ctx.catalog.read().await.clone();
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?;
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
@@ -1068,7 +1138,8 @@ mod tests {
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
game_dir.clone(),
game_dir.join(".test-state"),
Arc::new(FakeUnpacker),
CancellationToken::new(),
TaskTracker::new(),
@@ -1076,6 +1147,29 @@ mod tests {
)
}
#[test]
fn cancelled_download_error_does_not_emit_failed_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
let emitted = send_download_failed_unless_cancelled(&tx, "game", true);
assert!(!emitted);
assert!(rx.try_recv().is_err());
}
#[test]
fn uncancelled_download_error_emits_failed_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
let emitted = send_download_failed_unless_cancelled(&tx, "game", false);
assert!(emitted);
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game"
));
}
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
@@ -1332,7 +1426,7 @@ mod tests {
.insert("game".to_string(), OperationKind::Installing);
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("scan should succeed");
@@ -1378,13 +1472,13 @@ mod tests {
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("first scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, false, true);
let scan = scan_local_library(temp.path(), &catalog)
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("second scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
@@ -1403,13 +1497,13 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, true, true);
run_install_operation(&ctx, &tx, "game".to_string()).await;
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update(
recv_event(&mut rx).await,
@@ -1440,7 +1534,7 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
run_install_operation(&ctx, &tx, "game".to_string()).await;
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update(
recv_event(&mut rx).await,
@@ -1494,7 +1588,7 @@ mod tests {
.await
);
clear_active_download(&ctx, "game").await;
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None).await;
}
});
@@ -1561,7 +1655,7 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
run_install_operation(&ctx, &tx, "game".to_string()).await;
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update(
recv_event(&mut rx).await,
@@ -1593,7 +1687,7 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
run_install_operation(&ctx, &tx, "game".to_string()).await;
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
@@ -1616,7 +1710,7 @@ mod tests {
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"new archive");
run_install_operation(&ctx, &tx, "game".to_string()).await;
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
@@ -1695,7 +1789,7 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
@@ -1781,7 +1875,7 @@ mod tests {
let ctx = test_ctx(current.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(current.path(), &catalog)
let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
+3 -19
View File
@@ -1,13 +1,13 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use uuid::Uuid;
const PEER_ID_FILE: &str = "peer_id";
use crate::state_paths::peer_id_path;
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
pub fn load_or_create_peer_id(state_dir: Option<&Path>) -> eyre::Result<String> {
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
let path = peer_id_path(state_dir);
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim();
@@ -30,19 +30,3 @@ pub fn default_features() -> Vec<String> {
FEATURE_LIBRARY_SNAPSHOT.to_string(),
]
}
fn peer_id_path(state_dir: Option<&Path>) -> PathBuf {
if let Some(dir) = state_dir {
return dir.join(PEER_ID_FILE);
}
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
return PathBuf::from(dir).join(PEER_ID_FILE);
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(".lanspread").join(PEER_ID_FILE);
}
std::env::temp_dir().join("lanspread").join(PEER_ID_FILE)
}
+49 -32
View File
@@ -7,8 +7,10 @@ use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
const INTENT_SCHEMA_VERSION: u32 = 1;
const INTENT_FILE: &str = ".lanspread.json";
const INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
pub(crate) const LEGACY_INTENT_FILE: &str = ".lanspread.json";
pub(crate) const LEGACY_INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
const INTENT_FILE: &str = "install_intent.json";
const INTENT_TMP_FILE: &str = "install_intent.json.tmp";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InstallIntentState {
@@ -41,18 +43,22 @@ impl InstallIntent {
pub fn none(id: &str, eti_version: Option<String>) -> Self {
Self::new(id, InstallIntentState::None, eti_version)
}
pub fn is_current_for(&self, id: &str) -> bool {
self.schema_version == INTENT_SCHEMA_VERSION && self.id == id
}
}
pub fn intent_path(game_root: &Path) -> PathBuf {
game_root.join(INTENT_FILE)
pub fn intent_path(state_dir: &Path, id: &str) -> PathBuf {
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_FILE)
}
pub fn intent_tmp_path(game_root: &Path) -> PathBuf {
game_root.join(INTENT_TMP_FILE)
pub fn intent_tmp_path(state_dir: &Path, id: &str) -> PathBuf {
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_TMP_FILE)
}
pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
let path = intent_path(game_root);
pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
let path = intent_path(state_dir, id);
let data = match tokio::fs::read_to_string(&path).await {
Ok(data) => data,
Err(err) => {
@@ -64,7 +70,7 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
};
match serde_json::from_str::<InstallIntent>(&data) {
Ok(intent) if intent.schema_version == INTENT_SCHEMA_VERSION && intent.id == id => intent,
Ok(intent) if intent.is_current_for(id) => intent,
Ok(intent) => {
log::warn!(
"Ignoring install intent {} with schema {} for id {}",
@@ -81,10 +87,11 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
}
}
pub async fn write_intent(game_root: &Path, intent: &InstallIntent) -> eyre::Result<()> {
tokio::fs::create_dir_all(game_root).await?;
let path = intent_path(game_root);
let tmp_path = intent_tmp_path(game_root);
pub async fn write_intent(state_dir: &Path, id: &str, intent: &InstallIntent) -> eyre::Result<()> {
let game_state_dir = crate::state_paths::game_state_dir(state_dir, id);
tokio::fs::create_dir_all(&game_state_dir).await?;
let path = intent_path(state_dir, id);
let tmp_path = intent_tmp_path(state_dir, id);
let data = serde_json::to_vec_pretty(intent)?;
let mut file = tokio::fs::File::create(&tmp_path).await?;
@@ -122,6 +129,18 @@ mod tests {
use super::*;
use crate::test_support::TempDir;
async fn write_raw_intent(state_dir: &Path, id: &str, bytes: impl AsRef<[u8]>) {
let path = intent_path(state_dir, id);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.expect("intent parent should be created");
}
tokio::fs::write(path, bytes)
.await
.expect("intent should be written");
}
#[tokio::test]
async fn tmp_write_without_rename_leaves_previous_intent_intact() {
let temp = TempDir::new("lanspread-intent");
@@ -130,12 +149,12 @@ mod tests {
InstallIntentState::Updating,
Some("20240101".to_string()),
);
write_intent(temp.path(), &previous)
write_intent(temp.path(), "game", &previous)
.await
.expect("previous intent should be written");
tokio::fs::write(
intent_tmp_path(temp.path()),
intent_tmp_path(temp.path(), "game"),
serde_json::to_vec(&InstallIntent::new(
"game",
InstallIntentState::Installing,
@@ -154,12 +173,12 @@ mod tests {
#[tokio::test]
async fn schema_mismatch_is_treated_as_missing() {
let temp = TempDir::new("lanspread-intent");
tokio::fs::write(
intent_path(temp.path()),
write_raw_intent(
temp.path(),
"game",
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
)
.await
.expect("intent should be written");
.await;
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
@@ -168,12 +187,12 @@ mod tests {
#[tokio::test]
async fn mismatched_id_is_treated_as_missing() {
let temp = TempDir::new("lanspread-intent");
tokio::fs::write(
intent_path(temp.path()),
write_raw_intent(
temp.path(),
"game",
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
)
.await
.expect("intent should be written");
.await;
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
@@ -182,9 +201,7 @@ mod tests {
#[tokio::test]
async fn corrupt_intent_is_treated_as_missing() {
let temp = TempDir::new("lanspread-intent");
tokio::fs::write(intent_path(temp.path()), b"not json")
.await
.expect("intent should be written");
write_raw_intent(temp.path(), "game", b"not json").await;
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
@@ -193,21 +210,21 @@ mod tests {
#[tokio::test]
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
let temp = TempDir::new("lanspread-intent");
tokio::fs::write(
intent_path(temp.path()),
write_raw_intent(
temp.path(),
"game",
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
)
.await
.expect("intent should be written");
.await;
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::Updating);
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
write_intent(temp.path(), &InstallIntent::none("game", None))
write_intent(temp.path(), "game", &InstallIntent::none("game", None))
.await
.expect("intent should be written");
let written = tokio::fs::read_to_string(intent_path(temp.path()))
let written = tokio::fs::read_to_string(intent_path(temp.path(), "game"))
.await
.expect("intent should be readable");
assert!(
+1 -1
View File
@@ -1,4 +1,4 @@
mod intent;
pub(crate) mod intent;
mod remove;
mod transaction;
pub mod unpack;
+229 -43
View File
@@ -1,5 +1,6 @@
use std::{
collections::HashSet,
ffi::OsStr,
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
@@ -19,6 +20,7 @@ const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState {
@@ -33,18 +35,25 @@ struct InstallFsState {
backup: FsEntryState,
}
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
pub async fn install(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await?;
let result = install_inner(game_root, id, unpacker).await;
let result = install_inner(game_root, id, unpacker, account_name).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
@@ -54,24 +63,31 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
installing_dir(game_root).display()
);
}
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
pub async fn update(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
)
.await?;
let result = update_inner(game_root, id, unpacker).await;
let result = update_inner(game_root, id, unpacker, account_name).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
log::warn!(
"Failed to clean install backup {}: {err}",
@@ -82,7 +98,7 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
}
Err(err) => {
let rollback = rollback_update(game_root).await;
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
@@ -91,10 +107,11 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
}
}
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
game_root,
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
)
.await?;
@@ -102,7 +119,7 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
let result = uninstall_inner(game_root).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
@@ -110,13 +127,17 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
pub async fn recover_on_startup(
game_dir: &Path,
state_dir: &Path,
active_ids: &HashSet<String>,
) -> eyre::Result<()> {
recover_download_transients(game_dir).await?;
let mut entries = match tokio::fs::read_dir(game_dir).await {
@@ -141,22 +162,28 @@ pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -
continue;
}
recover_game_root(&entry.path(), &id).await?;
recover_game_root(&entry.path(), state_dir, &id).await?;
}
Ok(())
}
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
recover_download_transients(game_root).await?;
let intent = read_intent(game_root, id).await;
let intent = read_intent(state_dir, id).await;
let fs = inspect_install_fs(game_root).await;
match intent.state {
InstallIntentState::None => recover_none_intent(game_root).await?,
InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
InstallIntentState::Installing => {
recover_installing(game_root, state_dir, id, intent, fs).await?;
}
InstallIntentState::Updating => {
recover_updating(game_root, state_dir, id, intent, fs).await?;
}
InstallIntentState::Uninstalling => {
recover_uninstalling(game_root, state_dir, id, intent, fs).await?;
}
}
Ok(())
}
@@ -165,6 +192,7 @@ async fn install_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
if path_is_dir(&local).await {
@@ -174,13 +202,19 @@ async fn install_inner(
let staging = installing_dir(game_root);
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
write_account_name_if_present(&staging, account_name).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
Ok(())
}
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
async fn update_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
let staging = installing_dir(game_root);
@@ -196,6 +230,7 @@ async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
write_account_name_if_present(&staging, account_name).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
@@ -249,6 +284,48 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
Ok(archives)
}
async fn write_account_name_if_present(
install_root: &Path,
account_name: Option<&str>,
) -> eyre::Result<()> {
let Some(account_name) = account_name else {
return Ok(());
};
let Some(path) = find_account_name_file(install_root).await? else {
return Ok(());
};
tokio::fs::write(&path, account_name)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(())
}
async fn find_account_name_file(root: &Path) -> eyre::Result<Option<PathBuf>> {
let mut pending_dirs = vec![root.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = tokio::fs::read_dir(&dir).await?;
let mut child_dirs = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let path = entry.path();
if entry.file_name() == OsStr::new(ACCOUNT_NAME_FILE) && file_type.is_file() {
return Ok(Some(path));
}
if file_type.is_dir() {
child_dirs.push(path);
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(None)
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?;
@@ -257,6 +334,7 @@ async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
async fn recover_installing(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
@@ -268,11 +346,12 @@ async fn recover_installing(
{
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_updating(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
@@ -301,11 +380,12 @@ async fn recover_updating(
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
_ => {}
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_uninstalling(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
@@ -323,7 +403,7 @@ async fn recover_uninstalling(
} => uninstall_inner(game_root).await?,
_ => {}
}
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
@@ -416,6 +496,10 @@ async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
}
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
if !path_exists(path).await {
return Ok(());
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
@@ -437,6 +521,10 @@ async fn path_is_dir(path: &Path) -> bool {
.is_ok_and(|metadata| metadata.is_dir())
}
async fn path_exists(path: &Path) -> bool {
tokio::fs::metadata(path).await.is_ok()
}
fn local_dir(game_root: &Path) -> PathBuf {
game_root.join(LOCAL_DIR)
}
@@ -530,33 +618,61 @@ mod tests {
Arc::new(FakeUnpacker::default())
}
fn test_state() -> TempDir {
TempDir::new("lanspread-install-state")
}
#[tokio::test]
async fn install_success_promotes_staging_and_clears_intent() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(&root, "game", successful_unpacker())
install(&root, state.path(), "game", successful_unpacker(), None)
.await
.expect("install should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join(".local.installing").exists());
let intent = read_intent(&root, "game").await;
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn install_account_name_missing_file_is_noop() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(
&root,
state.path(),
"game",
successful_unpacker(),
Some("Alice"),
)
.await
.expect("install should succeed without account file");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
}
#[tokio::test]
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("b.eti"), b"archive");
write_file(&root.join("a.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
let unpacker = Arc::new(FakeUnpacker::default());
install(&root, "game", unpacker.clone())
install(&root, state.path(), "game", unpacker.clone(), None)
.await
.expect("install should succeed");
@@ -570,15 +686,66 @@ mod tests {
assert_eq!(archives, vec!["a.eti", "b.eti"]);
}
#[tokio::test]
async fn install_overwrites_first_account_name_file() {
struct AccountNameUnpacker;
impl Unpacker for AccountNameUnpacker {
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
tokio::fs::create_dir_all(dest.join("a")).await?;
tokio::fs::create_dir_all(dest.join("z")).await?;
tokio::fs::write(dest.join("a").join(ACCOUNT_NAME_FILE), b"old-a").await?;
tokio::fs::write(dest.join("z").join(ACCOUNT_NAME_FILE), b"old-z").await?;
Ok(())
})
}
}
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(
&root,
state.path(),
"game",
Arc::new(AccountNameUnpacker),
Some("Alice"),
)
.await
.expect("install should succeed");
assert_eq!(
std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE))
.expect("first account file should be readable"),
"Alice"
);
assert_eq!(
std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE))
.expect("second account file should be readable"),
"old-z"
);
}
#[tokio::test]
async fn update_failure_restores_previous_local() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
let err = update(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::failing()),
None,
)
.await
.expect_err("update should fail");
@@ -586,19 +753,26 @@ mod tests {
assert!(root.join("local").join("old.txt").is_file());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(&root, "game").await;
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn update_commit_rename_failure_restores_previous_local() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
let err = update(&root, "game", Arc::new(FakeUnpacker::commit_conflict()))
let err = update(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::commit_conflict()),
None,
)
.await
.expect_err("update should fail at commit rename");
@@ -614,19 +788,20 @@ mod tests {
assert!(!root.join("local").join("conflict.txt").exists());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(&root, "game").await;
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn update_success_promotes_new_local_and_removes_backup() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
update(&root, "game", successful_unpacker())
update(&root, state.path(), "game", successful_unpacker(), None)
.await
.expect("update should succeed");
@@ -634,19 +809,20 @@ mod tests {
assert!(!root.join("local").join("old.txt").exists());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(&root, "game").await;
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn uninstall_removes_only_local_install() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("payload.txt"), b"installed");
uninstall(&root, "game")
uninstall(&root, state.path(), "game")
.await
.expect("uninstall should succeed");
@@ -661,6 +837,7 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
let locked_dir = root.join("local").join("locked");
write_file(&root.join("version.ini"), b"20250101");
@@ -669,7 +846,7 @@ mod tests {
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
.expect("locked dir permissions should be set");
let _err = uninstall(&root, "game")
let _err = uninstall(&root, state.path(), "game")
.await
.expect_err("uninstall should fail while deleting backup");
@@ -697,7 +874,7 @@ mod tests {
b"locked"
);
assert!(!root.join(".local.backup").exists());
let intent = read_intent(&root, "game").await;
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
@@ -844,23 +1021,25 @@ mod tests {
},
];
let state = test_state();
for case in cases {
let temp = TempDir::new("lanspread-install");
let root = temp.game_root();
seed_recovery_case(&root, &case);
write_intent(
&root,
state.path(),
"game",
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
)
.await
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
recover_game_root(&root, "game")
recover_game_root(&root, state.path(), "game")
.await
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
assert_recovered_case(&root, &case);
let intent = read_intent(&root, "game").await;
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
assert_eq!(
intent.eti_version.as_deref(),
@@ -874,10 +1053,11 @@ mod tests {
#[tokio::test]
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join(".local.backup").join("user.txt"), b"user");
recover_game_root(&root, "game")
recover_game_root(&root, state.path(), "game")
.await
.expect("recovery should succeed");
@@ -887,11 +1067,12 @@ mod tests {
#[tokio::test]
async fn download_recovery_sweeps_reserved_version_files() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
recover_game_root(&root, "game")
recover_game_root(&root, state.path(), "game")
.await
.expect("recovery should succeed");
@@ -902,12 +1083,17 @@ mod tests {
#[tokio::test]
async fn startup_recovery_skips_active_game_roots() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let active_root = temp.path().join("active");
let inactive_root = temp.path().join("inactive");
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
recover_on_startup(temp.path(), &HashSet::from(["active".to_string()]))
recover_on_startup(
temp.path(),
state.path(),
&HashSet::from(["active".to_string()]),
)
.await
.expect("recovery should succeed");
+29 -6
View File
@@ -22,6 +22,7 @@ mod identity;
mod install;
mod library;
mod local_games;
mod migration;
mod network;
mod path_validation;
mod peer;
@@ -29,6 +30,7 @@ mod peer_db;
mod remote_peer;
mod services;
mod startup;
mod state_paths;
#[cfg(test)]
mod test_support;
@@ -42,6 +44,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameFileDescription};
pub use migration::{MigrationReport, migrate_legacy_state};
pub use peer_db::{
MajorityValidationResult,
PeerGameDB,
@@ -56,7 +59,6 @@ use tokio::sync::{
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub use crate::startup::PeerRuntimeHandle;
use crate::{
context::Ctx,
handlers::{
@@ -73,7 +75,9 @@ use crate::{
handle_uninstall_game_command,
load_local_library,
},
state_paths::resolve_state_dir,
};
pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path};
// =============================================================================
// Public API types
@@ -225,15 +229,20 @@ pub enum PeerCommand {
DownloadGameFiles {
id: String,
file_descriptions: Vec<GameFileDescription>,
account_name: Option<String>,
},
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
account_name: Option<String>,
},
/// Install already-downloaded archives into `local/`.
InstallGame { id: String },
InstallGame {
id: String,
account_name: Option<String>,
},
/// Remove only the `local/` install for a game.
UninstallGame { id: String },
/// Remove downloaded archive files for an uninstalled game.
@@ -300,12 +309,13 @@ pub fn start_peer_with_options(
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions { state_dir } = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let game_dir = game_dir.into();
log::info!(
"Starting peer system with game directory: {}",
game_dir.display()
);
let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?;
let peer_id = identity::load_or_create_peer_id(&state_dir)?;
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
@@ -316,6 +326,7 @@ pub fn start_peer_with_options(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
catalog,
))
@@ -329,6 +340,7 @@ async fn run_peer(
peer_game_db: Arc<RwLock<PeerGameDB>>,
peer_id: String,
game_dir: PathBuf,
state_dir: PathBuf,
unpacker: Arc<dyn Unpacker>,
shutdown: CancellationToken,
task_tracker: TaskTracker,
@@ -338,6 +350,7 @@ async fn run_peer(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
shutdown,
task_tracker,
@@ -397,14 +410,23 @@ async fn handle_peer_commands(
PeerCommand::DownloadGameFiles {
id,
file_descriptions,
account_name,
} => {
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
true,
account_name,
)
.await;
}
PeerCommand::DownloadGameFilesWithOptions {
id,
file_descriptions,
install_after_download,
account_name,
} => {
handle_download_game_files_command(
ctx,
@@ -412,11 +434,12 @@ async fn handle_peer_commands(
id,
file_descriptions,
install_after_download,
account_name,
)
.await;
}
PeerCommand::InstallGame { id } => {
handle_install_game_command(ctx, tx_notify_ui, id).await;
PeerCommand::InstallGame { id, account_name } => {
handle_install_game_command(ctx, tx_notify_ui, id, account_name).await;
}
PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
+27 -14
View File
@@ -71,7 +71,7 @@ pub async fn local_download_available(
// Local library index and scanning
// =============================================================================
const LIBRARY_INDEX_DIR: &str = ".lanspread";
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
const LIBRARY_INDEX_FILE: &str = "library_index.json";
const INTENT_LOG_FILE: &str = ".lanspread.json";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
@@ -114,8 +114,14 @@ pub struct LocalLibraryScan {
pub revision: u64,
}
fn library_index_path(game_dir: &Path) -> PathBuf {
game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE)
pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf {
game_dir
.join(LEGACY_LIBRARY_INDEX_DIR)
.join(LIBRARY_INDEX_FILE)
}
fn library_index_path(state_dir: &Path) -> PathBuf {
crate::state_paths::local_library_index_path(state_dir)
}
fn library_index_tmp_path(path: &Path) -> PathBuf {
@@ -278,7 +284,7 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
}
pub fn is_ignored_game_root_name(name: &str) -> bool {
name == LIBRARY_INDEX_DIR
name == LEGACY_LIBRARY_INDEX_DIR
}
fn is_reserved_transient_name(name: &str) -> bool {
@@ -286,7 +292,7 @@ fn is_reserved_transient_name(name: &str) -> bool {
|| name == VERSION_TMP_FILE
|| name == VERSION_DISCARDED_FILE
|| name == INTENT_LOG_FILE
|| name == LIBRARY_INDEX_DIR
|| name == LEGACY_LIBRARY_INDEX_DIR
}
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
@@ -550,9 +556,11 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
/// Scans the local game directory and returns summaries plus a game database.
pub async fn scan_local_library(
game_dir: impl AsRef<Path>,
state_dir: impl AsRef<Path>,
catalog: &HashSet<String>,
) -> eyre::Result<LocalLibraryScan> {
let game_path = game_dir.as_ref();
let state_path = state_dir.as_ref();
let metadata = match tokio::fs::metadata(game_path).await {
Ok(metadata) => metadata,
@@ -577,7 +585,7 @@ pub async fn scan_local_library(
}
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
let index_path = library_index_path(game_path);
let index_path = library_index_path(state_path);
let mut index = load_library_index(&index_path).await;
let mut seen_ids = HashSet::new();
let mut summaries = HashMap::new();
@@ -636,12 +644,14 @@ pub async fn scan_local_library(
/// Rescans a single game root through the cached index and returns full library state.
pub async fn rescan_local_game(
game_dir: impl AsRef<Path>,
state_dir: impl AsRef<Path>,
catalog: &HashSet<String>,
game_id: &str,
) -> eyre::Result<LocalLibraryScan> {
let game_path = game_dir.as_ref();
let state_path = state_dir.as_ref();
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
let index_path = library_index_path(game_path);
let index_path = library_index_path(state_path);
let mut index = load_library_index(&index_path).await;
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
@@ -765,6 +775,7 @@ mod tests {
#[tokio::test]
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
let temp = TempDir::new("lanspread-local-games");
let state = TempDir::new("lanspread-local-games-state");
let catalog = HashSet::from([
"ready".to_string(),
"local-only".to_string(),
@@ -783,7 +794,7 @@ mod tests {
b"20250101",
);
let scan = scan_local_library(temp.path(), &catalog)
let scan = scan_local_library(temp.path(), state.path(), &catalog)
.await
.expect("scan should succeed");
@@ -818,11 +829,12 @@ mod tests {
#[tokio::test]
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
let temp = TempDir::new("lanspread-local-games");
let state = TempDir::new("lanspread-local-games-state");
let catalog = HashSet::from(["game".to_string()]);
std::fs::create_dir_all(temp.path().join("game").join("local"))
.expect("local install dir should be created");
let first_scan = scan_local_library(temp.path(), &catalog)
let first_scan = scan_local_library(temp.path(), state.path(), &catalog)
.await
.expect("initial scan should succeed");
let local_only = first_scan
@@ -835,7 +847,7 @@ mod tests {
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
let rescan = rescan_local_game(temp.path(), &catalog, "game")
let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game")
.await
.expect("rescan should succeed");
let ready = rescan
@@ -851,11 +863,12 @@ mod tests {
#[tokio::test]
async fn concurrent_rescans_preserve_both_index_updates() {
let temp = TempDir::new("lanspread-local-games-concurrent");
let state = TempDir::new("lanspread-local-games-state");
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
let initial = scan_local_library(temp.path(), &catalog)
let initial = scan_local_library(temp.path(), state.path(), &catalog)
.await
.expect("initial scan should succeed");
assert_eq!(initial.revision, 1);
@@ -864,13 +877,13 @@ mod tests {
write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b");
let (scan_a, scan_b) = tokio::join!(
rescan_local_game(temp.path(), &catalog, "game-a"),
rescan_local_game(temp.path(), &catalog, "game-b")
rescan_local_game(temp.path(), state.path(), &catalog, "game-a"),
rescan_local_game(temp.path(), state.path(), &catalog, "game-b")
);
scan_a.expect("game-a rescan should succeed");
scan_b.expect("game-b rescan should succeed");
let index = load_library_index(&library_index_path(temp.path())).await;
let index = load_library_index(&library_index_path(state.path())).await;
assert_eq!(index.revision, 3);
let game_a = index
.games
+612
View File
@@ -0,0 +1,612 @@
use std::{
io::ErrorKind,
path::{Path, PathBuf},
time::Instant,
};
use futures::{StreamExt as _, stream};
use tokio::io::AsyncWriteExt as _;
use crate::{
install::intent::{
InstallIntent,
LEGACY_INTENT_FILE,
LEGACY_INTENT_TMP_FILE,
intent_path,
write_intent,
},
local_games::{is_ignored_game_root_name, legacy_library_index_path},
state_paths::{local_library_index_path, setup_done_path},
};
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
const MIGRATION_CONCURRENCY: usize = 16;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
pub struct MigrationReport {
pub games_checked: usize,
pub library_index_migrated: bool,
pub install_intents_migrated: usize,
pub setup_markers_migrated: usize,
pub legacy_files_deleted: usize,
pub unknown_softlan_files: usize,
pub failures: usize,
}
impl MigrationReport {
fn merge(&mut self, other: Self) {
self.games_checked += other.games_checked;
self.library_index_migrated |= other.library_index_migrated;
self.install_intents_migrated += other.install_intents_migrated;
self.setup_markers_migrated += other.setup_markers_migrated;
self.legacy_files_deleted += other.legacy_files_deleted;
self.unknown_softlan_files += other.unknown_softlan_files;
self.failures += other.failures;
}
}
/// Migrates legacy app-owned files out of the configured game directory.
///
/// This is intentionally separate from normal operation: callers should run it
/// before starting the peer runtime for a game directory.
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
let started = Instant::now();
let mut report = MigrationReport::default();
report.merge(migrate_library_index(game_dir, state_dir).await);
let game_roots = match collect_game_roots(game_dir).await {
Ok(game_roots) => game_roots,
Err(err) => {
if err.kind() != ErrorKind::NotFound {
log::warn!(
"Failed to enumerate game roots for legacy state migration in {}: {err}",
game_dir.display()
);
report.failures += 1;
}
log_migration_report(&report, started);
return report;
}
};
let game_reports = stream::iter(game_roots)
.map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await })
.buffer_unordered(MIGRATION_CONCURRENCY)
.collect::<Vec<_>>()
.await;
for game_report in game_reports {
report.merge(game_report);
}
log_migration_report(&report, started);
report
}
async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
let mut roots = Vec::new();
let mut entries = tokio::fs::read_dir(game_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
continue;
}
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if is_ignored_game_root_name(&id) {
continue;
}
roots.push((id, entry.path()));
}
Ok(roots)
}
async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = legacy_library_index_path(game_dir);
let target_path = local_library_index_path(state_dir);
match migrate_raw_file(&legacy_path, &target_path).await {
Ok(MigrationOutcome::Migrated) => {
report.library_index_migrated = true;
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::TargetAlreadyExists) => {
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::SourceMissing) => {}
Err(err) => {
log::warn!(
"Failed to migrate legacy library index {} to {}: {err}",
legacy_path.display(),
target_path.display()
);
report.failures += 1;
}
}
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await);
report.merge(remove_empty_legacy_library_dir(game_dir).await);
report
}
async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport {
let mut report = MigrationReport {
games_checked: 1,
..MigrationReport::default()
};
report.merge(migrate_install_intent(state_dir, &id, &root).await);
report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await);
report.merge(migrate_setup_marker(state_dir, &id, &root).await);
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await);
report.merge(note_unknown_softlan_files(&root).await);
report
}
async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join(LEGACY_INTENT_FILE);
let target_path = intent_path(state_dir, id);
match path_exists(&legacy_path).await {
Ok(false) => return report,
Ok(true) => {}
Err(err) => {
log::warn!(
"Failed to inspect legacy install intent {}: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
}
match path_exists(&target_path).await {
Ok(true) => {
report.merge(delete_file(&legacy_path).await);
return report;
}
Ok(false) => {}
Err(err) => {
log::warn!(
"Failed to inspect app-state install intent {}: {err}",
target_path.display()
);
report.failures += 1;
return report;
}
}
let data = match tokio::fs::read_to_string(&legacy_path).await {
Ok(data) => data,
Err(err) => {
log::warn!(
"Failed to read legacy install intent {}: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
};
let intent = match serde_json::from_str::<InstallIntent>(&data) {
Ok(intent) if intent.is_current_for(id) => intent,
Ok(intent) => {
log::warn!(
"Leaving legacy install intent {} in place because it belongs to id {} schema {}",
legacy_path.display(),
intent.id,
intent.schema_version
);
report.failures += 1;
return report;
}
Err(err) => {
log::warn!(
"Leaving corrupt legacy install intent {} in place: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
};
if let Err(err) = write_intent(state_dir, id, &intent).await {
log::warn!(
"Failed to write migrated install intent {}: {err}",
target_path.display()
);
report.failures += 1;
return report;
}
report.install_intents_migrated += 1;
report.merge(delete_file(&legacy_path).await);
report
}
async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let target_path = setup_done_path(state_dir, id);
match migrate_empty_marker(&legacy_path, &target_path).await {
Ok(MigrationOutcome::Migrated) => {
report.setup_markers_migrated += 1;
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::TargetAlreadyExists) => {
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::SourceMissing) => {}
Err(err) => {
log::warn!(
"Failed to migrate legacy setup marker {} to {}: {err}",
legacy_path.display(),
target_path.display()
);
report.failures += 1;
}
}
report
}
async fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
report.unknown_softlan_files += count_unknown_softlan_files(root).await;
report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await;
report
}
async fn count_unknown_softlan_files(dir: &Path) -> usize {
let mut count = 0;
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
Err(err) => {
log::warn!(
"Failed to inspect {} for legacy .softlan files: {err}",
dir.display()
);
return 0;
}
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if !name.starts_with(".softlan_")
|| name == LEGACY_SOFTLAN_INSTALL_MARKER
|| name == LEGACY_FIRST_START_DONE_FILE
{
continue;
}
count += 1;
log::info!(
"Leaving unknown legacy .softlan file in place: {}",
entry.path().display()
);
}
count
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MigrationOutcome {
SourceMissing,
TargetAlreadyExists,
Migrated,
}
async fn migrate_raw_file(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path).await? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path).await? {
remove_file_if_exists(legacy_path).await?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
let data = tokio::fs::read(legacy_path).await?;
write_bytes_atomically(target_path, &data).await?;
remove_file_if_exists(legacy_path).await?;
Ok(MigrationOutcome::Migrated)
}
async fn migrate_empty_marker(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path).await? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path).await? {
remove_file_if_exists(legacy_path).await?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
if let Some(parent) = target_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::File::create(target_path)
.await?
.sync_all()
.await?;
remove_file_if_exists(legacy_path).await?;
Ok(MigrationOutcome::Migrated)
}
async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp_path = library_index_tmp_path(path);
let mut file = tokio::fs::File::create(&tmp_path).await?;
file.write_all(data).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&tmp_path, path).await?;
sync_parent_dir(path)
}
fn library_index_tmp_path(path: &Path) -> PathBuf {
let Some(file_name) = path.file_name() else {
return path.with_extension("tmp");
};
let mut tmp_name = file_name.to_os_string();
tmp_name.push(".tmp");
path.with_file_name(tmp_name)
}
async fn path_exists(path: &Path) -> std::io::Result<bool> {
match tokio::fs::metadata(path).await {
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
async fn delete_if_exists(path: &Path) -> MigrationReport {
match remove_file_if_exists(path).await {
Ok(true) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Ok(false) => MigrationReport::default(),
Err(err) => {
log::warn!("Failed to delete legacy file {}: {err}", path.display());
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
async fn delete_file(path: &Path) -> MigrationReport {
match remove_file_if_exists(path).await {
Ok(true) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Ok(false) => MigrationReport::default(),
Err(err) => {
log::warn!("Failed to delete legacy file {}: {err}", path.display());
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
async fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
if !path_exists(path).await? {
return Ok(false);
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport {
let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR);
let exists = match path_exists(&path).await {
Ok(exists) => exists,
Err(err) => {
log::warn!(
"Failed to inspect legacy library index directory {}: {err}",
path.display()
);
return MigrationReport {
failures: 1,
..MigrationReport::default()
};
}
};
if !exists {
return MigrationReport::default();
}
match tokio::fs::remove_dir(&path).await {
Ok(()) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Err(err)
if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty =>
{
MigrationReport::default()
}
Err(err) => {
log::warn!(
"Failed to remove empty legacy library index directory {}: {err}",
path.display()
);
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
fn log_migration_report(report: &MigrationReport, started: Instant) {
log::info!(
"Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \
install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \
unknown_softlan_files={}, failures={}",
started.elapsed(),
report.games_checked,
report.library_index_migrated,
report.install_intents_migrated,
report.setup_markers_migrated,
report.legacy_files_deleted,
report.unknown_softlan_files,
report.failures
);
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::File::open(parent)?.sync_all()?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
install::intent::{InstallIntentState, read_intent},
test_support::TempDir,
};
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
#[tokio::test]
async fn migrates_legacy_library_index_to_app_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let legacy_path = legacy_library_index_path(games.path());
let target_path = local_library_index_path(state.path());
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
write_file(&legacy_path, br#"{"revision":7,"games":{}}"#);
write_file(&legacy_tmp_path, b"tmp");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert!(report.library_index_migrated);
assert_eq!(
std::fs::read_to_string(&target_path).expect("index should migrate"),
r#"{"revision":7,"games":{}}"#
);
assert!(!legacy_path.exists());
assert!(!legacy_tmp_path.exists());
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
}
#[tokio::test]
async fn migrates_per_game_intent_and_setup_marker() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let intent = InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".to_string()),
);
let legacy_intent = root.join(LEGACY_INTENT_FILE);
let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE);
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER);
write_file(
&legacy_intent,
&serde_json::to_vec_pretty(&intent).expect("intent should serialize"),
);
write_file(&legacy_tmp, b"tmp");
write_file(&legacy_setup, b"");
write_file(&legacy_marker, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.install_intents_migrated, 1);
assert_eq!(report.setup_markers_migrated, 1);
let migrated_intent = read_intent(state.path(), "game").await;
assert_eq!(migrated_intent.state, InstallIntentState::Updating);
assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101"));
assert!(setup_done_path(state.path(), "game").is_file());
assert!(!legacy_intent.exists());
assert!(!legacy_tmp.exists());
assert!(!legacy_setup.exists());
assert!(!legacy_marker.exists());
}
#[tokio::test]
async fn app_state_wins_over_legacy_per_game_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let app_intent = InstallIntent::none("game", Some("app".to_string()));
let legacy_intent = InstallIntent::new(
"game",
InstallIntentState::Installing,
Some("legacy".to_string()),
);
let legacy_intent_path = root.join(LEGACY_INTENT_FILE);
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
write_intent(state.path(), "game", &app_intent)
.await
.expect("app-state intent should be written");
write_file(
&legacy_intent_path,
&serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"),
);
write_file(&setup_done_path(state.path(), "game"), b"");
write_file(&legacy_setup, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.install_intents_migrated, 0);
assert_eq!(report.setup_markers_migrated, 0);
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
assert_eq!(intent.eti_version.as_deref(), Some("app"));
assert!(!legacy_intent_path.exists());
assert!(!legacy_setup.exists());
}
}
@@ -305,6 +305,7 @@ mod tests {
peer_game_db.clone(),
"local-peer".to_string(),
PathBuf::new(),
PathBuf::new(),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
@@ -277,7 +277,7 @@ async fn run_gated_rescan(
let game_dir = ctx.game_dir.read().await.clone();
let catalog = ctx.catalog.read().await.clone();
match rescan_local_game(&game_dir, &catalog, &id).await {
match rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, &id).await {
Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await,
Err(err) => log::error!("Failed to rescan local game {id}: {err}"),
}
@@ -293,7 +293,7 @@ async fn run_gated_rescan(
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
let game_dir = ctx.game_dir.read().await.clone();
let catalog = ctx.catalog.read().await.clone();
match scan_local_library(&game_dir, &catalog).await {
match scan_local_library(&game_dir, ctx.state_dir.as_ref(), &catalog).await {
Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await,
Err(err) => log::error!("Failed to scan local games directory: {err}"),
}
@@ -377,7 +377,8 @@ mod tests {
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
game_dir.clone(),
game_dir.join(".test-state"),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
+2 -1
View File
@@ -332,7 +332,8 @@ mod tests {
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
game_dir.clone(),
game_dir.join(".test-state"),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
+2
View File
@@ -82,6 +82,7 @@ pub(crate) fn spawn_peer_runtime(
peer_game_db: Arc<RwLock<PeerGameDB>>,
peer_id: String,
game_dir: PathBuf,
state_dir: PathBuf,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
) -> PeerRuntimeHandle {
@@ -98,6 +99,7 @@ pub(crate) fn spawn_peer_runtime(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
runtime_shutdown.clone(),
runtime_tracker.clone(),
+42
View File
@@ -0,0 +1,42 @@
use std::path::{Path, PathBuf};
const PEER_ID_FILE: &str = "peer_id";
const LOCAL_LIBRARY_DIR: &str = "local_library";
const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json";
const GAMES_DIR: &str = "games";
const SETUP_DONE_FILE: &str = "setup_done";
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf {
if let Some(dir) = explicit {
return dir.to_path_buf();
}
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
return PathBuf::from(dir);
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(".lanspread");
}
std::env::temp_dir().join("lanspread")
}
pub(crate) fn peer_id_path(state_dir: &Path) -> PathBuf {
state_dir.join(PEER_ID_FILE)
}
pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf {
state_dir
.join(LOCAL_LIBRARY_DIR)
.join(LOCAL_LIBRARY_INDEX_FILE)
}
pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf {
state_dir.join(GAMES_DIR).join(game_id)
}
#[must_use]
pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf {
game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE)
}
@@ -37,6 +37,7 @@ eyre = { workspace = true }
log = { workspace = true }
mimalloc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tauri = { workspace = true }
tauri-plugin-log = { workspace = true }
tauri-plugin-shell = { workspace = true }
@@ -1,10 +1,8 @@
#[cfg(target_os = "windows")]
use std::fs::File;
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
path::{Component, Path, PathBuf},
sync::Arc,
sync::{Arc, OnceLock},
time::{SystemTime, UNIX_EPOCH},
};
@@ -18,9 +16,11 @@ use lanspread_peer::{
PeerEvent,
PeerGameDB,
PeerRuntimeHandle,
PeerStartOptions,
UnpackFuture,
Unpacker,
start_peer,
migrate_legacy_state,
start_peer_with_options,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command};
@@ -42,6 +42,8 @@ struct LanSpreadState {
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
pending_install_account_names: Arc<RwLock<HashMap<String, String>>>,
state_dir: OnceLock<PathBuf>,
}
struct PeerEventTx(UnboundedSender<PeerEvent>);
@@ -74,7 +76,7 @@ struct LauncherGame {
can_host_server: bool,
}
#[derive(Clone, Debug, serde::Serialize)]
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
struct UnpackLogEntry {
archive: String,
destination: String,
@@ -90,7 +92,8 @@ struct SidecarUnpacker {
app_handle: AppHandle,
}
const MAX_UNPACK_LOGS: usize = 100;
const MAX_UNPACK_LOGS: usize = 20;
const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json";
impl Unpacker for SidecarUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
@@ -109,8 +112,6 @@ async fn get_unpack_logs(
Ok(state.inner().unpack_logs.read().await.clone())
}
#[cfg(target_os = "windows")]
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
@@ -142,7 +143,11 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
}
#[tauri::command]
async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
async fn install_game(
id: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
@@ -168,11 +173,21 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
return Ok(false);
};
let account_name = sanitize_username(&username);
let handled = if let Some(peer_ctrl) = peer_ctrl {
let command = if !downloaded {
PeerCommand::GetGame(id)
state
.inner()
.pending_install_account_names
.write()
.await
.insert(id.clone(), account_name);
PeerCommand::GetGame(id.clone())
} else if !installed {
PeerCommand::InstallGame { id }
PeerCommand::InstallGame {
id: id.clone(),
account_name: Some(account_name),
}
} else {
log::info!("Game is already installed: {id}");
return Ok(false);
@@ -180,6 +195,13 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
if let Err(e) = peer_ctrl.send(command) {
log::error!("Failed to send message to peer: {e:?}");
state
.inner()
.pending_install_account_names
.write()
.await
.remove(&id);
return Ok(false);
}
true
} else {
@@ -191,7 +213,11 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
}
#[tauri::command]
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
async fn update_game(
id: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
@@ -208,8 +234,20 @@ async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tau
let peer_ctrl = peer_ctrl_arc.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id }) {
state
.inner()
.pending_install_account_names
.write()
.await
.insert(id.clone(), sanitize_username(&username));
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
log::error!("Failed to send message to peer: {e:?}");
state
.inner()
.pending_install_account_names
.write()
.await
.remove(&id);
return Ok(false);
}
Ok(true)
@@ -302,6 +340,13 @@ async fn cancel_download(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
state
.inner()
.pending_install_account_names
.write()
.await
.remove(&id);
let is_active_download = {
let active_operations = state.inner().active_operations.read().await;
matches!(
@@ -441,8 +486,23 @@ fn sanitize_username(username: &str) -> String {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
script_params_with_mode("/c", script_path, id, settings)
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
script_params_with_mode("/k", script_path, id, settings)
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn script_params_with_mode(
cmd_mode: &str,
script_path: &Path,
id: &str,
settings: &LaunchSettings,
) -> String {
format!(
r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#,
r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#,
script_path.display(),
id,
settings.language,
@@ -487,7 +547,12 @@ async fn get_game_thumbnail(
}
#[cfg(target_os = "windows")]
fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
fn run_as_admin(
file: &str,
params: &str,
dir: &str,
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
) -> bool {
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
@@ -504,7 +569,7 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
PCWSTR::from_raw(file_wide.as_ptr()),
PCWSTR::from_raw(params_wide.as_ptr()),
PCWSTR::from_raw(dir_wide.as_ptr()),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
show_cmd,
)
};
@@ -540,9 +605,13 @@ async fn run_game_windows(
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
let game_start_bin = game_path.join(GAME_START_SCRIPT);
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
log::error!("app state directory is not initialized; cannot run game");
return Ok(());
};
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
if !first_start_done_file.exists() && game_setup_bin.exists() {
let setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id);
if !setup_done_file.exists() && game_setup_bin.exists() {
if !local_install_is_present(&game_path) {
log::warn!(
"local install is missing for {}; skipping game_setup",
@@ -555,6 +624,7 @@ async fn run_game_windows(
"cmd.exe",
&script_params(&game_setup_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
);
if !result {
@@ -562,10 +632,19 @@ async fn run_game_windows(
return Ok(());
}
if let Err(e) = File::create(&first_start_done_file) {
if let Some(parent) = setup_done_file.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
log::error!(
"failed to create first-start marker {}: {e}",
first_start_done_file.display()
"failed to create setup marker directory {}: {e}",
parent.display()
);
}
if let Err(e) = std::fs::File::create(&setup_done_file) {
log::error!(
"failed to create setup marker {}: {e}",
setup_done_file.display()
);
}
}
@@ -575,6 +654,7 @@ async fn run_game_windows(
"cmd.exe",
&script_params(&game_start_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
);
if !result {
@@ -646,8 +726,9 @@ async fn start_server_windows(
let result = run_as_admin(
"cmd.exe",
&script_params(&server_start_bin, &id, &settings),
&server_script_params(&server_start_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
);
if !result {
@@ -821,6 +902,11 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
}
}
#[tauri::command]
fn game_directory_exists(path: String) -> bool {
PathBuf::from(path).is_dir()
}
#[tauri::command]
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
log::info!("update_game_directory: {path}");
@@ -850,6 +936,21 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
}
let path_changed = current_path != path;
let Some(state_dir) = state.state_dir.get().cloned() else {
log::error!("app state directory is not initialized; cannot update game directory");
return Ok(());
};
if path_changed || state.peer_ctrl.read().await.is_none() {
let migration = migrate_legacy_state(&games_folder, &state_dir).await;
if migration.failures > 0 {
log::warn!(
"Legacy state migration completed with {} failure(s)",
migration.failures
);
}
}
*state.games_folder.write().await = path;
ensure_bundled_game_db_loaded(&app_handle).await;
@@ -1065,8 +1166,8 @@ async fn run_unrar_sidecar(
}
};
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
let status_code = out.status.code();
let success = out.status.success();
@@ -1123,17 +1224,117 @@ async fn record_unpack_failure(
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
let state = app_handle.state::<LanSpreadState>();
{
let mut entry = entry;
clean_unpack_log_entry(&mut entry);
let logs = {
let mut logs = state.inner().unpack_logs.write().await;
logs.push(entry);
trim_unpack_logs(&mut logs);
logs.clone()
};
persist_unpack_logs(app_handle, &logs).await;
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
log::warn!("Failed to emit unpack-logs-updated event: {err}");
}
}
fn trim_unpack_logs(logs: &mut Vec<UnpackLogEntry>) {
if logs.len() > MAX_UNPACK_LOGS {
let overflow = logs.len() - MAX_UNPACK_LOGS;
logs.drain(..overflow);
}
}
fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) {
let stdout = clean_terminal_log(&entry.stdout);
let stderr = clean_terminal_log(&entry.stderr);
entry.stdout = stdout;
entry.stderr = stderr;
}
fn clean_terminal_log(input: &str) -> String {
let mut output = String::new();
let mut line = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' if chars.peek() == Some(&'\n') => {
let _ = chars.next();
output.push_str(&line);
output.push('\n');
line.clear();
}
'\r' => {
line.clear();
}
'\n' => {
output.push_str(&line);
output.push('\n');
line.clear();
}
'\u{8}' => {
let _ = line.pop();
}
'\t' => line.push(ch),
ch if ch.is_control() => {}
ch => line.push(ch),
}
}
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
log::warn!("Failed to emit unpack-logs-updated event: {err}");
output.push_str(&line);
output
}
fn unpack_logs_path(state_dir: &Path) -> PathBuf {
state_dir.join(UNPACK_LOGS_FILE_NAME)
}
fn load_unpack_logs(state_dir: &Path) -> Vec<UnpackLogEntry> {
let path = unpack_logs_path(state_dir);
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
log::warn!("Failed to read unpack logs from {}: {err}", path.display());
return Vec::new();
}
};
let mut logs = match serde_json::from_str::<Vec<UnpackLogEntry>>(&contents) {
Ok(logs) => logs,
Err(err) => {
log::warn!("Failed to parse unpack logs from {}: {err}", path.display());
return Vec::new();
}
};
logs.iter_mut().for_each(clean_unpack_log_entry);
trim_unpack_logs(&mut logs);
logs
}
async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) {
let state = app_handle.state::<LanSpreadState>();
let Some(state_dir) = state.state_dir.get().cloned() else {
log::warn!("Cannot persist unpack logs before app state directory is initialized");
return;
};
let path = unpack_logs_path(&state_dir);
let contents = match serde_json::to_vec_pretty(logs) {
Ok(contents) => contents,
Err(err) => {
log::warn!(
"Failed to serialize unpack logs for {}: {err}",
path.display()
);
return;
}
};
if let Err(err) = tokio::fs::write(&path, contents).await {
log::warn!("Failed to persist unpack logs to {}: {err}", path.display());
}
}
@@ -1193,16 +1394,23 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
return;
}
let Some(state_dir) = state.state_dir.get().cloned() else {
log::error!("app state directory is not initialized; cannot start peer");
return;
};
let tx_peer_event = app_handle.state::<PeerEventTx>().inner().0.clone();
let unpacker = Arc::new(SidecarUnpacker {
app_handle: app_handle.clone(),
});
match start_peer(
match start_peer_with_options(
games_folder.to_path_buf(),
tx_peer_event,
state.peer_game_db.clone(),
unpacker,
state.catalog.clone(),
PeerStartOptions {
state_dir: Some(state_dir),
},
) {
Ok(handle) => {
let sender = handle.sender();
@@ -1273,6 +1481,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::NoPeersHaveGame { id } => {
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
clear_pending_install_account_name(app_handle, &id).await;
emit_game_id_event(
app_handle,
"game-no-peers",
@@ -1311,6 +1520,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::DownloadGameFilesFailed { id } => {
log::warn!("PeerEvent::DownloadGameFilesFailed received");
clear_pending_install_account_name(app_handle, &id).await;
emit_game_id_event(
app_handle,
"game-download-failed",
@@ -1320,6 +1530,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
clear_pending_install_account_name(app_handle, &id).await;
emit_game_id_event(
app_handle,
"game-download-peers-gone",
@@ -1444,6 +1655,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
}
async fn clear_pending_install_account_name(app_handle: &AppHandle, id: &str) {
let state = app_handle.state::<LanSpreadState>();
state.pending_install_account_names.write().await.remove(id);
}
async fn handle_got_game_files(
app_handle: &AppHandle,
id: String,
@@ -1458,11 +1674,17 @@ async fn handle_got_game_files(
);
let state = app_handle.state::<LanSpreadState>();
let account_name = state
.pending_install_account_names
.write()
.await
.remove(&id);
let peer_ctrl = state.peer_ctrl.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
id,
file_descriptions,
account_name,
})
{
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
@@ -1483,6 +1705,20 @@ fn handle_download_finished(app_handle: &AppHandle, id: String) {
mod tests {
use super::*;
fn unpack_log_fixture(index: usize) -> UnpackLogEntry {
let timestamp = u64::try_from(index).unwrap_or(u64::MAX);
UnpackLogEntry {
archive: format!("archive-{index}.rar"),
destination: format!("destination-{index}"),
status_code: Some(0),
stdout: format!("stdout {index}"),
stderr: String::new(),
started_at_ms: timestamp,
finished_at_ms: timestamp,
success: true,
}
}
fn game_fixture(id: &str, name: &str) -> Game {
Game {
id: id.to_string(),
@@ -1503,6 +1739,72 @@ mod tests {
}
}
#[test]
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
assert_eq!(clean_terminal_log(input), "Extracting foo OK\nAll done\n");
}
#[test]
fn terminal_log_cleanup_applies_backspaces() {
assert_eq!(clean_terminal_log("abc\u{8}\u{8}de\n"), "ade\n");
}
#[test]
fn terminal_log_cleanup_removes_other_controls() {
assert_eq!(clean_terminal_log("a\u{7}b\tc"), "ab\tc");
}
#[test]
fn unpack_log_retention_keeps_last_twenty_entries() {
let mut logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
trim_unpack_logs(&mut logs);
assert_eq!(logs.len(), MAX_UNPACK_LOGS);
assert_eq!(
logs.first().map(|entry| entry.archive.as_str()),
Some("archive-5.rar")
);
assert_eq!(
logs.last().map(|entry| entry.archive.as_str()),
Some("archive-24.rar")
);
}
#[test]
fn unpack_logs_load_from_app_state_dir_and_apply_retention() {
let root = std::env::temp_dir().join(format!(
"lanspread-unpack-logs-test-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos()
));
std::fs::create_dir_all(&root).expect("test state dir should be created");
let logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
std::fs::write(
unpack_logs_path(&root),
serde_json::to_vec(&logs).expect("logs should serialize"),
)
.expect("logs should be written");
let loaded = load_unpack_logs(&root);
assert_eq!(loaded.len(), MAX_UNPACK_LOGS);
assert_eq!(
loaded.first().map(|entry| entry.archive.as_str()),
Some("archive-5.rar")
);
assert_eq!(
loaded.last().map(|entry| entry.archive.as_str()),
Some("archive-24.rar")
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn active_operation_reconciliation_replaces_stale_ui_history() {
let mut active_operations = HashMap::from([
@@ -1621,7 +1923,7 @@ mod tests {
#[test]
fn script_params_use_common_argument_shape() {
let params = script_params(
let start_params = script_params(
Path::new("C:/Games/My Game")
.join(GAME_START_SCRIPT)
.as_path(),
@@ -1633,9 +1935,25 @@ mod tests {
);
assert_eq!(
params,
start_params,
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
);
let server_params = server_script_params(
Path::new("C:/Games/My Game")
.join(SERVER_START_SCRIPT)
.as_path(),
"my-game",
&LaunchSettings {
language: "en".to_string(),
username: "Alice".to_string(),
},
);
assert_eq!(
server_params,
r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""#
);
}
#[test]
@@ -1730,6 +2048,7 @@ pub fn run() {
install_game,
run_game,
start_server,
game_directory_exists,
update_game_directory,
update_game,
uninstall_game,
@@ -1743,6 +2062,16 @@ pub fn run() {
.manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event))
.setup(move |app| {
let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?;
let state = app.state::<LanSpreadState>();
let unpack_logs = load_unpack_logs(&state_dir);
tauri::async_runtime::block_on(async {
*state.unpack_logs.write().await = unpack_logs;
});
if state.state_dir.set(state_dir).is_err() {
log::warn!("app state directory was already initialized");
}
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
Ok(())
})
@@ -33,6 +33,9 @@ const formatLogTime = (timestampMs: number): string => {
return new Date(timestampMs).toLocaleString();
};
const logSortTime = (entry: UnpackLogEntry): number =>
entry.finished_at_ms > 0 ? entry.finished_at_ms : entry.started_at_ms;
const basename = (path: string): string => {
const segments = path.split(/[\\/]/);
return segments[segments.length - 1] || path;
@@ -97,6 +100,10 @@ export const UnpackLogsWindow = () => {
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount });
});
out.sort((a, b) => {
const timestampDelta = logSortTime(b.entry) - logSortTime(a.entry);
return timestampDelta !== 0 ? timestampDelta : b.originalIndex - a.originalIndex;
});
return out;
}, [logs, errorsOnly, regex]);
@@ -7,11 +7,7 @@ interface Props {
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
<div className="empty-state">
<div className="empty-state-icon"><Icon.folder /></div>
<h2 className="empty-state-title">Pick a game directory</h2>
<p className="empty-state-hint">
SoftLAN scans the folder you point it at for installable game bundles
and tracks what your peers on the LAN have available.
</p>
<h2 className="empty-state-title">Please select a game folder</h2>
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
<Icon.folder />
<span>Choose folder</span>
@@ -55,7 +55,8 @@ export const GameDetailModal = ({
&& !isInProgress(game.install_status);
const canViewFiles = game.downloaded
|| game.installed
|| game.install_status === InstallStatus.Downloading;
|| game.install_status === InstallStatus.Downloading
|| game.install_status === InstallStatus.Installing;
return (
<Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
@@ -14,10 +14,15 @@ import {
interface Props {
settings: UISettings;
gameDir: string;
hasGameDirectory: boolean;
onPickDirectory: () => void;
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
onClose: () => void;
}
const buildNr = import.meta.env.VITE_LANSPREAD_BUILD_NR;
interface RowProps {
label: string;
hint: string;
@@ -54,7 +59,37 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
</div>
);
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
interface GameFolderFieldProps {
path: string;
isValid: boolean;
onPickDirectory: () => void;
}
const GameFolderField = ({ path, isValid, onPickDirectory }: GameFolderFieldProps) => (
<div className={`folder-field ${isValid ? 'is-set' : 'is-unset'}`}>
<Icon.folder className="folder-field-icon" aria-hidden="true" />
<div className="folder-field-path" title={isValid ? path : 'No folder selected'}>
{isValid ? <bdi>{path}</bdi> : <span className="folder-field-empty">Not set</span>}
</div>
<button
type="button"
className="folder-field-btn"
aria-label={isValid ? 'Change game folder' : 'Choose game folder'}
onClick={onPickDirectory}
>
{isValid ? 'Change…' : 'Choose…'}
</button>
</div>
);
export const SettingsDialog = ({
settings,
gameDir,
hasGameDirectory,
onPickDirectory,
onChange,
onClose,
}: Props) => (
<Modal onClose={onClose} className="settings-modal">
<div className="settings-head">
<h2>Settings</h2>
@@ -108,6 +143,13 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
<section className="settings-section">
<div className="settings-section-title">Library</div>
<Row label="Game folder" hint="Parent directory where games are downloaded and installed">
<GameFolderField
path={gameDir}
isValid={hasGameDirectory}
onPickDirectory={onPickDirectory}
/>
</Row>
<Row label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio
value={settings.density}
@@ -126,6 +168,7 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
</div>
<div className="settings-foot">
<div className="settings-build-nr">Build-Nr: {buildNr}</div>
<button type="button" className="settings-done" onClick={onClose}>Done</button>
</div>
</Modal>
@@ -1,17 +0,0 @@
import { Icon } from '../Icon';
import { truncatePath } from '../../lib/format';
interface Props {
path: string;
onClick: () => void;
}
export const DirectoryButton = ({ path, onClick }: Props) => (
<button className="dirbtn" type="button" title={path || 'Choose a game directory'} onClick={onClick}>
<Icon.folder />
<span className="dirbtn-label">Game directory</span>
<span className="dirbtn-path">
{path ? truncatePath(path) : 'choose…'}
</span>
</button>
);
@@ -8,15 +8,18 @@ interface Props {
}
/**
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
* when the user is already typing into another input or textarea.
* Search input with `/` and Ctrl+F keyboard shortcuts for focus. Ignores
* shortcuts when the user is already typing into another input or textarea.
*/
export const SearchField = ({ value, onChange }: Props) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const clearClassName = value ? 'search-clear' : 'search-clear is-hidden';
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== '/') return;
const isFindShortcut = e.ctrlKey && !e.altKey && !e.shiftKey
&& e.key.toLowerCase() === 'f';
if (e.key !== '/' && !isFindShortcut) return;
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
e.preventDefault();
@@ -49,11 +52,12 @@ export const SearchField = ({ value, onChange }: Props) => {
}}
spellCheck={false}
/>
{value && (
<button
className="search-clear"
className={clearClassName}
type="button"
aria-label="Clear search"
aria-hidden={value ? undefined : true}
disabled={!value}
onClick={() => {
onChange('');
inputRef.current?.focus();
@@ -61,7 +65,6 @@ export const SearchField = ({ value, onChange }: Props) => {
>
<Icon.clearCircle />
</button>
)}
<span className="search-kbd">/</span>
</div>
);
@@ -2,7 +2,6 @@ import { Brand } from '../Brand';
import { SegmentedFilters } from './SegmentedFilters';
import { SearchField } from './SearchField';
import { SortMenu } from './SortMenu';
import { DirectoryButton } from './DirectoryButton';
import { KebabMenu, KebabItem } from './KebabMenu';
import { FilterCounts } from '../../lib/gameState';
@@ -17,8 +16,6 @@ interface Props {
setQuery: (value: string) => void;
sort: GameSort;
setSort: (value: GameSort) => void;
gameDir: string;
onPickDirectory: () => void;
kebabItems: ReadonlyArray<KebabItem>;
}
@@ -31,16 +28,25 @@ export const TopBar = ({
setQuery,
sort,
setSort,
gameDir,
onPickDirectory,
kebabItems,
}: Props) => (
<header className="topbar">
<div className="topbar-left">
<Brand peerCount={peerCount} />
<div className="topbar-left-trail">
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
</div>
</div>
<div className="topbar-center">
<SearchField value={query} onChange={setQuery} />
</div>
<div className="topbar-right">
<div className="topbar-right-lead">
<SortMenu value={sort} onChange={setSort} />
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
</div>
<div className="topbar-right-tail">
<KebabMenu items={kebabItems} />
</div>
</div>
</header>
);
@@ -51,7 +51,10 @@ export const useGameActions = (
const install = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('install_game', { id });
const success = await invoke<boolean>('install_game', {
id,
username: settings.username,
});
if (!success) return;
const game = games.games.find(item => item.id === id);
@@ -61,16 +64,19 @@ export const useGameActions = (
} catch (err) {
console.error('install_game failed:', err);
}
}, [games]);
}, [games, settings.username]);
const update = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('update_game', { id });
const success = await invoke<boolean>('update_game', {
id,
username: settings.username,
});
if (success) games.markChecking(id);
} catch (err) {
console.error('update_game failed:', err);
}
}, [games]);
}, [games, settings.username]);
const uninstall = useCallback(async (id: string) => {
try {
@@ -11,6 +11,7 @@ import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store
*/
export const useGameDirectory = () => {
const [gameDir, setGameDir] = useState('');
const [gameDirExists, setGameDirExists] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -30,7 +31,11 @@ export const useGameDirectory = () => {
}, []);
useEffect(() => {
if (!gameDir) return;
if (!gameDir.trim()) {
setGameDirExists(false);
return;
}
let cancelled = false;
const sync = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
@@ -38,19 +43,50 @@ export const useGameDirectory = () => {
} catch (err) {
console.error('Failed to persist game directory:', err);
}
};
void sync();
let exists = false;
try {
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
} catch (err) {
console.error('Failed to validate game directory:', err);
}
if (cancelled) return;
setGameDirExists(exists);
if (!exists) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to push game directory to backend:', err),
);
};
void sync();
return () => {
cancelled = true;
};
}, [gameDir]);
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
const rescan = useCallback(() => {
if (!gameDir) return;
if (!gameDir.trim()) {
setGameDirExists(false);
return;
}
const sync = async () => {
let exists = false;
try {
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
} catch (err) {
console.error('Failed to validate game directory:', err);
}
setGameDirExists(exists);
if (!exists) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to rescan game directory:', err),
);
};
void sync();
}, [gameDir]);
return { gameDir, setGameDir, rescan };
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
};
@@ -19,10 +19,6 @@ export const formatEtiVersion = (raw: string | undefined): string => {
return raw;
};
/** Truncate a path with a leading ellipsis when it exceeds the limit. */
export const truncatePath = (path: string, max = 36): string =>
path.length > max ? `${path.slice(-(max - 1))}` : path;
export const formatPlayers = (max?: number): string => {
if (!max || max <= 0) return '—';
return max === 1 ? '1' : `1${max}`;
@@ -6,6 +6,8 @@
flex-direction: column;
background: var(--bg-0);
color: var(--t-1);
container-type: inline-size;
container-name: launcher;
overflow: hidden;
position: relative;
isolation: isolate;
@@ -56,7 +58,7 @@
}
}
/* Top bar */
/* Top bar — three visual zones with search at the geometric center */
.topbar {
position: relative;
z-index: 10;
@@ -64,12 +66,75 @@
-webkit-backdrop-filter: blur(20px) saturate(140%);
backdrop-filter: blur(20px) saturate(140%);
border-bottom: 1px solid var(--bd-1);
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
column-gap: 16px;
padding: 14px 24px;
min-height: 64px;
}
.topbar-left {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 24px;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.topbar-left-trail {
display: flex;
align-items: center;
min-width: 0;
}
.topbar-center {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.topbar-center .search {
flex: 0 1 360px;
min-width: 0;
}
.topbar-right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.topbar-right-lead {
display: flex;
align-items: center;
min-width: 0;
}
.topbar-right-tail {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
/* Below ~1100px of launcher width the geometric centering stops reading —
collapse the three zones into a single left-to-right flowing row. */
@container launcher (max-width: 1100px) {
.topbar {
display: flex;
flex-wrap: nowrap;
min-height: 64px;
gap: 16px;
}
.topbar-left,
.topbar-center,
.topbar-right {
justify-content: flex-start;
flex: 0 0 auto;
gap: 12px;
}
.topbar-center {
flex: 1 1 200px;
}
.topbar-center .search {
flex: 1 1 auto;
}
}
/* Brand */
@@ -239,6 +304,10 @@
color: var(--t-1);
background: rgba(255, 255, 255, 0.08);
}
.search-clear.is-hidden {
visibility: hidden;
pointer-events: none;
}
.search-kbd {
display: inline-grid;
place-items: center;
@@ -325,46 +394,6 @@
color: var(--accent);
}
/* Directory button */
.dirbtn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-2);
font: inherit;
font-size: 12.5px;
cursor: pointer;
max-width: 360px;
transition:
border-color 0.15s,
color 0.15s;
flex-shrink: 1;
min-width: 0;
}
.dirbtn:hover {
border-color: var(--bd-2);
color: var(--t-1);
}
.dirbtn-label {
color: var(--t-1);
font-weight: 600;
flex-shrink: 0;
}
.dirbtn-path {
color: var(--t-3);
font-family: var(--font-mono);
font-size: 11.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Kebab menu */
.kebab {
position: relative;
@@ -1468,11 +1497,18 @@
}
.settings-foot {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
padding: 14px 22px 18px;
border-top: 1px solid var(--bd-1);
gap: 10px;
}
.settings-build-nr {
min-width: 0;
color: var(--t-3);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.settings-done {
height: 36px;
padding: 0 22px;
@@ -1526,6 +1562,86 @@
font-weight: 500;
}
/* Settings: game-folder field */
.folder-field {
display: inline-flex;
align-items: center;
gap: 8px;
width: 340px;
height: 36px;
padding: 0 4px 0 12px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
transition:
border-color 0.15s,
background 0.15s;
}
.folder-field:hover {
border-color: var(--bd-2);
}
.folder-field.is-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
}
.folder-field.is-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
}
.folder-field-icon {
color: var(--t-3);
flex-shrink: 0;
}
.folder-field.is-unset .folder-field-icon {
color: #f87171;
}
.folder-field-path {
flex: 1;
min-width: 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
color: var(--t-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.folder-field-empty {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 600;
color: #f87171;
letter-spacing: 0;
}
.folder-field-btn {
flex-shrink: 0;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: var(--t-1);
font: inherit;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.folder-field-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.folder-field.is-unset .folder-field-btn {
background: color-mix(in srgb, var(--accent) 85%, transparent);
color: #fff;
}
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent);
}
/* Settings: color swatches */
.swatch-row {
display: inline-flex;
@@ -1639,6 +1755,9 @@
margin: 0 0 20px;
max-width: 44ch;
}
.empty-state-title + .ghost-btn {
margin-top: 14px;
}
.empty-state .ghost-btn {
background: var(--accent);
color: white;
+4
View File
@@ -1 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_LANSPREAD_BUILD_NR: string;
}
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
export const MainWindow = () => {
const { settings, set: setSetting } = useSettings();
const { gameDir, setGameDir, rescan } = useGameDirectory();
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
@@ -53,14 +53,17 @@ export const MainWindow = () => {
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const counts = useMemo(() => countByFilter(games.games), [games.games]);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
// Query is local UI state (no need to persist).
const [query, setQuery] = useState('');
const filteredGames = useMemo(
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
[games.games, settings.filter, settings.sort, query],
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
);
const openGame = useMemo<Game | null>(
@@ -116,8 +119,6 @@ export const MainWindow = () => {
return (
<div className={className} style={rootStyle}>
{gameDir ? (
<>
<TopBar
peerCount={games.totalPeerCount}
filter={settings.filter}
@@ -127,14 +128,14 @@ export const MainWindow = () => {
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
gameDir={gameDir}
onPickDirectory={() => void pickDirectory()}
kebabItems={kebabItems}
/>
<main className="grid-wrap">
{hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0 ? (
games.games.length === 0 ? (
visibleGames.length === 0 ? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
@@ -155,13 +156,11 @@ export const MainWindow = () => {
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</main>
</>
) : (
<main className="grid-wrap">
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
</main>
)}
</main>
{openGame && (
<GameDetailModal
@@ -188,6 +187,9 @@ export const MainWindow = () => {
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => void pickDirectory()}
onChange={setSetting}
onClose={() => setSettingsOpen(false)}
/>
@@ -1,12 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// A timestamp keeps build numbers monotonic without a checked-in counter file.
const buildNr = Date.now().toString();
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
define: {
"import.meta.env.VITE_LANSPREAD_BUILD_NR": JSON.stringify(buildNr),
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
+81 -14
View File
@@ -32,6 +32,21 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
---
## Changes since v3
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
- **Persisted setting renamed.** `gameFolderSet: boolean``gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
## Changes since v2
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
- **Left:** brand mark + wordmark.
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
- See "Top bar (variant A)" below for the full spec and rationale.
## Changes since v1
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
@@ -48,21 +63,29 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
**Layout (top-to-bottom):**
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Contents, left-to-right with 18px gap and 24px horizontal padding:
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
- **Brand** — 28×28px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20px white. Next to it, the wordmark "SoftLAN" in 15px / 700 weight `--t-1` `#e6edf3`.
- **Segmented filter pills** — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
- **Left zone (col 1, flex space-between):**
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
- `All Games` · count chip
- `Local` · count chip
- `Installed` · count chip
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
- **Search field** — 36px tall, min-width 320px (flex 0 1 380px). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Has a leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border becomes `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The "/" key shortcut should focus the search.
- **Sort menu** — 36px button, same surface style as search. Label `Sort: <bold value>` plus 13px sort-bars icon and 11px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`.
- **Game directory button** — 36px button, max-width 360px. Folder icon, "Game directory" label (600 weight `--t-1`), then the current path in `ui-monospace` 11.5px `--t-3` truncated with leading ellipsis when long (e.g. `…s/Desktop/eti_games_AFTER_LAN_2025`).
- **Kebab menu** (`⋮`) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog — see below), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`.
The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder.
- **Center zone (col 2, search alone):**
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search.
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
@@ -152,6 +175,11 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
│ │
│ LIBRARY │
│ │
│ Game folder │
│ Parent directory where games are │
│ downloaded and installed │
│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36)
│ │
│ Grid density │
│ How tightly cards are packed │
│ [Compact│Normal│Large]│
@@ -175,6 +203,10 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
@@ -190,6 +222,40 @@ Persisted settings (write through to local storage / Tauri config):
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
- `density`: `compact` | `normal` | `large`. Default `normal`.
- `aspect`: `box` | `square` | `banner`. Default `box`.
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
---
## Game-folder field
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
| State | Trigger | Path display | Border | Button label |
|---|---|---|---|---|
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
1. **Folder icon**`Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
2. **Path display**`flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
---
@@ -371,8 +437,8 @@ Implement only if you decide variant A doesn't work after building.
- **Click filter tab / segmented pill** → change filter.
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
- **Click "Game directory" button** → open native folder picker via Tauri; on selection, rescan library.
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** → open native folder picker via Tauri; on selection, write to `settings.gameFolder` and rescan library. The field indicates whether a valid folder is currently configured (mono path + neutral `Change…`) or not (red `Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
- **Click "Refresh library"** in kebab → re-runs the library scan.
- **Esc** → closes any open modal (detail overlay, Settings).
@@ -426,12 +492,13 @@ type LauncherUI = {
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
username: string; // new
language: 'en' | 'de'; // new
username: string;
language: 'en' | 'de';
accent: string; // hex from the curated 6-color palette
bg: 'flat' | 'gradient' | 'animated';
density: 'compact' | 'normal' | 'large';
aspect: 'box' | 'square' | 'banner';
gameFolder: string | null; // v3: moved out of top bar, persists actual path
};
```
@@ -523,8 +590,8 @@ design_reference/
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
│ SegmentedFilters, UnderlineFilters, SearchField,
│ SortMenu, StorageMeter, DirectoryButton, KebabMenu,
│ GameDetailModal, SettingsDialog
│ SortMenu, StorageMeter, KebabMenu,
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
```
@@ -536,7 +603,7 @@ To preview the design in a browser:
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
- **F** — Settings dialog open, with the new **Profile** section at the top
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect). In the production app these live in the Settings dialog.
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
---
@@ -36,8 +36,9 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"density": "normal",
"aspect": "square",
"bg": "gradient",
"username": "d",
"language": "en"
"username": "ddidderr",
"language": "en",
"gameFolder": "\/some\/folder\/to\/games"
}/*EDITMODE-END*/;
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
@@ -113,6 +114,10 @@ function App() {
<TweakRadio label="Cover aspect" value={t.aspect}
options={['box', 'square', 'banner']}
onChange={(v) => setTweak('aspect', v)}/>
<TweakSection label="Library"/>
<TweakText label="Game folder" value={t.gameFolder}
onChange={(v) => setTweak('gameFolder', v)}/>
</TweaksPanel>
</React.Fragment>
);
+39 -4
View File
@@ -354,12 +354,17 @@ function StorageMeter({ accent, compact = false }) {
// Directory button (shows path)
// ────────────────────────────────────────────────────────────────────
function DirectoryButton({ path }) {
const short = path.length > 36 ? '…' + path.slice(-34) : path;
const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder';
const tooltip = isSet ? path : 'Please select a game folder';
return (
<button className="dirbtn" title={path}>
<button
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
>
<Icon.folder/>
<span className="dirbtn-label">Game directory</span>
<span className="dirbtn-path">{short}</span>
<span className="dirbtn-label">{label}</span>
</button>
);
}
@@ -519,6 +524,31 @@ function SegmentedRadio({ value, options, onChange, accent }) {
);
}
function GameFolderField({ value, onChange, accent }) {
const isSet = !!(value && value.trim());
const handleChange = () => {
// In production: open native folder picker via Tauri.
// For the prototype, prompt for a path so the field is exercisable.
const next = window.prompt('Game folder path (leave empty to clear)', value || '');
if (next == null) return;
onChange(next.trim());
};
return (
<div className={`folder-field ${isSet ? 'is-set' : 'is-unset'}`}
style={{ '--accent': accent }}>
<span className="folder-field-icon" aria-hidden="true"><Icon.folder/></span>
<div className="folder-field-path" title={isSet ? value : 'No folder selected'}>
{isSet
? <bdi>{value}</bdi>
: <span className="folder-field-empty">Not set</span>}
</div>
<button type="button" className="folder-field-btn" onClick={handleChange}>
{isSet ? 'Change\u2026' : 'Choose\u2026'}
</button>
</div>
);
}
function ColorSwatchPicker({ value, options, onChange }) {
return (
<div className="swatch-row">
@@ -577,6 +607,11 @@ function SettingsDialog({ settings, onChange, onClose }) {
</div>
<div className="settings-section">
<div className="settings-section-title">Library</div>
<SettingsRow label="Game folder" hint="Parent directory where games are downloaded and installed">
<GameFolderField value={settings.gameFolder}
onChange={(v) => onChange('gameFolder', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio value={settings.density}
options={SETTING_OPTIONS.density}
+12 -4
View File
@@ -1,8 +1,6 @@
// launcher.jsx — composes top bar + grid into a complete launcher screen
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
const DIR_PATH = '/home/pfs/Desktop/eti_games_AFTER_LAN_2025';
function applyFilterAndSort(games, filter, sort, query) {
let g = filterGames(games, filter);
if (query.trim()) {
@@ -51,15 +49,26 @@ function Launcher({
style={{ '--accent': accent }}>
{variant === 'single' ? (
<header className="topbar topbar-single">
<div className="topbar-left">
<div className="brand">
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN</div>
</div>
<div className="topbar-left-trail">
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
</div>
</div>
<div className="topbar-center">
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
</div>
<div className="topbar-right">
<div className="topbar-right-lead">
<SortMenu value={sort} onChange={setSort} accent={accent}/>
<DirectoryButton path={DIR_PATH}/>
</div>
<div className="topbar-right-trail">
<KebabMenu items={menuItems}/>
</div>
</div>
</header>
) : (
<header className="topbar topbar-two">
@@ -68,7 +77,6 @@ function Launcher({
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN <span className="brand-name-soft">Launcher</span></div>
</div>
<DirectoryButton path={DIR_PATH}/>
<div className="topbar-row1-right">
<StorageMeter accent={accent}/>
<KebabMenu items={menuItems}/>
+148 -20
View File
@@ -41,6 +41,8 @@
color: var(--t-1);
font-family: var(--font-ui);
font-size: 13px;
container-type: inline-size;
container-name: launcher;
line-height: 1.4;
overflow: hidden;
position: relative;
@@ -77,13 +79,70 @@
border-bottom: 1px solid var(--bd-1);
}
/* Variant 1: single row */
/* Variant 1: single row — three visual zones with search at geometric center */
.topbar-single {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
column-gap: 16px;
padding: 14px 24px;
}
.topbar-single .topbar-left {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 24px;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.topbar-single .topbar-left-trail {
display: flex;
align-items: center;
min-width: 0;
}
.topbar-single .topbar-center {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.topbar-single .topbar-center .search { flex: 0 1 360px; min-width: 0; }
.topbar-single .topbar-right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.topbar-single .topbar-right-lead {
display: flex;
align-items: center;
min-width: 0;
}
.topbar-single .topbar-right-trail {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
/* When the launcher gets narrow, the three-zone centering breaks down —
collapse to a single left-to-right flowing row. */
@container launcher (max-width: 1100px) {
.topbar-single {
display: flex;
flex-wrap: nowrap;
gap: 16px;
}
.topbar-single .topbar-left,
.topbar-single .topbar-center,
.topbar-single .topbar-right,
.topbar-single .topbar-right-trail {
justify-content: flex-start;
flex: 0 0 auto;
gap: 12px;
}
.topbar-single .topbar-center { flex: 1 1 200px; }
.topbar-single .topbar-center .search { flex: 1 1 auto; }
}
/* Variant 2: two row */
@@ -307,29 +366,27 @@
/* ─── Directory button ─── */
.dirbtn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
height: 36px; padding: 0 14px 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-2);
font: inherit; font-size: 12.5px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
cursor: pointer;
max-width: 360px;
transition: border-color .15s, color .15s;
flex-shrink: 1;
min-width: 0;
}
.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); }
.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; }
.dirbtn-path {
color: var(--t-3);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
transition: border-color .15s, color .15s, background .15s;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.dirbtn:hover { border-color: var(--bd-2); }
.dirbtn-label { line-height: 1; }
.dirbtn-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
}
.dirbtn-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
}
/* ─── Kebab menu ─── */
@@ -1183,3 +1240,74 @@
color: var(--t-3);
font-weight: 500;
}
/* ─── Settings: game-folder field ─── */
.folder-field {
display: inline-flex;
align-items: center;
gap: 8px;
width: 340px;
height: 36px;
padding: 0 4px 0 12px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
transition: border-color .15s, background .15s;
}
.folder-field:hover { border-color: var(--bd-2); }
.folder-field.is-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
}
.folder-field.is-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
}
.folder-field-icon {
display: inline-flex;
color: var(--t-3);
flex-shrink: 0;
}
.folder-field.is-unset .folder-field-icon { color: #f87171; }
.folder-field-path {
flex: 1;
min-width: 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
color: var(--t-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl; /* truncate from the head so the leaf folder stays visible */
text-align: left;
unicode-bidi: plaintext; /* keep character order intact */
}
.folder-field-empty {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 600;
color: #f87171;
letter-spacing: 0.1px;
}
.folder-field-btn {
flex-shrink: 0;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: var(--t-1);
font: inherit;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.1px;
cursor: pointer;
transition: background .15s, color .15s;
}
.folder-field-btn:hover { background: rgba(255,255,255,0.12); }
.folder-field.is-unset .folder-field-btn {
background: color-mix(in srgb, var(--accent, #3b82f6) 85%, transparent);
color: #fff;
}
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent, #3b82f6);
}