From 28c7254ef6952d9504472ebcbb05238b50aa6086 Mon Sep 17 00:00:00 2001 From: AAGaming Date: Tue, 9 Jul 2024 02:35:24 -0400 Subject: initial implementation of new router and qam hooks --- frontend/src/errorboundary-hook.tsx | 11 +- frontend/src/plugin-loader.tsx | 3 +- frontend/src/router-hook.tsx | 237 +++++++++++++++++++----------------- frontend/src/tabs-hook.tsx | 101 ++++----------- 4 files changed, 164 insertions(+), 188 deletions(-) diff --git a/frontend/src/errorboundary-hook.tsx b/frontend/src/errorboundary-hook.tsx index 8c96d9f4..072c96ca 100644 --- a/frontend/src/errorboundary-hook.tsx +++ b/frontend/src/errorboundary-hook.tsx @@ -22,9 +22,7 @@ class ErrorBoundaryHook extends Logger { this.log('Initialized'); window.__ERRORBOUNDARY_HOOK_INSTANCE?.deinit?.(); window.__ERRORBOUNDARY_HOOK_INSTANCE = this; - } - init() { // valve writes only the sanest of code const exp = /^\(\)=>\(.\|\|.\(new .\),.\)$/; const initErrorReportingStore = findModuleExport( @@ -76,6 +74,11 @@ class ErrorBoundaryHook extends Logger { } this.errorBoundaryPatch = replacePatch(ErrorBoundary.prototype, 'render', function (this: any) { + if (this.state._deckyForceRerender) { + const stateClone = {...this.state, _deckyForceRerender: null}; + this.setState(stateClone); + return null; + } if (this.state.error) { const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore; return ( @@ -89,6 +92,10 @@ 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}); + } } public temporarilyDisableReporting() { diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index f7d362a7..f5ff71b5 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -36,6 +36,7 @@ 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')); @@ -82,8 +83,6 @@ class PluginLoader extends Logger { constructor() { super(PluginLoader.name); - this.errorBoundaryHook.init(); - DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this)); DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this)); DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this)); diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx index e3325913..9aba497e 100644 --- a/frontend/src/router-hook.tsx +++ b/frontend/src/router-hook.tsx @@ -1,4 +1,4 @@ -import { ErrorBoundary, Focusable, Patch, afterPatch } from '@decky/ui'; +import { ErrorBoundary, Focusable, Patch, afterPatch, beforePatch, findInReactTree, findModuleByExport, findModuleExport, getReactRoot, sleep } from '@decky/ui'; import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react'; import type { Route } from 'react-router'; @@ -25,13 +25,13 @@ declare global { const isPatched = Symbol('is patched'); class RouterHook extends Logger { - private router: any; - private memoizedRouter: any; - private gamepadWrapper: any; private routerState: DeckyRouterState = new DeckyRouterState(); private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState(); - private wrapperPatch: Patch; - private routerPatch?: Patch; + private renderedComponents: ReactElement[] = []; + private Route: any; + private DeckyWrapper = this.routerWrapper.bind(this); + private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this); + private toReplace = new Map(); public routes?: any[]; constructor() { @@ -41,114 +41,133 @@ class RouterHook extends Logger { window.__ROUTER_HOOK_INSTANCE?.deinit?.(); window.__ROUTER_HOOK_INSTANCE = this; - this.gamepadWrapper = Focusable; - - let Route: new () => Route; - // Used to store the new replicated routes we create to allow routes to be unpatched. - const processList = ( - routeList: any[], - routes: Map | null, - routePatches: Map>, - save: boolean, - ) => { - this.debug('Route list: ', routeList); - if (save) this.routes = routeList; - let routerIndex = routeList.length; - if (routes) { - if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) { - if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--; - const newRouterArray: (ReactElement | JSX.Element)[] = []; - routes.forEach(({ component, props }, path) => { - newRouterArray.push( - - {createElement(component)} - , - ); - }); - routeList[routerIndex] = newRouterArray; - } + (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()")); + let routerNode = findRouterNode(); + while (!routerNode) { + this.warn( + 'Failed to find Router node, reattempting in 5 seconds.', + ); + await sleep(5000); + routerNode = findRouterNode(); } - routeList.forEach((route: Route, index: number) => { - const replaced = toReplace.get(route?.props?.path as string); - if (replaced) { - routeList[index].props.children = replaced; - toReplace.delete(route?.props?.path as string); - } - if (route?.props?.path && routePatches.has(route.props.path as string)) { - toReplace.set( - route?.props?.path as string, - // @ts-ignore - routeList[index].props.children, - ); - routePatches.get(route.props.path as string)?.forEach((patch) => { - const oType = routeList[index].props.children.type; - routeList[index].props.children = patch({ - ...routeList[index].props, - children: { - ...cloneElement(routeList[index].props.children), - type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props), - }, - }).children; - routeList[index].props.children[isPatched] = true; - }); + if (routerNode) { + this.debug("routerNode", routerNode); + // Patch the component globally + afterPatch(routerNode.elementType, "type", this.handleRouterRender.bind(this)); + // Swap out the current instance + routerNode.type = routerNode.elementType.type; + if (routerNode?.alternate) { + routerNode.alternate.type = routerNode.type; } - }); - }; - let toReplace = new Map(); - const DeckyWrapper = ({ children }: { children: ReactElement }) => { - const { routes, routePatches } = useDeckyRouterState(); - const mainRouteList = children.props.children[0].props.children; - const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning - processList(mainRouteList, routes, routePatches, true); - processList(ingameRouteList, null, routePatches, false); - - this.debug('Rerendered routes list'); - return children; - }; + // Force a full rerender via our custom error boundary + routerNode?.return?.stateNode?._deckyForceRerender?.(); + } + })(); + } + + public handleRouterRender(_: any, ret: any) { + const DeckyWrapper = this.DeckyWrapper; + const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper; + if (!this.Route) + // TODO make more redundant + this.Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type; + if (ret._decky) { + return ret; + } + const returnVal = ( + <> + + {ret} + + + + + + ); + (returnVal as any)._decky = true; + return returnVal; + } - let renderedComponents: ReactElement[] = []; + private globalComponentsWrapper () { + const { components } = useDeckyGlobalComponentsState(); + if (this.renderedComponents.length != components.size) { + this.debug('Rerendering global components'); + this.renderedComponents = Array.from(components.values()).map((GComponent) => ); + } + return <>{this.renderedComponents}; + }; - const DeckyGlobalComponentsWrapper = () => { - const { components } = useDeckyGlobalComponentsState(); - if (renderedComponents.length != components.size) { - this.debug('Rerendering global components'); - renderedComponents = Array.from(components.values()).map((GComponent) => ); - } - return <>{renderedComponents}; - }; - - this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => { - if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) { - const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2; - const potentialSettingsRootString = - ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || ''; - if (potentialSettingsRootString?.includes('Settings.Root()')) { - if (!this.router) { - this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type; - this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => { - if (!Route) - Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type; - const returnVal = ( - - {ret} - - ); - return returnVal; - }); - this.memoizedRouter = memo(this.router.type); - this.memoizedRouter.isDeckyRouter = true; - } - ret.props.children.props.children.push( - - - , + private routerWrapper({ children }: { children: ReactElement }) { + // Used to store the new replicated routes we create to allow routes to be unpatched. + + const { routes, routePatches } = useDeckyRouterState(); + // TODO make more redundant + if (!children?.props?.children?.[0]?.props?.children) { + console.log("routerWrapper wrong component?", children) + return children; + } + const mainRouteList = children.props.children[0].props.children; + const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning + this.processList(mainRouteList, routes, routePatches, true); + this.processList(ingameRouteList, null, routePatches, false); + + this.debug('Rerendered routes list'); + return children; + }; + + private processList( + routeList: any[], + routes: Map | null, + routePatches: Map>, + save: boolean, + ) { + const Route = this.Route; + this.debug('Route list: ', routeList); + if (save) this.routes = routeList; + let routerIndex = routeList.length; + if (routes) { + if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) { + if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--; + const newRouterArray: (ReactElement | JSX.Element)[] = []; + routes.forEach(({ component, props }, path) => { + newRouterArray.push( + + {createElement(component)} + , ); - ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter; - } + }); + routeList[routerIndex] = newRouterArray; + } + } + routeList.forEach((route: Route, index: number) => { + const replaced = this.toReplace.get(route?.props?.path as string); + if (replaced) { + routeList[index].props.children = replaced; + this.toReplace.delete(route?.props?.path as string); + } + if (route?.props?.path && routePatches.has(route.props.path as string)) { + this.toReplace.set( + route?.props?.path as string, + // @ts-ignore + routeList[index].props.children, + ); + routePatches.get(route.props.path as string)?.forEach((patch) => { + const oType = routeList[index].props.children.type; + routeList[index].props.children = patch({ + ...routeList[index].props, + children: { + ...cloneElement(routeList[index].props.children), + type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props), + }, + }).children; + routeList[index].props.children[isPatched] = true; + }); } - return ret; }); - } + }; addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) { this.routerState.addRoute(path, component, props); @@ -175,8 +194,8 @@ class RouterHook extends Logger { } deinit() { - this.wrapperPatch.unpatch(); - this.routerPatch?.unpatch(); + // this.wrapperPatch.unpatch(); + // this.routerPatch?.unpatch(); } } diff --git a/frontend/src/tabs-hook.tsx b/frontend/src/tabs-hook.tsx index 16643165..dbf6ca35 100644 --- a/frontend/src/tabs-hook.tsx +++ b/frontend/src/tabs-hook.tsx @@ -1,5 +1,5 @@ // TabsHook for versions after the Desktop merge -import { ErrorBoundary, Patch, QuickAccessTab, afterPatch, findInReactTree, getReactRoot, sleep } from '@decky/ui'; +import { ErrorBoundary, Patch, QuickAccessTab, afterPatch, createReactTreePatcher, findInReactTree, findModuleByExport, getReactRoot, setReactPatcherLoggingEnabled, sleep } from '@decky/ui'; import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState'; import Logger from './logger'; @@ -22,6 +22,7 @@ class TabsHook extends Logger { tabs: Tab[] = []; private qAMRoot?: any; private qamPatch?: Patch; + private cachedTabs: any; constructor() { super('TabsHook'); @@ -32,87 +33,37 @@ class TabsHook extends Logger { } init() { - const tree = getReactRoot(document.getElementById('root') as any); - let qAMRoot: any; - const findQAMRoot = (currentNode: any, iters: number): any => { - if (iters >= 80) { - // currently 67 - return null; - } - if ( - (typeof currentNode?.memoizedProps?.visible == 'boolean' || - typeof currentNode?.memoizedProps?.active == 'boolean') && - currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView') - ) { - this.log(`QAM root was found in ${iters} recursion cycles`); - return currentNode; - } - if (currentNode.child) { - let node = findQAMRoot(currentNode.child, iters + 1); - if (node !== null) return node; - } - if (currentNode.sibling) { - let node = findQAMRoot(currentNode.sibling, iters + 1); - if (node !== null) return node; - } - return null; - }; - (async () => { - qAMRoot = findQAMRoot(tree, 0); - while (!qAMRoot) { - this.error( - 'Failed to find QAM root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.', - ); - await sleep(5000); - qAMRoot = findQAMRoot(tree, 0); - } - this.qAMRoot = qAMRoot; - let patchedInnerQAM: any; - this.qamPatch = afterPatch(qAMRoot.return, 'type', (_: any, ret: any) => { - try { - if (!qAMRoot?.child) { - qAMRoot = findQAMRoot(tree, 0); - this.qAMRoot = qAMRoot; - } - if (qAMRoot?.child && !qAMRoot?.child?.type?.decky) { - afterPatch(qAMRoot.child, 'type', (_: any, ret: any) => { - try { - const qamTabsRenderer = findInReactTree(ret, (x) => x?.props?.onFocusNavDeactivated); - if (patchedInnerQAM) { - qamTabsRenderer.type = patchedInnerQAM; - } else { - afterPatch(qamTabsRenderer, 'type', (innerArgs: any, ret: any) => { - const tabs = findInReactTree(ret, (x) => x?.props?.tabs); - this.render(tabs.props.tabs, innerArgs[0].visible); - return ret; - }); - patchedInnerQAM = qamTabsRenderer.type; - } - } catch (e) { - this.error('Error patching QAM inner', e); - } - return ret; - }); - qAMRoot.child.type.decky = true; - qAMRoot.child.alternate.type = qAMRoot.child.type; - } - } catch (e) { - this.error('Error patching QAM', e); - } + // 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")) - return ret; - }); + 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"); - if (qAMRoot.return.alternate) { - qAMRoot.return.alternate.type = qAMRoot.return.type; + 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); + // Only affects this fiber node so we don't need to unpatch here + qamNode.type = qamNode.elementType.type; + if (qamNode?.alternate) { + qamNode.alternate.type = qamNode.type; } - this.log('Finished initial injection'); - })(); + } } deinit() { this.qamPatch?.unpatch(); - this.qAMRoot.return.alternate.type = this.qAMRoot.return.type; + // this.qAMRoot.return.alternate.type = this.qAMRoot.return.type; } add(tab: Tab) { -- cgit v1.2.3