Files
decky-loader/frontend/src/toaster.tsx
T

191 lines
6.3 KiB
TypeScript

import { Module, Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
import { ReactNode } from 'react';
import Toast from './components/Toast';
import Logger from './logger';
declare global {
interface Window {
__TOASTER_INSTANCE: any;
settingsStore: any;
NotificationStore: any;
}
}
class Toaster extends Logger {
// private routerHook: RouterHook;
// private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private rNode: any;
private audioModule: any;
private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toasterPatch?: Patch;
constructor() {
super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
this.init();
}
async init() {
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
// <DeckyToaster />
// </DeckyToasterStateContextProvider>
// ));
let instance: any;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
const findToasterRoot = (currentNode: any, iters: number): any => {
if (iters >= 65) {
// currently 65
return null;
}
if (
currentNode?.memoizedProps?.className?.startsWith?.('gamepadtoasts_GamepadToastPlaceholder') ||
currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder') ||
currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPopup')
) {
this.log(`Toaster root was found in ${iters} recursion cycles`);
return currentNode;
}
if (currentNode.sibling) {
let node = findToasterRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
if (currentNode.child) {
let node = findToasterRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
return null;
};
instance = findToasterRoot(tree, 0);
while (!instance) {
this.error(
'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
await sleep(5000);
instance = findToasterRoot(tree, 0);
}
this.node = instance.return;
this.rNode = this.node.return;
let toast: any;
let renderedToast: ReactNode = null;
let innerPatched: any;
const repatch = () => {
if (this.node && !this.node.type.decky) {
this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
if (innerPatched) {
inner.type = innerPatched;
} else {
afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
const currentToast = innerArgs[0]?.notification;
if (currentToast?.decky) {
if (currentToast == toast) {
ret.props.children = renderedToast;
} else {
toast = currentToast;
renderedToast = <Toast toast={toast.data} />;
ret.props.children = renderedToast;
}
} else {
toast = null;
renderedToast = null;
}
return ret;
});
innerPatched = inner.type;
}
return ret;
});
this.node.type.decky = true;
this.node.alternate.type = this.node.type;
}
};
const oRender = this.rNode.stateNode.__proto__.render;
let int: NodeJS.Timer | undefined;
this.rNode.stateNode.render = (...args: any[]) => {
const ret = oRender.call(this.rNode.stateNode, ...args);
if (ret && !this?.node?.return?.return) {
clearInterval(int);
int = setInterval(() => {
const n = findToasterRoot(tree, 0);
if (n?.return) {
clearInterval(int);
this.node = n.return;
this.rNode = this.node.return;
repatch();
} else {
this.error('Failed to re-grab Toaster node, trying again...');
}
}, 1200);
}
repatch();
return ret;
};
this.rNode.stateNode.shouldComponentUpdate = () => true;
this.rNode.stateNode.forceUpdate();
delete this.rNode.stateNode.shouldComponentUpdate;
this.audioModule = findModuleChild((m: Module) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
try {
if (m[prop].PlayNavSound && m[prop].RegisterCallbackOnPlaySound) return m[prop];
} catch {
return undefined;
}
}
});
this.log('Initialized');
this.finishStartup?.();
}
async toast(toast: ToastData) {
// toast.duration = toast.duration || 5e3;
// this.toasterState.addToast(toast);
await this.ready;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
eType: toast.eType || 11,
nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast,
decky: true,
};
// @ts-ignore
toastData.data.appid = () => 0;
if (toast.sound === undefined) toast.sound = 6;
if (toast.playSound === undefined) toast.playSound = true;
if (toast.showToast === undefined) toast.showToast = true;
if (
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
(window.settingsStore.settings.bDisableToastsInGame &&
!toast.critical &&
window.NotificationStore.BIsUserInGame())
)
return;
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
if (toast.showToast) {
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
}
}
deinit() {
this.toasterPatch?.unpatch();
this.node.alternate.type = this.node.type;
delete this.rNode.stateNode.render;
this.ready = new Promise((res) => (this.finishStartup = res));
// this.routerHook.removeGlobalComponent('DeckyToaster');
}
}
export default Toaster;