mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 09:01:47 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587a7fc8c3 |
No files matched your search
@@ -68,7 +68,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `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 |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
| `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 |
|
||||
|
||||
@@ -12,7 +12,11 @@ class Element {constructor(){this.children=[];this.textContent='';this.attribute
|
||||
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
|
||||
remove(){this.removed=true;}}
|
||||
const elements=new Map();
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const roots=new Map();
|
||||
const window={React,ReactDOM:{createRoot:node=>{
|
||||
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
|
||||
roots.set(node,root);return root;
|
||||
}},addEventListener(){}};
|
||||
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
|
||||
const context=vm.createContext({window,document:{getElementById:id=>{
|
||||
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
|
||||
@@ -101,6 +105,31 @@ assert.equal(toastNode.attributes.role,'status');
|
||||
assert.equal(toastNode.children[0].textContent,'Saved');
|
||||
assert.equal(toastNode.children[1].textContent,'Mode updated');
|
||||
toast.dismiss();assert.equal(toastNode.removed,true);
|
||||
// Decky's toast fields are ReactNode values. Keep JSX in the panel instead of
|
||||
// showing "[object Object]", and release their React roots on dismissal.
|
||||
const heading=React.createElement('em',null,'Saved');
|
||||
const detail=React.createElement('span',null,'Mode updated');
|
||||
const icon=React.createElement('svg',null);
|
||||
const rich=api.toaster.toast({title:heading,body:[detail,' again'],icon,
|
||||
subtext:'Open settings',showToast:true});
|
||||
const richNode=elements.get('framey-toasts').children[1];
|
||||
assert.equal(roots.get(richNode.children[0]).value,icon);
|
||||
assert.equal(roots.get(richNode.children[1]).value,heading);
|
||||
assert.deepEqual(Array.from(roots.get(richNode.children[2]).value),[detail,' again']);
|
||||
assert.equal(richNode.children[3].textContent,'Open settings');
|
||||
rich.dismiss();rich.dismiss();
|
||||
assert.equal(richNode.removed,true);
|
||||
for(const node of richNode.children.slice(0,3))assert.equal(roots.get(node).unmounted,true);
|
||||
let opened=0;
|
||||
const actionable=api.toaster.toast({title:'Open',onClick:()=>opened++});
|
||||
const actionNode=elements.get('framey-toasts').children[2];
|
||||
actionNode.children.at(-1).onclick();
|
||||
assert.equal(opened,1);
|
||||
assert.equal(actionNode.removed,true);
|
||||
actionable.dismiss();
|
||||
const hidden=api.toaster.toast({title:heading,showToast:false});
|
||||
assert.equal(elements.get('framey-toasts').children.length,3);
|
||||
hidden.dismiss();
|
||||
|
||||
function LocalRoute(){return React.createElement('p',null,'Route content');}
|
||||
const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
|
||||
|
||||
+29
-6
@@ -279,16 +279,39 @@
|
||||
};
|
||||
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};
|
||||
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body, subtext, icon, logo};
|
||||
if (!showToast) return {data, dismiss(){}};
|
||||
const item = document.createElement('section');
|
||||
item.setAttribute('role','status');
|
||||
const heading = document.createElement('strong'); heading.textContent = String(title || 'Plugin notice');
|
||||
const detail = document.createElement('p'); detail.textContent = String(body || '');
|
||||
const roots = [];
|
||||
const addContent = (tag, value) => {
|
||||
if (value === null || value === undefined || value === false) return;
|
||||
const node = document.createElement(tag);
|
||||
if (R.isValidElement(value) || Array.isArray(value)) {
|
||||
const root = window.ReactDOM.createRoot(node);
|
||||
roots.push(root);
|
||||
root.render(value);
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
node.textContent = String(value);
|
||||
} else return;
|
||||
item.append(node);
|
||||
};
|
||||
addContent('span', logo);
|
||||
addContent('span', icon);
|
||||
addContent('strong', title || 'Plugin notice');
|
||||
addContent('p', body);
|
||||
addContent('small', subtext);
|
||||
const close = document.createElement('button'); close.type='button'; close.textContent='Dismiss';
|
||||
const dismiss = () => {item.remove(); clearTimeout(timer);};
|
||||
close.onclick=dismiss; item.append(heading, detail, close);
|
||||
let dismissed = false;
|
||||
const dismiss = () => {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
clearTimeout(timer);
|
||||
for (const root of roots) root.unmount();
|
||||
item.remove();
|
||||
};
|
||||
close.onclick=dismiss; item.append(close);
|
||||
if (typeof onClick === 'function') {
|
||||
const open = document.createElement('button'); open.type='button'; open.textContent='Open';
|
||||
open.onclick=()=>{onClick(); dismiss();}; item.append(open);
|
||||
|
||||
Reference in new issue
Block a user