Files
saphid--frame-control/ui/framey-host.js
T

191 lines
11 KiB
JavaScript

/* Framey panel compatibility layer. The iframe has an opaque origin and no UI key. */
(() => {
'use strict';
const R = window.SP_REACT = window.React;
window.SP_REACTDOM = window.ReactDOM;
const jsx = (type, props, key) => {
const {children, ...elementProps} = props || {};
if (key !== undefined) elementProps.key = key;
return Object.prototype.hasOwnProperty.call(props || {}, 'children')
? R.createElement(type, elementProps, children)
: R.createElement(type, elementProps);
};
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
const root = document.getElementById('framey-root');
const error = document.getElementById('framey-error');
const toastRoot = document.getElementById('framey-toasts');
const pending = new Map();
const routes = new Map();
const routeListeners = new Set();
let currentRoute = null;
let nextId = 0;
let plugin;
function ask(method, args) {
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
pending.set(id, {resolve, reject, timer});
parent.postMessage({type:'framey:call', id, method, args}, '*');
});
}
addEventListener('message', event => {
if (event.source !== parent || !event.data || event.data.type !== 'framey:reply') return;
const item = pending.get(event.data.id);
if (!item) return;
pending.delete(event.data.id); clearTimeout(item.timer);
if (event.data.error) item.reject(Error(event.data.error)); else item.resolve(event.data.result);
});
addEventListener('unload', () => { try { plugin?.onDismount?.(); } catch (_) {} });
addEventListener('pointerup', event => {
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
}, {passive:true});
const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children);
const Field = ({label, description, children}) => R.createElement('label', null,
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
const ToggleField = ({label, description, checked, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, onChange:e=>onChange?.(e.target.checked)}));
const DropdownItem = ({label, description, rgOptions=[], selectedOption, onChange}) => R.createElement(Field, {label, description},
R.createElement('select', {value:String(selectedOption ?? ''), onChange:e=>onChange?.(rgOptions.find(x=>String(x.data)===e.target.value))},
rgOptions.map(x=>R.createElement('option', {key:String(x.data), value:String(x.data)}, x.label))));
const ButtonItem = ({label, children, onClick}) => R.createElement(Field, {label},
R.createElement('button', {type:'button', onClick}, children || label));
const PanelSection = ({title, children}) => R.createElement('section', null,
title && R.createElement('h2', null, title), 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'));
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
const closeModal = () => modalRoot.render(null);
function navigate(path) {
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
currentRoute = path;
for (const listener of routeListeners) listener(path);
return true;
}
const routerHook = {
addRoute(path, component) {
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) ||
(typeof component !== 'function' && !R.isValidElement(component)))
throw Error('Framey route needs a local path and React component');
routes.set(path, component);
return () => {routes.delete(path); if(currentRoute === path) navigate(null);};
},
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
// Steam routes and global components have no equivalent in the isolated panel.
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
};
const DFL = window.DFL = {
definePlugin: init => init,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem,
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), ConfirmModal,
staticClasses: {Title:'framey-title'},
Navigation: {Navigate:navigate, NavigateBack:()=>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)),
// Steam app, library, and store patches have no target inside this panel.
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
findInReactTree: () => null, findModuleExport: () => null,
appDetailsClasses: {}, appDetailsHeaderClasses: {},
useParams: () => ({}),
};
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
const data = {title, body};
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 close = document.createElement('button'); close.type='button'; close.textContent='Dismiss';
const dismiss = () => {item.remove(); clearTimeout(timer);};
close.onclick=dismiss; item.append(heading, detail, close);
if (typeof onClick === 'function') {
const open = document.createElement('button'); open.type='button'; open.textContent='Open';
open.onclick=()=>{onClick(); dismiss();}; item.append(open);
}
toastRoot.append(item);
const timer = setTimeout(dismiss, Math.min(30000,Math.max(3000,Number(duration)||5000)));
return {data,dismiss};
}};
class PanelBoundary extends R.Component {
constructor(props) { super(props); this.state = {failure:null}; }
static getDerivedStateFromError(failure) { return {failure}; }
render() { return this.state.failure ? R.createElement('p', null,
`This Decky panel is not compatible with Framey: ${this.state.failure.message}`) : this.props.children; }
}
function PluginSurface({plugin}) {
const [view, setView] = R.useState(plugin.content ? 'panel' : 'settings');
const [route, setRoute] = R.useState(currentRoute);
R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []);
const [running, setRunning] = R.useState(null);
const [message, setMessage] = R.useState('');
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8)
.filter(action => action && typeof action.label === 'string' && action.label.length <= 80 &&
typeof action.onClick === 'function') : [];
async function runAction(action, index) {
if (running !== null) return;
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); }
}
return R.createElement(R.Fragment, null,
plugin.titleView && R.createElement('header', null, plugin.titleView),
route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'),
plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined,
onClick:()=>setView('panel')}, 'Panel'),
R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined,
onClick:()=>setView('settings')}, 'Settings')),
actions.length > 0 && R.createElement('section', {'aria-label':'Plugin quick actions'},
R.createElement('h2', null, 'Quick actions'),
...actions.map((action, index) => R.createElement('button', {key:index, type:'button',
disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))),
message && R.createElement('p', {role:'status'}, message),
R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ?
R.createElement(routes.get(route)) : routes.get(route)) :
(view === 'settings' ? plugin.settings : plugin.content)));
}
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
connect: (version, name) => ({
_version: Math.min(version, 2),
call: (method, ...args) => ask(method, args),
callable: method => (...args) => ask(method, args),
addEventListener: () => {}, removeEventListener: () => {},
routerHook, toaster,
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
injectCssIntoTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
removeCssFromTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
getExternalResourceURL: () => null,
useQuickAccessVisible: () => true,
}),
};
async function start() {
try {
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], {type:'text/javascript'}));
let module;
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
plugin = await module.default();
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
R.createElement(PluginSurface, {plugin})));
parent.postMessage({type:'framey:ready'}, '*');
} catch (failure) {
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
parent.postMessage({type:'framey:error', message:failure.message}, '*');
}
}
start();
})();