summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/DeckyGlobalComponentsState.tsx12
-rw-r--r--frontend/src/components/DeckyMenuState.tsx147
-rw-r--r--frontend/src/menu-hook.tsx212
-rw-r--r--frontend/src/plugin-loader.tsx18
-rw-r--r--frontend/src/router-hook.tsx18
5 files changed, 383 insertions, 24 deletions
diff --git a/frontend/src/components/DeckyGlobalComponentsState.tsx b/frontend/src/components/DeckyGlobalComponentsState.tsx
index fe45588b..e3fd6342 100644
--- a/frontend/src/components/DeckyGlobalComponentsState.tsx
+++ b/frontend/src/components/DeckyGlobalComponentsState.tsx
@@ -14,13 +14,13 @@ export class DeckyGlobalComponentsState {
return { components: this._components };
}
- addComponent(path: string, component: FC) {
- this._components.set(path, component);
+ addComponent(name: string, component: FC) {
+ this._components.set(name, component);
this.notifyUpdate();
}
- removeComponent(path: string) {
- this._components.delete(path);
+ removeComponent(name: string) {
+ this._components.delete(name);
this.notifyUpdate();
}
@@ -30,8 +30,8 @@ export class DeckyGlobalComponentsState {
}
interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
- addComponent(path: string, component: FC): void;
- removeComponent(path: string): void;
+ addComponent(name: string, component: FC): void;
+ removeComponent(name: string): void;
}
const DeckyGlobalComponentsContext = createContext<DeckyGlobalComponentsContext>(null as any);
diff --git a/frontend/src/components/DeckyMenuState.tsx b/frontend/src/components/DeckyMenuState.tsx
new file mode 100644
index 00000000..a4a6a999
--- /dev/null
+++ b/frontend/src/components/DeckyMenuState.tsx
@@ -0,0 +1,147 @@
+import { CustomMainMenuItem, ItemPatch, OverlayPatch } from 'decky-frontend-lib';
+import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
+
+interface PublicDeckyMenuState {
+ items: Set<CustomMainMenuItem>;
+ itemPatches: Map<string, Set<ItemPatch>>;
+ overlayPatches: Set<OverlayPatch>;
+ overlayComponents: Set<ReactNode>;
+}
+
+export class DeckyMenuState {
+ private _items = new Set<CustomMainMenuItem>();
+ private _itemPatches = new Map<string, Set<ItemPatch>>();
+ private _overlayPatches = new Set<OverlayPatch>();
+ private _overlayComponents = new Set<ReactNode>();
+
+ public eventBus = new EventTarget();
+
+ publicState(): PublicDeckyMenuState {
+ return {
+ items: this._items,
+ itemPatches: this._itemPatches,
+ overlayPatches: this._overlayPatches,
+ overlayComponents: this._overlayComponents,
+ };
+ }
+
+ addItem(item: CustomMainMenuItem) {
+ this._items.add(item);
+ this.notifyUpdate();
+ return item;
+ }
+
+ addPatch(path: string, patch: ItemPatch) {
+ let patchList = this._itemPatches.get(path);
+ if (!patchList) {
+ patchList = new Set();
+ this._itemPatches.set(path, patchList);
+ }
+ patchList.add(patch);
+ this.notifyUpdate();
+ return patch;
+ }
+
+ addOverlayPatch(patch: OverlayPatch) {
+ this._overlayPatches.add(patch);
+ this.notifyUpdate();
+ return patch;
+ }
+
+ addOverlayComponent(component: ReactNode) {
+ this._overlayComponents.add(component);
+ this.notifyUpdate();
+ return component;
+ }
+
+ removePatch(path: string, patch: ItemPatch) {
+ const patchList = this._itemPatches.get(path);
+ patchList?.delete(patch);
+ if (patchList?.size == 0) {
+ this._itemPatches.delete(path);
+ }
+ this.notifyUpdate();
+ }
+
+ removeItem(item: CustomMainMenuItem) {
+ this._items.delete(item);
+ this.notifyUpdate();
+ return item;
+ }
+
+ removeOverlayPatch(patch: OverlayPatch) {
+ this._overlayPatches.delete(patch);
+ this.notifyUpdate();
+ }
+
+ removeOverlayComponent(component: ReactNode) {
+ this._overlayComponents.delete(component);
+ this.notifyUpdate();
+ }
+
+ private notifyUpdate() {
+ this.eventBus.dispatchEvent(new Event('update'));
+ }
+}
+
+interface DeckyMenuStateContext extends PublicDeckyMenuState {
+ addItem: DeckyMenuState['addItem'];
+ addPatch: DeckyMenuState['addPatch'];
+ addOverlayPatch: DeckyMenuState['addOverlayPatch'];
+ addOverlayComponent: DeckyMenuState['addOverlayComponent'];
+ removePatch: DeckyMenuState['removePatch'];
+ removeOverlayPatch: DeckyMenuState['removeOverlayPatch'];
+ removeOverlayComponent: DeckyMenuState['removeOverlayComponent'];
+ removeItem: DeckyMenuState['removeItem'];
+}
+
+const DeckyMenuStateContext = createContext<DeckyMenuStateContext>(null as any);
+
+export const useDeckyMenuState = () => useContext(DeckyMenuStateContext);
+
+interface Props {
+ deckyMenuState: DeckyMenuState;
+}
+
+export const DeckyMenuStateContextProvider: FC<Props> = ({ children, deckyMenuState }) => {
+ const [publicDeckyMenuState, setPublicDeckyMenuState] = useState<PublicDeckyMenuState>({
+ ...deckyMenuState.publicState(),
+ });
+
+ useEffect(() => {
+ function onUpdate() {
+ setPublicDeckyMenuState({ ...deckyMenuState.publicState() });
+ }
+
+ deckyMenuState.eventBus.addEventListener('update', onUpdate);
+
+ return () => deckyMenuState.eventBus.removeEventListener('update', onUpdate);
+ }, []);
+
+ const addItem = deckyMenuState.addItem.bind(deckyMenuState);
+ const addPatch = deckyMenuState.addPatch.bind(deckyMenuState);
+ const addOverlayPatch = deckyMenuState.addOverlayPatch.bind(deckyMenuState);
+ const addOverlayComponent = deckyMenuState.addOverlayComponent.bind(deckyMenuState);
+ const removePatch = deckyMenuState.removePatch.bind(deckyMenuState);
+ const removeOverlayPatch = deckyMenuState.removeOverlayPatch.bind(deckyMenuState);
+ const removeOverlayComponent = deckyMenuState.removeOverlayComponent.bind(deckyMenuState);
+ const removeItem = deckyMenuState.removeItem.bind(deckyMenuState);
+
+ return (
+ <DeckyMenuStateContext.Provider
+ value={{
+ ...publicDeckyMenuState,
+ addItem,
+ addPatch,
+ addOverlayPatch,
+ addOverlayComponent,
+ removePatch,
+ removeOverlayPatch,
+ removeOverlayComponent,
+ removeItem,
+ }}
+ >
+ {children}
+ </DeckyMenuStateContext.Provider>
+ );
+};
diff --git a/frontend/src/menu-hook.tsx b/frontend/src/menu-hook.tsx
new file mode 100644
index 00000000..d4bc5410
--- /dev/null
+++ b/frontend/src/menu-hook.tsx
@@ -0,0 +1,212 @@
+import {
+ CustomMainMenuItem,
+ ItemPatch,
+ MainMenuItem,
+ OverlayPatch,
+ afterPatch,
+ findInReactTree,
+ sleep,
+} from 'decky-frontend-lib';
+import { FC } from 'react';
+import { ReactNode, cloneElement, createElement } from 'react';
+
+import { DeckyMenuState, DeckyMenuStateContextProvider, useDeckyMenuState } from './components/DeckyMenuState';
+import Logger from './logger';
+
+declare global {
+ interface Window {
+ __MENU_HOOK_INSTANCE: any;
+ }
+}
+
+class MenuHook extends Logger {
+ private menuRenderer?: any;
+ private originalRenderer?: any;
+ private menuState: DeckyMenuState = new DeckyMenuState();
+
+ constructor() {
+ super('MenuHook');
+
+ this.log('Initialized');
+ window.__MENU_HOOK_INSTANCE?.deinit?.();
+ window.__MENU_HOOK_INSTANCE = this;
+ }
+
+ init() {
+ const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
+ let outerMenuRoot: any;
+ const findMenuRoot = (currentNode: any, iters: number): any => {
+ if (iters >= 60) {
+ // currently 54
+ return null;
+ }
+ if (currentNode?.memoizedProps?.navID == 'MainNavMenuContainer') {
+ this.log(`Menu root was found in ${iters} recursion cycles`);
+ return currentNode;
+ }
+ if (currentNode.child) {
+ let node = findMenuRoot(currentNode.child, iters + 1);
+ if (node !== null) return node;
+ }
+ if (currentNode.sibling) {
+ let node = findMenuRoot(currentNode.sibling, iters + 1);
+ if (node !== null) return node;
+ }
+ return null;
+ };
+
+ (async () => {
+ outerMenuRoot = findMenuRoot(tree, 0);
+ while (!outerMenuRoot) {
+ this.error(
+ 'Failed to find Menu root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
+ );
+ await sleep(5000);
+ outerMenuRoot = findMenuRoot(tree, 0);
+ }
+ this.log('found outermenuroot', outerMenuRoot);
+ const menuRenderer = outerMenuRoot.return;
+ this.menuRenderer = menuRenderer;
+ this.originalRenderer = menuRenderer.type;
+ let toReplace = new Map<string, ReactNode>();
+
+ let patchedInnerMenu: any;
+ let overlayComponentManager: any;
+
+ const DeckyOverlayComponentManager = () => {
+ const { overlayComponents } = useDeckyMenuState();
+
+ return <>{overlayComponents.values()}</>;
+ };
+
+ const DeckyInnerMenuWrapper = (props: { innerProps: any }) => {
+ const { overlayPatches } = useDeckyMenuState();
+
+ const rendererRet = this.originalRenderer(props.innerProps);
+
+ // Find the first array of children, this contains [mainmenu, overlay]
+ const childArray = findInReactTree(rendererRet, (x) => x?.[0]?.type);
+
+ // Insert the overlay components manager
+ if (!overlayComponentManager) {
+ overlayComponentManager = <DeckyOverlayComponentManager />;
+ }
+
+ childArray.push(overlayComponentManager);
+
+ // This must be cached in patchedInnerMenu to prevent re-renders
+ if (patchedInnerMenu) {
+ childArray[0].type = patchedInnerMenu;
+ } else {
+ afterPatch(childArray[0], 'type', (menuArgs, ret) => {
+ const { itemPatches, items } = useDeckyMenuState();
+
+ const itemList = ret.props.children;
+
+ // Add custom menu items
+ if (items.size > 0) {
+ const button = findInReactTree(ret.props.children, (x) =>
+ x?.type?.toString()?.includes('exactRouteMatch:'),
+ );
+
+ const MenuItemComponent: FC<MainMenuItem> = button.type;
+
+ items.forEach((item) => {
+ let realIndex = 0; // there are some non-item things in the array
+ let count = 0;
+ itemList.forEach((i: any) => {
+ if (count == item.index) return;
+ if (i?.type == MenuItemComponent) count++;
+ realIndex++;
+ });
+ itemList.splice(realIndex, 0, createElement(MenuItemComponent, item));
+ });
+ }
+
+ // Apply and revert patches
+ itemList.forEach((item: { props: MainMenuItem }, index: number) => {
+ if (!item?.props?.route) return;
+ const replaced = toReplace.get(item?.props?.route as string);
+ if (replaced) {
+ itemList[index] = replaced;
+ toReplace.delete(item?.props.route as string);
+ }
+ if (item?.props?.route && itemPatches.has(item.props.route as string)) {
+ toReplace.set(item?.props?.route as string, itemList[index]);
+ itemPatches.get(item.props.route as string)?.forEach((patch) => {
+ const oType = itemList[index].type;
+ itemList[index] = patch({
+ ...cloneElement(itemList[index]),
+ type: (props) => createElement(oType, props),
+ });
+ });
+ }
+ });
+
+ return ret;
+ });
+ patchedInnerMenu = childArray[0].type;
+ }
+
+ // Apply patches to the overlay
+ if (childArray[1]) {
+ overlayPatches.forEach((patch) => (childArray[1] = patch(childArray[1])));
+ }
+
+ return rendererRet;
+ };
+
+ const DeckyOuterMenuWrapper = (props: any) => {
+ return (
+ <DeckyMenuStateContextProvider deckyMenuState={this.menuState}>
+ <DeckyInnerMenuWrapper innerProps={props} />
+ </DeckyMenuStateContextProvider>
+ );
+ };
+ menuRenderer.type = DeckyOuterMenuWrapper;
+ if (menuRenderer.alternate) {
+ menuRenderer.alternate.type = menuRenderer.type;
+ }
+ this.log('Finished initial injection');
+ })();
+ }
+
+ deinit() {
+ this.menuRenderer.type = this.originalRenderer;
+ this.menuRenderer.alternate.type = this.menuRenderer.type;
+ }
+
+ addItem(item: CustomMainMenuItem) {
+ return this.menuState.addItem(item);
+ }
+
+ addPatch(path: string, patch: ItemPatch) {
+ return this.menuState.addPatch(path, patch);
+ }
+
+ addOverlayPatch(patch: OverlayPatch) {
+ return this.menuState.addOverlayPatch(patch);
+ }
+
+ addOverlayComponent(component: ReactNode) {
+ return this.menuState.addOverlayComponent(component);
+ }
+
+ removePatch(path: string, patch: ItemPatch) {
+ return this.menuState.removePatch(path, patch);
+ }
+
+ removeItem(item: CustomMainMenuItem) {
+ return this.menuState.removeItem(item);
+ }
+
+ removeOverlayPatch(patch: OverlayPatch) {
+ return this.menuState.removeOverlayPatch(patch);
+ }
+
+ removeOverlayComponent(component: ReactNode) {
+ return this.menuState.removeOverlayComponent(component);
+ }
+}
+
+export default MenuHook;
diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index c37e168c..e62c133f 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -1,13 +1,4 @@
-import {
- ConfirmModal,
- ModalRoot,
- Patch,
- QuickAccessTab,
- Router,
- showModal,
- sleep,
- staticClasses,
-} from 'decky-frontend-lib';
+import { ConfirmModal, ModalRoot, QuickAccessTab, Router, showModal, sleep, staticClasses } from 'decky-frontend-lib';
import { FC, lazy } from 'react';
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
@@ -19,6 +10,7 @@ import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView';
import WithSuspense from './components/WithSuspense';
import Logger from './logger';
+import MenuHook from './menu-hook';
import { Plugin } from './plugin';
import RouterHook from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
@@ -37,6 +29,7 @@ const FilePicker = lazy(() => import('./components/modals/filepicker'));
class PluginLoader extends Logger {
private plugins: Plugin[] = [];
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
+ private menuHook: MenuHook = new MenuHook();
// private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook();
public toaster: Toaster = new Toaster();
@@ -46,11 +39,10 @@ class PluginLoader extends Logger {
// stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: { name: string; version?: string }[] = [];
- private focusWorkaroundPatch?: Patch;
-
constructor() {
super(PluginLoader.name);
this.tabsHook.init();
+ this.menuHook.init();
this.log('Initialized');
const TabBadge = () => {
@@ -185,7 +177,6 @@ class PluginLoader extends Logger {
this.routerHook.removeRoute('/decky/settings');
deinitSteamFixes();
deinitFilepickerPatches();
- this.focusWorkaroundPatch?.unpatch();
}
public unloadPlugin(name: string) {
@@ -322,6 +313,7 @@ class PluginLoader extends Logger {
createPluginAPI(pluginName: string) {
return {
+ menuHook: this.menuHook,
routerHook: this.routerHook,
toaster: this.toaster,
callServerMethod: this.callServerMethod,
diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx
index bf3ae0cb..c24e2a73 100644
--- a/frontend/src/router-hook.tsx
+++ b/frontend/src/router-hook.tsx
@@ -120,6 +120,8 @@ class RouterHook extends Logger {
return <>{renderedComponents}</>;
};
+ let globalComponents: any;
+
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
@@ -143,11 +145,17 @@ class RouterHook extends Logger {
this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true;
}
- ret.props.children.props.children.push(
- <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
- <DeckyGlobalComponentsWrapper />
- </DeckyGlobalComponentsStateContextProvider>,
- );
+
+ if (!globalComponents) {
+ globalComponents = (
+ <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
+ <DeckyGlobalComponentsWrapper />
+ </DeckyGlobalComponentsStateContextProvider>
+ );
+ }
+
+ ret.props.children.props.children.push(globalComponents);
+
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
}
}