diff options
| author | AAGaming <aagaming@riseup.net> | 2024-08-05 14:07:10 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-08-05 14:07:10 -0400 |
| commit | 131f0961ff451ec47376483178e092c8d7403b27 (patch) | |
| tree | 4d2ea34e8220e14c4b820cc1ad38face7193f6fe /frontend/src/router-hook.tsx | |
| parent | 75aa1e4851445646994ba3a61ff41325403359fb (diff) | |
| download | decky-loader-131f0961ff451ec47376483178e092c8d7403b27.tar.gz decky-loader-131f0961ff451ec47376483178e092c8d7403b27.zip | |
Rewrite router/tabs/toaster hooks (#661)
Diffstat (limited to 'frontend/src/router-hook.tsx')
| -rw-r--r-- | frontend/src/router-hook.tsx | 394 |
1 files changed, 287 insertions, 107 deletions
diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx index e3325913..8cffb738 100644 --- a/frontend/src/router-hook.tsx +++ b/frontend/src/router-hook.tsx @@ -1,5 +1,14 @@ -import { ErrorBoundary, Focusable, Patch, afterPatch } from '@decky/ui'; -import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react'; +import { + ErrorBoundary, + Patch, + afterPatch, + findInReactTree, + findInTree, + findModuleByExport, + getReactRoot, + sleep, +} from '@decky/ui'; +import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react'; import type { Route } from 'react-router'; import { @@ -22,16 +31,26 @@ declare global { } } +export enum UIMode { + BigPicture = 4, + Desktop = 7, +} + 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 DeckyGamepadRouterWrapper = this.gamepadRouterWrapper.bind(this); + private DeckyDesktopRouterWrapper = this.desktopRouterWrapper.bind(this); + private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this); + private toReplace = new Map<string, ReactNode>(); + private desktopRouterPatch?: Patch; + private gamepadRouterPatch?: Patch; + private modeChangeRegistration?: any; + private patchedModes = new Set<number>(); public routes?: any[]; constructor() { @@ -41,112 +60,272 @@ class RouterHook extends Logger { window.__ROUTER_HOOK_INSTANCE?.deinit?.(); window.__ROUTER_HOOK_INSTANCE = this; - this.gamepadWrapper = Focusable; + const reactRouterStackModule = findModuleByExport((e) => e == 'router-backstack', 20); + if (reactRouterStackModule) { + this.Route = + Object.values(reactRouterStackModule).find( + (e) => typeof e == 'function' && /routePath:.\.match\?\.path./.test(e.toString()), + ) || + Object.values(reactRouterStackModule).find( + (e) => typeof e == 'function' && /routePath:null===\(.=.\.match\)/.test(e.toString()), + ); + if (!this.Route) { + this.error('Failed to find Route component'); + } + } else { + this.error('Failed to find router stack module'); + } - 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<string, RouterEntry> | null, - routePatches: Map<string, Set<RoutePatch>>, - 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( - <Route path={path} {...props}> - <ErrorBoundary>{createElement(component)}</ErrorBoundary> - </Route>, - ); - }); - routeList[routerIndex] = newRouterArray; - } + this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => { + this.debug(`UI mode changed to ${mode}`); + if (this.patchedModes.has(mode)) return; + this.patchedModes.add(mode); + this.debug(`Patching router for UI mode ${mode}`); + switch (mode) { + case UIMode.BigPicture: + this.debug('Patching gamepad router'); + this.patchGamepadRouter(); + break; + // Not fully implemented yet + // case UIMode.Desktop: + // this.debug("Patching desktop router"); + // this.patchDesktopRouter(); + // break; + default: + this.warn(`Router patch not implemented for UI mode ${mode}`); + break; } - 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; - }); - } + }); + } + + private async patchGamepadRouter() { + const root = getReactRoot(document.getElementById('root') as any); + const findRouterNode = () => + findInReactTree( + root, + (node) => + typeof node?.pendingProps?.loggedIn == 'undefined' && node?.type?.toString().includes('Settings.Root()'), + ); + await this.waitForUnlock(); + let routerNode = findRouterNode(); + while (!routerNode) { + this.warn('Failed to find Router node, reattempting in 5 seconds.'); + await sleep(5000); + await this.waitForUnlock(); + routerNode = findRouterNode(); + } + if (routerNode) { + // Patch the component globally + this.gamepadRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleGamepadRouterRender.bind(this)); + // Swap out the current instance + routerNode.type = routerNode.elementType.type; + if (routerNode?.alternate) { + routerNode.alternate.type = routerNode.type; + } + // Force a full rerender via our custom error boundary + const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, { + walkable: ['return'], }); - }; - let toReplace = new Map<string, ReactNode>(); - 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; - }; + errorBoundaryNode?.stateNode?._deckyForceRerender?.(); + } + } - let renderedComponents: ReactElement[] = []; + // Currently unused + // @ts-expect-error 6133 + private async patchDesktopRouter() { + const root = getReactRoot(document.getElementById('root') as any); + const findRouterNode = () => + findInReactTree(root, (node) => node?.elementType?.type?.toString()?.includes('bShowDesktopUIContent:')); + let routerNode = findRouterNode(); + while (!routerNode) { + this.warn('Failed to find Router node, reattempting in 5 seconds.'); + await sleep(5000); + routerNode = findRouterNode(); + } + if (routerNode) { + // this.debug("desktop router node", routerNode); + // Patch the component globally + this.desktopRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleDesktopRouterRender.bind(this)); + // Swap out the current instance + routerNode.type = routerNode.elementType.type; + if (routerNode?.alternate) { + routerNode.alternate.type = routerNode.type; + } + // Force a full rerender via our custom error boundary + const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, { + walkable: ['return'], + }); + errorBoundaryNode?.stateNode?._deckyForceRerender?.(); + // this.debug("desktop router node", routerNode); + // // Patch the component globally + // this.desktopRouterPatch = afterPatch(routerNode.type.prototype, 'render', this.handleDesktopRouterRender.bind(this)); + // const stateNodeClone = { render: routerNode.stateNode.render } as any; + // // Patch the current instance. render is readonly so we have to do this. + // Object.assign(stateNodeClone, routerNode.stateNode); + // Object.setPrototypeOf(stateNodeClone, Object.getPrototypeOf(routerNode.stateNode)); + // this.desktopRouterFirstInstancePatch = afterPatch(stateNodeClone, 'render', this.handleDesktopRouterRender.bind(this)); + // routerNode.stateNode = stateNodeClone; + // // Swap out the current instance + // if (routerNode?.alternate) { + // routerNode.alternate.type = routerNode.type; + // routerNode.alternate.stateNode = routerNode.stateNode; + // } + // routerNode.stateNode.forceUpdate(); + // Force a full rerender via our custom error boundary + // const errorBoundaryNode = findInTree(routerNode, e => e?.stateNode?._deckyForceRerender, { walkable: ["return"] }); + // errorBoundaryNode?.stateNode?._deckyForceRerender?.(); + } + } - const DeckyGlobalComponentsWrapper = () => { - const { components } = useDeckyGlobalComponentsState(); - if (renderedComponents.length != components.size) { - this.debug('Rerendering global components'); - renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />); + public async waitForUnlock() { + try { + while (window?.securitystore?.IsLockScreenActive?.()) { + await sleep(500); } - 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 = ( - <DeckyRouterStateContextProvider deckyRouterState={this.routerState}> - <DeckyWrapper>{ret}</DeckyWrapper> - </DeckyRouterStateContextProvider> - ); - return returnVal; - }); - this.memoizedRouter = memo(this.router.type); - this.memoizedRouter.isDeckyRouter = true; - } - ret.props.children.props.children.push( - <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}> - <DeckyGlobalComponentsWrapper /> - </DeckyGlobalComponentsStateContextProvider>, + } catch (e) { + this.warn('Error while checking if unlocked:', e); + } + } + + public handleDesktopRouterRender(_: any, ret: any) { + const DeckyDesktopRouterWrapper = this.DeckyDesktopRouterWrapper; + const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper; + this.debug('desktop router render', ret); + if (ret._decky) { + return ret; + } + const returnVal = ( + <> + <DeckyRouterStateContextProvider deckyRouterState={this.routerState}> + <DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper> + </DeckyRouterStateContextProvider> + <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}> + <DeckyGlobalComponentsWrapper /> + </DeckyGlobalComponentsStateContextProvider> + </> + ); + (returnVal as any)._decky = true; + return returnVal; + } + + public handleGamepadRouterRender(_: any, ret: any) { + const DeckyGamepadRouterWrapper = this.DeckyGamepadRouterWrapper; + const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper; + if (ret._decky) { + return ret; + } + const returnVal = ( + <> + <DeckyRouterStateContextProvider deckyRouterState={this.routerState}> + <DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper> + </DeckyRouterStateContextProvider> + <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}> + <DeckyGlobalComponentsWrapper /> + </DeckyGlobalComponentsStateContextProvider> + </> + ); + (returnVal as any)._decky = true; + return returnVal; + } + + 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 gamepadRouterWrapper({ 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) { + this.debug('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 gamepadui routes list'); + return children; + } + + private desktopRouterWrapper({ children }: { children: ReactElement }) { + // Used to store the new replicated routes we create to allow routes to be unpatched. + this.debug('desktop router wrapper render', children); + const { routes, routePatches } = useDeckyRouterState(); + const routeList = findInReactTree( + children, + (node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/library/home'), + ); + if (!routeList) { + this.debug('routerWrapper wrong component?', children); + return children; + } + const library = children.props.children[1].props.children.props; + if (!Array.isArray(library.children)) { + library.children = [library.children]; + } + this.debug('library', library); + this.processList(library.children, routes, routePatches, true); + + this.debug('Rerendered desktop routes list'); + return children; + } + + private processList( + routeList: any[], + routes: Map<string, RouterEntry> | null, + routePatches: Map<string, Set<RoutePatch>>, + 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( + <Route path={path} {...props}> + <ErrorBoundary>{createElement(component)}</ErrorBoundary> + </Route>, ); - 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; }); } @@ -175,8 +354,9 @@ class RouterHook extends Logger { } deinit() { - this.wrapperPatch.unpatch(); - this.routerPatch?.unpatch(); + this.modeChangeRegistration?.unregister(); + this.gamepadRouterPatch?.unpatch(); + this.desktopRouterPatch?.unpatch(); } } |
