Files
lanspread/PLAN.md
T
2026-05-15 16:07:17 +02:00

465 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: Transactional Local Installs, Intent-Logged Recovery, Event-Gated Scans
## Summary
- `local/` being a directory is the install signal. The contents of `local/` are the user's responsibility, not ours.
- `version.ini` is the **download completion sentinel**: a game is downloaded iff `version.ini` exists at the game-root level. `version.ini` is the last file written by every successful download, via atomic rename.
- Each game root carries a per-game JSON **intent log** for install operations, updated atomically before and after every state-changing FS operation. Startup recovery is a `(recorded_intent × fs_state)` lookup.
- Installs/updates/uninstalls run as transactions over each game root using fixed-name staging and backup directories, marked Lanspread-owned to prevent collisions with user data.
- Downloads are transactional via a single sentinel: old `version.ini` is renamed aside at the start, new `version.ini` is written atomically at the end. No intent log entry needed for downloads — the FS state is authoritative.
- Filesystem watcher replaces 15s polling. Events are gated by per-game operation locks and a per-game rescan-pending flag — no time-based debounce. A 5-minute fallback scan reconciles missed events.
- Install transaction logic moves out of `src-tauri` into a new `lanspread-peer::install` module so the peer owns the state machine end-to-end. Unpacker is injected at peer startup so the peer crate stays Tauri-free.
- A game is transferable to peers only when its ID is in the ETI catalog, `version.ini` is present, and no operation is active. Non-catalog game roots are ignored for peer announcements — we currently have no system to determine title/thumbnail for non-catalog games, so they're future scope.
## Wire protocol
Unchanged. Existing `library_rev` / `LibraryDelta` / `manifest_hash` machinery already handles announcement on any state change; uninstall is just another library change. `version.ini` remains part of the per-game manifest.
## Layout
### Game-dir root
```
<game_dir>/
.lanspread/ -- peer-wide cache (ignored by watcher and scans)
<game_id_1>/...
<game_id_2>/...
...
```
### Per game root
```
<game_dir>/<game_id>/
<game_id>.eti -- main archive
*.eti -- additional archives (multi-archive games are valid)
version.ini -- download sentinel. Presence = downloaded.
local/ -- committed install. Is-a-directory ⇒ installed.
.version.ini.tmp -- transient: version.ini being staged for atomic rename
.version.ini.discarded -- transient: previous version.ini parked aside during re-download
.local.installing/ -- extraction staging dir (Lanspread-owned)
.local.backup/ -- previous install retained for rollback (Lanspread-owned)
.lanspread.json -- per-game install intent log (atomic writes)
```
Fixed names; no timestamps. The intent log disambiguates "fresh" vs "stale" install-staging/backup dirs; for downloads the FS state alone is authoritative.
### Ownership marker
When the peer creates `.local.installing/` or `.local.backup/`, it drops a zero-byte `.lanspread_owned` file inside as soon as the directory exists.
Two ownership rules apply at recovery time:
- **Intent says `None`**: the marker is the only signal. Only `.local.*` dirs carrying `.lanspread_owned` may be touched; markerless `.local.*` dirs are user data — log and leave alone.
- **Intent says `Installing` / `Updating` / `Uninstalling`**: the corresponding `.local.installing/` and `.local.backup/` are unconditionally Lanspread-owned regardless of marker. This covers the inevitable crash window between `rename(local → .local.backup)` and the marker drop, and between `mkdir(.local.installing)` and the marker drop. The intent log already proves ownership; the marker is belt-and-suspenders for the `None`-intent case.
Download-related transient files (`.version.ini.tmp`, `.version.ini.discarded`) use fixed reserved names — no marker is needed because the names are reserved by Lanspread and any conflict would already be a name collision with our own scheme.
## State model
```
downloaded = version.ini exists (regular file, at game-root level)
installed = local/ is a directory
```
The two predicates are independent.
- `installed && downloaded` → Ready, fully shareable.
- `installed && !downloaded` → installed locally; peer announces in deltas but `availability = LocalOnly` (refuses to serve archives).
- `!installed && downloaded` → ready to install; UI shows pending; `eti_version` is surfaced from `version.ini`.
- `!installed && !downloaded` → blank slate.
`is_committed_install` (the predicate) requires `local/` to be a **directory**, not merely "a path that exists." This handles symlinks, leftover regular files named `local`, etc.
## Intent log (install operations only)
Per-game JSON at `<game_root>/.lanspread.json`:
```json
{
"schema_version": 1,
"id": "...",
"recorded_at": <unix_ts>,
"state": "None" | "Installing" | "Updating" | "Uninstalling",
"eti_version": "20251028"?,
"manifest_hash": <u64>?
}
```
`state: "None"` means no install-side operation in flight; FS is the truth. The intent log does **not** track download state — that's read directly from `version.ini` / `.version.ini.*` FS signals.
### Atomic write
1. Write `.lanspread.json.tmp`.
2. `file.sync_all()` (fsync the data).
3. `rename(.lanspread.json.tmp, .lanspread.json)`.
4. `fsync(parent_dir)` on Unix; on Windows skip (no portable directory-fsync equivalent).
### Intent transitions
- **Begin operation**: write intent with new state *before* any FS mutation.
- **Commit operation**: write intent with `None` immediately after the commit rename, before emitting events or starting cleanup.
- **Cleanup** (e.g., deleting `.local.backup` after a successful update): best-effort, after intent flush and event emit. Failure logs but doesn't fail the operation.
## Startup recovery
### Install recovery matrix
For each game root, read intent + inspect FS, then act:
```
recorded | local/ | .local.installing | .local.backup | action
-------------|--------|-------------------|---------------|----------------------------------------
None | - | - | - | trust FS; leave markerless .local.* alone (user data); sweep only markered .local.* orphans
Installing | yes | - | - | commit landed, intent crash; write None
Installing | - | yes | - | sweep staging; write None
Installing | - | - | - | crash before any staging; write None
Updating | no | yes | yes | sweep staging, restore backup; write None (old ver)
Updating | yes | yes (stale) | yes (stale) | sweep both; write None (new ver — commit landed before intent flush)
Updating | yes | - | yes | commit landed, intent crash before cleanup; sweep backup, write None
Updating | yes | - | - | commit + cleanup landed, intent crash; write None
Uninstalling | no | - | yes | finish: delete backup; write None
Uninstalling | no | - | - | write None
Uninstalling | yes | - | - | intent crash before any FS work; redo uninstall from step 1
```
**Default rule**: for any `(intent, fs)` combination not listed, write intent matching FS observation; do not mutate FS. Conservative bias — never delete unless we have positive proof of ownership (intent is `Installing` / `Updating` / `Uninstalling` for that ID, or the `.local.*` dir carries the marker).
**Sideloading** (user drops a game dir in via file manager): no intent log, FS-only observation. The "None" row handles it. JSON corruption or accidental deletion follows the same path — treat missing/unparseable intent as `None`.
### Download recovery
Independent of the install intent log; pure FS sweep:
- `.version.ini.tmp` present → partial commit; delete.
- `.version.ini.discarded` present → interrupted download; delete (do not restore; `.eti` bytes may be partially overwritten).
- After sweeping, the `version.ini` presence/absence is authoritative.
## Transaction primitives
All in the new `lanspread-peer::install` module (install) plus changes to `download.rs` (download). The same per-peer **operation table** holds `Downloading | Installing | Updating | Uninstalling` entries.
### Download transaction
Driven by `download_game_files`.
1. Operation table: insert `Downloading` for game ID.
2. If `version.ini` exists in the game root, `rename(version.ini, .version.ini.discarded)`.
3. `prepare_game_storage` runs over file descs **filtered to exclude** `is_version_ini()` entries — non-version.ini files are pre-allocated and overwritten in place by chunk writes as today.
4. Run parallel chunk downloads as today.
5. Version.ini chunks (small file, typically a few bytes) are routed to an in-memory buffer instead of disk during chunk receipt.
6. After all non-version.ini downloads complete and the version.ini buffer is full:
- Write buffer to `.version.ini.tmp`.
- `file.sync_all()`.
- `rename(.version.ini.tmp, version.ini)`.
7. Sweep `.version.ini.discarded` (best-effort).
8. Operation table: remove `Downloading`.
9. Emit `DownloadGameFilesFinished`.
10. Peer auto-fires `InstallGame { id }` (see install transaction).
**Rollback** (any failure or cancel):
- Sweep `.version.ini.tmp` if any.
- Sweep `.version.ini.discarded`.
- Do **not** restore old version.ini. `.eti` bytes may be partially overwritten and would no longer match the old state.
- Operation table: remove `Downloading`.
- Emit `DownloadGameFilesFailed`.
A failed download is destructive to the previous downloaded state but leaves `local/` untouched. The user can re-trigger download. This is acceptable because `local/` is what the user plays from, and download is fundamentally about replacing the on-disk archive.
### Install (no prior `local/`)
1. Write intent `Installing`.
2. Create `.local.installing/`, drop `.lanspread_owned` marker.
3. Extract archives into `.local.installing/`. Non-zero unrar exit ⇒ error ⇒ rollback.
4. `rename(.local.installing, local)`. ← commit point.
5. Write intent `None`.
6. Emit success event; trigger rescan.
**Rollback** (any step before commit): `rm -rf .local.installing`; write intent `None`; emit failure.
### Update (prior `local/` present)
1. Write intent `Updating`.
2. `rename(local, .local.backup)`; drop `.lanspread_owned` marker inside.
3. Create `.local.installing/`, drop marker.
4. Extract new archive into `.local.installing/`. Non-zero unrar exit ⇒ error ⇒ rollback.
5. `rename(.local.installing, local)`. ← commit point.
6. Write intent `None`.
7. Emit success event; trigger rescan.
8. Best-effort `rm -rf .local.backup`; failure logged, not fatal.
**Rollback**: `rm -rf .local.installing` (best-effort); `rename(.local.backup, local)`; write intent `None`; emit failure.
### Uninstall
1. Write intent `Uninstalling`.
2. `rename(local, .local.backup)`; drop `.lanspread_owned` marker.
3. `rm -rf .local.backup`.
4. Write intent `None`.
5. Emit success event; trigger rescan.
**Rollback** (step 3 fails): attempt `rename(.local.backup, local)`; surface error.
### unrar correctness
Non-zero exit status from unrar is an error. The `None` intent and the user-facing "unpack-finished" event fire only after both extraction and the promotion rename succeed.
## Peer crate changes
### New module `crates/lanspread-peer/src/install/`
```
mod.rs -- public surface: install(), update(), uninstall(), recover_on_startup()
transaction.rs -- staging/backup/commit/rollback primitives over a game root
intent.rs -- atomic read/write of per-game intent JSON
unpack.rs -- unrar invocation, via the injected Unpacker trait
```
### `Unpacker` injection
Object-safe boxed-future trait — no `async_trait` dep, no `async fn` in trait:
```rust
use std::{future::Future, path::Path, pin::Pin};
pub type UnpackFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
pub trait Unpacker: Send + Sync {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a>;
}
```
`start_peer` gains a 4th argument:
```rust
pub fn start_peer(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<PeerRuntimeHandle>
```
Tauri provides the impl that wraps `app_handle.shell().sidecar("unrar")` and passes it in. The peer crate stays Tauri-free.
### Catalog injection
The peer must know which game IDs the ETI catalog covers — only those IDs are announced to peers, only those flow through the install state machine, and only those participate in download/serve paths. The catalog set is owned by Tauri (loaded from the bundled `game.db` in `load_bundled_game_db`) and shared with the peer as `Arc<RwLock<HashSet<String>>>`. The RwLock leaves room for a future "catalog refresh without peer restart" path; today the set is populated once at startup before `start_peer` is called.
Catalog enforcement points inside the peer:
- `scan_local_library` and the optimized rescan: a game root whose ID is not in the catalog is skipped — no `GameSummary` emitted, no entry in the library index, no contribution to `library_rev`. The intent log and recovery still operate on it (so we don't corrupt on-disk state for an unknown ID), but it is invisible to peers and the UI.
- `local_download_available` and the file-stream handlers (see "Operation table" below): refuse all serve requests for non-catalog IDs.
- `InstallGame` / `UpdateGame` / `UninstallGame` commands for non-catalog IDs are rejected with an error; the peer logs and emits no events.
This is also why the watcher reacting to a new game root cannot announce it without consulting the catalog — events for non-catalog IDs flow through gating but never become deltas.
### Download changes (`crates/lanspread-peer/src/download.rs`)
- `prepare_game_storage`: filter `file_descs` to exclude `desc.is_version_ini()` entries. Non-version.ini files are pre-allocated as today.
- `download_game_files`:
- Partition `game_file_descs` into version.ini and non-version.ini.
- Insert `Downloading` into the operation table; rename existing `version.ini` to `.version.ini.discarded` if present.
- Run parallel chunked downloads for non-version.ini files (existing path).
- Route version.ini chunk writes to an in-memory buffer keyed by relative path.
- After all chunks succeed, commit each version.ini buffer atomically via `.version.ini.tmp` → rename.
- Sweep `.version.ini.discarded`.
- Remove `Downloading` from the operation table.
- `download_chunk` for version.ini paths: writes to memory rather than disk.
- New `PeerCommand` variant: `InstallGame { id }` — runs the install transaction over an already-downloaded game (auto-fired by the peer when a download commits; can also be fired by Tauri for explicit re-install). Plus `UpdateGame { id }` and `UninstallGame { id }`.
### Operation table
A single per-peer table holds the in-progress operation for each game ID (`Downloading | Installing | Updating | Uninstalling`). Consulted by:
- `local_download_available` (manifest serve path, used by `handle_get_game`) — refuse `GetGame` for a game during any active operation, when `version.ini` is absent, or when the ID is not in the catalog.
- File-stream handlers (`handle_file_data_request`, `handle_file_chunk_request` in `services/stream.rs`) — refuse `GetGameFileData` and `GetGameFileChunk` requests when the ID has an active operation, when `version.ini` is absent at the game root, or when the ID is not in the catalog. A remote peer may already hold a manifest from before our operation began; these handlers must re-verify per request rather than rely on `local_download_available` having gated the original manifest fetch. Additional rule: `local/`-relative paths are rejected unconditionally — manifests never contain `local/` entries, but defense-in-depth against a malicious or stale request.
- Watcher event handler — drop events for IDs with active operations.
- Scanner — preserve last-known summary for IDs with active operations; do not emit transient deltas.
- Library announcement — do not announce in `LibraryDelta` while an operation is active.
### Aggregation and candidate selection (`peer_db.rs`)
The `Availability` flag must propagate through peer-side aggregation; today it does not, which would make `LocalOnly` peers count as downloadable.
- `get_all_games`: when aggregating `GameSummary` values across peers, `Game.peer_count` counts only peers whose advertised `availability == Ready`. `LocalOnly` peers do not contribute to `peer_count` and do not flip the aggregated `downloaded` flag.
- `peers_with_game` and `peers_with_latest_version`: filter to peers with `availability == Ready` before returning addresses. A peer advertising `LocalOnly` must never be selected as a download source — the download will fail when the remote refuses `GetGame`.
- `get_latest_version_for_game`: ignore `LocalOnly` entries when computing latest version. A version we can't actually transfer should not influence "is an update available?" decisions.
### State model implementation
- `local_games.rs` switches `downloaded` from `<id>.eti exists` to `version.ini exists` (exact `<game_root>/version.ini`, regular file).
- `local_download_available` gates on `version.ini` + no active operation + catalog-known ID.
- `build_game_summary` reads `eti_version` from `version.ini` whenever `downloaded`, not only when `installed`. The current "Game is not downloaded" error path is removed: a game with `local/` present but no `version.ini` returns a `GameSummary` with `downloaded = false`, `installed = true`, `availability = LocalOnly`.
- `update_index_for_game` no longer short-circuits on missing `.eti`. The "no game here" decision is now: skip iff `version.ini` is absent **and** `local/` is not a directory **and** no `*.eti` is present at root level. Otherwise build a summary.
- Manifest building (`scan_game_descriptions` / `should_skip_root_entry`) continues to skip `local/`; `local/` contents are user-owned and never enter manifests, fingerprints, or transfer paths.
- Fingerprint still tracks `version.ini` mtime (already there); `.eti` mtime is retained as a cache invalidation hint. `local/` is-a-directory remains part of the fingerprint.
### `is_version_ini` predicate
`GameFileDescription::is_version_ini` is tightened to an exact root-level match: the predicate returns true only when `relative_path == "<game_id>/version.ini"`. The current `ends_with("/version.ini")` would misclassify nested `foo/bar/version.ini` as the sentinel; under the new sentinel rule that would break both download partitioning and the install state machine.
If a remote manifest contains zero or more than one root-level `version.ini` entry, `download_game_files` fails fast with a clear error — the sentinel is mandatory and must be unique.
## Scanner + watcher
### Workspace dependency
Add `notify = "7"` (or the current 7.x) to the top-level `Cargo.toml` `[workspace.dependencies]` and pull it into `lanspread-peer/Cargo.toml`. The crate is not currently in the dependency graph.
### Watcher topology
- `notify`-based, non-recursive watches on `game_dir` root and on each game root.
- Never watch inside `local/`, `.local.installing/`, `.local.backup/`, or the root `.lanspread/`.
- **Lifecycle**: the watcher reconciles its watched set whenever a game root appears or disappears, and tears down + rebuilds all watches on `SetGameDir`.
### No time-based debounce
Per-game-ID gating:
- Operation-lock present → drop event.
- Rescan running for ID → set "rescan-pending" flag; the running task re-runs once on completion.
- Else → spawn rescan for that ID.
### Optimized rescan (single ID)
1. Stat `version.ini` (existence + mtime), check `local/` is-a-directory, stat any `*.eti` files at root level.
2. Compare to cached fingerprint.
3. Recursive walk only on fingerprint change.
### Fallback scan
Every 300s, list top-level game-root dirs and run the optimized rescan per ID.
### Ignore list
In manifests, scans, and event handling: `.local.*`, `.version.ini.*`, `.lanspread`, `.lanspread.json`, `.sync`, `.softlan_game_installed` (latter two already skipped).
### Watcher init failure
Non-fatal. Log a warning; the fallback scan is sufficient on its own.
## Tauri / UI changes
### `src-tauri/src/lib.rs`
- Remove direct unpack logic: `unpack_game`, `do_unrar`. Remove the unpack-spawn inside `handle_download_finished` — unpack now happens inside the peer's install transaction. `handle_download_finished` becomes a plain `game-download-finished` event relay (no spawn, no sidecar invocation, no backup cleanup).
- Remove the entire `___TO_BE_DELETE___{id}` backup-the-whole-game-dir scheme: `backup_game_folder`, `restore_game_folder`, `cleanup_backup_folder`, `cleanup_failed_download` (download rollback now lives in the peer). The whole-game-folder backup approach is incompatible with the new model (downloads no longer touch `local/`, so there's nothing to back up).
- Remove `update_game_installation_state`'s "downloaded required for installed" coupling: `installed` is now `local/` is-a-directory, independent of `downloaded`.
- Provide the `Unpacker` impl that shells out to the unrar sidecar; pass it to `start_peer`. Populate the catalog set from `load_bundled_game_db` and pass it to `start_peer` too.
- Rename `games_in_download: HashSet<String>` to a generic in-progress operation set mirroring the peer's operation table (`HashMap<String, OpKind>` or similar — the variant tracks Downloading / Installing / Updating / Uninstalling so the UI can pick the right spinner).
- `install_game(id)` is rewritten to route by local state rather than always sending `GetGame`:
- `version.ini` absent → send `PeerCommand::GetGame { id }` (fetch first; peer auto-fires `InstallGame` on download commit).
- `version.ini` present, `local/` absent → send `PeerCommand::InstallGame { id }` directly (no re-download).
- `version.ini` present, `local/` present → no-op (already installed); the UI's "Open" affordance handles launch.
- `update_game(id)` becomes a thin wrapper that sends `PeerCommand::GetGame { id }`. The fresh download triggers the peer's auto-install, which does a transactional update (rename `local → .local.backup`, extract, commit-rename). The old Tauri-side whole-folder backup is gone.
### New Tauri command
`uninstall_game(id)` → forwards to `PeerCommand::UninstallGame`.
### Frontend (`src/App.tsx`)
- New `InstallStatus.Installing` (extraction in flight) and `InstallStatus.Uninstalling`.
- New install lifecycle events (peer-driven, separate from download and unpack):
- `game-install-begin` — intent flipped to `Installing` / `Updating`, before extraction.
- `game-install-failed` — extraction or commit-rename failed, rollback complete.
- `game-uninstall-begin`, `game-uninstall-finished`, `game-uninstall-failed`.
- Existing `game-download-finished` fires when the `version.ini` rename commits (which happens inside `download_game_files`, before the auto-fired `InstallGame`).
- Existing `game-unpack-finished` fires only on true transactional install success (peer-driven). It does NOT fire on extract failure or commit-rename failure — those route to `game-install-failed`.
- **Uninstall affordance**: secondary action (icon button or row-context menu), *not* a mode of the main button. Main button stays Install / Open.
- Surface `eti_version` for games that are downloaded but not installed.
- Render the `LocalOnly` availability badge for installed-but-not-downloaded games: the user can play them locally but the row should communicate that this peer can't serve archives.
## ARCHITECTURE.md
Update `crates/lanspread-peer/ARCHITECTURE.md`:
- Replace the "short debounce (1-2 seconds)" wording with the per-ID operation-lock + rescan-pending model.
- Replace the fingerprint-cache description with the per-game intent log.
- Document the `.local.installing` / `.local.backup` / `.lanspread.json` / `.lanspread_owned` layout and the ownership-marker rule.
- Document the version.ini-as-sentinel rule and `.version.ini.*` reserved names.
## Test plan
### Unit — install transactions
- Install success, update success, update rollback on extract failure, update rollback on commit-rename failure, uninstall success, uninstall delete-failure restore.
### Unit — download transaction
- Fresh download writes `version.ini` last via atomic rename.
- Re-download renames old `version.ini` to `.version.ini.discarded`, commits new one, sweeps discarded.
- Download interrupted between phases 1 and 5 leaves no `version.ini`; subsequent scan sees game as not downloaded.
- `prepare_game_storage` skips `version.ini` entries.
- Version.ini chunk routing to memory: buffer accumulates correctly across out-of-order chunk arrivals.
- Multi-`.eti` games: all archives downloaded, none of them used as the sentinel.
### Unit — recovery
- Each row of the install matrix, including the default-rule cases.
- Download recovery: `.version.ini.tmp` and `.version.ini.discarded` swept unconditionally on startup.
- JSON missing / corrupted / wrong schema_version → fall back to FS observation.
- `.local.*` dir without `.lanspread_owned` marker → ignored, not deleted.
### Unit — atomic intent write
- Simulated crash between tmp-write and rename leaves the previous intent intact.
- Schema version mismatch handled as "treat as missing."
### Unit — scanner gating
- Events for an ID under an operation lock are dropped.
- Rescan-pending flag collapses bursts to ≤2 scans per ID.
- Sideloaded dir is picked up by the fallback scan and ingested with correct state.
- Non-catalog game root is observed by the scanner but produces no `GameSummary`, no library-index entry, and no `LibraryDelta` change.
### Unit — serve gating
- `GetGame` is refused when an operation is active for the ID.
- `GetGameFileData` and `GetGameFileChunk` are refused mid-operation even when the requesting peer holds a pre-operation manifest.
- All three handlers refuse non-catalog IDs.
- All three handlers refuse when `version.ini` is absent (covers manually-deleted-sentinel case).
- File-chunk handler rejects any `relative_path` that resolves under `local/`.
### Unit — aggregation
- `get_all_games` counts only `Ready` peers toward `peer_count`; a single `LocalOnly` peer reports `peer_count = 0` for a game no other peer carries.
- `peers_with_game` and `peers_with_latest_version` exclude `LocalOnly` peers.
- `get_latest_version_for_game` ignores `LocalOnly` entries.
### Unit — installed-only state
- A game root with `local/` present and `version.ini` absent produces a summary with `downloaded = false`, `installed = true`, `availability = LocalOnly`.
- Same root, after the user copies in `version.ini`, flips to `Ready` on the next rescan.
### Manual
- New game appears via file-manager `mv` (atomic) and `cp` (piecemeal).
- Game disappears via `rm -rf`.
- Install / update / uninstall while watcher is active.
- Failed unpack restores old install.
- 100-game fallback scan stays fast.
- Pull plug mid-download; verify on restart: game shown as not downloaded, no stale partials lingering.
- Pull plug mid-update; verify install recovery restores old version.
- Delete `version.ini` manually while game is installed; verify `LocalOnly` availability and that UI still shows installed.
### Verify
- `just fmt`
- `just clippy`
- `just build`
## Assumptions
- Uninstall removes only `local/`; the archives and `version.ini` stay so the game can be reinstalled without re-download.
- All `.local.*`, `.version.ini.*`, and `.lanspread*` paths are reserved namespaces. Existing user dirs with `.local.*` names are not touched unless they carry the `.lanspread_owned` marker. `.version.ini.*` names are reserved by convention.
- The peer process owns all install/update/uninstall and download FS mutations; users editing inside `local/` is explicitly unsupported.
- A failed download is destructive to previous download state: old `version.ini` is not restored because `.eti` bytes may already be partially overwritten. `local/` is preserved (downloads never touch it). The user retries the download.
- Watcher failure is non-fatal; the 5-minute fallback scan is sufficient on its own.
- Sideload recommendation: users move complete game directories into `game_dir` (atomic rename), rather than copying file-by-file. For `cp`, the fallback scan reconciles once `version.ini` lands.
- Non-catalog game roots are ignored because the app has no system yet to determine title and thumbnail for them. Supporting arbitrary non-ETI games is future scope.