Skip to content
Open
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
37 changes: 36 additions & 1 deletion py_modules/adapters/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,13 @@ def load_state(self, defaults: dict) -> dict:

Returns the merged dict. If the file is missing or corrupt the
returned dict is a copy of *defaults* with the version stamp.

**Auto-recovery:** if the loaded state has an empty
``shortcut_registry`` but ``state.json.prev`` has a non-empty one,
the previous state is used instead and a warning is logged.
"""
state_path = os.path.join(self._runtime_dir, "state.json")
prev_path = state_path + ".prev"
state = dict(defaults)
try:
with open(state_path) as f:
Expand All @@ -136,12 +141,42 @@ def load_state(self, defaults: dict) -> dict:
except (FileNotFoundError, json.JSONDecodeError):
pass
state.setdefault("version", _STATE_VERSION)

# Auto-recover from .prev if current state lost its shortcut registry
registry = state.get("shortcut_registry", {})
if not registry:
try:
with open(prev_path) as f:
prev_saved = json.load(f)
if isinstance(prev_saved, dict):
prev_registry = prev_saved.get("shortcut_registry", {})
if prev_registry:
self._logger.warning(
"state.json has empty shortcut_registry but "
"state.json.prev has %d entries — auto-recovering",
len(prev_registry),
)
state.update(prev_saved)
state.setdefault("version", _STATE_VERSION)
except (FileNotFoundError, json.JSONDecodeError):
pass

return state

def save_state(self, data: dict) -> None:
"""Atomic write of *data* to ``state.json`` with flock, stamping version."""
"""Atomic write of *data* to ``state.json`` with flock, stamping version.

Before writing, the current ``state.json`` is rotated to
``state.json.prev`` so the previous state can be recovered if the
new write is empty or corrupt.
"""
data["version"] = _STATE_VERSION
state_path = os.path.join(self._runtime_dir, "state.json")
prev_path = state_path + ".prev"
# Rotate current → .prev before overwriting
if os.path.exists(state_path):
with contextlib.suppress(OSError):
os.replace(state_path, prev_path)
self._locked_write(state_path, data)

# ------------------------------------------------------------------
Expand Down
6 changes: 4 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { DownloadQueue } from "./components/DownloadQueue";
import { initSyncManager } from "./utils/syncManager";
import { setSyncProgress } from "./utils/syncProgress";
import { updateDownload, getDownloadState } from "./utils/downloadStore";
import { registerGameDetailPatch, unregisterGameDetailPatch, registerRomMAppId } from "./patches/gameDetailPatch";
import { unregisterGameDetailPatch, registerRomMAppId } from "./patches/gameDetailPatch";
import { registerMetadataPatches, unregisterMetadataPatches, applyAllPlaytime } from "./patches/metadataPatches";
import { registerLaunchInterceptor, unregisterLaunchInterceptor } from "./utils/launchInterceptor";
import { getAllMetadataCache, getAppIdRomIdMap, ensureDeviceRegistered, getSaveSyncSettings, getAllPlaytime, getMigrationStatus, getSaveSortMigrationStatus, logError, logInfo } from "./api/backend";
Expand Down Expand Up @@ -48,7 +48,9 @@ const QAMPanel: FC = () => {
};

export default definePlugin(() => {
registerGameDetailPatch();
// registerGameDetailPatch() intentionally removed — it calls
// routerHook.addPatch() which triggers Decky route re-renders that crash
// Steam's Library page (GetAppCountWithToolsFilter TypeError).
registerLaunchInterceptor();

// Load metadata cache, register store patches, and populate RomM app ID set.
Expand Down
85 changes: 85 additions & 0 deletions tests/adapters/test_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,88 @@ def test_save_settings_sets_permissions(self, adapter):
settings_path = os.path.join(adapter._settings_dir, "settings.json")
mode = os.stat(settings_path).st_mode & 0o777
assert mode == 0o600


# ── State backup & recovery ───────────────────────────────────────────────────


class TestStateBackupRecovery:
def test_save_state_creates_prev_file(self, adapter):
"""First save has no .prev, second save should rotate the first to .prev."""
adapter.save_state({"shortcut_registry": {"1": {"app_id": 100}}})
prev_path = os.path.join(adapter._runtime_dir, "state.json.prev")
assert not os.path.exists(prev_path) # No .prev on first write (no prior file)

adapter.save_state({"shortcut_registry": {"1": {"app_id": 200}}})
assert os.path.exists(prev_path)
with open(prev_path) as f:
prev = json.load(f)
assert prev["shortcut_registry"]["1"]["app_id"] == 100

def test_save_state_rotates_prev_on_each_write(self, adapter):
"""Each save replaces .prev with the previous state."""
adapter.save_state({"shortcut_registry": {"1": {"app_id": 100}}})
adapter.save_state({"shortcut_registry": {"1": {"app_id": 200}}})
adapter.save_state({"shortcut_registry": {"1": {"app_id": 300}}})

prev_path = os.path.join(adapter._runtime_dir, "state.json.prev")
with open(prev_path) as f:
prev = json.load(f)
# .prev should be the second save (app_id=200), not the first
assert prev["shortcut_registry"]["1"]["app_id"] == 200

def test_load_state_recovers_from_empty_registry(self, adapter):
"""If state.json has empty registry but .prev has entries, auto-recover."""
defaults = {"shortcut_registry": {}}
# Write a good state, then an empty one (simulates corruption/crash)
adapter.save_state({"shortcut_registry": {"1": {"app_id": 100}, "2": {"app_id": 200}}})
adapter.save_state({"shortcut_registry": {}})

result = adapter.load_state(defaults)
# Should recover from .prev
assert len(result["shortcut_registry"]) == 2
assert result["shortcut_registry"]["1"]["app_id"] == 100

def test_load_state_no_recovery_when_registry_has_entries(self, adapter):
"""Normal case: state.json has entries, .prev is ignored."""
defaults = {"shortcut_registry": {}}
adapter.save_state({"shortcut_registry": {"1": {"app_id": 100}}})
adapter.save_state({"shortcut_registry": {"2": {"app_id": 200}}})

result = adapter.load_state(defaults)
# Should use current state, not .prev
assert "2" in result["shortcut_registry"]
assert "1" not in result["shortcut_registry"]

def test_load_state_no_recovery_when_no_prev_file(self, adapter):
"""Empty registry with no .prev file — returns defaults."""
defaults = {"shortcut_registry": {}}
state_path = os.path.join(adapter._runtime_dir, "state.json")
with open(state_path, "w") as f:
json.dump({"shortcut_registry": {}, "version": _STATE_VERSION}, f)

result = adapter.load_state(defaults)
assert result["shortcut_registry"] == {}

def test_load_state_no_recovery_when_prev_also_empty(self, adapter):
"""Both state.json and .prev have empty registries — no recovery."""
defaults = {"shortcut_registry": {}}
# Write empty, then empty again
adapter.save_state({"shortcut_registry": {}})
adapter.save_state({"shortcut_registry": {}})

result = adapter.load_state(defaults)
assert result["shortcut_registry"] == {}

def test_load_state_no_recovery_when_prev_is_corrupt(self, adapter):
"""state.json empty, .prev is corrupt JSON — no recovery, no crash."""
defaults = {"shortcut_registry": {}}
state_path = os.path.join(adapter._runtime_dir, "state.json")
prev_path = state_path + ".prev"
with open(state_path, "w") as f:
json.dump({"shortcut_registry": {}, "version": _STATE_VERSION}, f)
with open(prev_path, "w") as f:
f.write("CORRUPT{{{")

result = adapter.load_state(defaults)
assert result["shortcut_registry"] == {}