Compare commits

...
Author SHA1 Message Date
SAPHID 1c1d941a1c Document locked unworn Framey receipt exercise 2026-10-26 05:27:46 +00:00
SAPHID c2f94c242d Give unworn Framey sample a content receipt 2026-10-26 05:25:48 +00:00
SAPHID e6e42a93f0 Fix stale receipt e2e response assertion 2026-10-26 05:23:47 +00:00
SAPHID fa354ce365 Bind Framey panel requests to installed plugin receipt 2026-10-26 05:16:02 +00:00
Alex Southwell fcdd1f22f5 Merge pull request #299 from saphid/fix/framey-close-stale-panel
Close stale Framey panels during plugin changes
2026-10-26 05:15:21 +00:00
Alex Southwell d43e760aef Merge pull request #298 from saphid/docs/v0441-release-evidence
Document v0.4.41 tagged release verification
2026-10-26 05:12:14 +00:00
SAPHID 240288df65 Close stale Framey panels during plugin changes 2026-10-26 05:06:43 +00:00
11 changed files with 183 additions and 59 deletions

No files matched your search

@@ -0,0 +1,11 @@
# Framey receipt guard, unworn Frame exercise (26 October 2026)
The bounded `scripts/framey-vr-check.py --exercise` runner acquired `/tmp/frame-test.lock` with its own random owner before reading the real Frame. Its 05:26 UTC run used source commit `c2f94c242dac670e18259193311a7375f5a87942`. Preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. No wearer was confirmed.
The temporary Frame Control dashboard overlay became visible to OpenVR. The stereo compositor image changed, and the `/dev/video99` mirror produced a frame. The sample settings panel displayed its stored off value, accepted a trusted synthetic OpenVR pointer event, saved on through the real Decky Python backend, read the setting from disk, and showed on after a fresh iframe reopen. The quick action read on. A native slider reached 50. Text entry used CDP after OpenVR focus. The runner exited 0 with `input.passed=true` and `compositorChanged=true`.
The private capture files remain outside the repository at `/tmp/frame-pr301-unworn-vr-fixed/`. The before/after stereo SHA-256 values were `1683bf1dab70c1a0a1e8f313c8317dc04f992070b5b0de232de9008700130711` and `684a27eecf7bcbbc5308092ddbb1517c4596fb283e061e73dd6bb7f025954e8b`; the mirror frame SHA-256 was `5939149ea9c85fd2d86d3f8879c348f006c421a386bd66a93fe`. Room pixels are not committed.
An earlier locked run at 05:24 UTC exposed a missing SHA-256 in the runner's temporary sample receipt: the new guard correctly asked to reopen the panel. Commit `c2f94c2` gives that sample a content digest; the second run passed. Both runs reported no cleanup errors, restored `power.pauseCompositorOnStandby=false` and `power.turnOffScreensTimeout=5`, removed their power recovery timers, and released only their own locks. Battery remained 99%.
This is an **unworn sample-plugin result**. It does not prove wearer-visible pixels, physical controller input, native scrolling (the attempted scroll offset stayed 0), VR keyboard behavior, comfort, or any official Decky plugin's VR panel or backend. The hourly worn check remains enabled.
+9
View File
@@ -177,6 +177,15 @@ the store and review the new version before retrying. If Frame Control restarts,
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
An open plugin panel closes before its own Update or Remove starts, so its old
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.
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.
## For plugin authors
Start with the [Framey settings sample](../examples/framey-settings/README.md)
+5 -2
View File
@@ -210,7 +210,7 @@ def main(argv=None):
files = {name: base64.b64encode((ROOT/'examples/framey-settings'/name).read_bytes()).decode()
for name in ('plugin.json', 'main.py', 'dist/index.js')}
source = '''
import base64,json,pathlib
import base64,hashlib,json,pathlib
base=pathlib.Path.home()/'.local/share/frame-control/decky'
folder=%r
p=base/'plugins'/folder
@@ -219,7 +219,10 @@ p.mkdir(parents=True); (p/'dist').mkdir()
for name,data in %r.items(): (p/name).write_bytes(base64.b64decode(data))
manifest=json.loads((p/'plugin.json').read_text());manifest['name']='Framey VR Check '+folder
(p/'plugin.json').write_text(json.dumps(manifest))
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check'}))
digest=hashlib.sha256()
for name in ('plugin.json','main.py','dist/index.js'):
digest.update(name.encode()+b'\\0'+(p/name).read_bytes())
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check','sha256':digest.hexdigest()}))
print(json.dumps({'created':True}))
''' % (folder, files)
remote.python(source)
+16 -8
View File
@@ -112,25 +112,28 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
receipt = HASHES[0]
def framey_call(body):
return ok('POST', '/api/framey/method', dict(body, receipt=receipt))
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
headers={'X-Frame-UI':''})
self.assertEqual(status, 403)
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
self.assertEqual(status, 400)
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
self.assertTrue(ok('POST', '/api/framey/method',
self.assertTrue(framey_call(
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
'legacy':True})['result'], {'success':True,'result':'light'})
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
'legacy':True})['result'], {'success':True,'result':True})
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
StoreFixture.release = 1
@@ -138,7 +141,12 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
{'action':'update','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
self.assertEqual(ok('POST', '/api/framey/method',
status, stale_reply, _ = api('POST', '/api/framey/method',
{'id':999,'receipt':receipt,'method':'get_setting','args':['theme','dark']})
self.assertEqual(status, 400)
self.assertIn('changed', str(stale_reply))
receipt = HASHES[1]
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':999,'acknowledged':True}))
@@ -151,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
stale = finished(ok('POST', '/api/decky/plugin',
{'action':'update','id':999,'sha256':HASHES[0],
+1 -1
View File
@@ -19,7 +19,7 @@ const parent=vm.createContext({document:{getElementById:el,createElement:()=>({}
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
vm.runInContext(parentSource,parent);
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
vm.runInContext('deckyState={running:true,plugins:[{storeId:14,sha256:"a",version:"1"}]};openFrameyPanel(14,"Steam Status")',parent);
let session=vm.runInContext('frameySession',parent);
const firstURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
const onParent=parentListeners.find(x=>x.type==='message').fn;
+4 -1
View File
@@ -15,7 +15,7 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
setTimeout(){},setInterval(){},console,
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
vm.runInContext(source,context);
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
let session=vm.runInContext('frameySession',context);
const onMessage=listeners.find(x=>x.type==='message').fn;
const send=(data,origin=frame)=>onMessage({source:origin,data});
@@ -28,6 +28,7 @@ const first=send(subscription);
assert.equal(requests.length,1);
assert.equal(requests[0].path,'/api/framey/events');
assert.equal(requests[0].body.id,96);
assert.equal(requests[0].body.receipt,'a'.repeat(64));
assert.equal(requests[0].body.session,session);
// WindowProxy survives navigation: stale same-plugin replies need a session check.
vm.runInContext('openFrameyPanel(96,"Timer")',context);
@@ -35,6 +36,7 @@ const secondSession=vm.runInContext('frameySession',context);
assert.notEqual(secondSession,session);
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
assert.equal(requests[1].body.session,session);
assert.equal(requests[1].body.receipt,'a'.repeat(64));
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
await first;assert.equal(replies.length,0);
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
@@ -59,6 +61,7 @@ await send(steamRead,{});
assert.equal(requests.length,initialCount);
const read=send(steamRead);
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
await read;assert.equal(replies.at(-1).result,'AU');
const staleRead=send({...steamRead,id:11});
+48
View File
@@ -68,6 +68,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
el('deckySearch').value='';el('deckyStoreFilter').value='all';
vm.runInContext('showDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
@@ -97,6 +98,7 @@ await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".re
assert.equal(changes,1);
assert.equal(session.has('frameyPluginJob'),true);
assert.equal(el('deckyJobCheck').hidden,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
@@ -111,6 +113,7 @@ ctx.mockApi=async path => {
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,false);
@@ -196,3 +199,48 @@ await staleFailure;
assert.match(el('deckyInstalled').children[0].children[0].textContent,/Current/);
assert.doesNotMatch(el('deckyState').textContent,/offline/);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
// A panel must not keep an old frontend after the installed receipt changes.
let installedHash='a'.repeat(64);
ctx.mockApi=async path => {
assert.equal(path,'/api/decky');
return {managed:true,running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0',sha256:installedHash}]};
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
assert.equal(el('frameyPanel').hidden,false);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,false,'unchanged receipt keeps the current panel');
installedHash='b'.repeat(64);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.equal(el('frameyFrame').src,undefined);
assert.match(el('deckyState').textContent,/old plugin panel was closed/);
// The panel closes before updating its own backend, and stale Open handlers
// cannot recreate it while a job is active.
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
assert.equal(el('frameyPanel').hidden,false);
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){
assert.equal(el('frameyPanel').hidden,true);
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
assert.equal(el('frameyPanel').hidden,true);
return {job:'job-update'};
}
if(path.startsWith('/api/job'))return {done:true,message:'Updated Plugin 2'};
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'2.0',sha256:'c'.repeat(64)}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"c".repeat(64)})',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.match(el('deckyJobStatus').textContent,/Old plugin panel closed; reopen it/);
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
ctx.mockApi=async()=>{throw Error('offline');};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
+32 -20
View File
@@ -18,11 +18,13 @@ import framey_panel
import frame_headset
import server
RECEIPT = 'a' * 64
class Panel(unittest.TestCase):
def test_steam_reads_are_allowlisted_and_installed_only(self):
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck','sha256':RECEIPT}]}):
for method, expression in framey_panel.STEAM_READS.items():
def fake_ssh(command, stdin, **_):
self.assertEqual(command, 'python3 -')
@@ -30,13 +32,16 @@ class Panel(unittest.TestCase):
self.assertIn(expression.replace('\n', r'\n'), stdin)
self.assertNotIn('__FRAMEY_STEAM_READ__', stdin)
return '{"result":"actual read"}'
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method)['result'], 'actual read')
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method,RECEIPT)['result'], 'actual read')
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
(81,[]), (81,'GetIPCountry();alert(1)')]:
with self.subTest(plugin_id=plugin_id, method=method):
with self.assertRaises(framey_panel.PanelError):
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method,RECEIPT)
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
framey_panel.read_steam(lambda *a,**k:self.fail('Stale read reached SSH'),
81,'GetIPCountry','b'*64)
def test_steam_read_response_limit_and_socket_cleanup(self):
from types import SimpleNamespace
@@ -86,19 +91,19 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
self.assertEqual(json.loads(ast.literal_eval(assignment.value.args[0]))['args'],args)
return '{"result":null}'
with patch.object(framey_panel.frame_decky,'status',return_value={
'plugins':[{'storeId':53,'name':'Shotty'}]}):
'plugins':[{'storeId':53,'name':'Shotty','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,
53,'set_id_map_fronend',args,True),{'result':None})
53,'set_id_map_fronend',args,True,RECEIPT),{'result':None})
with self.assertRaisesRegex(framey_panel.PanelError,'too large'):
framey_panel.call_method(lambda *a,**k:self.fail('Unexpected SSH'),
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True)
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True,RECEIPT)
def test_steam_reads_have_the_headset_service_boundary(self):
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry','receipt':RECEIPT}),{'result':'AU'})
read.assert_called_once_with(server.ssh,81,'GetIPCountry',RECEIPT)
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
@@ -117,8 +122,11 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
self.assertIn('loader/plugin_event', stdin)
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
'plugins':[{'storeId':96,'name':'Simple Timer','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5, RECEIPT)['cursor'], 6)
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
framey_panel.poll_events(lambda *a,**k:self.fail('Stale poll reached SSH'),
96,'test-123.45',['timer:tick'],5,'b'*64)
for plugin_id, session, events, cursor in [
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
@@ -129,7 +137,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
with self.assertRaises(framey_panel.PanelError):
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
plugin_id, session, events, cursor)
plugin_id, session, events, cursor, RECEIPT)
def test_event_action_has_same_headset_boundary_as_methods(self):
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
@@ -137,8 +145,8 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
server.framey_events_action({'id':96,'session':'test','events':['tick']})
with patch.object(server, 'HEADSET_RUNTIME', True), \
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick'],'receipt':RECEIPT})['events'], [])
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0, RECEIPT)
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
@@ -220,12 +228,12 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
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),
'plugins':[{'storeId':14,'name':'Legacy','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True, RECEIPT),
{'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)
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy, RECEIPT)
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
manifest = self.folder/'plugin.json'
@@ -255,11 +263,15 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
self.assertNotIn('__FRAMEY_METHOD_PAYLOAD__',stdin)
return json.dumps({'result':{'theme':'dark'}})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
'plugins':[{'storeId':14,'name':'ProtonDB Badges','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark'],receipt=RECEIPT),
{'result':{'theme':'dark'}})
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[],receipt=RECEIPT)
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[],receipt=RECEIPT)
for stale in (None, 'b' * 64, 'invalid'):
with self.subTest(stale=stale), self.assertRaisesRegex(framey_panel.PanelError, 'receipt|changed'):
framey_panel.call_method(lambda *a, **k:self.fail('Stale receipt reached SSH'),
14,'get_setting',[],receipt=stale)
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
def test_headset_bundle_contains_runtime_and_licenses(self):
+20 -14
View File
@@ -11,6 +11,7 @@ import frame_decky
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
RECEIPT = re.compile(r'[0-9a-f]{64}\Z')
MODULE_PATH = re.compile(r'[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.js\Z|index\.js\Z')
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
@@ -139,7 +140,18 @@ finally: page.sock.close()
'''
def call_method(ssh, plugin_id, method, args, legacy=False):
def _current_plugin(ssh, plugin_id, receipt):
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
raise PanelError('Reopen the plugin panel to refresh its store receipt')
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')
if found[0].get('sha256') != receipt:
raise PanelError('The plugin changed; close and reopen its panel')
return found[0]
def call_method(ssh, plugin_id, method, args, legacy=False, receipt=None):
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):
@@ -148,10 +160,8 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
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, 'legacy':legacy}
plugin = _current_plugin(ssh, plugin_id, receipt)
payload = {'name':plugin['name'], 'method':method, 'args':args, 'legacy':legacy}
# Library-sized arguments can exceed the OS limit for a single SSH command
# argument. Send them as a quoted Python literal on stdin instead.
script = METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__', repr(json.dumps(payload)))
@@ -192,15 +202,13 @@ finally: page.sock.close()
'''
def read_steam(ssh, plugin_id, method):
def read_steam(ssh, plugin_id, method, receipt=None):
"""Read one explicitly supported Steam API for an installed store panel."""
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 method not in STEAM_READS:
raise PanelError('This Steam API is unavailable in Framey')
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')
_current_plugin(ssh, plugin_id, receipt)
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
@@ -219,7 +227,7 @@ finally: page.sock.close()
'''
def poll_events(ssh, plugin_id, session, events, cursor=0):
def poll_events(ssh, plugin_id, session, events, cursor=0, receipt=None):
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
if type(plugin_id) is not int or plugin_id <= 0:
raise PanelError('Choose an installed Decky store plugin')
@@ -230,10 +238,8 @@ def poll_events(ssh, plugin_id, session, events, cursor=0):
raise PanelError('Invalid Decky event names')
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
raise PanelError('Invalid Decky event cursor')
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'], 'session':session, 'events':events, 'cursor':cursor}
plugin = _current_plugin(ssh, plugin_id, receipt)
payload = {'name':plugin['name'], 'session':session, 'events':events, 'cursor':cursor}
# Embed only our bundled source as a Python string; plugin data stays in JSON.
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
+33 -10
View File
@@ -108,25 +108,34 @@ function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
deckyStatusRequest=0,
activeFrameyId=null, frameySession=null, deckyBusy=false;
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
async function refreshDecky(){const request=++deckyStatusRequest;try{const state=await api('/api/decky');
if(request!==deckyStatusRequest)return;
deckyState=state;
const active=(state.plugins||[]).find(p=>p.storeId===activeFrameyId);
const panelStale=activeFrameyId!==null&&(!state.running||!active||
active.sha256!==activeFrameyReceipt?.sha256||active.version!==activeFrameyReceipt?.version);
if(panelStale)closeFrameyPanel();
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
'Decky is not running. Install it from Frame Control on your computer.';
if(panelStale)byId('deckyState').textContent+=' The old plugin panel was closed; reopen it after refreshing.';
const list=byId('deckyInstalled');list.replaceChildren();
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
open.disabled=deckyBusy||deckyJobPending()||!deckyState.running;
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
button.disabled=deckyBusy||deckyJobPending();button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
showDeckyStore();}catch(error){if(request!==deckyStatusRequest)return;
const panelClosed=activeFrameyId!==null;
if(panelClosed)closeFrameyPanel();
deckyState=null;
byId('deckyState').textContent=`Installed plugins unavailable: ${error.message}`;
if(panelClosed)byId('deckyState').textContent+=' The plugin panel was closed until status can be refreshed.';
byId('deckyInstalled').replaceChildren();
byId('deckyInstalled').textContent='Refresh to check installed plugins.';
showDeckyStore();}}
@@ -191,14 +200,20 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
}
if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
deckyBusy=true;showDeckyStore();
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
const panelClosed=(action==='update'||action==='remove')&&activeFrameyId===plugin.id;
const panelNote=panelClosed?(action==='remove'?' Plugin panel closed after removal.':
' Old plugin panel closed; reopen it after refreshing installed plugins.') : '';
if(panelClosed)closeFrameyPanel();
deckyJobView(`${action} ${plugin.name}`,
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
if(!started.job)throw Error('The server did not return a job ID');
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
await followDeckyJob(started.job,action,plugin.name);
sessionStorage.removeItem('frameyPluginJob');
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}`,null,true);}
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}
}
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
@@ -210,10 +225,12 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
byId('frameyCompatibility').textContent=compatibilityText(installed);
closeFrameyEvents();
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
byId('frameyPanelStatus').textContent='Loading plugin panel…';
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
@@ -223,10 +240,13 @@ function openFrameyPanel(id,name){
let frameyEventsActive=false;
function closeFrameyEvents(){
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
api('/api/framey/events',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
session:frameySession,events:[],cursor:0}).catch(()=>{});
frameyEventsActive=false;
}
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
byId('frameyClose').onclick=closeFrameyPanel;
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
@@ -267,9 +287,11 @@ window.addEventListener('message',async event=>{
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
reply={type:'framey:reply',id:req.id};
frameyEventsActive=req.args[0].length>0;
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
try{reply.result=await api('/api/framey/events',{id:pluginId,receipt,session,
events:req.args[0],cursor:req.args[1]});}
catch(error){reply.error=error.message;}
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
event.source.postMessage(reply,'*');
@@ -278,9 +300,10 @@ window.addEventListener('message',async event=>{
if(!['framey:call','framey:steam'].includes(req.type)||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
const steamRead=req.type==='framey:steam';
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
reply={type:'framey:reply',id:req.id};
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
{id:pluginId,receipt,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
catch(error){reply.error=error.message;}
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
});
+4 -3
View File
@@ -581,7 +581,8 @@ 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'), body.get('legacy', False))
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'),
body.get('legacy', False), body.get('receipt'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc
@@ -590,7 +591,7 @@ def framey_steam_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.read_steam(ssh, body.get('id'), body.get('method'))
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'), body.get('receipt'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc
@@ -600,7 +601,7 @@ def framey_events_action(body):
raise Failure('Decky plugin panels run in the headset service', 400)
try:
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
body.get('events'), body.get('cursor', 0))
body.get('events'), body.get('cursor', 0), body.get('receipt'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc