From e0115f356d3b33369a61cfba034b080a7764559f Mon Sep 17 00:00:00 2001 From: SAPHID Date: Mon, 26 Oct 2026 04:04:53 +0000 Subject: [PATCH] Guard Framey quick actions against repeated taps --- docs/framey-loader.md | 4 +++- tests/page/framey_actions.mjs | 19 +++++++++++++++++-- ui/framey-host.js | 12 ++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/framey-loader.md b/docs/framey-loader.md index a84121e..ab0c19e 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -201,7 +201,9 @@ an optional `settings` React element under a **Settings** tab and an optional Framey shows tabs only when both views exist. For one-tap headset controls, return `frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}]` from the plugin initializer. Framey displays up to eight actions and reports a pending, -success, or failure state. These descriptor fields are Framey additions; Decky +success, or failure state. It ignores extra taps while an action is pending, +and a failed action can be retried. Invalid action descriptors do not count +toward the eight-action limit. 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 [Decky API compatibility table](frame-plugin-author.md#framey-decky-api-compatibility) diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 28518e0..edaf8d6 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -7,7 +7,8 @@ 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)], useEffect(){},isValidElement:value=>!!value?.type}; + useState:value=>[value,next=>states.push(next)], useRef:value=>({current:value}), + 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;}} @@ -98,13 +99,27 @@ const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{typ {label:'invalid'},...Array.from({length:9},(_,i)=>({label:`Action ${i}`,onClick:()=>{}}))]}; const tree=window.__surface({plugin}); const buttons=nodes(tree,'button'); -assert.equal(buttons.length,9); // Panel, Settings, and seven valid actions inside the first eight entries. +assert.equal(buttons.length,10); // Panel, Settings, and eight valid actions after filtering. assert.equal(nodes(tree,'nav')[0].props['aria-label'],'Plugin views'); assert.equal(nodes(tree,'section')[0].props['aria-label'],'Plugin quick actions'); assert.equal(nodes(tree,'header').length,1); await buttons[2].props.onClick(); assert.equal(called,1); assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3)); +let actionCount=0, settleAction; +const guarded=window.__surface({plugin:{content:{type:'content'},frameyQuickActions:[{ + label:'Save setting',onClick:()=>{actionCount++;return new Promise((resolve,reject)=>{ + settleAction={resolve,reject};});}}]}}); +const saveButton=nodes(guarded,'button')[0]; +const firstSave=saveButton.props.onClick(); +await saveButton.props.onClick(); // React has not rendered disabled state yet. +assert.equal(actionCount,1,'rapid second tap must not repeat a backend mutation'); +settleAction.reject(Error('offline')); +await firstSave; +const retrySave=saveButton.props.onClick(); +assert.equal(actionCount,2,'a failed action must release the synchronous lock'); +settleAction.resolve('Saved'); +await retrySave; const settingsOnly=window.__surface({plugin:{settings:{type:'settings'}}}); assert.equal(nodes(settingsOnly,'nav').length,0); assert.equal(nodes(settingsOnly,'settings').length,1); diff --git a/ui/framey-host.js b/ui/framey-host.js index 4aa0795..8ae0310 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -482,18 +482,22 @@ const [route, setRoute] = R.useState(currentRoute); R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []); const [running, setRunning] = R.useState(null); + const runningRef = R.useRef(false); const [message, setMessage] = R.useState(''); - const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8) + const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions .filter(action => action && typeof action.label === 'string' && action.label.length <= 80 && - typeof action.onClick === 'function') : []; + typeof action.onClick === 'function').slice(0, 8) : []; async function runAction(action, index) { - if (running !== null) return; + // React state may not render between two controller clicks. Lock before + // awaiting a backend action so one tap cannot start a second mutation. + if (runningRef.current) return; + runningRef.current = true; setRunning(index); setMessage(`${action.label}…`); try { const result = await action.onClick(); setMessage(typeof result === 'string' && result.length < 200 ? result : `${action.label} done`); } catch (failure) { setMessage(`${action.label} failed: ${failure.message || failure}`); } - finally { setRunning(null); } + finally { runningRef.current = false; setRunning(null); } } return R.createElement(R.Fragment, null, plugin.titleView && R.createElement('header', null, plugin.titleView),