Compare commits

...
Author SHA1 Message Date
SAPHID 3ee57d26eb Merge remote-tracking branch 'origin/main' into review/framey-transactions
# Conflicts:
#	docs/framey-loader.md
#	tests/page/framey_store.mjs
#	ui/frame_decky_store.py
#	ui/headset.html
2026-10-25 07:29:24 +00:00
SAPHID 4a3d2d6d0f Show Decky plugin job progress in VR 2026-10-25 07:10:03 +00:00
SAPHID a22295ff76 Browse and search the full Decky store in VR 2026-10-25 07:07:47 +00:00
SAPHID 402428e86b Merge remote-tracking branch 'origin/main' into review/framey-vr
# Conflicts:
#	docs/current-support-matrix.md
2026-10-25 07:05:45 +00:00
SAPHID 79c4f2c208 Lock Frame before worn checks and clean private captures 2026-10-25 07:04:15 +00:00
SAPHID d392687237 Record Framey VR verification limits 2026-10-25 06:52:29 +00:00
SAPHID 389c8617e8 Host Decky plugin panels in Frame Control VR 2026-10-25 06:50:20 +00:00
6 changed files with 87 additions and 16 deletions

No files matched your search

+7
View File
@@ -61,6 +61,13 @@ The root label is a compatibility warning; other APIs are checked only when a
plugin is opened. The store still needs the managed Decky user service running
before its Install or Update buttons are enabled.
Install, Update, and Remove show the current job stage in the headset panel.
The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. Refresh the installed list before starting another
operation after an error.
## For plugin authors
Publish a normal [Decky store](https://plugins.deckbrew.xyz/) API v2 release
+28 -1
View File
@@ -9,13 +9,16 @@ class Element {
append(...nodes) {this.children.push(...nodes);}
replaceChildren(...nodes) {this.children=nodes; this.textContent='';}
scrollIntoView() {}
removeAttribute(name) {delete this[name];}
}
const elements = new Map();
elements.set('deckyStoreFilter',Object.assign(new Element('deckyStoreFilter'),{value:'all'}));
const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new Element(id)); return elements.get(id);},
createElement() {return new Element();}, hidden:false};
const session = new Map();
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
sessionStorage:{getItem:()=>'',setItem(){}}, history:{replaceState(){}},
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
history:{replaceState(){}}, confirm:()=>true,
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
fetch(){throw Error('No network expected');}, console});
vm.runInContext(script,ctx);
@@ -43,3 +46,27 @@ el('deckyStoreFilter').value='compatible'; el('deckyStoreFilter').onchange();
assert.equal(el('deckyStoreSummary').textContent,'42 plugins found');
el('deckySearch').value='no matching plugin'; el('deckySearch').oninput();
assert.equal(el('deckyStoreList').textContent,'No plugins match this search.');
// The page polls the actual job endpoint and keeps a separate visible result.
ctx.mockApi=async path => {
if(path==='/api/decky/plugin')return {job:'job-1'};
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2"})',ctx);
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
assert.equal(el('deckyJobProgress').value,100);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
ctx.mockApi=async path => {
if(path==='/api/decky/plugin')return {job:'job-2'};
if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'};
if(path==='/api/decky')return {running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2"})',ctx);
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
assert.equal(session.has('frameyPluginJob'),false);
+3 -1
View File
@@ -118,10 +118,12 @@ class StoreHost(unittest.TestCase):
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
def ssh(command, **kwargs):
return '{}'
progress = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog') as network:
frame_decky_store.change(ssh, 'remove', 42)
frame_decky_store.change(ssh, 'remove', 42, lambda stage, percent: progress.append((stage, percent)))
network.assert_not_called()
self.assertEqual(progress, [('Checking installed plugins', 5), ('Removing plugin on Frame', 80)])
if __name__ == '__main__':
+7 -1
View File
@@ -162,9 +162,10 @@ finally:
'''
def change(ssh, action, plugin_id):
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0:
raise ValueError('Choose a valid store plugin action')
report('Checking installed plugins', 5)
state = frame_decky.status(ssh)
if not state['managed'] or not state['running']:
raise ValueError('Start Frame Control managed Decky before changing plugins')
@@ -175,6 +176,7 @@ def change(ssh, action, plugin_id):
folder = installed[0]['folder']
plugin = {'sha256': '0' * 64, 'version': 'removed'}
else:
report('Reading official store', 15)
plugin = next((item for item in catalog() if item['id'] == plugin_id), None)
if not plugin:
raise ValueError('Plugin is not in the official Decky store')
@@ -187,9 +189,11 @@ def change(ssh, action, plugin_id):
raise ValueError('Only an installed Frame Control store plugin can be updated')
data = None
if action != 'remove':
report('Downloading plugin archive', 30)
url = _urls()[1] + plugin['sha256'] + '.zip'
with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Frame-Control'}), timeout=35) as response:
data = response.read(MAX_ZIP + 1)
report('Verifying plugin archive', 50)
folder = archive_info(data, plugin['sha256'], plugin['name'])
if action == 'update' and installed[0]['folder'] != folder:
raise ValueError('The store changed its plugin folder; remove the old version before installing')
@@ -199,7 +203,9 @@ def change(ssh, action, plugin_id):
remote = '/tmp/frame-control-decky-plugin-' + token
try:
if data is not None:
report('Sending archive to Frame', 65)
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80)
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}',
stdin=APPLY, timeout=90))
finally:
+41 -12
View File
@@ -46,6 +46,9 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<h2>Decky plugins</h2><p>Manage official store plugins here. Decky's Quick Access Menu is absent from this VR shell. Framey can show compatible plugin panels and settings below; Steam library and store page patches are unavailable.</p>
<div class="row"><button id="deckyRefresh" class="secondary">Refresh plugins</button><button id="deckyStoreLoad" class="secondary">Open official store</button></div>
<p id="deckyState" role="status"></p><div id="deckyInstalled" class="stack"></div>
<div id="deckyJobPanel" class="block" hidden style="margin:14px 0"><strong id="deckyJobTitle"></strong>
<p id="deckyJobStatus" role="status" aria-live="polite"></p><progress id="deckyJobProgress" max="100"></progress>
<div class="row"><button id="deckyJobCheck" class="secondary" hidden>Check result</button></div></div>
<div id="deckyStorePanel" hidden><div class="row" style="margin:16px 0"><label>Find a plugin <input id="deckySearch" type="search" placeholder="Name, author or description"></label>
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">Without root requirement</option><option value="installed">Installed plugins</option></select></label></div>
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
@@ -100,7 +103,7 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null;
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, deckyBusy=false;
async function refreshDecky(){try{deckyState=await api('/api/decky');
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':'Decky is not running. Install it from Frame Control on your computer.';
const list=byId('deckyInstalled');list.replaceChildren();
@@ -109,7 +112,7 @@ async function refreshDecky(){try{deckyState=await api('/api/decky');
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
button.disabled=deckyBusy;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){byId('deckyState').textContent=error.message;}}
async function loadDeckyStore(){byId('deckyStorePanel').hidden=false;
@@ -134,18 +137,43 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
const button=document.createElement('button');button.textContent='Install';
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
button.disabled=!deckyState?.running||plugin.root;
button.disabled=deckyBusy||!deckyState?.running||plugin.root;
if(plugin.root)label.textContent+=' · Needs root; not supported on Frame';
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
async function deckyChange(action,plugin){if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
byId('deckyState').textContent=`${action} ${plugin.name}…`;
try{const job=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true});
if(job.job){let completed=false;for(let n=0;n<180;n++){await new Promise(resolve=>setTimeout(resolve,1000));
const progress=await api(`/api/job?id=${encodeURIComponent(job.job)}`);
byId('deckyState').textContent=progress.stage||progress.message||`${action} ${plugin.name}…`;
if(progress.done){if(progress.error)throw Error(progress.error);completed=true;break;}}
if(!completed)throw Error('The operation is still running. Refresh plugins to check its result.');}
await refreshDecky();}catch(error){byId('deckyState').textContent=error.message;}}
function deckyJobView(title,message,percent,failed=false){
byId('deckyJobPanel').hidden=false;byId('deckyJobTitle').textContent=title;
byId('deckyJobStatus').textContent=message;
const bar=byId('deckyJobProgress');if(Number.isFinite(percent))bar.value=percent;else bar.removeAttribute('value');
byId('deckyJobCheck').hidden=!failed||!sessionStorage.getItem('frameyPluginJob');
}
async function followDeckyJob(job,action,name){
for(let n=0;n<180;n++){
const progress=await api(`/api/job?id=${encodeURIComponent(job)}`);
if(progress.done){if(progress.error){sessionStorage.removeItem('frameyPluginJob');throw Error(progress.error);}
deckyJobView(`${action} ${name}`,progress.message||`${action} complete`,100);return;}
deckyJobView(`${action} ${name}`,progress.stage||'Waiting for Frame…',progress.percent);
await new Promise(resolve=>setTimeout(resolve,1000));
}
throw Error('Still working. Use Check result to reconnect to this job.');
}
async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
deckyBusy=true;showDeckyStore();
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,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);}
finally{deckyBusy=false;await refreshDecky();}
}
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
if(!saved||typeof saved.job!=='string')return;
deckyBusy=true;byId('deckyJobCheck').hidden=true;
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
activeFrameyId=id;byId('frameyTitle').textContent=name;
@@ -212,5 +240,6 @@ window.addEventListener('message',event=>{if(!activeExtension||event.source!==by
battery:{percent:latestStatus?.battery?.percent??null},steamvr:!!latestStatus?.services?.steamvr};
else return;event.source.postMessage({type:'frame-control:response',id:request.id,value},'*');});
refresh();refreshGames();refreshRecord();refreshExtensions();refreshDecky();
checkDeckyJob();
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
</script></body></html>
+1 -1
View File
@@ -572,7 +572,7 @@ def decky_plugin(body):
if body.get('acknowledged') is not True:
raise Failure('Confirm that store plugins run code as your Frame user', 400)
return start_job('Decky plugin ' + action,
lambda: frame_decky_store.change(ssh, action, plugin_id))
lambda report: frame_decky_store.change(ssh, action, plugin_id, report), progress=True)
def framey_method_action(body):