Merge pull request #347 from saphid/fix/framey-store-memory-job

Keep Framey store job recoverable without session storage
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-10-27 00:03:01 +00:00
commit e9844473ea
3 files changed
+74 -11

No files matched your search

+4
View File
@@ -204,6 +204,10 @@ browser session, so **Check result** can resume polling after a navigation or
temporary connection error. After a polling error, Install, Update, and Remove
stay disabled while the saved job's result is unknown; use **Check result**
before starting another change.
If Chromium denies session storage, the open panel keeps the accepted job ID in
memory and **Check result** still works after a connection error. A page reload
loses that memory fallback; wait for the old job to finish and refresh installed
status before starting another change.
Refresh the installed list before starting another
operation after an error. The headset panel and desktop app send the hash of
the version you saw; if the official store changes that version before the job
+43
View File
@@ -244,3 +244,46 @@ 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/);
// A denied sessionStorage must not lose an accepted job in the current panel.
// Keep Update/Remove disabled after a polling outage and allow Check result.
for(const method of ['getItem','setItem','removeItem'])
ctx.sessionStorage[method]=()=>{throw Error('storage denied');};
let accepted=0;
ctx.mockApi=async path => {
if(path==='/api/decky/plugin'){accepted++;return {job:'memory-job'};}
if(path.startsWith('/api/job'))throw Error('connection lost');
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi;deckyState={managed:true,running:true,plugins:[]};' +
'deckyStore=[{id:2,name:"Plugin 2",version:"1.0",description:"A panel",root:false}];showDeckyStore()',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(accepted,1);
assert.equal(vm.runInContext('deckyJobPending()',ctx),true);
assert.equal(el('deckyJobCheck').hidden,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
assert.equal(accepted,1);
ctx.mockApi=async path => {
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(vm.runInContext('deckyJobPending()',ctx),false);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
// Opening the headset panel with a key in its URL must survive denied storage.
const deniedDocument={getElementById:id=>new Element(id),createElement:()=>new Element()};
const deniedBoot=vm.createContext({document:deniedDocument,
location:{hash:'#private-key',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem(){throw Error('storage denied');},setItem(){throw Error('storage denied');}},
window:{addEventListener(){}},setInterval(){},setTimeout(){},confirm:()=>true,console,
fetch(){throw Error('No network expected');}});
vm.runInContext(script,deniedBoot);
assert.equal(vm.runInContext('key',deniedBoot),'private-key');
+27 -11
View File
@@ -65,8 +65,8 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<div id="extensionPanel" hidden><div class="row" style="margin-top:16px"><strong id="extensionTitle"></strong><button id="extensionClose" class="secondary">Close extension</button></div>
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div>
<script>
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
if(key)sessionStorage.setItem('frameHeadsetKey',key);
const key=location.hash.slice(1)||(()=>{try{return sessionStorage.getItem('frameHeadsetKey')||'';}catch(_){return '';}})();
if(key)try{sessionStorage.setItem('frameHeadsetKey',key);}catch(_){}
history.replaceState(null,'',location.pathname);
const byId=id=>document.getElementById(id);
let latestStatus=null, activeExtension=null, recordBusy=false;
@@ -107,7 +107,23 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
// Some Chromium profiles deny sessionStorage. Keep the accepted job ID in
// memory so this open panel can still reconnect instead of offering a second
// install while the first one may be running.
let deckyPendingJob=null;
function savedDeckyJob(){
if(deckyPendingJob)return deckyPendingJob;
try{return JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){return null;}
}
function rememberDeckyJob(job){
deckyPendingJob=job;
try{sessionStorage.setItem('frameyPluginJob',JSON.stringify(job));}catch(_){}
}
function clearDeckyJob(){
deckyPendingJob=null;
try{sessionStorage.removeItem('frameyPluginJob');}catch(_){}
}
function deckyJobPending(){return !!savedDeckyJob();}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
deckyStatusRequest=0,
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
@@ -175,7 +191,7 @@ 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');
byId('deckyJobCheck').hidden=!failed||!deckyJobPending();
}
async function followDeckyJob(job,action,name){
for(let n=0;n<180;n++){
@@ -183,12 +199,12 @@ async function followDeckyJob(job,action,name){
try{progress=await api(`/api/job?id=${encodeURIComponent(job)}`);}
catch(error){
if(/no such job/i.test(error.message)){
sessionStorage.removeItem('frameyPluginJob');
clearDeckyJob();
throw Error('The app restarted and lost this job result. Refresh installed plugins to see what changed.');
}
throw error;
}
if(progress.done){if(progress.error){sessionStorage.removeItem('frameyPluginJob');throw Error(progress.error);}
if(progress.done){if(progress.error){clearDeckyJob();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));
@@ -211,18 +227,18 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
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}));
rememberDeckyJob({job:started.job,action,name:plugin.name});
await followDeckyJob(started.job,action,plugin.name);
sessionStorage.removeItem('frameyPluginJob');
clearDeckyJob();
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;}
async function checkDeckyJob(){const saved=savedDeckyJob();
if(deckyBusy)return;
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');showDeckyStore();return;}
if(!saved||typeof saved.job!=='string'){clearDeckyJob();showDeckyStore();return;}
deckyBusy=true;byId('deckyJobCheck').hidden=true;
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
try{await followDeckyJob(saved.job,saved.action,saved.name);clearDeckyJob();}
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;