diff --git a/docs/evidence/framey-session-cache-2026-10-25.md b/docs/evidence/framey-session-cache-2026-10-25.md new file mode 100644 index 0000000..cfeb521 --- /dev/null +++ b/docs/evidence/framey-session-cache-2026-10-25.md @@ -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. diff --git a/docs/framey-loader.md b/docs/framey-loader.md index 04ad252..ce9878b 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -37,6 +37,11 @@ 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 panel a bounded, in-memory `localStorage` substitute +because browser storage is denied to an opaque-origin iframe. It supports the +usual item methods, up to 256 entries and 512 KiB of keys and values. The data +is cleared when the panel reloads or closes. Use the plugin backend for settings +that must survive a new panel session. The host currently supplies sections, rows, buttons, toggles, text fields, sliders, grouped dropdowns, @@ -58,8 +63,10 @@ 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 opaque iframe. No real Decky backend -or worn headset test was part of that check. +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 the panel-lifetime substitute avoids those errors while preserving the +opaque origin. Neither check used a real Decky backend or a 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 diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 3473ab7..731803d 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -19,6 +19,23 @@ const context=vm.createContext({window,document:{getElementById:id=>{ navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}), setTimeout:()=>1,clearTimeout(){}}); vm.runInContext(testSource,context); +// A plugin gets bounded, panel-lifetime storage without the parent's origin. +const storage=window.localStorage; +assert.equal(storage.getItem('missing'),null); +storage.setItem('theme','dark'); +storage.setItem('count',2); +assert.equal(storage.length,2); +assert.equal(storage.key(0),'theme'); +assert.equal(storage.getItem('count'),'2'); +storage.setItem('theme','light'); +assert.equal(storage.getItem('theme'),'light'); +assert.throws(()=>storage.setItem('large','x'.repeat(262144)),{name:'QuotaExceededError'}); +assert.equal(storage.getItem('theme'),'light'); +storage.removeItem('count'); +assert.equal(storage.length,1); +storage.clear(); +assert.equal(storage.length,0); +assert.throws(()=>Object.defineProperty(window,'localStorage',{value:{}}),TypeError); // Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists. const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key'); assert.equal(jsx.type,'p'); diff --git a/ui/framey-host.js b/ui/framey-host.js index c94c015..390c827 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -1,6 +1,31 @@ /* Framey panel compatibility layer. The iframe has an opaque origin and no UI key. */ (() => { 'use strict'; + // Opaque-origin iframes throw on localStorage access. Give plugins a small + // panel-lifetime cache without granting their code the parent page's origin. + const cache = new Map(); + const cacheLimit = 262144; // UTF-16 code units: at most 512 KiB of keys and values. + const cacheQuotaError = () => { + const error = new Error('Framey panel cache is full'); + error.name = 'QuotaExceededError'; + return error; + }; + const panelStorage = Object.freeze({ + get length() { return cache.size; }, + key(index) { return Number.isInteger(Number(index)) ? [...cache.keys()][Number(index)] ?? null : null; }, + getItem(key) { return cache.get(String(key)) ?? null; }, + setItem(key, value) { + key = String(key); value = String(value); + const previous = cache.get(key); + const size = [...cache].reduce((total, [name, data]) => total + name.length + data.length, 0) + - (previous === undefined ? 0 : key.length + previous.length) + key.length + value.length; + if ((previous === undefined && cache.size >= 256) || size > cacheLimit) throw cacheQuotaError(); + cache.set(key, value); + }, + removeItem(key) { cache.delete(String(key)); }, + clear() { cache.clear(); }, + }); + Object.defineProperty(window, 'localStorage', {value:panelStorage, enumerable:true}); const R = window.SP_REACT = window.React; window.SP_REACTDOM = window.ReactDOM; const jsx = (type, props, key) => {