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
2 changes: 0 additions & 2 deletions py_modules/models/saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,8 @@ class SaveSyncSettings:
"""User-facing save sync configuration."""

save_sync_enabled: bool
conflict_mode: str
sync_before_launch: bool
sync_after_exit: bool
clock_skew_tolerance_sec: int


@dataclass(frozen=True)
Expand Down
50 changes: 29 additions & 21 deletions py_modules/services/saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,8 @@ def make_default_state() -> dict:
"playtime": {},
"settings": {
"save_sync_enabled": False,
"conflict_mode": "ask_me",
"sync_before_launch": True,
"sync_after_exit": True,
"clock_skew_tolerance_sec": 60,
"default_slot": "default",
"autocleanup_limit": 10,
},
Expand Down Expand Up @@ -239,20 +237,39 @@ def _migrate_loaded_state(self) -> None:
- Rename per-game ``active_core`` → ``last_synced_core``.
- Drop legacy per-file ``dismissed_newer_save_id`` (was used by
the removed newer-in-slot detection).
- Strip removed legacy settings keys (``conflict_mode``,
``clock_skew_tolerance_sec``).
"""
saves = self._save_sync_state.get("saves", {})
self._migrate_saves_entries()
self._strip_legacy_settings()

def _migrate_saves_entries(self) -> None:
"""Rename ``active_core`` → ``last_synced_core`` and drop dead per-file flags."""
saves = self._save_sync_state.get("saves")
if not isinstance(saves, dict):
return
for entry in saves.values():
if not isinstance(entry, dict):
continue
if "active_core" in entry:
entry["last_synced_core"] = entry.pop("active_core")
files = entry.get("files", {})
if isinstance(files, dict):
for file_state in files.values():
if isinstance(file_state, dict):
file_state.pop("dismissed_newer_save_id", None)
files = entry.get("files")
if not isinstance(files, dict):
continue
for file_state in files.values():
if isinstance(file_state, dict):
file_state.pop("dismissed_newer_save_id", None)

def _strip_legacy_settings(self) -> None:
"""Strip removed settings keys from loaded state.

Old state files keep these forever otherwise (``load_state`` does
``dict.update`` on settings, so orphan keys survive). Idempotent.
"""
settings = self._save_sync_state.get("settings")
if isinstance(settings, dict):
settings.pop("conflict_mode", None)
settings.pop("clock_skew_tolerance_sec", None)

def load_state(self) -> None:
"""Load save sync state from disk, merging with defaults."""
Expand Down Expand Up @@ -2590,23 +2607,17 @@ def get_save_sync_settings(self) -> dict:
settings.setdefault("autocleanup_limit", 10)
if not self._save_sync_state.get("settings"):
settings.setdefault("save_sync_enabled", False)
settings.setdefault("conflict_mode", "ask_me")
settings.setdefault("sync_before_launch", True)
settings.setdefault("sync_after_exit", True)
settings.setdefault("clock_skew_tolerance_sec", 60)
return settings

@staticmethod
def _sanitize_setting(key: str, value: object, valid_modes: set[str]) -> tuple[object, bool]:
def _sanitize_setting(key: str, value: object) -> tuple[object, bool]:
"""Validate and coerce a single settings key/value pair.

Returns (coerced_value, skip) where skip=True means the value should
be discarded (e.g. invalid conflict_mode or empty slot name).
be discarded (e.g. empty slot name).
"""
if key == "conflict_mode":
return value, value not in valid_modes
if key == "clock_skew_tolerance_sec":
return max(0, int(value)), False # type: ignore[arg-type]
if key == "default_slot":
if value is None:
return None, False # None = legacy mode
Expand All @@ -2619,24 +2630,21 @@ def _sanitize_setting(key: str, value: object, valid_modes: set[str]) -> tuple[o
return value, False

def update_save_sync_settings(self, settings: dict) -> dict:
"""Update save sync settings (conflict_mode, sync toggles, etc.)."""
"""Update save sync settings (sync toggles, slot, etc.)."""
allowed_keys = {
"save_sync_enabled",
"conflict_mode",
"sync_before_launch",
"sync_after_exit",
"clock_skew_tolerance_sec",
"default_slot",
"autocleanup_limit",
}
valid_modes = {"newest_wins", "always_upload", "always_download", "ask_me"}

current = self._save_sync_state.setdefault("settings", {})

for key, value in settings.items():
if key not in allowed_keys:
continue
value, skip = self._sanitize_setting(key, value, valid_modes)
value, skip = self._sanitize_setting(key, value)
if skip:
continue
current[key] = value
Expand Down
18 changes: 1 addition & 17 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from "../api/backend";
import type { SaveSortMigrationStatus, RegisteredDevice } from "../api/backend";
import { getSaveSortMigrationState, setSaveSortMigrationStatus as setStoreSaveSortStatus, clearSaveSortMigration, onSaveSortMigrationChange } from "../utils/saveSortMigrationStore";
import type { SaveSyncSettings as SaveSyncSettingsType, ConflictMode, RetroArchInputCheck } from "../types";
import type { SaveSyncSettings as SaveSyncSettingsType, RetroArchInputCheck } from "../types";

// Module-level state survives component remounts (modal close can remount QAM)
const pendingEdits: { url?: string; username?: string; password?: string } = {};
Expand Down Expand Up @@ -91,13 +91,6 @@ const TextInputModal: FC<{
);
};

const conflictModeOptions = [
{ data: "ask_me" as ConflictMode, label: "Ask Me (Default)" },
{ data: "newest_wins" as ConflictMode, label: "Newest Wins" },
{ data: "always_upload" as ConflictMode, label: "Always Upload" },
{ data: "always_download" as ConflictMode, label: "Always Download" },
];

interface SettingsPageProps {
onBack: () => void;
}
Expand Down Expand Up @@ -585,15 +578,6 @@ export const SettingsPage: FC<SettingsPageProps> = ({ onBack }) => {
onChange={(value) => handleSaveSyncSettingChange({ sync_after_exit: value })}
/>
</PanelSectionRow>
<PanelSectionRow>
<DropdownItem
label="When saves conflict"
description="How to handle conflicting save files between devices"
rgOptions={conflictModeOptions}
selectedOption={saveSyncSettings.conflict_mode}
onChange={(option) => handleSaveSyncSettingChange({ conflict_mode: option.data as ConflictMode })}
/>
</PanelSectionRow>
<PanelSectionRow>
<Field
label="Default Save Slot"
Expand Down
4 changes: 0 additions & 4 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,10 @@ export interface RomMetadata {
steam_categories?: number[];
}

export type ConflictMode = "newest_wins" | "always_upload" | "always_download" | "ask_me";

export interface SaveSyncSettings {
save_sync_enabled: boolean;
conflict_mode: ConflictMode;
sync_before_launch: boolean;
sync_after_exit: boolean;
clock_skew_tolerance_sec: number;
default_slot: string | null;
autocleanup_limit: number;
}
Expand Down
25 changes: 11 additions & 14 deletions src/utils/launchInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { toaster } from "@decky/api";
import { isRomMAppId } from "../patches/gameDetailPatch";
import { getInstalledRom, getSaveStatus, getSaveSyncSettings, refreshMigrationState, logInfo, logError } from "../api/backend";
import { getInstalledRom, getSaveStatus, refreshMigrationState, logInfo, logError } from "../api/backend";
import { getMigrationState, setMigrationStatus } from "./migrationStore";
import { setSaveSortMigrationStatus } from "./saveSortMigrationStore";
import { hasAnySaveConflict } from "./saveStatus";
Expand Down Expand Up @@ -64,20 +64,17 @@ export function registerLaunchInterceptor(): void {
return;
}

// Check for save conflicts in ask_me mode
// Block launch when a save conflict is pending — the user must
// resolve it from the game page before playing.
try {
const settings = await getSaveSyncSettings();
if (settings.conflict_mode === "ask_me") {
const saveStatus = await getSaveStatus(rom.rom_id);
const hasConflict = hasAnySaveConflict(saveStatus);
if (hasConflict) {
SteamClient.Apps.CancelGameAction(gameActionId);
toaster.toast({
title: "RomM Save Sync",
body: "Save conflict detected \u2014 open game page to resolve before playing",
});
return;
}
const saveStatus = await getSaveStatus(rom.rom_id);
if (hasAnySaveConflict(saveStatus)) {
SteamClient.Apps.CancelGameAction(gameActionId);
toaster.toast({
title: "RomM Save Sync",
body: "Save conflict detected — open game page to resolve before playing",
});
return;
}
} catch {
// Non-critical — let the game launch if we can't check conflicts
Expand Down
8 changes: 3 additions & 5 deletions tests/models/test_saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,21 @@ class TestSaveSyncSettings:
def test_construction(self):
s = SaveSyncSettings(
save_sync_enabled=True,
conflict_mode="newest_wins",
sync_before_launch=True,
sync_after_exit=True,
clock_skew_tolerance_sec=60,
)
assert s.save_sync_enabled is True

def test_asdict(self):
s = SaveSyncSettings(
save_sync_enabled=False,
conflict_mode="ask_me",
sync_before_launch=False,
sync_after_exit=False,
clock_skew_tolerance_sec=120,
)
d = asdict(s)
assert d["clock_skew_tolerance_sec"] == 120
assert d["save_sync_enabled"] is False
assert d["sync_before_launch"] is False
assert d["sync_after_exit"] is False


class TestSyncResult:
Expand Down
75 changes: 53 additions & 22 deletions tests/services/test_saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def test_make_default_state(self):

def test_init_state_populates_defaults(self, tmp_path):
svc, _ = make_service(tmp_path, save_sync_state={})
assert svc._save_sync_state["settings"]["conflict_mode"] == "ask_me"
assert svc._save_sync_state["settings"]["save_sync_enabled"] is False
assert svc._save_sync_state["saves"] == {}

def test_init_state_preserves_existing(self, tmp_path):
Expand Down Expand Up @@ -237,6 +237,50 @@ def test_load_state_skips_migration_for_malformed_entries(self, tmp_path):
files = svc._save_sync_state["saves"]["42"]["files"]
assert "dismissed_newer_save_id" not in files["good.srm"]

def test_migrate_loaded_state_strips_legacy_settings_keys(self, tmp_path):
"""Legacy ``conflict_mode`` and ``clock_skew_tolerance_sec`` settings
are dropped on state load. Other settings keys survive."""
svc, _ = make_service(tmp_path)
svc._save_sync_state["settings"] = {
"conflict_mode": "ask_me",
"clock_skew_tolerance_sec": 60,
"save_sync_enabled": True,
}

svc._migrate_loaded_state()

settings = svc._save_sync_state["settings"]
assert "conflict_mode" not in settings
assert "clock_skew_tolerance_sec" not in settings
assert settings["save_sync_enabled"] is True

def test_migrate_loaded_state_strip_legacy_settings_idempotent(self, tmp_path):
"""Stripping legacy settings is a no-op when they aren't present."""
svc, _ = make_service(tmp_path)
svc._save_sync_state["settings"] = {"save_sync_enabled": True}

svc._migrate_loaded_state() # should not raise

assert svc._save_sync_state["settings"] == {"save_sync_enabled": True}

def test_migrate_loaded_state_handles_missing_settings(self, tmp_path):
"""Migration is defensive: missing ``settings`` key doesn't crash."""
svc, _ = make_service(tmp_path)
svc._save_sync_state.pop("settings", None)

svc._migrate_loaded_state() # should not raise

assert "settings" not in svc._save_sync_state

def test_migrate_loaded_state_handles_non_dict_settings(self, tmp_path):
"""Migration is defensive: non-dict ``settings`` is left untouched."""
svc, _ = make_service(tmp_path)
svc._save_sync_state["settings"] = "broken"

svc._migrate_loaded_state() # should not raise

assert svc._save_sync_state["settings"] == "broken"

def test_save_and_load_state(self, tmp_path):
svc, _ = make_service(tmp_path)
svc._save_sync_state["device_id"] = "test-device"
Expand Down Expand Up @@ -1239,28 +1283,22 @@ class TestSettings:
async def test_get_defaults(self, tmp_path):
svc, _ = make_service(tmp_path)
settings = svc.get_save_sync_settings()
assert settings["conflict_mode"] == "ask_me"
assert settings["save_sync_enabled"] is False
assert settings["sync_before_launch"] is True
assert settings["sync_after_exit"] is True

@pytest.mark.asyncio
async def test_update_settings(self, tmp_path):
svc, _ = make_service(tmp_path)
result = svc.update_save_sync_settings(
{
"save_sync_enabled": True,
"conflict_mode": "newest_wins",
"sync_before_launch": False,
}
)
assert result["success"] is True
assert result["settings"]["save_sync_enabled"] is True
assert result["settings"]["conflict_mode"] == "newest_wins"

@pytest.mark.asyncio
async def test_invalid_mode_ignored(self, tmp_path):
svc, _ = make_service(tmp_path)
svc.update_save_sync_settings({"conflict_mode": "invalid_mode"})
settings = svc.get_save_sync_settings()
assert settings["conflict_mode"] == "ask_me"
assert result["settings"]["sync_before_launch"] is False

@pytest.mark.asyncio
async def test_unknown_key_ignored(self, tmp_path):
Expand All @@ -1269,13 +1307,6 @@ async def test_unknown_key_ignored(self, tmp_path):
assert result["success"] is True
assert "unknown_key" not in result["settings"]

@pytest.mark.asyncio
async def test_clock_skew_clamped(self, tmp_path):
svc, _ = make_service(tmp_path)
svc.update_save_sync_settings({"clock_skew_tolerance_sec": -10})
settings = svc.get_save_sync_settings()
assert settings["clock_skew_tolerance_sec"] == 0


# ---------------------------------------------------------------------------
# TestDeleteSaves
Expand Down Expand Up @@ -2294,25 +2325,25 @@ def test_update_default_slot_empty_string_becomes_none(self, tmp_path):

def test_empty_string_becomes_none(self, tmp_path):
svc, _ = make_service(tmp_path)
val, skip = svc._sanitize_setting("default_slot", "", set())
val, skip = svc._sanitize_setting("default_slot", "")
assert val is None
assert skip is False

def test_none_value_passes_through(self, tmp_path):
svc, _ = make_service(tmp_path)
val, skip = svc._sanitize_setting("default_slot", None, set())
val, skip = svc._sanitize_setting("default_slot", None)
assert val is None
assert skip is False

def test_whitespace_only_becomes_none(self, tmp_path):
svc, _ = make_service(tmp_path)
val, skip = svc._sanitize_setting("default_slot", " ", set())
val, skip = svc._sanitize_setting("default_slot", " ")
assert val is None
assert skip is False

def test_nonempty_string_trimmed(self, tmp_path):
svc, _ = make_service(tmp_path)
val, skip = svc._sanitize_setting("default_slot", " desktop ", set())
val, skip = svc._sanitize_setting("default_slot", " desktop ")
assert val == "desktop"
assert skip is False

Expand Down
Loading
Loading