diff --git a/docs/framey-loader.md b/docs/framey-loader.md index ab0c19e..d872f24 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -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) diff --git a/tests/page/framey_cache.mjs b/tests/page/framey_cache.mjs index 838e2fa..c88a38b 100644 --- a/tests/page/framey_cache.mjs +++ b/tests/page/framey_cache.mjs @@ -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; diff --git a/tests/page/framey_event_parent.mjs b/tests/page/framey_event_parent.mjs index f00d11f..42f7e73 100644 --- a/tests/page/framey_event_parent.mjs +++ b/tests/page/framey_event_parent.mjs @@ -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}); diff --git a/tests/page/framey_store.mjs b/tests/page/framey_store.mjs index 210e6b0..8a24c3f 100644 --- a/tests/page/framey_store.mjs +++ b/tests/page/framey_store.mjs @@ -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/); diff --git a/ui/headset.html b/ui/headset.html index 01278f9..3350a25 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -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))||'{}');