From e2ffcd34f3b5addc404d43a5b950a0fae123fcf0 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 25 Oct 2026 07:56:38 +0000 Subject: [PATCH] Support Decky panel routes and toasts in Framey (#227) * 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 * Show plugin quick actions and settings in Framey panel * Add local Decky routes and toasts to Framey --------- Co-authored-by: SAPHID --- docs/decky-frame.md | 8 ++++-- docs/frame-plugin-author.md | 23 +++++++++++++++ docs/framey-loader.md | 12 ++++---- tests/page/framey_actions.mjs | 36 +++++++++++++++++++++-- tests/test_framey_panel.py | 1 + ui/framey-host.js | 54 +++++++++++++++++++++++++++++++---- ui/framey_panel.py | 2 +- 7 files changed, 119 insertions(+), 17 deletions(-) diff --git a/docs/decky-frame.md b/docs/decky-frame.md index f1df978..dcd2073 100644 --- a/docs/decky-frame.md +++ b/docs/decky-frame.md @@ -57,9 +57,11 @@ change Frame Control owned plugin folders under hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls back an update if the service restart fails. The current VR shell does not provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel -shows installed plugins and store actions, but plugin pages, settings, routes, -and Decky toasts are **not** available there yet. The panel itself still needs -a worn-headset visual and controller check. +shows installed plugins and store actions. Its separate Framey iframe now hosts +compatible API v2 plugin pages, settings, local routes, and panel toasts. +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). ## What was checked on the real headset diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 7383302..dac3542 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -37,3 +37,26 @@ To test safely, use a separate Frame Control managed Decky install, verify the ZIP digest from the official store, install one plugin at a time, and inspect `journalctl --user -u frame-control-decky.service`. Remove only your own plugin folder when you finish. See the current [compatibility table](decky-frame.md). + +## Framey API v2 compatibility + +Framey hosts a plugin's frontend in an isolated iframe in the Frame Control +SteamVR panel. The table describes the current implementation and its tests; +none of these frontend controls has passed a worn-headset check yet. + +| Decky API or UI feature | Framey behavior | Verified in | +| --- | --- | --- | +| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test | +| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls | Local browser and host unit test | +| 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 get/set setting; host unit test | +| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification | +| `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 | +| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey | + +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. diff --git a/docs/framey-loader.md b/docs/framey-loader.md index bf6eb03..c3b9c9e 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -38,11 +38,11 @@ does not receive Frame Control's UI key. React and ReactDOM are bundled for offline use under their MIT licenses in `ui/vendor`. The host currently supplies sections, rows, buttons, toggles, dropdowns, -scroll containers, a modal, locale information, and backend calls for the -plugin that owns the panel. Steam library/store page patches, global Steam UI -components, Decky route hooks, file pickers, and privileged root methods have -no VR target. A plugin that depends on them can render only part of its UI or -show an incompatibility message. A Decky plugin's Python backend still runs as +scroll containers, a modal, locale information, local panel routes and toasts, +and backend calls for the plugin that owns the panel. Steam library/store page +patches, global Steam UI components, file pickers, and privileged root methods +have no VR target. A plugin that depends on them can render only part of its UI +or show an incompatibility message. A Decky plugin's Python backend still runs as the headset user when Decky loads it, so review third-party code before opting in. The panel iframe can make HTTPS requests subject to ordinary browser CORS. @@ -91,6 +91,8 @@ plugin initializer. Framey displays up to eight actions and reports a pending, success, or failure state. These descriptor fields are Framey additions; Decky ignores them. Use a plugin backend method through `@decky/api` for work needing persistent state, and keep a quick action safe to repeat. +See the [API v2 compatibility table](frame-plugin-author.md#framey-api-v2-compatibility) +for tested behavior and remaining limits. The [Frame Control extension API](extensions.md) is an alternative for a Frame-specific panel. It needs only a local manifest, HTML, and the read-only diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 5a5033a..63c9556 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -7,10 +7,17 @@ const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__s assert.notEqual(testSource,source); const states=[]; const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}), - useState:value=>[value,next=>states.push(next)]}; + useState:value=>[value,next=>states.push(next)], useEffect(){},isValidElement:value=>!!value?.type}; +class Element {constructor(){this.children=[];this.textContent='';this.attributes={};} + append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;} + remove(){this.removed=true;}} +const elements=new Map(); const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}}; -const context=vm.createContext({window,document:{getElementById:()=>({})},navigator:{language:'en'}, - parent:{postMessage(){}},addEventListener(){},setTimeout(){},clearTimeout(){}}); +const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)}; +const context=vm.createContext({window,document:{getElementById:id=>{ + if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()}, + navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}), + setTimeout:()=>1,clearTimeout(){}}); vm.runInContext(testSource,context); const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))]; let called=0; @@ -27,3 +34,26 @@ await buttons[2].props.onClick(); assert.equal(called,1); assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3)); assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1); + +// 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'}}); +assert.equal(await reply,'dark'); +await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/); + +const toast=api.toaster.toast({title:'Saved',body:'Mode updated',duration:4000}); +const toastNode=elements.get('framey-toasts').children[0]; +assert.equal(toastNode.attributes.role,'status'); +assert.equal(toastNode.children[0].textContent,'Saved'); +assert.equal(toastNode.children[1].textContent,'Mode updated'); +toast.dismiss();assert.equal(toastNode.removed,true); + +function LocalRoute(){return React.createElement('p',null,'Route content');} +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); +undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false); diff --git a/tests/test_framey_panel.py b/tests/test_framey_panel.py index 5c6f4fe..65c411a 100644 --- a/tests/test_framey_panel.py +++ b/tests/test_framey_panel.py @@ -47,6 +47,7 @@ class Panel(unittest.TestCase): page = framey_panel.panel(14).decode() self.assertIn('framey-bundle', page) self.assertIn('/framey-runtime/host.js', page) + self.assertIn('framey-toasts', page) self.assertNotIn('X-Frame-UI', page) self.assertNotIn('SP_REACT.createElement', page) # bundled code is base64, not executable HTML. with self.assertRaises(FileNotFoundError): diff --git a/ui/framey-host.js b/ui/framey-host.js index 01a8b1e..8c7b675 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -6,7 +6,11 @@ window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']}; const root = document.getElementById('framey-root'); const error = document.getElementById('framey-error'); + const toastRoot = document.getElementById('framey-toasts'); const pending = new Map(); + const routes = new Map(); + const routeListeners = new Set(); + let currentRoute = null; let nextId = 0; let plugin; @@ -48,13 +52,32 @@ R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close')); const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal')); const closeModal = () => modalRoot.render(null); + function navigate(path) { + if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false; + currentRoute = path; + for (const listener of routeListeners) listener(path); + return true; + } + const routerHook = { + addRoute(path, component) { + if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || + (typeof component !== 'function' && !R.isValidElement(component))) + throw Error('Framey route needs a local path and React component'); + routes.set(path, component); + return () => {routes.delete(path); if(currentRoute === path) navigate(null);}; + }, + removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);}, + // Steam routes and global components have no equivalent in the isolated panel. + addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{}, + }; const DFL = window.DFL = { definePlugin: init => init, PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem, Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), DialogButton: passthrough('button'), ConfirmModal, staticClasses: {Title:'framey-title'}, - Navigation: {NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }}, + Navigation: {Navigate:navigate, NavigateBack:()=>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. @@ -64,8 +87,24 @@ useParams: () => ({}), }; DFL.DialogButton.render = () => ({type: DFL.DialogButton}); - const noRoute = {addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{}}; - const toaster = {toast: ({title, body}) => { error.textContent = [title, body].filter(Boolean).join(': '); }}; + const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => { + const data = {title, body}; + if (!showToast) return {data, dismiss(){}}; + const item = document.createElement('section'); + item.setAttribute('role','status'); + const heading = document.createElement('strong'); heading.textContent = String(title || 'Plugin notice'); + const detail = document.createElement('p'); detail.textContent = String(body || ''); + const close = document.createElement('button'); close.type='button'; close.textContent='Dismiss'; + const dismiss = () => {item.remove(); clearTimeout(timer);}; + close.onclick=dismiss; item.append(heading, detail, close); + if (typeof onClick === 'function') { + const open = document.createElement('button'); open.type='button'; open.textContent='Open'; + open.onclick=()=>{onClick(); dismiss();}; item.append(open); + } + toastRoot.append(item); + const timer = setTimeout(dismiss, Math.min(30000,Math.max(3000,Number(duration)||5000))); + return {data,dismiss}; + }}; class PanelBoundary extends R.Component { constructor(props) { super(props); this.state = {failure:null}; } static getDerivedStateFromError(failure) { return {failure}; } @@ -74,6 +113,8 @@ } function PluginSurface({plugin}) { const [view, setView] = R.useState(plugin.content ? 'panel' : 'settings'); + const [route, setRoute] = R.useState(currentRoute); + R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []); const [running, setRunning] = R.useState(null); const [message, setMessage] = R.useState(''); const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8) @@ -90,6 +131,7 @@ } return R.createElement(R.Fragment, null, plugin.titleView && R.createElement('header', null, plugin.titleView), + route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'), plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'}, R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined, onClick:()=>setView('panel')}, 'Panel'), @@ -100,7 +142,9 @@ ...actions.map((action, index) => R.createElement('button', {key:index, type:'button', disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))), message && R.createElement('p', {role:'status'}, message), - R.createElement('div', null, view === 'settings' ? plugin.settings : plugin.content)); + R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ? + R.createElement(routes.get(route)) : routes.get(route)) : + (view === 'settings' ? plugin.settings : plugin.content))); } window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = { connect: (version, name) => ({ @@ -108,7 +152,7 @@ call: (method, ...args) => ask(method, args), callable: method => (...args) => ask(method, args), addEventListener: () => {}, removeEventListener: () => {}, - routerHook:noRoute, toaster, + routerHook, toaster, fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')), executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')), injectCssIntoTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')), diff --git a/ui/framey_panel.py b/ui/framey_panel.py index f9e63cd..e5dcafb 100644 --- a/ui/framey_panel.py +++ b/ui/framey_panel.py @@ -61,7 +61,7 @@ def panel(plugin_id): button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px} button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}button{margin:4px 8px 4px 0}button:disabled{opacity:.55}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px} label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6} -

+

''').encode()