Compare commits

...
Author SHA1 Message Date
SAPHID e529d60554 Merge remote-tracking branch 'origin/main' into fix/pr261-main-sync
# Conflicts:
#	tests/page/framey_actions.mjs
2026-10-25 23:16:34 +00:00
Alex Southwell 63820d1909 Merge pull request #256 from saphid/feat/framey-steamgriddb
Framey: support local plugin routes with parameters
2026-10-25 23:12:51 +00:00
SAPHID 1aaa3c8c06 Support legacy Decky panels and keyword backend calls 2026-10-25 23:12:28 +00:00
15 changed files with 130 additions and 18 deletions

No files matched your search

+24
View File
@@ -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.
+3 -1
View File
@@ -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 |
+4 -1
View File
@@ -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.
+10 -1
View File
@@ -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)
@@ -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') {
Binary file not shown.
+2
View File
@@ -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),
+1
View File
@@ -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,
+14 -1
View File
@@ -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);
+1 -1
View File
@@ -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']}))
+25
View File
@@ -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}))
+19 -4
View File
@@ -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})));
+14 -6
View File
@@ -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))
+1 -1
View File
@@ -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,'*');
});
+1 -1
View File
@@ -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