Compare commits

...
Author SHA1 Message Date
SAPHID 240288df65 Close stale Framey panels during plugin changes 2026-10-26 05:06:43 +00:00
5 changed files with 80 additions and 7 deletions

No files matched your search

+6
View File
@@ -177,6 +177,12 @@ 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.
## For plugin authors
Start with the [Framey settings sample](../examples/framey-settings/README.md)
+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;
+1 -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",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});
+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/);
+24 -5
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();
@@ -226,7 +243,9 @@ function closeFrameyEvents(){
api('/api/framey/events',{id:activeFrameyId,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))||'{}');