From e400cb1dcf4e3ab6484086ade74385a14a27f3af Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sun, 25 Oct 2026 23:04:02 +0000 Subject: [PATCH] Support local Framey routes with named parameters --- docs/frame-plugin-author.md | 5 +++- tests/page/framey_actions.mjs | 16 ++++++++++++ ui/framey-host.js | 48 ++++++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 3b11e71..778a68f 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -66,7 +66,7 @@ none of these frontend controls has passed a worn-headset check yet. | `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.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified | | `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 | @@ -75,6 +75,9 @@ none of these frontend controls has passed a worn-headset check yet. 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. +Optional route segments must be at the end. Framey accepts local path segments +only; query strings, external URLs, Steam library routes and duplicate parameter +names are rejected. Removing an active route returns to the plugin panel. Toasts stay inside the panel, so keep essential errors in the plugin UI too. An API v1 manifest does not guarantee a working Framey panel. Test the exact store archive and do not assume SteamClient app details, library patches, or diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index e43fd1e..48d0e08 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -111,3 +111,19 @@ 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); +function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);} +const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true); +assert.equal(window.DFL.useParams().appid,'123'); +assert.equal(window.DFL.useParams().view,undefined); +assert.equal(nodes(window.__surface({plugin}),GameRoute).length,1); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true); +assert.equal(window.DFL.useParams().view,'art'); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false); +assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute)); +assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute)); +removeGame(); +assert.equal(window.DFL.useParams().appid,undefined); +assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0); diff --git a/ui/framey-host.js b/ui/framey-host.js index 357d2fe..cc8da1e 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -26,6 +26,7 @@ const routes = new Map(); const routeListeners = new Set(); let currentRoute = null; + let currentParams = {}; let nextId = 0; let plugin; // A sandboxed iframe has an opaque origin, so its native localStorage getter @@ -137,21 +138,54 @@ R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close')); const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal')); const closeModal = () => modalRoot.render(null); + const localPath = path => typeof path === 'string' && path.length <= 120 && + /^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//'); + const routePattern = path => typeof path === 'string' && path.length <= 120 && + /^\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??)(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??))*$/.test(path) && + !/\?\//.test(path) && + new Set([...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].map(match => match[1])).size === + [...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].length; + function matchRoute(pattern, path) { + const expected = pattern.slice(1).split('/'); + const actual = path.slice(1).split('/'); + if (actual.length > expected.length) return null; + const params = {}; + for (let index = 0; index < expected.length; index++) { + const segment = expected[index], value = actual[index]; + if (segment[0] === ':') { + const optional = segment.endsWith('?'), name = segment.slice(1, optional ? -1 : undefined); + if (value === undefined) { if (!optional) return null; } + else params[name] = value; + } else if (segment !== value) return null; + } + return params; + } + function activeRoute(path) { + if (routes.has(path)) return {component:routes.get(path), params:{}}; + for (const [pattern, component] of routes) { + const params = matchRoute(pattern, path); + if (params) return {component, params}; + } + return null; + } function navigate(path) { - if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false; + if (path !== null && !localPath(path)) return false; + const match = path === null ? null : activeRoute(path); + if (path !== null && !match) return false; currentRoute = path; + currentParams = match?.params || {}; for (const listener of routeListeners) listener(path); return true; } const routerHook = { addRoute(path, component) { - if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || + if (!routePattern(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);}; + return () => routerHook.removeRoute(path); }, - removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);}, + removeRoute(path) {routes.delete(path); if(currentRoute && !activeRoute(currentRoute)) navigate(null);}, // Steam routes and global components have no equivalent in the isolated panel. addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{}, }; @@ -170,7 +204,7 @@ afterPatch: () => () => {}, createReactTreePatcher: () => () => {}, findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null, appDetailsClasses: {}, appDetailsHeaderClasses: {}, - useParams: () => ({}), + useParams: () => currentParams, }; DFL.DialogButton.render = () => ({type: DFL.DialogButton}); const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => { @@ -228,8 +262,8 @@ ...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, route ? (typeof routes.get(route) === 'function' ? - R.createElement(routes.get(route)) : routes.get(route)) : + R.createElement('div', null, route ? (typeof activeRoute(route)?.component === 'function' ? + R.createElement(activeRoute(route).component) : activeRoute(route)?.component) : (view === 'settings' ? plugin.settings : plugin.content))); } window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {