From 1aaa3c8c06a05b6102fa0f0d2901c5dc9c688da0 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sun, 25 Oct 2026 23:12:28 +0000 Subject: [PATCH] Support legacy Decky panels and keyword backend calls --- docs/evidence/framey-legacy-2026-10-25.md | 24 +++++++++++++++++ docs/frame-plugin-author.md | 4 ++- docs/framey-loader.md | 5 +++- tests/e2e/test_framey.py | 11 +++++++- .../usr/local/lib/fakeframe/cef_shim.js | 12 ++++++++- tests/fixtures/framey-official/12.zip | Bin 0 -> 15751 bytes tests/fixtures/framey-official/README.md | 2 ++ tests/framey_official.py | 1 + tests/page/framey_actions.mjs | 16 ++++++++++- tests/test_framey_official.py | 2 +- tests/test_framey_panel.py | 25 ++++++++++++++++++ ui/framey-host.js | 23 +++++++++++++--- ui/framey_panel.py | 20 +++++++++----- ui/headset.html | 2 +- ui/server.py | 2 +- 15 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 docs/evidence/framey-legacy-2026-10-25.md create mode 100644 tests/fixtures/framey-official/12.zip 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 3b11e71..3b6bd1f 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 0000000000000000000000000000000000000000..1fe4d6afe2e242d8e6ca44e7dbac7770239c98d4 GIT binary patch literal 15751 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 literal 0 HcmV?d00001 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 e43fd1e..18037e5 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,3 +111,17 @@ 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); 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 357d2fe..41faf9b 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -59,13 +59,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 => { @@ -247,15 +247,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 8c6f0ca..4003ca9 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -229,7 +229,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