Compare commits

..
Author SHA1 Message Date
SAPHID fe4e09032d Provide session cache to isolated Framey panels 2026-10-25 21:09:45 +00:00
6 changed files with 80 additions and 22 deletions

No files matched your search

@@ -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.
+10 -6
View File
@@ -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
@@ -75,10 +82,7 @@ The in-headset **Open official store** view loads the full official catalogue.
Search matches names, authors, and descriptions. Filters show non-root plugins
or installed plugins, and Previous/Next buttons browse 20 results per page.
The root label is a compatibility warning; other APIs are checked only when a
plugin is opened. Both `root` and `_root`
store tags mark a plugin as needing root, and duplicate store IDs are omitted
because an ID alone could select a different archive than the visible row.
The store still needs the managed Decky user service running
plugin is opened. The store still needs the managed Decky user service running
before its Install or Update buttons are enabled. If a plugin prevents Decky
from starting, Remove remains available for Frame Control-owned plugins while
the managed service is stopped; it does not restart the service.
+17
View File
@@ -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');
-9
View File
@@ -41,15 +41,6 @@ class ArchiveTests(unittest.TestCase):
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
def test_catalog_marks_underscore_root_and_omits_ambiguous_store_ids(self):
records = [{'id': 42, 'name': 'Needs root', 'tags': ['_root'],
'versions': [{'name': '1.0', 'hash': 'a' * 64}]},
{'id': 43, 'name': 'First', 'versions': [{'name': '1.0', 'hash': 'b' * 64}]},
{'id': 43, 'name': 'Second', 'versions': [{'name': '2.0', 'hash': 'c' * 64}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen',
return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
+2 -7
View File
@@ -59,13 +59,8 @@ def catalog():
'description': str(item.get('description') or '')[:300],
'author': author[:100],
'version': latest['name'][:40], 'sha256': latest['hash'],
'root': 'root' in tags or '_root' in tags})
# A repeated store ID makes a clicked row ambiguous: change() resolves by ID
# and could install a different archive than the one the user reviewed.
counts = {}
for item in result:
counts[item['id']] = counts.get(item['id'], 0) + 1
return [item for item in result if counts[item['id']] == 1]
'root': 'root' in tags})
return result
def archive_info(data, expected_hash, expected_name):
+25
View File
@@ -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) => {