mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 18:01:57 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dddfc6bea8 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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'<script id="framey-bundle" type="application/json">([^<]+)</script>', 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'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', 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())
|
||||
|
||||
|
||||
|
||||
@@ -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__':
|
||||
|
||||
+35
-15
@@ -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 ('''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Framey plugin</title><style>body{font:21px/1.4 system-ui;background:#0d1925;color:#f3f7fa;margin:0;padding:20px}
|
||||
button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px}
|
||||
|
||||
+2
-2
@@ -3705,8 +3705,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'Framey panel unavailable'}, 404)
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
module = framey_panel.local_module(int(plugin_id), name)
|
||||
plugin_id, receipt, name = path.removeprefix('/framey/module/').split('/', 2)
|
||||
module = framey_panel.local_module(int(plugin_id), receipt, name)
|
||||
# The plugin iframe has an opaque origin; only its module
|
||||
# imports need cross-origin access to these public JS files.
|
||||
self.send_bytes(module, 'text/javascript; charset=utf-8', framey=True,
|
||||
|
||||
Reference in new issue
Block a user