Compare commits

...
Author SHA1 Message Date
SAPHID 45bd789cb7 Reconcile Framey cache bridge with session shim 2026-10-25 21:21:13 +00:00
SAPHID 1db65d6c82 Merge remote-tracking branch 'origin/main' into feat/framey-plugin-cache 2026-10-25 21:20:22 +00:00
Alex Southwell 1e69ca4194 Merge pull request #244 from saphid/fix/framey-session-cache
Give isolated Framey panels a session cache
2026-10-25 21:17:49 +00:00
SAPHID 2b2d72ac9a Persist bounded Framey plugin cache across panel reopen 2026-10-25 21:12:30 +00:00
SAPHID fe4e09032d Provide session cache to isolated Framey panels 2026-10-25 21:09:45 +00:00
Alex Southwell 1356ea4df1 Merge pull request #243 from saphid/docs/framey-steam-status-browser
Record local Steam Status Framey frontend result
2026-10-25 11:04:33 +00:00
SAPHID c79ba7571f Record local Steam Status Framey frontend check 2026-10-25 10:55:46 +00:00
Alex Southwell 76dcd0c426 Merge pull request #242 from saphid/release/v0.4.34
Prepare Frame Control v0.4.34
2026-10-25 10:43:24 +00:00
11 changed files with 237 additions and 7 deletions

No files matched your search

@@ -0,0 +1,19 @@
# Framey cache check, 25 October 2026
The official Steam Status 1.2.3 ZIP pinned in
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
allow-popups"`, so its native storage remained unavailable. The new Framey
storage facade accepted a set/get probe without a `SecurityError`. Steam
Status rendered and issued four `get_settings` calls to a stub parent backend.
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
snapshot and the plugin wrote the same cache key again. The browser log had
zero `SecurityError` or `localStorage` errors.
The parent was a local test page with in-memory Decky backend replies. One
public status fetch also failed in the test browser, so this check does not
establish offline correctness or the freshness of Steam Status data. The
per-plugin save/reopen protocol, stale panel session rejection, and cache
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
live Decky settings, VR visibility, and controller input remain unverified.
@@ -0,0 +1,26 @@
# Framey session cache browser check, 25 October 2026
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
used a per-panel memory substitute. It did **not** change the iframe's sandbox
flags or give plugin code Frame Control's UI key.
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
from `tests/fixtures/framey-official/123.zip` (SHA-256
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
four `get_settings` calls. That reply was a stub, not the Decky backend.
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
The parent could not read the iframe document (`contentDocument` was null).
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
Managers and Players Online. Its public status request returned HTTP 200.
No cache `SecurityError` appeared in the captured console, while the
same archive had logged that error with the v0.4.34 host.
Node host tests also checked item updates, removal, clear, key order, string
conversion, quota rejection, and preservation of an existing item after a
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
values **in memory**. Closing or reloading the panel loses this cache. This
check did not test offline behavior, persistent plugin settings, a live Decky
backend, SteamVR visibility, controller input, or headset performance.
@@ -0,0 +1,28 @@
# Steam Status in the local Framey browser, 25 October 2026
This check used the unmodified official Steam Status 1.2.3 archive already
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
The archive declares Decky API v2. No headset was contacted for this check.
On a Linux PC, we extracted the archive to a disposable plugin folder with a
Frame Control store receipt. A local HTTP server served the page from
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
`framey-host.js`. A parent page put the panel in an iframe with
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
backend calls. This was a **stub**, not the Decky service.
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
request returned HTTP 200 and populated the status view. A click on **Refresh**
sent Framey's trusted pointer event to the parent; this test did not prove a
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
contains only the local test page and public service data.
The plugin logged `SecurityError` while reading and writing `localStorage` in
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
and offline behavior in Framey are **not verified**. The real Decky backend,
live settings, SteamVR visibility, controller input and headset performance
were not tested. This result supports only local frontend rendering for this
exact archive and Frame Control v0.4.34 host code.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+6
View File
@@ -62,6 +62,7 @@ none of these frontend controls has passed a worn-headset check yet.
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
@@ -71,3 +72,8 @@ Keep routes local to the panel and give every operation a visible result. A
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
Frame Control store; only paths registered by that same plugin can navigate.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
Use the Decky Python backend for settings that must survive browser cache loss.
The Framey `localStorage` bridge is for disposable frontend caches, including
the cache read attempted by Steam Status 1.2.3. It does not expose another
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
and a worn-headset settings check are still needed.
+15
View File
@@ -37,6 +37,12 @@ through Decky's *existing* websocket connection. Framey never opens a second
Decky websocket, because Decky allows only one frontend connection. The iframe
does not receive Frame Control's UI key. React and ReactDOM are bundled for
offline use under their MIT licenses in `ui/vendor`.
Framey gives each plugin a bounded `localStorage` substitute because browser
storage is denied to an opaque-origin iframe. The parent panel keeps a separate
cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It
restores the cache when a panel reopens if the parent's browser storage is
available. If storage is denied or cleared, the cache lasts only for the current
panel. Use the plugin backend for settings that must survive cache loss.
The host currently supplies sections, rows, buttons, toggles, text fields,
sliders, grouped dropdowns,
@@ -55,6 +61,15 @@ live Decky settings persistence, or badges on Steam game pages. The last item
has no supported injection target in the Frame Control panel.
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
records exactly what was exercised.
The pinned official Steam Status 1.2.3 frontend also
[rendered in local Chromium](evidence/framey-steam-status-browser-2026-10-25.md)
with a stubbed settings reply. Its public status request worked, but its cache
logged `localStorage` errors in Framey's v0.4.34 opaque iframe. A later
[local browser check](evidence/framey-session-cache-2026-10-25.md) confirmed
that a panel-lifetime substitute avoids those errors while preserving the
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
tested the per-plugin cache after reopening a panel. These checks used a stub
backend and no worn headset.
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4
and Steam Status 1.2.3 archives, checks their API v2 manifests and frontend
bytes, and verifies they can be packed as Framey panels. It does not execute
+4 -3
View File
@@ -85,9 +85,10 @@ assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav'
// API v2 backend calls remain request-scoped and errors return to the caller.
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
const reply=api.call('get_setting','mode','default');
assert.equal(messages[0].method,'get_setting');
assert.deepEqual(Array.from(messages[0].args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:messages[0].id,result:'dark'}});
const call=messages.find(message=>message.type==='framey:call');
assert.equal(call.method,'get_setting');
assert.deepEqual(Array.from(call.args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:call.id,result:'dark'}});
assert.equal(await reply,'dark');
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
const parentSource=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const hostSource=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.match(hostSource,/framey:cache:init/);
const values=new Map();
const localStorage={getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value)};
const elements=new Map();
const el=id=>{if(!elements.has(id))elements.set(id,{id,hidden:false,removeAttribute(){},scrollIntoView(){}});
return elements.get(id);};
const parentListeners=[];
const parent=vm.createContext({document:{getElementById:el,createElement:()=>({})},
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage,
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);
let session=vm.runInContext('frameySession',parent);
const onParent=parentListeners.find(x=>x.type==='message').fn;
function iframe(){
const listeners=[];const queued=[];let live=false;
const frameWindow={location:{hash:`#${session}`},React:{Fragment:'fragment',Component:class {},createElement:()=>({}),isValidElement:()=>true},
ReactDOM:{createRoot:()=>({render(){}})}};
const proxy={postMessage:message=>listeners.find(x=>x.type==='message')?.fn({source:hostParent,data:message})};
el('frameyFrame').contentWindow=proxy;
const hostParent={postMessage:message=>live?onParent({source:proxy,data:message}):queued.push(message)};
const context=vm.createContext({window:frameWindow,document:{getElementById:()=>({})},
navigator:{language:'en'},parent:hostParent,DOMException,
addEventListener:(type,fn)=>listeners.push({type,fn}),setTimeout(){},clearTimeout(){}});
vm.runInContext(hostSource,context);
for(const message of queued)onParent({source:proxy,data:message});
live=true;
const send=message=>onParent({source:proxy,data:message});
return {storage:frameWindow.localStorage,send};
}
let first=iframe();
assert.equal(first.storage.getItem('status-cache'),null);
first.storage.setItem('status-cache','{"online":true}');
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
first.storage.setItem('temporary','discard');
first.storage.removeItem('temporary');
const oldSession=session;
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
session=vm.runInContext('frameySession',parent);
assert.notEqual(session,oldSession);
let reopened=iframe();
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
assert.equal(reopened.storage.length,1);
assert.equal(reopened.storage.key(0),'status-cache');
assert.throws(()=>reopened.storage.setItem('huge','x'.repeat(65537)),/Framey plugin cache is full/);
// A malformed or over-limit message from a plugin must not poison saved data.
reopened.send({type:'framey:cache:save',session,entries:{huge:'x'.repeat(65537)}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
reopened.storage.clear();
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
+5
View File
@@ -20,6 +20,11 @@ import server
class Panel(unittest.TestCase):
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_vr_store_browsing_and_search(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_store.mjs')],
capture_output=True, text=True, timeout=10)
+42
View File
@@ -21,6 +21,36 @@
let currentRoute = null;
let nextId = 0;
let plugin;
// A sandboxed iframe has an opaque origin, so its native localStorage getter
// throws SecurityError. Keep the familiar synchronous API inside the iframe;
// the parent stores only this plugin's bounded snapshot under its own origin.
const cache = new Map();
const cacheLimit = 128 * 1024;
const cacheSession = window.location?.hash.slice(1) || '';
let acceptSeed;
const seeded = new Promise(resolve => {acceptSeed = resolve;});
const storage = {
get length() {return cache.size;},
key(index) {return [...cache.keys()][index] ?? null;},
getItem(key) {return cache.get(String(key)) ?? null;},
setItem(key, value) {
const name = String(key), data = String(value);
const next = new Map(cache); next.set(name, data);
if (next.size > 64 || name.length > 256 || data.length > 65536 ||
JSON.stringify(Object.fromEntries(next)).length > cacheLimit)
throw new DOMException('Framey plugin cache is full', 'QuotaExceededError');
cache.set(name, data); persistCache();
},
removeItem(key) {cache.delete(String(key)); persistCache();},
clear() {cache.clear(); persistCache();},
};
function persistCache() {
parent.postMessage({type:'framey:cache:save', session:cacheSession,
entries:Object.fromEntries(cache)}, '*');
}
Object.defineProperty(window, 'localStorage', {configurable:true, value:storage});
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
function ask(method, args) {
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
@@ -32,6 +62,17 @@
});
}
addEventListener('message', event => {
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
event.data.session === cacheSession && acceptSeed) {
const entries = event.data.entries;
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
for (const [key, value] of Object.entries(entries))
if (key.length <= 256 && typeof value === 'string' && value.length <= 65536)
cache.set(key, value);
}
acceptSeed(); acceptSeed = null;
return;
}
if (event.source !== parent || !event.data || event.data.type !== 'framey:reply') return;
const item = pending.get(event.data.id);
if (!item) return;
@@ -197,6 +238,7 @@
};
async function start() {
try {
await seeded;
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], {type:'text/javascript'}));
let module;
+29 -4
View File
@@ -103,7 +103,7 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
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;
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, frameySession=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.':
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
@@ -186,17 +186,42 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
activeFrameyId=id;byId('frameyTitle').textContent=name;
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
byId('frameyPanelStatus').textContent='Loading plugin panel…';
byId('frameyFrame').src=`/framey/panel/${id}`;
byId('frameyFrame').src=`/framey/panel/${id}#${frameySession}`;
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;};
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
if(!data||typeof data!=='object'||Array.isArray(data))return {};
const pairs=Object.entries(data);
return pairs.length<=64&&pairs.every(([key,value])=>key.length<=256&&typeof value==='string'&&value.length<=65536)&&
JSON.stringify(data).length<=128*1024?data:{};
}catch(_){return {};}
}
function frameyCacheSave(id,entries){
if(!entries||typeof entries!=='object'||Array.isArray(entries))return;
const pairs=Object.entries(entries);
if(pairs.length>64||pairs.some(([key,value])=>key.length>256||typeof value!=='string'||value.length>65536))return;
const encoded=JSON.stringify(entries);
if(encoded.length>128*1024)return;
try{localStorage.setItem(frameyCacheKey(id),encoded);}catch(_){} // Private browsing can deny storage.
}
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:cache:init'&&req.session===frameySession){
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
entries:frameyCacheRead(activeFrameyId)},'*');return;
}
if(req.type==='framey:cache:save'){
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
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;}