diff --git a/examples/framey-settings/README.md b/examples/framey-settings/README.md index acfc089..0c100d4 100644 --- a/examples/framey-settings/README.md +++ b/examples/framey-settings/README.md @@ -14,6 +14,12 @@ action calls the backend, so it reads the persisted setting rather than a browser cache. A missing, damaged, or old non-boolean value reads as the caller's default; the next save atomically replaces the sample's settings file. +The current file is `{"schema": 1, "enabled": true|false}`. An unversioned +`{"enabled": true|false}` file from the first sample release still reads, and +the next save writes schema 1. A file with another explicit schema returns a +visible read or save error and remains untouched, so downgrading the sample +cannot erase settings written by a newer version. Add a deliberate migration +for each future schema instead of changing this guard silently. The panel also guards its own read and write calls synchronously. If two controller events arrive before React redraws a disabled control, the second event is ignored; after the request settles, a new change can be saved. diff --git a/examples/framey-settings/main.py b/examples/framey-settings/main.py index 4c491d1..e5fa5b3 100644 --- a/examples/framey-settings/main.py +++ b/examples/framey-settings/main.py @@ -5,26 +5,41 @@ from pathlib import Path import tempfile import decky +SETTINGS_SCHEMA = 1 + def settings_file(): # Decky provides this per-plugin path through its official Python module. return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json' +def read_settings(): + try: + stored = json.loads(settings_file().read_text(encoding='utf-8')) + except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError): + return {} + if not isinstance(stored, dict): + return {} + # The first sample release wrote {"enabled": bool} without a schema. + # Keep reading it; the next save writes the current format. + schema = stored.get('schema', SETTINGS_SCHEMA) + if type(schema) is not int or schema != SETTINGS_SCHEMA: + raise ValueError('Settings were written by an unsupported sample version') + return stored + + class Plugin: async def get_setting(self, key: str, default=False): if key != 'enabled': raise ValueError('Unknown setting') - try: - stored = json.loads(settings_file().read_text(encoding='utf-8')) - except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError): - return default - value = stored.get(key) if isinstance(stored, dict) else None + value = read_settings().get(key) return value if type(value) is bool else default async def set_setting(self, key: str, value: bool): if key != 'enabled' or type(value) is not bool: raise ValueError('Expected an enabled boolean') + # Refuse to erase settings from a newer plugin after a downgrade. + read_settings() target = settings_file() target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) temporary = None @@ -32,7 +47,7 @@ class Plugin: with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent, prefix='.settings-', delete=False) as output: temporary = Path(output.name) - json.dump({'enabled': value}, output) + json.dump({'schema': SETTINGS_SCHEMA, 'enabled': value}, output) output.write('\n') temporary.chmod(0o600) os.replace(temporary, target) diff --git a/tests/test_framey_sample.py b/tests/test_framey_sample.py index a25d45e..375c9f8 100644 --- a/tests/test_framey_sample.py +++ b/tests/test_framey_sample.py @@ -1,6 +1,7 @@ """The author example stores settings as a normal Decky user plugin.""" import asyncio import importlib.util +import json from pathlib import Path import subprocess import sys @@ -36,7 +37,8 @@ class SamplePlugin(unittest.TestCase): self.assertTrue(asyncio.run(first.set_setting('enabled', True))) second = backend(folder) self.assertTrue(asyncio.run(second.get_setting('enabled', False))) - self.assertTrue((Path(folder) / 'settings.json').is_file()) + self.assertEqual(json.loads((Path(folder) / 'settings.json').read_text()), + {'schema': 1, 'enabled': True}) def test_backend_rejects_unknown_keys_and_non_boolean_values(self): with tempfile.TemporaryDirectory() as folder: @@ -57,6 +59,43 @@ class SamplePlugin(unittest.TestCase): self.assertTrue(asyncio.run(plugin.set_setting('enabled', True))) self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False))) + def test_legacy_boolean_survives_restart_and_migrates_on_save(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / 'settings.json' + path.write_text('{"enabled": true}\n') + self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False))) + self.assertEqual(json.loads(path.read_text()), {'enabled': True}, + 'Reading must not rewrite a user setting') + self.assertTrue(asyncio.run(backend(folder).set_setting('enabled', False))) + self.assertEqual(json.loads(path.read_text()), {'schema': 1, 'enabled': False}) + self.assertFalse(asyncio.run(backend(folder).get_setting('enabled', True))) + + def test_newer_schema_is_preserved_on_read_and_save(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / 'settings.json' + original = '{"schema": 2, "enabled": true, "future": "keep"}\n' + path.write_text(original) + plugin = backend(folder) + with self.assertRaisesRegex(ValueError, 'unsupported sample version'): + asyncio.run(plugin.get_setting('enabled', False)) + with self.assertRaisesRegex(ValueError, 'unsupported sample version'): + asyncio.run(plugin.set_setting('enabled', False)) + self.assertEqual(path.read_text(), original) + self.assertEqual(list(Path(folder).glob('.settings-*')), []) + + def test_failed_atomic_replace_keeps_previous_setting(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / 'settings.json' + plugin = backend(folder) + self.assertTrue(asyncio.run(plugin.set_setting('enabled', True))) + previous = path.read_bytes() + with patch('os.replace', side_effect=OSError('storage unavailable')): + with self.assertRaisesRegex(OSError, 'storage unavailable'): + asyncio.run(plugin.set_setting('enabled', False)) + self.assertEqual(path.read_bytes(), previous) + self.assertEqual(list(Path(folder).glob('.settings-*')), []) + self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False))) + if __name__ == '__main__': unittest.main()