diff --git a/docs/decky-frame.md b/docs/decky-frame.md index 40fcd8a..dd37cc1 100644 --- a/docs/decky-frame.md +++ b/docs/decky-frame.md @@ -44,22 +44,23 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut ### Which of these plugins can open a Framey panel? -The separate Framey panel accepts a Frame Control store install only when its -`plugin.json` declares `api_version: 2` and it has `dist/index.js`. A backend -load does not bypass this frontend check. We downloaded the 11 versions above +The separate Framey panel accepts a Frame Control store install when its +`plugin.json` declares API v1 or v2 and it has a modern Decky frontend bundle +in `dist/index.js`. A backend load does not bypass this frontend check. We downloaded the 11 versions above again on 25 October 2026 and matched every ZIP's SHA-256 to the official store catalogue. All 11 had a frontend bundle, but their manifests differed: | Manifest in the checked archive | Plugins | Framey panel result | | --- | --- | --- | | API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. | -| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Framey refuses these frontends. Wine Cellar and MagicPods also fail the ARM64 archive check. | +| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. | | No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | Framey refuses these frontends. Audio Loader and PowerTools also request root. | The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json) has each store ID, exact version, SHA-256, manifest field, and bundle check. -This is a static PC inspection. It does not show that any plugin is visible or -usable in the headset. The earlier real Frame backend checks in the table above +The archive audit itself is static PC inspection. Later local host and fake +Frame tests cover the Storage Cleaner and HLTB cases noted above; none shows +that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above remain the only device evidence for these versions. A new store version needs a new check because its manifest and binaries can change. @@ -83,7 +84,7 @@ API v2 pages and API v1 bundles that use Decky's modern frontend initializer, with settings, local routes, and panel toasts where supported. Steam route patches and system notifications remain unavailable. The panel still needs a worn-headset visual and controller check. See the -[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility). +[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility). ## What was checked on the real headset diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 2691aa9..3b11e71 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -68,6 +68,7 @@ none of these frontend controls has passed a worn-headset check yet. | `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 | +| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. | | Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection | | `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey | @@ -85,3 +86,9 @@ 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. + +HLTB for Deck 2.0.12 demonstrates the limit of the panel shim: its official +bundle can start and produce a settings view with Framey's Steam module lookup +stub, but its game page and context menu patches have no Steam target in the +isolated panel. The tests do not call its HLTB network search or Python backend. +Do not claim game length badges work in VR from this result. diff --git a/tests/fixtures/framey-official/26.zip b/tests/fixtures/framey-official/26.zip new file mode 100644 index 0000000..b4da70c Binary files /dev/null and b/tests/fixtures/framey-official/26.zip differ diff --git a/tests/fixtures/framey-official/README.md b/tests/fixtures/framey-official/README.md index 8e65272..862970d 100644 --- a/tests/fixtures/framey-official/README.md +++ b/tests/fixtures/framey-official/README.md @@ -10,11 +10,15 @@ from a local fixture so CI does not depend on the store being online. | `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` | | `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` | | `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` | +| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` | The matching source projects are [ProtonDB Badges](https://github.com/bschelst/protondb-decky), [Steam Status](https://github.com/bschelst/steamstatus-decky), and -[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner). +[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner), and +[HLTB for Deck](https://github.com/morwy/hltb-for-deck). The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed -frontend bytes, managed store receipt, and removal. It does **not** run their -Python backends or prove their JavaScript panels render in the VR shell. +frontend bytes, managed store receipt, and removal. A separate local host +test starts HLTB's bundled frontend and builds its settings view with a stub +Steam module lookup. It does **not** run plugin Python backends, HLTB searches, +or prove JavaScript panels render in the VR shell. diff --git a/tests/framey_official.py b/tests/framey_official.py index 2119e63..49b41c4 100644 --- a/tests/framey_official.py +++ b/tests/framey_official.py @@ -10,6 +10,8 @@ PLUGINS = ( 'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'}, {'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1, 'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'}, + {'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1, + 'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'}, ) diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index bab1597..e43fd1e 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -107,4 +107,7 @@ const undo=api.routerHook.addRoute('/plugin/details',LocalRoute); assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true); assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1); assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false); +assert.equal(window.DFL.findModuleChild(()=>true),null); +window.DFL.Navigation.CloseSideMenus(); +assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0); undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false); diff --git a/tests/page/framey_hltb.mjs b/tests/page/framey_hltb.mjs new file mode 100644 index 0000000..179f68c --- /dev/null +++ b/tests/page/framey_hltb.mjs @@ -0,0 +1,57 @@ +// Execute the pinned official HLTB frontend's entry point with Framey's API. +// This checks module startup only; it never makes network or backend calls. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {inflateRawSync} from 'node:zlib'; +import vm from 'node:vm'; + +const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8'); +const hostTest=host.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();'); +assert.notEqual(hostTest,host); +const zip=readFileSync(new URL('../fixtures/framey-official/26.zip',import.meta.url)); +const eocd=zip.lastIndexOf(Buffer.from([0x50,0x4b,0x05,0x06])); +assert.ok(eocd>0); +let offset=zip.readUInt32LE(eocd+16),bundle; +for(let i=0;i({type,props,children}), + createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}), + forwardRef:fn=>fn,memo:fn=>fn,useState:value=>[value,()=>{}], + useContext:ctx=>ctx?._currentValue,useMemo:fn=>fn(),useCallback:fn=>fn, + useRef:value=>({current:value}),useEffect(){}}, + {get:(target,key)=>target[key]??(()=>{})}); +const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}}; +const elements=new Map(); +const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage(){}}, + document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}}, + addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob}); +vm.runInContext(hostTest,context); +context.DFL=window.DFL; +context.SP_REACT=window.SP_REACT; +context.SP_JSX=window.SP_JSX; +vm.runInContext(official,context,{timeout:2000}); +const plugin=window.__official(); +assert.equal(typeof window.DFL.findModuleChild,'function'); +assert.equal(window.DFL.findModuleChild(()=>{}),null); // Steam has no matching module in the VR panel. +assert.equal(plugin.content?.type?.name,'QuickAccessView'); +const panel=plugin.content.type(); +assert.ok(panel?.type); // Read-only settings render with empty local data. +assert.equal(typeof plugin.onDismount,'function'); +plugin.onDismount(); diff --git a/tests/test_framey_official.py b/tests/test_framey_official.py index fbc0b5a..2b06c94 100644 --- a/tests/test_framey_official.py +++ b/tests/test_framey_official.py @@ -5,6 +5,8 @@ import hashlib import json from pathlib import Path import re +import shutil +import subprocess import sys import tempfile import unittest @@ -19,6 +21,12 @@ from framey_official import PLUGINS, archive class OfficialBundles(unittest.TestCase): + @unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check') + def test_hltb_frontend_starts_with_framey_module_stub(self): + script = Path(__file__).parent / 'page/framey_hltb.mjs' + result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + def test_pinned_store_archives_install_and_pack_into_framey_panel(self): with tempfile.TemporaryDirectory() as root: plugins = Path(root) diff --git a/tests/test_framey_panel.py b/tests/test_framey_panel.py index 33aafa1..675f738 100644 --- a/tests/test_framey_panel.py +++ b/tests/test_framey_panel.py @@ -1,4 +1,4 @@ -"""Framey hosts only store-owned API v2 frontends and scopes backend calls.""" +"""Framey hosts store-owned modern Decky frontends and scopes backend calls.""" import sandbox # noqa: F401 import gzip import http.client diff --git a/ui/framey-host.js b/ui/framey-host.js index 0433024..0a8907c 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -155,13 +155,13 @@ Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal, staticClasses: {Title:'framey-title'}, - Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), + Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null), NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }}, showModal: element => modalRoot.render(R.createElement('section', null, R.createElement('button', {onClick:closeModal}, 'Close'), element)), // Steam app, library, and store patches have no target inside this panel. afterPatch: () => () => {}, createReactTreePatcher: () => () => {}, - findInReactTree: () => null, findModuleExport: () => null, + findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null, appDetailsClasses: {}, appDetailsHeaderClasses: {}, useParams: () => ({}), };