From dddfc6bea8fb0ac5fb4fb63dbe87a5dc6633eba1 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Mon, 26 Oct 2026 09:16:07 +0000 Subject: [PATCH] Bind Framey plugin modules to installed receipts --- docs/framey-loader.md | 4 +++ tests/test_framey_official.py | 10 ++++--- tests/test_framey_panel.py | 40 +++++++++++++++++++++------- ui/framey_panel.py | 50 ++++++++++++++++++++++++----------- ui/server.py | 4 +-- 5 files changed, 78 insertions(+), 30 deletions(-) diff --git a/docs/framey-loader.md b/docs/framey-loader.md index 233dd1d..5b42e84 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -217,6 +217,10 @@ frontend cannot call the changing backend. Open panel is unavailable while a store job is pending. A later installed-status refresh also closes an open panel if Decky stopped, its store receipt changed, or the status read failed. Reopen the plugin after a successful refresh to use its current frontend. +Chunked frontends load modules through URLs bound to the installed store +receipt. If a desktop store update replaces the plugin during panel startup, +old module requests fail and the panel must be reopened; Framey does not mix +chunks from two installed versions. The headset also includes the panel's store SHA-256 receipt with each backend call, Steam read, and event poll. It rejects a request if the installed receipt changed before the next status refresh; reopen the panel to use the new version. diff --git a/tests/test_framey_official.py b/tests/test_framey_official.py index 79af0ca..3ef182b 100644 --- a/tests/test_framey_official.py +++ b/tests/test_framey_official.py @@ -33,7 +33,8 @@ class OfficialBundles(unittest.TestCase): self.assertIn(b'deckyFrontendLib.definePlugin', frontend) bundle.extractall(root) folder = Path(root) / plugin['folder'] - (folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']})) + (folder / '.frame-control-store.json').write_text(json.dumps({ + 'storeId': plugin['id'], 'sha256': plugin['sha256']})) with patch.object(framey_panel, 'PLUGINS', Path(root)): page = framey_panel.panel(plugin['id']).decode() encoded = re.search(r'', page) @@ -71,16 +72,17 @@ class OfficialBundles(unittest.TestCase): self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version']) z.extractall(plugins) folder = plugins / plugin['folder'] - (folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']})) + (folder / '.frame-control-store.json').write_text(json.dumps({ + 'storeId': plugin['id'], 'sha256': plugin['sha256']})) with patch.object(framey_panel, 'PLUGINS', plugins): page = framey_panel.panel(plugin['id']).decode() encoded = re.search(r'', page) self.assertIsNotNone(encoded) self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes()) if plugin['id'] == 135: - self.assertIn('data-module="/framey/module/135/index.js"', page) + self.assertIn(f'data-module="/framey/module/135/{plugin["sha256"]}/index.js"', page) with patch.object(framey_panel, 'PLUGINS', plugins): - self.assertEqual(framey_panel.local_module(135, 'chunks/pluginApi-B8sV7970.js'), + self.assertEqual(framey_panel.local_module(135, plugin['sha256'], 'chunks/pluginApi-B8sV7970.js'), (folder/'dist/chunks/pluginApi-B8sV7970.js').read_bytes()) diff --git a/tests/test_framey_panel.py b/tests/test_framey_panel.py index 502048c..18bed83 100644 --- a/tests/test_framey_panel.py +++ b/tests/test_framey_panel.py @@ -227,7 +227,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/); self.plugins = Path(tmp.name)/'plugins' self.folder = self.plugins/'protondb-decky' (self.folder/'dist').mkdir(parents=True) - (self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4'})) + (self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4','sha256':RECEIPT})) (self.folder/'plugin.json').write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]})) (self.folder/'dist/index.js').write_text("export default () => ({content: SP_REACT.createElement('p', null, 'OK')});") home = patch.object(framey_panel, 'PLUGINS', self.plugins) @@ -260,18 +260,36 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/); chunks.mkdir() (chunks/'view.js').write_text('export const title = "Settings";') page = framey_panel.panel(14).decode() - self.assertIn('data-module="/framey/module/14/index.js"', page) - self.assertEqual(framey_panel.local_module(14, 'chunks/view.js'), + self.assertIn(f'data-module="/framey/module/14/{RECEIPT}/index.js"', page) + self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'), b'export const title = "Settings";') for name in ('../plugin.json', 'chunks/../../plugin.json', 'chunks/view.js.map', 'other.js', 'chunks/%2e%2e/view.js'): with self.subTest(name=name), self.assertRaises(FileNotFoundError): - framey_panel.local_module(14, name) + framey_panel.local_module(14, RECEIPT, name) (chunks/'linked.js').symlink_to(chunks/'view.js') with self.assertRaises(FileNotFoundError): - framey_panel.local_module(14, 'chunks/linked.js') + framey_panel.local_module(14, RECEIPT, 'chunks/linked.js') with self.assertRaises(FileNotFoundError): - framey_panel.local_module(15, 'chunks/view.js') + framey_panel.local_module(15, RECEIPT, 'chunks/view.js') + + def test_chunked_panel_rejects_modules_from_a_replaced_store_receipt(self): + (self.folder/'dist/index.js').write_text("import './chunks/view.js'; export default () => ({});") + chunks = self.folder/'dist/chunks' + chunks.mkdir() + (chunks/'view.js').write_text('export const version = 1;') + old_url = f'/framey/module/14/{RECEIPT}/chunks/view.js' + self.assertIn(f'/framey/module/14/{RECEIPT}/index.js', framey_panel.panel(14).decode()) + self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'), b'export const version = 1;') + (self.folder/'.frame-control-store.json').write_text(json.dumps({ + 'storeId':14,'version':'2.0','sha256':'b'*64})) + (chunks/'view.js').write_text('export const version = 2;') + with self.assertRaisesRegex(FileNotFoundError, 'changed'): + framey_panel.local_module(14, RECEIPT, 'chunks/view.js') + self.assertEqual(framey_panel.local_module(14, 'b'*64, 'chunks/view.js'), b'export const version = 2;') + self.assertNotIn(old_url, framey_panel.panel(14).decode()) + with self.assertRaises(FileNotFoundError): + framey_panel.local_module(14, 'invalid', 'chunks/view.js') def test_legacy_keyword_rpc_is_scoped_and_validated(self): import shlex @@ -365,12 +383,16 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/); (self.folder/'dist/chunks').mkdir() (self.folder/'dist/chunks/view.js').write_text('export const value = 1;') - status, headers, body = get('/framey/module/14/chunks/view.js') + status, headers, body = get(f'/framey/module/14/{RECEIPT}/chunks/view.js') self.assertEqual(status, 200) self.assertEqual(headers['Access-Control-Allow-Origin'], 'null') self.assertEqual(body, b'export const value = 1;') - self.assertEqual(get('/framey/module/14/chunks/../plugin.json')[0], 404) - self.assertEqual(get('/framey/module/15/chunks/view.js')[0], 404) + self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/../plugin.json')[0], 404) + self.assertEqual(get(f'/framey/module/15/{RECEIPT}/chunks/view.js')[0], 404) + (self.folder/'.frame-control-store.json').write_text(json.dumps({ + 'storeId':14,'version':'2.0','sha256':'b'*64})) + self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/view.js')[0], 404) + self.assertEqual(get(f'/framey/module/14/{"b"*64}/chunks/view.js')[0], 200) if __name__ == '__main__': diff --git a/ui/framey_panel.py b/ui/framey_panel.py index a5bfde7..1dfd95f 100644 --- a/ui/framey_panel.py +++ b/ui/framey_panel.py @@ -20,7 +20,17 @@ class PanelError(ValueError): pass -def _owned_dist(plugin_id): +def _receipt_digest(path): + if path.is_symlink() or not path.is_file() or path.stat().st_size > 16_384: + raise FileNotFoundError('Framey plugin store receipt unavailable') + meta = json.loads(path.read_text()) + digest = meta.get('sha256') if isinstance(meta, dict) else None + if not isinstance(digest, str) or not RECEIPT.fullmatch(digest): + raise FileNotFoundError('Framey plugin store receipt unavailable') + return meta, digest + + +def _owned_dist(plugin_id, expected_receipt=None): """Locate a compatible store-owned frontend without following linked paths.""" if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink(): raise FileNotFoundError('Framey plugin unavailable') @@ -31,16 +41,17 @@ def _owned_dist(plugin_id): manifest = folder/'plugin.json' dist = folder/'dist' entry = dist/'index.js' - if any(p.is_symlink() for p in (receipt, manifest, dist, entry)): + if any(p.is_symlink() for p in (manifest, dist, entry)): continue try: - if not receipt.is_file() or receipt.stat().st_size > 16_384: - continue - meta = json.loads(receipt.read_text()) - if not isinstance(meta, dict): - continue - if meta.get('storeId') != plugin_id: - continue + meta, digest = _receipt_digest(receipt) + except (OSError, ValueError, UnicodeError): + continue + if meta.get('storeId') != plugin_id: + continue + try: + if expected_receipt is not None and digest != expected_receipt: + raise FileNotFoundError('Framey plugin changed; reopen its panel') if not manifest.is_file() or manifest.stat().st_size > 16_384: raise FileNotFoundError('Framey plugin manifest unavailable') data = json.loads(manifest.read_text()) @@ -76,7 +87,11 @@ def _owned_dist(plugin_id): supported = True if not supported: raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge') - return dist, bundle + if _receipt_digest(receipt) != (meta, digest): + raise FileNotFoundError('Framey plugin changed; reopen its panel') + return dist, bundle, digest + except FileNotFoundError: + raise except (OSError, ValueError): raise FileNotFoundError('Framey plugin files are invalid') raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store') @@ -87,11 +102,13 @@ def local_bundle(plugin_id): return _owned_dist(plugin_id)[1] -def local_module(plugin_id, name): +def local_module(plugin_id, receipt, name): """Serve only JavaScript within a store-owned dist directory for module imports.""" if not isinstance(name, str) or not MODULE_PATH.fullmatch(name): raise FileNotFoundError('Framey module path unavailable') - dist, bundle = _owned_dist(plugin_id) + if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt): + raise FileNotFoundError('Framey module receipt unavailable') + dist, bundle, _ = _owned_dist(plugin_id, receipt) if name == 'index.js': return bundle target = dist @@ -101,14 +118,17 @@ def local_module(plugin_id, name): raise FileNotFoundError('Framey module path unavailable') if not target.is_file() or target.stat().st_size > 8*1024*1024: raise FileNotFoundError('Framey module unavailable') - return target.read_bytes() + data = target.read_bytes() + if _receipt_digest(dist.parent/'.frame-control-store.json')[1] != receipt: + raise FileNotFoundError('Framey plugin changed; reopen its panel') + return data def panel(plugin_id): """The plugin receives no Frame UI key and is sandboxed to an opaque origin.""" - bundle = local_bundle(plugin_id) + _, bundle, receipt = _owned_dist(plugin_id) encoded = base64.b64encode(bundle).decode('ascii') - module = f' data-module="/framey/module/{plugin_id}/index.js"' if b'./chunks/' in bundle else '' + module = f' data-module="/framey/module/{plugin_id}/{receipt}/index.js"' if b'./chunks/' in bundle else '' return (''' Framey plugin