mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 07:01:01 +01:00
Guard Framey quick actions against repeated taps
This commit is contained in:
1 parent
56bde24be1
commit
e0115f356d
3 files changed
+28
-7
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user