diff --git a/docs/evidence/framey-legacy-2026-10-25.md b/docs/evidence/framey-legacy-2026-10-25.md new file mode 100644 index 0000000..2961de5 --- /dev/null +++ b/docs/evidence/framey-legacy-2026-10-25.md @@ -0,0 +1,24 @@ +# Legacy Decky frontend bridge + +The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures) +has no `api_version` field and returns its initializer from a Decky IIFE. +Framey now wraps that expression in an ES module inside the existing +opaque-origin iframe. It passes a legacy `serverAPI` to the initializer. + +Local Chromium ran the unchanged official frontend, including React effects, +with all backend calls rejected and network access blocked. It emitted +`framey:ready` without a JavaScript startup error during the observation window. +This proves initialization, not playback, MPRIS access, or a useful populated UI. + +The fake Frame installed, packed, checked the frontend bytes and removed this +exact archive. Its synthetic Sample Panel also read a legacy keyword argument, +wrote a setting, and read that setting through the modern API. This exercised +the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was +checked against Decky's `handle_plugin_method_call_legacy`: it calls with a +keyword object and returns `{success, result}`. Invalid argument shapes and +unmanaged plugin IDs are rejected. + +Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'` +(21 passed), `node tests/page/framey_actions.mjs`, and +`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or +headset state was changed by these checks. Real Frame backend behavior,+controller input, playback and worn VR use remain unverified. diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 778a68f..0e5db81 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -57,7 +57,9 @@ none of these frontend controls has passed a worn-headset check yet. | Decky API or UI feature | Framey behavior | Verified in | | --- | --- | --- | | `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test | -| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; older API v1 bundles still show an incompatibility message | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. | +| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. | +| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal | +| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result | | Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test | | `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test | | `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified | diff --git a/docs/framey-loader.md b/docs/framey-loader.md index 303332f..e9dc97d 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -24,7 +24,10 @@ and swaps one managed plugin folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries. The Decky user service restarts after a file change. Framey's panel host accepts API v2 bundles and API v1 bundles using Decky's modern frontend initializer, -with `dist/index.js`; other installed plugins remain manageable but have no +with `dist/index.js`. Legacy `(function (...) {...})(DFL, SP_REACT)` bundles are +also accepted: their returned initializer receives the legacy `serverAPI`, and +keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES +module inside the same sandbox; it does not require `eval`. Other installed plugins remain manageable but have no Framey panel. API v1 admission is a compatibility attempt, not a guarantee that the plugin's controls work in VR. The store manager does not currently screen a plugin's runtime network downloads. diff --git a/tests/e2e/test_framey.py b/tests/e2e/test_framey.py index 2594021..1032bff 100644 --- a/tests/e2e/test_framey.py +++ b/tests/e2e/test_framey.py @@ -122,6 +122,15 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T self.assertEqual(ok('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light') + self.assertEqual(ok('POST', '/api/framey/method', + {'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}], + 'legacy':True})['result'], {'success':True,'result':'light'}) + self.assertEqual(ok('POST', '/api/framey/method', + {'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}], + 'legacy':True})['result'], {'success':True,'result':True}) + self.assertEqual(ok('POST', '/api/framey/method', + {'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved') + StoreFixture.release = 1 job = finished(ok('POST', '/api/decky/plugin', {'action':'update','id':999,'acknowledged':True})) @@ -165,7 +174,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle: expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest() self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected) - self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], plugin['api_version']) + self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version']) job = finished(ok('POST', '/api/decky/plugin', {'action':'remove','id':plugin['id'],'acknowledged':True})) self.assertFalse(job.get('error'), job) diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index 34aa0d5..f9cb6a0 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -28,10 +28,20 @@ function build(steam) { // Synthetic Decky API for Framey's CDP bridge test. This does not model the // real loader or prove frontend visibility on a headset. const deckyBackend = {async call(route, plugin, method, ...args) { - if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel') + if (!['loader/call_plugin_method','loader/call_legacy_plugin_method'].includes(route) || plugin !== 'Sample Panel') throw Error('unexpected Decky plugin route'); const settings = (steam.decky_settings ||= {}); const owned = (settings[plugin] ||= {}); + if (route === 'loader/call_legacy_plugin_method') { + const kwargs = args[0]; + if (method === 'get_setting') + return {success:true, result:Object.hasOwn(owned, kwargs.key) ? owned[kwargs.key] : kwargs.default}; + if (method === 'set_setting') { + owned[kwargs.key] = kwargs.value; + return {success:true, result:true}; + } + return {success:false, result:'method unavailable in fake Decky backend'}; + } if (method === 'get_setting') return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1]; if (method === 'set_setting') { diff --git a/tests/fixtures/framey-official/12.zip b/tests/fixtures/framey-official/12.zip new file mode 100644 index 0000000..1fe4d6a Binary files /dev/null and b/tests/fixtures/framey-official/12.zip differ diff --git a/tests/fixtures/framey-official/README.md b/tests/fixtures/framey-official/README.md index de44ea6..015e0bb 100644 --- a/tests/fixtures/framey-official/README.md +++ b/tests/fixtures/framey-official/README.md @@ -7,6 +7,7 @@ from a local fixture so CI does not depend on the store being online. | File | Official plugin | Version | Store SHA-256 | | --- | --- | --- | --- | +| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` | | `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` | | `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` | | `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` | @@ -14,6 +15,7 @@ from a local fixture so CI does not depend on the store being online. | `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` | The matching source projects are +[MusicControl](https://github.com/nikurasu/MusicControl), [ProtonDB Badges](https://github.com/bschelst/protondb-decky), [Steam Status](https://github.com/bschelst/steamstatus-decky), [Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner), diff --git a/tests/framey_official.py b/tests/framey_official.py index 48334c4..070e64f 100644 --- a/tests/framey_official.py +++ b/tests/framey_official.py @@ -4,6 +4,7 @@ from pathlib import Path ROOT = Path(__file__).parent / 'fixtures/framey-official' PLUGINS = ( + {'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'}, {'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2, 'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'}, {'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2, diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 48d0e08..248a9c2 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs'; import vm from 'node:vm'; const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8'); -const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface;\n})();'); +const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();'); assert.notEqual(testSource,source); const states=[]; const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}), @@ -111,6 +111,19 @@ assert.equal(window.DFL.findModuleChild(()=>true),null); window.DFL.Navigation.CloseSideMenus(); assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0); undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false); +// Legacy backend methods preserve Decky keyword arguments and success envelopes. +const legacyReply=window.__legacy.callPluginMethod('get_setting',{key:'theme'}); +const legacyCall=messages.filter(message=>message.type==='framey:call').at(-1); +assert.equal(legacyCall.legacy,true); +assert.deepEqual(JSON.parse(JSON.stringify(legacyCall.args)),[{key:'theme'}]); +listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:legacyCall.id, + result:{success:true,result:'dark'}}}); +assert.equal((await legacyReply).result,'dark'); +assert.equal((await window.__legacy.callPluginMethod('get_setting',[])).success,false); +const failedLegacy=window.__legacy.callPluginMethod('get_setting',{}); +const failedCall=messages.filter(message=>message.type==='framey:call').at(-1); +listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:failedCall.id,error:'Unavailable'}}); +assert.equal((await failedLegacy).success,false); function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);} const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute); assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true); diff --git a/tests/test_framey_official.py b/tests/test_framey_official.py index 0a734a3..a963b30 100644 --- a/tests/test_framey_official.py +++ b/tests/test_framey_official.py @@ -49,7 +49,7 @@ class OfficialBundles(unittest.TestCase): plugin['folder']) with zipfile.ZipFile(io.BytesIO(data)) as z: manifest = json.loads(z.read(plugin['folder'] + '/plugin.json')) - self.assertEqual(manifest['api_version'], plugin['api_version']) + 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']})) diff --git a/tests/test_framey_panel.py b/tests/test_framey_panel.py index 675f738..68a9ff3 100644 --- a/tests/test_framey_panel.py +++ b/tests/test_framey_panel.py @@ -58,6 +58,31 @@ class Panel(unittest.TestCase): with self.assertRaises(FileNotFoundError): framey_panel.panel(15) + def test_legacy_iife_is_admitted_without_api_manifest(self): + (self.folder/'plugin.json').write_text(json.dumps({'name':'Legacy', 'flags':[]})) + (self.folder/'dist/index.js').write_text( + '(function(DFL, React){return DFL.definePlugin(serverAPI=>({content:React.createElement("p",null,"Legacy")}));})(DFL, SP_REACT);') + self.assertIn('framey-bundle', framey_panel.panel(14).decode()) + (self.folder/'dist/index.js').write_text('window.unrecognized = true;') + with self.assertRaises(FileNotFoundError): + framey_panel.panel(14) + + def test_legacy_keyword_rpc_is_scoped_and_validated(self): + import shlex + def fake_ssh(command, stdin=None, **_): + payload = json.loads(shlex.split(command)[2]) + self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting', + 'args':[{'key':'mode'}], 'legacy':True}) + self.assertIn('loader/call_legacy_plugin_method', stdin) + return json.dumps({'result':{'success':True, 'result':'dark'}}) + with patch.object(framey_panel.frame_decky, 'status', return_value={ + 'plugins':[{'storeId':14,'name':'Legacy'}]}): + self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True), + {'result':{'success':True, 'result':'dark'}}) + for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]: + with self.assertRaises(framey_panel.PanelError): + framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy) + def test_unsupported_root_symlink_and_unmanaged_are_refused(self): manifest = self.folder/'plugin.json' manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0})) diff --git a/ui/framey-host.js b/ui/framey-host.js index cc8da1e..c32cfec 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -60,13 +60,13 @@ parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*'); setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable. - function ask(method, args) { + function ask(method, args, legacy=false) { if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method')); const id = ++nextId; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000); pending.set(id, {resolve, reject, timer}); - parent.postMessage({type:'framey:call', id, method, args}, '*'); + parent.postMessage({type:'framey:call', id, method, args, legacy}, '*'); }); } addEventListener('message', event => { @@ -281,15 +281,30 @@ useQuickAccessVisible: () => true, }), }; + const legacyServerAPI = { + routerHook, toaster, + callPluginMethod: (method, kwargs={}) => { + if (!kwargs || typeof kwargs !== 'object' || Array.isArray(kwargs)) + return Promise.resolve({success:false, result:'Legacy plugin arguments must be an object'}); + // Decky's legacy route takes keyword arguments and returns this envelope. + return ask(method, [kwargs], true).catch(failure=>({success:false, result:String(failure.message || failure)})); + }, + fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')), + }; async function start() { try { await seeded; const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0)); - const url = URL.createObjectURL(new Blob([bytes], {type:'text/javascript'})); + // Old Decky builds are expressions returning the initializer, rather than + // ES modules. A module wrapper preserves the sandbox and avoids eval. + const legacy = /^\s*\(function\s*\(/.test(new TextDecoder().decode(bytes)); + const url = URL.createObjectURL(new Blob(legacy ? + ['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'})); let module; try { module = await import(url); } finally { URL.revokeObjectURL(url); } if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point'); - plugin = await module.default(); + plugin = await module.default(legacyServerAPI); + if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title; if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display'); window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null, R.createElement(PluginSurface, {plugin}))); diff --git a/ui/framey_panel.py b/ui/framey_panel.py index 8f3844e..733ac84 100644 --- a/ui/framey_panel.py +++ b/ui/framey_panel.py @@ -44,12 +44,16 @@ def local_bundle(plugin_id): raise FileNotFoundError('Framey plugin manifest unavailable') flags = data.get('flags', []) version = data.get('api_version') - if type(version) is not int or version not in (1, 2) or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')): - raise FileNotFoundError('This Decky frontend is not API v1/v2 or needs root') + if ((version is not None and (type(version) is not int or version not in (1, 2))) or + not isinstance(flags, list) or any(x in flags for x in ('root', '_root'))): + raise FileNotFoundError('This Decky frontend has an unsupported API or needs root') if not entry.is_file() or entry.stat().st_size > 8*1024*1024: raise FileNotFoundError('Framey plugin frontend unavailable') bundle = entry.read_bytes() - if version == 1 and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle: + legacy = re.match(rb'\s*\(function\s*\(', bundle) is not None + if version is None and not legacy: + raise FileNotFoundError('This legacy frontend is not a supported Decky IIFE bundle') + if version == 1 and not legacy and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle: raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge') return bundle except (OSError, ValueError): @@ -76,23 +80,27 @@ import json, pathlib, sys sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui')) from frame_steam import Page payload = json.loads(sys.argv[1]) -expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call('loader/call_plugin_method'," + json.dumps(payload['name']) + ',' + json.dumps(payload['method']) + ',' + ','.join(json.dumps(x) for x in payload['args']) + ')})()' +route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method' +arguments = [payload['name'], payload['method'], *payload['args']] +expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()' page = Page() try: print(json.dumps({'result':page.eval(expression)})) finally: page.sock.close() ''' -def call_method(ssh, plugin_id, method, args): +def call_method(ssh, plugin_id, method, args, legacy=False): if type(plugin_id) is not int or plugin_id <= 0: raise PanelError('Choose an installed Decky store plugin') if not isinstance(method, str) or not METHOD.fullmatch(method): raise PanelError('Invalid plugin method') if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192: raise PanelError('Plugin method arguments are too large') + if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))): + raise PanelError('Legacy plugin methods need one keyword argument object') found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id] if len(found) != 1: raise PanelError('The plugin is not managed by the Frame Control Decky store') - payload = {'name':found[0]['name'], 'method':method, 'args':args} + payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy} return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=METHOD_SCRIPT, timeout=25)) diff --git a/ui/headset.html b/ui/headset.html index 911e501..4dae1e4 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -234,7 +234,7 @@ window.addEventListener('message',async event=>{ if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;} if(req.type!=='framey:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return; const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id}; - try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args});reply.result=data.result;} + try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;} catch(error){reply.error=error.message;} if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*'); }); diff --git a/ui/server.py b/ui/server.py index a2ce112..2824959 100755 --- a/ui/server.py +++ b/ui/server.py @@ -581,7 +581,7 @@ def framey_method_action(body): if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1': raise Failure('Decky plugin panels run in the headset service', 400) try: - return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args')) + return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False)) except framey_panel.PanelError as exc: raise Failure(str(exc), 400) from exc