Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

react: Add Router hook & fix typescript issues #68

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,6 @@ cython_debug/

# static files are built
backend/static

# pnpm lockfile
frontend/pnpm-lock.yaml
2 changes: 1 addition & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async def loader_reinjector(self):

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
Expand Down
5 changes: 4 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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": {
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/components/DeckyRouterState.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
2 changes: 1 addition & 1 deletion frontend/src/components/PluginView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }} />
Expand Down
20 changes: 11 additions & 9 deletions frontend/src/components/TitleView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/plugin-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down Expand Up @@ -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',
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/router-hook.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 1 deletion frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down