From 131f0961ff451ec47376483178e092c8d7403b27 Mon Sep 17 00:00:00 2001
From: AAGaming <aagaming@riseup.net>
Date: Mon, 5 Aug 2024 14:07:10 -0400
Subject: Rewrite router/tabs/toaster hooks (#661)

---
 frontend/src/router-hook.tsx | 394 +++++++++++++++++++++++++++++++------------
 1 file changed, 287 insertions(+), 107 deletions(-)

(limited to 'frontend/src/router-hook.tsx')

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();
   }
 }
 
-- 
cgit v1.2.3

