diff --git a/docs/evidence/framey-legacy-2026-10-25.md b/docs/evidence/framey-legacy-2026-10-25.md index 2961de5..7ebf03d 100644 --- a/docs/evidence/framey-legacy-2026-10-25.md +++ b/docs/evidence/framey-legacy-2026-10-25.md @@ -21,4 +21,5 @@ unmanaged plugin IDs are rejected. Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'` (21 passed), `node tests/page/framey_actions.mjs`, and `scripts/e2e.sh test_framey` (2 passed). No official Python backend code or -headset state was changed by these checks. Real Frame backend behavior,+controller input, playback and worn VR use remain unverified. +headset state was changed by these checks. Real Frame backend behavior, +controller input, playback and worn VR use remain unverified. diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 3046e12..a4018e7 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -63,11 +63,14 @@ none of these frontend controls has passed a worn-headset check yet. | Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test | | `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test | | `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified | +| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium | +| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality | | `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check | | 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 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. | +| 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.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. | diff --git a/docs/framey-loader.md b/docs/framey-loader.md index c65ac46..8fc7d0f 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -83,6 +83,15 @@ work in VR. Storage Cleaner rendered an [empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md). Its cleanup actions were not tested; they may remove local game saves. +Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers +and progress bars. Its explicit SteamClient subscription fallbacks return +disposable handles without emitting fabricated events. There is no attached +running game in `DFL.Router`, and power, app launch and hardware mutation methods +are not supplied. Plugins that need live Steam stores still require integration +work. IndexedDB is hidden because the browser exposes it but denies access in +an opaque iframe; localForage can then select Framey's bounded localStorage. +Direct IndexedDB users remain unsupported. + The fake Frame integration test now writes a Decky setting through Framey's scoped backend endpoint, reads it in a new CEF request, updates the plugin, then checks it again. It also confirms removal blocks method calls and a diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 248a9c2..59c28ef 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -111,6 +111,7 @@ 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); + // Legacy backend methods preserve Decky keyword arguments and success envelopes. const legacyReply=window.__legacy.callPluginMethod('get_setting',{key:'theme'}); const legacyCall=messages.filter(message=>message.type==='framey:call').at(-1); @@ -124,6 +125,42 @@ const failedLegacy=window.__legacy.callPluginMethod('get_setting',{}); const failedCall=messages.filter(message=>message.type==='framey:call').at(-1); listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:failedCall.id,error:'Unavailable'}}); assert.equal((await failedLegacy).success,false); + +// Local UI controls work without Steam components or fabricated hardware data. +const tabChanges=[]; +const tabs=window.DFL.Tabs({tabs:[{id:'one',title:'One',content:'First'}, + {id:'two',title:'Two',content:'Second'},{id:'locked',title:'Locked',disabled:true}], + activeTab:'two',onShowTab:id=>tabChanges.push(id)}); +assert.equal(nodes(tabs,'button')[1].props['aria-selected'],true); +assert.deepEqual(nodes(tabs,'div').at(-1).children,['Second']); +nodes(tabs,'button')[0].props.onClick(); +nodes(tabs,'button')[2].props.onClick(); +assert.deepEqual(tabChanges,['one']); +assert.equal(window.DFL.Spinner({}).props.role,'status'); +assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100); +assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two'); +let eventCount=0; +const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++); +assert.equal(typeof unsubscribe,'function'); +assert.equal(unsubscribe.unregister,unsubscribe); +unsubscribe.unregister();unsubscribe();assert.equal(eventCount,0); +assert.equal(window.SteamClient.System.ShutdownPC,undefined); +assert.equal(window.DFL.Router.MainRunningApp,undefined); +assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage. +const unpatch=window.DFL.beforePatch({},'method',()=>{}); +assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch(); +assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function'); +let selectedMenuItem=0; +window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({}); +window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({}); +window.DFL.MenuItem({bDisabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({}); +window.DFL.MenuItem({onClick:()=>selectedMenuItem++,onSelected:()=>selectedMenuItem++}).props.onClick({}); +assert.equal(selectedMenuItem,2); +for (const [locale,expected] of [['de-DE','german'],['zh-CN','schinese'],['zh-TW','tchinese'], + ['pt-BR','brazilian'],['en-US','english'],['xx-ZZ','english']]) { + context.navigator.language=locale; + assert.equal(await window.SteamClient.Settings.GetCurrentLanguage(),expected); +} 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); diff --git a/ui/framey-host.js b/ui/framey-host.js index c32cfec..a126a77 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -57,6 +57,9 @@ entries:Object.fromEntries(cache)}, '*'); } Object.defineProperty(window, 'localStorage', {configurable:true, value:storage}); + // localForage otherwise chooses the browser's exposed-but-denied IndexedDB + // driver in an opaque iframe. Let it detect the bounded localStorage driver. + Object.defineProperty(window, 'indexedDB', {configurable:true, value:undefined}); parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*'); setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable. @@ -133,6 +136,25 @@ showValue && R.createElement('span', null, String(value) + valueSuffix)); const PanelSection = ({title, children}) => R.createElement('section', null, title && R.createElement('h2', null, title), children); + const Spinner = ({children}) => R.createElement('p', {role:'status'}, children || 'Loading…'); + const Tabs = ({tabs=[], activeTab, onShowTab}) => { + const [selected, setSelected] = R.useState(tabs[0]?.id); + const current = tabs.find(tab=>tab.id === (activeTab ?? selected)) || tabs[0]; + return R.createElement('div', null, + R.createElement('div', {role:'tablist'}, ...tabs.map(tab=>R.createElement('button', { + type:'button', key:tab.id, role:'tab', 'aria-selected':tab===current, + disabled:!!tab.disabled, onClick:()=>{if(!tab.disabled){setSelected(tab.id);onShowTab?.(tab.id);}}}, tab.title))), + R.createElement('div', {role:'tabpanel'}, current?.content)); + }; + const SidebarNavigation = ({pages=[], title}) => R.createElement('section', null, + title && R.createElement('h2', null, title), R.createElement(Tabs, { + tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({ + id:page.route || String(index), title:page.title, content:page.content}))})); + const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', { + value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max}); + const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) => + R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled), + onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children); const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null, R.createElement('h2', null, strTitle), children, R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close')); @@ -189,23 +211,73 @@ // Steam routes and global components have no equivalent in the isolated panel. addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{}, }; + const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;}; + const showModal = element => { + modalRoot.render(R.createElement('section', null, + R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element)); + return {Close:closeModal}; + }; const DFL = window.DFL = { definePlugin: init => init, PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField, Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal, + DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'), + Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation, + Marquee:passthrough('span'), DialogBody:passthrough('div'), DialogBodyText:passthrough('p'), + DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'), + DialogLabel:passthrough('label'), DialogControlsSection:passthrough('section'), + DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'), + Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem, + ProgressBar, ProgressBarWithInfo:ProgressBar, + gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{}, + gamepadSliderClasses:{}, gamepadContextMenuClasses:{}, scrollPanelClasses:{}, + quickAccessControlsClasses:{}, mainMenuAppRunningClasses:{}, libraryAssetImageClasses:{}, + 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), 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)), + showModal, showContextMenu:showModal, // Steam app, library, and store patches have no target inside this panel. - afterPatch: () => () => {}, createReactTreePatcher: () => () => {}, + afterPatch:noPatch, beforePatch:noPatch, replacePatch:noPatch, createReactTreePatcher:noPatch, findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null, + findModule:()=>null, findModuleByExport:()=>null, findClassModule:()=>null, + findClass:()=>null, findSP:()=>null, fakeRenderComponent:()=>null, appDetailsClasses: {}, appDetailsHeaderClasses: {}, useParams: () => currentParams, }; + // No Steam game is attached to an isolated panel. Subscriptions have a + // disposable handle but emit no fabricated game, power, or input events. + const subscription = () => {const dispose=()=>{};dispose.unregister=dispose;return dispose;}; + const register = names => Object.fromEntries(names.map(name=>[name,subscription])); + const steamLanguage = () => { + const locale = (navigator.language || 'en').toLowerCase(); + if (/^zh-(tw|hk|mo|hant)/.test(locale)) return 'tchinese'; + if (locale.startsWith('pt-br')) return 'brazilian'; + if (locale.startsWith('es-419')) return 'latam'; + return ({en:'english',de:'german',fr:'french',es:'spanish',it:'italian',ja:'japanese', + ko:'koreana',zh:'schinese',ru:'russian',pl:'polish',pt:'portuguese',nl:'dutch', + da:'danish',fi:'finnish',sv:'swedish',no:'norwegian',cs:'czech',hu:'hungarian', + ro:'romanian',th:'thai',tr:'turkish',vi:'vietnamese',uk:'ukrainian',id:'indonesian' + })[locale.split('-')[0]] || 'english'; + }; + window.SteamClient = { + Settings:{GetCurrentLanguage:async()=>steamLanguage(), + ...register(['RegisterForSettingsChanges'])}, + Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange', + 'RegisterForGameActionStart','RegisterForGameActionEnd']), + GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']), + System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']), + UI:register(['RegisterForFocusChangeEvents'])}, + Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']), + User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress', + 'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']), + Overlay:register(['RegisterForOverlayActivated']), + UI:register(['RegisterForUIModeChanged']), + }; + DFL.Router = {...DFL.Navigation, MainRunningApp:undefined}; DFL.DialogButton.render = () => ({type: DFL.DialogButton}); const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => { const data = {title, body};