summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAAGaming <aagaming00@protonmail.com>2022-05-30 14:26:54 -0400
committerGitHub <noreply@github.com>2022-05-30 20:26:54 +0200
commit007860f8f771a7ee62b1c384fbe4f741528a75d5 (patch)
treee901ec893500f0531ebb66b5066b2003086570b3
parent44776b393e984e5968c8b092fade56644c39a4a7 (diff)
downloaddecky-loader-007860f8f771a7ee62b1c384fbe4f741528a75d5.tar.gz
decky-loader-007860f8f771a7ee62b1c384fbe4f741528a75d5.zip
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 <jonas@dellinger.dev>
-rw-r--r--.gitignore3
-rw-r--r--backend/main.py2
-rw-r--r--frontend/package.json5
-rw-r--r--frontend/src/components/DeckyRouterState.tsx67
-rw-r--r--frontend/src/components/PluginView.tsx2
-rw-r--r--frontend/src/components/TitleView.tsx20
-rw-r--r--frontend/src/plugin-loader.tsx4
-rw-r--r--frontend/src/router-hook.tsx92
-rw-r--r--frontend/tsconfig.json3
9 files changed, 185 insertions, 13 deletions
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<string, ComponentType>;
+}
+
+export class DeckyRouterState {
+ private _routes: Map<string, ComponentType> = new Map<string, ComponentType>();
+
+ 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<DeckyRouterStateContext>(null as any);
+
+export const useDeckyRouterState = () => useContext(DeckyRouterStateContext);
+
+interface Props {
+ deckyRouterState: DeckyRouterState;
+}
+
+export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRouterState }) => {
+ const [publicDeckyRouterState, setPublicDeckyRouterState] = useState<PublicDeckyRouterState>({
+ ...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 (
+ <DeckyRouterStateContext.Provider value={{ ...publicDeckyRouterState, addRoute, removeRoute }}>
+ {children}
+ </DeckyRouterStateContext.Provider>
+ );
+};
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 (
- <div style={{height: '100%'}}>
+ <div style={{ height: '100%' }}>
<div style={{ position: 'absolute', top: '3px', left: '16px', zIndex: 20 }}>
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={closeActivePlugin}>
<FaArrowLeft style={{ display: 'block' }} />
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 <div className={staticClasses.Title}>
- Decky
- <div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
- <DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={openPluginStore}>
+ return (
+ <div className={staticClasses.Title}>
+ Decky
+ <div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
+ <DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={openPluginStore}>
<FaShoppingBag style={{ display: 'block' }} />
- </DialogButton>
+ </DialogButton>
</div>
- </div>;
+ </div>
+ );
}
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<RouteProps>;
+ 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(<Route path={path}>{createElement(Render)}</Route>);
+ });
+ 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 = (
+ <DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
+ <DeckyWrapper>{ret}</DeckyWrapper>
+ </DeckyRouterStateContextProvider>
+ );
+ 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,