mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 13:00:33 +01:00
Merge pull request #299 from saphid/fix/framey-close-stale-panel
Close stale Framey panels during plugin changes
This commit is contained in:
5 files changed
+80
-7
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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
@@ -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))||'{}');
|
||||
|
||||
Reference in new issue
Block a user