mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 15:00:25 +01:00
* Host Decky plugin panels in Frame Control VR * Record Framey VR verification limits * Lock Frame before worn checks and clean private captures * Browse and search the full Decky store in VR * Show Decky plugin job progress in VR --------- Co-authored-by: SAPHID <saphid@users.noreply.github.com>
246 lines
22 KiB
HTML
246 lines
22 KiB
HTML
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Frame Control VR [fc-headset]</title>
|
|
<style>
|
|
:root{color-scheme:dark;font:22px/1.4 system-ui,sans-serif;background:#0d1925;color:#f3f7fa}
|
|
*{box-sizing:border-box}body{margin:0;padding:30px;max-width:1280px}h1{font-size:40px;margin:0 0 6px}
|
|
h2{font-size:29px;margin:25px 0 10px}p{margin:0 0 18px;color:#b9c9d6}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
|
.card,.block{background:#1b3042;border:1px solid #40576c;border-radius:14px;padding:20px}.card{min-height:140px}
|
|
.label{display:block;color:#b9c9d6;font-size:19px}.value{display:block;font-size:34px;font-weight:700;margin-top:10px}
|
|
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.stack{display:grid;gap:12px}
|
|
button{font:inherit;background:#23618c;color:white;border:1px solid #72baf0;border-radius:12px;padding:13px 20px;cursor:pointer;min-height:62px}
|
|
button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}button:disabled{opacity:.55;cursor:default}
|
|
button.secondary{background:#253c50;border-color:#617f97}button.danger{background:#78372d;border-color:#d78d79}
|
|
small{color:#b9c9d6}#message,#actionStatus{min-height:1.5em;margin:12px 0}.game{display:flex;gap:14px;justify-content:space-between;align-items:center}
|
|
iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid #617f97;border-radius:12px;margin-top:14px}
|
|
#statusScreen[hidden]{display:none}#statusScreen{position:fixed;inset:0;z-index:10;overflow:auto;background:#07131f;padding:30px}
|
|
#statusScreen h2{font-size:clamp(40px,6vw,76px);margin:10px 0 20px}#statusScreen .cards{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
#statusScreen .card{min-height:170px}#statusScreen .value{font-size:clamp(40px,6vw,76px)}
|
|
#statusScreen .value.warn{color:#ffd27a}#statusScreen .value.bad{color:#ff9585}
|
|
#statusScreen .meta{font-size:20px;margin-top:22px;color:#b9c9d6}
|
|
@media(max-width:720px){.cards,#statusScreen .cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
|
|
</style></head><body>
|
|
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Select this panel again from the dashboard after switching away.</p>
|
|
<div class="cards">
|
|
<div class="card"><span class="label">Battery</span><span class="value" id="battery">…</span></div>
|
|
<div class="card"><span class="label">Home storage free</span><span class="value" id="storage">…</span></div>
|
|
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
|
|
</div>
|
|
<p id="message" role="status" aria-live="polite"></p>
|
|
<div class="row"><button id="refresh">Refresh status</button><button id="statusScreenOpen" class="secondary">Large status screen</button><button id="gameMode" class="secondary">Return to Game Mode</button>
|
|
<button id="screenshot" class="secondary">Save screenshot</button><button id="record" class="secondary">Start recording</button></div>
|
|
<p id="actionStatus" role="status" aria-live="polite"></p>
|
|
<section id="statusScreen" role="dialog" aria-modal="true" aria-labelledby="statusScreenTitle" hidden>
|
|
<div class="row"><button id="statusScreenClose">Close status screen</button><button id="statusScreenRefresh" class="secondary">Refresh</button></div>
|
|
<h2 id="statusScreenTitle">Frame status</h2>
|
|
<div class="cards">
|
|
<div class="card"><span class="label">Battery</span><span class="value" id="bigBattery">…</span></div>
|
|
<div class="card"><span class="label">SteamVR</span><span class="value" id="bigSteamvr">…</span></div>
|
|
<div class="card"><span class="label">Home storage free</span><span class="value" id="bigStorage">…</span></div>
|
|
<div class="card"><span class="label">Frame OS</span><span class="value" id="bigOs">…</span></div>
|
|
</div>
|
|
<p id="bigStatusMessage" class="meta" role="status" aria-live="polite">Read-only status. This view does not diagnose a crash.</p>
|
|
</section>
|
|
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
|
|
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
|
|
<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>
|
|
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
|
|
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button></div>
|
|
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
|
|
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
|
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
|
|
<div class="row"><button id="extensionsRefresh" class="secondary">Refresh extensions</button><span id="extensionsFolder"><small></small></span></div>
|
|
<div id="extensions" class="stack" style="margin-top:12px">Loading…</div>
|
|
<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);
|
|
history.replaceState(null,'',location.pathname);
|
|
const byId=id=>document.getElementById(id);
|
|
let latestStatus=null, activeExtension=null, recordBusy=false;
|
|
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
|
|
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
|
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
|
|
async function refresh(){try{const status=latestStatus=await api('/api/status');
|
|
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
|
|
const free=status.disk?.home?.free;
|
|
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
|
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
|
|
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
|
|
renderLargeStatus(status);
|
|
}catch(error){latestStatus=null;byId('message').textContent=error.message;renderLargeStatus(null,error.message);}}
|
|
function renderLargeStatus(status,error=''){
|
|
const battery=status?.battery?.percent;
|
|
byId('bigBattery').textContent=Number.isFinite(battery)?`${battery}%`:'Unavailable';
|
|
byId('bigBattery').className='value'+(Number.isFinite(battery)&&battery<20?' bad':Number.isFinite(battery)&&battery<40?' warn':'');
|
|
byId('bigSteamvr').textContent=status?.services?.steamvr?'Running':'Unavailable';
|
|
byId('bigSteamvr').className='value'+(status?.services?.steamvr?'':' bad');
|
|
const free=status?.disk?.home?.free;
|
|
byId('bigStorage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
|
byId('bigStorage').className='value'+(Number.isFinite(free)&&free<5e9?' warn':'');
|
|
byId('bigOs').textContent=status?.os?.version||'Unknown';
|
|
byId('bigStatusMessage').textContent=error||`Read-only status · Frame OS build ${status?.os?.build||'unknown'} · Updated ${new Date().toLocaleTimeString()}. This view does not diagnose a crash.`;
|
|
}
|
|
async function act(action){const button=byId(action==='game-mode'?'gameMode':'screenshot');button.disabled=true;
|
|
try{const result=await api('/api/headset/quick',{action});byId('actionStatus').textContent=result.message;}
|
|
catch(error){byId('actionStatus').textContent=error.message;}finally{button.disabled=false;}}
|
|
async function refreshGames(){const list=byId('games');try{const data=await api('/api/running');list.replaceChildren();
|
|
for(const game of data.running){const row=document.createElement('div');row.className='block game';
|
|
const name=document.createElement('strong');name.textContent=game.name||`App ${game.appid}`;
|
|
const quit=document.createElement('button');quit.className='danger';quit.textContent=`Force quit ${name.textContent}`;
|
|
quit.onclick=async()=>{if(!confirm(`Force quit ${name.textContent}? Unsaved progress may be lost.`))return;
|
|
quit.disabled=true;try{const result=await api('/api/running',{action:'quit',appid:String(game.appid)});
|
|
byId('actionStatus').textContent=result.message||`Quit ${name.textContent}`;await refreshGames();}
|
|
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, 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();
|
|
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);
|
|
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.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;
|
|
byId('deckyStoreSummary').textContent='Loading official Decky store…';
|
|
try{const data=await api('/api/decky/store');deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
|
catch(error){byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
|
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
|
const q=byId('deckySearch').value.trim().toLowerCase();
|
|
const filter=byId('deckyStoreFilter').value;
|
|
const found=deckyStore.filter(p=>(!q||`${p.name} ${p.author||''} ${p.description}`.toLowerCase().includes(q))&&
|
|
(filter==='all'||(filter==='compatible'&&!p.root)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
|
|
const size=20, pages=Math.max(1,Math.ceil(found.length/size));
|
|
deckyStoreIndex=Math.min(deckyStoreIndex,pages-1);
|
|
byId('deckyStoreSummary').textContent=`${found.length} plugin${found.length===1?'':'s'} found`;
|
|
byId('deckyStorePage').textContent=`Page ${deckyStoreIndex+1} of ${pages}`;
|
|
byId('deckyStorePrevious').disabled=deckyStoreIndex===0;
|
|
byId('deckyStoreNext').disabled=deckyStoreIndex>=pages-1;
|
|
if(!found.length)list.textContent='No plugins match this search.';
|
|
for(const plugin of found.slice(deckyStoreIndex*size,(deckyStoreIndex+1)*size)){
|
|
const row=document.createElement('div');row.className='block game';
|
|
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
|
|
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=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);}}
|
|
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;
|
|
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
|
byId('frameyFrame').src=`/framey/panel/${id}`;
|
|
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
|
|
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
|
byId('frameyPanelStatus').textContent='No compatible API v2 panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
|
}
|
|
byId('frameyClose').onclick=()=>{activeFrameyId=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
|
window.addEventListener('message',async event=>{
|
|
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
|
|
const req=event.data;if(!req||typeof req!=='object')return;
|
|
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
|
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
|
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
|
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
|
if(req.type!=='framey:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
|
const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id};
|
|
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args});reply.result=data.result;}
|
|
catch(error){reply.error=error.message;}
|
|
if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
|
});
|
|
window.addEventListener('pointerup',event=>{if(event.isTrusted)
|
|
api('/api/framey/check',{kind:'input'}).catch(()=>{});},{passive:true});
|
|
async function refreshRecord(){try{const data=await api('/api/record');
|
|
byId('record').textContent=['recording','starting'].includes(data.state)?'Stop recording':'Start recording';
|
|
byId('record').disabled=recordBusy||['stopping','saving'].includes(data.state);
|
|
if(data.state==='recording')byId('actionStatus').textContent=`Recording ${Math.round(data.elapsed||0)} s${data.audio?' with sound':' without sound'}`;
|
|
else if(data.state==='saved'||data.state==='failed')byId('actionStatus').textContent=data.message||data.error;
|
|
}catch(error){byId('record').disabled=true;byId('actionStatus').textContent=error.message;}}
|
|
async function toggleRecord(){if(recordBusy)return;recordBusy=true;byId('record').disabled=true;
|
|
try{const state=await api('/api/record');const action=state.state==='recording'?'stop':'start';
|
|
const result=await api('/api/record',{action});byId('actionStatus').textContent=result.message||result.state;}
|
|
catch(error){byId('actionStatus').textContent=error.message;}finally{recordBusy=false;refreshRecord();}}
|
|
async function refreshExtensions(){const list=byId('extensions');try{const data=await api('/api/extensions');
|
|
byId('extensionsFolder').textContent=`Folder: ${data.folder}`;list.replaceChildren();
|
|
for(const ext of data.extensions){const row=document.createElement('div');row.className='block game';
|
|
const label=document.createElement('span');label.textContent=`${ext.name} · ${ext.description}`;
|
|
const open=document.createElement('button');open.textContent=`Open ${ext.name}`;open.onclick=()=>{
|
|
activeExtension=ext.id;byId('extensionTitle').textContent=ext.name;
|
|
byId('extensionFrame').src=`/extension/${encodeURIComponent(ext.id)}/index.html`;
|
|
byId('extensionPanel').hidden=false;byId('extensionPanel').scrollIntoView();};
|
|
row.append(label,open);list.append(row);}if(!data.extensions.length)list.textContent='No extensions installed.';
|
|
if(data.errors.length){const errors=document.createElement('p');errors.textContent=data.errors.map(x=>`${x.folder}: ${x.error}`).join('; ');list.append(errors);}
|
|
}catch(error){list.textContent=error.message;}}
|
|
byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot');
|
|
byId('statusScreenOpen').onclick=()=>{byId('statusScreen').hidden=false;renderLargeStatus(latestStatus);byId('statusScreenClose').focus();};
|
|
byId('statusScreenClose').onclick=()=>{byId('statusScreen').hidden=true;byId('statusScreenOpen').focus();};
|
|
byId('statusScreenRefresh').onclick=refresh;
|
|
window.addEventListener('keydown',event=>{if(event.key==='Escape'&&!byId('statusScreen').hidden)byId('statusScreenClose').click();});
|
|
byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions;
|
|
byId('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;
|
|
byId('deckySearch').oninput=()=>{deckyStoreIndex=0;showDeckyStore();};
|
|
byId('deckyStoreFilter').onchange=()=>{deckyStoreIndex=0;showDeckyStore();};
|
|
byId('deckyStorePrevious').onclick=()=>{deckyStoreIndex--;showDeckyStore();};
|
|
byId('deckyStoreNext').onclick=()=>{deckyStoreIndex++;showDeckyStore();};
|
|
byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;};
|
|
window.addEventListener('message',event=>{if(!activeExtension||event.source!==byId('extensionFrame').contentWindow)return;
|
|
const request=event.data;if(!request||request.type!=='frame-control:request'||!Number.isSafeInteger(request.id))return;
|
|
let value;if(request.action==='capabilities')value={apiVersion:1,actions:['capabilities','status']};
|
|
else if(request.action==='status')value={connected:!!latestStatus,
|
|
os:{version:latestStatus?.os?.version||null,build:latestStatus?.os?.build||null},
|
|
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>
|