Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/architecture/backend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ the rest are single modules. A service over ~700 LOC is the decomposition signal
| `connection.py` | ConnectionService — connection test + RomM minimum-version gate + Client API Token lifecycle (mint/establish via credentials, validate/store a user-pasted token, or exchange a short-lived pairing code for a token — the latter two for OIDC accounts; host-bound to the minting origin; see [ConnectionService notes](#connectionservice-notes)) |
| `protocols/` | Protocol interfaces grouped by concern (see [Protocol Interfaces](#protocol-interfaces)) |

#### Version-list liveness

`VersionSwitchService.get_version_list` separates retained local membership from current RomM availability. On each lazy
Game Page load, the bound detail request proves the bound id live and supplies the direct sibling ids; only non-bound
local members absent from that direct sibling view are exact-id probed through `RommRomReader.get_rom_once`, the
adapter's single-attempt short-timeout `request_once` path, concurrently on the existing executor fan-out. Only
`RommNotFoundError` produces `vanished: true`. All other probe failures and malformed/falsy responses fail open. No
result is persisted.

The response carries `vanished` on every version and `bound_vanished` even when `multi_version` is false. Retained
vanished rows remain in the payload and keep the independently-computed `switchable` membership verdict, but are
excluded from default ranking. A bound-id 404 is a successful entity verdict (`bound_vanished: true`,
`server_query_failed: false`), not a global reachability signal. Artwork and save reads do not contribute availability
evidence; `fetch_cover_base64` remains a nullable data callable.

#### LibraryService decomposition (`services/library/`)

The library sync subsystem is a façade over three sub-services that coordinate through a shared `LibrarySyncStateBox`:
Expand Down
26 changes: 26 additions & 0 deletions docs/architecture/steam-non-steam-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,32 @@ Two guards keep a re-import from wiping a freshly-bound shortcut (`#1036`):
the run just re-bound onto the new `rom_id` is never emitted for removal. The `get_by_app_id` reverse lookup orders
`rom_id DESC LIMIT 1` so it resolves the live (newest) binding for any pre-migration edge state.

### Retained-row availability in the version picker

ADR-0007 keeps a `roms` row after RomM stops returning that id because the row can anchor local-only saves, playtime,
and an installed ROM. Retention does not imply that RomM still offers the row as a playable version. The Game Page's
lazy `get_version_list` load therefore recomputes availability every time; there is no liveness column, migration,
cache, or persisted verdict.

The existing detail request for the bound id supplies its own answer and RomM's current direct `sibling_roms` view. The
bound id and every local id positively present in that view are live. A local group member absent from the direct view
is only a suspect because RomM sibling membership can be transitive, so each suspect is checked by an exact-id
`get_rom_once` request. These checks fan out concurrently on the worker executor and use the short timeout with no
retry, keeping them off the event loop and out of the initial Game Page render.

Only a typed `RommNotFoundError` from an exact id marks that entry `vanished`. A successful response is live; timeout,
transport, authentication, server errors, and malformed or empty data all fail open and leave it available. If the bound
detail itself 404s, `bound_vanished` is true, `server_query_failed` stays false, and every other local member is checked
individually. That entity-specific 404 is not fed into the global connection store; a genuine explicit
server-unreachable result still is. Cover and save endpoints are not liveness authorities.

Vanished rows stay visible with their active and downloaded markers, but are disabled and excluded before the existing
default-resolution kernel runs. `vanished` does not change `switchable` or
`domain.sibling_group.target_in_sibling_group`: availability and sibling membership remain separate verdicts. This lets
a shortcut still bound to a vanished id show the retained context while the user selects a live alternative. The Saves
tab likewise skips positively vanished inactive installs before checking local drift, then continues through later live
candidates.

## Sync-start reconcile of Steam-UI-deleted shortcuts

A user can delete a RomM shortcut through **Steam's own UI** (remove from library), which the plugin never observes. The
Expand Down
13 changes: 12 additions & 1 deletion docs/user-guide/managing-games.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ a not-yet-synced version whose match conflicts, are both shown (so you know they
either would move the shortcut onto a different game. To make one selectable, fix its metadata match in RomM so it
agrees with this game's, then re-sync.

A retained local version that RomM has positively confirmed it no longer has is also shown dimmed and disabled, with the
separate label _"No longer available on RomM"_. Its **active** and **Downloaded** markers remain visible so it is clear
what the shortcut and local files still refer to, but it never receives the **Default** badge. If the shortcut is still
active on that unavailable id, choose any live alternative in the same list to recover the game.

This availability check is fresh each time the version list loads and is not saved in the plugin database. Only a 404
for that exact ROM id establishes that it is gone. Timeouts, sign-in/permission errors, server errors, and malformed
responses fail open: the plugin does not disable a version merely because it could not get a trustworthy answer. A
ROM-specific 404 also does not make the rest of the plugin report that RomM is offline.

Selecting a different version **rebinds** the game to it: the Download button now fetches that version, the panel's
title and Region/Languages rows update to reflect it, and its cover refreshes to the new version. The Steam shortcut
keeps its name, its place in your collections, and its playtime — all tied to the shortcut, which never changes. A
Expand All @@ -71,7 +81,8 @@ switch. What happens depends on whether the version you're leaving is downloaded
drops the sync option and offers only **Switch anyway** and **Cancel**, explaining why.

When you leave a downloaded version with unsynced saves behind, the **Saves** tab shows a reminder banner ("switch back
to sync them") so those saves aren't forgotten.
to sync them") so those saves aren't forgotten. It does not recommend switching back to a version positively confirmed
as no longer available on RomM; it skips that retained row and continues checking any later live versions.

While a download of the game is running, switching is blocked with a short message — cancel the running download first.

Expand Down
5 changes: 5 additions & 0 deletions docs/user-guide/save-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ plugin warns you before you leave such a version and shows a reminder banner on
forgotten (see [Managing Games → Versions](managing-games.md#versions)). Nothing is lost — the saves just don't sync
while their version is inactive.

The reminder never tells you to switch back to a retained version that the current version-list check positively
confirmed is [no longer available on RomM](managing-games.md#the-switch-version-control). Such a row may still keep its
local files and saves, but it cannot be selected; the Saves tab skips it and continues looking for drift on later live
inactive versions.

## Important: Use Your Own RomM Account

Save files in RomM are tied to the authenticated user account. If multiple people share the same RomM account, their
Expand Down
7 changes: 3 additions & 4 deletions py_modules/adapters/romm/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,9 @@ def request_once(self, path: str, *, timeout: int):
"""GET a JSON resource in a SINGLE attempt with a SHORT *timeout*.

Deliberately bypasses :meth:`with_retry` and the 30s urlopen timeout used
by :meth:`request`. The launch-gate reachability probe needs a fast
offline verdict (~3s, one shot) instead of waiting through 3 retry
attempts and up to ~90s of accumulated remote timeouts. The real sync
paths keep the retrying :meth:`request`.
by :meth:`request`. Point probes need a fast verdict (~3s, one shot)
instead of waiting through 3 retry attempts and up to ~90s of accumulated
remote timeouts. The real sync paths keep the retrying :meth:`request`.
"""
return self._build_get(path, timeout=timeout)()

Expand Down
9 changes: 6 additions & 3 deletions py_modules/adapters/romm/romm_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,8 @@ def get_version(self) -> str | None:

# ── Server / Auth ─────────────────────────────────────────────────

# Fast-fail reachability probe: a single ~3s attempt, no retry. Keeps the
# launch gate's "offline" verdict snappy instead of waiting through the
# retrying heartbeat (3 attempts + up to ~90s of accumulated timeouts).
# Fast-fail point probes: a single ~3s attempt, no retry. Keeps verdicts
# snappy instead of waiting through 3 attempts and accumulated timeouts.
_PROBE_TIMEOUT_SECONDS = 3

def heartbeat(self) -> dict[str, Any]:
Expand All @@ -130,6 +129,10 @@ def get_current_user(self) -> dict[str, Any]:
def get_rom(self, rom_id: int) -> dict[str, Any]:
return self._client.request(f"/api/roms/{rom_id}")

def get_rom_once(self, rom_id: int) -> dict[str, Any]:
"""Single-attempt, short-timeout exact-ROM read for liveness probes."""
return self._client.request_once(f"/api/roms/{rom_id}", timeout=self._PROBE_TIMEOUT_SECONDS)

def list_roms(self, platform_id: int, limit: int = LIST_PAGE_SIZE, offset: int = 0) -> dict[str, Any]:
return self._client.request(
f"/api/roms?platform_ids={platform_id}&limit={limit}&offset={offset}{_LIST_AGGREGATIONS_DISABLED}"
Expand Down
2 changes: 1 addition & 1 deletion py_modules/services/artwork.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,7 @@ async def refresh_cover(self, rom_id: int) -> dict[str, Any]:
if not rom:
return {
"success": False,
"reason": ErrorCode.SERVER_UNREACHABLE.value,
"reason": ErrorCode.NOT_FOUND.value,
"message": "Could not fetch ROM from server",
}

Expand Down
4 changes: 4 additions & 0 deletions py_modules/services/protocols/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ def get_rom(self, rom_id: int) -> dict[str, Any]:
"""
...

def get_rom_once(self, rom_id: int) -> dict[str, Any]:
"""Fetch a single ROM by ID once with the short probe timeout."""
...

def list_roms(self, platform_id: int, limit: int = LIST_PAGE_SIZE, offset: int = 0) -> dict[str, Any]:
"""List ROMs for a platform with pagination.

Expand Down
Loading
Loading