From 627d8c4533f1a1d0adc8864836c49411529c7fe7 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 15 May 2026 16:07:17 +0200 Subject: [PATCH] plan.md --- PLAN.md | 464 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..1475521 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,464 @@ +# 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 + +``` +/ + .lanspread/ -- peer-wide cache (ignored by watcher and scans) + /... + /... + ... +``` + +### Per game root + +``` +// + .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 `/.lanspread.json`: + +```json +{ + "schema_version": 1, + "id": "...", + "recorded_at": , + "state": "None" | "Installing" | "Updating" | "Uninstalling", + "eti_version": "20251028"?, + "manifest_hash": ? +} +``` + +`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> + 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, + tx_notify_ui: UnboundedSender, + peer_game_db: Arc>, + unpacker: Arc, + catalog: Arc>>, +) -> eyre::Result +``` + +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>>`. 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 `.eti exists` to `version.ini exists` (exact `/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 == "/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` to a generic in-progress operation set mirroring the peer's operation table (`HashMap` 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.