mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-11 22:31:59 +00:00
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>
This commit is contained in:
@@ -153,3 +153,6 @@ cython_debug/
|
|||||||
|
|
||||||
# static files are built
|
# static files are built
|
||||||
backend/static
|
backend/static
|
||||||
|
|
||||||
|
# pnpm lockfile
|
||||||
|
frontend/pnpm-lock.yaml
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ class PluginManager:
|
|||||||
|
|
||||||
async def inject_javascript(self, request=None):
|
async def inject_javascript(self, request=None):
|
||||||
try:
|
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:
|
except:
|
||||||
logger.info("Failed to inject JavaScript into tab")
|
logger.info("Failed to inject JavaScript into tab")
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "cd .. && husky install frontend/.husky",
|
"prepare": "cd .. && husky install frontend/.husky",
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
|
"watch": "rollup -c -w",
|
||||||
"lint": "prettier -c src",
|
"lint": "prettier -c src",
|
||||||
"format": "prettier -c src -w"
|
"format": "prettier -c src -w"
|
||||||
},
|
},
|
||||||
@@ -23,7 +24,9 @@
|
|||||||
"prettier-plugin-import-sort": "^0.0.7",
|
"prettier-plugin-import-sort": "^0.0.7",
|
||||||
"react": "16.14.0",
|
"react": "16.14.0",
|
||||||
"react-dom": "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": {
|
"importSort": {
|
||||||
".js, .jsx, .ts, .tsx": {
|
".js, .jsx, .ts, .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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
import { staticClasses, DialogButton } from 'decky-frontend-lib';
|
import { DialogButton, staticClasses } from 'decky-frontend-lib';
|
||||||
import { VFC } from 'react';
|
import { VFC } from 'react';
|
||||||
import { FaShoppingBag } from "react-icons/fa";
|
import { FaShoppingBag } from 'react-icons/fa';
|
||||||
|
|
||||||
import { useDeckyState } from './DeckyState';
|
import { useDeckyState } from './DeckyState';
|
||||||
|
|
||||||
const TitleView: VFC = () => {
|
const TitleView: VFC = () => {
|
||||||
const { activePlugin } = useDeckyState();
|
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) {
|
if (activePlugin === null) {
|
||||||
return <div className={staticClasses.Title}>
|
return (
|
||||||
|
<div className={staticClasses.Title}>
|
||||||
Decky
|
Decky
|
||||||
<div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
|
<div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
|
||||||
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={openPluginStore}>
|
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={openPluginStore}>
|
||||||
<FaShoppingBag style={{ display: 'block' }} />
|
<FaShoppingBag style={{ display: 'block' }} />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import PluginView from './components/PluginView';
|
|||||||
import TitleView from './components/TitleView';
|
import TitleView from './components/TitleView';
|
||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
import { Plugin } from './plugin';
|
import { Plugin } from './plugin';
|
||||||
|
import RouterHook from './router-hook';
|
||||||
import TabsHook from './tabs-hook';
|
import TabsHook from './tabs-hook';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -15,6 +16,8 @@ declare global {
|
|||||||
class PluginLoader extends Logger {
|
class PluginLoader extends Logger {
|
||||||
private plugins: Plugin[] = [];
|
private plugins: Plugin[] = [];
|
||||||
private tabsHook: TabsHook = new TabsHook();
|
private tabsHook: TabsHook = new TabsHook();
|
||||||
|
// private windowHook: WindowHook = new WindowHook();
|
||||||
|
private routerHook: RouterHook = new RouterHook();
|
||||||
private deckyState: DeckyState = new DeckyState();
|
private deckyState: DeckyState = new DeckyState();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -81,6 +84,7 @@ class PluginLoader extends Logger {
|
|||||||
|
|
||||||
static createPluginAPI(pluginName: string) {
|
static createPluginAPI(pluginName: string) {
|
||||||
return {
|
return {
|
||||||
|
routerHook: this.routerHook,
|
||||||
async callServerMethod(methodName: string, args = {}) {
|
async callServerMethod(methodName: string, args = {}) {
|
||||||
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react",
|
||||||
|
"jsxFactory": "window.SP_REACT.createElement",
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user