Guard Framey quick actions against repeated taps

This commit is contained in:
SAPHID committed 2026-10-26 04:04:53 +00:00
1 parent 56bde24be1
commit e0115f356d
3 files changed
+28 -7

No files matched your search

+3 -1
View File
@@ -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)
+17 -2
View File
@@ -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);
+8 -4
View File
@@ -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),