From 007860f8f771a7ee62b1c384fbe4f741528a75d5 Mon Sep 17 00:00:00 2001 From: AAGaming Date: Mon, 30 May 2022 14:26:54 -0400 Subject: react: Add Router hook & fix typescript issues (#68) * add rollup watch command, add pnpm lockfile * wait for react * add WIP patcher, window hook, and webpack * fix typescript, fix React, lint, add pnpm to gitignore * actually fix react * show frontend JS errors in console * cleanup * Add Router hook * Remove console.log * Expose routerHook in createPluginAPI Co-authored-by: Jonas Dellinger --- .gitignore | 3 + backend/main.py | 2 +- frontend/package.json | 5 +- frontend/src/components/DeckyRouterState.tsx | 67 ++++++++++++++++++++ frontend/src/components/PluginView.tsx | 2 +- frontend/src/components/TitleView.tsx | 20 +++--- frontend/src/plugin-loader.tsx | 4 ++ frontend/src/router-hook.tsx | 92 ++++++++++++++++++++++++++++ frontend/tsconfig.json | 3 +- 9 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/DeckyRouterState.tsx create mode 100644 frontend/src/router-hook.tsx diff --git a/.gitignore b/.gitignore index 4ab519c0..baa5d0bc 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,6 @@ cython_debug/ # static files are built backend/static + +# pnpm lockfile +frontend/pnpm-lock.yaml diff --git a/backend/main.py b/backend/main.py index 0bf0a49d..f942ee29 100644 --- a/backend/main.py +++ b/backend/main.py @@ -86,7 +86,7 @@ class PluginManager: async def inject_javascript(self, request=None): try: - await inject_to_tab("SP", open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read(), True) + await inject_to_tab("SP", "try{" + open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read() + "}catch(e){console.error(e)}", True) except: logger.info("Failed to inject JavaScript into tab") pass diff --git a/frontend/package.json b/frontend/package.json index 51bed2db..73c033b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "prepare": "cd .. && husky install frontend/.husky", "build": "rollup -c", + "watch": "rollup -c -w", "lint": "prettier -c src", "format": "prettier -c src -w" }, @@ -23,7 +24,9 @@ "prettier-plugin-import-sort": "^0.0.7", "react": "16.14.0", "react-dom": "16.14.0", - "rollup": "^2.70.2" + "rollup": "^2.70.2", + "tslib": "^2.4.0", + "typescript": "^4.7.2" }, "importSort": { ".js, .jsx, .ts, .tsx": { diff --git a/frontend/src/components/DeckyRouterState.tsx b/frontend/src/components/DeckyRouterState.tsx new file mode 100644 index 00000000..3c9a5f9b --- /dev/null +++ b/frontend/src/components/DeckyRouterState.tsx @@ -0,0 +1,67 @@ +import { ComponentType, FC, createContext, useContext, useEffect, useState } from 'react'; + +interface PublicDeckyRouterState { + routes: Map; +} + +export class DeckyRouterState { + private _routes: Map = new Map(); + + public eventBus = new EventTarget(); + + publicState(): PublicDeckyRouterState { + return { routes: this._routes }; + } + + addRoute(path: string, render: ComponentType) { + this._routes.set(path, render); + this.notifyUpdate(); + } + + removeRoute(path: string) { + this._routes.delete(path); + this.notifyUpdate(); + } + + private notifyUpdate() { + this.eventBus.dispatchEvent(new Event('update')); + } +} + +interface DeckyRouterStateContext extends PublicDeckyRouterState { + addRoute(path: string, render: ComponentType): void; + removeRoute(path: string): void; +} + +const DeckyRouterStateContext = createContext(null as any); + +export const useDeckyRouterState = () => useContext(DeckyRouterStateContext); + +interface Props { + deckyRouterState: DeckyRouterState; +} + +export const DeckyRouterStateContextProvider: FC = ({ children, deckyRouterState }) => { + const [publicDeckyRouterState, setPublicDeckyRouterState] = useState({ + ...deckyRouterState.publicState(), + }); + + useEffect(() => { + function onUpdate() { + setPublicDeckyRouterState({ ...deckyRouterState.publicState() }); + } + + deckyRouterState.eventBus.addEventListener('update', onUpdate); + + return () => deckyRouterState.eventBus.removeEventListener('update', onUpdate); + }, []); + + const addRoute = (path: string, render: ComponentType) => deckyRouterState.addRoute(path, render); + const removeRoute = (path: string) => deckyRouterState.removeRoute(path); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index 65288c05..4bc159e2 100644 --- a/frontend/src/components/PluginView.tsx +++ b/frontend/src/components/PluginView.tsx @@ -9,7 +9,7 @@ const PluginView: VFC = () => { if (activePlugin) { return ( -
+
diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx index a9e1017a..8ca81028 100644 --- a/frontend/src/components/TitleView.tsx +++ b/frontend/src/components/TitleView.tsx @@ -1,23 +1,25 @@ -import { staticClasses, DialogButton } from 'decky-frontend-lib'; +import { DialogButton, staticClasses } from 'decky-frontend-lib'; import { VFC } from 'react'; -import { FaShoppingBag } from "react-icons/fa"; +import { FaShoppingBag } from 'react-icons/fa'; import { useDeckyState } from './DeckyState'; const TitleView: VFC = () => { const { activePlugin } = useDeckyState(); - const openPluginStore = () => fetch("http://127.0.0.1:1337/methods/open_plugin_store", {method: "POST"}); + const openPluginStore = () => fetch('http://127.0.0.1:1337/methods/open_plugin_store', { method: 'POST' }); if (activePlugin === null) { - return
- Decky -
- + return ( +
+ Decky +
+ - +
-
; +
+ ); } return ( diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index ddb92542..fc1f57e9 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -6,6 +6,7 @@ import PluginView from './components/PluginView'; import TitleView from './components/TitleView'; import Logger from './logger'; import { Plugin } from './plugin'; +import RouterHook from './router-hook'; import TabsHook from './tabs-hook'; declare global { @@ -15,6 +16,8 @@ declare global { class PluginLoader extends Logger { private plugins: Plugin[] = []; private tabsHook: TabsHook = new TabsHook(); + // private windowHook: WindowHook = new WindowHook(); + private routerHook: RouterHook = new RouterHook(); private deckyState: DeckyState = new DeckyState(); constructor() { @@ -81,6 +84,7 @@ class PluginLoader extends Logger { static createPluginAPI(pluginName: string) { return { + routerHook: this.routerHook, async callServerMethod(methodName: string, args = {}) { const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, { method: 'POST', diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx new file mode 100644 index 00000000..ef2844b6 --- /dev/null +++ b/frontend/src/router-hook.tsx @@ -0,0 +1,92 @@ +import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib'; +import { FC, ReactElement, createElement } from 'react'; + +import { DeckyRouterState, DeckyRouterStateContextProvider, useDeckyRouterState } from './components/DeckyRouterState'; +import Logger from './logger'; + +declare global { + interface Window { + __ROUTER_HOOK_INSTANCE: any; + } +} + +interface RouteProps { + path: string; + children: ReactElement; +} + +class RouterHook extends Logger { + private router: any; + private memoizedRouter: any; + private gamepadWrapper: any; + private routerState: DeckyRouterState = new DeckyRouterState(); + + constructor() { + super('RouterHook'); + + this.log('Initialized'); + window.__ROUTER_HOOK_INSTANCE?.deinit?.(); + window.__ROUTER_HOOK_INSTANCE = this; + + this.gamepadWrapper = findModuleChild((m) => { + if (typeof m !== 'object') return undefined; + for (let prop in m) { + if (m[prop]?.render?.toString()?.includes('["flow-children","onActivate","onCancel","focusClassName",')) + return m[prop]; + } + }); + + let Route: FC; + const DeckyWrapper = ({ children }: { children: ReactElement }) => { + const { routes } = useDeckyRouterState(); + + const routerIndex = children.props.children[0].props.children.length - 1; + if ( + !children.props.children[0].props.children[routerIndex].length || + children.props.children[0].props.children !== routes.size + ) { + const newRouterArray: ReactElement[] = []; + routes.forEach((Render, path) => { + newRouterArray.push({createElement(Render)}); + }); + children.props.children[0].props.children[routerIndex] = newRouterArray; + } + return children; + }; + + afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => { + if (ret?.props?.children?.props?.children?.length == 5) { + if ( + ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type + ?.toString() + ?.includes('GamepadUI.Settings.Root()') + ) { + if (!this.router) { + this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type; + 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 = window.SP_REACT.memo(this.router.type); + this.memoizedRouter.isDeckyRouter = true; + } + ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter; + } + } + return ret; + }); + } + + deinit() { + unpatch(this.gamepadWrapper, 'render'); + this.router && unpatch(this.router, 'type'); + } +} + +export default RouterHook; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2901f27f..13b0c350 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -3,7 +3,8 @@ "outDir": "dist", "module": "ESNext", "target": "ES2020", - "jsx": "react-jsx", + "jsx": "react", + "jsxFactory": "window.SP_REACT.createElement", "declaration": false, "moduleResolution": "node", "noUnusedLocals": true, -- cgit v1.2.3