Compare commits

...

59 Commits

Author SHA1 Message Date
ddidderr c00e6eae84 fix(peer): drain streamed install senders after completion
A streamed install sender kept the original frame sink alive outside the
producer task. After the producer sent Complete, or an Error for a provider
failure, the forwarding loop still had a live mpsc sender in scope and waited
forever for another frame.

Move the sink into the producer so the channel closes when the producer exits.
That lets the QUIC writer close, the request task return, and the outbound
TransferGuard drop after successful streamed installs and provider-side
failures.

The peer-cli harness now keeps the outbound-transfer map it passes into the
peer runtime and exposes per-game counts in status. S39 asserts that the source
has no active outbound transfer for cnctw after the streamed install finishes,
which catches the sender-side lifecycle leak that receiver-only assertions
missed. The peer-cli README and scenario table document that status field and
expectation.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check
- git diff --cached --check
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S39 S40 --build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 S42 S43 S44 S45 S46 S47

Refs: NEXT_STEPS.md streamed install lifecycle hardening
2026-06-11 08:31:12 +02:00
ddidderr 66c7d5912b fix(peer): harden streamed install lifecycle
Claude Fable 5's branch review found that receiver cancellation or a QUIC
send failure could leave the sender-side archive producer blocked on the
bounded frame channel. That kept the outbound transfer guard alive and could
block later installs or updates of the same game.

Route archive frames through a cancellable StreamInstallFrameSink instead of
exposing the raw channel sender to providers. The QUIC forwarder now cancels
and closes the receive side before awaiting the producer, so a blocked send
wakes and the transfer guard can drop normally.

Make PeerCommand::StreamInstallGame own its peer metadata preflight inside the
peer core. The Tauri layer now sends the command directly, and the peer runtime
fetches file details from catalog-version peers before running the existing
majority validation and retry logic. This removes the UI-only pending streamed
install set and gives PeerEvent::GotGameFiles one meaning again: continue a
normal archive download.

Tighten the receiver transaction edge cases too. Rollback removes a newly
created empty game root, but preserves pre-existing roots. Once streamed
staging has been promoted to local/, intent or launch-settings cleanup failures
are logged for startup recovery instead of reporting a failed install for bytes
that are already committed.

Accept missing RAR CRC32 metadata for zero-byte files as CRC32 00000000 while
still requiring CRC32 metadata for non-empty files. Update the peer README,
scenario docs, and next-steps handoff so the documented ownership and remaining
trust limitation match the implementation.

Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- git diff --check
- python3 -m py_compile \
  crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image

Refs: streamed-install review handoff from Claude Fable 5
2026-06-11 07:33:34 +02:00
ddidderr 9c765aba9c [deps] cargo update
Updating http                       v1.4.1   -> v1.4.2
Updating js-sys                     v0.3.99  -> v0.3.100
Updating regex-syntax               v0.8.10  -> v0.8.11
Updating regex                      v1.12.3  -> v1.12.4
Updating s2n-codec                  v0.81.0  -> v0.82.0
Updating s2n-quic-core              v0.81.0  -> v0.82.0
Updating s2n-quic-crypto            v0.81.0  -> v0.82.0
Updating s2n-quic-platform          v0.81.0  -> v0.82.0
Updating s2n-quic-rustls            v0.81.0  -> v0.82.0
Updating s2n-quic-tls-default       v0.81.0  -> v0.82.0
Updating s2n-quic-tls               v0.81.0  -> v0.82.0
Updating s2n-quic-transport         v0.81.0  -> v0.82.0
Updating s2n-quic                   v1.81.0  -> v1.82.0
Updating uuid                       v1.23.2  -> v1.23.3
Updating wasm-bindgen-futures       v0.4.72  -> v0.4.73
Updating wasm-bindgen-macro-support v0.2.122 -> v0.2.123
Updating wasm-bindgen-macro         v0.2.122 -> v0.2.123
Updating wasm-bindgen-shared        v0.2.122 -> v0.2.123
Updating wasm-bindgen               v0.2.122 -> v0.2.123
Updating web-sys                    v0.3.99  -> v0.3.100
Updating zerocopy-derive            v0.8.50  -> v0.8.52
Updating zerocopy                   v0.8.50  -> v0.8.52
2026-06-10 22:13:23 +02:00
ddidderr 47ef87748f test(peer-cli): align scenarios with catalog versions
Remote aggregation now filters to catalog-version roots, but the checked-in
peer-cli fixtures and skew scenarios still stamped synthetic future versions.
That hid fixture rows in S3 and left scenario docs asserting latest-version
behavior.

Teach the harness the catalog versions for fixture game IDs, stamp generated
fixtures with catalog versions by default, and update skew, mesh, propagation,
and throughput scenarios to expect only catalog-version peers. Also wire S38
into the executable matrix so the documented first-play launch-setting scenario
is covered by the same full run as S1-S47.

This keeps stale peers as negative coverage: they are absent from list-games and
cannot provide descriptors, votes, or chunks. The fixture version.ini updates
are checked in so alpha, bravo, charlie, and persona roots advertise
downloadable catalog games again.

Test Plan:
- python3 -m py_compile
  crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S3 S8 S14 S15 S16 S17 S21 S22 S23 S24 S29 S30 S31 S34 S36 S37 \
  S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S38
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --check
- git diff --cached --check

Docs: PEER_CLI_SCENARIOS.md
2026-06-08 07:06:21 +02:00
ddidderr f62515451b feat(ui): label streamed installs as not shareable
NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be
clear to users. Keep streamed installs in the installed visual state so
sorting, filters, and the primary Play action stay unchanged, but make the
sharing limitation visible in the UI.

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

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

Refs: NEXT_STEPS.md item 7
2026-06-07 22:29:26 +02:00
ddidderr 9288fda037 test(peer-cli): expand streamed install edge coverage
NEXT_STEPS item 6 called for the remaining streamed-install edge cases to
be covered in the peer-cli matrix. Add S43-S47 for already-installed
rejection, corrupt archive rollback, sender disconnect, receiver cancel,
and sorted multi-archive streaming.

The receiver-cancel scenario needs the harness to drive the same runtime
path as the GUI, so `lanspread-peer-cli` now accepts a narrow
`cancel-download` command that forwards to `PeerCommand::CancelDownload`.
A parser test covers the new JSONL command shape.

Add `fixture-multi/cnctw`, a tiny two-archive RAR fixture. S47 uses it to
prove streamed installs process root `.eti` archives in sorted order and
commit only extracted `local/` payloads, not the root archives or
`version.ini` sentinel.

Test Plan:
- just fmt
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S43 S44 S45 S46 S47 --build-image
- just test
- just clippy
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 6
2026-06-07 22:26:49 +02:00
ddidderr 88bfaeb04a test(peer-cli): cover streamed retry fallback
NEXT_STEPS item 5 needs streamed installs to have an explicit retry
policy. The handler already retries whole-stream attempts across the
majority-validated peer set, so add S42 to prove that behavior with the
Docker harness instead of leaving it implicit.

S42 starts two catalog-version-matching `cnctw` sources. The first source
sorts first in retry order but has `--unrar /missing-unrar`, so its stream
attempt fails before sending chunks. The second source then completes a
fresh whole-stream attempt. The scenario asserts local-only installed
state, no root archive or sentinel, no `.local.installing` staging
leftover, chunk events only from the good source, matching streamed byte
count, and SHA-256 payload equality against the good source's `unrar p`.

This pins the current policy: retry the entire stream from another
validated peer, do not preserve partial files across attempts, and do not
promise byte-offset resume.

Test Plan:
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S42
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 5
2026-06-07 22:14:41 +02:00
ddidderr bb7497c0ff refactor(peer): name streamed integrity boundary
NEXT_STEPS item 4 needed the streamed-install integrity model to be a
conscious decision. Keep the current runtime behavior, but name it as
sender archive integrity: the receiver verifies streamed file size and
RAR CRC32 from the sender's archive metadata before committing the
install transaction.

This protects against truncation, transport corruption, and stream
provider bugs. It deliberately does not claim malicious-peer protection,
because the sender controls both the streamed bytes and the RAR metadata.
The docs now say that trusted content requires a future catalog schema
with catalog-owned archive or extracted-file SHA-256 hashes.

Test Plan:
- just fmt
- just test
- just clippy
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 --build-image
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 4
2026-06-07 22:05:03 +02:00
ddidderr 0e970dcec7 test(peer-cli): cover solid streamed installs
NEXT_STEPS item 3 needed solid archive handling to be a deliberate
contract instead of an incidental RAR header attribute. Add a tiny real
solid RAR fixture and S41 to the extended peer-cli scenarios so the
Docker harness proves this path end to end.

The scenario verifies the source archive with container-bundled
`unrar lt`, streams the install with the injected provider, and then
asserts the receiver is installed local-only without a root archive or
root `version.ini`. It also compares local payload SHA-256 hashes against
`unrar p` output and checks the streamed byte count matches the extracted
entries. This keeps the existing one metadata pass plus one sequential
payload pass contract covered for solid archives.

Test Plan:
- just fmt
- just test
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 --build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 3
2026-06-07 22:00:21 +02:00
ddidderr c313f7c9ae docs(stream-install): mark one-pass provider complete
NEXT_STEPS item 2 still described the old per-file unrar provider shape even
though the current shared provider now performs one technical listing pass and
one sequential unrar payload pass per archive. Update the roadmap so the next
implementation slice starts at the remaining solid-archive policy work instead
of chasing an already-replaced extraction loop.

The item 3 wording now keeps the solid/non-solid archive fork explicit without
suggesting the current provider still needs to be swapped merely to avoid
per-file extraction.

Test Plan:
- git diff --check

Refs: NEXT_STEPS.md item 2
2026-06-07 21:40:48 +02:00
ddidderr 40697a73e5 feat(tauri): add low-disk streamed install action
NEXT_STEPS item 1 called out that streamed install was still CLI-only
because the Tauri app started the peer with no stream provider. Users can now
choose an explicit "Low disk install" action from the game detail modal for
remote-only games instead of taking the default archive-preserving download
path.

The GUI command queues a normal peer detail fetch first so the peer database
has the file metadata needed for source validation. A small pending handoff in
Tauri routes the resulting GotGameFiles event into StreamInstallGame instead
of DownloadGameFiles, and clears that pending state on no-peer or download
failure events. This keeps the existing download continuation untouched for
the default action.

The external unrar stream provider moved from the CLI harness into
lanspread-peer so CLI and Tauri use the same implementation. Tauri resolves
the bundled unrar sidecar path and injects that provider at peer startup;
falling back to the noop provider keeps peer startup alive if the sidecar
cannot be resolved, while the streamed install operation still fails safely.

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

Refs: NEXT_STEPS.md item 1
2026-06-07 21:39:02 +02:00
ddidderr 389511f620 remove NEXT_STEPS_CLAUDES_REVIEW.md, it has been applied 2026-06-07 21:22:21 +02:00
ddidderr 5dd356eca8 fix(stream-install)!: stream archive payloads as raw frames
Streamed installs were sending FileChunk payloads through the shared JSON
Message impl. serde_json serializes bytes as arrays of integers, which
bloats wire traffic and burns CPU on large archives. Replace
StreamInstallFrame encoding with tagged frames: JSON control frames keep
their shape under tag 0, while file chunks carry raw bytes under tag 1.

The stream install metadata now carries unpacked archive size and mandatory
CRC32. The CLI unrar provider validates CRCs up front, runs one
archive-wide unrar p stream, splits stdout by listed file sizes, and
refuses trailing or missing bytes. That avoids solid archive
re-decompression and sidesteps unrar wildcard masks for path arguments.

Receivers now sample existing download progress events for streamed
installs, report staging-relative chunk paths, and retry trusted peers with
a fresh streamed-install transaction after a failed attempt. The current
protocol policy does not preserve compatibility with older stream-install
builds.

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

BREAKING CHANGE: StreamInstallFrame now uses tagged frames with raw chunk
payloads and requires current peers on both sides of streamed installs.

Refs: NEXT_STEPS_CLAUDES_REVIEW.md
2026-06-07 21:12:15 +02:00
ddidderr cc147def73 Claude's Review notes 2026-06-07 20:40:33 +02:00
ddidderr 373def6d44 feat(peer): prototype streamed installs
Add a streamed-install prototype that can receive archive-derived install bytes
straight into local/ without first storing the peer-owned root archive payload.
This is intended for low-disk clients that want to install a game but opt out of
becoming a downloadable peer source for that game.

The protocol gains a current-version-only StreamInstall request and framed
StreamInstallFrame responses. The peer core owns the generic transport,
transaction, path validation, size checks, CRC32 verification, and lifecycle
state. The archive-specific work is hidden behind StreamInstallProvider so the
prototype can use unrar while the final implementation can swap in a better
provider without rewriting the peer command path.

The receiver writes into .local.installing and only promotes to local/ after the
full stream verifies. It deliberately does not write the root version.ini or
archive files, so the settled local state is installed=true, downloaded=false,
and availability=LocalOnly. That preserves the existing rule that local/ is not
served to peers and makes streamed receivers non-sources by construction.

The CLI is the only caller for now. It exposes stream-install and provides the
prototype unrar implementation with unrar lt for entry metadata and unrar p for
file bytes. This is simple and good enough to prove non-solid archive streaming,
but it is not the production provider shape for solid archives because per-file
unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes
stream_install_provider: None, so the GUI behavior stays unchanged until a real
product path is designed.

Document the production-readiness work in NEXT_STEPS.md. The main follow-up is
to make the provider abstraction final-ish and replace the per-file CLI unrar
provider with a one-pass archive provider, then wire a deliberate GUI low-disk
mode, retry semantics, and broader failure scenarios.

Test Plan:
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
  S39 S40 --build-image
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- git diff --check
- git diff --cached --check

Follow-up: NEXT_STEPS.md
2026-06-07 20:32:05 +02:00
ddidderr 8a8437036d fix: justfile default must be "run" not "setup" 2026-06-07 19:19:03 +02:00
ddidderr 639a06e224 deps: deno update --latest 2026-06-07 19:18:37 +02:00
ddidderr 27a054fcb7 cleanup: remove unnecessary THEPLAN.md 2026-06-07 19:18:16 +02:00
ddidderr 72659eab2e [deps] cargo update
Updating bitflags v2.12.1 -> v2.13.0
Updating hashlink v0.11.0 -> v0.11.1
2026-06-07 19:02:10 +02:00
ddidderr 9d14e63613 fix: harden application log viewer
Add an Application Logs window backed by a bounded persistent main log file.
The viewer loads history from lanspread.log, subscribes to live INFO/WARN/ERROR
log events, supports filtering/copy/pause controls, and keeps the menu/window
routing separate from the unpack log viewer.

The backend sink now owns serialized access to the log file. History reads and
append-time trimming use the same sink lock, so opening the logs window cannot
race with a concurrent write and rewrite away a freshly appended line. The sink
also keeps a persistent file handle instead of reopening the file for each
captured event.

Live log events carry sink-local sequence ids. The frontend uses the history
watermark plus returned history line counts to suppress live events that were
already included in the history response, while preserving buffered rows that
were trimmed out of the history file. Auto-scroll now follows the last visible
row identity, so it continues following after the in-memory cap keeps the row
count stable.

No timestamp code change was needed. On the Linux dev host, a temporary probe
showed time::OffsetDateTime::now_local() returning +02:00 while UTC was +00:00,
matching the host CEST offset.

Test Plan:
- just fmt
- just frontend-test
- just test
- just clippy
- just build
- git diff --cached --check
- temporary Linux probe of OffsetDateTime::now_local() showed local +02:00

Refs: none
2026-06-07 18:59:05 +02:00
ddidderr d63d4b9c2f docs: add setup command and refresh README
Add a project-level setup recipe for the initial development bootstrap. The
recipe installs the Tauri CLI and resolves the Tauri frontend dependencies from
the Deno lockfile so new contributors have one command before running or
building the app.

Refresh the README to keep it short and focused on what Lanspread is, how to
bootstrap it, and the most important just recipes.

Test Plan:
- just --summary
- just --dry-run setup
- git diff --check
- git diff --cached --check
2026-06-05 09:56:42 +02:00
ddidderr 6ef286e535 deps: deno update --latest 2026-06-05 09:21:40 +02:00
ddidderr 0bd9e76e34 deps: cargo upgrade 2026-06-05 09:21:00 +02:00
ddidderr febde452fb fmt: add tombi format, run just fmt again 2026-06-05 09:19:23 +02:00
ddidderr 06398fe298 fix(peer): reject transfer paths outside requested game
Inbound file-transfer requests carry both a game ID and a relative path. The
serve gate validated whether the requested game was currently servable, but it
did not require the path itself to be rooted under that same game. A
non-conforming peer could therefore register a guard for one game while asking
to read files from another game root.

Require normalized transfer paths to start with the requested game ID before the
file can be dispatched. This keeps the outbound transfer guard, serve policy,
and filesystem path aligned. Absolute, traversal, local-data, missing-sentinel,
active-operation, and wrong-version paths remain rejected by the existing gates.

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

Refs: Claude review finding #4
2026-05-30 16:36:59 +02:00
ddidderr 9b700c7e3f fix(peer): bound outbound transfer drain waits
Update and remove-download operations must wait for existing outbound readers to
release game files before mutating or deleting the game root. That wait was
unbounded, so a stuck transfer guard could leave the game permanently marked as
Updating or RemovingDownload and prevent the requested operation from ever
starting.

Return a structured begin-operation result and put a five-second timeout around
the drain wait. If the transfer count does not reach zero, the operation start
fails, the active-operation snapshot is cleared, and the caller emits the
normal failure event for the attempted operation. The destructive mutation is
not allowed to proceed after a timeout.

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

Refs: Claude review finding #3
2026-05-30 16:36:59 +02:00
ddidderr 7e40cf4bfb fix(ui): coalesce outbound transfer list refreshes
Every outbound transfer start and finish can arrive on a hot path while a peer
is serving many file chunks. The Tauri event handler used to rebuild and emit
the full games list for each edge, cloning all games and probing per-game server
script files repeatedly during an active serve.

Batch outbound-transfer count changes behind a short scheduled refresh. The
peer still records exact counts in shared state, and the delayed refresh reads
that state once per burst. A generation counter keeps changes that arrive while
an emit is already scheduled from being lost; they trigger one follow-up emit
with the latest counts.

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

Refs: Claude review finding #2
2026-05-30 16:36:59 +02:00
ddidderr f89ff9ceea fix(peer): reset cancelled outbound file streams
Cancelled outbound transfers previously returned from the streaming loop without
terminating the QUIC send half. A whole-file receiver relies on the stream
ending to distinguish EOF from an in-progress body, so cancellation could leave
it waiting on a truncated transfer until its own timeout fired.

Reset the send stream on every cancellation branch, including cancellation
while waiting for the final close acknowledgement. A reset is deliberately used
instead of a graceful close so truncated whole-file transfers cannot be
misinterpreted as a valid EOF.

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

Refs: Claude review finding #1
2026-05-30 16:36:58 +02:00
ddidderr 738095235f feat(peer): coordinate outbound transfers with local game mutations
Updating or removing a local game rewrites its on-disk files. Peers that
were mid-download of that game would keep streaming bytes from files that
are being deleted or replaced, handing them a corrupt or stale copy.
There was also no authoritative notion of which game version a peer
should serve or accept, so a peer could serve whatever happened to be on
disk and downloaders could aggregate files from peers running mismatched
versions.

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

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

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

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

Test Plan:
- just test
- just frontend-test
- just clippy
2026-05-30 16:36:58 +02:00
ddidderr 18f21bdf30 fix(launch): stamp first-use settings on every launch path
First-use launch settings moved out of install/update transactions, but three
edge cases could still leave archive stub values in place while the marker said
the settings pass had already happened.

Start Server now runs the same stamping preflight as Play before launching
server_start.cmd. That covers games whose server scripts read account_name.txt,
language.txt, or SmartSteamEmu.ini before the user ever presses Play.

Install and update now reset the per-game marker before committing
InstallIntent::None. Recovery also clears the marker for install/update states
where the new local tree has already landed, so a crash after promotion cannot
publish a clean intent while preserving a stale marker. Rollback recovery keeps
the marker, because the old local tree remains the active install.

SmartSteamEmu.ini stamping now searches every matching file until one contains a
PersonaName line. This keeps a decoy or incomplete INI from permanently blocking
the real one while still preserving early-exit behavior for account_name.txt and
language.txt, which only need the first matching file.

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

Refs: local review findings
2026-05-29 06:52:00 +02:00
ddidderr 09709cc008 feat(peer): stamp launcher settings on first play, add PersonaName rewrite
Some games ship a SmartSteamEmu.ini somewhere under their installed
local/ tree with a `PersonaName = ...` line that must carry the player's
configured username. They also ship account_name.txt and language.txt
files that the launcher already overwrote with the username/language.

Previously that account_name.txt/language.txt overwrite happened inside
the install transaction, so it only applied to freshly (re)installed
games — games already installed by an older build never got fixed up,
and the SmartSteamEmu.ini PersonaName line was not handled at all.

This moves all per-user setting application out of install and into a
single one-shot step performed the first time a game is played, gated by
a new per-game marker `games/<id>/launch_settings_applied` under the
state dir. On first play we search the whole local/ tree and stamp:

  - the username into the first account_name.txt,
  - the language into the first language.txt,
  - the username into the first SmartSteamEmu.ini PersonaName line,
    preserving that line's existing line ending (\n or \r\n) and its
    surrounding whitespace, leaving sibling lines untouched.

The marker only records that we *tried*: it is written unconditionally
after the first play, so a game with none of these files is still marked
done and never rescanned. Because already-installed games have no marker
yet, they are fixed up on their next play rather than only on reinstall.

To keep the marker honest across version changes, the install and update
transactions now clear it on success, so a freshly extracted local/ is
re-stamped on the next play.

Behavior changes from the user's perspective:
  - The first time you press Play after this change, your username/
    language are (re)applied to an existing install, including games you
    installed before this feature existed.
  - SmartSteamEmu.ini's PersonaName now reflects the launcher username.

Plumbing: account_name/language are removed from PeerCommand::InstallGame
/DownloadGameFiles[WithOptions] and the whole install handler chain, and
the Tauri pending_install_settings bookkeeping is gone — the launcher now
computes the values at play time in run_game and calls
lanspread_peer::apply_launch_settings_once. The headless harness gains a
`play` command exposing the same step for scripted testing.

Test Plan
  - just test: new lanspread_peer::launch_settings unit tests cover the
    PersonaName rewrite, \n/\r\n preservation, first-match search, the
    unconditional marker, and the no-op-once-applied path; a transaction
    test covers the install marker reset. Whole workspace is green.
  - just clippy clean; the change adds no new clippy warnings (incl.
    --tests).
  - S38 (new in PEER_CLI_SCENARIOS.md): host run of lanspread-peer-cli
    against the new fixture-persona/css RAR .eti (with --unrar) installs
    css, then `play css` stamps the deeply-buried CRLF PersonaName line,
    account_name.txt, and language.txt and creates the marker; a second
    `play` is a no-op even after the values are reset externally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:51:59 +02:00
ddidderr 9bafd981d7 feat(install): write launcher language marker files
Some games include a language.txt marker in the unpacked local tree, similar
in spirit to account_name.txt. Installs and updates now carry the launcher
language alongside the account name so those game-provided marker files are
rewritten before staged files are promoted into local/.

The Tauri command boundary keeps the UI setting vocabulary as de/en, then maps
it to the file vocabulary expected by games: german or english. Unknown values
continue through the existing DEFAULT_LANGUAGE path, so the marker file falls
back to english just like script launch arguments fall back to en.

The transaction layer deliberately reuses the same first-match traversal helper
for both marker files. The searches stay independent, so games may place
account_name.txt and language.txt in different directories if their archive
layout requires that.

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

Refs: none
2026-05-21 22:24:59 +02:00
ddidderr e06a887da1 fix(ui): strip literal br tags from game descriptions
Some ETI game descriptions include the literal string <br> in metadata.
React renders descriptions as text, so the marker appears to users instead
of being treated as a line break.

Strip only the exact <br> token at the detail modal boundary. This keeps
the fix UI-only and avoids treating descriptions as HTML or normalizing any
other markup-like text.

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

Refs: none
2026-05-21 21:56:42 +02:00
ddidderr 574acfca45 feat(install): stamp username into account_name.txt after install
Some games ship an `account_name.txt` file somewhere under the unpacked
`local/` tree (location varies per game). After install or update, write
the configured username into the first such file we find so the game
launches under the user's account instead of whatever default the
archive contains.

The search is a deterministic alphabetical DFS rooted at the install
staging dir (`.local.installing/`, which becomes `local/` on rename),
stopping at the first regular-file match. Symlinks named
`account_name.txt` are skipped (`is_file()` is false for symlinks on
Linux), so a hostile archive can't redirect the write outside the game
tree. If no `account_name.txt` exists anywhere in the install, the step
is a no-op. If the write fails, the existing install rollback (cleanup
of staging on fresh installs, restore from backup on updates) handles
it — no partial state is left behind.

The username flows from the Tauri layer, where it is already sanitized
by `sanitize_username`, down through `PeerCommand` variants
(`InstallGame`, `DownloadGameFiles`, `DownloadGameFilesWithOptions`)
into `install`/`update`, which now take an `Option<&str> account_name`.
For the "install game that isn't downloaded yet" path the username has
to bridge the async gap between the `GetGame` / `FetchLatestFromPeers`
request and the eventual `GotGameFiles` event; we park it in a
per-game-id map on `LanSpreadState` and pop it when forwarding the
download command. The map is also cleared defensively on
`cancel_download`, `DownloadGameFilesFailed`, and
`DownloadGameFilesAllPeersGone` so a stale entry can't bleed into a
subsequent install with a different username.

`PeerCommand` is the in-process command channel, not the wire protocol;
no on-wire types changed, so the "one wire version" policy is
preserved. The peer-cli harness keeps passing `account_name: None`
since it tests peer interop, not user-facing settings.

# Test Plan

Unit tests in `crates/lanspread-peer/src/install/transaction.rs`:
- `install_overwrites_first_account_name_file` — unpacker creates
  `a/account_name.txt` and `z/account_name.txt`; after install with
  username "Alice", `a/` is overwritten and `z/` is left untouched,
  pinning the sorted-DFS "first match wins" behavior.
- `install_account_name_missing_file_is_noop` — install with a
  username but no `account_name.txt` anywhere in the archive
  succeeds and creates no spurious file.

Manual GUI check: in Settings, set a username; install a game whose
archive contains `account_name.txt`; open `local/` and confirm the
file now holds the configured username. Repeat for the update flow
(install, change username, click update).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:32:29 +02:00
ddidderr 2e7a0cff2f feat(ui): move game folder picker into settings
The design update moved game-folder configuration out of launcher chrome and
into Settings > Library. Follow that contract in the runtime UI without
changing the existing storage or Tauri directory commands.

The top bar now leaves its right edge for the kebab menu. Settings owns a new
Game folder row that shows a valid selected path with a neutral Change button,
or the red Not set state with a stronger Choose button when no accessible
directory is configured. Both the empty-library state and the Settings row
still use the existing native directory picker, so existing saved paths and
rescans keep their current behavior.

Keep useGameDirectory as the directory-state owner and expose the shared
hasGameDirectory boolean from that hook so the grid and Settings field agree on
what counts as configured.

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

Refs: 62b409f4bfc4995c25461776107d28f52b24f30e
2026-05-21 21:32:29 +02:00
ddidderr eedfc0105d feat(tauri): persist unpack logs and clean sidecar output
Unpack logs lived only in memory, so closing the app dropped history.
Unrar progress also flooded stdout with carriage-return redraws, which
made the log viewer noisy and hard to search.

Persist the last twenty entries to unpack-logs.json under the app data
directory, load them on startup, and rewrite stdout/stderr through a
small terminal-sequence cleaner (CR/LF, backspace, control chars) before
storage and display. Sort the unpack-logs window newest-first by finish
or start time.

Test plan:
- cargo test -p lanspread-tauri-deno-ts -- terminal_log unpack_log
- Run an unpack, restart the app, open unpack logs: prior entries remain
- Confirm progress lines collapse to final text instead of spam

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 21:32:28 +02:00
ddidderr 19ae1938f6 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 21:32:28 +02:00
ddidderr a7d99261cf fix(ui): reserve search clear button space
Typing the first character into the launcher search field used to insert the
clear button into the flex row, which widened the field after focus. The clear
button now stays mounted in the row so its slot is part of the empty field's
layout, while the empty state hides and disables it.

Users now get the wider steady-state search width immediately, and the control
does not jump when the clear affordance becomes visible.

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

Refs: user-reported search field jump
2026-05-21 21:32:28 +02:00
ddidderr debb1c0c49 feat(ui): focus search with Ctrl+F
Ctrl+F is a common search shortcut and should open the launcher search just
like the existing slash shortcut. Handle it in the same SearchField keydown
listener so the behavior stays scoped to the topbar search component.

The shortcut is ignored while the user is already typing in another input or
textarea, matching the existing slash behavior. When handled, it prevents the
webview's browser find UI and focuses the app search field instead.

Test Plan:
- `just frontend-test`

Refs: none
2026-05-21 21:32:28 +02:00
ddidderr 0151d7a16c style(ui): match game-folder topbar design
Follow the latest top-bar spec for the Game Folder control. The button stays
as only the folder icon and short label, with the full path in tooltip and aria
text, and now sits in the app-level control group with the kebab menu on the
far-right edge of the wide top bar.

This keeps Sort as the right-zone lead control next to the centered search
cluster while treating Game Folder and the kebab menu as a tight trailing pair.
The narrow fallback still flows in source order: sort, game folder, kebab.

Test Plan:
- git diff --check
- rg -n "dirbtn-status-dot|status dot|green status|red status" crates/lanspread-tauri-deno-ts/src
- just frontend-test
- just build
2026-05-21 21:32:28 +02:00
ddidderr 31ace174e3 fix(ui): treat missing game folders as unset
Validate the persisted game directory before sending it to the backend or
showing library content for it. When the saved path no longer exists, the
launcher keeps the top bar visible but shows the folder picker empty state
and labels the Game Folder button as an unset folder.

This keeps stale local data from being presented as the active library when
an old path is deleted or disconnected.

Test Plan:
- git diff --check
- just frontend-test
- just build
2026-05-21 21:32:28 +02:00
ddidderr e0efb69bf0 design docs: no green/red light on Game folder 2026-05-21 21:32:28 +02:00
ddidderr 059c1e7720 feat(ui): redesign game-folder button as icon + label + dot
Implements the v2 design's game-folder button in the Tauri launcher.
The previous control squeezed the full game-directory path into the
top-bar button as truncated monospace (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading
ellipsis rarely showed the meaningful part of the path, ate
horizontal space the new 3-zone top bar needs for its primary
controls, and competed with the filter / search / sort cluster for
attention.

The button now communicates the *state* of the configuration at a
glance — an icon + short label + colored status dot — while the full
path moves into the native tooltip and `aria-label`, where it stays
one mouseover (and screen-reader friendly) away.

Two visual states, both 36 px tall and sharing the surface of the
other top-bar controls:

- **Set & valid** (`.dirbtn-set`) — label `Game folder`, green dot
  (`--ok`) with a soft glow, default border, tooltip = full path.
- **Not set / invalid** (`.dirbtn-unset`) — label `Set game folder`,
  red dot (`--danger`) with a soft glow, a red-tinted border, and a
  faint red wash on hover so the bad state reads as "this is what
  you need to fix". Tooltip = `Please select a game folder`.

`DirectoryButton` now takes `path: string | null` and picks the
state from `!!(path && path.trim())`. The `aria-label` carries the
full state in words (`Game folder: <path>` / `Set game folder`) so
screen readers don't have to interpret the colored dot.

Note: in the current `MainWindow`, `gameDir` is gated upstream — if
no directory is selected, `NoDirectoryState` is shown instead of the
top bar, so the unset state will only surface here if we later
validate disk existence and clear `gameDir`. The button accepts a
nullable path anyway, so it's ready when that check lands.

`truncatePath` in `lib/format.ts` was the only caller-less helper
left behind and is removed.

Test Plan

- `npx tsc --noEmit` from the frontend crate — clean.
- `just frontend-test` — passes.
- Manual: `just run`, pick a valid game directory, and confirm the
  top-bar button reads `Game folder` with a green dot; hovering it
  reveals the full path in the OS tooltip. Inspect the DOM and
  confirm `aria-label` reads `Game folder: <path>`. (The unset
  variant currently isn't surfaced by `MainWindow`; eyeball it via
  DevTools by toggling the `dirbtn-set` class to `dirbtn-unset`.)
2026-05-21 21:32:28 +02:00
ddidderr 095bc9b9ff feat(ui): center search in a 3-zone top bar
Implements the v2 design's top-bar reorganization in the Tauri
launcher. The bar was previously a flat flex row that let the search
field drift left or right depending on filter / sort widths; now it's
a 3-column CSS grid with the search field pinned to the geometric
center of the window.

- `.topbar` becomes `display: grid` with
  `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` and a
  16 px column gap. The middle (auto) column holds only the search,
  capped at `flex: 0 1 360px` so it cannot push into the side columns.
- The left zone is `flex; justify-content: space-between`: brand
  pins far-left, filter pills hug the search. The filter pills are
  now grouped with the search semantically (they scope it) instead of
  floating next to the brand.
- The right zone mirrors that: sort hugs the search, kebab pins
  far-right, with the directory button between them.
- A `@container launcher (max-width: 1100px)` rule collapses the
  layout back to a single nowrap flex row at narrow widths — the
  geometric centering doesn't read at small widths and would force
  awkward truncation, so we abandon it rather than fight it. The
  launcher root opts into container queries via
  `container-type: inline-size; container-name: launcher`.

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

Test Plan

- `just frontend-test` — passes.
- `npx tsc --noEmit` from the frontend crate — clean.
- Manual: run `just run`, confirm the search input's horizontal center
  matches the window's horizontal center across the standard launcher
  width. Shrink the window below 1100 px and confirm the row collapses
  to a single left-to-right strip with no overlap or wrapping.
2026-05-21 21:32:28 +02:00
ddidderr b169a05c31 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 21:32:28 +02:00
ddidderr 6e28f736e8 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 21:32:28 +02:00
ddidderr 8d96d99160 fix(ui): keep file viewer visible while installing
The detail view already exposes View Files while a game is downloaded,
installed, or actively downloading. During the download-to-install handoff,
the backend can report Installing before the local downloaded/installed flags
have settled in the next snapshot, which briefly hid the file viewer button.

Treat Installing as another state where the game root should remain reachable.
This keeps the detail view stable during the handoff without changing backend
file-opening behavior.

Test Plan:
- just frontend-test
2026-05-21 21:32:28 +02:00
ddidderr a913e4c776 feat(settings): show automatic build number
The launcher settings dialog now shows a Build-Nr value in its footer so users
can identify the exact application build without manually updating a number
before release.

The value is generated by Vite when the frontend bundle is built and injected
as an import.meta.env constant. Using the current millisecond timestamp keeps
the number numeric and monotonic for normal builds without a checked-in counter
file or any extra build-state mutation. The tradeoff is that the value follows
the build machine clock rather than a central sequence service.

The footer layout now keeps the build number on the lower left and the Done
button on the lower right.

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

Refs: local user request
2026-05-21 21:32:28 +02:00
ddidderr a5307d3d6a 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 21:32:28 +02:00
ddidderr 9835e77e8d feat: store launcher state outside game dirs
Move launcher-owned metadata from game roots into the configured peer state
area. Peer identity, the local library index, install intent logs, and setup
markers now live under app/CLI state instead of being written beside games.
The Tauri shell passes its app data directory into the peer, and the peer CLI
runs the same path through its explicit --state-dir.

Add a dedicated pre-start migration phase for legacy files. It migrates the
old global library index, per-game install intents, and the old first-start
marker into app state, then deletes legacy files only after the replacement
write succeeds. Normal scan, install, recovery, and transfer paths no longer
read legacy state files.

Rename the old first-start meaning to setup_done and only set it after
launching game_setup.cmd. Start/setup scripts keep the shared argument shape,
while server_start.cmd now uses cmd /k and a visible window so server logs stay
open for inspection.

While validating the Docker scenario matrix, make download terminal events
come from the handler after local state refresh and operation cleanup. This
makes download-finished/download-failed safe points for immediate follow-up CLI
commands. Also update the multi-peer chunking scenario to use a sparse archive
large enough to actually span multiple production chunks.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py

Refs: local app-state migration discussion
2026-05-21 21:32:28 +02:00
ddidderr 4f34c4a249 feat: pass profile settings to launch scripts
Add launcher profile settings for username and language, then thread those
values into the Windows script launch path. The game setup, game start, and
server start scripts now share the same argument shape:

- game path: local
- game id
- language: en or de
- player name

Expose a local can_host_server flag in the games payload by checking for
server_start.cmd in an installed game's root directory. The detail modal uses
that flag to show Start Server only for installed games with the script, and
the new start_server command invokes server_start.cmd with the same sanitized
settings used by game_setup.cmd and game_start.cmd.

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

Refs: design/README.md
2026-05-21 21:32:28 +02:00
ddidderr 91c709960a docs: update launcher design for profile and server actions
Document the profile settings added to the launcher design and the new
Start Server detail action. The settings contract now includes a persisted
username and language choice, and the game detail overlay shows Start Server
only for installed games that can host a dedicated server.

The reference mock now includes the matching Profile controls, a server icon,
server-capable sample catalog entries, and the updated detail/settings
artboards so implementation can follow the selected design direction.

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

Refs: design/README.md
2026-05-21 21:32:27 +02:00
ddidderr 12a0d7abe9 feat(ui): align peer count chip with design reference
Mirror the design-doc update in the actual download progress component
so the GUI matches the trimmed chip. Previously the large progress
panel rendered `[icon] from N peer(s)` inline; now it renders just
`[icon] N`, with the full "Downloading from N peers on the LAN"
sentence retained as the `title` tooltip for discoverability.

Changes:
- `DownloadProgress.tsx` (lg variant): drop the "from" / unit text from
  the inline span, keeping only the count. `peerUnit` stays in scope
  because the tooltip still needs singular/plural.
- `launcher.css`: collapse `.dl-peers` and `.dl-peers strong` into a
  single rule that puts the t-1 colour, 600 weight and tabular-nums
  directly on the chip (the inner `<strong>` no longer exists). Gap
  drops from 5px to 4px to match the tighter icon+number layout.
- Container queries: peers drops at <=240px and ETA drops at <=320px,
  matching the new thresholds in the design reference. The narrower
  chip simply fits in smaller modals, so the old 300/380 cutoffs were
  hiding stats that would have rendered fine.

Test Plan
- `just frontend-test` (passes)
- `just run`, start a download, confirm the chip reads `[icon] N`,
  hover shows the tooltip, and narrowing the window collapses ETA
  before peers at the new breakpoints.
2026-05-21 00:41:10 +02:00
ddidderr b96e8c5747 docs(design): trim peer count chip to icon + bare number
The download progress "peers" chip previously rendered as
`[icon] from N peers` — a full mini-sentence sitting between the speed
and ETA stats. With four groups already separated by middots, the extra
preposition and unit made the row read as prose rather than a stat
strip, and the singular/plural switch added a second source of layout
jitter on top of the digit-width change.

Update the design reference (README, components.jsx, styles.css) so the
chip shows just the users glyph and the count, matching the visual
weight of the other stat groups. The full sentence
("Downloading from N peers on the LAN") moves to the `title` tooltip,
which keeps the affordance discoverable without spending row width on
it. The count adopts `var(--t-1)` + 600 + tabular-nums directly on
`.dl-peers` (no inner `<strong>` needed), so the chip is a single span.

Also tighten the container-query breakpoints. Removing the prose makes
the chip much narrower, so the previous 300px cutoff for hiding peers
and 380px cutoff for hiding ETA were over-eager — both stats now fit
comfortably in narrower modals. Drop them to 240px (peers) and 320px
(ETA). The pct/cancel column still never collapses.

Test Plan
- Visual review of the design reference HTML at component widths
  240px / 320px / 380px to confirm peers and ETA drop at the new
  thresholds rather than the old ones.
- Confirm the chip's tooltip still spells out the full sentence.
2026-05-21 00:40:54 +02:00
ddidderr 8acb6dc246 feat: show active download peer count
Render the active peer count already carried by download progress events in the
large download progress control. The peer chip appears between speed and ETA,
uses singular/plural copy, and hides after the ETA when the detail modal gets
very narrow.

This keeps the UI aligned with the design reference without changing backend
state ownership or download progress plumbing.

Test Plan:
- git diff --check
- git diff --cached --check
- just frontend-test
- just build
2026-05-21 00:32:57 +02:00
ddidderr f1e915c379 docs: document download peer count chip
Update the launcher design reference so active downloads show how many LAN
peers are currently seeding the transfer. The reference now places the peer
chip between speed and ETA, describes the singular/plural copy, and records
how the ETA and peer count collapse in narrow modal layouts.

Test Plan:
- git diff --cached --check
2026-05-21 00:30:57 +02:00
ddidderr b56f4e2757 feat(peer): expose active download peer count
The launcher needs design work later for showing how many peers are currently
feeding an active download. Surface that data now on the existing progress
payload so UI state can consume it without a separate event stream or rendering
change.

The peer download tracker now treats each live chunk receive as peer activity
and reports the number of unique peers with in-flight streams. This is a live
transfer count, not the number of peers that advertised the game or received a
plan. Multiple chunks from one peer count once, and the count falls as chunk
streams finish.

Tauri already forwards DownloadGameFilesProgress, so no bridge event was added.
The TypeScript model accepts active_peer_count under download_progress and
preserves it with the same reducer path that keeps bytes and speed while the
backend says the game is still downloading.

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

Refs: none
2026-05-21 00:28:08 +02:00
ddidderr 7e97d6a83a docs(findings): note crash-during-download leaves orphan archives
While reviewing the cancel-cleanup fix (`fix(peer): delete partial files
when a download is cancelled`), it became clear that the new
`discard_cancelled_download` sweep only runs from inside the in-flight
orchestrator. If the process dies mid-download (kill, crash, power loss)
the partial `.eti` archives are left behind: `install::recover_on_startup`
calls `recover_download_transients`, which only removes
`.version.ini.tmp` and `.version.ini.discarded`. The user is left with
a card that looks "downloaded" but with corrupted archives that can only
be cleared via the explicit `Remove files` action.

Closing the gap would mean running the same discard pass during recovery
for any game root whose install intent is `None` and whose `version.ini`
is absent (intent log already distinguishes installed-and-then-broken
from interrupted-download). Not blocking — the user-initiated cancel
button is now correct in its scope; this is the symmetric crash recovery
case captured for a future cleanup pass.

Refs: c380046 (fix(peer): delete partial files when a download is cancelled)
2026-05-21 00:07:24 +02:00
ddidderr c3800461a4 fix(peer): delete partial files when a download is cancelled
Cancelling an in-flight download via `PeerCommand::CancelDownload` previously
torn down the network transfer and cleared `active_downloads`, but left the
partial `.eti` archive(s) sitting in the game root forever. The next library
scan still picked up the half-written files as a "downloaded" game, and the
only escape was the `Remove files` action. This is the symmetric fix to
`62ceb06 feat(peer): remove downloaded game files safely`: the cancel path
must clean up after itself the same way an explicit remove does.

The fix introduces a dedicated `download/storage.rs` module that owns both the
existing pre-allocation step (`prepare_game_storage`, moved out of
`planning.rs` because pure file I/O has no business sitting next to chunk
planning) and a new `discard_cancelled_download` sweep. The orchestrator
calls the sweep at every cancellation exit point, immediately after
`rollback_version_ini_transaction` so the version sentinel transients are
gone before the bulk deletion runs.

The sweep deliberately preserves a known set of names so a cancelled update
of an installed game does not destroy user-extracted files:

  - `local/`                  committed install directory
  - `.local.installing/`,
    `.local.backup/`          in-flight install transaction state, needed by
                              `install::recover_game_root` on next startup
  - `.lanspread.json`         per-game install intent log
  - `.softlan_game_installed` external softlan installer marker
  - `.sync/`                  external sync tooling

Everything else under the game root (the `.eti` archives, any nested payload
directories, partial chunk files) is removed, and the game root itself is
removed if it ends up empty. The set matches `should_ignore_game_child` in
`services/local_monitor.rs` minus the version.ini transients (which the
rollback step removes itself just before the discard runs).

Tradeoff worth knowing: this does NOT restore the pre-update `version.ini`
sentinel. `begin_version_ini_transaction` parks the existing sentinel as
`.version.ini.discarded`, and `rollback_version_ini_transaction` deletes
that file rather than renaming it back. The user-visible consequence is
that cancelling a mid-flight update of an installed game leaves the local
install playable but no longer flagged as "downloaded" — the documented
"settles as local-only" behaviour now recorded in
`crates/lanspread-peer/ARCHITECTURE.md` and `README.md`. Restoring the
sentinel on cancel was considered, but it would mean a cancelled update
keeps advertising the OLD version as Ready, which is worse than the
current outcome.

Two unrelated correctness issues that surfaced while threading cancellation
through the orchestrator are bundled in here because they belong to the
same user-visible "Cancel button works" story:

1. `download_from_peer` now races `connect_to_peer` against
   `cancel_token.cancelled()` (`download/transport.rs:314-322`). Previously
   a cancel arriving while QUIC was still in its connect handshake had to
   wait for the connect timeout to elapse before the cleanup could run.

2. The download task in `handlers.rs` now calls
   `refresh_local_game_for_ending_operation` on every terminal branch —
   success-without-install, install-handoff-failure, and the `Err(e)` /
   cancel branch — before `end_download_operation` clears
   `active_downloads`. Without this, the UI's settled snapshot on the
   cancel path could lag behind the actual file system state because the
   active-operation snapshot was cleared while the discard was still
   running, leaving a brief window where the card showed the pre-cancel
   state.

What this does NOT fix: a crash (process kill, power loss) during a
download still leaves orphan `.eti` files because `recover_download_transients`
in `install/transaction.rs` only sweeps the version.ini transients. Closing
that gap would mean calling the same discard from startup recovery for any
game root whose install intent is None and whose `version.ini` is absent.
Tracked in `FINDINGS.md` as a follow-up.

Test Plan:
- `just clippy && just test` — 102 unit tests pass, no new warnings.
- Two new storage tests:
  - `discard_cancelled_download_removes_peer_owned_payload` exercises the
    fresh-download cancel (no `local/`, root sweeps clean).
  - `discard_cancelled_download_preserves_local_install_state` exercises
    the update cancel (`local/`, `.lanspread.json`, `.local.backup/`
    survive; `version.ini` and `.eti` go away).
- Manual GUI smoke (operator): start a fresh download of a multi-archive
  game from a peer, click Cancel from the detail modal while the progress
  bar is between 5% and 95%. Expect the game root to be empty (or absent)
  afterwards and no orphan `.eti` files. Repeat against an installed game
  by clicking Update, then Cancel mid-download; expect `local/` contents
  intact and the card to drop back to Play (or Update if the newer-version
  peer is still around).
- `lanspread-peer-cli` has no `cancel` command yet, so the headless
  `PEER_CLI_SCENARIOS.md` matrix does not cover this end-to-end. Adding a
  CLI cancel command + scenario is the natural follow-up.

Refs: 62ceb06 (feat(peer): remove downloaded game files safely)
Refs: b7df2de (fix(download): emit failure events on early-returns and update UI transition)
2026-05-21 00:07:12 +02:00
110 changed files with 9677 additions and 1645 deletions
Generated
+250 -498
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -49,23 +38,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -81,12 +53,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "atk"
version = "0.18.2"
@@ -127,9 +93,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
@@ -189,25 +155,13 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -226,35 +180,11 @@ dependencies = [
"objc2",
]
[[package]]
name = "borsh"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -263,9 +193,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -282,43 +212,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
@@ -347,7 +243,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"cairo-sys-rs",
"glib",
"libc",
@@ -410,9 +306,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.62"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -453,12 +349,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
@@ -472,9 +362,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
@@ -542,7 +432,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"core-foundation",
"core-graphics-types",
"foreign-types",
@@ -555,7 +445,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"core-foundation",
"libc",
]
@@ -799,7 +689,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
@@ -807,9 +697,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@@ -913,9 +803,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
dependencies = [
"serde",
]
@@ -949,16 +839,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1022,15 +902,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1068,6 +939,17 @@ dependencies = [
"spin",
]
[[package]]
name = "flume"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1137,12 +1019,6 @@ dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.32"
@@ -1447,7 +1323,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1568,19 +1444,25 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
"foldhash 0.2.0",
]
[[package]]
@@ -1596,11 +1478,11 @@ dependencies = [
[[package]]
name = "hashlink"
version = "0.10.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
dependencies = [
"hashbrown 0.15.5",
"hashbrown 0.16.1",
]
[[package]]
@@ -1639,9 +1521,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
@@ -1678,9 +1560,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -1918,11 +1800,11 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"inotify-sys",
"libc",
]
@@ -2055,13 +1937,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.98"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -2093,16 +1974,16 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"serde",
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
@@ -2114,7 +1995,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"libc",
]
@@ -2153,6 +2034,7 @@ name = "lanspread-peer"
version = "0.1.0"
dependencies = [
"bytes",
"crc32fast",
"eyre",
"futures",
"gethostname",
@@ -2209,13 +2091,18 @@ dependencies = [
"log",
"mimalloc",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-store",
"time",
"tokio",
"tokio-util",
"tracing",
"tracing-log",
"tracing-subscriber",
"walkdir",
"windows 0.62.2",
]
@@ -2293,27 +2180,27 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.47"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
@@ -2343,12 +2230,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "markup5ever"
@@ -2363,12 +2247,12 @@ dependencies = [
[[package]]
name = "mdns-sd"
version = "0.19.2"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081"
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
dependencies = [
"fastrand",
"flume",
"flume 0.11.1",
"if-addrs",
"log",
"mio",
@@ -2378,9 +2262,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memoffset"
@@ -2393,9 +2277,9 @@ dependencies = [
[[package]]
name = "mimalloc"
version = "0.1.50"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
@@ -2418,9 +2302,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
@@ -2430,9 +2314,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.19.1"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2455,7 +2339,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2485,7 +2369,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"fsevent-sys",
"inotify",
"kqueue",
@@ -2503,14 +2387,23 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
@@ -2588,7 +2481,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2601,7 +2494,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"objc2",
"objc2-foundation",
]
@@ -2622,7 +2515,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"dispatch2",
"objc2",
]
@@ -2633,7 +2526,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -2666,7 +2559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -2693,7 +2586,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
@@ -2706,7 +2599,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
]
@@ -2717,7 +2610,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -2729,7 +2622,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -2760,7 +2653,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"block2",
"objc2",
"objc2-app-kit",
@@ -2965,7 +2858,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
@@ -3038,7 +2931,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.11+spec-1.1.0",
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -3074,26 +2967,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -3124,12 +2997,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3138,22 +3005,11 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha 0.2.2",
"rand_chacha",
"rand_core 0.5.1",
"rand_hc",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.1"
@@ -3175,16 +3031,6 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3194,15 +3040,6 @@ dependencies = [
"getrandom 0.1.16",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -3230,7 +3067,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
]
[[package]]
@@ -3266,9 +3103,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.3"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
@@ -3289,24 +3126,15 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.13.3"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3374,52 +3202,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust_decimal"
version = "1.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.6",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3441,7 +3223,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
@@ -3490,17 +3272,11 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s2n-codec"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c081add6c80a35614d55ac1ecac3b0d609de48c7d17c643e9bab0e84e27539"
checksum = "a650d3f187901f3519ec8a1fe7da3faccc0b2fb40f350eda2c7851fdf2bda0f6"
dependencies = [
"byteorder",
"bytes",
@@ -3509,9 +3285,9 @@ dependencies = [
[[package]]
name = "s2n-quic"
version = "1.80.0"
version = "1.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1949f3735d6c7003b74c0519afcddf7800bdc14cd65b03930710511802b4032"
checksum = "c27c34127facefcd3e5530c4de5739a62cd4a593710b1194dacbd8e884b6be92"
dependencies = [
"bytes",
"cfg-if",
@@ -3533,9 +3309,9 @@ dependencies = [
[[package]]
name = "s2n-quic-core"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf79f1e410296ae7bf17a0e877d3ce30cb589c73faf6ea31e9ee61aad486197d"
checksum = "79fbc3f06797d985363f74de105d18554b5a272b924b166d73a6564943da1230"
dependencies = [
"atomic-waker",
"byteorder",
@@ -3555,9 +3331,9 @@ dependencies = [
[[package]]
name = "s2n-quic-crypto"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a6c42fa0e4a5305121fd0d55662614287283b2d54aeae8adecefe72d8d36b55"
checksum = "e58ea5aa39eecc29559d1e1bb4a5d55a747fa7b80cff5a3400c57489510644e3"
dependencies = [
"aws-lc-rs",
"cfg-if",
@@ -3569,9 +3345,9 @@ dependencies = [
[[package]]
name = "s2n-quic-platform"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a161996e9c20557626704b883f8845d04133c0a27b176d1a1ee401932423d2f2"
checksum = "4eebb6007139cfffdf3d473d39f01a214032c339432a6293b16b0f7b25343f40"
dependencies = [
"cfg-if",
"futures",
@@ -3584,9 +3360,9 @@ dependencies = [
[[package]]
name = "s2n-quic-rustls"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68e0dffd40e31fb305e68ab9884a74b1c94949b3e258d02fb6e3a1f1c4305ba0"
checksum = "eb0084afa65eefae2c37d9ab44118a14dfc5bb78dbf997c0f5176f7cf8d2e633"
dependencies = [
"bytes",
"rustls",
@@ -3598,9 +3374,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c085a4e5085a30f796270f383e6b9707e81c3abbdbbf9123ad7577628cf26d4a"
checksum = "91150b25ce824ffea581b449ad04acf9b4aef2fa68a46f667cdc9cc6f7b87823"
dependencies = [
"bytes",
"errno",
@@ -3613,9 +3389,9 @@ dependencies = [
[[package]]
name = "s2n-quic-tls-default"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac276a124e18b980cc8f8483a41a26f42564216f759f7f5822a8c4ffb8af8f04"
checksum = "e1f5ae64863972facee778dc80a24317e613f035296631f267b71f225e569c22"
dependencies = [
"s2n-quic-rustls",
"s2n-quic-tls",
@@ -3623,9 +3399,9 @@ dependencies = [
[[package]]
name = "s2n-quic-transport"
version = "0.80.0"
version = "0.82.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b6c77ed7cf87296886620917ba760ddb1e042446faa7be876887725c89dab63"
checksum = "3b82fca53ce1734cc1d1dca96cc9ceb65ed528f27cb43b7de865215b6cf17908"
dependencies = [
"bytes",
"futures-channel",
@@ -3641,9 +3417,9 @@ dependencies = [
[[package]]
name = "s2n-tls"
version = "0.3.36"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d22c36f207d0bb6a38272d5d1fa1cd99094335c70db1bfef54e0b8b672ae26f"
checksum = "f60ada8adc59c848686744ff0ca93f1d1edc565a69d96a58a26abd84a521a5a0"
dependencies = [
"errno",
"hex",
@@ -3654,9 +3430,9 @@ dependencies = [
[[package]]
name = "s2n-tls-sys"
version = "0.3.36"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcf9e3b136cdd2c99f0cee2811d80b9f6bc44b5fd48c172857de32babb506f5e"
checksum = "e6960ccb00e47fe4b641f9c7b769515a4f89cb65459ca7afbed20fa39aac0b1b"
dependencies = [
"aws-lc-rs",
"cc",
@@ -3729,19 +3505,13 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "selectors"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"cssparser",
"derive_more",
"log",
@@ -3819,9 +3589,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -3859,23 +3629,11 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -3893,9 +3651,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
@@ -3945,6 +3703,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -3958,9 +3725,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
@@ -3999,12 +3766,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -4036,9 +3797,9 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -4103,9 +3864,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.8.6"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -4114,12 +3875,13 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.8.6"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
"base64 0.22.1",
"bytes",
"cfg-if",
"crc",
"crossbeam-queue",
"either",
@@ -4128,12 +3890,11 @@ dependencies = [
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashbrown 0.16.1",
"hashlink",
"indexmap 2.14.0",
"log",
"memchr",
"once_cell",
"percent-encoding",
"serde",
"sha2",
@@ -4147,9 +3908,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.8.6"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
dependencies = [
"proc-macro2",
"quote",
@@ -4160,15 +3921,15 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
dependencies = [
"cfg-if",
"dotenvy",
"either",
"heck 0.5.0",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
@@ -4183,12 +3944,13 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.8.6"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
dependencies = [
"atoi",
"flume",
"flume 0.12.0",
"form_urlencoded",
"futures-channel",
"futures-core",
"futures-executor",
@@ -4198,7 +3960,6 @@ dependencies = [
"log",
"percent-encoding",
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.18",
"tracing",
@@ -4286,7 +4047,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -4336,11 +4096,11 @@ dependencies = [
[[package]]
name = "tao"
version = "0.35.2"
version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-graphics",
@@ -4385,12 +4145,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4568,28 +4322,6 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -4777,6 +4509,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -4983,9 +4724,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.11+spec-1.1.0"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -5025,11 +4766,11 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.10"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"bytes",
"futures-util",
"http",
@@ -5083,6 +4824,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -5121,9 +4888,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unic-char-property"
@@ -5174,9 +4941,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-xid"
@@ -5227,12 +4994,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5241,9 +5002,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -5252,10 +5013,10 @@ dependencies = [
]
[[package]]
name = "value-bag"
version = "1.12.0"
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
@@ -5346,9 +5107,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if",
"once_cell",
@@ -5359,9 +5120,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.71"
version = "0.4.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5369,9 +5130,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5379,9 +5140,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.121"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5392,9 +5153,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.121"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
dependencies = [
"unicode-ident",
]
@@ -5440,7 +5201,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"semver",
@@ -5448,9 +5209,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.98"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6131,7 +5892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"bitflags 2.13.0",
"indexmap 2.14.0",
"log",
"serde",
@@ -6211,15 +5972,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
@@ -6243,9 +5995,9 @@ dependencies = [
[[package]]
name = "yoke"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -6266,18 +6018,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
+23 -13
View File
@@ -14,39 +14,49 @@ members = [
[workspace.dependencies]
base64 = "0.22"
bytes = { version = "1", features = ["serde"] }
crc32fast = "1"
eyre = "0.6"
futures = "0.3"
gethostname = "1"
if-addrs = "0.15"
log = "0.4"
mdns-sd = "0.19"
mdns-sd = "0.20"
mimalloc = { version = "0.1", features = ["secure"] }
notify = "8"
s2n-quic = { version = "1", features = ["provider-event-tracing"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", default-features = false, features = [
"derive",
"runtime-tokio",
"sqlite",
] }
sqlx = {
version = "0.9",
default-features = false,
features = [
"derive",
"runtime-tokio",
"sqlite",
]
}
strum = { version = "0.28", features = ["derive"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-shell = "2"
tauri-plugin-store = "2"
time = { version = "0.3", features = ["local-offset"] }
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec", "rt"] }
tracing = "0.1"
tracing-log = "0.2"
tracing-subscriber = "0.3"
uuid = { version = "1", features = ["v7"] }
walkdir = "2"
windows = { version = "0.62", features = [
"Win32",
"Win32_UI",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
windows = {
version = "0.62",
features = [
"Win32",
"Win32_UI",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
]
}
[profile.release]
debug = true
+14
View File
@@ -2,6 +2,20 @@
## Open
### Crash-during-download leaves orphan archive files
`crates/lanspread-peer/src/install/transaction.rs:329``recover_download_transients`
sweeps only `.version.ini.tmp` and `.version.ini.discarded` on startup. The new
cancel-cleanup (`download/storage.rs::discard_cancelled_download`) is only invoked
from the in-flight orchestrator, so a crash mid-download leaves partial `.eti`
archives in the game root. After restart the user sees a game that looks
half-downloaded with no way to clean it up except `RemoveDownloadedGame`. Closing
this would mean calling the same discard pass during recovery for any game root
whose intent is `None` and whose `version.ini` is absent.
Not blocking. The cancel-button fix is correct in its scope; this is the symmetric
crash-recovery case.
### `handleErrorEvent` still writes status fields directly
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error
+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
+66
View File
@@ -0,0 +1,66 @@
# Streamed Install Next Steps
Id treat the prototype as proof of the hard part: “can we stream
archive-derived install bytes into `local/` without making the receiver a
source?” Yes. Next Id harden the pieces that decide whether this is
product-ready.
1. **Done — Move from CLI-only to real app integration**
The GUI now has an explicit “Low disk install” action in the game detail
modal for remote-only games. The Tauri backend queues that path through
`stream_install_game`, injects the shared external `unrar` stream provider,
and hands fetched file details to `StreamInstallGame` instead of the normal
download command.
2. **Done — Replace per-file `unrar p` with a final archive provider**
The shared external `unrar` stream provider now runs `unrar lt` once for the
archive metadata and one sequential `unrar p` pass per archive for payload
bytes. It frames directories, file starts, file chunks, and file ends from
the technical listing, so CLI and GUI callers use one purpose-built provider
instead of a per-file extraction loop.
3. **Done — Handle solid archives deliberately**
The provider exposes the RAR `solid` flag in `ArchiveBegin` and always uses
one sequential payload pass per archive, which is the safe path for solid
archives. S41 now verifies a real solid RAR fixture through the Docker
peer-cli flow, including local-only final state, absent root archive/sentinel,
byte count, and extracted payload SHA-256 hashes.
4. **Done — Decide the integrity model**
Streamed installs intentionally verify against sender archive metadata for
now: each file must match the RAR-advertised size and CRC32. That catches
transport corruption, truncation, and provider bugs, but does not claim
malicious-peer protection. Trusted content remains a separate catalog schema
step: add catalog-owned archive or extracted-file SHA-256 hashes, then verify
those at the receiver before commit.
5. **Done — Upgrade retry/resume semantics**
Streamed install attempts now use the same majority-validated peer set as
normal downloads, and each failed attempt rolls back its staging transaction
before trying the next peer. S42 pins the policy: retry the whole stream from
another validated peer, keep no partial files across attempts, and do not add
byte-offset resume until there is a strong reason.
6. **Done — Expand scenario coverage**
S43-S47 cover the remaining streamed-install edges: already-installed
rejection, corrupt archive rollback, sender disconnect mid-stream, receiver
cancel mid-stream, and multi-archive `.eti` roots streamed in sorted order.
The peer-cli harness now exposes `cancel-download` so cancellation scenarios
exercise the same runtime path as the GUI.
7. **Done — Clean product semantics**
The UI now keeps streamed installs in the installed visual state while making
the sharing limitation explicit: cards show `Not shareable`, and the detail
modal status shows `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` label.
The remaining production-readiness step is additive: move from sender-owned RAR
metadata to catalog-owned archive or extracted-file hashes, then verify those
at the receiver before committing the streamed install.
+203 -24
View File
@@ -22,48 +22,57 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching file manifests; use a large file when proving chunk split. | The aggregated row counts only catalog-version ready peers. Large-file chunks may split between B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. |
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root starts with a stale `version.ini`, then changes to the catalog version. | The other peer receives a library update without reconnecting; the stale row is absent before the change, then the catalog-version game appears as downloadable. |
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique games, shared games, and differing versions; a sixth client connects to all five. | The client shows one row per game ID, correct ready-source `peer_count`, latest `eti_game_version`, no duplicates, and no self entries. |
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
| S36 | Catalog singleton beats stale majority | Five peers advertise one game; one peer has the catalog version and four peers have stale versions. | `list-games` reports `peer_count=1` and the catalog `eti_game_version`; all descriptors and chunks come from the singleton catalog-version peer, while stale peers remain hidden and contribute zero bytes. |
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `download-begin`, streamed `download-chunk-finished`, `download-finished`, `install-begin`, and `install-finished`. Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256; the source reports no active outbound transfer for `cnctw` after completion. |
| S40 | Streamed install receiver is not a peer source | After S39, a third peer connects only to the streamed-install receiver. | The third peer may see the receiver's local-only summary in peer snapshots, but `list-games` remote aggregation does not expose `cnctw` as downloadable, `peer_count` remains zero/absent, and attempting `download cnctw` fails with no local files created. |
| S41 | Solid archive streamed install | Empty client connects to a peer serving `fixture-solid/cnctw`, whose `.eti` is a real solid RAR archive. The receiver uses the container-bundled `unrar` stream provider. | The fixture is verified as solid with `unrar lt`; streamed install finishes with `downloaded=false`, `installed=true`, `availability=LocalOnly`; root archive and `version.ini` are absent; streamed byte count equals the extracted solid entries; local payload SHA-256 hashes match `unrar p` output. |
| S42 | Streamed install whole-stream retry | Empty client connects to two peers serving the same catalog-version `cnctw`: one broken source whose `--unrar` path is missing, followed by one good source. | The broken source sorts before the good source in retry order, contributes zero chunks, and the good source completes a fresh whole-stream attempt. The final state is local-only installed, no root archive/sentinel, no `.local.installing`, byte count matches the extracted entries, and payload hashes match the good source. |
| S43 | Already-installed streamed install rejection | A client first stream-installs `cnctw`, then attempts `stream-install cnctw` again. | The second request emits `download-failed`, does not emit a new success event, leaves the existing local-only install intact, and clears active operations. |
| S44 | Corrupt archive streamed install rollback | A source advertises catalog-version `cnctw`, but its root `.eti` is replaced with invalid bytes before the client requests `stream-install cnctw`. | The stream emits `download-failed`, does not emit download/install success, clears active operations, and leaves no `local/`, `.local.installing`, root archive, or root `version.ini` on the receiver. |
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
## Version-Skew Contract
Use S15-S17 to pin down what "newer" means when several peers have the same
game ID:
Use S15-S17 to pin down what happens when several peers have the same game ID
but only some match the local catalog version:
- Version comparison uses the eight-digit `version.ini` string, so use sortable
`YYYYMMDD` values in manual fixtures.
- The receiver's catalog is authoritative. A remote root whose `version.ini`
does not match the catalog's expected version for that game ID is not
downloadable.
- `list-games` aggregates by game ID. The game appears once; `peer_count`
counts all ready peers with that ID, including peers that only have older
versions.
- The aggregated `eti_game_version` must be the newest ready version.
counts only ready peers with that ID and the catalog version.
- The aggregated `eti_game_version` must be the catalog version.
- The descriptor set emitted to the download path, file-size validation, and
transfer planning are latest-only. Older-version peers may be queried by a
generic detail request, but their descriptors must not supply download
descriptors, majority votes, or chunks once a newer version exists.
- If exactly one peer has the latest version, that peer is the only transfer
source. If several peers tie on the latest version, validation and chunk
fanout happen among that latest-version set only.
transfer planning are catalog-version-only. Stale peers must not supply
download descriptors, majority votes, or chunks.
- If exactly one peer has the catalog version, that peer is the only transfer
source. If several peers match the catalog version, validation and chunk
fanout happen among that catalog-version set only.
- Capture proof with the `list-games` row, `got-game-files` descriptors,
`download-chunk-finished` source addresses, and source/receiver SHA-256
manifests.
@@ -77,7 +86,7 @@ GUI:
payload files may remain, but they must not be advertised as a ready local
game and must not leave an active operation stuck.
- Source failure during a redundant download should retry failed chunks against
another validated source for the same latest-version file.
another validated source for the same catalog-version file.
- Live local library changes are observable by connected peers through library
deltas; reconnect is not required for add, remove, or version-bump cases.
- Same-game operations are single-flight. A duplicate download request while a
@@ -86,13 +95,183 @@ GUI:
are not downloadable.
For a manual run, prefer a catalog game ID already served by the fixture lab,
such as `cnc4`, then create temporary `just peer-cli-run` game roots with
different `version.ini` contents. The existing alpha/bravo/charlie fixtures
cover duplicate-source and shared-game cases, but not the three-version skew
until a dedicated fixture or temporary games root is prepared.
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
peers match the catalog version and others deliberately use stale
`version.ini` contents. The existing alpha/bravo/charlie fixtures cover
duplicate-source and shared-game cases; S15-S17 add the focused skew cases.
## First-Play Launch-Setting Contract
Use S38 to pin down how launcher settings are stamped into an installed game:
- Stamping happens on the first `play`, not during install/update. The install
transaction only clears the `games/<id>/launch_settings_applied` marker so the
next play reapplies settings to a freshly (re)created `local/`.
- The first play stamps the username into the first `account_name.txt` and the
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
line keeps its existing line ending (`\n` or `\r\n`).
- The marker records only that we *tried*: it is written unconditionally after
the first play, so a game with none of these files is still marked done.
- S38 needs a real archive expanded with `--unrar`; the Docker matrix image now
carries the Linux sidecar for streamed-install coverage, while the peer
crate's `launch_settings` unit tests cover the rewrite, line-ending, and
marker logic deterministically.
## Streamed Install Archive Contract
Use S39-S41 to pin down low-disk streamed installs:
- The stream provider performs one archive metadata pass and one payload pass
per `.eti`, then frames entry boundaries for the receiver.
- Non-solid and solid archives both install into `local/` without committing a
root archive or root `version.ini`, so the receiver is installed but not a
downloadable source.
- Streamed install integrity is currently sender archive integrity: size and
RAR CRC32 must match the sender's archive metadata. The SHA-256 checks in the
scenarios prove the Docker/provider path matches the source fixture; they are
not catalog-owned trust anchors.
- S41 verifies the fixture is actually solid inside the source container, so
solid handling stays covered by the same Docker harness as the existing
streamed-install scenarios.
- S42 verifies retry/resume semantics: failed streamed attempts roll back their
staging directory and retry the whole stream from another validated peer.
There is no byte-offset resume contract.
- S43-S47 cover the remaining streamed-install failure and archive-shape edges:
already-installed rejection, corrupt archive rollback, sender disconnect,
receiver cancel, and multi-archive root sorting.
## Run Log
### 2026-06-07 - Catalog-Version Matrix Alignment (S1-S47)
- Code under test aligned checked-in fixture `version.ini` sentinels with the
catalog, made `run_extended_scenarios.py` stamp generated fixture games with
catalog versions by default, updated S15-S17/S23/S30/S36/S37 to assert
catalog-authoritative aggregation, and wired S38 into the executable matrix.
- Gates before Docker: `python3 -m py_compile
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
- Targeted rebuilt-image runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S3 S8 S14 S15 S16 S17 S21 S22 S23 S24 S29 S30 S31 S34 S36 S37 S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image`
passed.
- S38 standalone runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S38`
passed, proving the real-RAR `css` fixture installs with the container
`/usr/local/bin/unrar` sidecar and stamps launch settings only once.
- Full matrix runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
passed for S1-S47 against the rebuilt `lanspread-peer-cli:dev` image.
- The final full-run highlights included S3 aggregation, S15-S17
catalog-version skew/fanout/conflict, S23 stale-to-catalog propagation, S30
mesh aggregation, S36 catalog singleton over stale majority, S37 throughput,
S38 first-play stamping, and S39-S47 streamed-install coverage.
### 2026-06-07 - Streamed Install Edge Coverage (S43-S47)
- Code under test added `cancel-download` to `lanspread-peer-cli`, added the
tiny `fixture-multi/cnctw` two-archive fixture, and added S43-S47 in
`run_extended_scenarios.py`.
- Gates before Docker: `just fmt` and `python3 -m py_compile
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S43 S44 S45 S46 S47 --build-image`
passed against the rebuilt `lanspread-peer-cli:dev` image.
- S43 stream-installed `cnctw`, retried `stream-install cnctw`, observed
`download-failed`, and verified the existing local-only install stayed intact.
- S44 replaced the source `cnctw.eti` with invalid bytes. The receiver emitted
`download-failed`, cleared active operations, and left no `local/`,
`.local.installing`, root archive, or root `version.ini`.
- S45 killed the sole `alienswarm` source after the first streamed chunk. The
receiver ended with `download-failed`, emitted no success, cleared active
operations, and rolled back local/staging state.
- S46 cancelled `alienswarm` on the receiver after the first streamed chunk.
The receiver emitted no success and no user-visible `download-failed`, cleared
active operations, and rolled back local/staging state.
- S47 streamed `fixture-multi/cnctw` and observed chunk paths in sorted root
archive order: `cnctw/.local.installing/order/first.txt`, then
`cnctw/.local.installing/order/second.txt`.
### 2026-06-07 - Streamed Install Whole-Stream Retry (S42)
- Code under test added S42 in `run_extended_scenarios.py`.
- Gates before Docker: `python3 -m py_compile
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S42`
passed against the current `lanspread-peer-cli:dev` image.
- S42 started a broken source with `--unrar /missing-unrar` and a good source
with the same catalog-version `cnctw` metadata. The broken source sorted first
(`10.66.0.2:32897`) and the good source second (`10.66.0.3:34092`).
- The broken source contributed zero chunks; the good source completed the fresh
whole-stream attempt with `3145728` streamed file bytes.
- The final client state was `downloaded=false`, `installed=true`,
`availability=LocalOnly`, with no root `version.ini`, no root `cnctw.eti`,
and no `.local.installing` staging directory. Payload SHA-256 hashes matched
the good source's `unrar p` output.
### 2026-06-07 - Solid Streamed Install Coverage (S41)
- Code under test added `fixture-solid/cnctw`, a real solid RAR `.eti`, plus
S41 in `run_extended_scenarios.py`.
- Gates before Docker: `just fmt`, `git diff --check`, and
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
passed.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 --build-image`
passed against the rebuilt `lanspread-peer-cli:dev` image.
- S41 verified the source archive with `unrar lt -cfg-` inside the source
container; the archive reported `Details: RAR 5, solid`.
- The streamed install finished with `downloaded=false`, `installed=true`,
`availability=LocalOnly`, no root `version.ini`, and no root `cnctw.eti`.
- The client received `118` streamed file bytes, matching the extracted solid
entries. Payload SHA-256 hashes matched `unrar p` output:
`88764c9a6c9b5b846b4323cf7725cb7fd70766ddd7fba4168332804a839fa193`
(`bin/cnctw-solid-payload.bin`) and
`44afc308269b2381b7c707a056dd8d9d393274108ac4d880237fa6772c861d7a`
(`data/cnctw-solid-assets.dat`).
### 2026-06-07 - Streamed Install Prototype (S39-S40)
- Code under test added `stream-install` to `lanspread-peer-cli`, a peer
`StreamInstallGame` command, streamed install frames over QUIC, and an
injected `unrar lt`/`unrar p` provider for archive-derived bytes.
- Gates before Docker: `just fmt` and
`RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test` passed for the
workspace.
- Runner:
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S39 S40 --build-image`
passed against the rebuilt `lanspread-peer-cli:dev` image.
- S39 streamed a catalog-version-adjusted `cnctw` fixture from a real RAR
`.eti` into the receiver's `local/` only. The receiver had
`downloaded=false`, `installed=true`, `availability=LocalOnly`, no root
`version.ini`, no root `.eti`, and payload SHA-256 hashes
`82f4da22dc042166def2a5ee2eca19fc9e52785f99838e86c32167cb342e2588`
(`bin/cnctw-payload.bin`) and
`abf833a06c74ea9f17d505c2684186491898ce906405e0f098f0deac19476b06`
(`data/cnctw-assets.dat`) matching `unrar p`.
- S40 connected an observer only to that streamed-install receiver. The
observer saw the receiver's `cnctw` summary as local-only, remote aggregation
hid it as a downloadable source, and `download cnctw` failed with
`no peers have game cnctw`.
### 2026-05-28 - First-Play Launch-Setting Stamping (S38)
- Code under test moved the `account_name.txt`/`language.txt` overwrite out of
the install transaction and into a single first-play step (shared with the new
`SmartSteamEmu.ini` `PersonaName` rewrite) gated by the
`games/<id>/launch_settings_applied` marker.
- `just test` passed the whole workspace, including the new
`lanspread_peer::launch_settings` unit tests and
`install::transaction::install_resets_launch_settings_marker`.
- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css`
with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF
`SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer
installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the
username into the deep `PersonaName` line (CRLF preserved, sibling lines
intact) and `account_name.txt`, the language into `language.txt`, and created
the marker. A second `play css` returned `already_applied=true` and rewrote
nothing even after the value was reset externally.
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
- Code under test included `5c4976d` (`fix(peer): settle local state before
+32 -30
View File
@@ -1,46 +1,48 @@
# lanspread
## Description
Peer-to-peer game library sharing for LAN parties. Peers discover each other on
the local network via mDNS, exchange library metadata over QUIC, and let users
browse and download games from each other. Ships as a Tauri desktop app.
Peer-to-peer game library sharing for LAN parties.
## Build / install
- Peers let users browse and download games from each other
- they discover each other on the local network via mDNS
- they exchange library metadata over QUIC
Ships as a Tauri desktop app.
## Development
### Prerequisites
Install Rust, Deno, and `just` first, then bootstrap the project:
```bash
# install Tauri CLI
cargo install tauri-cli
# install Deno with a package manager or from https://deno.land/
just setup
```
### Build or Run
That installs the Tauri CLI with `cargo install tauri-cli` and installs the
Deno/npm dependencies from `crates/lanspread-tauri-deno-ts`.
Run the desktop app in development mode:
```bash
# build
just build
# run
just run
# test
just test
```
### Scripted peer harness
`crates/lanspread-peer-cli` runs the peer runtime without the GUI and speaks
JSONL on stdin/stdout. It is intended for automated multi-peer smoke tests.
Build without bundling:
```bash
just peer-cli-build
just peer-cli-image
just peer-cli-run alpha
just build
```
Create production bundles:
```bash
just bundle
```
## Important just commands
- `just setup` - install the Tauri CLI and frontend dependencies.
- `just run` - run the Tauri app in dev mode.
- `just build` - build the app without bundling.
- `just bundle` - create production bundles.
- `just fmt` - format Rust, TOML, and the justfile.
- `just clippy` - lint the Rust workspace.
- `just test` - run workspace tests.
- `just frontend-test` - run frontend tests.
- `just peer-cli-build` - build the JSONL peer test harness.
- `just peer-cli-image` - build the peer harness Docker image.
- `just peer-cli-run NAME` - run one peer harness container.
+11 -11
View File
@@ -3,23 +3,23 @@ name = "lanspread-compat"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
test = false
[dependencies]
# local
lanspread-db = { path = "../lanspread-db" }
eyre = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
[lib]
test = false
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
+2 -2
View File
@@ -57,14 +57,14 @@ impl From<EtiGame> for Game {
release_year: eti_game.game_release,
publisher: eti_game.game_publisher,
max_players: eti_game.game_maxplayers,
version: eti_game.game_version,
version: eti_game.game_version.clone(),
genre: eti_game.genre_de,
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
downloaded: false,
installed: false,
availability: Availability::LocalOnly,
eti_game_version: None,
eti_game_version: Some(eti_game.game_version),
local_version: None,
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
}
+9 -9
View File
@@ -3,13 +3,8 @@ name = "lanspread-db"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
[dependencies]
eyre = { workspace = true }
@@ -17,5 +12,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[lib]
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
+55 -1
View File
@@ -78,7 +78,7 @@ pub struct Game {
/// Backend-reported availability state for this game's local or peer summary.
#[serde(default)]
pub availability: Availability,
/// ETI game version from version.ini (YYYYMMDD format) (server)
/// Authoritative ETI game version from the bundled game.db (YYYYMMDD format).
pub eti_game_version: Option<String>,
/// Local game version from version.ini (YYYYMMDD format)
pub local_version: Option<String>,
@@ -198,6 +198,60 @@ impl Default for GameDB {
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GameCatalog {
expected_versions: HashMap<String, Option<String>>,
}
impl GameCatalog {
#[must_use]
pub fn empty() -> Self {
Self {
expected_versions: HashMap::new(),
}
}
#[must_use]
pub fn from_game_db(game_db: &GameDB) -> Self {
Self {
expected_versions: game_db
.games
.values()
.map(|game| (game.id.clone(), game.eti_game_version.clone()))
.collect(),
}
}
#[must_use]
pub fn from_ids(ids: impl IntoIterator<Item = String>) -> Self {
Self {
expected_versions: ids.into_iter().map(|id| (id, None)).collect(),
}
}
pub fn insert(&mut self, id: String, expected_version: Option<String>) {
self.expected_versions.insert(id, expected_version);
}
#[must_use]
pub fn contains<S>(&self, id: S) -> bool
where
S: AsRef<str>,
{
self.expected_versions.contains_key(id.as_ref())
}
#[must_use]
pub fn expected_version<S>(&self, id: S) -> Option<&str>
where
S: AsRef<str>,
{
self.expected_versions
.get(id.as_ref())
.and_then(Option::as_deref)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GameFileDescription {
pub game_id: String,
+10 -10
View File
@@ -3,19 +3,19 @@ name = "lanspread-mdns"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
test = false
[dependencies]
eyre = { workspace = true }
log = { workspace = true }
mdns-sd = { workspace = true }
[lib]
test = false
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
+12 -12
View File
@@ -3,14 +3,12 @@ name = "lanspread-peer-cli"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lib]
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
needless_pass_by_value = "allow"
[[bin]]
name = "lanspread-peer-cli"
path = "src/main.rs"
[dependencies]
lanspread-compat = { path = "../lanspread-compat" }
@@ -22,9 +20,11 @@ serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
[lib]
doctest = false
[lints.clippy]
needless_pass_by_value = "allow"
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[[bin]]
name = "lanspread-peer-cli"
path = "src/main.rs"
[lints.rust]
unsafe_code = "forbid"
+4 -2
View File
@@ -4,14 +4,16 @@ WORKDIR /work
COPY . .
RUN cargo build --release -p lanspread-peer-cli
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /work/target/release/lanspread-peer-cli /usr/local/bin/lanspread-peer-cli
COPY crates/lanspread-tauri-deno-ts/src-tauri/game.db /app/game.db
COPY crates/lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu /usr/local/bin/unrar
RUN chmod +x /usr/local/bin/unrar
ENTRYPOINT ["lanspread-peer-cli"]
CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db"]
+4
View File
@@ -42,3 +42,7 @@ echoed back on the result or error line.
{"id":"u1","cmd":"uninstall","game_id":"fixture-one"}
{"id":"q1","cmd":"shutdown"}
```
The `status` result includes receiver-side `active_operations` and
sender-side `active_outbound_transfers` counts by game ID, which the scenario
runner uses to verify transfer lifecycle cleanup.
@@ -1 +1 @@
20250101
20190317
@@ -1 +1 @@
20250103
20160130
@@ -1 +1 @@
20250102
20200721
@@ -1 +1 @@
20250201
20210416
@@ -1 +1 @@
20250202
20170204
@@ -1 +1 @@
20250203
20160128
@@ -1 +1 @@
20250102
20200721
@@ -1 +1 @@
20250202
20170204
@@ -1 +1 @@
20250301
20160920
@@ -1 +1 @@
20250302
20200315
@@ -1 +1 @@
20250303
20200907
@@ -0,0 +1 @@
20160128
@@ -0,0 +1 @@
20240623
@@ -0,0 +1 @@
20160128
@@ -1,13 +1,15 @@
#!/usr/bin/env python3
"""Run the peer-cli scenarios S1-S36 through Docker."""
"""Run the peer-cli scenarios S1-S47 through Docker."""
from __future__ import annotations
import argparse
import hashlib
import ipaddress
import json
import os
import queue
import shlex
import shutil
import subprocess
import sys
@@ -26,7 +28,20 @@ CONTAINER_PREFIX = "lanspread-peer-cli-ext"
CATALOG_DB = "/app/game.db"
FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures"
CHUNK_SIZE = 128 * 1024 * 1024
CATALOG_VERSIONS = {
"alienswarm": "20190317",
"bf1942": "20160130",
"bfbc2": "20210416",
"cnc4": "20170204",
"cnctw": "20160128",
"cod5": "20160920",
"cod6": "20200315",
"coh": "20200907",
"css": "20240623",
"ggoo": "20200721",
}
PERF_GAME_ID = "bf1942"
PERF_GAME_VERSION = CATALOG_VERSIONS[PERF_GAME_ID]
PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024
IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"}
@@ -303,8 +318,8 @@ class Runner:
("S13", self.s13_exact_transfer_equality),
("S14", self.s14_large_multi_peer_chunking),
("S15", self.s15_three_way_version_skew),
("S16", self.s16_latest_fanout_with_stale),
("S17", self.s17_latest_conflict_rejection),
("S16", self.s16_catalog_fanout_with_stale),
("S17", self.s17_catalog_conflict_rejection),
("S18", self.s18_redundant_source_drop),
("S19", self.s19_sole_source_drop),
("S20", self.s20_receiver_write_failure),
@@ -323,8 +338,18 @@ class Runner:
("S33", self.s33_install_after_mutation),
("S34", self.s34_many_small_files),
("S35", self.s35_unknown_game_filtered),
("S36", self.s36_latest_singleton),
("S36", self.s36_catalog_singleton),
("S37", self.s37_single_source_download_throughput),
("S38", self.s38_first_play_launch_settings),
("S39", self.s39_streamed_install_local_only),
("S40", self.s40_streamed_receiver_not_source),
("S41", self.s41_solid_archive_streamed_install),
("S42", self.s42_streamed_install_retries_next_source),
("S43", self.s43_streamed_install_rejects_installed_game),
("S44", self.s44_corrupt_stream_rolls_back),
("S45", self.s45_sender_disconnect_mid_stream),
("S46", self.s46_receiver_cancel_mid_stream),
("S47", self.s47_multi_archive_streams_in_sorted_order),
]
for scenario_id, scenario in scenarios:
@@ -509,20 +534,20 @@ class Runner:
def s8_ambiguous_metadata_rejection(self) -> str:
dir_a = self.fixture_root / "s8-a"
dir_b = self.fixture_root / "s8-b"
copy_game("ggoo", dir_a, version="20260101")
copy_game("ggoo", dir_b, version="20260101")
copy_game("ggoo", dir_a)
copy_game("ggoo", dir_b)
with (dir_b / "ggoo" / "ggoo.eti").open("ab") as handle:
handle.write(b"conflict")
peer_a = self.peer("s8-a", games_dir=dir_a)
peer_b = self.peer("s8-b", games_dir=dir_b)
client = self.peer("s8-client")
connect_many(client, [peer_a, peer_b])
wait_remote_game(client, "ggoo", peer_count=2, version="20260101")
wait_remote_game(client, "ggoo", peer_count=2, version=CATALOG_VERSIONS["ggoo"])
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
client.wait_for(event_is("download-failed", "ggoo"), timeout=30, description="ggoo failed", waiter=waiter)
assert_not_exists(client.host_games_dir / "ggoo" / "version.ini")
return "conflicting latest ggoo file sizes emitted download-failed and left no version.ini"
return "conflicting catalog-version ggoo file sizes emitted download-failed and left no version.ini"
def s9_missing_game(self) -> str:
client = self.peer("s9-client")
@@ -592,30 +617,37 @@ 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=PERF_GAME_VERSION)
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")]
specs = [
("s15-a", "20150101"),
("s15-b", "20160101"),
("s15-c", CATALOG_VERSIONS["cnc4"]),
]
peers = []
for name, version in specs:
game_dir = self.fixture_root / name
@@ -623,19 +655,19 @@ class Runner:
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s15-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter)
assert_only_chunk_sources(client, "cnc4", {peers[2].ready_addr})
diff_game_dirs(peers[2].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
return "three-way skew selected only 20250301 peer and receiver diffed cleanly"
return "three-way skew exposed only the catalog-version peer and receiver diffed cleanly"
def s16_latest_fanout_with_stale(self) -> str:
def s16_catalog_fanout_with_stale(self) -> str:
specs = [
("s16-a", "20250101"),
("s16-b", "20250301"),
("s16-c", "20250301"),
("s16-a", "20180101"),
("s16-b", CATALOG_VERSIONS["alienswarm"]),
("s16-c", CATALOG_VERSIONS["alienswarm"]),
]
peers = []
for name, version in specs:
@@ -644,7 +676,7 @@ class Runner:
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s16-client")
connect_many(client, peers)
wait_remote_game(client, "alienswarm", peer_count=3, version="20250301")
wait_remote_game(client, "alienswarm", peer_count=2, version=CATALOG_VERSIONS["alienswarm"])
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="alienswarm finish", waiter=waiter)
@@ -653,13 +685,13 @@ class Runner:
if peers[0].ready_addr in totals:
raise ScenarioError(f"stale peer contributed chunks: {totals}")
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
return f"latest B/C peers served alienswarm while stale A contributed zero; totals={totals}"
return f"catalog-version B/C peers served alienswarm while stale A contributed zero; totals={totals}"
def s17_latest_conflict_rejection(self) -> str:
def s17_catalog_conflict_rejection(self) -> str:
specs = [
("s17-a", "20250101", False),
("s17-b", "20250301", False),
("s17-c", "20250301", True),
("s17-a", "20150101", False),
("s17-b", CATALOG_VERSIONS["cnc4"], False),
("s17-c", CATALOG_VERSIONS["cnc4"], True),
]
peers = []
for name, version, conflict in specs:
@@ -671,12 +703,12 @@ class Runner:
peers.append(self.peer(name, games_dir=game_dir))
client = self.peer("s17-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
wait_remote_game(client, "cnc4", peer_count=2, version=CATALOG_VERSIONS["cnc4"])
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
client.wait_for(event_is("download-failed", "cnc4"), timeout=30, description="cnc4 failed", waiter=waiter)
assert_not_exists(client.host_games_dir / "cnc4" / "version.ini")
return "latest-version file conflict failed download and left no committed version.ini"
return "catalog-version file conflict failed download and left no committed version.ini"
def s18_redundant_source_drop(self) -> str:
source_a_dir = self.fixture_root / "s18-a"
@@ -759,13 +791,13 @@ class Runner:
def s23_version_bump_propagation(self) -> str:
alpha = self.peer("s23-alpha")
bravo_dir = self.fixture_root / "s23-bravo"
copy_game("cnc4", bravo_dir, version="20250101")
copy_game("cnc4", bravo_dir, version="20160101")
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
connect_many(alpha, [bravo])
wait_remote_game(alpha, "cnc4", peer_count=1, version="20250101")
(bravo_dir / "cnc4" / "version.ini").write_text("20260501", encoding="utf-8")
wait_remote_game(alpha, "cnc4", peer_count=1, version="20260501")
return "alpha observed cnc4 eti_game_version change 20250101 -> 20260501 without reconnect"
wait_remote_absent(alpha, "cnc4", timeout=5)
(bravo_dir / "cnc4" / "version.ini").write_text(CATALOG_VERSIONS["cnc4"], encoding="utf-8")
wait_remote_game(alpha, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
return "alpha observed stale cnc4 become catalog-version downloadable without reconnect"
def s24_two_clients_one_source(self) -> str:
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -862,11 +894,11 @@ class Runner:
def s30_mesh_aggregation(self) -> str:
dirs = []
specs = [
("s30-a", [("ggoo", "20250101"), ("bf1942", "20250101")]),
("s30-b", [("ggoo", "20250101"), ("cnc4", "20250101")]),
("s30-c", [("cnc4", "20250301"), ("cod5", "20250101")]),
("s30-d", [("cnctw", "20250101"), ("coh", "20250101")]),
("s30-e", [("cnctw", "20250201"), ("bf1942", "20250201")]),
("s30-a", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
("s30-b", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("cnc4", CATALOG_VERSIONS["cnc4"])]),
("s30-c", [("cnc4", CATALOG_VERSIONS["cnc4"]), ("cod5", CATALOG_VERSIONS["cod5"])]),
("s30-d", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("coh", CATALOG_VERSIONS["coh"])]),
("s30-e", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
]
peers = []
for name, games in specs:
@@ -878,12 +910,12 @@ class Runner:
client = self.peer("s30-client")
connect_many(client, peers)
expected = {
"ggoo": (2, "20250101"),
"bf1942": (2, "20250201"),
"cnc4": (2, "20250301"),
"cod5": (1, "20250101"),
"cnctw": (2, "20250201"),
"coh": (1, "20250101"),
"ggoo": (2, CATALOG_VERSIONS["ggoo"]),
"bf1942": (2, CATALOG_VERSIONS["bf1942"]),
"cnc4": (2, CATALOG_VERSIONS["cnc4"]),
"cod5": (1, CATALOG_VERSIONS["cod5"]),
"cnctw": (2, CATALOG_VERSIONS["cnctw"]),
"coh": (1, CATALOG_VERSIONS["coh"]),
}
for game_id, (peer_count, version) in expected.items():
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
@@ -893,7 +925,7 @@ class Runner:
raise ScenarioError(f"duplicate game rows: {ids}")
if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()):
raise ScenarioError("client listed itself as a peer")
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/latest versions"
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/catalog versions"
def s31_bootstrapped_peer_source(self) -> str:
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
@@ -989,34 +1021,34 @@ class Runner:
assert_not_exists(client.host_games_dir / "mystery-game")
return f"unknown game absent from list-games; download errored '{err['error']}'; no local files"
def s36_latest_singleton(self) -> str:
def s36_catalog_singleton(self) -> str:
peers = []
for index in range(5):
game_dir = self.fixture_root / f"s36-{index}"
version = "20260501" if index == 0 else "20250101"
version = CATALOG_VERSIONS["cnc4"] if index == 0 else "20160101"
copy_game("cnc4", game_dir, version=version)
peers.append(self.peer(f"s36-{index}", games_dir=game_dir))
client = self.peer("s36-client")
connect_many(client, peers)
wait_remote_game(client, "cnc4", peer_count=5, version="20260501")
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter)
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter)
latest_addr = peers[0].ready_addr
if latest_addr is None:
raise ScenarioError("latest peer had no ready addr")
catalog_addr = peers[0].ready_addr
if catalog_addr is None:
raise ScenarioError("catalog-version peer had no ready addr")
for item in client.output:
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
continue
data = item["data"]
if data.get("game_id") == "cnc4" and data.get("peer_addr") != latest_addr:
if data.get("game_id") == "cnc4" and data.get("peer_addr") != catalog_addr:
raise ScenarioError(f"stale peer contributed chunk: {data}")
diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
descs = got["data"]["file_descriptions"]
if not descs:
raise ScenarioError("got-game-files had no descriptors")
return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
return "client reported singleton catalog-version peer; stale peers stayed hidden and sent no chunks; diff matched"
def s37_single_source_download_throughput(self) -> str:
source_dir = self.fixture_root / "s37-source"
@@ -1024,7 +1056,7 @@ class Runner:
source = self.peer("s37-source", games_dir=source_dir)
client = self.peer("s37-client")
connect_many(client, [source])
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version="20260520")
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version=PERF_GAME_VERSION)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
@@ -1043,7 +1075,7 @@ class Runner:
throughput = finished.get("data", {}).get("throughput")
if not throughput:
raise ScenarioError(f"download-finished did not include throughput: {finished}")
expected_bytes = PERF_GAME_SIZE + len("20260520")
expected_bytes = PERF_GAME_SIZE + len(PERF_GAME_VERSION)
if int(throughput["bytes"]) != expected_bytes:
raise ScenarioError(
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
@@ -1057,6 +1089,535 @@ class Runner:
f"{throughput['chunks']} chunks"
)
def s38_first_play_launch_settings(self) -> str:
client_dir = self.fixture_root / "s38-client"
copy_game("css", client_dir)
client = self.peer(
"s38-client",
games_dir=client_dir,
extra_args=["--unrar", "/usr/local/bin/unrar"],
)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "install", "game_id": "css"})
client.wait_for(
event_is("install-finished", "css"),
timeout=30,
description="css install",
waiter=waiter,
)
wait_local_game(client, "css", downloaded=True, installed=True)
marker = client.host_state_dir / "games" / "css" / "launch_settings_applied"
if marker.exists():
raise ScenarioError("launch settings marker existed before first play")
local_root = client.host_games_dir / "css" / "local"
account_file = local_root / "profiles" / "local" / "account_name.txt"
language_file = local_root / "profiles" / "local" / "language.txt"
ini_file = (
local_root
/ "engine"
/ "bin"
/ "win64"
/ "steam_settings"
/ "SmartSteamEmu.ini"
)
for path in [account_file, language_file, ini_file]:
if not path.is_file():
raise ScenarioError(f"expected installed launch settings file: {path}")
if b"PersonaName = stubplayer\r\n" not in ini_file.read_bytes():
raise ScenarioError("installed SmartSteamEmu.ini did not preserve CRLF stub PersonaName")
first = client.send(
{
"cmd": "play",
"game_id": "css",
"username": "Lan Hero",
"language": "german",
}
)["data"]["outcome"]
expected_first = {
"already_applied": False,
"account_name_written": True,
"language_written": True,
"persona_name_written": True,
}
if first != expected_first:
raise ScenarioError(f"unexpected first play outcome: {first}")
if not marker.is_file():
raise ScenarioError("launch settings marker was not written after first play")
if account_file.read_text(encoding="utf-8") != "Lan Hero":
raise ScenarioError("account_name.txt was not stamped with username")
if language_file.read_text(encoding="utf-8") != "german":
raise ScenarioError("language.txt was not stamped with language")
stamped_ini = ini_file.read_bytes()
if b"PersonaName = Lan Hero\r\n" not in stamped_ini:
raise ScenarioError("PersonaName was not stamped with CRLF preserved")
if b"AppId = 240\r\n" not in stamped_ini or b"Language = english\r\n" not in stamped_ini:
raise ScenarioError("SmartSteamEmu.ini sibling lines were not preserved")
client.docker_exec(
"sh",
"-c",
"printf resetaccount > /games/css/local/profiles/local/account_name.txt",
)
client.docker_exec(
"sh",
"-c",
"printf resetlang > /games/css/local/profiles/local/language.txt",
)
client.docker_exec(
"sh",
"-c",
"printf '[Settings]\\r\\nAppId = 240\\r\\n"
"PersonaName = resetplayer\\r\\nLanguage = english\\r\\n' > "
"/games/css/local/engine/bin/win64/steam_settings/SmartSteamEmu.ini",
)
second = client.send(
{
"cmd": "play",
"game_id": "css",
"username": "Second User",
"language": "french",
}
)["data"]["outcome"]
expected_second = {
"already_applied": True,
"account_name_written": False,
"language_written": False,
"persona_name_written": False,
}
if second != expected_second:
raise ScenarioError(f"unexpected second play outcome: {second}")
if account_file.read_text(encoding="utf-8") != "resetaccount":
raise ScenarioError("second play rewrote account_name.txt despite marker")
if language_file.read_text(encoding="utf-8") != "resetlang":
raise ScenarioError("second play rewrote language.txt despite marker")
if b"PersonaName = resetplayer\r\n" not in ini_file.read_bytes():
raise ScenarioError("second play rewrote PersonaName despite marker")
return "css first play stamped launch settings once; second play respected the marker"
def stream_install_cnctw(self, prefix: str) -> tuple[Peer, Peer]:
source_dir = self.fixture_root / f"{prefix}-bravo"
copy_game("cnctw", source_dir, version="20160128")
source = self.peer(f"{prefix}-bravo", games_dir=source_dir)
client = self.peer(f"{prefix}-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1)
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-begin", "cnctw"),
timeout=20,
description="stream begin cnctw",
waiter=waiter,
)
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="stream install cnctw",
waiter=waiter,
)
return source, client
def s39_streamed_install_local_only(self) -> str:
source, client = self.stream_install_cnctw("s39")
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
expected = {
"bin/cnctw-payload.bin": unrar_entry_sha256(
source, "cnctw", "bin/cnctw-payload.bin"
),
"data/cnctw-assets.dat": unrar_entry_sha256(
source, "cnctw", "data/cnctw-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(f"streamed local payload hashes mismatched: {actual} != {expected}")
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = 3 * 1024 * 1024
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
wait_no_outbound_transfer(source, "cnctw")
return (
"cnctw streamed into local/ only; root archive and version.ini absent; "
f"payload hashes={actual}; source outbound transfer drained"
)
def s40_streamed_receiver_not_source(self) -> str:
_source, receiver = self.stream_install_cnctw("s40")
observer = self.peer("s40-observer")
connect_many(observer, [receiver])
receiver_snapshot = wait_peer_has_game(observer, receiver.peer_id, "cnctw")
summary = next(
game
for game in receiver_snapshot.get("games", [])
if game.get("id") == "cnctw"
)
if summary.get("availability") != "LocalOnly" or summary.get("downloaded"):
raise ScenarioError(f"receiver did not advertise cnctw as local-only: {summary}")
wait_remote_absent(observer, "cnctw", timeout=5)
err = observer.send(
{"cmd": "download", "game_id": "cnctw", "install": False},
expect_error=True,
)
if "no peers have game cnctw" not in err["error"]:
raise ScenarioError(f"unexpected local-only download error: {err}")
assert_not_exists(observer.host_games_dir / "cnctw")
return (
"observer saw receiver's local-only cnctw snapshot, but remote aggregation hid it "
f"and download errored '{err['error']}'"
)
def s41_solid_archive_streamed_install(self) -> str:
source_dir = self.fixture_root / "s41-solid-source"
source_game = source_dir / "cnctw"
shutil.copytree(FIXTURES / "fixture-solid" / "cnctw", source_game)
source = self.peer("s41-solid-source", games_dir=source_dir)
assert_peer_rar_archive_solid(source, "cnctw")
client = self.peer("s41-solid-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="solid stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="solid stream install cnctw",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
expected = {
"bin/cnctw-solid-payload.bin": unrar_entry_sha256(
source, "cnctw", "bin/cnctw-solid-payload.bin"
),
"data/cnctw-solid-assets.dat": unrar_entry_sha256(
source, "cnctw", "data/cnctw-solid-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(
f"solid streamed payload hashes mismatched: {actual} != {expected}"
)
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = sum((game_root / "local" / rel).stat().st_size for rel in expected)
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"solid streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
return (
"solid cnctw archive streamed through one local-only install; "
f"payload hashes={actual}, bytes={streamed_bytes}"
)
def s42_streamed_install_retries_next_source(self) -> str:
bad_dir = self.fixture_root / "s42-bad-source"
good_dir = self.fixture_root / "s42-good-source"
copy_game("cnctw", bad_dir, version="20160128")
copy_game("cnctw", good_dir, version="20160128")
bad = self.peer(
"s42-bad-source",
games_dir=bad_dir,
extra_args=["--unrar", "/missing-unrar"],
)
good = self.peer("s42-good-source", games_dir=good_dir)
if socket_addr_sort_key(bad.ready_addr) > socket_addr_sort_key(good.ready_addr):
raise ScenarioError(
"S42 requires the broken source to sort before the good source; "
f"bad={bad.ready_addr}, good={good.ready_addr}"
)
client = self.peer("s42-client")
connect_many(client, [bad, good])
wait_remote_game(client, "cnctw", peer_count=2, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=60,
description="retry stream finish cnctw",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="retry stream install cnctw",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / ".local.installing")
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "cnctw.eti")
assert_only_chunk_sources(client, "cnctw", {good.ready_addr})
expected = {
"bin/cnctw-payload.bin": unrar_entry_sha256(
good, "cnctw", "bin/cnctw-payload.bin"
),
"data/cnctw-assets.dat": unrar_entry_sha256(
good, "cnctw", "data/cnctw-assets.dat"
),
}
actual = {
rel: sha256_file(game_root / "local" / rel)
for rel in expected
}
if actual != expected:
raise ScenarioError(f"retry streamed payload hashes mismatched: {actual} != {expected}")
streamed_bytes = sum(
int(item.get("data", {}).get("length", 0))
for item in client.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == "cnctw"
)
expected_bytes = 3 * 1024 * 1024
if streamed_bytes != expected_bytes:
raise ScenarioError(
f"retry streamed byte count mismatch: {streamed_bytes} != {expected_bytes}"
)
return (
"broken first source failed without chunks, next source completed whole stream; "
f"good={good.ready_addr}, bad={bad.ready_addr}, bytes={streamed_bytes}"
)
def s43_streamed_install_rejects_installed_game(self) -> str:
_source, client = self.stream_install_cnctw("s43")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-failed", "cnctw"),
timeout=20,
description="already-installed stream rejection",
waiter=waiter,
)
assert_no_event_since(client, start, "install-finished", "cnctw")
assert_no_event_since(client, start, "download-finished", "cnctw")
wait_no_active(client, "cnctw")
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
return "already-installed cnctw rejected a second streamed install without state drift"
def s44_corrupt_stream_rolls_back(self) -> str:
source_dir = self.fixture_root / "s44-corrupt-source"
copy_game("cnctw", source_dir, version="20160128")
(source_dir / "cnctw" / "cnctw.eti").write_bytes(b"not a rar archive")
source = self.peer("s44-corrupt-source", games_dir=source_dir)
client = self.peer("s44-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-failed", "cnctw"),
timeout=30,
description="corrupt stream failed",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", "cnctw")
assert_no_event_since(client, start, "install-finished", "cnctw")
wait_no_active(client, "cnctw")
assert_failed_stream_left_no_local(client, "cnctw")
return "corrupt cnctw archive emitted download-failed and left no local install"
def s45_sender_disconnect_mid_stream(self) -> str:
source_dir = self.fixture_root / "s45-source"
copy_game("alienswarm", source_dir, version="20190317")
source = self.peer("s45-source", games_dir=source_dir)
client = self.peer("s45-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
client.wait_for(
event_is("download-chunk-finished", "alienswarm"),
timeout=30,
description="first alienswarm stream chunk before source drop",
waiter=waiter,
)
source.kill()
terminal = client.wait_for(
event_name_in({"download-failed", "download-peers-gone"}, "alienswarm"),
timeout=60,
description="sender disconnect terminal event",
waiter=waiter,
)
assert_no_event_since(client, start, "download-finished", "alienswarm")
assert_no_event_since(client, start, "install-finished", "alienswarm")
wait_no_active(client, "alienswarm")
assert_failed_stream_left_no_local(client, "alienswarm")
return (
"sender disconnect after first alienswarm chunk rolled back stream; "
f"terminal={terminal['event']}"
)
def s46_receiver_cancel_mid_stream(self) -> str:
source_dir = self.fixture_root / "s46-source"
copy_game("alienswarm", source_dir, version="20190317")
source = self.peer("s46-source", games_dir=source_dir)
client = self.peer("s46-client")
connect_many(client, [source])
wait_remote_game(client, "alienswarm", peer_count=1, version="20190317")
start = len(client.output)
waiter = LineWaiter(start)
client.send({"cmd": "stream-install", "game_id": "alienswarm"})
client.wait_for(
event_is("download-chunk-finished", "alienswarm"),
timeout=30,
description="first alienswarm stream chunk before receiver cancel",
waiter=waiter,
)
client.send({"cmd": "cancel-download", "game_id": "alienswarm"})
wait_no_active(client, "alienswarm", timeout=60)
assert_no_event_since(client, start, "download-finished", "alienswarm")
assert_no_event_since(client, start, "download-failed", "alienswarm")
assert_no_event_since(client, start, "install-finished", "alienswarm")
assert_failed_stream_left_no_local(client, "alienswarm")
return "receiver cancel after first alienswarm chunk rolled back without failed event"
def s47_multi_archive_streams_in_sorted_order(self) -> str:
source_dir = self.fixture_root / "s47-source"
source_game = source_dir / "cnctw"
shutil.copytree(FIXTURES / "fixture-multi" / "cnctw", source_game)
source = self.peer("s47-source", games_dir=source_dir)
client = self.peer("s47-client")
connect_many(client, [source])
wait_remote_game(client, "cnctw", peer_count=1, version="20160128")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "stream-install", "game_id": "cnctw"})
client.wait_for(
event_is("download-finished", "cnctw"),
timeout=30,
description="multi-archive stream finish",
waiter=waiter,
)
client.wait_for(
event_is("install-finished", "cnctw"),
timeout=30,
description="multi-archive stream install",
waiter=waiter,
)
game = wait_local_game(client, "cnctw", downloaded=False, installed=True)
assert_game_state(
game,
downloaded=False,
installed=True,
availability="LocalOnly",
)
game_root = client.host_games_dir / "cnctw"
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / "a-first.eti")
assert_not_exists(game_root / "z-second.eti")
chunk_paths = streamed_chunk_paths(client, "cnctw")
expected_paths = [
"cnctw/.local.installing/order/first.txt",
"cnctw/.local.installing/order/second.txt",
]
if chunk_paths != expected_paths:
raise ScenarioError(f"multi-archive stream order mismatch: {chunk_paths}")
first = (game_root / "local" / "order" / "first.txt").read_text(encoding="utf-8")
second = (game_root / "local" / "order" / "second.txt").read_text(encoding="utf-8")
if first != "first archive payload\n" or second != "second archive payload\n":
raise ScenarioError(f"multi-archive payload mismatch: {first!r}, {second!r}")
return f"multi-archive cnctw streamed in sorted order: {chunk_paths}"
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
@@ -1125,6 +1686,7 @@ def copy_game(game_id: str, destination_games_dir: Path, *, version: str | None
shutil.rmtree(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, destination)
version = version if version is not None else CATALOG_VERSIONS.get(game_id)
if version is not None:
(destination / "version.ini").write_text(version, encoding="utf-8")
@@ -1161,19 +1723,62 @@ def create_many_small_game(root: Path) -> None:
for index in range(20):
child = root / f"file-{index:02}.bin"
child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8)
(root / "version.ini").write_text("20250101", encoding="utf-8")
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "20250101"), encoding="utf-8")
def create_large_sparse_game(root: Path, *, size: int) -> None:
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
(root / "version.ini").write_text("20260520", encoding="utf-8")
(root / "version.ini").write_text(PERF_GAME_VERSION, encoding="utf-8")
archive = root / f"{root.name}.eti"
with archive.open("wb") as handle:
handle.truncate(size)
def sha256_file(path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
def unrar_entry_sha256(peer: Peer, game_id: str, relative_path: str) -> str:
command = (
f"unrar p -inul /games/{shlex.quote(game_id)}/{shlex.quote(game_id)}.eti "
f"{shlex.quote(relative_path)} | sha256sum"
)
output = peer.docker_exec("sh", "-c", command).stdout.strip()
if not output:
raise ScenarioError(f"empty sha256 output for {game_id}:{relative_path}")
return output.split()[0]
def assert_peer_rar_archive_solid(peer: Peer, game_id: str) -> None:
output = peer.docker_exec(
"unrar",
"lt",
"-cfg-",
f"/games/{game_id}/{game_id}.eti",
).stdout
for line in output.splitlines():
stripped = line.strip()
if stripped.startswith("Details:"):
if "solid" in stripped.lower():
return
raise ScenarioError(f"RAR archive is not solid: {game_id}")
raise ScenarioError(f"RAR archive details were not reported: {game_id}")
def socket_addr_sort_key(addr: str | None) -> tuple[int, int]:
if addr is None:
raise ScenarioError("cannot sort missing peer address")
host, port = addr.rsplit(":", 1)
host = host.removeprefix("[").removesuffix("]")
return (int(ipaddress.ip_address(host)), int(port))
def format_bytes(size: int) -> str:
return f"{size / 1024 / 1024 / 1024:.2f} GiB"
@@ -1250,6 +1855,32 @@ def wait_local_game(
)
def wait_no_active(peer: Peer, game_id: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
last_active: list[dict[str, Any]] = []
while time.monotonic() < deadline:
active = peer.status()["active_operations"]
last_active = active
if all(item["game_id"] != game_id for item in active):
return
time.sleep(0.4)
raise ScenarioError(f"{peer.name} still has active operation for {game_id}: {last_active}")
def wait_no_outbound_transfer(peer: Peer, game_id: str, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
last_active: dict[str, int] = {}
while time.monotonic() < deadline:
active = peer.status()["active_outbound_transfers"]
last_active = active
if active.get(game_id, 0) == 0:
return
time.sleep(0.4)
raise ScenarioError(
f"{peer.name} still has outbound transfer for {game_id}: {last_active}"
)
def assert_game_state(
game: dict[str, Any],
*,
@@ -1296,7 +1927,10 @@ def wait_peer_has_game(
def assert_local_absent(peer: Peer, game_id: str) -> None:
rows = peer.list_games()["local"]
if any(row["id"] == game_id and row.get("downloaded") for row in rows):
if any(
row["id"] == game_id and (row.get("downloaded") or row.get("installed"))
for row in rows
):
raise ScenarioError(f"{peer.name} advertises failed local {game_id}: {rows}")
@@ -1312,6 +1946,15 @@ def assert_not_exists(path: Path) -> None:
raise ScenarioError(f"expected path to be absent: {path}")
def assert_failed_stream_left_no_local(peer: Peer, game_id: str) -> None:
game_root = peer.host_games_dir / game_id
assert_local_absent(peer, game_id)
assert_not_exists(game_root / "local")
assert_not_exists(game_root / ".local.installing")
assert_not_exists(game_root / "version.ini")
assert_not_exists(game_root / f"{game_id}.eti")
def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
def predicate(item: dict[str, Any]) -> bool:
if item.get("type") != "event" or item.get("event") != event:
@@ -1323,6 +1966,17 @@ def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]
return predicate
def event_name_in(events: set[str], game_id: str | None = None) -> Callable[[dict[str, Any]], bool]:
def predicate(item: dict[str, Any]) -> bool:
if item.get("type") != "event" or item.get("event") not in events:
return False
if game_id is None:
return True
return item.get("data", {}).get("game_id") == game_id
return predicate
def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) -> None:
for item in peer.output[waiter.seen :]:
if item.get("type") == "event" and item.get("event") == event:
@@ -1330,6 +1984,13 @@ def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) ->
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
def assert_no_event_since(peer: Peer, start: int, event: str, game_id: str) -> None:
for item in peer.output[start:]:
if item.get("type") == "event" and item.get("event") == event:
if item.get("data", {}).get("game_id") == game_id:
raise ScenarioError(f"unexpected {event} for {game_id}: {item}")
def assert_only_chunk_sources(
peer: Peer,
game_id: str,
@@ -1355,6 +2016,16 @@ def assert_only_chunk_sources(
raise ScenarioError(f"no chunk events recorded for {game_id}")
def streamed_chunk_paths(peer: Peer, game_id: str) -> list[str]:
return [
item["data"]["relative_path"]
for item in peer.output
if item.get("type") == "event"
and item.get("event") == "download-chunk-finished"
and item.get("data", {}).get("game_id") == game_id
]
def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]:
totals: dict[str, int] = {}
for item in peer.output:
+54
View File
@@ -33,12 +33,23 @@ pub enum CliCommand {
game_id: String,
install_after_download: bool,
},
StreamInstall {
game_id: String,
},
CancelDownload {
game_id: String,
},
Install {
game_id: String,
},
Uninstall {
game_id: String,
},
Play {
game_id: String,
username: String,
language: Option<String>,
},
WaitPeers {
count: usize,
timeout: Duration,
@@ -58,8 +69,11 @@ impl CliCommand {
Self::ListGames => "list-games",
Self::SetGameDir { .. } => "set-game-dir",
Self::Download { .. } => "download",
Self::StreamInstall { .. } => "stream-install",
Self::CancelDownload { .. } => "cancel-download",
Self::Install { .. } => "install",
Self::Uninstall { .. } => "uninstall",
Self::Play { .. } => "play",
Self::WaitPeers { .. } => "wait-peers",
Self::Connect { .. } => "connect",
Self::Shutdown => "shutdown",
@@ -95,12 +109,26 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
game_id: game_id(object)?,
install_after_download: install_after_download(object)?,
},
"stream-install" => CliCommand::StreamInstall {
game_id: game_id(object)?,
},
"cancel-download" => CliCommand::CancelDownload {
game_id: game_id(object)?,
},
"install" => CliCommand::Install {
game_id: game_id(object)?,
},
"uninstall" => CliCommand::Uninstall {
game_id: game_id(object)?,
},
"play" => CliCommand::Play {
game_id: game_id(object)?,
username: required_str(object, "username")?,
language: object
.get("language")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
"wait-peers" => CliCommand::WaitPeers {
count: required_u64(object, "count")?
.try_into()
@@ -330,6 +358,32 @@ mod tests {
assert_eq!(parsed["data"]["peer_count"], 0);
}
#[test]
fn parses_stream_install_command() {
let parsed = parse_command_line(r#"{"cmd":"stream-install","game_id":"cnctw"}"#)
.expect("command should parse");
assert_eq!(
parsed.command,
CliCommand::StreamInstall {
game_id: "cnctw".to_string(),
}
);
}
#[test]
fn parses_cancel_download_command() {
let parsed = parse_command_line(r#"{"cmd":"cancel-download","game_id":"cnctw"}"#)
.expect("command should parse");
assert_eq!(
parsed.command,
CliCommand::CancelDownload {
game_id: "cnctw".to_string(),
}
);
}
#[tokio::test]
async fn fixture_unpacker_creates_install_payload() {
let temp = TempDir::new("lanspread-peer-cli-fixture");
+98 -16
View File
@@ -12,11 +12,14 @@ use std::{
use eyre::Context;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
ExternalUnrarStreamProvider,
InstallOperation,
NoopStreamInstallProvider,
OutboundTransfers,
PeerCommand,
PeerEvent,
PeerGameDB,
@@ -24,11 +27,14 @@ use lanspread_peer::{
PeerRuntimeHandle,
PeerSnapshot,
PeerStartOptions,
StreamInstallProvider,
migrate_legacy_state,
start_peer_with_options,
};
use lanspread_peer_cli::{
CliCommand,
CommandEnvelope,
DEFAULT_FIXTURE_VERSION,
ExternalUnrarUnpacker,
FixtureSeed,
FixtureUnpacker,
@@ -113,8 +119,11 @@ struct DownloadMeasurement {
struct SharedState {
state: RwLock<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: OutboundTransfers,
notify: Notify,
games_dir: PathBuf,
state_dir: PathBuf,
}
#[tokio::main]
@@ -125,14 +134,21 @@ 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()));
let catalog = Arc::new(RwLock::new(catalog));
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar {
let active_outbound_transfers: OutboundTransfers = Arc::new(RwLock::new(HashMap::new()));
let unrar_for_streaming = args.unrar.clone().or_else(default_unrar_program);
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar.clone() {
Some(path) => Arc::new(ExternalUnrarUnpacker::new(path)),
None => Arc::new(FixtureUnpacker),
};
let stream_install_provider: Arc<dyn StreamInstallProvider> = match unrar_for_streaming {
Some(path) => Arc::new(ExternalUnrarStreamProvider::new(path)),
None => Arc::new(NoopStreamInstallProvider),
};
let mut handle = start_peer_with_options(
args.games_dir.clone(),
@@ -142,6 +158,8 @@ async fn main() -> eyre::Result<()> {
catalog.clone(),
PeerStartOptions {
state_dir: Some(args.state_dir.clone()),
active_outbound_transfers: Some(active_outbound_transfers.clone()),
stream_install_provider: Some(stream_install_provider),
},
)?;
let sender = handle.sender();
@@ -150,7 +168,10 @@ async fn main() -> eyre::Result<()> {
state: RwLock::new(CliState::default()),
peer_game_db,
catalog: catalog.clone(),
active_outbound_transfers,
notify: Notify::new(),
games_dir: args.games_dir.clone(),
state_dir: args.state_dir.clone(),
});
let writer = JsonlWriter::new();
@@ -162,6 +183,7 @@ async fn main() -> eyre::Result<()> {
"name": args.name,
"games_dir": args.games_dir,
"state_dir": args.state_dir,
"migration": migration,
"fixtures": fixture_seeds,
}),
));
@@ -240,6 +262,21 @@ async fn handle_command(
})?;
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
}
CliCommand::StreamInstall { game_id } => {
ensure_catalog_game(shared, game_id).await?;
ensure_no_active_operation(shared, game_id).await?;
sender.send(PeerCommand::StreamInstallGame {
id: game_id.clone(),
})?;
Ok(json!({"queued": true, "game_id": game_id}))
}
CliCommand::CancelDownload { game_id } => {
ensure_catalog_game(shared, game_id).await?;
sender.send(PeerCommand::CancelDownload {
id: game_id.clone(),
})?;
Ok(json!({"queued": true, "game_id": game_id}))
}
CliCommand::Install { game_id } => {
ensure_catalog_game(shared, game_id).await?;
ensure_no_active_operation(shared, game_id).await?;
@@ -248,6 +285,11 @@ async fn handle_command(
})?;
Ok(json!({"queued": true, "game_id": game_id}))
}
CliCommand::Play {
game_id,
username,
language,
} => play(shared, game_id, username, language.as_deref()).await,
CliCommand::Uninstall { game_id } => {
ensure_catalog_game(shared, game_id).await?;
ensure_no_active_operation(shared, game_id).await?;
@@ -275,12 +317,20 @@ async fn handle_command(
async fn status(shared: &SharedState) -> eyre::Result<Value> {
let state = shared.state.read().await;
let peer_count = shared.peer_game_db.read().await.peer_snapshots().len();
let active_outbound_transfers = {
let active = shared.active_outbound_transfers.read().await;
active
.iter()
.map(|(game_id, transfers)| (game_id.clone(), transfers.len()))
.collect::<HashMap<_, _>>()
};
Ok(json!({
"local_peer": state.local_peer.clone(),
"peer_count": peer_count,
"local_games": state.local_games.len(),
"remote_games": state.remote_games.len(),
"active_operations": active_operations_json(&state.active_operations),
"active_outbound_transfers": active_outbound_transfers,
}))
}
@@ -291,15 +341,8 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
let state = shared.state.read().await;
let catalog = shared.catalog.read().await.clone();
let remote = shared
.peer_game_db
.read()
.await
.get_all_games()
.into_iter()
.filter(|game| catalog.contains(&game.id))
.collect::<Vec<_>>();
let catalog = shared.catalog.read().await;
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
Ok(json!({
"local": state.local_games.clone(),
"remote": remote,
@@ -307,6 +350,25 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
}))
}
async fn play(
shared: &SharedState,
game_id: &str,
username: &str,
language: Option<&str>,
) -> eyre::Result<Value> {
ensure_catalog_game(shared, game_id).await?;
let game_root = shared.games_dir.join(game_id);
let outcome = lanspread_peer::apply_launch_settings_once(
&shared.state_dir,
&game_root,
game_id,
Some(username),
language,
)
.await?;
Ok(json!({ "game_id": game_id, "outcome": outcome }))
}
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
if shared.catalog.read().await.contains(game_id) {
return Ok(());
@@ -403,6 +465,7 @@ async fn event_loop(
}
}
#[allow(clippy::too_many_lines)]
async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) {
match event {
PeerEvent::LocalPeerReady { peer_id, addr } => {
@@ -427,6 +490,7 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
state.local_games.clone_from(&games);
("local-library-changed", json!({ "games": games }))
}
PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})),
PeerEvent::ActiveOperationsChanged { active_operations } => {
let mut state = shared.state.write().await;
state.active_operations.clone_from(&active_operations);
@@ -637,18 +701,27 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
.collect()
}
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
let mut catalog = HashSet::new();
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
let mut catalog = GameCatalog::empty();
if let Some(path) = catalog_db
&& path.exists()
{
match get_games(path).await {
Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)),
Ok(games) => {
for game in games {
catalog.insert(game.game_id, Some(game.game_version));
}
}
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
}
}
catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone()));
for seed in fixtures {
catalog.insert(
seed.game_id.clone(),
Some(DEFAULT_FIXTURE_VERSION.to_string()),
);
}
catalog
}
@@ -692,6 +765,15 @@ fn default_catalog_db() -> Option<PathBuf> {
.find(|path| path.exists())
}
fn default_unrar_program() -> Option<PathBuf> {
[
PathBuf::from("/usr/local/bin/unrar"),
PathBuf::from("/usr/bin/unrar"),
]
.into_iter()
.find(|path| path.exists())
}
fn next_string(args: &mut impl Iterator<Item = OsString>, flag: &str) -> eyre::Result<String> {
args.next()
.ok_or_else(|| eyre::eyre!("{flag} requires a value"))?
+33 -8
View File
@@ -116,6 +116,9 @@ Downloaded and installed are independent predicates:
- `installed` is true when `<game_root>/local/` is a directory. The contents of
`local/` are user-owned and are skipped by manifests, fingerprints, and file
serving.
- Install and update transactions unpack into staging, then overwrite the first
discovered game-provided `account_name.txt` and `language.txt` files under
the staged tree from launcher settings before promoting it to `local/`.
Reserved per-game paths:
@@ -124,7 +127,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 +137,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
@@ -152,6 +162,21 @@ Most scans become O(number of game dirs), with full recursion only when needed.
active for that ID, and the root-level `version.ini` sentinel exists.
- `local/` paths are never served, even if a stale or malicious manifest request
asks for them.
- Cancelling a download discards the peer-owned root download payload and
scratch sentinel files. `local/` and install transaction metadata are
preserved, so a cancelled update of an installed game settles as local-only.
### Streamed install integrity
- Low-disk streamed installs request archive-derived file bytes from one peer
and write them directly into the install transaction staging directory.
- The receiver verifies every streamed file against the sender archive's file
size and RAR CRC32 before the transaction may commit. This catches truncated
streams, transport corruption, and provider bugs.
- This is not malicious-peer protection: the peer controls both the archive
metadata and the streamed bytes. A trusted-content model needs catalog-owned
hashes, either for the root archives or for extracted files, and receiver-side
SHA-256 verification against those catalog values before commit.
## Fault tolerance rules
@@ -192,8 +217,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.
+7 -6
View File
@@ -3,10 +3,8 @@ name = "lanspread-peer"
version = "0.1.0"
edition = "2024"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
[dependencies]
lanspread-db = { path = "../lanspread-db" }
@@ -16,6 +14,7 @@ lanspread-utils = { path = "../lanspread-utils" }
# external
bytes = { workspace = true }
crc32fast = { workspace = true }
eyre = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
@@ -31,5 +30,7 @@ tokio-util = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
[lib]
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
+45 -15
View File
@@ -14,8 +14,8 @@ It is designed to run headless other crates (most notably
roots are announced or served.
- `PeerCommand` represents the small control surface exposed to the UI layer:
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`,
`InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`,
`SetGameDir`, and `GetPeerCount`.
`StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`,
`CancelDownload`, `SetGameDir`, and `GetPeerCount`.
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
library snapshots, download/install/uninstall lifecycle updates, runtime
failures, and peer membership changes.
@@ -28,8 +28,8 @@ lifetime of the process:
1. **Server component** (`run_server_component`) listens for QUIC connections,
advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`,
`Request::GetGameFileData`, and `Request::GetGameFileChunk` by reading from
the local game directory.
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
`Request::StreamInstall` by reading from the local game directory.
2. **Discovery loop** (`run_peer_discovery`) uses the `lanspread-mdns`
helper to discover other peers. The blocking mDNS work is executed on a
dedicated thread via `tokio::task::spawn_blocking` so that the Tokio runtime
@@ -75,33 +75,63 @@ When the UI asks to download a game:
keeps existing data intact and only truncates when we intentionally fall back
to a full file transfer, which prevents corruption when multiple peers fill
different regions of the same file.
5. `version.ini` chunks are buffered in memory and committed last via
5. `DownloadProgressTracker` samples byte counters, transfer speed, and the
number of unique peers that are actively streaming chunks. The Tauri UI sees
those values together through the regular download-progress event.
6. `version.ini` chunks are buffered in memory and committed last via
`.version.ini.tmp` followed by an atomic rename. Failures are accumulated and
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed or
cancelled downloads sweep `.version.ini.tmp` and `.version.ini.discarded`
without restoring the previous sentinel.
6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
retried (up to `MAX_RETRY_COUNT`) via `retry_failed_chunks`; failed downloads
sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the
previous sentinel. Cancelled downloads also discard the peer-owned download
payload while preserving `local/` and install transaction metadata.
7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
is emitted and the peer auto-runs the install transaction.
### Streamed Install Pipeline
Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal
archive download pipeline. The peer core owns the whole operation: it refreshes
file metadata from catalog-version peers, runs the same majority file-size
validation used by normal downloads, selects a validated peer list, and emits
the regular download/install lifecycle events while streaming archive-expanded
bytes directly into a `StreamedInstallTransaction`.
The sender-side `StreamInstallProvider` writes control and chunk frames through
a cancellable `StreamInstallFrameSink`. If the QUIC writer fails because the
receiver cancelled or disconnected, the sink wakes any producer blocked on the
bounded frame channel and lets the transfer guard drop normally.
Each failed peer attempt rolls back its staging directory before trying the next
validated peer. A transaction that created a previously missing game root
removes that root again when rollback leaves it empty. Once staging has been
renamed to `local/`, post-promote intent or launch-settings cleanup failures are
logged for startup recovery rather than reported as a failed install.
`PeerCommand::CancelDownload` cancels the tracked download token for an active
transfer. The transfer task remains responsible for clearing `active_operations`,
so the UI continues to treat active-operation snapshots as the single source of
discarding partial payload files, and refreshing the settled local snapshot, so
the UI continues to treat active-operation snapshots as the single source of
truth for whether a download is still running.
### Install Transactions
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
+29 -11
View File
@@ -1,17 +1,22 @@
//! Shared context types for the peer system.
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
path::PathBuf,
sync::Arc,
};
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
use lanspread_db::db::GameDB;
use lanspread_db::db::{GameCatalog, GameDB};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
use crate::{
PeerEvent,
StreamInstallProvider,
Unpacker,
events,
library::LocalLibraryState,
peer_db::PeerGameDB,
};
/// Thread-safe map of active outbound file transfers grouped by game ID.
pub type OutboundTransfers = Arc<RwLock<HashMap<String, Vec<(u64, CancellationToken)>>>>;
/// Mutating filesystem operation currently in flight for a game root.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -32,6 +37,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>>,
@@ -39,10 +45,12 @@ pub struct Ctx {
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
pub active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
pub unpacker: Arc<dyn Unpacker>,
pub catalog: Arc<RwLock<HashSet<String>>>,
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
pub catalog: Arc<RwLock<GameCatalog>>,
pub peer_id: Arc<String>,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
}
/// Context for peer connection handling.
@@ -54,11 +62,13 @@ pub struct PeerCtx {
pub local_peer_addr: Arc<RwLock<Option<SocketAddr>>>,
pub active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
pub catalog: Arc<RwLock<HashSet<String>>>,
pub catalog: Arc<RwLock<GameCatalog>>,
pub peer_id: Arc<String>,
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
pub stream_install_provider: Arc<dyn StreamInstallProvider>,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
pub active_outbound_transfers: OutboundTransfers,
}
impl std::fmt::Debug for PeerCtx {
@@ -79,13 +89,17 @@ 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,
catalog: Arc<RwLock<HashSet<String>>>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
) -> 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,
@@ -93,10 +107,12 @@ impl Ctx {
active_operations: Arc::new(RwLock::new(HashMap::new())),
active_downloads: Arc::new(RwLock::new(HashMap::new())),
unpacker,
stream_install_provider,
catalog,
peer_id: Arc::new(peer_id),
shutdown,
task_tracker,
active_outbound_transfers,
}
}
@@ -115,8 +131,10 @@ impl Ctx {
catalog: self.catalog.clone(),
peer_id: self.peer_id.clone(),
tx_notify_ui,
stream_install_provider: self.stream_install_provider.clone(),
shutdown: self.shutdown.clone(),
task_tracker: self.task_tracker.clone(),
active_outbound_transfers: self.active_outbound_transfers.clone(),
}
}
}
@@ -4,6 +4,7 @@ mod orchestrator;
mod planning;
mod progress;
mod retry;
mod storage;
mod transport;
mod version_ini;
@@ -10,15 +10,10 @@ use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use super::{
planning::{
ChunkDownloadResult,
DownloadChunk,
build_peer_plans,
extract_version_descriptor,
prepare_game_storage,
},
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor},
progress::{DownloadProgressTracker, sample_download_progress},
retry::{RetryContext, retry_failed_chunks},
storage::{discard_cancelled_download, prepare_game_storage},
transport::download_from_peer,
version_ini::{
VersionIniBuffer,
@@ -48,34 +43,30 @@ 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;
eyre::bail!("download cancelled for game {game_id}");
}
if let Err(err) = prepare_game_storage(&games_folder, &transfer_descs).await {
rollback_version_ini_transaction(&game_root).await;
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
return Err(err);
}
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
@@ -104,28 +95,32 @@ pub async fn download_game_files(
if let Err(err) = transfer_result {
rollback_version_ini_transaction(&game_root).await;
if !cancel_token.is_cancelled() {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
}
return Err(err);
}
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
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(())
}
async fn discard_cancelled_download_best_effort(games_folder: &Path, game_id: &str) {
if let Err(err) = discard_cancelled_download(games_folder, game_id).await {
log::warn!("Failed to discard cancelled download payload for {game_id}: {err}");
}
}
struct TransferContext<'a> {
game_id: &'a str,
games_folder: &'a Path,
+5 -82
View File
@@ -1,9 +1,8 @@
use std::{collections::HashMap, net::SocketAddr, path::Path};
use std::{collections::HashMap, net::SocketAddr};
use lanspread_db::db::GameFileDescription;
use tokio::{fs::OpenOptions, sync::mpsc::UnboundedSender};
use crate::{PeerEvent, config::CHUNK_SIZE, path_validation::validate_game_file_path};
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()
@@ -60,56 +55,6 @@ pub(super) fn extract_version_descriptor(
Ok((version_desc, transfer_descs))
}
/// Prepares storage for game files by creating directories and pre-allocating files.
pub(super) async fn prepare_game_storage(
games_folder: &Path,
file_descs: &[GameFileDescription],
) -> eyre::Result<()> {
for desc in file_descs {
if desc.is_version_ini() {
continue;
}
// Validate the path to prevent directory traversal
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
if desc.is_dir {
tokio::fs::create_dir_all(&validated_path).await?;
} else {
if let Some(parent) = validated_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// Create and pre-allocate the file with the expected size
let file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&validated_path)
.await?;
// Pre-allocate the file with the expected size
let size = desc.size;
if let Err(e) = file.set_len(size).await {
log::warn!(
"Failed to pre-allocate file {} (size: {}): {}",
desc.relative_path,
size,
e
);
// Continue without pre-allocation - the file will grow as chunks are written
} else {
log::debug!(
"Pre-allocated file {} with {} bytes",
desc.relative_path,
size
);
}
}
}
Ok(())
}
/// Resolves which peers have a specific file.
pub(super) fn resolve_file_peers<'a>(
relative_path: &str,
@@ -206,8 +151,6 @@ mod tests {
use lanspread_db::db::GameFileDescription;
use super::*;
use crate::test_support::TempDir;
fn loopback_addr(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}
@@ -346,26 +289,8 @@ mod tests {
}
}
#[tokio::test]
async fn prepare_game_storage_skips_version_ini_sentinel() {
let temp = TempDir::new("lanspread-download");
let descs = vec![GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/version.ini".to_string(),
is_dir: false,
size: 8,
}];
prepare_game_storage(temp.path(), &descs)
.await
.expect("storage preparation should succeed");
assert!(!temp.path().join("game").join("version.ini").exists());
}
#[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(),
@@ -382,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(),
@@ -416,6 +339,6 @@ mod tests {
size: 8,
},
];
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
assert!(extract_version_descriptor("game", multiple).is_err());
}
}
+81 -3
View File
@@ -1,6 +1,7 @@
use std::{
collections::HashMap,
future::Future,
net::SocketAddr,
sync::{
Arc,
Mutex,
@@ -29,6 +30,7 @@ pub(super) struct DownloadProgressTracker {
downloaded_bytes: AtomicU64,
transferred_bytes: AtomicU64,
chunks: Mutex<HashMap<ChunkProgressKey, u64>>,
active_peers: Mutex<HashMap<SocketAddr, usize>>,
}
impl DownloadProgressTracker {
@@ -38,11 +40,13 @@ impl DownloadProgressTracker {
downloaded_bytes: AtomicU64::new(0),
transferred_bytes: AtomicU64::new(0),
chunks: Mutex::new(HashMap::new()),
active_peers: Mutex::new(HashMap::new()),
})
}
pub(super) fn track_chunk(
self: &Arc<Self>,
peer_addr: SocketAddr,
relative_path: &str,
offset: u64,
expected_bytes: u64,
@@ -53,6 +57,7 @@ impl DownloadProgressTracker {
relative_path: relative_path.to_string(),
offset,
},
_peer_activity: self.track_active_peer(peer_addr),
expected_bytes,
received_bytes: 0,
}
@@ -104,12 +109,51 @@ impl DownloadProgressTracker {
}
}
fn track_active_peer(self: &Arc<Self>, peer_addr: SocketAddr) -> ActivePeerDownload {
{
let mut active_peers = self
.active_peers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*active_peers.entry(peer_addr).or_default() += 1;
}
ActivePeerDownload {
tracker: self.clone(),
peer_addr,
}
}
fn finish_active_peer(&self, peer_addr: SocketAddr) {
let mut active_peers = self
.active_peers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(count) = active_peers.get_mut(&peer_addr) else {
return;
};
if *count <= 1 {
active_peers.remove(&peer_addr);
} else {
*count -= 1;
}
}
fn active_peer_count(&self) -> usize {
self.active_peers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
fn snapshot(&self, id: &str, bytes_per_second: u64) -> DownloadProgress {
DownloadProgress {
id: id.to_string(),
downloaded_bytes: self.reported_downloaded_bytes(),
total_bytes: self.total_bytes,
bytes_per_second,
active_peer_count: self.active_peer_count(),
}
}
}
@@ -117,6 +161,7 @@ impl DownloadProgressTracker {
pub(super) struct ChunkProgress {
tracker: Arc<DownloadProgressTracker>,
key: ChunkProgressKey,
_peer_activity: ActivePeerDownload,
expected_bytes: u64,
received_bytes: u64,
}
@@ -136,6 +181,17 @@ impl ChunkProgress {
}
}
struct ActivePeerDownload {
tracker: Arc<DownloadProgressTracker>,
peer_addr: SocketAddr,
}
impl Drop for ActivePeerDownload {
fn drop(&mut self) {
self.tracker.finish_active_peer(self.peer_addr);
}
}
fn add_saturating(counter: &AtomicU64, delta: u64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(delta))
@@ -230,14 +286,19 @@ where
mod tests {
use super::*;
fn loopback_addr(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}
#[test]
fn tracker_counts_only_new_bytes_for_a_retried_chunk() {
let tracker = DownloadProgressTracker::new(100);
let mut first_attempt = tracker.track_chunk("game/file.bin", 0, 100);
let peer = loopback_addr(12000);
let mut first_attempt = tracker.track_chunk(peer, "game/file.bin", 0, 100);
first_attempt.record_bytes(40);
first_attempt.record_bytes(10);
let mut retry = tracker.track_chunk("game/file.bin", 0, 100);
let mut retry = tracker.track_chunk(peer, "game/file.bin", 0, 100);
retry.record_bytes(25);
retry.record_bytes(50);
@@ -248,10 +309,27 @@ mod tests {
#[test]
fn tracker_clamps_reported_bytes_to_total() {
let tracker = DownloadProgressTracker::new(10);
let mut chunk = tracker.track_chunk("game/file.bin", 0, 0);
let mut chunk = tracker.track_chunk(loopback_addr(12000), "game/file.bin", 0, 0);
chunk.record_bytes(25);
assert_eq!(tracker.raw_downloaded_bytes(), 25);
assert_eq!(tracker.reported_downloaded_bytes(), 10);
}
#[test]
fn tracker_reports_unique_active_peer_count() {
let tracker = DownloadProgressTracker::new(100);
let first_peer = loopback_addr(12000);
let second_peer = loopback_addr(12001);
{
let _first_chunk = tracker.track_chunk(first_peer, "game/file.bin", 0, 50);
let _second_chunk = tracker.track_chunk(first_peer, "game/file.bin", 50, 50);
let _third_chunk = tracker.track_chunk(second_peer, "game/other.bin", 0, 10);
assert_eq!(tracker.snapshot("game", 0).active_peer_count, 2);
}
assert_eq!(tracker.snapshot("game", 0).active_peer_count, 0);
}
}
@@ -0,0 +1,219 @@
use std::{io::ErrorKind, path::Path};
use lanspread_db::db::GameFileDescription;
use tokio::fs::OpenOptions;
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
const SYNC_DIR: &str = ".sync";
/// Prepares storage for game files by creating directories and pre-allocating files.
pub(super) async fn prepare_game_storage(
games_folder: &Path,
file_descs: &[GameFileDescription],
) -> eyre::Result<()> {
for desc in file_descs {
if desc.is_version_ini() {
continue;
}
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
if desc.is_dir {
tokio::fs::create_dir_all(&validated_path).await?;
} else {
if let Some(parent) = validated_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&validated_path)
.await?;
let size = desc.size;
if let Err(e) = file.set_len(size).await {
log::warn!(
"Failed to pre-allocate file {} (size: {}): {}",
desc.relative_path,
size,
e
);
} else {
log::debug!(
"Pre-allocated file {} with {} bytes",
desc.relative_path,
size
);
}
}
}
Ok(())
}
/// Discards the peer-owned downloaded payload after a cancelled transfer.
///
/// Downloads own the root archive/cache files, but not `local/` or install
/// transaction metadata. Preserving those paths lets a cancelled update settle
/// as a local-only install instead of deleting user-owned extracted files.
pub(super) async fn discard_cancelled_download(
games_folder: &Path,
game_id: &str,
) -> eyre::Result<()> {
let game_root = games_folder.join(game_id);
let Some(metadata) = symlink_metadata_if_exists(&game_root).await? else {
return Ok(());
};
if metadata.file_type().is_symlink() {
eyre::bail!(
"refusing to discard cancelled download through symlink root {}",
game_root.display()
);
}
if !metadata.is_dir() {
eyre::bail!(
"refusing to discard cancelled download from non-directory root {}",
game_root.display()
);
}
let mut entries = tokio::fs::read_dir(&game_root).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
if name
.to_str()
.is_some_and(should_preserve_on_download_discard)
{
continue;
}
remove_entry(&entry.path()).await?;
}
remove_dir_if_empty(&game_root).await
}
fn should_preserve_on_download_discard(name: &str) -> bool {
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
}
async fn remove_entry(path: &Path) -> eyre::Result<()> {
let Some(metadata) = symlink_metadata_if_exists(path).await? else {
return Ok(());
};
if metadata.file_type().is_symlink() || metadata.is_file() {
remove_file_if_exists(path).await
} else if metadata.is_dir() {
tokio::fs::remove_dir_all(path).await?;
Ok(())
} else {
remove_file_if_exists(path).await
}
}
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn remove_dir_if_empty(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_dir(path).await {
Ok(()) => Ok(()),
Err(err)
if matches!(
err.kind(),
ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
) =>
{
Ok(())
}
Err(err) => Err(err.into()),
}
}
async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
#[cfg(test)]
mod tests {
use lanspread_db::db::GameFileDescription;
use super::*;
use crate::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 prepare_game_storage_skips_version_ini_sentinel() {
let temp = TempDir::new("lanspread-download");
let descs = vec![GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/version.ini".to_string(),
is_dir: false,
size: 8,
}];
prepare_game_storage(temp.path(), &descs)
.await
.expect("storage preparation should succeed");
assert!(!temp.path().join("game").join("version.ini").exists());
}
#[tokio::test]
async fn discard_cancelled_download_removes_peer_owned_payload() {
let temp = TempDir::new("lanspread-download-discard");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join(".version.ini.tmp"), b"tmp");
write_file(&root.join(".version.ini.discarded"), b"old");
write_file(&root.join("archive.eti"), b"partial");
write_file(&root.join("nested").join("payload.bin"), b"partial");
discard_cancelled_download(temp.path(), "game")
.await
.expect("cancelled payload should be discarded");
assert!(!root.exists());
}
#[tokio::test]
async fn discard_cancelled_download_preserves_local_install_state() {
let temp = TempDir::new("lanspread-download-discard-local");
let root = temp.game_root();
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(".local.backup").join(".lanspread_owned"), b"");
discard_cancelled_download(temp.path(), "game")
.await
.expect("cancelled payload should be discarded");
assert!(!root.join("version.ini").exists());
assert!(!root.join("archive.eti").exists());
assert_eq!(
std::fs::read(root.join("local").join("save.dat"))
.expect("local install should remain"),
b"user-data"
);
assert!(root.join(".local.backup").is_dir());
}
}
@@ -62,6 +62,7 @@ async fn open_chunk_stream(
/// Receives one requested chunk from a peer stream.
async fn receive_chunk(
peer_addr: SocketAddr,
mut rx: ReceiveStream,
base_dir: &Path,
chunk: &DownloadChunk,
@@ -71,7 +72,7 @@ async fn receive_chunk(
if let Some(buffer) = version_buffer
&& buffer.matches(&chunk.relative_path)
{
return download_version_ini_chunk(rx, chunk, &buffer, progress_tracker).await;
return download_version_ini_chunk(peer_addr, rx, chunk, &buffer, progress_tracker).await;
}
// Validate the path to prevent directory traversal
@@ -91,7 +92,7 @@ async fn receive_chunk(
let mut remaining = chunk.length;
let mut received_bytes = 0u64;
let mut progress =
progress_tracker.track_chunk(&chunk.relative_path, chunk.offset, chunk.length);
progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length);
while let Some(bytes) = rx.receive().await? {
file.write_all(&bytes).await?;
@@ -135,7 +136,15 @@ async fn receive_chunk_result(
version_buffer: Option<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>,
) -> ChunkDownloadResult {
let result = receive_chunk(rx, &base_dir, &chunk, version_buffer, progress_tracker).await;
let result = receive_chunk(
peer_addr,
rx,
&base_dir,
&chunk,
version_buffer,
progress_tracker,
)
.await;
ChunkDownloadResult {
chunk,
result,
@@ -144,6 +153,7 @@ async fn receive_chunk_result(
}
async fn download_version_ini_chunk(
peer_addr: SocketAddr,
mut rx: ReceiveStream,
chunk: &DownloadChunk,
buffer: &VersionIniBuffer,
@@ -151,7 +161,7 @@ async fn download_version_ini_chunk(
) -> eyre::Result<()> {
let mut received = Vec::new();
let mut progress =
progress_tracker.track_chunk(&chunk.relative_path, chunk.offset, chunk.length);
progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length);
while let Some(bytes) = rx.receive().await? {
progress.record_bytes(bytes.len());
received.extend_from_slice(&bytes);
@@ -311,7 +321,12 @@ pub(super) async fn download_from_peer(
ensure_download_not_cancelled(cancel_token, game_id)?;
let mut conn = match connect_to_peer(peer_addr).await {
let mut conn = match tokio::select! {
() = cancel_token.cancelled() => {
eyre::bail!("download cancelled for game {game_id}");
}
result = connect_to_peer(peer_addr) => result,
} {
Ok(conn) => conn,
Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)),
};
+6 -1
View File
@@ -2,6 +2,7 @@
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use lanspread_db::db::GameCatalog;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
@@ -65,9 +66,13 @@ fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
pub async fn emit_peer_game_list(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) {
let games = { peer_game_db.read().await.get_all_games() };
let games = {
let catalog = catalog.read().await;
peer_game_db.read().await.get_catalog_games(&catalog)
};
send(tx_notify_ui, PeerEvent::ListGames(games));
}
+727 -106
View File
@@ -6,10 +6,12 @@ use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::sync::CancellationToken;
use crate::{
InstallOperation,
@@ -23,7 +25,7 @@ use crate::{
game_from_summary,
get_game_file_descriptions,
local_dir_is_directory,
local_download_available,
local_download_matches_catalog,
rescan_local_game,
scan_local_library,
version_ini_is_regular_file,
@@ -32,16 +34,20 @@ use crate::{
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
services::{HandshakeCtx, perform_handshake_with_peer},
stream_install::receive_streamed_install,
};
// =============================================================================
// Command handlers
// =============================================================================
const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10);
const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// Handles the `ListGames` command.
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
log::info!("ListGames command received");
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, tx_notify_ui).await;
}
/// Tries to serve a game from local files.
@@ -54,7 +60,7 @@ async fn try_serve_local_game(
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
return false;
}
drop(active_operations);
@@ -90,9 +96,10 @@ pub(crate) async fn handle_get_game_command(
}
log::info!("Requesting game from peers: {id}");
let expected_version = catalog_expected_version(ctx, &id).await;
let peers = {
let peer_game_db = ctx.peer_game_db.read().await;
source.select_peers(&peer_game_db, &id)
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
};
if peers.is_empty() {
log::warn!("No peers have game {id}");
@@ -107,6 +114,7 @@ pub(crate) async fn handle_get_game_command(
ctx.task_tracker.spawn(fetch_game_details_from_peers(
peers,
id,
expected_version,
peer_game_db,
tx_notify_ui,
|peer_addr, game_id, peer_game_db| async move {
@@ -126,10 +134,16 @@ impl GameDetailSource {
matches!(self, Self::LocalOrPeers)
}
fn select_peers(self, peer_game_db: &PeerGameDB, id: &str) -> Vec<SocketAddr> {
fn select_peers(
self,
peer_game_db: &PeerGameDB,
id: &str,
expected_version: Option<&str>,
) -> Vec<SocketAddr> {
match self {
Self::LocalOrPeers => peer_game_db.peers_with_game(id),
Self::LatestPeersOnly => peer_game_db.peers_with_latest_version(id),
Self::LocalOrPeers | Self::LatestPeersOnly => {
peer_game_db.peers_with_expected_version(id, expected_version)
}
}
}
}
@@ -154,6 +168,7 @@ async fn request_game_details_and_update(
async fn fetch_game_details_from_peers<F, Fut>(
peers: Vec<SocketAddr>,
id: String,
expected_version: Option<String>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
mut fetch_details: F,
@@ -175,7 +190,12 @@ async fn fetch_game_details_from_peers<F, Fut>(
}
if fetched_any {
let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };
let aggregated_files = {
peer_game_db
.read()
.await
.aggregated_game_files(&id, expected_version.as_deref())
};
if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
id: id.clone(),
@@ -210,6 +230,7 @@ pub async fn handle_download_game_files_command(
}
let games_folder = { ctx.game_dir.read().await.clone() };
let expected_version = catalog_expected_version(ctx, &id).await;
// Use majority validation to get trusted file descriptions and peer whitelist
let (validated_descriptions, peer_whitelist, file_peer_map) = {
@@ -217,7 +238,7 @@ pub async fn handle_download_game_files_command(
.peer_game_db
.read()
.await
.validate_file_sizes_majority(&id)
.validate_file_sizes_majority(&id, expected_version.as_deref())
{
Ok((files, peers, file_peer_map)) => {
log::info!(
@@ -260,7 +281,7 @@ pub async fn handle_download_game_files_command(
let local_dl_available = {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
local_download_available(&games_folder, &id, &active_operations, &catalog).await
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).await
};
if peer_whitelist.is_empty() {
@@ -289,9 +310,17 @@ pub async fn handle_download_game_files_command(
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before downloading {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
}
let active_operations = ctx.active_operations.clone();
@@ -321,7 +350,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;
@@ -330,8 +359,18 @@ pub async fn handle_download_game_files_command(
let Some(prepared) =
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
else {
if let Err(err) = refresh_local_game_for_ending_operation(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
)
.await
{
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);
return;
};
@@ -345,6 +384,8 @@ 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,
@@ -353,22 +394,50 @@ pub async fn handle_download_game_files_command(
)
.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 {
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
if let Err(err) =
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
if let Err(err) = refresh_local_game_for_ending_operation(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
)
.await
{
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);
}
download_state_guard.disarm();
}
Err(e) => {
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
)
.await
{
log::error!(
"Failed to refresh local library after download failure: {refresh_err}"
);
}
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
log::error!("Download failed for {download_id}: {e}");
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,
);
}
}
});
@@ -383,6 +452,60 @@ pub async fn handle_install_game_command(
spawn_install_operation(ctx, tx_notify_ui, id);
}
pub async fn handle_stream_install_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring streamed install command for non-catalog game {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
let games_folder = { ctx.game_dir.read().await.clone() };
let game_root = games_folder.join(&id);
if local_dir_is_directory(&game_root).await {
log::warn!("Ignoring streamed install command for already-installed game {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
send_download_failed(tx_notify_ui, &id);
return;
}
}
let expected_version = catalog_expected_version(ctx, &id).await;
let cancel_token = ctx.shutdown.child_token();
ctx.active_downloads
.write()
.await
.insert(id.clone(), cancel_token.clone());
let ctx_clone = ctx.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.spawn(async move {
run_stream_install_operation(
ctx_clone,
tx_notify_ui,
id,
game_root,
expected_version,
cancel_token,
)
.await;
});
}
/// Handles the `UninstallGame` command.
pub async fn handle_uninstall_game_command(
ctx: &Ctx,
@@ -423,6 +546,284 @@ pub async fn handle_cancel_download_command(
cancel_token.cancel();
}
async fn run_stream_install_operation(
ctx: Ctx,
tx_notify_ui: UnboundedSender<PeerEvent>,
id: String,
game_root: PathBuf,
expected_version: Option<String>,
cancel_token: CancellationToken,
) {
let download_guard = OperationGuard::download(
id.clone(),
ctx.active_operations.clone(),
ctx.active_downloads.clone(),
tx_notify_ui.clone(),
);
events::send(
&tx_notify_ui,
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
);
let peer_addrs =
match select_stream_install_peers(&ctx, &id, expected_version.as_deref(), &cancel_token)
.await
{
Ok(peers) => peers,
Err(err) => {
let download_was_cancelled = cancel_token.is_cancelled();
if download_was_cancelled {
log::info!("Streamed install preflight cancelled for {id}: {err}");
} else {
log::error!("Streamed install preflight failed for {id}: {err}");
}
finish_failed_stream_download(
&ctx,
&tx_notify_ui,
&id,
download_guard,
download_was_cancelled,
)
.await;
return;
}
};
match receive_streamed_install_from_peers(
&ctx,
&tx_notify_ui,
&id,
&game_root,
&peer_addrs,
&cancel_token,
)
.await
{
Ok(transaction) => {
if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing)
.await
{
clear_active_download(&ctx, &id).await;
send_download_finished(&tx_notify_ui, &id);
download_guard.disarm();
commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await;
return;
}
if let Err(err) = transaction.rollback().await {
log::error!("Failed to roll back streamed install for {id}: {err}");
}
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false).await;
}
Err(err) => {
let download_was_cancelled = cancel_token.is_cancelled();
if download_was_cancelled {
log::info!("Streamed install download cancelled for {id}: {err}");
} else {
log::error!("Streamed install download failed for {id}: {err}");
}
finish_failed_stream_download(
&ctx,
&tx_notify_ui,
&id,
download_guard,
download_was_cancelled,
)
.await;
}
}
}
async fn receive_streamed_install_from_peers(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
game_root: &Path,
peer_addrs: &[SocketAddr],
cancel_token: &CancellationToken,
) -> eyre::Result<install::StreamedInstallTransaction> {
let mut last_receive_error = None;
for &peer_addr in peer_addrs {
if cancel_token.is_cancelled() {
eyre::bail!("streamed install for {id} was cancelled");
}
let transaction =
install::begin_streamed_install(game_root, ctx.state_dir.as_ref(), id).await?;
let receive_result = receive_streamed_install(
peer_addr,
id,
transaction.staging_dir(),
tx_notify_ui.clone(),
cancel_token.clone(),
)
.await;
match receive_result {
Ok(()) => return Ok(transaction),
Err(err) => {
if let Err(rollback_err) = transaction.rollback().await {
log::error!("Failed to roll back streamed install for {id}: {rollback_err}");
}
if cancel_token.is_cancelled() {
return Err(err);
}
log::warn!(
"Streamed install attempt from {peer_addr} failed for {id}; trying another peer if available: {err}"
);
last_receive_error = Some(err);
}
}
}
Err(last_receive_error.unwrap_or_else(|| {
eyre::eyre!("streamed install download failed for {id}: no peer attempts were made")
}))
}
async fn select_stream_install_peers(
ctx: &Ctx,
id: &str,
expected_version: Option<&str>,
cancel_token: &CancellationToken,
) -> eyre::Result<Vec<SocketAddr>> {
let mut metadata_peers = {
ctx.peer_game_db
.read()
.await
.peers_with_expected_version(id, expected_version)
};
metadata_peers.sort();
if metadata_peers.is_empty() {
eyre::bail!("no peers have game {id}");
}
refresh_stream_install_file_details(ctx, id, &metadata_peers, cancel_token).await?;
let mut peers = match ctx
.peer_game_db
.read()
.await
.validate_file_sizes_majority(id, expected_version)
{
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => peer_whitelist,
Ok(_) => {
eyre::bail!("no trusted peers available for streamed install of {id}");
}
Err(err) => {
return Err(err.wrap_err(format!(
"file size majority validation failed for streamed install {id}"
)));
}
};
peers.sort();
if peers.is_empty() {
eyre::bail!("no peer selected for streamed install of {id}");
}
Ok(peers)
}
async fn refresh_stream_install_file_details(
ctx: &Ctx,
id: &str,
peers: &[SocketAddr],
cancel_token: &CancellationToken,
) -> eyre::Result<()> {
let mut fetched_any = false;
for &peer_addr in peers {
if cancel_token.is_cancelled() {
eyre::bail!("streamed install for {id} was cancelled");
}
match request_game_details_and_update(peer_addr, id, ctx.peer_game_db.clone()).await {
Ok(_) => {
log::info!("Fetched streamed-install file list for {id} from peer {peer_addr}");
fetched_any = true;
}
Err(err) => {
log::error!(
"Failed to fetch streamed-install files for {id} from {peer_addr}: {err}"
);
}
}
}
if !fetched_any {
eyre::bail!("failed to retrieve game files for {id} from any peer");
}
Ok(())
}
async fn finish_failed_stream_download(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
guard: OperationGuard,
cancelled: bool,
) {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await {
log::error!("Failed to refresh local library after streamed install failure: {err}");
}
end_download_operation(ctx, tx_notify_ui, id).await;
guard.disarm();
send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled);
}
async fn commit_streamed_install(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
transaction: install::StreamedInstallTransaction,
) {
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation: InstallOperation::Installing,
},
);
match transaction.commit().await {
Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after streamed install: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::InstallGameFinished { id: id.clone() },
);
}
Err(err) => {
log::error!("Streamed install commit failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!(
"Failed to refresh local library after streamed install commit failure: {refresh_err}"
);
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
}
}
}
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
let ctx = ctx.clone();
let tx_notify_ui = tx_notify_ui.clone();
@@ -436,9 +837,20 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
return;
};
if !begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
match begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before install/update of {id}");
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
return;
}
}
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
@@ -514,12 +926,13 @@ 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()).await
}
InstallOperation::Updating => {
install::update(&game_root, &id, ctx.unpacker.clone()).await
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
}
}
};
@@ -560,9 +973,20 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before uninstall of {id}");
events::send(
tx_notify_ui,
PeerEvent::UninstallGameFailed { id: id.clone() },
);
return;
}
}
let game_root = { ctx.game_dir.read().await.join(&id) };
@@ -577,7 +1001,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 {
@@ -622,9 +1046,20 @@ async fn run_remove_downloaded_operation(
return;
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
return;
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
return;
}
BeginOperationResult::DrainTimedOut => {
log::error!("Timed out waiting for outbound transfers before removal of {id}");
events::send(
tx_notify_ui,
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
);
return;
}
}
let game_dir = { ctx.game_dir.read().await.clone() };
@@ -674,12 +1109,36 @@ async fn run_remove_downloaded_operation(
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BeginOperationResult {
Started,
AlreadyActive,
DrainTimedOut,
}
async fn begin_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
) -> bool {
) -> BeginOperationResult {
begin_operation_with_drain_timeout(
ctx,
tx_notify_ui,
id,
operation,
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
)
.await
}
async fn begin_operation_with_drain_timeout(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
drain_timeout: Duration,
) -> BeginOperationResult {
let started = {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.entry(id.to_string()) {
@@ -691,11 +1150,70 @@ async fn begin_operation(
}
};
if started {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
if !started {
return BeginOperationResult::AlreadyActive;
}
started
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
if operation_requires_outbound_drain(operation)
&& !cancel_and_wait_for_outbound_transfers(ctx, id, drain_timeout).await
{
end_operation(ctx, tx_notify_ui, id).await;
return BeginOperationResult::DrainTimedOut;
}
BeginOperationResult::Started
}
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
operation == OperationKind::Updating || operation == OperationKind::RemovingDownload
}
async fn cancel_and_wait_for_outbound_transfers(
ctx: &Ctx,
id: &str,
drain_timeout: Duration,
) -> bool {
let mut tokens_to_cancel = Vec::new();
{
let active = ctx.active_outbound_transfers.read().await;
if let Some(transfers) = active.get(id) {
for (_, token) in transfers {
tokens_to_cancel.push(token.clone());
}
}
}
for token in tokens_to_cancel {
token.cancel();
}
let drained = tokio::time::timeout(drain_timeout, async {
loop {
let count = {
let active = ctx.active_outbound_transfers.read().await;
active.get(id).map_or(0, Vec::len)
};
if count == 0 {
break;
}
tokio::time::sleep(OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL).await;
}
})
.await
.is_ok();
if !drained {
let count = {
let active = ctx.active_outbound_transfers.read().await;
active.get(id).map_or(0, Vec::len)
};
log::error!(
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}"
);
}
drained
}
async fn transition_download_to_install(
@@ -743,6 +1261,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;
@@ -752,6 +1295,14 @@ async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
ctx.catalog.read().await.contains(id)
}
async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
ctx.catalog
.read()
.await
.expected_version(id)
.map(ToOwned::to_owned)
}
/// Handles the `SetGameDir` command.
pub async fn handle_set_game_dir_command(
ctx: &Ctx,
@@ -821,7 +1372,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
}
@@ -846,30 +1397,11 @@ 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(())
}
async fn refresh_local_game(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
) -> 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?;
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
scan,
LocalLibraryEventPolicy::OnChange,
None,
)
.await;
Ok(())
}
/// Refreshes the game whose operation has completed before clearing its
/// active-operation snapshot, while preserving freeze behavior for other games.
async fn refresh_local_game_for_ending_operation(
@@ -879,7 +1411,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,
@@ -961,13 +1493,8 @@ async fn update_and_announce_games_with_policy(
active_operation_ids.remove(id);
}
if !active_operation_ids.is_empty() {
let previous = ctx.local_library.read().await.games.clone();
for id in &active_operation_ids {
if let Some(summary) = previous.get(id.as_str()) {
summaries.insert(id.clone(), summary.clone());
} else {
summaries.remove(id);
}
summaries.remove(id);
}
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
}
@@ -1021,13 +1548,14 @@ async fn update_and_announce_games_with_policy(
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
collections::HashMap,
net::SocketAddr,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary};
use tokio::sync::mpsc;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -1063,14 +1591,40 @@ 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(),
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
}
#[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
@@ -1149,7 +1703,7 @@ mod tests {
}
#[test]
fn update_source_selects_latest_ready_peer_manifest() {
fn update_source_selects_expected_ready_peer_manifest() {
let old_addr = addr(12_000);
let new_addr = addr(12_001);
let local_only_addr = addr(12_002);
@@ -1171,13 +1725,13 @@ mod tests {
);
assert_eq!(
GameDetailSource::LatestPeersOnly.select_peers(&db, "game"),
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
vec![new_addr]
);
}
#[tokio::test]
async fn update_fetch_emits_fresh_manifest_from_latest_peer() {
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
let old_addr = addr(12_010);
let new_addr = addr(12_011);
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
@@ -1196,33 +1750,40 @@ mod tests {
}
let peers = {
let db = peer_game_db.read().await;
GameDetailSource::LatestPeersOnly.select_peers(&db, "game")
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
};
let (tx, mut rx) = mpsc::unbounded_channel();
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
fetch_game_details_from_peers(peers, "game".to_string(), peer_game_db.clone(), tx, {
let fetched_peers = fetched_peers.clone();
move |peer_addr, game_id, peer_game_db| {
fetch_game_details_from_peers(
peers,
"game".to_string(),
Some("20250101".to_string()),
peer_game_db.clone(),
tx,
{
let fetched_peers = fetched_peers.clone();
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
let files = vec![
file_desc(&game_id, "game/version.ini", 8),
file_desc(&game_id, "game/new.eti", 11),
];
peer_game_db.write().await.update_peer_game_files(
&"new".to_string(),
&game_id,
files.clone(),
);
Ok(files)
move |peer_addr, game_id, peer_game_db| {
let fetched_peers = fetched_peers.clone();
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
let files = vec![
file_desc(&game_id, "game/version.ini", 8),
file_desc(&game_id, "game/new.eti", 11),
];
peer_game_db.write().await.update_peer_game_files(
&"new".to_string(),
&game_id,
files.clone(),
);
Ok(files)
}
}
}
})
},
)
.await;
assert_eq!(
@@ -1243,7 +1804,7 @@ mod tests {
file_descriptions
.iter()
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
"latest peer manifest should be emitted to the download path"
"expected-version peer manifest should be emitted to the download path"
);
}
@@ -1258,6 +1819,7 @@ mod tests {
fetch_game_details_from_peers(
vec![first_addr, second_addr],
"game".to_string(),
Some("20250101".to_string()),
peer_game_db,
tx.clone(),
{
@@ -1291,7 +1853,7 @@ mod tests {
#[tokio::test]
async fn update_request_skips_local_manifest_even_when_download_exists() {
let temp = TempDir::new("lanspread-handler-latest-peer");
let temp = TempDir::new("lanspread-handler-expected-peer");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20240101");
write_file(&root.join("game.eti"), b"old archive");
@@ -1314,23 +1876,37 @@ mod tests {
}
#[tokio::test]
async fn local_library_scan_freezes_active_game_state() {
let temp = TempDir::new("lanspread-handler-active-freeze");
async fn local_library_scan_hides_active_game_state() {
let temp = TempDir::new("lanspread-handler-active-hide");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
// 1. Initial scan: the game is ready and announced
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
.await
.expect("scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert_eq!(games.len(), 1);
assert_eq!(games[0].id, "game");
// 2. Set the game as active/in-progress and scan again
ctx.active_operations
.write()
.await
.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");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
@@ -1338,7 +1914,7 @@ mod tests {
};
assert!(
games.is_empty(),
"active game should keep its previous announced state"
"active game should be hidden/unannounced during operations"
);
}
@@ -1352,7 +1928,10 @@ mod tests {
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
assert!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
assert_eq!(
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
BeginOperationResult::Started
);
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
@@ -1362,6 +1941,48 @@ mod tests {
);
}
#[tokio::test]
async fn begin_operation_timeout_clears_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-drain-timeout");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let token = CancellationToken::new();
ctx.active_outbound_transfers
.write()
.await
.insert("game".to_string(), vec![(1, token.clone())]);
assert_eq!(
begin_operation_with_drain_timeout(
&ctx,
&tx,
"game",
OperationKind::Updating,
Duration::from_millis(1),
)
.await,
BeginOperationResult::DrainTimedOut
);
assert!(token.is_cancelled());
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}],
);
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(
!ctx.active_operations.read().await.contains_key("game"),
"timed-out drain should not leave the operation stuck active"
);
}
#[tokio::test]
async fn unchanged_settled_scan_is_not_reemitted() {
let temp = TempDir::new("lanspread-handler-settled-unchanged");
@@ -1373,13 +1994,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;
@@ -1398,7 +2019,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;
@@ -1690,7 +2311,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;
@@ -1776,7 +2397,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!(
+10 -2
View File
@@ -1,8 +1,16 @@
mod intent;
pub(crate) mod intent;
mod remove;
mod transaction;
pub mod unpack;
pub use remove::remove_downloaded;
pub use transaction::{install, recover_on_startup, uninstall, update};
pub(crate) use transaction::root_eti_archives;
pub use transaction::{
StreamedInstallTransaction,
begin_streamed_install,
install,
recover_on_startup,
uninstall,
update,
};
pub use unpack::{UnpackFuture, Unpacker};
+464 -48
View File
@@ -11,7 +11,7 @@ use super::{
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
unpack::Unpacker,
};
use crate::local_games::version_ini_is_regular_file;
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path};
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
@@ -33,10 +33,154 @@ struct InstallFsState {
backup: FsEntryState,
}
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
pub struct StreamedInstallTransaction {
game_root: PathBuf,
state_dir: PathBuf,
id: String,
staging: PathBuf,
eti_version: Option<String>,
created_game_root: bool,
}
impl StreamedInstallTransaction {
#[must_use]
pub fn staging_dir(&self) -> &Path {
&self.staging
}
pub async fn commit(self) -> eyre::Result<()> {
let local = local_dir(&self.game_root);
if let Err(err) = tokio::fs::rename(&self.staging, &local)
.await
.wrap_err_with(|| format!("failed to promote streamed install for {}", self.id))
{
if let Err(cleanup_err) = remove_dir_all_if_exists(&self.staging).await {
log::warn!(
"Failed to clean streamed install staging {}: {cleanup_err}",
self.staging.display()
);
}
if let Err(cleanup_err) =
remove_created_empty_game_root(&self.game_root, self.created_game_root).await
{
log::warn!(
"Failed to clean streamed install game root {}: {cleanup_err}",
self.game_root.display()
);
}
let _ = write_intent(
&self.state_dir,
&self.id,
&InstallIntent::none(&self.id, self.eti_version.clone()),
)
.await;
return Err(err);
}
if let Err(err) = reset_launch_settings_marker(&self.state_dir, &self.id).await {
log::error!(
"Streamed install for {} was promoted but launch-settings marker reset failed: {err}",
self.id
);
}
if let Err(err) = write_intent(
&self.state_dir,
&self.id,
&InstallIntent::none(&self.id, self.eti_version.clone()),
)
.await
{
log::error!(
"Streamed install for {} was promoted but intent cleanup failed: {err}",
self.id
);
}
Ok(())
}
pub async fn rollback(self) -> eyre::Result<()> {
let cleanup_result = async {
remove_dir_all_if_exists(&self.staging).await?;
remove_created_empty_game_root(&self.game_root, self.created_game_root).await
}
.await;
let intent_result = write_intent(
&self.state_dir,
&self.id,
&InstallIntent::none(&self.id, self.eti_version.clone()),
)
.await;
cleanup_result?;
intent_result
}
}
pub async fn begin_streamed_install(
game_root: &Path,
state_dir: &Path,
id: &str,
) -> eyre::Result<StreamedInstallTransaction> {
if path_is_dir(&local_dir(game_root)).await {
eyre::bail!("game {id} is already installed");
}
let created_game_root = !path_exists(game_root).await;
tokio::fs::create_dir_all(game_root).await?;
let eti_version = read_downloaded_version(game_root).await;
if let Err(err) = write_intent(
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await
{
if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await
{
log::warn!(
"Failed to clean streamed install game root {}: {cleanup_err}",
game_root.display()
);
}
return Err(err);
}
let staging = installing_dir(game_root);
if let Err(err) = prepare_owned_empty_dir(&staging).await {
let _ = write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await;
if let Err(cleanup_err) = remove_created_empty_game_root(game_root, created_game_root).await
{
log::warn!(
"Failed to clean streamed install game root {}: {cleanup_err}",
game_root.display()
);
}
return Err(err);
}
let staging = tokio::fs::canonicalize(&staging).await.unwrap_or(staging);
Ok(StreamedInstallTransaction {
game_root: game_root.to_path_buf(),
state_dir: state_dir.to_path_buf(),
id: id.to_string(),
staging,
eti_version,
created_game_root,
})
}
pub async fn install(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> 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?;
@@ -44,7 +188,8 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
let result = install_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
@@ -54,16 +199,22 @@ 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>,
) -> 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?;
@@ -71,7 +222,8 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
let result = update_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
reset_launch_settings_marker(state_dir, id).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 +234,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 +243,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 +255,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 +263,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 +298,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(())
}
@@ -233,7 +396,7 @@ async fn unpack_archives(
Ok(())
}
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
pub(crate) async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
let mut entries = tokio::fs::read_dir(game_root).await?;
let mut archives = Vec::new();
while let Some(entry) = entries.next_entry().await? {
@@ -249,6 +412,18 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
Ok(archives)
}
/// Drop the per-game launch-settings marker before committing install/update
/// success, so recovery can retry the reset before publishing a clean intent.
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> {
let marker = launch_settings_applied_path(state_dir, id);
remove_file_if_exists(&marker).await.wrap_err_with(|| {
format!(
"failed to reset launch-settings marker {}",
marker.display()
)
})
}
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,10 +432,12 @@ 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,
) -> eyre::Result<()> {
let commit_landed = fs.local == FsEntryState::Present;
if let InstallFsState {
installing: FsEntryState::Present,
..
@@ -268,15 +445,29 @@ 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
if commit_landed {
reset_launch_settings_marker(state_dir, id).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,
) -> eyre::Result<()> {
if matches!(
fs,
InstallFsState {
local: FsEntryState::Present,
backup: FsEntryState::Present,
..
}
) {
reset_launch_settings_marker(state_dir, id).await?;
}
match fs {
InstallFsState {
local: FsEntryState::Missing,
@@ -301,11 +492,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 +515,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 +608,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(()),
@@ -431,12 +627,38 @@ async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
}
}
async fn remove_created_empty_game_root(game_root: &Path, created: bool) -> eyre::Result<()> {
if !created {
return Ok(());
}
remove_empty_dir_if_exists(game_root).await
}
async fn remove_empty_dir_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_dir(path).await {
Ok(()) => Ok(()),
Err(err)
if matches!(
err.kind(),
ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
) =>
{
Ok(())
}
Err(err) => Err(err.into()),
}
}
async fn path_is_dir(path: &Path) -> bool {
tokio::fs::metadata(path)
.await
.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 +752,123 @@ 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())
.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_resets_launch_settings_marker() {
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(&launch_settings_applied_path(state.path(), "game"), b"");
install(&root, state.path(), "game", successful_unpacker())
.await
.expect("install should succeed");
assert!(!launch_settings_applied_path(state.path(), "game").exists());
}
#[tokio::test]
async fn streamed_install_rollback_removes_new_empty_game_root() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.path().join("streamed-game");
let transaction = begin_streamed_install(&root, state.path(), "streamed-game")
.await
.expect("streamed transaction should begin");
assert!(transaction.staging_dir().is_dir());
transaction
.rollback()
.await
.expect("streamed rollback should succeed");
assert!(!root.exists());
let intent = read_intent(state.path(), "streamed-game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn streamed_install_rollback_keeps_existing_game_root() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
let transaction = begin_streamed_install(&root, state.path(), "game")
.await
.expect("streamed transaction should begin");
transaction
.rollback()
.await
.expect("streamed rollback should succeed");
assert!(root.is_dir());
assert!(root.join("version.ini").is_file());
assert!(!root.join(INSTALLING_DIR).exists());
}
#[tokio::test]
async fn streamed_install_commit_succeeds_when_post_promote_intent_cleanup_fails() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
let transaction = begin_streamed_install(&root, state.path(), "game")
.await
.expect("streamed transaction should begin");
write_file(&transaction.staging_dir().join("payload.txt"), b"installed");
let game_state_dir = crate::state_paths::game_state_dir(state.path(), "game");
std::fs::remove_dir_all(&game_state_dir).expect("game state dir should be removed");
write_file(&game_state_dir, b"not a directory");
transaction
.commit()
.await
.expect("promoted streamed install should be reported as success");
assert_eq!(
std::fs::read(root.join(LOCAL_DIR).join("payload.txt"))
.expect("promoted payload should be present"),
b"installed"
);
}
#[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())
.await
.expect("install should succeed");
@@ -573,34 +885,46 @@ mod tests {
#[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()))
.await
.expect_err("update should fail");
let err = update(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::failing()),
)
.await
.expect_err("update should fail");
assert!(err.to_string().contains("forced unpack failure"));
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()))
.await
.expect_err("update should fail at commit rename");
let err = update(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::commit_conflict()),
)
.await
.expect_err("update should fail at commit rename");
assert!(
err.to_string().contains("failed to promote update"),
@@ -614,19 +938,21 @@ 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");
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
update(&root, "game", successful_unpacker())
update(&root, state.path(), "game", successful_unpacker())
.await
.expect("update should succeed");
@@ -634,19 +960,21 @@ 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;
assert!(!launch_settings_applied_path(state.path(), "game").exists());
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 +989,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 +998,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 +1026,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 +1173,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(),
@@ -871,13 +1202,92 @@ mod tests {
}
}
#[tokio::test]
async fn recovery_resets_marker_for_commit_landed_install_or_update() {
let state = test_state();
let cases = [
(
"installing-committed",
InstallIntentState::Installing,
false,
),
("updating-committed", InstallIntentState::Updating, true),
];
for (id, intent_state, has_backup) in cases {
let temp = TempDir::new("lanspread-install");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
if has_backup {
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
}
write_file(&launch_settings_applied_path(state.path(), id), b"");
write_intent(
state.path(),
id,
&InstallIntent::new(id, intent_state, Some("20250101".into())),
)
.await
.expect("intent should be written");
recover_game_root(&root, state.path(), id)
.await
.expect("recovery should succeed");
assert!(
!launch_settings_applied_path(state.path(), id).exists(),
"{id} marker should be reset"
);
let intent = read_intent(state.path(), id).await;
assert_eq!(intent.state, InstallIntentState::None, "{id}");
}
}
#[tokio::test]
async fn recovery_keeps_marker_when_update_rolls_back() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(
&root.join(INSTALLING_DIR).join("payload.txt"),
INSTALLING_PAYLOAD,
);
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
write_intent(
state.path(),
"game",
&InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".into()),
),
)
.await
.expect("intent should be written");
recover_game_root(&root, state.path(), "game")
.await
.expect("recovery should succeed");
assert!(launch_settings_applied_path(state.path(), "game").exists());
assert_eq!(
std::fs::read(root.join(LOCAL_DIR).join("payload.txt"))
.expect("backup payload should be restored"),
BACKUP_PAYLOAD
);
}
#[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 +1297,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,14 +1313,19 @@ 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()]))
.await
.expect("recovery should succeed");
recover_on_startup(
temp.path(),
state.path(),
&HashSet::from(["active".to_string()]),
)
.await
.expect("recovery should succeed");
assert!(active_root.join(VERSION_TMP_FILE).is_file());
assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
@@ -0,0 +1,502 @@
//! One-shot launcher-setting application performed the first time a game is played.
//!
//! Some games ship per-user setting files somewhere under their installed
//! `local/` tree — an `account_name.txt`, a `language.txt`, and/or a
//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. The first time the
//! user launches a game we stamp the launcher's configured username and language
//! into whichever of those files exist, then record a per-game marker so the
//! step never runs again.
//!
//! The marker only records that we *tried*: it is written unconditionally after
//! the first attempt, whether or not any file or matching line was found. Moving
//! this out of the install transaction means already-installed games are fixed
//! up on their next play rather than only on a fresh (re)install.
use std::{
ffi::OsStr,
io::ErrorKind,
path::{Path, PathBuf},
};
use eyre::WrapErr;
use crate::state_paths::launch_settings_applied_path;
const LOCAL_DIR: &str = "local";
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
const LANGUAGE_FILE: &str = "language.txt";
const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini";
const PERSONA_NAME_KEY: &str = "PersonaName";
/// What the one-shot launcher-setting step did for a game.
///
/// These flags are an independent, observable report of each file's fate rather
/// than a state machine, so a plain record of bools is the clearest shape here.
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct LaunchSettingsOutcome {
/// The marker already existed, so nothing was searched or changed.
pub already_applied: bool,
/// An `account_name.txt` was found and overwritten with the username.
pub account_name_written: bool,
/// A `language.txt` was found and overwritten with the language.
pub language_written: bool,
/// A `SmartSteamEmu.ini` `PersonaName` line was found and rewritten.
pub persona_name_written: bool,
}
/// Apply launcher settings once for `game_id`, then mark the attempt as done.
///
/// If the per-game marker already exists this is a no-op returning
/// `already_applied = true`. Otherwise it searches `<game_root>/local/` for the
/// known setting files, stamps `account_name` into the first `account_name.txt`
/// and first `SmartSteamEmu.ini` with a `PersonaName` line (preserving that
/// line's existing line ending) and `language` into the first `language.txt`,
/// and — whatever was or was not found — records the marker so the step never
/// runs again for this game.
pub async fn apply_launch_settings_once(
state_dir: &Path,
game_root: &Path,
game_id: &str,
account_name: Option<&str>,
language: Option<&str>,
) -> eyre::Result<LaunchSettingsOutcome> {
let marker = launch_settings_applied_path(state_dir, game_id);
if tokio::fs::try_exists(&marker).await.unwrap_or(false) {
return Ok(LaunchSettingsOutcome {
already_applied: true,
..LaunchSettingsOutcome::default()
});
}
let local_root = game_root.join(LOCAL_DIR);
let outcome = LaunchSettingsOutcome {
already_applied: false,
account_name_written: overwrite_first_file(&local_root, ACCOUNT_NAME_FILE, account_name)
.await?,
language_written: overwrite_first_file(&local_root, LANGUAGE_FILE, language).await?,
persona_name_written: rewrite_first_persona_name(&local_root, account_name).await?,
};
mark_applied(&marker).await?;
Ok(outcome)
}
/// Overwrite the first file named `file_name` under `root` with `value`.
///
/// Returns `false` without touching anything when `value` is `None` or no such
/// file exists.
async fn overwrite_first_file(
root: &Path,
file_name: &str,
value: Option<&str>,
) -> eyre::Result<bool> {
let Some(value) = value else {
return Ok(false);
};
let Some(path) = find_first_file(root, file_name).await? else {
return Ok(false);
};
tokio::fs::write(&path, value)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(true)
}
/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`.
async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result<bool> {
let Some(persona_name) = persona_name else {
return Ok(false);
};
for path in find_files(root, SMART_STEAM_EMU_INI).await? {
let content = tokio::fs::read_to_string(&path)
.await
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
continue;
};
tokio::fs::write(&path, rewritten)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
return Ok(true);
}
Ok(false)
}
/// Find the first regular file named `file_name` anywhere under `root`.
///
/// A missing `root` (for example an uninstalled game with no `local/`) yields
/// `None`. Directories are visited in sorted order for deterministic results.
async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result<Option<PathBuf>> {
let mut pending_dirs = vec![root.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(err) => {
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
}
};
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 file_type.is_dir() {
child_dirs.push(path);
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
return Ok(Some(path));
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(None)
}
/// Find every regular file named `file_name` anywhere under `root`.
///
/// A missing `root` yields an empty list. Directories are visited in sorted
/// order for deterministic results.
async fn find_files(root: &Path, file_name: &str) -> eyre::Result<Vec<PathBuf>> {
let mut matches = Vec::new();
let mut pending_dirs = vec![root.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(err) => {
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
}
};
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 file_type.is_dir() {
child_dirs.push(path);
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
matches.push(path);
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(matches)
}
/// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
///
/// Returns `None` when no matching line exists, so the caller can skip writing.
fn rewrite_persona_name_content(content: &str, persona_name: &str) -> Option<String> {
let mut output = String::with_capacity(content.len() + persona_name.len());
let mut replaced = false;
for segment in content.split_inclusive('\n') {
if replaced {
output.push_str(segment);
continue;
}
let (body, ending) = split_trailing_newline(segment);
if let Some(new_body) = rewrite_persona_line(body, persona_name) {
output.push_str(&new_body);
output.push_str(ending);
replaced = true;
} else {
output.push_str(segment);
}
}
replaced.then_some(output)
}
/// Split a `split_inclusive('\n')` segment into its body and trailing newline.
fn split_trailing_newline(segment: &str) -> (&str, &str) {
if let Some(body) = segment.strip_suffix("\r\n") {
(body, "\r\n")
} else if let Some(body) = segment.strip_suffix('\n') {
(body, "\n")
} else {
(segment, "")
}
}
/// Rewrite a single line matching `^\s*PersonaName\s*=\s*...` to use `persona_name`.
///
/// Leading whitespace and the spacing around `=` are preserved; everything after
/// the separator's trailing whitespace is replaced with `persona_name`.
fn rewrite_persona_line(line: &str, persona_name: &str) -> Option<String> {
let leading_len = line.len() - line.trim_start().len();
let (leading, rest) = line.split_at(leading_len);
let rest = rest.strip_prefix(PERSONA_NAME_KEY)?;
let mid_len = rest.len() - rest.trim_start().len();
let (mid_ws, after_mid) = rest.split_at(mid_len);
let after_eq = after_mid.strip_prefix('=')?;
let post_len = after_eq.len() - after_eq.trim_start().len();
let post_ws = &after_eq[..post_len];
Some(format!(
"{leading}{PERSONA_NAME_KEY}{mid_ws}={post_ws}{persona_name}"
))
}
async fn mark_applied(marker: &Path) -> eyre::Result<()> {
if let Some(parent) = marker.parent() {
tokio::fs::create_dir_all(parent)
.await
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
}
tokio::fs::write(marker, [])
.await
.wrap_err_with(|| format!("failed to write {}", marker.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TempDir;
#[test]
fn rewrites_simple_line_and_preserves_unix_ending() {
let content = "[User]\nPersonaName = stubname\nLanguage = english\n";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(
rewritten,
"[User]\nPersonaName = realuser\nLanguage = english\n"
);
}
#[test]
fn preserves_crlf_line_ending() {
let content = "[User]\r\nPersonaName = stubname\r\nLanguage = english\r\n";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(
rewritten,
"[User]\r\nPersonaName = realuser\r\nLanguage = english\r\n"
);
}
#[test]
fn preserves_leading_whitespace_and_separator_spacing() {
let content = "\tPersonaName=stubname\n";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(rewritten, "\tPersonaName=realuser\n");
}
#[test]
fn rewrites_final_line_without_trailing_newline() {
let content = "PersonaName = stubname";
let rewritten =
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
assert_eq!(rewritten, "PersonaName = realuser");
}
#[test]
fn ignores_similar_keys() {
assert!(rewrite_persona_line("PersonaNameExtra = x", "realuser").is_none());
assert!(rewrite_persona_line("MyPersonaName = x", "realuser").is_none());
assert!(rewrite_persona_line("PersonaName foo", "realuser").is_none());
}
#[test]
fn returns_none_when_no_persona_line() {
let content = "[User]\nLanguage = english\n";
assert!(rewrite_persona_name_content(content, "realuser").is_none());
}
#[tokio::test]
async fn applies_username_to_both_files_then_marks_done() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let account = root.join(LOCAL_DIR).join("profile").join(ACCOUNT_NAME_FILE);
let ini = root
.join(LOCAL_DIR)
.join("config")
.join("emu")
.join(SMART_STEAM_EMU_INI);
let language = root.join(LOCAL_DIR).join(LANGUAGE_FILE);
write_file(&account, b"stubname");
write_file(&ini, b"[User]\r\nPersonaName = stubname\r\n");
write_file(&language, b"english");
let outcome = apply_launch_settings_once(
state.path(),
root,
"game",
Some("realuser"),
Some("german"),
)
.await
.expect("apply should succeed");
assert_eq!(
outcome,
LaunchSettingsOutcome {
already_applied: false,
account_name_written: true,
language_written: true,
persona_name_written: true,
}
);
assert_eq!(
std::fs::read_to_string(&account).expect("account file should be readable"),
"realuser"
);
assert_eq!(
std::fs::read_to_string(&ini).expect("ini should be readable"),
"[User]\r\nPersonaName = realuser\r\n"
);
assert_eq!(
std::fs::read_to_string(&language).expect("language file should be readable"),
"german"
);
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
#[tokio::test]
async fn is_noop_once_marker_exists() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let ini = root.join(LOCAL_DIR).join(SMART_STEAM_EMU_INI);
write_file(&ini, b"PersonaName = stubname\n");
let first = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("first apply should succeed");
assert!(first.persona_name_written);
assert!(!first.already_applied);
// Externally reset the value; a second apply must not touch it again.
write_file(&ini, b"PersonaName = stubname\n");
let second = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("second apply should succeed");
assert!(second.already_applied);
assert!(!second.persona_name_written);
assert_eq!(
std::fs::read_to_string(&ini).expect("ini should be readable"),
"PersonaName = stubname\n"
);
}
#[tokio::test]
async fn marks_done_even_when_nothing_found() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
write_file(
&root.join(LOCAL_DIR).join("readme.txt"),
b"no settings here",
);
let outcome = apply_launch_settings_once(
state.path(),
root,
"game",
Some("realuser"),
Some("german"),
)
.await
.expect("apply should succeed");
assert_eq!(outcome, LaunchSettingsOutcome::default());
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
#[tokio::test]
async fn marks_done_when_local_missing() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let outcome = apply_launch_settings_once(
state.path(),
game.path(),
"game",
Some("realuser"),
Some("german"),
)
.await
.expect("apply should succeed");
assert_eq!(outcome, LaunchSettingsOutcome::default());
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
#[tokio::test]
async fn overwrites_first_account_file_in_sorted_order() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let first = root.join(LOCAL_DIR).join("a").join(ACCOUNT_NAME_FILE);
let second = root.join(LOCAL_DIR).join("z").join(ACCOUNT_NAME_FILE);
write_file(&first, b"old-a");
write_file(&second, b"old-z");
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("apply should succeed");
assert_eq!(
std::fs::read_to_string(&first).expect("first account file should be readable"),
"realuser"
);
assert_eq!(
std::fs::read_to_string(&second).expect("second account file should be readable"),
"old-z"
);
}
#[tokio::test]
async fn searches_past_ini_files_without_persona_name() {
let state = TempDir::new("lanspread-launch-state");
let game = TempDir::new("lanspread-launch-game");
let root = game.path();
let first = root.join(LOCAL_DIR).join("a").join(SMART_STEAM_EMU_INI);
let second = root.join(LOCAL_DIR).join("b").join(SMART_STEAM_EMU_INI);
write_file(&first, b"[User]\nLanguage = english\n");
write_file(&second, b"PersonaName = stubname\n");
let outcome =
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
.await
.expect("apply should succeed");
assert!(outcome.persona_name_written);
assert_eq!(
std::fs::read_to_string(&first).expect("first ini should be readable"),
"[User]\nLanguage = english\n"
);
assert_eq!(
std::fs::read_to_string(&second).expect("second ini should be readable"),
"PersonaName = realuser\n"
);
assert!(launch_settings_applied_path(state.path(), "game").is_file());
}
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");
}
}
+73 -9
View File
@@ -20,8 +20,10 @@ mod events;
mod handlers;
mod identity;
mod install;
mod launch_settings;
mod library;
mod local_games;
mod migration;
mod network;
mod path_validation;
mod peer;
@@ -29,6 +31,8 @@ mod peer_db;
mod remote_peer;
mod services;
mod startup;
mod state_paths;
mod stream_install;
#[cfg(test)]
mod test_support;
@@ -36,12 +40,13 @@ mod test_support;
// Public re-exports
// =============================================================================
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
pub use migration::{MigrationReport, migrate_legacy_state};
pub use peer_db::{
MajorityValidationResult,
PeerGameDB,
@@ -56,7 +61,6 @@ use tokio::sync::{
};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub use crate::startup::PeerRuntimeHandle;
use crate::{
context::Ctx,
handlers::{
@@ -73,6 +77,20 @@ use crate::{
handle_uninstall_game_command,
load_local_library,
},
state_paths::resolve_state_dir,
};
pub use crate::{
context::OutboundTransfers,
launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once},
startup::PeerRuntimeHandle,
state_paths::{launch_settings_applied_path, setup_done_path},
stream_install::{
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
StreamInstallFrameSink,
StreamInstallFuture,
StreamInstallProvider,
},
};
// =============================================================================
@@ -144,6 +162,8 @@ pub enum PeerEvent {
PeerCountUpdated(usize),
/// The local library contents changed after a scan.
LocalLibraryChanged { games: Vec<Game> },
/// The number of active outbound transfers changed.
OutboundTransferCountChanged,
/// The set of in-progress local operations changed.
ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>,
@@ -162,6 +182,8 @@ pub struct DownloadProgress {
pub downloaded_bytes: u64,
pub total_bytes: u64,
pub bytes_per_second: u64,
/// Unique peers currently streaming at least one chunk for this download.
pub active_peer_count: usize,
}
/// Long-running peer runtime components reported in failure events.
@@ -230,6 +252,8 @@ pub enum PeerCommand {
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
},
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
StreamInstallGame { id: String },
/// Install already-downloaded archives into `local/`.
InstallGame { id: String },
/// Remove only the `local/` install for a game.
@@ -247,10 +271,29 @@ pub enum PeerCommand {
}
/// Optional startup settings for non-GUI callers and tests.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Default)]
pub struct PeerStartOptions {
/// Directory used for peer identity and other state.
pub state_dir: Option<PathBuf>,
pub active_outbound_transfers: Option<crate::context::OutboundTransfers>,
/// Provider used to stream archive entries for low-disk streamed installs.
pub stream_install_provider: Option<Arc<dyn StreamInstallProvider>>,
}
impl std::fmt::Debug for PeerStartOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PeerStartOptions")
.field("state_dir", &self.state_dir)
.field(
"active_outbound_transfers",
&self.active_outbound_transfers.as_ref().map(|_| "..."),
)
.field(
"stream_install_provider",
&self.stream_install_provider.as_ref().map(|_| "..."),
)
.finish()
}
}
// =============================================================================
@@ -275,7 +318,7 @@ pub fn start_peer(
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
catalog: Arc<RwLock<GameCatalog>>,
) -> eyre::Result<PeerRuntimeHandle> {
start_peer_with_options(
game_dir,
@@ -294,16 +337,25 @@ pub fn start_peer_with_options(
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
catalog: Arc<RwLock<GameCatalog>>,
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions { state_dir } = options;
let PeerStartOptions {
state_dir,
active_outbound_transfers,
stream_install_provider,
} = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let game_dir = game_dir.into();
let active_outbound_transfers = active_outbound_transfers
.unwrap_or_else(|| Arc::new(RwLock::new(std::collections::HashMap::new())));
let stream_install_provider =
stream_install_provider.unwrap_or_else(|| Arc::new(NoopStreamInstallProvider));
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();
@@ -314,8 +366,11 @@ pub fn start_peer_with_options(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
catalog,
active_outbound_transfers,
stream_install_provider,
))
}
@@ -327,19 +382,25 @@ 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,
catalog: Arc<RwLock<HashSet<String>>>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
) -> eyre::Result<()> {
let ctx = Ctx::new(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
shutdown,
task_tracker,
catalog,
active_outbound_transfers,
stream_install_provider,
);
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
log::error!("Failed to load initial local game database: {err}");
@@ -413,6 +474,9 @@ async fn handle_peer_commands(
)
.await;
}
PeerCommand::StreamInstallGame { id } => {
handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::InstallGame { id } => {
handle_install_game_command(ctx, tx_notify_ui, id).await;
}
+93 -28
View File
@@ -9,7 +9,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, sync::Mutex};
@@ -51,7 +51,7 @@ pub async fn local_download_available(
game_dir: &Path,
game_id: &str,
active_operations: &HashMap<String, OperationKind>,
catalog: &HashSet<String>,
catalog: &GameCatalog,
) -> bool {
if !catalog.contains(game_id) {
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
@@ -67,11 +67,45 @@ pub async fn local_download_available(
version_ini_is_regular_file(game_path.as_path()).await
}
/// Checks if a local game may be served to peers under the authoritative catalog version.
pub async fn local_download_matches_catalog(
game_dir: &Path,
game_id: &str,
active_operations: &HashMap<String, OperationKind>,
catalog: &GameCatalog,
) -> bool {
if !local_download_available(game_dir, game_id, active_operations, catalog).await {
return false;
}
let Some(expected_version) = catalog.expected_version(game_id) else {
return true;
};
let game_path = game_dir.join(game_id);
match lanspread_db::db::read_version_from_ini(&game_path) {
Ok(Some(local_version)) if local_version == expected_version => true,
Ok(Some(local_version)) => {
log::debug!(
"Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}"
);
false
}
Ok(None) => false,
Err(err) => {
log::warn!(
"Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}"
);
false
}
}
}
// =============================================================================
// 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 +148,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 +318,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 +326,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 {
@@ -462,7 +502,7 @@ struct IndexUpdate {
async fn update_index_for_game(
game_root: &Path,
game_id: &str,
catalog: &HashSet<String>,
catalog: &GameCatalog,
index: &mut LibraryIndex,
) -> eyre::Result<IndexUpdate> {
if !catalog.contains(game_id) {
@@ -550,9 +590,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>,
catalog: &HashSet<String>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
) -> 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 +619,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 +678,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>,
catalog: &HashSet<String>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
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?;
@@ -672,10 +716,7 @@ pub async fn get_game_file_descriptions(
#[cfg(test)]
mod tests {
use std::{
collections::{HashMap, HashSet},
path::Path,
};
use std::{collections::HashMap, path::Path};
use lanspread_proto::Availability;
@@ -765,7 +806,8 @@ mod tests {
#[tokio::test]
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
let temp = TempDir::new("lanspread-local-games");
let catalog = HashSet::from([
let state = TempDir::new("lanspread-local-games-state");
let catalog = GameCatalog::from_ids([
"ready".to_string(),
"local-only".to_string(),
"eti-only".to_string(),
@@ -783,7 +825,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 +860,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 catalog = HashSet::from(["game".to_string()]);
let state = TempDir::new("lanspread-local-games-state");
let catalog = GameCatalog::from_ids(["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 +878,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 +894,12 @@ mod tests {
#[tokio::test]
async fn concurrent_rescans_preserve_both_index_updates() {
let temp = TempDir::new("lanspread-local-games-concurrent");
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
let state = TempDir::new("lanspread-local-games-state");
let catalog = GameCatalog::from_ids(["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 +908,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
@@ -896,7 +940,7 @@ mod tests {
let game_root = temp.path().join("game");
write_file(&game_root.join("version.ini"), b"20250101");
let catalog = HashSet::from(["game".to_string()]);
let catalog = GameCatalog::from_ids(["game".to_string()]);
let no_operations = HashMap::new();
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
@@ -904,8 +948,29 @@ mod tests {
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
assert!(
!local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
.await
);
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
}
#[tokio::test]
async fn local_download_matches_catalog_requires_expected_version() {
let temp = TempDir::new("lanspread-local-games");
let game_root = temp.path().join("game");
write_file(&game_root.join("version.ini"), b"20260101");
let mut catalog = GameCatalog::empty();
catalog.insert("game".to_string(), Some("20250101".to_string()));
let no_operations = HashMap::new();
assert!(
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
);
catalog.insert("game".to_string(), Some("20260101".to_string()));
assert!(
local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
);
}
}
+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());
}
}
+84 -9
View File
@@ -4,6 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::GameFileDescription;
use lanspread_utils::maybe_addr;
use s2n_quic::{
application,
connection,
stream::{Error as StreamError, SendStream},
};
@@ -14,12 +15,24 @@ use tokio::{
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
fn cancel_send_stream(tx: &mut SendStream, remote_addr: impl std::fmt::Display, path: &Path) {
// Reset instead of finishing so truncated whole-file transfers cannot look like EOF.
if let Err(err) = tx.reset(application::Error::UNKNOWN) {
log::debug!(
"{remote_addr} failed to reset cancelled transfer for {}: {err}",
path.display()
);
}
}
#[allow(clippy::too_many_lines)]
async fn stream_file_bytes(
tx: &mut SendStream,
base_dir: &Path,
relative_path: &str,
offset: u64,
length: Option<u64>,
cancel_token: tokio_util::sync::CancellationToken,
) -> eyre::Result<()> {
let remote_addr = maybe_addr!(tx.connection().remote_addr());
@@ -45,13 +58,34 @@ async fn stream_file_bytes(
let mut buf = vec![0u8; FILE_TRANSFER_BUFFER_SIZE];
while remaining > 0 {
if cancel_token.is_cancelled() {
log::info!(
"{remote_addr} transfer cancelled for {}",
validated_path.display()
);
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
let read_len = std::cmp::min(remaining, buf.len() as u64);
let read_len: usize = read_len.try_into().unwrap_or(usize::MAX);
if read_len == 0 {
break;
}
let bytes_read = file.read(&mut buf[..read_len]).await?;
let bytes_read = tokio::select! {
() = cancel_token.cancelled() => {
log::info!(
"{remote_addr} transfer cancelled for {}",
validated_path.display()
);
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
res = file.read(&mut buf[..read_len]) => {
res?
}
};
if bytes_read == 0 {
if !expect_exact {
transfer_complete = true;
@@ -59,7 +93,19 @@ async fn stream_file_bytes(
break;
}
tx.send(Bytes::copy_from_slice(&buf[..bytes_read])).await?;
tokio::select! {
() = cancel_token.cancelled() => {
log::info!(
"{remote_addr} transfer cancelled for {}",
validated_path.display()
);
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => {
res?;
}
}
remaining = remaining.saturating_sub(bytes_read as u64);
total_bytes += bytes_read as u64;
@@ -97,12 +143,21 @@ async fn stream_file_bytes(
validated_path.display()
);
match tx.close().await {
Ok(()) => {}
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
tokio::select! {
() = cancel_token.cancelled() => {
log::info!("{remote_addr} transfer cancelled while closing stream");
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user"));
}
res = tx.close() => {
match res {
Ok(()) => {}
Err(err) if transfer_complete && is_clean_remote_close(&err) => {
log::debug!("{remote_addr} closed stream after transfer completion: {err}");
}
Err(err) => return Err(err.into()),
}
}
Err(err) => return Err(err.into()),
}
Ok(())
}
@@ -121,8 +176,18 @@ pub async fn send_game_file_data(
game_file_desc: &GameFileDescription,
tx: &mut SendStream,
game_dir: &Path,
cancel_token: tokio_util::sync::CancellationToken,
) {
if let Err(e) = stream_file_bytes(tx, game_dir, &game_file_desc.relative_path, 0, None).await {
if let Err(e) = stream_file_bytes(
tx,
game_dir,
&game_file_desc.relative_path,
0,
None,
cancel_token,
)
.await
{
let remote_addr = maybe_addr!(tx.connection().remote_addr());
log::error!(
"{remote_addr} failed to stream file {}: {e}",
@@ -138,8 +203,18 @@ pub async fn send_game_file_chunk(
length: u64,
tx: &mut SendStream,
game_dir: &Path,
cancel_token: tokio_util::sync::CancellationToken,
) {
if let Err(e) = stream_file_bytes(tx, game_dir, relative_path, offset, Some(length)).await {
if let Err(e) = stream_file_bytes(
tx,
game_dir,
relative_path,
offset,
Some(length),
cancel_token,
)
.await
{
let remote_addr = maybe_addr!(tx.connection().remote_addr());
log::error!(
"{remote_addr} failed to stream chunk {game_id}/{relative_path} offset {offset} length {length}: {e}"
+141 -9
View File
@@ -7,7 +7,7 @@ use std::{
time::{Duration, Instant},
};
use lanspread_db::db::{Availability, Game, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription};
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
use crate::library::compute_library_digest;
@@ -357,6 +357,54 @@ impl PeerGameDB {
games
}
/// Returns catalog games aggregated from peers that advertise the expected catalog version.
#[must_use]
pub fn get_catalog_games(&self, catalog: &GameCatalog) -> Vec<Game> {
let mut aggregated: HashMap<String, Game> = HashMap::new();
let mut peer_counts: HashMap<String, u32> = HashMap::new();
for peer in self.peers.values() {
for game in peer.games.values().filter(|game| {
catalog.contains(&game.id)
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
}) {
*peer_counts.entry(game.id.clone()).or_insert(0) += 1;
}
}
for peer in self.peers.values() {
for game in peer.games.values().filter(|game| {
catalog.contains(&game.id)
&& game_matches_expected_version(game, catalog.expected_version(&game.id))
}) {
aggregated
.entry(game.id.clone())
.and_modify(|existing| {
existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
if game.size > existing.size {
existing.size = game.size;
}
existing.set_downloaded(true);
if game.installed {
existing.installed = true;
}
})
.or_insert_with(|| {
let mut game_clone = summary_to_game(game);
if let Some(expected_version) = catalog.expected_version(&game.id) {
game_clone.eti_game_version = Some(expected_version.to_string());
}
game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
game_clone
});
}
}
let mut games: Vec<Game> = aggregated.into_values().collect();
games.sort_by(|a, b| a.name.cmp(&b.name));
games
}
/// Returns the latest version of a game across all peers.
#[must_use]
pub fn get_latest_version_for_game(&self, game_id: &str) -> Option<String> {
@@ -451,6 +499,24 @@ impl PeerGameDB {
.collect()
}
/// Returns addresses of peers that have the expected catalog version of a game.
#[must_use]
pub fn peers_with_expected_version(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> Vec<SocketAddr> {
self.peers
.iter()
.filter(|(_, peer)| {
peer.games
.get(game_id)
.is_some_and(|game| game_matches_expected_version(game, expected_version))
})
.map(|(_, peer)| peer.addr)
.collect()
}
/// Returns addresses of peers that have the latest version of a game.
#[must_use]
pub fn peers_with_latest_version(&self, game_id: &str) -> Vec<SocketAddr> {
@@ -514,11 +580,33 @@ impl PeerGameDB {
.collect()
}
/// Returns file descriptions from peers that advertise the expected catalog version.
#[must_use]
pub fn expected_version_game_files_for(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> Vec<(SocketAddr, Vec<GameFileDescription>)> {
let expected_peers = self.peers_with_expected_version(game_id, expected_version);
if expected_peers.is_empty() {
return Vec::new();
}
self.game_files_for(game_id)
.into_iter()
.filter(|(addr, _)| expected_peers.contains(addr))
.collect()
}
/// Returns aggregated file descriptions for a game across all peers.
#[must_use]
pub fn aggregated_game_files(&self, game_id: &str) -> Vec<GameFileDescription> {
pub fn aggregated_game_files(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> Vec<GameFileDescription> {
let mut seen: HashMap<String, GameFileDescription> = HashMap::new();
for (_, files) in self.latest_game_files_for(game_id) {
for (_, files) in self.expected_version_game_files_for(game_id, expected_version) {
for file in files {
seen.entry(file.relative_path.clone()).or_insert(file);
}
@@ -559,8 +647,9 @@ impl PeerGameDB {
pub fn validate_file_sizes_majority(
&self,
game_id: &str,
expected_version: Option<&str>,
) -> eyre::Result<MajorityValidationResult> {
let game_files = self.latest_game_files_for(game_id);
let game_files = self.expected_version_game_files_for(game_id, expected_version);
if game_files.is_empty() {
return Ok((Vec::new(), Vec::new(), HashMap::new()));
}
@@ -813,6 +902,14 @@ fn game_is_ready(summary: &GameSummary) -> bool {
summary.availability == Availability::Ready
}
fn game_matches_expected_version(summary: &GameSummary, expected_version: Option<&str>) -> bool {
if !game_is_ready(summary) {
return false;
}
expected_version.is_none_or(|expected| summary.eti_version.as_deref() == Some(expected))
}
fn summary_to_game(summary: &GameSummary) -> Game {
let eti_game_version = game_is_ready(summary)
.then(|| summary.eti_version.clone())
@@ -925,6 +1022,41 @@ mod tests {
assert!(db.peers_with_latest_version("game").is_empty());
}
#[test]
fn catalog_aggregation_counts_only_expected_version_peers() {
let old_addr = addr(12003);
let expected_addr = addr(12004);
let newer_addr = addr(12005);
let mut db = PeerGameDB::new();
db.upsert_peer("old".to_string(), old_addr);
db.upsert_peer("expected".to_string(), expected_addr);
db.upsert_peer("newer".to_string(), newer_addr);
db.update_peer_games(
&"old".to_string(),
vec![summary("game", "20240101", Availability::Ready)],
);
db.update_peer_games(
&"expected".to_string(),
vec![summary("game", "20250101", Availability::Ready)],
);
db.update_peer_games(
&"newer".to_string(),
vec![summary("game", "20260101", Availability::Ready)],
);
let mut catalog = GameCatalog::empty();
catalog.insert("game".to_string(), Some("20250101".to_string()));
let games = db.get_catalog_games(&catalog);
assert_eq!(games.len(), 1);
assert_eq!(games[0].peer_count, 1);
assert_eq!(games[0].eti_game_version.as_deref(), Some("20250101"));
assert_eq!(
db.peers_with_expected_version("game", Some("20250101")),
vec![expected_addr]
);
}
#[test]
fn transport_addr_matches_known_peer_on_ephemeral_port() {
let advertised = ip_addr([10, 66, 0, 2], 40000);
@@ -979,7 +1111,7 @@ mod tests {
}
#[test]
fn validation_uses_latest_version_file_metadata() {
fn validation_uses_expected_version_file_metadata() {
let old_addr = addr(12003);
let new_addr = addr(12004);
let mut db = PeerGameDB::new();
@@ -1010,21 +1142,21 @@ mod tests {
],
);
let aggregated = db.aggregated_game_files("game");
let aggregated = db.aggregated_game_files("game", Some("20250101"));
let archive = aggregated
.iter()
.find(|desc| desc.relative_path == "game/archive.eti")
.expect("latest archive should be present");
.expect("expected-version archive should be present");
assert_eq!(archive.size, 20);
let (validated, peers, file_peer_map) = db
.validate_file_sizes_majority("game")
.validate_file_sizes_majority("game", Some("20250101"))
.expect("old-version file metadata should not create ambiguity");
assert_eq!(peers, vec![new_addr]);
let archive = validated
.iter()
.find(|desc| desc.relative_path == "game/archive.eti")
.expect("latest archive should validate");
.expect("expected-version archive should validate");
assert_eq!(archive.size, 20);
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
}
@@ -2,6 +2,7 @@
use std::{net::SocketAddr, sync::Arc};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Hello, HelloAck, PROTOCOL_VERSION};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
@@ -22,6 +23,7 @@ pub(crate) struct HandshakeCtx {
local_library: Arc<RwLock<LocalLibraryState>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
catalog: Arc<RwLock<GameCatalog>>,
}
impl HandshakeCtx {
@@ -32,6 +34,7 @@ impl HandshakeCtx {
local_library: ctx.local_library.clone(),
peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: tx_notify_ui.clone(),
catalog: ctx.catalog.clone(),
}
}
@@ -42,6 +45,7 @@ impl HandshakeCtx {
local_library: ctx.local_library.clone(),
peer_game_db: ctx.peer_game_db.clone(),
tx_notify_ui: ctx.tx_notify_ui.clone(),
catalog: ctx.catalog.clone(),
}
}
}
@@ -121,7 +125,7 @@ pub(crate) async fn perform_handshake_with_peer(
.await;
after_peer_library_recorded(&ctx, upsert, record_addr).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
Ok(())
}
@@ -156,7 +160,7 @@ pub(super) async fn accept_inbound_hello(
.await;
after_peer_library_recorded(&handshake_ctx, upsert, addr).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
build_hello_ack(ctx).await
}
@@ -201,12 +205,13 @@ async fn after_peer_library_recorded(
#[cfg(test)]
mod tests {
use std::{
collections::{HashMap, HashSet},
collections::HashMap,
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use lanspread_db::db::GameCatalog;
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -242,6 +247,7 @@ mod tests {
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
peer_game_db,
tx_notify_ui,
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
}
}
@@ -301,14 +307,19 @@ mod tests {
#[tokio::test]
async fn inbound_hello_applies_remote_library_snapshot() {
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let mut catalog = GameCatalog::empty();
catalog.insert("remote-game".to_string(), Some("20250101".to_string()));
let ctx = Ctx::new(
peer_game_db.clone(),
"local-peer".to_string(),
PathBuf::new(),
PathBuf::new(),
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(HashSet::new())),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
);
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
+12 -2
View File
@@ -2,6 +2,7 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use lanspread_db::db::GameCatalog;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -18,6 +19,7 @@ use crate::{
pub async fn run_ping_service(
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<GameCatalog>>,
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
shutdown: CancellationToken,
@@ -40,6 +42,7 @@ pub async fn run_ping_service(
ping_idle_peers(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -50,6 +53,7 @@ pub async fn run_ping_service(
prune_stale_peers(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -60,6 +64,7 @@ pub async fn run_ping_service(
async fn ping_idle_peers(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -75,6 +80,7 @@ async fn ping_idle_peers(
let tx_notify_ui = tx_notify_ui.clone();
let peer_game_db = peer_game_db.clone();
let catalog = catalog.clone();
let active_operations = active_operations.clone();
let active_downloads = active_downloads.clone();
let shutdown = shutdown.clone();
@@ -93,6 +99,7 @@ async fn ping_idle_peers(
log::warn!("Peer {peer_addr} failed ping check");
remove_peer_and_refresh(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -105,6 +112,7 @@ async fn ping_idle_peers(
log::error!("Failed to ping peer {peer_addr}: {err}");
remove_peer_and_refresh(
&peer_game_db,
&catalog,
&active_operations,
&active_downloads,
&tx_notify_ui,
@@ -120,6 +128,7 @@ async fn ping_idle_peers(
async fn prune_stale_peers(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -137,7 +146,7 @@ async fn prune_stale_peers(
}
if removed_any {
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
handle_active_downloads_without_peers(
peer_game_db,
active_operations,
@@ -150,6 +159,7 @@ async fn prune_stale_peers(
async fn remove_peer_and_refresh(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
catalog: &Arc<RwLock<GameCatalog>>,
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -157,7 +167,7 @@ async fn remove_peer_and_refresh(
log_label: &str,
) {
if remove_peer(peer_game_db, tx_notify_ui, peer_id, log_label).await {
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
events::emit_peer_game_list(peer_game_db, catalog, tx_notify_ui).await;
handle_active_downloads_without_peers(
peer_game_db,
active_operations,
@@ -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}"),
}
@@ -336,12 +336,12 @@ fn should_ignore_game_child(name: &str) -> bool {
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use lanspread_db::db::GameCatalog;
use notify::{
EventKind,
event::{AccessKind, AccessMode},
@@ -373,15 +373,18 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> Ctx {
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> Ctx {
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(),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(std::collections::HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
}
@@ -444,7 +447,7 @@ mod tests {
let temp = TempDir::new("lanspread-local-monitor");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from(["game".to_string()]),
GameCatalog::from_ids(["game".to_string()]),
);
ctx.active_operations
.write()
@@ -479,7 +482,7 @@ mod tests {
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from(["game".to_string()]),
GameCatalog::from_ids(["game".to_string()]),
);
let gate = RescanGate::default();
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -514,7 +517,7 @@ mod tests {
write_file(&game_root.join("version.ini"), b"20250101");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from(["game".to_string()]),
GameCatalog::from_ids(["game".to_string()]),
);
let gate = RescanGate::default();
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -550,7 +553,7 @@ mod tests {
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from(["game".to_string()]),
GameCatalog::from_ids(["game".to_string()]),
);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -574,7 +577,7 @@ mod tests {
);
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from(["game".to_string()]),
GameCatalog::from_ids(["game".to_string()]),
);
let (tx, mut rx) = mpsc::unbounded_channel();
+212 -26
View File
@@ -12,9 +12,10 @@ use crate::{
context::PeerCtx,
error::PeerError,
events,
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
peer::{send_game_file_chunk, send_game_file_data},
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
stream_install::{send_game_install_stream, send_stream_install_error},
};
type ResponseWriter = FramedWrite<SendStream, LengthDelimitedCodec>;
@@ -99,6 +100,9 @@ async fn dispatch_request(
} => {
handle_file_chunk_request(ctx, game_id, relative_path, offset, length, framed_tx).await
}
Request::StreamInstall { game_id } => {
handle_stream_install_request(ctx, game_id, framed_tx).await
}
Request::Goodbye { peer_id } => {
handle_goodbye(ctx, remote_addr, peer_id).await;
framed_tx
@@ -162,7 +166,7 @@ async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelt
};
if applied {
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
} else {
let addr = {
let db = ctx.peer_game_db.read().await;
@@ -209,7 +213,7 @@ async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
local_download_available(game_dir, game_id, &active_operations, &catalog).await
local_download_matches_catalog(game_dir, game_id, &active_operations, &catalog).await
}
async fn can_dispatch_file_transfer(
@@ -218,10 +222,23 @@ async fn can_dispatch_file_transfer(
game_id: &str,
relative_path: &str,
) -> bool {
!path_points_inside_local(game_id, relative_path)
relative_path_belongs_to_game(game_id, relative_path)
&& !path_points_inside_local(game_id, relative_path)
&& can_serve_game(ctx, game_dir, game_id).await
}
fn relative_path_belongs_to_game(game_id: &str, relative_path: &str) -> bool {
let normalised = relative_path.replace('\\', "/");
if normalised.starts_with('/') {
return false;
}
normalised
.split('/')
.find(|part| !part.is_empty())
.is_some_and(|first| first == game_id)
}
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
let normalised = relative_path.replace('\\', "/");
let mut parts = normalised.split('/').filter(|part| !part.is_empty());
@@ -232,6 +249,67 @@ fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
}
}
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TRANSFER_ID: AtomicU64 = AtomicU64::new(1);
struct TransferGuard {
game_id: String,
id: u64,
active_outbound_transfers: crate::context::OutboundTransfers,
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
}
impl TransferGuard {
async fn new(
game_id: String,
active_outbound_transfers: crate::context::OutboundTransfers,
tx_notify_ui: tokio::sync::mpsc::UnboundedSender<crate::PeerEvent>,
shutdown: &tokio_util::sync::CancellationToken,
) -> (Self, tokio_util::sync::CancellationToken) {
let id = NEXT_TRANSFER_ID.fetch_add(1, Ordering::SeqCst);
let token = shutdown.child_token();
{
let mut active = active_outbound_transfers.write().await;
active
.entry(game_id.clone())
.or_default()
.push((id, token.clone()));
}
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
(
Self {
game_id,
id,
active_outbound_transfers,
tx_notify_ui,
},
token,
)
}
}
impl Drop for TransferGuard {
fn drop(&mut self) {
let game_id = self.game_id.clone();
let id = self.id;
let active_outbound_transfers = self.active_outbound_transfers.clone();
let tx_notify_ui = self.tx_notify_ui.clone();
tokio::spawn(async move {
{
let mut active = active_outbound_transfers.write().await;
if let Some(tokens) = active.get_mut(&game_id) {
tokens.retain(|(tid, _)| *tid != id);
if tokens.is_empty() {
active.remove(&game_id);
}
}
}
let _ = tx_notify_ui.send(crate::PeerEvent::OutboundTransferCountChanged);
});
}
}
async fn handle_file_data_request(
ctx: &PeerCtx,
desc: GameFileDescription,
@@ -242,6 +320,14 @@ async fn handle_file_data_request(
desc.relative_path
);
let (guard, cancel_token) = TransferGuard::new(
desc.game_id.clone(),
ctx.active_outbound_transfers.clone(),
ctx.tx_notify_ui.clone(),
&ctx.shutdown,
)
.await;
let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone();
if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
@@ -249,11 +335,13 @@ async fn handle_file_data_request(
"Declining GetGameFileData for {} because the game is not currently transferable",
desc.relative_path
);
drop(guard);
let _ = tx.close().await;
return FramedWrite::new(tx, LengthDelimitedCodec::new());
}
send_game_file_data(&desc, &mut tx, &game_dir).await;
send_game_file_data(&desc, &mut tx, &game_dir, cancel_token).await;
drop(guard);
FramedWrite::new(tx, LengthDelimitedCodec::new())
}
@@ -269,37 +357,99 @@ async fn handle_file_chunk_request(
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
);
let (guard, cancel_token) = TransferGuard::new(
game_id.clone(),
ctx.active_outbound_transfers.clone(),
ctx.tx_notify_ui.clone(),
&ctx.shutdown,
)
.await;
let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone();
if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
log::info!(
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
);
drop(guard);
let _ = tx.close().await;
return FramedWrite::new(tx, LengthDelimitedCodec::new());
}
send_game_file_chunk(&game_id, &relative_path, offset, length, &mut tx, &game_dir).await;
send_game_file_chunk(
&game_id,
&relative_path,
offset,
length,
&mut tx,
&game_dir,
cancel_token,
)
.await;
drop(guard);
FramedWrite::new(tx, LengthDelimitedCodec::new())
}
async fn handle_stream_install_request(
ctx: &PeerCtx,
game_id: String,
framed_tx: ResponseWriter,
) -> ResponseWriter {
log::info!("Received StreamInstall request for {game_id} from peer");
let (guard, cancel_token) = TransferGuard::new(
game_id.clone(),
ctx.active_outbound_transfers.clone(),
ctx.tx_notify_ui.clone(),
&ctx.shutdown,
)
.await;
let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone();
if !can_serve_game(ctx, &game_dir, &game_id).await {
log::info!(
"Declining StreamInstall for {game_id} because the game is not currently transferable"
);
tx = send_stream_install_error(tx, format!("game {game_id} is not transferable")).await;
drop(guard);
return FramedWrite::new(tx, LengthDelimitedCodec::new());
}
let game_root = game_dir.join(&game_id);
let (returned_tx, result) = send_game_install_stream(
ctx.stream_install_provider.clone(),
tx,
&game_root,
&game_id,
cancel_token,
)
.await;
if let Err(err) = result {
log::warn!("StreamInstall for {game_id} ended with error: {err}");
}
drop(guard);
FramedWrite::new(returned_tx, LengthDelimitedCodec::new())
}
async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id: String) {
log::info!("Received Goodbye from peer {peer_id}");
let removed = { ctx.peer_game_db.write().await.remove_peer(&peer_id) };
let Some(peer) = removed else { return };
events::emit_peer_lost(&ctx.peer_game_db, &ctx.tx_notify_ui, peer.addr).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.catalog, &ctx.tx_notify_ui).await;
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
};
use lanspread_db::db::GameCatalog;
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
@@ -327,16 +477,19 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
fn test_ctx(game_dir: PathBuf, catalog: GameCatalog) -> PeerCtx {
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
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(),
Arc::new(RwLock::new(catalog)),
Arc::new(RwLock::new(std::collections::HashMap::new())),
Arc::new(crate::NoopStreamInstallProvider),
)
.to_peer_ctx(tx_notify_ui)
}
@@ -350,6 +503,19 @@ mod tests {
assert!(!path_points_inside_local("game", "game/archive.eti"));
}
#[test]
fn transferable_paths_must_belong_to_requested_game() {
assert!(relative_path_belongs_to_game("game", "game/version.ini"));
assert!(relative_path_belongs_to_game("game", "game\\archive.eti"));
assert!(!relative_path_belongs_to_game("game", "other/archive.eti"));
assert!(!relative_path_belongs_to_game("game", "archive.eti"));
assert!(!relative_path_belongs_to_game("game", "/game/archive.eti"));
assert!(!relative_path_belongs_to_game(
"game",
"../game/archive.eti"
));
}
#[tokio::test]
async fn get_game_response_respects_serve_gates() {
let temp = TempDir::new("lanspread-stream");
@@ -359,17 +525,19 @@ mod tests {
b"20250101",
);
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
write_file(
&temp.path().join("wrong-version").join("version.ini"),
b"20260101",
);
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
.expect("missing sentinel root should be created");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from([
"ready".to_string(),
"active".to_string(),
"missing-sentinel".to_string(),
]),
);
let mut catalog = GameCatalog::empty();
catalog.insert("ready".to_string(), Some("20250101".to_string()));
catalog.insert("active".to_string(), Some("20250101".to_string()));
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
ctx.active_operations
.write()
.await
@@ -387,6 +555,10 @@ mod tests {
get_game_response(&ctx, "active".to_string()).await,
Response::GameNotFound(id) if id == "active"
));
assert!(matches!(
get_game_response(&ctx, "wrong-version".to_string()).await,
Response::GameNotFound(id) if id == "wrong-version"
));
assert!(matches!(
get_game_response(&ctx, "missing-sentinel".to_string()).await,
Response::GameNotFound(id) if id == "missing-sentinel"
@@ -402,23 +574,28 @@ mod tests {
b"20250101",
);
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
write_file(
&temp.path().join("wrong-version").join("version.ini"),
b"20260101",
);
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
.expect("missing sentinel root should be created");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from([
"ready".to_string(),
"active".to_string(),
"missing-sentinel".to_string(),
]),
);
let mut catalog = GameCatalog::empty();
catalog.insert("ready".to_string(), Some("20250101".to_string()));
catalog.insert("active".to_string(), Some("20250101".to_string()));
catalog.insert("missing-sentinel".to_string(), Some("20250101".to_string()));
catalog.insert("wrong-version".to_string(), Some("20250101".to_string()));
let ctx = test_ctx(temp.path().to_path_buf(), catalog);
ctx.active_operations
.write()
.await
.insert("active".to_string(), OperationKind::Downloading);
assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await);
assert!(
!can_dispatch_file_transfer(&ctx, temp.path(), "ready", "active/version.ini").await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
@@ -431,6 +608,15 @@ mod tests {
assert!(
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
temp.path(),
"wrong-version",
"wrong-version/version.ini",
)
.await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
+12 -1
View File
@@ -11,6 +11,7 @@ use std::{
};
use futures::FutureExt as _;
use lanspread_db::db::GameCatalog;
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -22,6 +23,7 @@ use crate::{
PeerCommand,
PeerEvent,
PeerRuntimeComponent,
StreamInstallProvider,
Unpacker,
context::Ctx,
events,
@@ -82,8 +84,11 @@ 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>>>,
catalog: Arc<RwLock<GameCatalog>>,
active_outbound_transfers: crate::context::OutboundTransfers,
stream_install_provider: Arc<dyn StreamInstallProvider>,
) -> PeerRuntimeHandle {
let shutdown = CancellationToken::new();
let task_tracker = TaskTracker::new();
@@ -98,10 +103,13 @@ pub(crate) fn spawn_peer_runtime(
peer_game_db,
peer_id,
game_dir,
state_dir,
unpacker,
runtime_shutdown.clone(),
runtime_tracker.clone(),
catalog,
active_outbound_transfers,
stream_install_provider,
)
.await
{
@@ -188,6 +196,7 @@ fn spawn_peer_discovery_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEv
fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
let tx_notify_ui = tx_notify_ui.clone();
let peer_game_db = ctx.peer_game_db.clone();
let catalog = ctx.catalog.clone();
let active_operations = ctx.active_operations.clone();
let active_downloads = ctx.active_downloads.clone();
let shutdown = ctx.shutdown.clone();
@@ -205,6 +214,7 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
move || {
let tx_notify_ui = tx_notify_ui.clone();
let peer_game_db = peer_game_db.clone();
let catalog = catalog.clone();
let active_operations = active_operations.clone();
let active_downloads = active_downloads.clone();
let shutdown = shutdown.clone();
@@ -213,6 +223,7 @@ fn spawn_peer_liveness_service(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
run_ping_service(
tx_notify_ui,
peer_game_db,
catalog,
active_operations,
active_downloads,
shutdown,
+48
View File
@@ -0,0 +1,48 @@
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";
const LAUNCH_SETTINGS_APPLIED_FILE: &str = "launch_settings_applied";
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)
}
#[must_use]
pub fn launch_settings_applied_path(state_dir: &Path, game_id: &str) -> PathBuf {
game_state_dir(state_dir, game_id).join(LAUNCH_SETTINGS_APPLIED_FILE)
}
+958
View File
@@ -0,0 +1,958 @@
use std::{
future::Future,
net::SocketAddr,
path::{Path, PathBuf},
pin::Pin,
process::Stdio,
sync::Arc,
time::{Duration, Instant},
};
use bytes::Bytes;
use crc32fast::Hasher;
use futures::{SinkExt, StreamExt};
use lanspread_proto::{Message, Request, StreamInstallFrame};
use s2n_quic::stream::SendStream;
use tokio::{
fs::File,
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
process::Command,
sync::{mpsc, mpsc::UnboundedSender},
time::{self, MissedTickBehavior},
};
use tokio_util::{
codec::{FramedRead, FramedWrite, LengthDelimitedCodec},
sync::CancellationToken,
};
use crate::{
DownloadProgress,
PeerEvent,
install::root_eti_archives,
network::connect_to_peer,
path_validation::validate_game_file_path,
};
const FRAME_CHANNEL_DEPTH: usize = 16;
const STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
/// Integrity metadata advertised by the sender's RAR archive.
///
/// This catches transport corruption, truncation, and provider bugs. It is not
/// a trusted-content guarantee because a malicious peer controls both the bytes
/// and the archive metadata. Trusted content would need catalog-owned hashes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SenderArchiveIntegrity {
expected_size: u64,
expected_crc32: u32,
}
impl SenderArchiveIntegrity {
fn new(expected_size: u64, expected_crc32: u32) -> Self {
Self {
expected_size,
expected_crc32,
}
}
fn verify(self, relative_path: &str, received: u64, actual_crc32: u32) -> eyre::Result<()> {
if received != self.expected_size {
eyre::bail!(
"streamed file {relative_path} size mismatch: got {received}, expected {}",
self.expected_size
);
}
if actual_crc32 != self.expected_crc32 {
eyre::bail!(
"streamed file {relative_path} sender RAR CRC32 mismatch: got {actual_crc32:08X}, expected {:08X}",
self.expected_crc32
);
}
Ok(())
}
}
pub type StreamInstallFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
#[derive(Clone)]
pub struct StreamInstallFrameSink {
frames: mpsc::Sender<StreamInstallFrame>,
cancel_token: CancellationToken,
}
impl StreamInstallFrameSink {
fn new(frames: mpsc::Sender<StreamInstallFrame>, cancel_token: CancellationToken) -> Self {
Self {
frames,
cancel_token,
}
}
pub async fn send(&self, frame: StreamInstallFrame) -> eyre::Result<()> {
tokio::select! {
() = self.cancel_token.cancelled() => {
eyre::bail!("streamed install frame send was cancelled");
}
result = self.frames.send(frame) => {
result.map_err(|_| eyre::eyre!("streamed install frame receiver closed"))
}
}
}
}
pub trait StreamInstallProvider: Send + Sync {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
frames: StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> StreamInstallFuture<'a>;
}
#[derive(Debug, Default)]
pub struct NoopStreamInstallProvider;
impl StreamInstallProvider for NoopStreamInstallProvider {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
_frames: StreamInstallFrameSink,
_cancel_token: CancellationToken,
) -> StreamInstallFuture<'a> {
Box::pin(async move {
eyre::bail!(
"streamed install provider is not configured for {}",
archive.display()
)
})
}
}
#[derive(Debug)]
pub struct ExternalUnrarStreamProvider {
program: PathBuf,
}
impl ExternalUnrarStreamProvider {
#[must_use]
pub fn new(program: PathBuf) -> Self {
Self { program }
}
}
impl StreamInstallProvider for ExternalUnrarStreamProvider {
fn stream_archive<'a>(
&'a self,
archive: &'a Path,
frames: StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> StreamInstallFuture<'a> {
Box::pin(async move {
let listing = unrar_listing(&self.program, archive).await?;
let archive_name = archive
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("archive.eti")
.to_string();
frames
.send(StreamInstallFrame::ArchiveBegin {
archive_name: archive_name.clone(),
solid: listing.solid,
unpacked_size: listing.unpacked_size(),
})
.await?;
stream_unrar_entries(
&self.program,
archive,
&listing.entries,
&frames,
cancel_token.clone(),
)
.await?;
frames
.send(StreamInstallFrame::ArchiveEnd { archive_name })
.await
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RarListing {
solid: bool,
entries: Vec<RarEntry>,
}
impl RarListing {
fn unpacked_size(&self) -> u64 {
self.entries
.iter()
.filter(|entry| entry.kind == RarEntryKind::File)
.map(|entry| entry.size)
.sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RarEntry {
relative_path: String,
kind: RarEntryKind,
size: u64,
crc32: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RarEntryKind {
File,
Directory,
}
#[derive(Default)]
struct RarEntryDraft {
relative_path: Option<String>,
kind: Option<RarEntryKind>,
size: Option<u64>,
crc32: Option<u32>,
}
async fn unrar_listing(program: &Path, archive: &Path) -> eyre::Result<RarListing> {
let output = Command::new(program)
.arg("lt")
.arg("-cfg-")
.arg(archive)
.output()
.await?;
if !output.status.success() {
eyre::bail!(
"unrar lt failed for {} with status {}: {}",
archive.display(),
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
parse_unrar_listing(&String::from_utf8_lossy(&output.stdout))
}
fn parse_unrar_listing(output: &str) -> eyre::Result<RarListing> {
let mut solid = false;
let mut entries = Vec::new();
let mut current = RarEntryDraft::default();
for line in output.lines() {
let trimmed = line.trim();
if let Some(details) = trimmed.strip_prefix("Details:") {
solid = details.to_ascii_lowercase().contains("solid");
continue;
}
if let Some(name) = trimmed.strip_prefix("Name:") {
push_rar_entry(&mut entries, std::mem::take(&mut current))?;
current.relative_path = Some(name.trim().to_string());
continue;
}
if let Some(kind) = trimmed.strip_prefix("Type:") {
current.kind = match kind.trim() {
"File" => Some(RarEntryKind::File),
"Directory" => Some(RarEntryKind::Directory),
_ => None,
};
continue;
}
if let Some(size) = trimmed.strip_prefix("Size:") {
current.size = Some(size.trim().parse()?);
continue;
}
if let Some(crc) = trimmed.strip_prefix("CRC32:") {
current.crc32 = Some(u32::from_str_radix(crc.trim(), 16)?);
}
}
push_rar_entry(&mut entries, current)?;
Ok(RarListing { solid, entries })
}
fn push_rar_entry(entries: &mut Vec<RarEntry>, draft: RarEntryDraft) -> eyre::Result<()> {
let Some(relative_path) = draft.relative_path else {
return Ok(());
};
let Some(kind) = draft.kind else {
return Ok(());
};
let (size, crc32) = match kind {
RarEntryKind::File => {
let size = draft
.size
.ok_or_else(|| eyre::eyre!("RAR file entry {relative_path} has no Size"))?;
let crc32 = match (size, draft.crc32) {
(_, Some(crc32)) => crc32,
(0, None) => 0,
(_, None) => {
eyre::bail!("RAR file entry {relative_path} has no CRC32");
}
};
(size, Some(crc32))
}
RarEntryKind::Directory => (0, None),
};
entries.push(RarEntry {
relative_path,
kind,
size,
crc32,
});
Ok(())
}
async fn stream_unrar_entries(
program: &Path,
archive: &Path,
entries: &[RarEntry],
frames: &StreamInstallFrameSink,
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let mut child = Command::new(program)
.arg("p")
.arg("-inul")
.arg("-cfg-")
.arg(archive)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let result = async {
let mut stdout = child
.stdout
.take()
.ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?;
let mut buffer = vec![0_u8; STREAM_CHUNK_SIZE];
for entry in entries {
if cancel_token.is_cancelled() {
eyre::bail!("streamed archive {} was cancelled", archive.display());
}
match entry.kind {
RarEntryKind::Directory => {
frames
.send(StreamInstallFrame::Directory {
relative_path: entry.relative_path.clone(),
})
.await?;
}
RarEntryKind::File => {
let Some(crc32) = entry.crc32 else {
eyre::bail!("RAR file entry {} has no CRC32", entry.relative_path);
};
frames
.send(StreamInstallFrame::FileBegin {
relative_path: entry.relative_path.clone(),
size: entry.size,
crc32,
})
.await?;
stream_unrar_file_from_stdout(
&mut stdout,
archive,
entry,
frames,
&mut buffer,
&cancel_token,
)
.await?;
frames
.send(StreamInstallFrame::FileEnd {
relative_path: entry.relative_path.clone(),
})
.await?;
}
}
}
let extra =
read_unrar_stdout(&mut stdout, &mut buffer[..1], &cancel_token, archive).await?;
if extra != 0 {
eyre::bail!(
"unrar produced bytes after listed entries for {}",
archive.display()
);
}
let status = wait_unrar_child(&mut child, &cancel_token, archive).await?;
if !status.success() {
eyre::bail!(
"unrar p failed for {} with status {status}",
archive.display()
);
}
Ok(())
}
.await;
if result.is_err() {
let _ = child.kill().await;
}
result
}
async fn stream_unrar_file_from_stdout(
stdout: &mut (impl AsyncRead + Unpin),
archive: &Path,
entry: &RarEntry,
frames: &StreamInstallFrameSink,
buffer: &mut [u8],
cancel_token: &CancellationToken,
) -> eyre::Result<()> {
let mut remaining = entry.size;
while remaining > 0 {
let read_len = usize::try_from(remaining.min(u64::try_from(buffer.len())?))?;
let read =
read_unrar_stdout(stdout, &mut buffer[..read_len], cancel_token, archive).await?;
if read == 0 {
eyre::bail!(
"unrar ended while streaming {} from {}; {remaining} bytes missing",
entry.relative_path,
archive.display()
);
}
frames
.send(StreamInstallFrame::FileChunk {
bytes: Bytes::copy_from_slice(&buffer[..read]),
})
.await?;
remaining = remaining.saturating_sub(u64::try_from(read)?);
}
Ok(())
}
async fn read_unrar_stdout(
stdout: &mut (impl AsyncRead + Unpin),
buffer: &mut [u8],
cancel_token: &CancellationToken,
archive: &Path,
) -> eyre::Result<usize> {
tokio::select! {
() = cancel_token.cancelled() => {
eyre::bail!("streamed archive {} was cancelled", archive.display());
}
read = stdout.read(buffer) => Ok(read?),
}
}
async fn wait_unrar_child(
child: &mut tokio::process::Child,
cancel_token: &CancellationToken,
archive: &Path,
) -> eyre::Result<std::process::ExitStatus> {
tokio::select! {
() = cancel_token.cancelled() => {
let _ = child.kill().await;
eyre::bail!("streamed archive {} was cancelled", archive.display());
}
status = child.wait() => Ok(status?),
}
}
pub(crate) async fn send_stream_install_error(
tx: SendStream,
message: impl Into<String>,
) -> SendStream {
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
if let Err(err) = framed_tx
.send(
StreamInstallFrame::Error {
message: message.into(),
}
.encode(),
)
.await
{
log::warn!("Failed to send streamed install error frame: {err}");
}
if let Err(err) = framed_tx.close().await {
log::debug!("Failed to close streamed install error response: {err}");
}
framed_tx.into_inner()
}
pub(crate) async fn send_game_install_stream(
provider: Arc<dyn StreamInstallProvider>,
tx: SendStream,
game_root: &Path,
game_id: &str,
cancel_token: CancellationToken,
) -> (SendStream, eyre::Result<()>) {
let archives = match root_eti_archives(game_root).await {
Ok(archives) => archives,
Err(err) => {
let message = err.to_string();
let tx = send_stream_install_error(tx, message.clone()).await;
return (tx, Err(eyre::eyre!(message)));
}
};
if archives.is_empty() {
let message = format!("no .eti archives found for {game_id}");
let tx = send_stream_install_error(tx, message.clone()).await;
return (tx, Err(eyre::eyre!(message)));
}
let (frame_tx, mut frame_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let producer_cancel = cancel_token.child_token();
let frame_sink = StreamInstallFrameSink::new(frame_tx, producer_cancel.clone());
let game_id_for_producer = game_id.to_string();
let producer = tokio::spawn({
let provider = provider.clone();
let producer_cancel = producer_cancel.clone();
async move {
for archive in archives {
if producer_cancel.is_cancelled() {
eyre::bail!("streamed install for {game_id_for_producer} was cancelled");
}
if let Err(err) = provider
.stream_archive(&archive, frame_sink.clone(), producer_cancel.clone())
.await
{
let message = err.to_string();
let _ = frame_sink.send(StreamInstallFrame::Error { message }).await;
return Err(err);
}
}
let _ = frame_sink.send(StreamInstallFrame::Complete).await;
Ok(())
}
});
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
let mut send_result = Ok(());
while let Some(frame) = frame_rx.recv().await {
if let Err(err) = framed_tx.send(frame.encode()).await {
producer_cancel.cancel();
send_result = Err(eyre::eyre!("failed to send streamed install frame: {err}"));
break;
}
}
drop(frame_rx);
let close_result = framed_tx
.close()
.await
.map_err(|err| eyre::eyre!("failed to close streamed install stream: {err}"));
let tx = framed_tx.into_inner();
let producer_result = match producer.await {
Ok(result) => result,
Err(err) => Err(eyre::eyre!("streamed install producer task failed: {err}")),
};
let result = send_result.and(producer_result).and(close_result);
(tx, result)
}
pub(crate) async fn receive_streamed_install(
peer_addr: SocketAddr,
game_id: &str,
staging_dir: &Path,
tx_notify_ui: UnboundedSender<PeerEvent>,
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let staging_dir = tokio::fs::canonicalize(staging_dir)
.await
.unwrap_or_else(|_| staging_dir.to_path_buf());
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx
.send(
Request::StreamInstall {
game_id: game_id.to_string(),
}
.encode(),
)
.await?;
framed_tx.close().await?;
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut current_file: Option<IncomingFile> = None;
let mut progress = StreamInstallProgress::new(game_id.to_string());
let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL);
progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
progress_interval.tick().await;
loop {
let next = tokio::select! {
() = cancel_token.cancelled() => eyre::bail!("streamed install for {game_id} was cancelled"),
_ = progress_interval.tick() => {
progress.emit_current(&tx_notify_ui);
continue;
}
next = framed_rx.next() => next,
};
let Some(frame) = next else {
eyre::bail!("streamed install ended before Complete");
};
let frame = frame?.freeze();
let frame = StreamInstallFrame::decode(frame);
match frame {
StreamInstallFrame::ArchiveBegin {
archive_name,
solid,
unpacked_size,
} => {
progress.add_total(unpacked_size);
progress.emit_snapshot(&tx_notify_ui, 0);
log::info!(
"Receiving streamed install archive {archive_name} for {game_id} \
(solid={solid}, unpacked_size={unpacked_size})"
);
}
StreamInstallFrame::Directory { relative_path } => {
let path = resolve_stream_path(&staging_dir, &relative_path)?;
tokio::fs::create_dir_all(path).await?;
}
StreamInstallFrame::FileBegin {
relative_path,
size,
crc32,
} => {
if current_file.is_some() {
eyre::bail!("received FileBegin for {relative_path} before previous FileEnd");
}
let path = resolve_stream_path(&staging_dir, &relative_path)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = File::create(&path).await?;
current_file = Some(IncomingFile::new(relative_path, path, size, crc32, file));
}
StreamInstallFrame::FileChunk { bytes } => {
let Some(file) = current_file.as_mut() else {
eyre::bail!("received FileChunk without FileBegin");
};
let length = file
.write_chunk(game_id, peer_addr, &tx_notify_ui, bytes)
.await?;
progress.record_bytes(length);
}
StreamInstallFrame::FileEnd { relative_path } => {
let Some(file) = current_file.take() else {
eyre::bail!("received FileEnd for {relative_path} without FileBegin");
};
file.finish(&relative_path).await?;
}
StreamInstallFrame::ArchiveEnd { archive_name } => {
log::info!("Finished streamed install archive {archive_name} for {game_id}");
}
StreamInstallFrame::Complete => {
if current_file.is_some() {
eyre::bail!("streamed install completed with an open file");
}
progress.emit_snapshot(&tx_notify_ui, 0);
return Ok(());
}
StreamInstallFrame::Error { message } => {
eyre::bail!("streamed install sender failed: {message}");
}
}
}
}
struct StreamInstallProgress {
id: String,
total_bytes: u64,
downloaded_bytes: u64,
last_downloaded_bytes: u64,
last_at: Instant,
}
impl StreamInstallProgress {
fn new(id: String) -> Self {
Self {
id,
total_bytes: 0,
downloaded_bytes: 0,
last_downloaded_bytes: 0,
last_at: Instant::now(),
}
}
fn add_total(&mut self, bytes: u64) {
self.total_bytes = self.total_bytes.saturating_add(bytes);
}
fn record_bytes(&mut self, bytes: u64) {
self.downloaded_bytes = self.downloaded_bytes.saturating_add(bytes);
}
fn emit_current(&mut self, tx_notify_ui: &UnboundedSender<PeerEvent>) {
let now = Instant::now();
let speed = bytes_per_second(
self.downloaded_bytes
.saturating_sub(self.last_downloaded_bytes),
now.duration_since(self.last_at),
);
self.last_downloaded_bytes = self.downloaded_bytes;
self.last_at = now;
self.emit_snapshot(tx_notify_ui, speed);
}
fn emit_snapshot(&self, tx_notify_ui: &UnboundedSender<PeerEvent>, bytes_per_second: u64) {
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesProgress(DownloadProgress {
id: self.id.clone(),
downloaded_bytes: self.downloaded_bytes,
total_bytes: self.total_bytes,
bytes_per_second,
active_peer_count: 1,
}));
}
}
fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 {
let millis = elapsed.as_millis().max(1);
let rate = u128::from(bytes).saturating_mul(1_000) / millis;
u64::try_from(rate).unwrap_or(u64::MAX)
}
struct IncomingFile {
relative_path: String,
path: PathBuf,
integrity: SenderArchiveIntegrity,
received: u64,
crc32: Hasher,
file: File,
}
impl IncomingFile {
fn new(
relative_path: String,
path: PathBuf,
expected_size: u64,
expected_crc32: u32,
file: File,
) -> Self {
Self {
relative_path,
path,
integrity: SenderArchiveIntegrity::new(expected_size, expected_crc32),
received: 0,
crc32: Hasher::new(),
file,
}
}
async fn write_chunk(
&mut self,
game_id: &str,
peer_addr: SocketAddr,
tx_notify_ui: &UnboundedSender<PeerEvent>,
bytes: Bytes,
) -> eyre::Result<u64> {
let offset = self.received;
let length = u64::try_from(bytes.len())?;
if offset.saturating_add(length) > self.integrity.expected_size {
eyre::bail!(
"streamed file {} exceeded expected size {}",
self.relative_path,
self.integrity.expected_size
);
}
self.file.write_all(&bytes).await?;
self.crc32.update(&bytes);
self.received = self.received.saturating_add(length);
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
id: game_id.to_string(),
peer_addr,
relative_path: format!("{game_id}/.local.installing/{}", self.relative_path),
offset,
length,
});
Ok(length)
}
async fn finish(mut self, relative_path: &str) -> eyre::Result<()> {
if self.relative_path != relative_path {
eyre::bail!(
"streamed file end mismatch: began {}, ended {relative_path}",
self.relative_path
);
}
self.file.flush().await?;
let actual_crc32 = self.crc32.finalize();
self.integrity
.verify(&self.relative_path, self.received, actual_crc32)?;
log::debug!(
"Received streamed file {} -> {}",
self.relative_path,
self.path.display()
);
Ok(())
}
}
fn resolve_stream_path(staging_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
validate_game_file_path(staging_dir, relative_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TempDir;
#[test]
fn stream_paths_stay_inside_staging_dir() {
let temp = TempDir::new("lanspread-stream-install-path");
let staging = temp.path().join("staging");
std::fs::create_dir_all(&staging).expect("staging should be created");
let staging = std::fs::canonicalize(staging).expect("staging should canonicalize");
assert!(resolve_stream_path(&staging, "bin/game.exe").is_ok());
assert!(resolve_stream_path(&staging, "../outside").is_err());
assert!(resolve_stream_path(&staging, "/absolute").is_err());
assert!(resolve_stream_path(&staging, "C:/windows").is_err());
}
#[test]
fn parses_unrar_technical_listing() {
let listing = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5, solid
Name: bin/payload.bin
Type: File
Size: 123
CRC32: 38B488A7
Name: bin
Type: Directory
"#,
)
.expect("listing should parse");
assert!(listing.solid);
assert_eq!(
listing.entries,
vec![
RarEntry {
relative_path: "bin/payload.bin".to_string(),
kind: RarEntryKind::File,
size: 123,
crc32: Some(0x38B4_88A7),
},
RarEntry {
relative_path: "bin".to_string(),
kind: RarEntryKind::Directory,
size: 0,
crc32: None,
},
]
);
}
#[test]
fn rejects_unrar_file_entries_without_crc32() {
let err = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5
Name: bin/payload.bin
Type: File
Size: 123
"#,
)
.expect_err("file entries without CRC32 should be rejected");
assert!(err.to_string().contains("has no CRC32"));
}
#[test]
fn accepts_zero_size_unrar_file_entries_without_crc32() {
let listing = parse_unrar_listing(
r#"
Archive: game.eti
Details: RAR 5
Name: bin/empty.cfg
Type: File
Size: 0
"#,
)
.expect("empty file without CRC32 should parse as CRC32 zero");
assert_eq!(
listing.entries,
vec![RarEntry {
relative_path: "bin/empty.cfg".to_string(),
kind: RarEntryKind::File,
size: 0,
crc32: Some(0),
}]
);
}
#[test]
fn sender_archive_integrity_accepts_matching_size_and_crc32() {
let bytes = b"payload";
let integrity =
SenderArchiveIntegrity::new(u64::try_from(bytes.len()).unwrap(), crc32_of(bytes));
integrity
.verify(
"bin/payload.bin",
u64::try_from(bytes.len()).unwrap(),
crc32_of(bytes),
)
.expect("matching sender archive metadata should verify");
}
#[test]
fn sender_archive_integrity_rejects_size_mismatch() {
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
let err = integrity
.verify("bin/payload.bin", 6, crc32_of(b"payload"))
.expect_err("truncated file should fail sender archive integrity");
assert!(err.to_string().contains("size mismatch"));
}
#[test]
fn sender_archive_integrity_rejects_crc32_mismatch() {
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
let err = integrity
.verify("bin/payload.bin", 7, crc32_of(b"paylord"))
.expect_err("mutated file should fail sender archive integrity");
assert!(err.to_string().contains("sender RAR CRC32 mismatch"));
}
fn crc32_of(bytes: &[u8]) -> u32 {
let mut hasher = Hasher::new();
hasher.update(bytes);
hasher.finalize()
}
}
+10 -10
View File
@@ -3,13 +3,9 @@ name = "lanspread-proto"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
doctest = false
test = false
[dependencies]
# local
@@ -21,6 +17,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[lib]
test = false
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
+98 -1
View File
@@ -4,7 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::{Game, GameFileDescription};
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 4;
pub const PROTOCOL_VERSION: u32 = 5;
pub use lanspread_db::db::Availability;
@@ -67,6 +67,9 @@ pub enum Request {
offset: u64,
length: u64,
},
StreamInstall {
game_id: String,
},
Hello(Hello),
LibraryDelta {
peer_id: String,
@@ -94,6 +97,41 @@ pub enum Response {
InternalPeerError(String),
}
const STREAM_INSTALL_CONTROL_FRAME_TAG: u8 = 0;
const STREAM_INSTALL_FILE_CHUNK_FRAME_TAG: u8 = 1;
const STREAM_INSTALL_ENCODE_ERROR_FRAME: &[u8] =
b"\0{\"Error\":{\"message\":\"stream install frame encoding error\"}}";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum StreamInstallFrame {
ArchiveBegin {
archive_name: String,
solid: bool,
unpacked_size: u64,
},
Directory {
relative_path: String,
},
FileBegin {
relative_path: String,
size: u64,
crc32: u32,
},
FileChunk {
bytes: Bytes,
},
FileEnd {
relative_path: String,
},
ArchiveEnd {
archive_name: String,
},
Complete,
Error {
message: String,
},
}
// Add Message trait
pub trait Message {
fn decode(bytes: Bytes) -> Self;
@@ -145,3 +183,62 @@ impl Message for Response {
}
}
}
impl Message for StreamInstallFrame {
fn decode(bytes: Bytes) -> Self {
if bytes.is_empty() {
return stream_install_decode_error("stream install frame is empty");
}
let tag = bytes[0];
let payload = bytes.slice(1..);
match tag {
STREAM_INSTALL_CONTROL_FRAME_TAG => decode_stream_install_control_frame(&payload),
STREAM_INSTALL_FILE_CHUNK_FRAME_TAG => StreamInstallFrame::FileChunk { bytes: payload },
_ => stream_install_decode_error(format!("unknown stream install frame tag {tag}")),
}
}
fn encode(&self) -> Bytes {
match self {
StreamInstallFrame::FileChunk { bytes } => {
tagged_stream_install_frame(STREAM_INSTALL_FILE_CHUNK_FRAME_TAG, bytes)
}
_ => match serde_json::to_vec(self) {
Ok(payload) => {
tagged_stream_install_frame(STREAM_INSTALL_CONTROL_FRAME_TAG, &payload)
}
Err(e) => {
tracing::error!(?e, "StreamInstallFrame encoding error");
Bytes::from_static(STREAM_INSTALL_ENCODE_ERROR_FRAME)
}
},
}
}
}
fn decode_stream_install_control_frame(payload: &[u8]) -> StreamInstallFrame {
match serde_json::from_slice(payload) {
Ok(StreamInstallFrame::FileChunk { .. }) => {
stream_install_decode_error("stream install control frame cannot contain file bytes")
}
Ok(frame) => frame,
Err(e) => {
tracing::error!(?e, "StreamInstallFrame decoding error");
stream_install_decode_error(format!("stream install frame decoding error: {e}"))
}
}
}
fn tagged_stream_install_frame(tag: u8, payload: &[u8]) -> Bytes {
let mut frame = Vec::with_capacity(1 + payload.len());
frame.push(tag);
frame.extend_from_slice(payload);
Bytes::from(frame)
}
fn stream_install_decode_error(message: impl Into<String>) -> StreamInstallFrame {
StreamInstallFrame::Error {
message: message.into(),
}
}
@@ -0,0 +1,42 @@
use bytes::Bytes;
use lanspread_proto::{Message, StreamInstallFrame};
#[test]
fn file_chunks_encode_raw_bytes() {
let bytes = Bytes::from_static(&[0, 1, 2, 255]);
let encoded = StreamInstallFrame::FileChunk {
bytes: bytes.clone(),
}
.encode();
assert_eq!(&encoded[..], &[1, 0, 1, 2, 255]);
assert_eq!(
StreamInstallFrame::decode(encoded),
StreamInstallFrame::FileChunk { bytes }
);
}
#[test]
fn control_frames_are_tagged_json() {
let frame = StreamInstallFrame::FileBegin {
relative_path: "bin/game.exe".to_string(),
size: 42,
crc32: 0x38B4_88A7,
};
let encoded = frame.encode();
assert_eq!(encoded[0], 0);
assert_eq!(StreamInstallFrame::decode(encoded), frame);
}
#[test]
fn empty_frames_decode_as_errors() {
match StreamInstallFrame::decode(Bytes::new()) {
StreamInstallFrame::Error { message } => {
assert!(message.contains("empty"));
}
other => {
panic!("expected error frame, got {other:?}");
}
}
}
+58 -58
View File
@@ -6,13 +6,13 @@
"npm:@tauri-apps/plugin-dialog@^2.7.1": "2.7.1",
"npm:@tauri-apps/plugin-shell@^2.3.5": "2.3.5",
"npm:@tauri-apps/plugin-store@^2.4.3": "2.4.3",
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14",
"npm:@types/react@^19.2.14": "19.2.14",
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.13",
"npm:react-dom@^19.2.6": "19.2.6_react@19.2.6",
"npm:react@^19.2.6": "19.2.6",
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17",
"npm:@types/react@^19.2.17": "19.2.17",
"npm:@vitejs/plugin-react@^6.0.2": "6.0.2_vite@8.0.16",
"npm:react-dom@^19.2.7": "19.2.7_react@19.2.7",
"npm:react@^19.2.7": "19.2.7",
"npm:typescript@^6.0.3": "6.0.3",
"npm:vite@^8.0.13": "8.0.13"
"npm:vite@^8.0.16": "8.0.16"
},
"npm": {
"@emnapi/core@1.10.0": {
@@ -42,71 +42,71 @@
"@tybys/wasm-util"
]
},
"@oxc-project/types@0.130.0": {
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="
"@oxc-project/types@0.133.0": {
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="
},
"@rolldown/binding-android-arm64@1.0.1": {
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"@rolldown/binding-android-arm64@1.0.3": {
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-arm64@1.0.1": {
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"@rolldown/binding-darwin-arm64@1.0.3": {
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-x64@1.0.1": {
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"@rolldown/binding-darwin-x64@1.0.3": {
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rolldown/binding-freebsd-x64@1.0.1": {
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"@rolldown/binding-freebsd-x64@1.0.3": {
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-arm-gnueabihf@1.0.1": {
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"@rolldown/binding-linux-arm-gnueabihf@1.0.3": {
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rolldown/binding-linux-arm64-gnu@1.0.1": {
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"@rolldown/binding-linux-arm64-gnu@1.0.3": {
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-arm64-musl@1.0.1": {
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"@rolldown/binding-linux-arm64-musl@1.0.3": {
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-ppc64-gnu@1.0.1": {
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"@rolldown/binding-linux-ppc64-gnu@1.0.3": {
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rolldown/binding-linux-s390x-gnu@1.0.1": {
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"@rolldown/binding-linux-s390x-gnu@1.0.3": {
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rolldown/binding-linux-x64-gnu@1.0.1": {
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"@rolldown/binding-linux-x64-gnu@1.0.3": {
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-x64-musl@1.0.1": {
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"@rolldown/binding-linux-x64-musl@1.0.3": {
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-openharmony-arm64@1.0.1": {
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"@rolldown/binding-openharmony-arm64@1.0.3": {
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@rolldown/binding-wasm32-wasi@1.0.1": {
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"@rolldown/binding-wasm32-wasi@1.0.3": {
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
@@ -114,13 +114,13 @@
],
"cpu": ["wasm32"]
},
"@rolldown/binding-win32-arm64-msvc@1.0.1": {
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"@rolldown/binding-win32-arm64-msvc@1.0.3": {
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rolldown/binding-win32-x64-msvc@1.0.1": {
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"@rolldown/binding-win32-x64-msvc@1.0.3": {
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"os": ["win32"],
"cpu": ["x64"]
},
@@ -226,19 +226,19 @@
"tslib"
]
},
"@types/react-dom@19.2.3_@types+react@19.2.14": {
"@types/react-dom@19.2.3_@types+react@19.2.17": {
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dependencies": [
"@types/react"
]
},
"@types/react@19.2.14": {
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"@types/react@19.2.17": {
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dependencies": [
"csstype"
]
},
"@vitejs/plugin-react@6.0.2_vite@8.0.13": {
"@vitejs/plugin-react@6.0.2_vite@8.0.16": {
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"dependencies": [
"@rolldown/pluginutils",
@@ -349,26 +349,26 @@
"picomatch@4.0.4": {
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
},
"postcss@8.5.14": {
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"postcss@8.5.15": {
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"react-dom@19.2.6_react@19.2.6": {
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"react-dom@19.2.7_react@19.2.7": {
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"dependencies": [
"react",
"scheduler"
]
},
"react@19.2.6": {
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="
"react@19.2.7": {
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="
},
"rolldown@1.0.1": {
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"rolldown@1.0.3": {
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"dependencies": [
"@oxc-project/types",
"@rolldown/pluginutils"
@@ -398,8 +398,8 @@
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"tinyglobby@0.2.16": {
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": [
"fdir",
"picomatch"
@@ -412,8 +412,8 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"bin": true
},
"vite@8.0.13": {
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"vite@8.0.16": {
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dependencies": [
"lightningcss",
"picomatch",
@@ -436,12 +436,12 @@
"npm:@tauri-apps/plugin-shell@^2.3.5",
"npm:@tauri-apps/plugin-store@^2.4.3",
"npm:@types/react-dom@^19.2.3",
"npm:@types/react@^19.2.14",
"npm:@types/react@^19.2.17",
"npm:@vitejs/plugin-react@^6.0.2",
"npm:react-dom@^19.2.6",
"npm:react@^19.2.6",
"npm:react-dom@^19.2.7",
"npm:react@^19.2.7",
"npm:typescript@^6.0.3",
"npm:vite@^8.0.13"
"npm:vite@^8.0.16"
]
}
}
+4 -4
View File
@@ -12,17 +12,17 @@
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-store": "^2.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-shell": "^2.3.5"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.13",
"vite": "^8.0.16",
"@tauri-apps/cli": "^2.11.2"
}
}
@@ -8,28 +8,19 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib", "staticlib"]
doctest = false
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "lanspread_tauri_deno_ts_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
test = true
doctest = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
needless_pass_by_value = "allow"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
lanspread-compat = { path = "../../lanspread-compat" }
lanspread-db = { path = "../../lanspread-db" }
# local
lanspread-peer = { path = "../../lanspread-peer" }
lanspread-db = { path = "../../lanspread-db" }
lanspread-compat = { path = "../../lanspread-compat" }
# external
base64 = { workspace = true }
@@ -37,13 +28,27 @@ 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 }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-plugin-store = { workspace = true }
time = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true }
walkdir = { workspace = true }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
windows = { workspace = true }
[lints.clippy]
needless_pass_by_value = "allow"
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-logs",
"description": "Capability for the main-logs window",
"windows": [
"main-logs"
],
"permissions": [
"core:default"
]
}
@@ -1,26 +1,31 @@
#[cfg(target_os = "windows")]
use std::fs::File;
use std::{
collections::{HashMap, HashSet},
fs::{self, OpenOptions},
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
net::SocketAddr,
path::{Component, Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use eyre::bail;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
PeerEvent,
PeerGameDB,
PeerRuntimeHandle,
PeerStartOptions,
StreamInstallProvider,
UnpackFuture,
Unpacker,
start_peer,
migrate_legacy_state,
start_peer_with_options,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command};
@@ -28,9 +33,51 @@ use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
};
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
use tracing_subscriber::{
layer::{Context, Layer},
prelude::*,
registry::LookupSpan,
};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
type OutboundTransfers =
Arc<RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>>;
const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100);
#[derive(Default)]
struct OutboundTransferEmitState {
scheduled: bool,
generation: u64,
}
impl OutboundTransferEmitState {
fn record_change(&mut self) -> bool {
self.generation = self.generation.saturating_add(1);
if self.scheduled {
return false;
}
self.scheduled = true;
true
}
fn observed_generation(&self) -> u64 {
self.generation
}
fn finish_emit(&mut self, observed_generation: u64) -> bool {
if self.generation != observed_generation {
return true;
}
self.scheduled = false;
false
}
}
/// Tauri-managed runtime state shared by commands and setup tasks.
#[derive(Default)]
struct LanSpreadState {
@@ -40,8 +87,18 @@ struct LanSpreadState {
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
games_folder: Arc<RwLock<String>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
catalog: Arc<RwLock<GameCatalog>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
state_dir: OnceLock<PathBuf>,
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallSettings {
account_name: String,
language: String,
}
struct PeerEventTx(UnboundedSender<PeerEvent>);
@@ -63,11 +120,19 @@ struct UiActiveOperation {
#[derive(Clone, Debug, serde::Serialize)]
struct GamesListPayload {
games: Vec<Game>,
games: Vec<LauncherGame>,
active_operations: Vec<UiActiveOperation>,
}
#[derive(Clone, Debug, serde::Serialize)]
struct LauncherGame {
#[serde(flatten)]
game: Game,
can_host_server: bool,
active_outbound_transfers: usize,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
struct UnpackLogEntry {
archive: String,
destination: String,
@@ -79,11 +144,29 @@ struct UnpackLogEntry {
success: bool,
}
#[derive(Clone, Debug, serde::Serialize)]
struct MainLogLinePayload {
line: String,
level: String,
sequence: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct MainLogHistoryPayload {
contents: String,
last_sequence: u64,
}
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";
const MAIN_LOG_FILE_NAME: &str = "lanspread.log";
const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024;
const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024;
impl Unpacker for SidecarUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
@@ -102,8 +185,43 @@ 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";
#[tauri::command]
async fn get_main_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<MainLogHistoryPayload> {
if let Some(sink) = state.inner().main_log_sink.get() {
return Ok(sink.read_history()?);
}
let state_dir = app_handle.path().app_data_dir()?;
fs::create_dir_all(&state_dir)?;
let path = main_log_path(&state_dir);
match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) {
Ok(contents) => Ok(MainLogHistoryPayload {
contents,
last_sequence: 0,
}),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: 0,
}),
Err(err) => Err(err.into()),
}
}
#[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))]
const GAME_START_SCRIPT: &str = "game_start.cmd";
const SERVER_START_SCRIPT: &str = "server_start.cmd";
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
const DEFAULT_LANGUAGE: &str = "en";
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
const DEFAULT_USERNAME: &str = "Commander";
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
const MAX_USERNAME_CHARS: usize = 24;
#[tauri::command]
async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
@@ -124,7 +242,12 @@ 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,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
@@ -150,11 +273,12 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
return Ok(false);
};
let _ = (language, username);
let handled = if let Some(peer_ctrl) = peer_ctrl {
let command = if !downloaded {
PeerCommand::GetGame(id)
PeerCommand::GetGame(id.clone())
} else if !installed {
PeerCommand::InstallGame { id }
PeerCommand::InstallGame { id: id.clone() }
} else {
log::info!("Game is already installed: {id}");
return Ok(false);
@@ -162,6 +286,7 @@ 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:?}");
return Ok(false);
}
true
} else {
@@ -173,7 +298,62 @@ 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 stream_install_game(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let Some((downloaded, installed, peer_count)) = state
.inner()
.games
.read()
.await
.get_game_by_id(&id)
.map(|game| (game.downloaded, game.installed, game.peer_count))
else {
log::warn!("Ignoring streamed install request for unknown game: {id}");
return Ok(false);
};
if downloaded || installed || peer_count == 0 {
log::warn!(
"Ignoring streamed install request for {id}: downloaded={downloaded}, \
installed={installed}, peer_count={peer_count}"
);
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id }) {
log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}");
return Ok(false);
}
Ok(true)
}
#[tauri::command]
async fn update_game(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
@@ -189,8 +369,9 @@ async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tau
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let _ = (language, username);
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id }) {
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
@@ -381,6 +562,86 @@ fn is_single_component_game_id(id: &str) -> bool {
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct LaunchSettings {
language: String,
username: String,
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn launch_settings(language: &str, username: &str) -> LaunchSettings {
LaunchSettings {
language: sanitize_language(language),
username: sanitize_username(username),
}
}
fn install_settings(language: &str, username: &str) -> InstallSettings {
InstallSettings {
account_name: sanitize_username(username),
language: install_language(language),
}
}
fn install_language(language: &str) -> String {
match sanitize_language(language).as_str() {
"de" => "german".to_string(),
_ => "english".to_string(),
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn sanitize_language(language: &str) -> String {
match language.trim().to_ascii_lowercase().as_str() {
"de" => "de".to_string(),
"en" => "en".to_string(),
_ => DEFAULT_LANGUAGE.to_string(),
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn sanitize_username(username: &str) -> String {
let cleaned = username
.trim()
.chars()
.filter(|c| !c.is_control() && *c != '"' && *c != '%')
.take(MAX_USERNAME_CHARS)
.collect::<String>();
if cleaned.is_empty() {
DEFAULT_USERNAME.to_string()
} else {
cleaned
}
}
#[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 {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#,
script_path.display(),
id,
settings.language,
settings.username,
)
}
#[tauri::command]
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
@@ -418,7 +679,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};
@@ -435,7 +701,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,
)
};
@@ -445,8 +711,16 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
#[cfg(target_os = "windows")]
async fn run_game_windows(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<()> {
if !is_single_component_game_id(&id) {
log::warn!("Ignoring run request for invalid game id: {id}");
return Ok(());
}
let settings = launch_settings(&language, &username);
let games_folder_lock = state.inner().games_folder.clone();
let games_folder = {
let guard = games_folder_lock.read().await;
@@ -461,11 +735,15 @@ async fn run_game_windows(
let game_path = games_folder.join(id.clone());
let game_setup_bin = game_path.join("game_setup.cmd");
let game_start_bin = game_path.join("game_start.cmd");
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",
@@ -476,62 +754,176 @@ async fn run_game_windows(
let result = run_as_admin(
"cmd.exe",
&format!(
r#"/c "{} local {} de playername""#,
game_setup_bin.display(),
&id
),
&script_params(&game_setup_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
);
if !result {
log::error!("failed to run game_setup.cmd");
log::error!("failed to run {GAME_SETUP_SCRIPT}");
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()
);
}
}
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
if game_start_bin.exists() {
let result = run_as_admin(
"cmd.exe",
&format!(
r#"/c "{} local {} de playername""#,
game_start_bin.display(),
&id
),
&script_params(&game_start_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
);
if !result {
log::error!("failed to run game_start.cmd");
log::error!("failed to run {GAME_START_SCRIPT}");
}
}
Ok(())
}
/// Stamp the launcher's username and language into the installed game's setting
/// files the first time it is played. Uses the same processed values the install
/// transaction used to write before this step moved to play time.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
async fn apply_launch_settings(
state_dir: &Path,
game_path: &Path,
id: &str,
language: &str,
username: &str,
) {
let settings = install_settings(language, username);
match lanspread_peer::apply_launch_settings_once(
state_dir,
game_path,
id,
Some(&settings.account_name),
Some(&settings.language),
)
.await
{
Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"),
Err(e) => log::error!("failed to apply launch settings for {id}: {e}"),
}
}
#[tauri::command]
async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
async fn run_game(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
run_game_windows(id, state).await?;
run_game_windows(id, language, username, state).await?;
}
#[cfg(not(target_os = "windows"))]
{
let _ = state;
let _ = (state, language, username);
log::error!("run_game not implemented for this platform: id={id}");
}
Ok(())
}
#[cfg(target_os = "windows")]
async fn start_server_windows(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if !is_single_component_game_id(&id) {
log::warn!("Ignoring server start request for invalid game id: {id}");
return Ok(false);
}
let settings = launch_settings(&language, &username);
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
if !games_folder.exists() {
log::error!("games_folder {} does not exist", games_folder.display());
return Ok(false);
}
let game_path = games_folder.join(id.clone());
if !local_install_is_present(&game_path) {
log::warn!(
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
game_path.display()
);
return Ok(false);
}
let server_start_bin = game_path.join(SERVER_START_SCRIPT);
if !server_start_bin.is_file() {
log::warn!(
"server start script is missing for {}: {}",
id,
server_start_bin.display()
);
return Ok(false);
}
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
log::error!("app state directory is not initialized; cannot start server");
return Ok(false);
};
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
let result = run_as_admin(
"cmd.exe",
&server_script_params(&server_start_bin, &id, &settings),
&game_path.display().to_string(),
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
);
if !result {
log::error!("failed to run {SERVER_START_SCRIPT}");
}
Ok(result)
}
#[tauri::command]
async fn start_server(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
#[cfg(target_os = "windows")]
{
start_server_windows(id, language, username, state).await
}
#[cfg(not(target_os = "windows"))]
{
let _ = (state, language, username);
log::error!("start_server not implemented for this platform: id={id}");
Ok(false)
}
}
#[cfg(target_os = "windows")]
fn local_install_is_present(game_path: &Path) -> bool {
game_path.join("local").is_dir()
@@ -581,6 +973,24 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
}
}
fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
// Peer events update availability, but catalog metadata stays anchored to game.db.
for game in game_db.games.values_mut() {
game.peer_count = 0;
}
for peer_game in peer_games {
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
existing.peer_count = peer_game.peer_count;
} else {
log::debug!(
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
id = peer_game.id
);
}
}
}
fn clear_all_local_game_states(game_db: &mut GameDB) {
for game in game_db.games.values_mut() {
clear_local_game_state(game);
@@ -592,19 +1002,31 @@ async fn emit_games_list(app_handle: &AppHandle) {
let games_db_lock = state.games.clone();
let game_db = games_db_lock.read().await;
let games_folder = state.games_folder.read().await.clone();
if game_db.games.is_empty() {
log::debug!("Game database empty; skipping emit");
return;
}
let active_transfers = state.active_outbound_transfers.read().await;
let games_to_emit = game_db
.all_games()
.into_iter()
.cloned()
.collect::<Vec<Game>>();
.map(|game| {
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
active_outbound_transfers,
game,
}
})
.collect::<Vec<LauncherGame>>();
drop(game_db);
drop(active_transfers);
let active_operations = {
let active_operations = state.active_operations.read().await;
@@ -623,6 +1045,17 @@ async fn emit_games_list(app_handle: &AppHandle) {
}
}
fn game_can_host_server(games_folder: &str, game: &Game) -> bool {
if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) {
return false;
}
PathBuf::from(games_folder)
.join(&game.id)
.join(SERVER_START_SCRIPT)
.is_file()
}
fn ui_active_operations_from_map(
active_operations: &HashMap<String, UiOperationKind>,
) -> Vec<UiActiveOperation> {
@@ -660,6 +1093,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}");
@@ -689,6 +1127,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;
@@ -712,36 +1165,7 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
{
let mut game_db = state.games.write().await;
// Reset peer counts up front. Presence/metadata stay anchored to the baked game.db.
for game in game_db.games.values_mut() {
game.peer_count = 0;
}
for peer_game in games {
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
existing.peer_count = peer_game.peer_count;
if let Some(peer_version) = &peer_game.eti_game_version {
match &existing.eti_game_version {
Some(current_version) if current_version >= peer_version => {}
_ => {
existing.eti_game_version = Some(peer_version.clone());
log::debug!(
"Updated eti_game_version for {} to {} based on peer data",
peer_game.id,
peer_version
);
}
}
}
} else {
log::debug!(
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
id = peer_game.id
);
}
}
apply_peer_remote_games(&mut game_db, games);
}
emit_games_list(&app).await;
@@ -904,8 +1328,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();
@@ -962,20 +1386,476 @@ 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);
if logs.len() > MAX_UNPACK_LOGS {
let overflow = logs.len() - MAX_UNPACK_LOGS;
logs.drain(..overflow);
}
}
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),
}
}
output.push_str(&line);
output
}
fn unpack_logs_path(state_dir: &Path) -> PathBuf {
state_dir.join(UNPACK_LOGS_FILE_NAME)
}
fn main_log_path(state_dir: &Path) -> PathBuf {
state_dir.join(MAIN_LOG_FILE_NAME)
}
#[cfg(test)]
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> {
let mut file = match OpenOptions::new().read(true).write(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
trim_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> {
let metadata = file.metadata()?;
if metadata.len() <= max_bytes {
file.seek(SeekFrom::End(0))?;
return Ok(());
}
let tail = if max_bytes == 0 {
String::new()
} else {
file.seek(SeekFrom::Start(metadata.len() - max_bytes))?;
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
file.read_to_end(&mut bytes)?;
valid_utf8_tail(bytes)
};
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(tail.as_bytes())?;
file.seek(SeekFrom::End(0))?;
Ok(())
}
fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<String> {
let mut file = fs::File::open(path)?;
read_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn read_main_log_file_to_limit_with_file(
file: &mut fs::File,
max_bytes: u64,
) -> io::Result<String> {
let metadata = file.metadata()?;
if metadata.len() == 0 || max_bytes == 0 {
file.seek(SeekFrom::End(0))?;
return Ok(String::new());
}
let start = metadata.len().saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start))?;
let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX);
let mut bytes = Vec::with_capacity(capacity);
file.read_to_end(&mut bytes)?;
file.seek(SeekFrom::End(0))?;
if start == 0 {
Ok(String::from_utf8_lossy(&bytes).into_owned())
} else {
Ok(valid_utf8_tail(bytes))
}
}
fn valid_utf8_tail(bytes: Vec<u8>) -> String {
for offset in 0..bytes.len().min(4) {
if let Ok(tail) = std::str::from_utf8(&bytes[offset..]) {
return tail.to_string();
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
#[derive(Clone)]
struct MainLogSink {
app_handle: AppHandle,
path: PathBuf,
file_state: Arc<Mutex<MainLogFileState>>,
}
#[derive(Default)]
struct MainLogFileState {
file: Option<fs::File>,
last_sequence: u64,
}
impl MainLogSink {
fn new(app_handle: AppHandle, path: PathBuf) -> Self {
Self {
app_handle,
path,
file_state: Arc::new(Mutex::new(MainLogFileState::default())),
}
}
fn write_line(&self, line: String, level: Level) {
write_main_log_stdout(&line);
let sequence = self.append_file_line(&line);
let _ = self.app_handle.emit(
"main-log-line",
MainLogLinePayload {
line,
level: level.as_str().to_string(),
sequence,
},
);
}
fn read_history(&self) -> io::Result<MainLogHistoryPayload> {
let mut file_state = self
.file_state
.lock()
.map_err(|_| io::Error::other("main log file lock poisoned"))?;
if file_state.file.is_none() && !self.path.exists() {
return Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: file_state.last_sequence,
});
}
let contents = {
let file = self.cached_file(&mut file_state.file)?;
trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?;
read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?
};
Ok(MainLogHistoryPayload {
contents,
last_sequence: file_state.last_sequence,
})
}
fn append_file_line(&self, line: &str) -> Option<u64> {
let Ok(mut file_state) = self.file_state.lock() else {
return None;
};
let write_result = self.cached_file(&mut file_state.file).and_then(|file| {
file.seek(SeekFrom::End(0))
.and_then(|_| writeln!(file, "{line}"))
});
if write_result.is_err() {
file_state.file = None;
return None;
}
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
let sequence = file_state.last_sequence;
let should_trim = file_state.file.as_ref().is_some_and(|file| {
file.metadata().is_ok_and(|metadata| {
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
})
});
if should_trim && let Some(file) = file_state.file.as_mut() {
let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES);
}
Some(sequence)
}
fn cached_file<'a>(&self, file: &'a mut Option<fs::File>) -> io::Result<&'a mut fs::File> {
if file.is_none() {
*file = Some(open_main_log_file(&self.path)?);
}
file.as_mut()
.ok_or_else(|| io::Error::other("main log file was not opened"))
}
}
fn open_main_log_file(path: &Path) -> io::Result<fs::File> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)
}
struct MainLogLayer {
sink: MainLogSink,
}
impl MainLogLayer {
fn new(sink: MainLogSink) -> Self {
Self { sink }
}
}
impl<S> Layer<S> for MainLogLayer
where
S: Subscriber + for<'span> LookupSpan<'span>,
{
fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool {
should_capture_main_log_metadata(metadata)
}
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let metadata = event.metadata();
if !should_capture_main_log_metadata(metadata) {
return;
}
let mut visitor = MainLogFieldVisitor::default();
event.record(&mut visitor);
let target = visitor
.log_target
.clone()
.unwrap_or_else(|| metadata.target().to_string());
let message = visitor.into_message();
let (date, time) = current_main_log_timestamp();
let line =
format_main_log_line_parts(&date, &time, &target, metadata.level().as_str(), &message);
self.sink.write_line(line, *metadata.level());
}
}
#[derive(Default)]
struct MainLogFieldVisitor {
message: Option<String>,
log_target: Option<String>,
fields: Vec<String>,
}
impl MainLogFieldVisitor {
fn record_value(&mut self, field_name: &str, value: String) {
match field_name {
"message" => self.message = Some(value),
"log.target" => self.log_target = Some(value),
"log.module_path" | "log.file" | "log.line" => {}
_ => self.fields.push(format!("{field_name}={value}")),
}
}
fn into_message(self) -> String {
let mut parts = Vec::new();
if let Some(message) = self.message
&& !message.is_empty()
{
parts.push(message);
}
parts.extend(self.fields);
if parts.is_empty() {
String::from("(no message)")
} else {
normalize_main_log_message(&parts.join(" "))
}
}
}
impl Visit for MainLogFieldVisitor {
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.record_value(field.name(), value.to_string());
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.record_value(field.name(), value.to_string());
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.record_value(field.name(), value.to_string());
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.record_value(field.name(), value.to_string());
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.record_value(field.name(), format!("{value:?}"));
}
}
fn should_capture_main_log_metadata(metadata: &Metadata<'_>) -> bool {
if metadata.target().starts_with("mdns_sd::service_daemon") {
return false;
}
matches!(*metadata.level(), Level::ERROR | Level::WARN | Level::INFO)
}
fn current_main_log_timestamp() -> (String, String) {
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
let date = now.date();
let clock = now.time();
(
format!(
"{:04}-{:02}-{:02}",
date.year(),
u8::from(date.month()),
date.day()
),
format!(
"{:02}:{:02}:{:02}",
clock.hour(),
clock.minute(),
clock.second()
),
)
}
fn format_main_log_line_parts(
date: &str,
time: &str,
target: &str,
level: &str,
message: &str,
) -> String {
format!(
"[{date}][{time}][{}][{level}] {}",
normalize_main_log_target(target),
normalize_main_log_message(message)
)
}
fn normalize_main_log_target(target: &str) -> String {
target.replace(['\r', '\n'], " ")
}
fn normalize_main_log_message(message: &str) -> String {
message.replace('\r', "\\r").replace('\n', "\\n")
}
fn write_main_log_stdout(line: &str) {
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
let _ = writeln!(stdout, "{line}");
}
fn init_main_logging(sink: MainLogSink) -> Result<(), Box<dyn std::error::Error>> {
let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink));
tracing::subscriber::set_global_default(subscriber)?;
tracing_log::LogTracer::builder()
.with_max_level(log::LevelFilter::Info)
.init()?;
Ok(())
}
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());
}
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1015,7 +1895,7 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
if needs_load {
let game_db = load_bundled_game_db(app_handle).await;
let catalog = game_db.games.keys().cloned().collect::<HashSet<_>>();
let catalog = GameCatalog::from_game_db(&game_db);
*state.games.write().await = game_db;
*state.catalog.write().await = catalog;
}
@@ -1032,16 +1912,26 @@ 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(
let stream_install_provider = stream_install_provider_for_app(app_handle);
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),
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
stream_install_provider: Some(stream_install_provider),
},
) {
Ok(handle) => {
let sender = handle.sender();
@@ -1058,6 +1948,22 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
}
}
fn stream_install_provider_for_app(app_handle: &AppHandle) -> Arc<dyn StreamInstallProvider> {
match resolve_unrar_sidecar_program(app_handle) {
Ok(program) => Arc::new(ExternalUnrarStreamProvider::new(program)),
Err(err) => {
log::error!("Failed to resolve streamed-install unrar sidecar: {err}");
Arc::new(NoopStreamInstallProvider)
}
}
}
fn resolve_unrar_sidecar_program(app_handle: &AppHandle) -> eyre::Result<PathBuf> {
let sidecar = app_handle.shell().sidecar("unrar")?;
let command: std::process::Command = sidecar.into();
Ok(PathBuf::from(command.get_program()))
}
fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) {
if let Err(e) = app_handle.emit(event, Some(id.to_owned())) {
log::error!("{label}: Failed to emit {event} event: {e}");
@@ -1078,6 +1984,44 @@ fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedRece
});
}
async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let should_spawn = {
let mut emit_state = state.outbound_transfer_emit.write().await;
emit_state.record_change()
};
if !should_spawn {
return;
}
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await;
let observed_generation = {
let state = app_handle.state::<LanSpreadState>();
state
.outbound_transfer_emit
.read()
.await
.observed_generation()
};
emit_games_list(&app_handle).await;
let needs_follow_up_emit = {
let state = app_handle.state::<LanSpreadState>();
let mut emit_state = state.outbound_transfer_emit.write().await;
emit_state.finish_emit(observed_generation)
};
if !needs_follow_up_emit {
break;
}
}
});
}
#[allow(clippy::too_many_lines)]
async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
match event {
@@ -1104,6 +2048,10 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
emit_games_list(app_handle).await;
}
PeerEvent::OutboundTransferCountChanged => {
log::info!("PeerEvent::OutboundTransferCountChanged received");
schedule_outbound_transfer_emit(app_handle).await;
}
PeerEvent::GotGameFiles {
id,
file_descriptions,
@@ -1304,7 +2252,7 @@ async fn handle_got_game_files(
file_descriptions,
})
{
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
log::error!("Failed to continue queued game transfer: {e}");
}
}
@@ -1322,6 +2270,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(),
@@ -1342,6 +2304,139 @@ mod tests {
}
}
fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame {
lanspread_compat::eti::EtiGame {
game_id: game_id.to_string(),
game_title: "Catalog Game".to_string(),
game_key: "catalog-game".to_string(),
game_release: "2000".to_string(),
game_publisher: "publisher".to_string(),
game_size: 1.0,
game_readme_de: "description".to_string(),
game_readme_en: "description".to_string(),
game_readme_fr: "description".to_string(),
game_maxplayers: 4,
game_master_req: 0,
genre_de: "genre".to_string(),
game_version: game_version.to_string(),
}
}
#[test]
fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() {
let game = Game::from(eti_game_fixture("alpha", "20200721"));
assert_eq!(game.version, "20200721");
assert_eq!(game.eti_game_version.as_deref(), Some("20200721"));
assert_eq!(game.local_version, None);
}
#[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 main_log_line_format_is_stable_and_single_line() {
let line = format_main_log_line_parts(
"2026-06-07",
"12:34:56",
"lanspread\napp",
"WARN",
"first line\nsecond line",
);
assert_eq!(
line,
"[2026-06-07][12:34:56][lanspread app][WARN] first line\\nsecond line"
);
}
#[test]
fn main_log_trim_keeps_utf8_tail_at_char_boundary() {
let root = std::env::temp_dir().join(format!(
"lanspread-main-log-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 path = main_log_path(&root);
std::fs::write(&path, format!("{}{}", "a".repeat(11), "é".repeat(20)))
.expect("main log should be written");
trim_main_log_file_to_limit(&path, 21).expect("main log should trim");
let trimmed = std::fs::read_to_string(&path).expect("trimmed log should remain utf-8");
assert!(trimmed.as_bytes().len() <= 21);
assert!(trimmed.starts_with('é'));
assert!(trimmed.ends_with('é'));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn active_operation_reconciliation_replaces_stale_ui_history() {
let mut active_operations = HashMap::from([
@@ -1430,6 +2525,32 @@ mod tests {
);
}
#[test]
fn outbound_transfer_emit_state_coalesces_bursts_without_losing_updates() {
let mut state = OutboundTransferEmitState::default();
assert!(
state.record_change(),
"first change should schedule an emit"
);
assert_eq!(state.observed_generation(), 1);
assert!(
!state.record_change(),
"second change should reuse the scheduled emit"
);
assert_eq!(state.observed_generation(), 2);
assert!(
state.finish_emit(1),
"a generation observed before the latest change needs a follow-up emit"
);
assert!(
!state.finish_emit(2),
"the latest observed generation clears the scheduled emit"
);
assert!(state.record_change(), "a later burst should schedule again");
}
#[test]
fn game_file_viewer_ids_must_be_single_path_components() {
assert!(is_single_component_game_id("game"));
@@ -1440,6 +2561,102 @@ mod tests {
assert!(!is_single_component_game_id("/game"));
}
#[test]
fn launch_settings_sanitize_script_arguments() {
assert_eq!(
launch_settings("DE", " Alice \"Ace\"%PATH%\n "),
LaunchSettings {
language: "de".to_string(),
username: "Alice AcePATH".to_string(),
}
);
assert_eq!(
launch_settings("fr", ""),
LaunchSettings {
language: DEFAULT_LANGUAGE.to_string(),
username: DEFAULT_USERNAME.to_string(),
}
);
}
#[test]
fn install_settings_use_language_file_values() {
assert_eq!(
install_settings("de", " Alice \"Ace\"%PATH%\n "),
InstallSettings {
account_name: "Alice AcePATH".to_string(),
language: "german".to_string(),
}
);
assert_eq!(
install_settings("fr", ""),
InstallSettings {
account_name: DEFAULT_USERNAME.to_string(),
language: "english".to_string(),
}
);
}
#[test]
fn script_params_use_common_argument_shape() {
let start_params = script_params(
Path::new("C:/Games/My Game")
.join(GAME_START_SCRIPT)
.as_path(),
"my-game",
&LaunchSettings {
language: "en".to_string(),
username: "Alice".to_string(),
},
);
assert_eq!(
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]
fn server_host_capability_requires_installed_game_with_script() {
let root = std::env::temp_dir().join(format!(
"lanspread-server-test-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos()
));
let game_root = root.join("game");
std::fs::create_dir_all(&game_root).expect("game root should be created");
std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written");
let mut game = game_fixture("game", "Game");
assert!(!game_can_host_server(
root.to_string_lossy().as_ref(),
&game
));
game.installed = true;
assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
let mut alpha = game_fixture("alpha", "Catalog Alpha");
@@ -1481,31 +2698,61 @@ mod tests {
assert!(game_db.get_game_by_id("unknown").is_none());
}
#[test]
fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() {
let mut alpha = game_fixture("alpha", "Catalog Alpha");
alpha.size = 999;
alpha.eti_game_version = Some("20200721".to_string());
let mut beta = game_fixture("beta", "Catalog Beta");
beta.peer_count = 2;
beta.eti_game_version = Some("20200101".to_string());
let mut game_db = GameDB::from(vec![alpha, beta]);
let mut peer_alpha = game_fixture("alpha", "Peer Alpha");
peer_alpha.size = 42;
peer_alpha.peer_count = 3;
peer_alpha.eti_game_version = Some("20990101".to_string());
let mut unknown = game_fixture("unknown", "Unknown");
unknown.peer_count = 1;
unknown.eti_game_version = Some("20990101".to_string());
apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]);
let alpha = game_db.get_game_by_id("alpha").expect("alpha remains");
assert_eq!(alpha.name, "Catalog Alpha");
assert_eq!(alpha.size, 999);
assert_eq!(alpha.peer_count, 3);
assert_eq!(alpha.eti_game_version.as_deref(), Some("20200721"));
let beta = game_db.get_game_by_id("beta").expect("beta remains");
assert_eq!(beta.peer_count, 0);
assert_eq!(beta.eti_game_version.as_deref(), Some("20200101"));
assert!(game_db.get_game_by_id("unknown").is_none());
}
}
#[allow(clippy::missing_panics_doc)]
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let tauri_logger_builder = tauri_plugin_log::Builder::new()
.clear_targets()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
.level(log::LevelFilter::Info)
.level_for("mdns_sd::service_daemon", log::LevelFilter::Off);
// channel to receive events from the peer
let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::<PeerEvent>();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_logger_builder.build())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
request_games,
install_game,
stream_install_game,
run_game,
start_server,
game_directory_exists,
update_game_directory,
update_game,
uninstall_game,
@@ -1514,11 +2761,27 @@ pub fn run() {
open_game_files,
get_peer_count,
get_game_thumbnail,
get_unpack_logs
get_unpack_logs,
get_main_logs
])
.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 main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir));
let state = app.state::<LanSpreadState>();
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
log::warn!("main log sink was already initialized");
}
init_main_logging(main_log_sink)?;
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(())
})
+7 -2
View File
@@ -1,11 +1,16 @@
import { MainWindow } from './windows/MainWindow';
import { MainLogsWindow, isMainLogsView } from './MainLogsWindow';
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
/**
* Tauri can spawn this bundle in either the main launcher window or the
* unpack-logs companion window. The URL query string disambiguates the two so
* companion log windows. The URL query string disambiguates the views so
* a single Vite build serves both.
*/
const App = () => (isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />);
const App = () => {
if (isMainLogsView()) return <MainLogsWindow />;
if (isUnpackLogsView()) return <UnpackLogsWindow />;
return <MainWindow />;
};
export default App;
@@ -0,0 +1,134 @@
.main-log-window {
height: 100vh;
box-sizing: border-box;
padding: 18px;
background: #000313;
color: #D5DBFE;
display: flex;
flex-direction: column;
gap: 12px;
}
.main-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.main-log-header h1 {
margin: 0;
font-size: 22px;
}
.main-log-controls,
.main-log-filter-row {
display: flex;
align-items: center;
gap: 12px;
}
.main-log-controls {
flex: 1;
justify-content: flex-end;
min-width: 0;
}
.main-log-filter-row {
flex-wrap: wrap;
flex-shrink: 0;
}
.main-log-level-filter {
--accent: #4866b9;
}
.main-log-toggle {
display: flex;
align-items: center;
gap: 6px;
color: #aeb7df;
font-size: 13px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.main-log-regex {
flex: 1 1 340px;
min-width: 220px;
}
.main-log-copy-status {
color: #8ee6a6;
font-size: 12px;
min-width: 64px;
}
.main-log-load-error {
flex-shrink: 0;
color: #ff8a8a;
font-size: 12px;
font-family: Consolas, "Courier New", monospace;
}
.main-log-stats {
color: #8892b0;
font-size: 12px;
flex-shrink: 0;
}
.main-log-viewport {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px;
border: 1px solid #2a3252;
border-radius: 6px;
background: #050813;
font-family: Consolas, "Courier New", monospace;
font-size: 12.5px;
line-height: 1.42;
}
.main-log-line {
white-space: pre-wrap;
word-break: break-word;
color: #D5DBFE;
}
.main-log-line.level-trace,
.main-log-line.level-debug {
color: #9aa6c8;
}
.main-log-line.level-warn {
color: #ffd37a;
}
.main-log-line.level-error {
color: #ff8a8a;
}
.main-log-empty {
color: #8892b0;
padding: 8px 4px;
}
@media (max-width: 720px) {
.main-log-window {
padding: 14px;
}
.main-log-header {
align-items: flex-start;
flex-direction: column;
}
.main-log-controls {
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
}
}
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { SegmentedRadio } from './components/SegmentedRadio';
import {
capLogRows,
consumeLoadedHistoryRow,
dedupeBufferedRows,
formatCount,
LEVEL_FILTER_MIN,
LEVEL_FILTER_OPTIONS,
LEVEL_ORDER,
lineCountsFromRows,
type LevelFilter,
type MainLogHistoryPayload,
type MainLogLinePayload,
type MainLogRow,
rowFromPayload,
rowsFromHistory,
} from './lib/mainLogs';
import './MainLogsWindow.css';
export const isMainLogsView = (): boolean =>
new URLSearchParams(window.location.search).get('view') === 'main-logs';
export const MainLogsWindow = () => {
const [logs, setLogs] = useState<MainLogRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [regexInput, setRegexInput] = useState('');
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
const [autoScroll, setAutoScroll] = useState(true);
const [paused, setPaused] = useState(false);
const [pausedBufferCount, setPausedBufferCount] = useState(0);
const [copyStatus, setCopyStatus] = useState<string | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const historyLoadedRef = useRef(false);
const initialBufferRef = useRef<MainLogRow[]>([]);
const pausedBufferRef = useRef<MainLogRow[]>([]);
const pausedRef = useRef(false);
const lastHistorySequenceRef = useRef(0);
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
setLogs(current => capLogRows([...current, ...rows]));
}, []);
const bufferPausedRows = useCallback((rows: MainLogRow[]) => {
pausedBufferRef.current = capLogRows([...pausedBufferRef.current, ...rows]);
setPausedBufferCount(pausedBufferRef.current.length);
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
const handleIncomingRow = (row: MainLogRow) => {
if (!historyLoadedRef.current) {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return;
}
if (
consumeLoadedHistoryRow(
historyLineCountsRef.current,
row,
lastHistorySequenceRef.current,
)
) {
return;
}
if (pausedRef.current) {
bufferPausedRows([row]);
return;
}
appendVisibleRows([row]);
};
const setup = async () => {
try {
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
handleIncomingRow(rowFromPayload(event.payload));
});
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
if (cancelled) return;
lastHistorySequenceRef.current = history.lastSequence;
const historyRows = rowsFromHistory(history.contents);
const historyLineCounts = lineCountsFromRows(historyRows);
const liveRows = dedupeBufferedRows(
historyLineCounts,
initialBufferRef.current,
lastHistorySequenceRef.current,
);
initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true;
if (pausedRef.current) {
setLogs(capLogRows(historyRows));
bufferPausedRows(liveRows);
} else {
setLogs(capLogRows([...historyRows, ...liveRows]));
}
setLoadError(null);
} catch (err) {
if (!cancelled) {
historyLoadedRef.current = true;
setLoadError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void setup();
return () => {
cancelled = true;
historyLoadedRef.current = false;
initialBufferRef.current = [];
lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map();
unlisten?.();
};
}, [appendVisibleRows, bufferPausedRows]);
const { regex, regexError } = useMemo(() => {
if (!regexInput) {
return { regex: null as RegExp | null, regexError: null as string | null };
}
try {
return { regex: new RegExp(regexInput, 'i'), regexError: null };
} catch (e) {
return { regex: null, regexError: e instanceof Error ? e.message : String(e) };
}
}, [regexInput]);
const filteredRows = useMemo(() => {
const minLevel = LEVEL_FILTER_MIN[levelFilter];
return logs.filter(row => {
if (LEVEL_ORDER[row.level] < minLevel) return false;
return regex ? regex.test(row.line) : true;
});
}, [levelFilter, logs, regex]);
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
useEffect(() => {
if (!autoScroll) return;
const viewport = viewportRef.current;
if (!viewport) return;
requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight;
});
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
const flushPausedRows = useCallback(() => {
const buffered = pausedBufferRef.current;
if (buffered.length === 0) return;
pausedBufferRef.current = [];
setPausedBufferCount(0);
appendVisibleRows(buffered);
}, [appendVisibleRows]);
const togglePaused = useCallback(() => {
if (paused) {
pausedRef.current = false;
setPaused(false);
flushPausedRows();
return;
}
pausedRef.current = true;
setPaused(true);
}, [flushPausedRows, paused]);
const clearLogs = useCallback(() => {
setLogs([]);
initialBufferRef.current = [];
pausedBufferRef.current = [];
setPausedBufferCount(0);
}, []);
const copyFilteredLogs = useCallback(async () => {
try {
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
setCopyStatus('Copied');
} catch {
setCopyStatus('Copy failed');
}
window.setTimeout(() => setCopyStatus(null), 1600);
}, [filteredRows]);
return (
<main className="main-log-window">
<div className="main-log-header">
<h1>Application Logs</h1>
<div className="main-log-controls">
<label className="main-log-toggle">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
Auto-scroll
</label>
<button className="settings-button" onClick={togglePaused}>
{paused ? 'Resume' : 'Pause'}
</button>
<button className="settings-button" onClick={clearLogs} disabled={logs.length === 0}>
Clear
</button>
<button
className="settings-button"
onClick={() => void copyFilteredLogs()}
disabled={filteredRows.length === 0}
>
Copy
</button>
{copyStatus && <span className="main-log-copy-status">{copyStatus}</span>}
</div>
</div>
<div className="main-log-filter-row">
<div className="main-log-level-filter">
<SegmentedRadio
value={levelFilter}
options={LEVEL_FILTER_OPTIONS}
onChange={setLevelFilter}
/>
</div>
<input
className={`unpack-log-regex main-log-regex ${regexError ? 'invalid' : ''}`}
type="text"
placeholder="Filter lines by regex (case-insensitive)..."
value={regexInput}
onChange={(e) => setRegexInput(e.target.value)}
title={regexError ?? ''}
spellCheck={false}
/>
</div>
{regexError && (
<div className="unpack-log-regex-error">regex error: {regexError}</div>
)}
{loadError && (
<div className="main-log-load-error">load error: {loadError}</div>
)}
<div className="main-log-stats">
{loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`}
{paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`}
</div>
<section ref={viewportRef} className="main-log-viewport" aria-live={paused ? 'off' : 'polite'}>
{filteredRows.length === 0 ? (
<div className="main-log-empty">
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
</div>
) : filteredRows.map(row => (
<div
key={row.id}
className={`main-log-line level-${row.level.toLowerCase()}`}
>
{row.line}
</div>
))}
</section>
</main>
);
};
@@ -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]);
@@ -32,6 +32,7 @@ const progressStats = (game: Game) => {
total,
speed,
eta: etaSeconds,
activePeerCount: progress?.active_peer_count ?? 0,
};
};
@@ -52,6 +53,8 @@ export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }:
};
if (size === 'lg') {
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
return (
<div
className={className}
@@ -75,6 +78,18 @@ export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }:
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
{stats.activePeerCount > 0 && (
<>
<span className="dl-sep dl-sep-peers">·</span>
<span
className="dl-peers"
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
>
<Icon.users />
<span>{stats.activePeerCount}</span>
</span>
</>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
</div>
@@ -21,6 +21,15 @@ export const Icon = {
<path d="M4 2.5v11l10-5.5z" />
</svg>
),
server: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<rect x="2" y="3" width="12" height="4.5" rx="1" />
<rect x="2" y="8.5" width="12" height="4.5" rx="1" />
<circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none" />
<circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none" />
<path d="M7 5.25h4.5M7 10.75h4.5" />
</svg>
),
install: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="M8 2v8" />
@@ -1,13 +1,5 @@
import { Game } from '../lib/types';
import { deriveState } from '../lib/gameState';
const LABELS: Record<string, string> = {
installed: 'Installed',
local: 'Local',
downloading: 'Downloading',
busy: 'Working',
none: '',
};
import { deriveState, stateChipLabel } from '../lib/gameState';
interface Props {
game: Game;
@@ -17,7 +9,7 @@ interface Props {
export const StateChip = ({ game, showNone = false }: Props) => {
const state = deriveState(game);
const label = LABELS[state] ?? '';
const label = stateChipLabel(game);
if (!label && !showNone) return null;
return (
<div className="state-chip" data-state={state}>
@@ -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>
@@ -3,6 +3,7 @@ import { JSX, KeyboardEvent } from 'react';
import { Game } from '../../lib/types';
import { CoverAspect } from '../../hooks/useSettings';
import { formatBytes } from '../../lib/format';
import { hasNewerLocalVersion } from '../../lib/gameState';
import { GameCover } from './GameCover';
import { StateChip } from '../StateChip';
@@ -42,6 +43,14 @@ export const GameCard = ({
onOpen(game);
}
};
const newerThanExpected = hasNewerLocalVersion(game);
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
const statusMessage = hasOutbound
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
const statusLevel = hasOutbound
? 'info'
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
return (
<button
@@ -66,8 +75,8 @@ export const GameCard = ({
<div className="card-meta">
{metaSeparator(formatBytes(game.size), game.genre || null)}
</div>
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message ?? ''}
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
{statusMessage}
</div>
<ActionButton
game={game}
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Game, InstallStatus } from '../../lib/types';
import { deriveState, isInProgress } from '../../lib/gameState';
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props {
@@ -13,9 +13,11 @@ interface Props {
thumbnailUrl: string | null;
onClose: () => void;
onPrimary: (game: Game) => void;
onStreamInstall: (game: Game) => void;
onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void;
onCancelDownload: (game: Game) => void;
onStartServer: (game: Game) => void;
onViewFiles: (game: Game) => void;
}
@@ -27,33 +29,41 @@ const tagsFromGame = (game: Game): string[] => {
return tags;
};
const statusLabelFor = (game: Game): string => {
switch (deriveState(game)) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const GameDetailModal = ({
game,
thumbnailUrl,
onClose,
onPrimary,
onStreamInstall,
onUninstall,
onRemoveDownload,
onCancelDownload,
onStartServer,
onViewFiles,
}: Props) => {
const tags = tagsFromGame(game);
// Some game metadata contains a literal <br>; keep sanitization exact.
const description = game.description.split('<br>').join('');
const canRemoveDownload = game.downloaded
&& !game.installed
&& !isInProgress(game.install_status);
const showStreamInstall = canStreamInstall(game);
const canViewFiles = game.downloaded
|| game.installed
|| game.install_status === InstallStatus.Downloading;
|| game.install_status === InstallStatus.Downloading
|| game.install_status === InstallStatus.Installing;
const newerThanExpected = hasNewerLocalVersion(game);
const newerStatus = newerThanExpected
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
: undefined;
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
const outboundStatus = hasOutbound
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
: undefined;
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
const statusLevel = hasOutbound
? 'info'
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
return (
<Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
@@ -90,22 +100,22 @@ export const GameDetailModal = ({
<div className="meta-cell">
<div className="meta-label">Version</div>
<div className="meta-value meta-mono">
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
</div>
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{statusLabelFor(game)}</div>
<div className="meta-value">{gameStatusLabel(game)}</div>
</div>
</div>
{game.description && (
<p className="modal-desc">{game.description}</p>
{description && (
<p className="modal-desc">{description}</p>
)}
{game.status_message && (
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message}
{statusMessage && (
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
{statusMessage}
</p>
)}
@@ -116,6 +126,27 @@ export const GameDetailModal = ({
onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload}
/>
{showStreamInstall && (
<button
type="button"
className="ghost-btn"
title="Install without keeping archive files"
onClick={() => onStreamInstall(game)}
>
<Icon.install />
<span>Low disk install</span>
</button>
)}
{game.installed && game.can_host_server === true && (
<button
type="button"
className="act-btn act-lg act-server"
onClick={() => onStartServer(game)}
>
<Icon.server />
<span className="act-label">Start Server</span>
</button>
)}
{game.installed && (
<button
type="button"
@@ -8,15 +8,21 @@ import {
ASPECT_OPTIONS,
BG_OPTIONS,
DENSITY_OPTIONS,
UISettings,
LANGUAGE_OPTIONS,
type UISettings,
} from '../../hooks/useSettings';
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;
@@ -33,7 +39,57 @@ const Row = ({ label, hint, children }: RowProps) => (
</div>
);
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
interface TextInputProps {
value: string;
placeholder: string;
maxLength: number;
onChange: (value: string) => void;
}
const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInputProps) => (
<div className="settings-text">
<input
type="text"
value={value}
placeholder={placeholder}
maxLength={maxLength}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
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>
@@ -48,6 +104,25 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
</div>
<div className="settings-body">
<section className="settings-section">
<div className="settings-section-title">Profile</div>
<Row label="Username" hint="Shown to other players on the LAN">
<SettingsTextInput
value={settings.username}
placeholder="Enter a username"
maxLength={24}
onChange={(v) => onChange('username', v)}
/>
</Row>
<Row label="Language" hint="Interface language">
<SegmentedRadio
value={settings.language}
options={LANGUAGE_OPTIONS}
onChange={(v) => onChange('language', v)}
/>
</Row>
</section>
<section className="settings-section">
<div className="settings-section-title">Appearance</div>
<Row label="Accent color" hint="Used for primary actions and highlights">
@@ -68,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}
@@ -86,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,19 +52,19 @@ export const SearchField = ({ value, onChange }: Props) => {
}}
spellCheck={false}
/>
{value && (
<button
className="search-clear"
type="button"
aria-label="Clear search"
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
>
<Icon.clearCircle />
</button>
)}
<button
className={clearClassName}
type="button"
aria-label="Clear search"
aria-hidden={value ? undefined : true}
disabled={!value}
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
>
<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">
<Brand peerCount={peerCount} />
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
<SearchField value={query} onChange={setQuery} />
<SortMenu value={sort} onChange={setSort} />
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
<KebabMenu items={kebabItems} />
<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} />
</div>
<div className="topbar-right-tail">
<KebabMenu items={kebabItems} />
</div>
</div>
</header>
);
@@ -1,11 +1,15 @@
import { useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { ask } from '@tauri-apps/plugin-dialog';
import { UseGamesResult } from './useGames';
import { type UseGamesResult } from './useGames';
import { type UISettings } from './useSettings';
export interface GameActions {
play: (id: string) => Promise<void>;
startServer: (id: string) => Promise<void>;
install: (id: string) => Promise<void>;
streamInstall: (id: string) => Promise<void>;
update: (id: string) => Promise<void>;
uninstall: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
@@ -19,18 +23,41 @@ export interface GameActions {
* are marked as "checking peers" until the backend emits an authoritative
* operation snapshot; cancellation waits for the backend to clear that snapshot.
*/
export const useGameActions = (games: UseGamesResult): GameActions => {
export const useGameActions = (
games: UseGamesResult,
settings: Pick<UISettings, 'language' | 'username'>,
): GameActions => {
const play = useCallback(async (id: string) => {
try {
await invoke('run_game', { id });
await invoke('run_game', {
id,
language: settings.language,
username: settings.username,
});
} catch (err) {
console.error('run_game failed:', err);
}
}, []);
}, [settings.language, settings.username]);
const startServer = useCallback(async (id: string) => {
try {
await invoke('start_server', {
id,
language: settings.language,
username: settings.username,
});
} catch (err) {
console.error('start_server failed:', err);
}
}, [settings.language, settings.username]);
const install = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('install_game', { id });
const success = await invoke<boolean>('install_game', {
id,
language: settings.language,
username: settings.username,
});
if (!success) return;
const game = games.games.find(item => item.id === id);
@@ -40,16 +67,37 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
} catch (err) {
console.error('install_game failed:', err);
}
}, [games, settings.language, settings.username]);
const streamInstall = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('stream_install_game', { id });
if (success) games.markChecking(id);
} catch (err) {
console.error('stream_install_game failed:', err);
}
}, [games]);
const update = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('update_game', { id });
const game = games.games.find(item => item.id === id);
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
const confirmed = await ask(
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
{ title: 'Active Transfers in Progress', kind: 'warning' }
);
if (!confirmed) return;
}
const success = await invoke<boolean>('update_game', {
id,
language: settings.language,
username: settings.username,
});
if (success) games.markChecking(id);
} catch (err) {
console.error('update_game failed:', err);
}
}, [games]);
}, [games, settings.language, settings.username]);
const uninstall = useCallback(async (id: string) => {
try {
@@ -61,11 +109,19 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
const removeDownload = useCallback(async (id: string) => {
try {
const game = games.games.find(item => item.id === id);
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
const confirmed = await ask(
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
{ title: 'Active Transfers in Progress', kind: 'warning' }
);
if (!confirmed) return;
}
await invoke('remove_downloaded_game', { id });
} catch (err) {
console.error('remove_downloaded_game failed:', err);
}
}, []);
}, [games]);
const cancelDownload = useCallback(async (id: string) => {
try {
@@ -83,5 +139,15 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
}
}, []);
return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
return {
play,
startServer,
install,
streamInstall,
update,
uninstall,
removeDownload,
cancelDownload,
viewFiles,
};
};
@@ -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);
}
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();
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to push game directory to backend:', err),
);
return () => {
cancelled = true;
};
}, [gameDir]);
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
const rescan = useCallback(() => {
if (!gameDir) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to rescan game directory:', err),
);
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 };
};
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { load } from '@tauri-apps/plugin-store';
import { GameFilter, GameSort } from '../lib/types';
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
export type Density = 'compact' | 'normal' | 'large';
@@ -15,10 +15,14 @@ export interface UISettings {
aspect: CoverAspect;
sort: GameSort;
filter: GameFilter;
username: string;
language: LauncherLanguage;
}
type StoredGameSort = GameSort | 'size';
type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>;
const DEFAULT_USERNAME = 'Commander';
const MAX_USERNAME_LENGTH = 24;
export const ACCENT_OPTIONS = [
{ value: '#3b82f6', label: 'Blue' },
@@ -47,6 +51,18 @@ export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }
{ value: 'banner', label: 'Banner' },
];
export const LANGUAGE_OPTIONS: ReadonlyArray<{ value: LauncherLanguage; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
];
const defaultLanguage = (): LauncherLanguage => {
if (typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('de')) {
return 'de';
}
return 'en';
};
export const DEFAULT_SETTINGS: UISettings = {
accent: '#3b82f6',
bg: 'gradient',
@@ -54,6 +70,8 @@ export const DEFAULT_SETTINGS: UISettings = {
aspect: 'square',
sort: 'status',
filter: 'local',
username: DEFAULT_USERNAME,
language: defaultLanguage(),
};
const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
@@ -63,12 +81,23 @@ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
sort: sanitizeSort(raw?.sort),
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
username: sanitizeUsername(raw?.username),
language: sanitizeLanguage(raw?.language),
});
const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => (
sort === 'size' ? 'sizeDesc' : sort ?? DEFAULT_SETTINGS.sort
);
const sanitizeLanguage = (language: LauncherLanguage | undefined): LauncherLanguage => (
language === 'de' || language === 'en' ? language : DEFAULT_SETTINGS.language
);
const sanitizeUsername = (username: string | undefined): string => {
const trimmed = username?.trim() ?? '';
return trimmed ? Array.from(trimmed).slice(0, MAX_USERNAME_LENGTH).join('') : DEFAULT_USERNAME;
};
export interface UseSettings {
settings: UISettings;
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
@@ -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}`;
@@ -82,25 +82,73 @@ export const deriveState = (game: Game): DerivedState => {
return 'none';
};
export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Local';
case 'downloading': return 'Downloading';
case 'busy': return 'Working';
case 'none': return '';
}
};
export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) {
return 'Installed, not shareable';
}
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const isUnavailable = (game: Game): boolean =>
!game.installed
&& !game.downloaded
&& game.peer_count === 0
&& game.install_status === InstallStatus.NotInstalled;
const parseVersionStamp = (version: string | undefined): number | null => {
if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed;
};
export const compareVersionStamps = (
left: string | undefined,
right: string | undefined,
): number | null => {
const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight;
};
export const hasNewerLocalVersion = (game: Game): boolean =>
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
export const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false;
const peer = game.eti_game_version;
const local = game.local_version;
if (!local && peer) return true;
if (local && peer) {
const l = parseInt(local, 10);
const p = parseInt(peer, 10);
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
}
return false;
if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
};
export const canStreamInstall = (game: Game): boolean =>
!game.downloaded
&& !game.installed
&& game.peer_count > 0
&& !isInProgress(game.install_status);
/** What pressing the card's main action button should do, given the state. */
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
@@ -0,0 +1,148 @@
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
export type LogLevel = typeof LOG_LEVELS[number];
export type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
export interface MainLogLinePayload {
line: string;
level: string;
sequence?: number | null;
}
export interface MainLogHistoryPayload {
contents: string;
lastSequence: number;
}
export interface MainLogRow {
id: string;
line: string;
level: LogLevel;
sequence?: number;
}
export const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
export const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
all: LEVEL_ORDER.TRACE,
debug: LEVEL_ORDER.DEBUG,
info: LEVEL_ORDER.INFO,
warn: LEVEL_ORDER.WARN,
error: LEVEL_ORDER.ERROR,
};
export const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'debug', label: 'Debug+' },
{ value: 'info', label: 'Info+' },
{ value: 'warn', label: 'Warn+' },
{ value: 'error', label: 'Error only' },
];
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
let nextSyntheticLogRowId = 0;
const syntheticLogRowId = (): string => {
nextSyntheticLogRowId += 1;
return `live-synthetic-${nextSyntheticLogRowId}`;
};
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
export const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
export const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
export const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => {
const sequence = typeof payload.sequence === 'number' ? payload.sequence : undefined;
return {
id: sequence === undefined ? syntheticLogRowId() : `live-${sequence}`,
line: payload.line,
level: normalizeLogLevel(payload.level),
sequence,
};
};
export const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map((line, index) => ({
id: `history-${index}`,
line,
level: parseLogLevelFromLine(line),
}));
export const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
let charCount = 0;
const capped: MainLogRow[] = [];
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
const rowChars = row.line.length + 1;
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
break;
}
capped.push(row);
charCount += rowChars;
}
return capped.reverse();
};
export const rowWasLoadedInHistory = (row: MainLogRow, lastHistorySequence: number): boolean =>
typeof row.sequence === 'number' && row.sequence <= lastHistorySequence;
export const lineCountsFromRows = (rows: MainLogRow[]): Map<string, number> => {
const lineCounts = new Map<string, number>();
rows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return lineCounts;
};
export const consumeLoadedHistoryRow = (
historyLineCounts: Map<string, number>,
row: MainLogRow,
lastHistorySequence: number,
): boolean => {
if (!rowWasLoadedInHistory(row, lastHistorySequence)) return false;
const count = historyLineCounts.get(row.line) ?? 0;
if (count <= 0) return false;
if (count === 1) {
historyLineCounts.delete(row.line);
} else {
historyLineCounts.set(row.line, count - 1);
}
return true;
};
export const dedupeBufferedRows = (
historyLineCounts: Map<string, number>,
bufferedRows: MainLogRow[],
lastHistorySequence: number,
): MainLogRow[] =>
bufferedRows.filter(row => !consumeLoadedHistoryRow(historyLineCounts, row, lastHistorySequence));
export const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
@@ -21,12 +21,13 @@ export enum ActiveOperationKind {
RemovingDownload = 'RemovingDownload',
}
export type StatusLevel = 'info' | 'error';
export type StatusLevel = 'info' | 'warning' | 'error';
export interface DownloadProgress {
downloaded_bytes: number;
total_bytes: number;
bytes_per_second: number;
active_peer_count: number;
}
export interface DownloadProgressPayload extends DownloadProgress {
@@ -57,6 +58,8 @@ export interface Game {
status_level?: StatusLevel;
download_progress?: DownloadProgress;
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
}
export interface ActiveOperation {
@@ -77,3 +80,6 @@ export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
/** Visual state of a card. Derived from backend operation status and local flags. */
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
/** Two-character language code passed through to game scripts. */
export type LauncherLanguage = 'en' | 'de';
@@ -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;
flex-wrap: nowrap;
min-height: 64px;
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;
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;
@@ -710,6 +739,12 @@
.card-status.is-error {
color: #f87171;
}
.card-status.is-warning {
color: #fbbf24;
}
.card-status.is-info {
color: #60a5fa;
}
.density-compact .card-body {
padding: 9px 10px 10px;
@@ -807,6 +842,20 @@
background: rgba(255, 255, 255, 0.12);
border-color: var(--bd-3);
}
.act-server {
color: var(--t-1);
background: color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.04));
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.act-server:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.04));
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
filter: none;
}
.act-server svg {
color: var(--accent);
}
.act-busy {
color: var(--t-1);
background: rgba(255, 255, 255, 0.06);
@@ -1045,6 +1094,18 @@
font-weight: 600;
white-space: nowrap;
}
.dl-lg-secondary .dl-peers {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--t-1);
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dl-lg-secondary .dl-peers svg {
opacity: 0.7;
}
.dl-lg-secondary .dl-eta {
min-width: 0;
overflow: hidden;
@@ -1054,12 +1115,18 @@
.dl-sep {
opacity: 0.45;
}
@container (max-width: 380px) {
@container (max-width: 320px) {
.dl-lg-secondary .dl-eta,
.dl-lg-secondary .dl-sep-eta {
display: none;
}
}
@container (max-width: 240px) {
.dl-lg-secondary .dl-peers,
.dl-lg-secondary .dl-sep-peers {
display: none;
}
}
.dl-lg-pct {
grid-area: pct;
color: var(--t-1);
@@ -1322,6 +1389,16 @@
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.08);
}
.modal-status.is-warning {
color: #fbbf24;
border-color: rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.08);
}
.modal-status.is-info {
color: #60a5fa;
border-color: rgba(96, 165, 250, 0.4);
background: rgba(96, 165, 250, 0.08);
}
.modal-actions {
display: flex;
align-items: center;
@@ -1436,11 +1513,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;
@@ -1457,6 +1541,123 @@
filter: brightness(1.1);
}
/* Settings: text input */
.settings-text {
display: inline-flex;
align-items: center;
width: 220px;
height: 36px;
padding: 0 12px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
transition:
background 0.15s,
border-color 0.15s,
box-shadow 0.15s;
}
.settings-text:focus-within {
background: var(--bg-2);
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.settings-text input {
min-width: 0;
flex: 1;
background: transparent;
border: 0;
outline: 0;
color: var(--t-1);
font: inherit;
font-size: 13.5px;
font-weight: 600;
letter-spacing: 0;
}
.settings-text input::placeholder {
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 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;
@@ -1570,6 +1771,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;
}
@@ -43,24 +43,50 @@ const openLogsWindow = async () => {
}
};
const openMainLogsWindow = async () => {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
try {
const existing = await WebviewWindow.getByLabel('main-logs');
if (existing) {
await existing.setFocus();
return;
}
const win = new WebviewWindow('main-logs', {
url: '/?view=main-logs',
title: 'Application Logs',
width: 980,
height: 720,
resizable: true,
});
await win.once<unknown>('tauri://error', (event) => {
console.error('Error opening application logs window:', event.payload);
});
} catch (err) {
console.error('Error opening application logs window:', err);
}
};
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);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
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>(
@@ -104,6 +130,7 @@ export const MainWindow = () => {
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
{ kind: 'separator' },
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
], [rescan]);
@@ -116,25 +143,23 @@ export const MainWindow = () => {
return (
<div className={className} style={rootStyle}>
{gameDir ? (
<>
<TopBar
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting('filter', v)}
counts={counts}
query={query}
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
gameDir={gameDir}
onPickDirectory={() => void pickDirectory()}
kebabItems={kebabItems}
/>
<main className="grid-wrap">
<TopBar
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting('filter', v)}
counts={counts}
query={query}
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
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 +180,11 @@ export const MainWindow = () => {
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</main>
</>
) : (
<main className="grid-wrap">
</>
) : (
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
</main>
)}
)}
</main>
{openGame && (
<GameDetailModal
@@ -169,9 +192,11 @@ export const MainWindow = () => {
thumbnailUrl={thumbnails.get(openGame.id)}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
onStartServer={(g) => actions.startServer(g.id)}
onViewFiles={(g) => actions.viewFiles(g.id)}
/>
)}
@@ -187,6 +212,9 @@ export const MainWindow = () => {
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => void pickDirectory()}
onChange={setSetting}
onClose={() => setSettingsOpen(false)}
/>
@@ -2,6 +2,7 @@ import {
actionLabel,
activeStatusById,
applyFilterAndSort,
canStreamInstall,
countByFilter,
deriveState,
downloadProgressPercent,
@@ -10,7 +11,9 @@ import {
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
stateChipLabel,
} from '../src/lib/gameState.ts';
import {
ActiveOperationKind,
@@ -99,6 +102,7 @@ Deno.test('download progress is preserved only while actively downloading', () =
downloaded_bytes: 50,
total_bytes: 100,
bytes_per_second: 12_500_000,
active_peer_count: 2,
},
});
@@ -114,6 +118,11 @@ Deno.test('download progress is preserved only while actively downloading', () =
50,
'active download snapshot should keep progress',
);
assertEquals(
stillDownloading.download_progress?.active_peer_count,
2,
'active download snapshot should keep live peer count',
);
assertEquals(
settled.download_progress,
undefined,
@@ -128,6 +137,7 @@ Deno.test('downloading action label includes current speed', () => {
downloaded_bytes: 50,
total_bytes: 100,
bytes_per_second: 12_500_000,
active_peer_count: 2,
},
});
@@ -184,6 +194,7 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
downloaded_bytes: 12 * 1024 * 1024 * 1024,
total_bytes: 35 * 1024 * 1024 * 1024,
bytes_per_second: 49_400_000,
active_peer_count: 3,
},
});
@@ -201,3 +212,75 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
);
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
});
Deno.test('stream install is available only for idle remote games', () => {
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
true,
'remote-only idle games should allow streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
false,
'downloaded games should install from local archives',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
false,
'installed games should not expose streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
false,
'games without peers should not expose streamed install',
);
assertEquals(
canStreamInstall(game({
downloaded: false,
installed: false,
peer_count: 1,
install_status: InstallStatus.CheckingPeers,
})),
false,
'busy games should not expose streamed install',
);
});
Deno.test('streamed local installs are labeled installed but not shareable', () => {
const streamed = game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
assertEquals(
deriveState(streamed),
'installed',
'streamed local installs should keep installed visual state',
);
assertEquals(
stateChipLabel(streamed),
'Not shareable',
'card chip should make the non-shareable state visible',
);
assertEquals(
gameStatusLabel(streamed),
'Installed, not shareable',
'detail status should spell out installed plus non-shareable',
);
assertEquals(
stateChipLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed chip label',
);
assertEquals(
gameStatusLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed detail label',
);
});
@@ -0,0 +1,55 @@
import {
dedupeBufferedRows,
lineCountsFromRows,
rowFromPayload,
rowsFromHistory,
rowWasLoadedInHistory,
} from '../src/lib/mainLogs.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
Deno.test('history rows parse levels and stable ids', () => {
const rows = rowsFromHistory('[2026-06-07][12:00:00][app][WARN] careful\nplain line\n');
assertEquals(rows.length, 2, 'history should skip trailing empty line');
assertEquals(rows[0].id, 'history-0', 'history id should include row position');
assertEquals(rows[0].level, 'WARN', 'explicit level should be parsed');
assertEquals(rows[1].level, 'INFO', 'unknown level should default to info');
});
Deno.test('buffered main log rows covered by history sequence are removed', () => {
const historyRows = rowsFromHistory('[2026-06-07][12:00:01][app][INFO] included\n');
const included = rowFromPayload({
line: '[2026-06-07][12:00:01][app][INFO] included',
level: 'INFO',
sequence: 4,
});
const fresh = rowFromPayload({
line: '[2026-06-07][12:00:02][app][INFO] fresh',
level: 'INFO',
sequence: 5,
});
const deduped = dedupeBufferedRows(lineCountsFromRows(historyRows), [included, fresh], 4);
assertEquals(rowWasLoadedInHistory(included, 4), true, 'included row should match history');
assertEquals(deduped.length, 1, 'only fresh row should remain');
assertEquals(deduped[0].line, fresh.line, 'fresh row should not be dropped');
});
Deno.test('buffered rows missing from trimmed history are retained', () => {
const retained = rowFromPayload({
line: '[2026-06-07][12:00:00][app][INFO] trimmed out',
level: 'INFO',
sequence: 2,
});
const deduped = dedupeBufferedRows(new Map(), [retained], 4);
assertEquals(deduped.length, 1, 'trimmed row should remain visible');
assertEquals(deduped[0].line, retained.line, 'trimmed row should be preserved');
});
@@ -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`
//
+5 -5
View File
@@ -3,14 +3,14 @@ name = "lanspread-utils"
version = "0.1.0"
edition = "2024"
[lints.rust]
unsafe_code = "forbid"
[lib]
doctest = false
test = false
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lib]
test = false
doctest = false
[lints.rust]
unsafe_code = "forbid"
+165 -33
View File
@@ -32,6 +32,29 @@ 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.
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
---
## Screens / views
### 1. Main library (variant A — primary)
@@ -40,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:
- `All Games` · count chip
- `Local` · count chip
- `Installed` · count chip
- **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`.
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
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)`).
@@ -88,12 +119,26 @@ Opens when the user **clicks anywhere on a game card except the action button**.
2. **Body** — 22px top, 26px bottom, 28px horizontal:
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded).
- **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`.
- **Actions row** — flex row, 10px gap, 4px top padding:
- Primary action button (44px tall, see "Action button" below — Play / Install / Download depending on state)
- If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
- If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
- Spacer (`flex: 1`).
- Ghost-button **View files** (neutral) — opens system file manager at the game folder.
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
2. **Start Server***only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
6. Spacer (`flex: 1`).
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
#### Start Server button
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
---
@@ -108,10 +153,20 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
├─────────────────────────────────────────┤
│ │
APPEARANCE │ ← section title: 10.5px / 700 / 0.12em / uppercase / --t-3
PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3
│ │
Accent color │ ← row label: 14px / 600 / --t-1
Used for primary actions and highlights │ ← row hint: 12px / --t-3
Username │ ← row label: 14px / 600 / --t-1
Shown to other players on the LAN │ ← row hint: 12px / --t-3
│ [ Enter a username ] │ ← text input (220×36)
│ │
│ Language │
│ Interface language │
│ [English│Deutsch] │ ← segmented radio (new)
│ │
│ APPEARANCE │
│ │
│ Accent color │
│ Used for primary actions and highlights │
│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned
│ │
│ Background │
@@ -120,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]│
@@ -138,6 +198,15 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
- Right (`settings-row-control`): the control
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
- **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`.
@@ -147,10 +216,46 @@ Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
Persisted settings (write through to local storage / Tauri config):
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
- `accent`: one of the six hex values above. Default `#3b82f6`.
- `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.
---
@@ -251,20 +356,22 @@ grid-template-areas:
```
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, three groups separated by `·` (0.45 opacity):
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
1. `<strong>11.4 GB</strong> / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest)
2. `47.6 MB/s` (`var(--t-1)`)
3. `8 min left` (`var(--t-2)`)
3. `[users-icon] 5``.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
4. `8 min left` (`var(--t-2)`)
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
**Graceful degradation in narrow modals:**
```css
@container (max-width: 380px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
```
The ETA drops first if the modal narrows; bytes + speed stay (they're the actionable numbers). The pct/cancel column never collapses.
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
### Number formatting
@@ -287,10 +394,11 @@ type Game = {
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01, only when state === 'downloading'
speed?: number; // current throughput in MB/s
peers?: number; // number of LAN peers currently seeding
};
```
In the real app, `progress` and `speed` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. Replace with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
Filter changes:
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
@@ -329,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).
@@ -360,12 +468,16 @@ type Game = {
state: 'installed' | 'local' | 'downloading' | 'none';
progress?: number; // 01 — present only when state === 'downloading'
speed?: number; // MB/s — present only when state === 'downloading'
peers?: number; // LAN peers currently seeding
players: string; // e.g. "232"
tags: string[];
cover: { c1: string; c2: string; accent: string; mood?: string };
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
};
```
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
**UI state:**
```ts
type LauncherUI = {
@@ -377,7 +489,20 @@ type LauncherUI = {
};
```
**Persisted settings:** see Settings dialog section. Persist via Tauri's plugin-store or a local JSON file in app data dir.
**Persisted settings** (mirror of Settings dialog state):
```ts
type LauncherSettings = {
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
};
```
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
@@ -445,7 +570,7 @@ Cover art in the design files is **stylized placeholder art** — generated enti
In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`.
The icon set (search, play, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight.
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button.
Fonts to load:
```html
@@ -465,15 +590,20 @@ 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
```
To preview the design in a browser:
1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder).
2. You'll see a design canvas with all variants (A, B, C, D, E) side-by-side. Click an artboard's expand button to view it full-screen.
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change accent / density / aspect / background. In the production app these live in the Settings dialog (variant E).
2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen.
- **A / B** — chrome variants (A is the chosen direction)
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
- **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 / game folder). In the production app these all live in the Settings dialog.
---
@@ -484,3 +614,5 @@ To preview the design in a browser:
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
+17 -3
View File
@@ -35,14 +35,17 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#3b82f6",
"density": "normal",
"aspect": "square",
"bg": "gradient"
"bg": "gradient",
"username": "ddidderr",
"language": "en",
"gameFolder": "\/some\/folder\/to\/games"
}/*EDITMODE-END*/;
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const heroGame = GAMES.find(g => g.id === 'ra3'); // installed → modal shows Play + Uninstall
const heroGame = GAMES.find(g => g.id === 'cs'); // installed + canHostServer → shows Play + Start Server + Uninstall
return (
<React.Fragment>
@@ -62,7 +65,7 @@ function App() {
<DCSection id="detail" title="Game detail overlay"
subtitle="Opens when you click a card. Full description, metadata, primary action + secondary actions (incl. uninstall).">
<DCArtboard id="detail-modal" label="C · Detail overlay (installed game)" width={1340} height={840}>
<DCArtboard id="detail-modal" label="C · Detail overlay (installed, can host server)" width={1340} height={840}>
<Launcher variant="single" tweaks={t} setTweak={setTweak}
initialFilter="installed" initialSort="az"
initialOpenGame={heroGame}/>
@@ -90,6 +93,13 @@ function App() {
</DesignCanvas>
<TweaksPanel>
<TweakSection label="Profile"/>
<TweakText label="Username" value={t.username}
onChange={(v) => setTweak('username', v)}/>
<TweakRadio label="Language" value={t.language}
options={[{value: 'en', label: 'English'}, {value: 'de', label: 'Deutsch'}]}
onChange={(v) => setTweak('language', v)}/>
<TweakSection label="Theme"/>
<TweakColor label="Accent" value={t.accent} options={ACCENTS}
onChange={(v) => setTweak('accent', v)}/>
@@ -104,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>
);
+85 -4
View File
@@ -9,6 +9,7 @@ const { useState, useMemo, useRef, useEffect } = React;
const Icon = {
search: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7" cy="7" r="5"/><path d="m13.5 13.5-3-3"/></svg>,
play: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M4 2.5v11l10-5.5z"/></svg>,
server: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="12" height="4.5" rx="1"/><rect x="2" y="8.5" width="12" height="4.5" rx="1"/><circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none"/><circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none"/><path d="M7 5.25h4.5M7 10.75h4.5"/></svg>,
install:(p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 12.5h11"/></svg>,
download:(p)=> <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 13.5h11"/></svg>,
folder: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z"/></svg>,
@@ -159,6 +160,15 @@ function DownloadProgress({ game, accent, size = 'md', full = false }) {
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{fmtSpeed(speed)}</span>
{game.peers > 0 && (
<React.Fragment>
<span className="dl-sep dl-sep-peers">·</span>
<span className="dl-peers" title={`Downloading from ${game.peers} ${game.peers === 1 ? 'peer' : 'peers'} on the LAN`}>
<Icon.users/>
<span>{game.peers}</span>
</span>
</React.Fragment>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{fmtEta(etaSec)} left</span>
</div>
@@ -344,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>
);
}
@@ -426,6 +441,13 @@ function GameDetailModal({ game, accent, onClose }) {
<p className="modal-desc">{game.desc}</p>
<div className="modal-actions">
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
{game.canHostServer && game.state === 'installed' && (
<button className="act-btn act-lg act-server"
style={{ '--accent': accent }}
onClick={(e) => e.stopPropagation()}>
<Icon.server/><span>Start Server</span>
</button>
)}
{game.state === 'installed' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
)}
@@ -459,8 +481,22 @@ const SETTING_OPTIONS = {
bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }],
density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }],
aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }],
language: [{ value: 'en', label: 'English' }, { value: 'de', label: 'Deutsch' }],
};
function SettingsTextInput({ value, placeholder, maxLength = 24, onChange, accent }) {
return (
<div className="settings-text" style={{ '--accent': accent }}>
<input type="text"
value={value || ''}
placeholder={placeholder}
maxLength={maxLength}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}/>
</div>
);
}
function SettingsRow({ label, hint, children }) {
return (
<div className="settings-row">
@@ -488,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">
@@ -515,6 +576,21 @@ function SettingsDialog({ settings, onChange, onClose }) {
<button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
</div>
<div className="settings-body">
<div className="settings-section">
<div className="settings-section-title">Profile</div>
<SettingsRow label="Username" hint="Shown to other players on the LAN">
<SettingsTextInput value={settings.username}
placeholder="Enter a username"
onChange={(v) => onChange('username', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Language" hint="Interface language">
<SegmentedRadio value={settings.language || 'en'}
options={SETTING_OPTIONS.language}
onChange={(v) => onChange('language', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Appearance</div>
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
@@ -531,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}
+16 -16
View File
@@ -18,7 +18,7 @@ const GAMES = [
{
id: 'avp', title: 'Aliens vs. Predator', size: 35.0, version: '2019.10.01',
desc: "Three campaigns, three nightmares. Be the alien stalking the dark, the predator hunting both, or the marine just trying to make it home with a working flashlight.",
state: 'downloading', progress: 0.32, speed: 49.4, players: '216', tags: ['FPS', 'Horror', 'Multiplayer'],
state: 'downloading', progress: 0.32, speed: 49.4, peers: 5, players: '216', tags: ['FPS', 'Horror', 'Multiplayer'],
cover: { c1: '#064e3b', c2: '#020617', accent: '#34d399', mood: 'dark' },
},
{
@@ -28,13 +28,13 @@ const GAMES = [
cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' },
},
{
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30',
id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30', canHostServer: true,
desc: "The original Battlefield. WWII on land, sea, and air across 16 maps. The mod scene basically reinvented PC gaming on top of this engine.",
state: 'installed', players: '264', tags: ['FPS', 'Vehicles', 'LAN'],
cover: { c1: '#92400e', c2: '#1c1917', accent: '#facc15', mood: 'war' },
},
{
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27',
id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27', canHostServer: true,
desc: "Modern combat with commander mode, squads, and the kind of jet-vs-jet duels you tell stories about for a decade.",
state: 'local', players: '264', tags: ['FPS', 'Vehicles', 'Tactical'],
cover: { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee', mood: 'tactical' },
@@ -46,19 +46,19 @@ const GAMES = [
cover: { c1: '#f97316', c2: '#7c2d12', accent: '#fde047', mood: 'arcade' },
},
{
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22',
id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22', canHostServer: true,
desc: "WWII shooter — Russian, British and American campaigns, plus the multiplayer that defined LAN parties for years.",
state: 'installed', players: '232', tags: ['FPS', 'War'],
cover: { c1: '#57534e', c2: '#1c1917', accent: '#fbbf24', mood: 'war' },
},
{
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21',
id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21', canHostServer: true,
desc: "The shooter that flipped the genre to modern combat and minted a generation of esports careers. All Ghillied Up still holds up.",
state: 'local', players: '232', tags: ['FPS', 'Modern'],
cover: { c1: '#525252', c2: '#0a0a0a', accent: '#84cc16', mood: 'tactical' },
},
{
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08',
id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08', canHostServer: true,
desc: "Expansion to the original CoD. Battle of the Bulge, Sicily, Kursk. Adds tanks, B-17 sequences, and the flamethrower nobody asked for but everybody loved.",
state: 'none', players: '232', tags: ['FPS', 'Expansion'],
cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' },
@@ -76,37 +76,37 @@ const GAMES = [
cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' },
},
{
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21',
id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21', canHostServer: true,
desc: "The 1.6 build still everyone insists was the peak. Terrorists vs Counter-Terrorists, AWP camping, de_dust2.",
state: 'installed', players: '232', tags: ['FPS', 'Competitive', 'LAN'],
cover: { c1: '#1e40af', c2: '#0c1f3a', accent: '#fbbf24', mood: 'tactical' },
},
{
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23',
id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23', canHostServer: true,
desc: "CS reborn on the Source engine. Same maps, same rules, with physics that lets the molotovs work properly.",
state: 'installed', players: '232', tags: ['FPS', 'Competitive'],
cover: { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b', mood: 'tactical' },
},
{
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20',
id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20', canHostServer: true,
desc: "Open-source arena FPS with in-game level editing. Fast, free, and one of those things every LAN party always had a copy of.",
state: 'none', players: '216', tags: ['FPS', 'Open Source'],
cover: { c1: '#dc2626', c2: '#7f1d1d', accent: '#f1f5f9', mood: 'arcade' },
},
{
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31',
id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31', canHostServer: true,
desc: "Sci-fi horror reboot of the franchise. Mars Research Facility, demonic incursion, the shotgun that started a flashlight debate.",
state: 'local', players: '216', tags: ['FPS', 'Horror'],
cover: { c1: '#7f1d1d', c2: '#000000', accent: '#f97316', mood: 'dark' },
},
{
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15',
id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15', canHostServer: true,
desc: "Co-op zombie survival with the AI Director rewriting every campaign run. Bring four friends or four strangers; the chainsaw works the same.",
state: 'installed', players: '18', tags: ['Co-op', 'FPS', 'Horror'],
cover: { c1: '#15803d', c2: '#052e16', accent: '#fef08a', mood: 'horror' },
},
{
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01',
id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01', canHostServer: true,
desc: "Infinite voxel sandbox. Build, mine, survive, get blown up by a creeper. The LAN button is right there.",
state: 'installed', players: '2100', tags: ['Sandbox', 'Survival', 'LAN'],
cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' },
@@ -118,9 +118,9 @@ const GAMES = [
cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' },
},
{
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15',
id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15', canHostServer: true,
desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.",
state: 'downloading', progress: 0.71, speed: 12.8, players: '216', tags: ['FPS', 'Arena', 'LAN'],
state: 'downloading', progress: 0.71, speed: 12.8, peers: 3, players: '216', tags: ['FPS', 'Arena', 'LAN'],
cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' },
},
{
@@ -130,13 +130,13 @@ const GAMES = [
cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' },
},
{
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18',
id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18', canHostServer: true,
desc: "Class-based shooter with nine archetypes, absurd hats, and a meta with more history than most actual sports.",
state: 'local', players: '232', tags: ['FPS', 'Class-based'],
cover: { c1: '#b45309', c2: '#7c2d12', accent: '#fbbf24', mood: 'cartoon' },
},
{
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01',
id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01', canHostServer: true,
desc: "Arena shooter at maximum velocity. Onslaught, Assault, Bombing Run — vehicles, jump boots, the announcer screaming HEADSHOT.",
state: 'none', players: '232', tags: ['FPS', 'Arena'],
cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' },
+19 -11
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="brand">
<div className="brand-mark" style={{ background: accent }}>S</div>
<div className="brand-name">SoftLAN</div>
<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}/>
</div>
<div className="topbar-right-trail">
<KebabMenu items={menuItems}/>
</div>
</div>
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
<SortMenu value={sort} onChange={setSort} accent={accent}/>
<DirectoryButton path={DIR_PATH}/>
<KebabMenu items={menuItems}/>
</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}/>
+209 -22
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;
flex-wrap: nowrap;
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 ─── */
@@ -630,6 +687,22 @@
}
.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); }
/* Start Server — secondary "primary" action sitting next to Play.
Uses the accent as a tinted fill + border so it reads as host-action
without competing with the green Play button. */
.act-server {
color: var(--t-1);
background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04));
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
}
.act-server:hover {
background: color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.04));
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
filter: none;
}
.act-server svg { color: var(--accent); }
/* ─── Download progress (in place of action button when state === 'downloading') ─── */
.dl {
position: relative;
@@ -784,14 +857,25 @@
}
.dl-lg-secondary .dl-bytes .dl-of { color: var(--t-2); font-weight: 500; }
.dl-lg-secondary .dl-speed { color: var(--t-1); font-weight: 600; white-space: nowrap; }
.dl-lg-secondary .dl-peers {
display: inline-flex; align-items: center; gap: 4px;
color: var(--t-1); font-weight: 600; font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dl-lg-secondary .dl-peers svg { opacity: 0.7; }
.dl-lg-secondary .dl-eta { white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.dl-sep { opacity: 0.45; }
/* Gracefully drop the ETA when the modal is narrow */
@container (max-width: 380px) {
@container (max-width: 320px) {
.dl-lg-secondary .dl-eta,
.dl-lg-secondary .dl-sep-eta { display: none; }
}
/* Even narrower: drop peers too, keep size + speed */
@container (max-width: 240px) {
.dl-lg-secondary .dl-peers,
.dl-lg-secondary .dl-sep-peers { display: none; }
}
.dl-lg-pct {
grid-area: pct;
@@ -1124,3 +1208,106 @@
color: white;
box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18);
}
/* ─── Settings: text input ─── */
.settings-text {
display: inline-flex;
align-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
padding: 0 12px;
height: 36px;
width: 220px;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.settings-text:focus-within {
background: var(--bg-2);
border-color: var(--accent, #3b82f6);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent, #3b82f6) 22%, transparent);
}
.settings-text input {
flex: 1; min-width: 0;
background: transparent;
border: 0; outline: 0;
color: var(--t-1);
font: inherit;
font-size: 13.5px;
font-weight: 600;
letter-spacing: 0.1px;
}
.settings-text input::placeholder {
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);
}
+7
View File
@@ -2,6 +2,12 @@ export RUSTFLAGS := "-C target-cpu=native"
export WEBKIT_DISABLE_COMPOSITING_MODE := "1"
export DOCKER_CONFIG := env_var_or_default("DOCKER_CONFIG", ".lanspread-peer-cli/docker-config")
default: run
setup:
cargo install tauri-cli
cd crates/lanspread-tauri-deno-ts && deno install --frozen=true
run:
cargo tauri dev --release
@@ -13,6 +19,7 @@ bundle:
fmt:
cargo +nightly fmt
tombi format
just --fmt
_fix:
-1
View File
@@ -1,4 +1,3 @@
# cargo-vet audits file
[[audits.windows-link]]
-1
View File
@@ -1,4 +1,3 @@
# cargo-vet config file
[cargo-vet]