mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-17 08:47:49 +00:00
Main menu and overlay patching API
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user