diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | backend/main.py | 2 | ||||
| -rw-r--r-- | frontend/package.json | 5 | ||||
| -rw-r--r-- | frontend/src/components/DeckyRouterState.tsx | 67 | ||||
| -rw-r--r-- | frontend/src/components/PluginView.tsx | 2 | ||||
| -rw-r--r-- | frontend/src/components/TitleView.tsx | 20 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/router-hook.tsx | 92 | ||||
| -rw-r--r-- | frontend/tsconfig.json | 3 |
9 files changed, 185 insertions, 13 deletions
@@ -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, |
