From 39aa8d56fb08aaa699c5fbacae55862986735efc Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 27 Oct 2026 00:28:47 +0000 Subject: [PATCH] Return through local Framey panel routes --- docs/frame-plugin-author.md | 5 ++++- tests/page/framey_actions.mjs | 36 +++++++++++++++++++++++++++++++++++ ui/framey-host.js | 27 +++++++++++++++++++++----- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 7a1363a..944b6b7 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -76,7 +76,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. | | `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. | | IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. | -| `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.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. Back walks up to 32 local page transitions and skips routes removed since navigation. | Host unit test; VR navigation unverified | | `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable | | `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. | | `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. | @@ -90,6 +90,9 @@ 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. +`Navigation.NavigateBack()` returns to the preceding local page, then the +plugin root. `Navigation.CloseSideMenus()` returns directly to the root and +clears that panel's history. Neither operation navigates Steam's own UI. Toasts stay inside the panel, so keep essential errors in the plugin UI too. Treat module lookup results as Framey-local exports. Do not depend on Steam webpack IDs, function-source signatures, or Steam-only CSS class names; a diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 5f57396..1876866 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -464,11 +464,47 @@ 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.equal(window.DFL.Navigation.NavigateBack(),true); +assert.equal(window.DFL.useParams().appid,'123'); +assert.equal(window.DFL.useParams().view,undefined,'Back returns to the previous local page'); +assert.equal(window.DFL.Navigation.NavigateBack(),true); +assert.equal(window.DFL.useParams().appid,undefined,'A second Back returns to the plugin root'); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true); +assert.equal(window.DFL.Navigation.NavigateBack(),true); +assert.equal(window.DFL.useParams().view,undefined,'Navigating to the same page did not add a history entry'); +assert.equal(window.DFL.Navigation.NavigateBack(),true); +assert.equal(window.DFL.useParams().appid,undefined); assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute)); assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute)); +api.routerHook.addRoute('/plugin/other',LocalRoute); +assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true); +assert.equal(window.DFL.Navigation.Navigate('/plugin/other'),true); removeGame(); +assert.equal(window.DFL.Navigation.NavigateBack(),true); +assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0, + 'Back skips a route that the plugin unregistered'); assert.equal(window.DFL.useParams().appid,undefined); assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0); +api.routerHook.removeRoute('/plugin/other'); +api.routerHook.addRoute('/plugin/first',LocalRoute); +api.routerHook.addRoute('/plugin/second',LocalRoute); +for(let index=0;index<40;index++) + assert.equal(window.DFL.Navigation.Navigate(index%2 ? '/plugin/first' : '/plugin/second'),true); +for(let index=0;index<32;index++)window.DFL.Navigation.NavigateBack(); +assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1); +window.DFL.Navigation.NavigateBack(); +assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0, + 'local route history is bounded and falls back to the plugin root'); +window.DFL.Navigation.Navigate('/plugin/first'); +window.DFL.Navigation.Navigate('/plugin/second'); +window.DFL.Navigation.CloseSideMenus(); +window.DFL.Navigation.NavigateBack(); +assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0, + 'CloseSideMenus clears the local route history'); +api.routerHook.removeRoute('/plugin/first'); +api.routerHook.removeRoute('/plugin/second'); // Event subscription is acknowledged before a method that immediately emits. const onMessage=listeners.find(x=>x.type==='message').handler; diff --git a/ui/framey-host.js b/ui/framey-host.js index 037bb7e..1faf6f5 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -56,6 +56,7 @@ const pending = new Map(); const routes = new Map(); const routeListeners = new Set(); + const routeHistory = []; let currentRoute = null; let currentParams = {}; let nextId = 0; @@ -368,15 +369,31 @@ } return null; } - function navigate(path) { - if (path !== null && !localPath(path)) return false; - const match = path === null ? null : activeRoute(path); - if (path !== null && !match) return false; + function showRoute(path, match) { currentRoute = path; currentParams = match?.params || {}; for (const listener of routeListeners) listener(path); return true; } + function navigate(path) { + if (path !== null && !localPath(path)) return false; + const match = path === null ? null : activeRoute(path); + if (path !== null && !match) return false; + if (path === null) routeHistory.length = 0; + else if (path !== currentRoute) { + routeHistory.push(currentRoute); + if (routeHistory.length > 32) routeHistory.shift(); + } + return showRoute(path, match); + } + function navigateBack() { + while (routeHistory.length) { + const previous = routeHistory.pop(); + const match = previous === null ? null : activeRoute(previous); + if (previous === null || match) return showRoute(previous, match); + } + return navigate(null); + } const routerHook = { addRoute(path, component) { if (!routePattern(path) || @@ -491,7 +508,7 @@ joinClassNames:(...names)=>names.filter(Boolean).join(' '), sleep:ms=>new Promise(resolve=>setTimeout(resolve,Math.max(0,Number(ms)||0))), staticClasses: {Title:'framey-title'}, - Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null), + Navigation: {Navigate:navigate, NavigateBack:navigateBack, CloseSideMenus:()=>navigate(null), NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }}, showModal, showContextMenu:showModal, // Steam app, library, and store patches have no target inside this panel.