implement new toaster hook

this also supports the notification list and probably also desktop toasts (UI wip, read location enum prop from toast component probably)
This commit is contained in:
AAGaming
2024-07-18 01:20:29 -04:00
parent 28c7254ef6
commit 88e7919a12
5 changed files with 86 additions and 173 deletions
+5 -5
View File
@@ -69,13 +69,13 @@ class ErrorBoundaryHook extends Logger {
});
if (!ErrorBoundary) {
this.error('could not find ValveErrorBoundary');
this.error('@decky/ui could not find ErrorBoundary, skipping patch');
return;
}
this.errorBoundaryPatch = replacePatch(ErrorBoundary.prototype, 'render', function (this: any) {
if (this.state._deckyForceRerender) {
const stateClone = {...this.state, _deckyForceRerender: null};
const stateClone = { ...this.state, _deckyForceRerender: null };
this.setState(stateClone);
return null;
}
@@ -93,9 +93,9 @@ class ErrorBoundaryHook extends Logger {
return callOriginal;
});
// Small hack that gives us a lot more flexibility to force rerenders.
ValveErrorBoundary.prototype._deckyForceRerender = function (this: any) {
this.setState({...this.state, _deckyForceRerender: true});
}
ErrorBoundary.prototype._deckyForceRerender = function (this: any) {
this.setState({ ...this.state, _deckyForceRerender: true });
};
}
public temporarilyDisableReporting() {
-1
View File
@@ -36,7 +36,6 @@ import Toaster from './toaster';
import { getVersionInfo } from './updater';
import { getSetting, setSetting } from './utils/settings';
import TranslationHelper, { TranslationClass } from './utils/TranslationHelper';
import AppHook from './app-hook';
const StorePage = lazy(() => import('./components/store/Store'));
const SettingsPage = lazy(() => import('./components/settings'));
+19 -16
View File
@@ -1,5 +1,5 @@
import { ErrorBoundary, Focusable, Patch, afterPatch, beforePatch, findInReactTree, findModuleByExport, findModuleExport, getReactRoot, sleep } from '@decky/ui';
import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import { ErrorBoundary, Patch, afterPatch, findInReactTree, getReactRoot, sleep } from '@decky/ui';
import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react';
import type { Route } from 'react-router';
import {
@@ -32,6 +32,7 @@ class RouterHook extends Logger {
private DeckyWrapper = this.routerWrapper.bind(this);
private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this);
private toReplace = new Map<string, ReactNode>();
private routerPatch?: Patch;
public routes?: any[];
constructor() {
@@ -41,22 +42,25 @@ class RouterHook extends Logger {
window.__ROUTER_HOOK_INSTANCE?.deinit?.();
window.__ROUTER_HOOK_INSTANCE = this;
(async()=> {
(async () => {
const root = getReactRoot(document.getElementById('root') as any);
// TODO be more specific, this is horrible and very very slow
const findRouterNode = () =>findInReactTree(root, node => typeof node?.pendingProps?.loggedIn == "undefined" && node?.type?.toString().includes("Settings.Root()"));
const findRouterNode = () =>
findInReactTree(
root,
(node) =>
typeof node?.pendingProps?.loggedIn == 'undefined' && node?.type?.toString().includes('Settings.Root()'),
);
let routerNode = findRouterNode();
while (!routerNode) {
this.warn(
'Failed to find Router node, reattempting in 5 seconds.',
);
this.warn('Failed to find Router node, reattempting in 5 seconds.');
await sleep(5000);
routerNode = findRouterNode();
}
if (routerNode) {
this.debug("routerNode", routerNode);
this.debug('routerNode', routerNode);
// Patch the component globally
afterPatch(routerNode.elementType, "type", this.handleRouterRender.bind(this));
this.routerPatch = afterPatch(routerNode.elementType, 'type', this.handleRouterRender.bind(this));
// Swap out the current instance
routerNode.type = routerNode.elementType.type;
if (routerNode?.alternate) {
@@ -91,14 +95,14 @@ class RouterHook extends Logger {
return returnVal;
}
private globalComponentsWrapper () {
private globalComponentsWrapper() {
const { components } = useDeckyGlobalComponentsState();
if (this.renderedComponents.length != components.size) {
this.debug('Rerendering global components');
this.renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
}
return <>{this.renderedComponents}</>;
};
}
private routerWrapper({ children }: { children: ReactElement }) {
// Used to store the new replicated routes we create to allow routes to be unpatched.
@@ -106,7 +110,7 @@ class RouterHook extends Logger {
const { routes, routePatches } = useDeckyRouterState();
// TODO make more redundant
if (!children?.props?.children?.[0]?.props?.children) {
console.log("routerWrapper wrong component?", children)
console.log('routerWrapper wrong component?', children);
return children;
}
const mainRouteList = children.props.children[0].props.children;
@@ -116,7 +120,7 @@ class RouterHook extends Logger {
this.debug('Rerendered routes list');
return children;
};
}
private processList(
routeList: any[],
@@ -167,7 +171,7 @@ class RouterHook extends Logger {
});
}
});
};
}
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
this.routerState.addRoute(path, component, props);
@@ -194,8 +198,7 @@ class RouterHook extends Logger {
}
deinit() {
// this.wrapperPatch.unpatch();
// this.routerPatch?.unpatch();
this.routerPatch?.unpatch();
}
}
+26 -15
View File
@@ -1,5 +1,14 @@
// TabsHook for versions after the Desktop merge
import { ErrorBoundary, Patch, QuickAccessTab, afterPatch, createReactTreePatcher, findInReactTree, findModuleByExport, getReactRoot, setReactPatcherLoggingEnabled, sleep } from '@decky/ui';
import {
ErrorBoundary,
Patch,
QuickAccessTab,
afterPatch,
createReactTreePatcher,
findInReactTree,
findModuleByExport,
getReactRoot,
} from '@decky/ui';
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
import Logger from './logger';
@@ -20,9 +29,7 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
private qAMRoot?: any;
private qamPatch?: Patch;
private cachedTabs: any;
constructor() {
super('TabsHook');
@@ -34,25 +41,29 @@ class TabsHook extends Logger {
init() {
// TODO patch the "embedded" renderer in this module too (seems to be for VR? unsure)
const qamModule = findModuleByExport(e => e?.type?.toString()?.includes("QuickAccessMenuBrowserView"));
const qamRenderer = Object.values(qamModule).find((e: any) => e?.type?.toString()?.includes("QuickAccessMenuBrowserView"))
const qamModule = findModuleByExport((e) => e?.type?.toString()?.includes('QuickAccessMenuBrowserView'));
const qamRenderer = Object.values(qamModule).find((e: any) =>
e?.type?.toString()?.includes('QuickAccessMenuBrowserView'),
);
const patchHandler = createReactTreePatcher([
tree => findInReactTree(tree, node => node?.props?.onFocusNavDeactivated)
], (args, ret) => {
this.log("qam render", args, ret);
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
this.render(tabs.props.tabs, args[0].visible);
return ret;
}, "TabsHook");
const patchHandler = createReactTreePatcher(
[(tree) => findInReactTree(tree, (node) => node?.props?.onFocusNavDeactivated)],
(args, ret) => {
this.log('qam render', args, ret);
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
this.render(tabs.props.tabs, args[0].visible);
return ret;
},
'TabsHook',
);
this.qamPatch = afterPatch(qamRenderer, "type", patchHandler);
this.qamPatch = afterPatch(qamRenderer, 'type', patchHandler);
// Patch already rendered qam
const root = getReactRoot(document.getElementById('root') as any);
const qamNode = root && findInReactTree(root, (n: any) => n.elementType == qamRenderer); // need elementType, because type is actually mobx wrapper
if (qamNode) {
this.debug("qamNode", qamNode);
this.debug('qamNode', qamNode);
// Only affects this fiber node so we don't need to unpatch here
qamNode.type = qamNode.elementType.type;
if (qamNode?.alternate) {
+36 -136
View File
@@ -1,19 +1,16 @@
import type { ToastData } from '@decky/api';
import {
Export,
Patch,
afterPatch,
findClassByName,
findInReactTree,
findModuleExport,
getReactRoot,
sleep,
} from '@decky/ui';
import { ReactNode } from 'react';
import { Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui';
import Toast from './components/Toast';
import Logger from './logger';
// TODO export
enum ToastType {
New,
Update,
Remove,
}
declare global {
interface Window {
__TOASTER_INSTANCE: any;
@@ -23,18 +20,12 @@ declare global {
}
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;
private toastPatch?: Patch;
constructor() {
super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
@@ -42,115 +33,18 @@ class Toaster extends Logger {
}
async init() {
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
// <DeckyToaster />
// </DeckyToasterStateContextProvider>
// ));
let instance: any;
const tree = getReactRoot(document.getElementById('root') as any);
const toasterClass1 = findClassByName('GamepadToastPlaceholder');
const toasterClass2 = findClassByName('ToastPlaceholder');
const toasterClass3 = findClassByName('ToastPopup');
const toasterClass4 = findClassByName('GamepadToastPopup');
const findToasterRoot = (currentNode: any, iters: number): any => {
if (iters >= 80) {
// currently 66
return null;
const ToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
this.debug('toastrenderer', ToastRenderer);
// TODO find a way to undo this if possible?
const patchedRenderer = injectFCTrampoline(ToastRenderer);
this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
this.debug('render toast', args);
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
this.debug('rendering decky toast');
return args[0].group.notifications.map((notification: any) => <Toast toast={notification.data} />);
}
if (
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass1) ||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass2) ||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass3) ||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass4)
) {
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.warn(
'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 = findInReactTree(
this.node.return.return,
(node) => node?.stateNode && node.type?.InstallErrorReportingStore,
);
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 = Object.getPrototypeOf(this.rNode.stateNode).render;
let int: number | undefined;
this.rNode.stateNode.render = (...args: any[]) => {
const ret = oRender.call(this.rNode.stateNode, ...args);
if (ret && !this?.node?.return?.return) {
int && 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 = findModuleExport((e: Export) => e.PlayNavSound && e.RegisterCallbackOnPlaySound);
return callOriginal;
});
this.log('Initialized');
this.finishStartup?.();
@@ -168,8 +62,6 @@ class Toaster extends Logger {
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;
@@ -180,19 +72,27 @@ class Toaster extends Logger {
window.NotificationStore.BIsUserInGame())
)
return;
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
if (toast.showToast) {
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
function fnTray(toast: any, tray: any) {
let group = {
eType: toast.eType,
notifications: [toast],
};
tray.unshift(group);
}
const info = {
showToast: toast.showToast,
sound: toast.sound,
eFeature: 0,
toastDurationMS: toastData.nToastDurationMS,
fnTray,
};
window.NotificationStore.ProcessNotification(info, toastData, ToastType.New);
}
}
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');
this.toastPatch?.unpatch();
}
}