diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/DeckyGlobalComponentsState.tsx | 12 | ||||
| -rw-r--r-- | frontend/src/components/DeckyMenuState.tsx | 147 | ||||
| -rw-r--r-- | frontend/src/menu-hook.tsx | 212 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 18 | ||||
| -rw-r--r-- | frontend/src/router-hook.tsx | 18 |
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; } } |
