From bd346432ef1acb3510481d4a605936fb18f969b8 Mon Sep 17 00:00:00 2001 From: danielcopper Date: Tue, 5 May 2026 21:36:04 +0200 Subject: [PATCH 1/5] chore(saves): remove conflict_mode setting The 0.16 newest-wins matrix sync rewrite removed the only meaningful consumer of conflict_mode (resolve_conflict_by_mode in domain/save_conflicts, deleted in #264). The remaining frontend gate kept the launch-time conflict check optional; that check should always run, so drop the setting end-to-end and let the interceptor block launches whenever a save conflict is pending. --- py_modules/models/saves.py | 1 - py_modules/services/saves.py | 14 ++++---------- src/components/SettingsPage.tsx | 18 +----------------- src/types/index.ts | 3 --- src/utils/launchInterceptor.ts | 25 +++++++++++-------------- tests/models/test_saves.py | 2 -- tests/services/test_saves.py | 24 +++++++++--------------- tests/test_plugin_saves.py | 25 ++++++------------------- 8 files changed, 31 insertions(+), 81 deletions(-) diff --git a/py_modules/models/saves.py b/py_modules/models/saves.py index f62f73a..aaceaa2 100644 --- a/py_modules/models/saves.py +++ b/py_modules/models/saves.py @@ -42,7 +42,6 @@ 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 diff --git a/py_modules/services/saves.py b/py_modules/services/saves.py index 2849cbc..8e4e8b0 100644 --- a/py_modules/services/saves.py +++ b/py_modules/services/saves.py @@ -205,7 +205,6 @@ 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, @@ -2590,21 +2589,18 @@ 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": @@ -2619,24 +2615,22 @@ 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 diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index df363b0..04cba77 100755 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -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 } = {}; @@ -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; } @@ -585,15 +578,6 @@ export const SettingsPage: FC = ({ onBack }) => { onChange={(value) => handleSaveSyncSettingChange({ sync_after_exit: value })} /> - - handleSaveSyncSettingChange({ conflict_mode: option.data as ConflictMode })} - /> - Date: Tue, 5 May 2026 21:39:59 +0200 Subject: [PATCH 2/5] chore(saves): remove clock_skew_tolerance_sec setting The only consumer (resolve_conflict_by_mode in domain/save_conflicts) was deleted with the 0.16 newest-wins matrix sync rewrite (#264). Drop the setting from the dataclass, defaults, sanitiser, and frontend type. --- py_modules/models/saves.py | 1 - py_modules/services/saves.py | 5 ----- src/types/index.ts | 1 - tests/models/test_saves.py | 6 +++--- tests/services/test_saves.py | 7 ------- tests/test_plugin_saves.py | 12 ------------ 6 files changed, 3 insertions(+), 29 deletions(-) diff --git a/py_modules/models/saves.py b/py_modules/models/saves.py index aaceaa2..f26d2d1 100644 --- a/py_modules/models/saves.py +++ b/py_modules/models/saves.py @@ -44,7 +44,6 @@ class SaveSyncSettings: save_sync_enabled: bool sync_before_launch: bool sync_after_exit: bool - clock_skew_tolerance_sec: int @dataclass(frozen=True) diff --git a/py_modules/services/saves.py b/py_modules/services/saves.py index 8e4e8b0..da81a77 100644 --- a/py_modules/services/saves.py +++ b/py_modules/services/saves.py @@ -207,7 +207,6 @@ def make_default_state() -> dict: "save_sync_enabled": False, "sync_before_launch": True, "sync_after_exit": True, - "clock_skew_tolerance_sec": 60, "default_slot": "default", "autocleanup_limit": 10, }, @@ -2591,7 +2590,6 @@ def get_save_sync_settings(self) -> dict: settings.setdefault("save_sync_enabled", False) settings.setdefault("sync_before_launch", True) settings.setdefault("sync_after_exit", True) - settings.setdefault("clock_skew_tolerance_sec", 60) return settings @staticmethod @@ -2601,8 +2599,6 @@ def _sanitize_setting(key: str, value: object) -> tuple[object, bool]: Returns (coerced_value, skip) where skip=True means the value should be discarded (e.g. empty slot name). """ - 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 @@ -2620,7 +2616,6 @@ def update_save_sync_settings(self, settings: dict) -> dict: "save_sync_enabled", "sync_before_launch", "sync_after_exit", - "clock_skew_tolerance_sec", "default_slot", "autocleanup_limit", } diff --git a/src/types/index.ts b/src/types/index.ts index 969f002..957d25a 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -255,7 +255,6 @@ export interface SaveSyncSettings { save_sync_enabled: boolean; sync_before_launch: boolean; sync_after_exit: boolean; - clock_skew_tolerance_sec: number; default_slot: string | null; autocleanup_limit: number; } diff --git a/tests/models/test_saves.py b/tests/models/test_saves.py index d664e40..da725c0 100644 --- a/tests/models/test_saves.py +++ b/tests/models/test_saves.py @@ -84,7 +84,6 @@ def test_construction(self): save_sync_enabled=True, sync_before_launch=True, sync_after_exit=True, - clock_skew_tolerance_sec=60, ) assert s.save_sync_enabled is True @@ -93,10 +92,11 @@ def test_asdict(self): save_sync_enabled=False, 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: diff --git a/tests/services/test_saves.py b/tests/services/test_saves.py index 99da714..5718c69 100644 --- a/tests/services/test_saves.py +++ b/tests/services/test_saves.py @@ -1263,13 +1263,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 diff --git a/tests/test_plugin_saves.py b/tests/test_plugin_saves.py index 41e570a..6858be4 100644 --- a/tests/test_plugin_saves.py +++ b/tests/test_plugin_saves.py @@ -631,7 +631,6 @@ async def test_get_returns_current(self, plugin): assert result["save_sync_enabled"] is True assert result["sync_before_launch"] is True assert result["sync_after_exit"] is True - assert result["clock_skew_tolerance_sec"] == 60 @pytest.mark.asyncio async def test_update_changes_settings(self, plugin, tmp_path): @@ -668,17 +667,6 @@ async def test_unknown_keys_ignored(self, plugin): assert result["settings"]["sync_before_launch"] is True assert "unknown_key" not in result["settings"] - @pytest.mark.asyncio - async def test_clock_skew_clamped_to_zero(self, plugin): - """Negative clock_skew_tolerance_sec clamped to 0.""" - result = await plugin.update_save_sync_settings( - { - "clock_skew_tolerance_sec": -10, - } - ) - - assert result["settings"]["clock_skew_tolerance_sec"] == 0 - @pytest.mark.asyncio async def test_boolean_coercion(self, plugin): """sync toggles coerced to bool.""" From 5e7eb0b7495af6b67b00d597d3ffbeacabe9eeb8 Mon Sep 17 00:00:00 2001 From: danielcopper Date: Tue, 5 May 2026 21:43:05 +0200 Subject: [PATCH 3/5] chore(saves): strip legacy settings keys on state load load_state merges persisted settings via dict.update, so old state files keep removed keys (conflict_mode, clock_skew_tolerance_sec) forever. Pop them in _migrate_loaded_state so the next save_state writes a clean file. --- py_modules/services/saves.py | 33 +++++++++++++++++++++------------ tests/services/test_saves.py | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/py_modules/services/saves.py b/py_modules/services/saves.py index da81a77..1b94cb8 100644 --- a/py_modules/services/saves.py +++ b/py_modules/services/saves.py @@ -237,20 +237,29 @@ 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", {}) - 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) + if isinstance(saves, dict): + 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) + + # Strip removed legacy settings keys. Old state files keep these + # forever otherwise (load_state does dict.update on settings, so + # orphan keys survive). Idempotent: pop returns None when absent. + 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.""" diff --git a/tests/services/test_saves.py b/tests/services/test_saves.py index 5718c69..7fafeb9 100644 --- a/tests/services/test_saves.py +++ b/tests/services/test_saves.py @@ -237,6 +237,32 @@ 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_save_and_load_state(self, tmp_path): svc, _ = make_service(tmp_path) svc._save_sync_state["device_id"] = "test-device" From 6ca0295e2f8e64d832697ac39bf1eebb764e81bf Mon Sep 17 00:00:00 2001 From: danielcopper Date: Wed, 6 May 2026 07:09:06 +0200 Subject: [PATCH 4/5] chore(saves): polish migration strip - guard, coverage, doc - Drop the throwaway empty-dict fallback from the legacy settings strip in `_migrate_loaded_state`; missing/non-dict settings now cleanly fall through the `isinstance` guard. - Pin the defensive guard with two tests: missing `settings` key and non-dict `settings` value. --- py_modules/services/saves.py | 2 +- tests/services/test_saves.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/py_modules/services/saves.py b/py_modules/services/saves.py index 1b94cb8..eb7840f 100644 --- a/py_modules/services/saves.py +++ b/py_modules/services/saves.py @@ -256,7 +256,7 @@ def _migrate_loaded_state(self) -> None: # Strip removed legacy settings keys. Old state files keep these # forever otherwise (load_state does dict.update on settings, so # orphan keys survive). Idempotent: pop returns None when absent. - settings = self._save_sync_state.get("settings", {}) + settings = self._save_sync_state.get("settings") if isinstance(settings, dict): settings.pop("conflict_mode", None) settings.pop("clock_skew_tolerance_sec", None) diff --git a/tests/services/test_saves.py b/tests/services/test_saves.py index 7fafeb9..5d9ba9a 100644 --- a/tests/services/test_saves.py +++ b/tests/services/test_saves.py @@ -263,6 +263,24 @@ def test_migrate_loaded_state_strip_legacy_settings_idempotent(self, tmp_path): 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" From 9ca6b6a7dc190ba85f5542bb0d544713aff2a76b Mon Sep 17 00:00:00 2001 From: danielcopper Date: Wed, 6 May 2026 09:02:11 +0200 Subject: [PATCH 5/5] refactor(saves): reduce _migrate_loaded_state cognitive complexity Extract the saves-block migration and the legacy-settings strip into private helpers (_migrate_saves_entries, _strip_legacy_settings). _migrate_loaded_state becomes a thin dispatcher. Cognitive complexity 22 -> below threshold (Sonar python:S3776). --- py_modules/services/saves.py | 42 ++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/py_modules/services/saves.py b/py_modules/services/saves.py index eb7840f..d99cbd2 100644 --- a/py_modules/services/saves.py +++ b/py_modules/services/saves.py @@ -240,22 +240,32 @@ def _migrate_loaded_state(self) -> None: - Strip removed legacy settings keys (``conflict_mode``, ``clock_skew_tolerance_sec``). """ - saves = self._save_sync_state.get("saves", {}) - if isinstance(saves, dict): - 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) - - # Strip removed legacy settings keys. Old state files keep these - # forever otherwise (load_state does dict.update on settings, so - # orphan keys survive). Idempotent: pop returns None when absent. + 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 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)