summaryrefslogtreecommitdiff
path: root/frontend/src/hooks
diff options
context:
space:
mode:
authorAAGaming <aagaming@riseup.net>2026-10-26 23:50:59 -0400
committerAAGaming <aagaming@riseup.net>2026-10-26 23:50:59 -0400
commite82a55e9582fbd6d84f3f4510b04480c35e41fe4 (patch)
tree1fe05c2296bbedbf301137a67a28ef542804fd69 /frontend/src/hooks
parent6301508037b30489c42ee9ea8acee97cdea76297 (diff)
downloaddecky-loader-e82a55e9582fbd6d84f3f4510b04480c35e41fe4.tar.gz
decky-loader-e82a55e9582fbd6d84f3f4510b04480c35e41fe4.zip
feat: initial VERY VERY incomplete steam frame support
Diffstat (limited to 'frontend/src/hooks')
-rw-r--r--frontend/src/hooks/router.tsx359
-rw-r--r--frontend/src/hooks/tabs.tsx131
-rw-r--r--frontend/src/hooks/toaster.tsx125
-rw-r--r--frontend/src/hooks/vr-dashboard-bar.tsx105
4 files changed, 720 insertions, 0 deletions
diff --git a/frontend/src/hooks/router.tsx b/frontend/src/hooks/router.tsx
new file mode 100644
index 00000000..670ff8b4
--- /dev/null
+++ b/frontend/src/hooks/router.tsx
@@ -0,0 +1,359 @@
+import {
+ EUIMode,
+ ErrorBoundary,
+ Patch,
+ afterPatch,
+ findInReactTree,
+ findInTree,
+ findModuleByExport,
+ getReactRoot,
+ sleep,
+} from '@decky/ui';
+import { FC, JSX, ReactElement, ReactNode, cloneElement, createElement } from 'react';
+import type { Route } from 'react-router';
+
+import {
+ DeckyGlobalComponentsState,
+ DeckyGlobalComponentsStateContextProvider,
+ useDeckyGlobalComponentsState,
+} from '../components/DeckyGlobalComponentsState';
+import {
+ DeckyRouterState,
+ DeckyRouterStateContextProvider,
+ RoutePatch,
+ RouterEntry,
+ useDeckyRouterState,
+} from '../components/DeckyRouterState';
+import Logger from '../logger';
+
+declare global {
+ interface Window {
+ __ROUTER_HOOK_INSTANCE: any;
+ }
+}
+
+const isPatched = Symbol('is patched');
+
+class RouterHook extends Logger {
+ private routerState: DeckyRouterState = new DeckyRouterState();
+ private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
+ private renderedComponents: ReactElement<any>[] = [];
+ private Route: any;
+ private DeckyGamepadRouterWrapper = this.gamepadRouterWrapper.bind(this);
+ private DeckyDesktopRouterWrapper = this.desktopRouterWrapper.bind(this);
+ private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this);
+ private toReplace = new Map<string, ReactNode>();
+ private desktopRouterPatch?: Patch;
+ private gamepadRouterPatch?: Patch;
+ private modeChangeRegistration?: any;
+ private patchedModes = new Set<number>();
+ public routes?: any[];
+
+ constructor() {
+ super('RouterHook');
+
+ this.log('Initialized');
+ window.__ROUTER_HOOK_INSTANCE?.deinit?.();
+ window.__ROUTER_HOOK_INSTANCE = this;
+
+ const reactRouterStackModule = findModuleByExport((e) => typeof e == 'string' && e == 'router-backstack', 20);
+ if (reactRouterStackModule) {
+ this.Route =
+ Object.values(reactRouterStackModule).find(
+ (e) => typeof e == 'function' && /routePath:[\w$]+\.match\?\.path./.test(e.toString()),
+ ) ||
+ Object.values(reactRouterStackModule).find(
+ (e) => typeof e == 'function' && /routePath:null===\(.=.\.match\)/.test(e.toString()),
+ );
+ if (!this.Route) {
+ this.error('Failed to find Route component');
+ }
+ } else {
+ this.error('Failed to find router stack module');
+ }
+
+ this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: EUIMode) => {
+ this.debug(`UI mode changed to ${mode}`);
+ if (this.patchedModes.has(mode)) return;
+ this.patchedModes.add(mode);
+ this.debug(`Patching router for UI mode ${mode}`);
+ switch (mode) {
+ case EUIMode.GamePad:
+ this.debug('Patching gamepad router');
+ this.patchGamepadRouter();
+ break;
+ // Not fully implemented yet
+ // case EUIMode.Desktop:
+ // this.debug("Patching desktop router");
+ // this.patchDesktopRouter();
+ // break;
+ default:
+ this.warn(`Router patch not implemented for UI mode ${mode}`);
+ break;
+ }
+ });
+ }
+
+ private async patchGamepadRouter() {
+ const root = getReactRoot(document.getElementById('root') as any);
+ const findRouterNode = () =>
+ findInReactTree(
+ root,
+ (node) =>
+ typeof node?.pendingProps?.loggedIn == 'undefined' && node?.type?.toString().includes('Settings.Root()'),
+ );
+ await this.waitForUnlock();
+ let routerNode = findRouterNode();
+ while (!routerNode) {
+ this.warn('Failed to find Router node, reattempting in 5 seconds.');
+ await sleep(5000);
+ await this.waitForUnlock();
+ routerNode = findRouterNode();
+ }
+ if (routerNode) {
+ // Patch the component globally
+ this.gamepadRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleGamepadRouterRender.bind(this));
+ // Swap out the current instance
+ routerNode.type = routerNode.elementType.type;
+ if (routerNode?.alternate) {
+ routerNode.alternate.type = routerNode.type;
+ }
+ // Force a full rerender via our custom error boundary
+ const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
+ walkable: ['return'],
+ });
+ errorBoundaryNode?.stateNode?._deckyForceRerender?.();
+ }
+ }
+
+ // Currently unused
+ // @ts-expect-error 6133
+ private async patchDesktopRouter() {
+ const root = getReactRoot(document.getElementById('root') as any);
+ const findRouterNode = () =>
+ findInReactTree(root, (node) => node?.elementType?.type?.toString?.()?.includes('bShowDesktopUIContent:'));
+ let routerNode = findRouterNode();
+ while (!routerNode) {
+ this.warn('Failed to find Router node, reattempting in 5 seconds.');
+ await sleep(5000);
+ routerNode = findRouterNode();
+ }
+ if (routerNode) {
+ // this.debug("desktop router node", routerNode);
+ // Patch the component globally
+ this.desktopRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleDesktopRouterRender.bind(this));
+ // Swap out the current instance
+ routerNode.type = routerNode.elementType.type;
+ if (routerNode?.alternate) {
+ routerNode.alternate.type = routerNode.type;
+ }
+ // Force a full rerender via our custom error boundary
+ const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
+ walkable: ['return'],
+ });
+ errorBoundaryNode?.stateNode?._deckyForceRerender?.();
+ // this.debug("desktop router node", routerNode);
+ // // Patch the component globally
+ // this.desktopRouterPatch = afterPatch(routerNode.type.prototype, 'render', this.handleDesktopRouterRender.bind(this));
+ // const stateNodeClone = { render: routerNode.stateNode.render } as any;
+ // // Patch the current instance. render is readonly so we have to do this.
+ // Object.assign(stateNodeClone, routerNode.stateNode);
+ // Object.setPrototypeOf(stateNodeClone, Object.getPrototypeOf(routerNode.stateNode));
+ // this.desktopRouterFirstInstancePatch = afterPatch(stateNodeClone, 'render', this.handleDesktopRouterRender.bind(this));
+ // routerNode.stateNode = stateNodeClone;
+ // // Swap out the current instance
+ // if (routerNode?.alternate) {
+ // routerNode.alternate.type = routerNode.type;
+ // routerNode.alternate.stateNode = routerNode.stateNode;
+ // }
+ // routerNode.stateNode.forceUpdate();
+ // Force a full rerender via our custom error boundary
+ // const errorBoundaryNode = findInTree(routerNode, e => e?.stateNode?._deckyForceRerender, { walkable: ["return"] });
+ // errorBoundaryNode?.stateNode?._deckyForceRerender?.();
+ }
+ }
+
+ public async waitForUnlock() {
+ try {
+ while (window?.securitystore?.IsLockScreenActive?.()) {
+ await sleep(500);
+ }
+ } catch (e) {
+ this.warn('Error while checking if unlocked:', e);
+ }
+ }
+
+ public handleDesktopRouterRender(_: any, ret: any) {
+ const DeckyDesktopRouterWrapper = this.DeckyDesktopRouterWrapper;
+ const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
+ this.debug('desktop router render', ret);
+ if (ret._decky) {
+ return ret;
+ }
+ const returnVal = (
+ <>
+ <DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
+ <DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper>
+ </DeckyRouterStateContextProvider>
+ <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
+ <DeckyGlobalComponentsWrapper />
+ </DeckyGlobalComponentsStateContextProvider>
+ </>
+ );
+ (returnVal as any)._decky = true;
+ return returnVal;
+ }
+
+ public handleGamepadRouterRender(_: any, ret: any) {
+ const DeckyGamepadRouterWrapper = this.DeckyGamepadRouterWrapper;
+ const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
+ if (ret._decky) {
+ return ret;
+ }
+ const returnVal = (
+ <>
+ <DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
+ <DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper>
+ </DeckyRouterStateContextProvider>
+ <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
+ <DeckyGlobalComponentsWrapper />
+ </DeckyGlobalComponentsStateContextProvider>
+ </>
+ );
+ (returnVal as any)._decky = true;
+ return returnVal;
+ }
+
+ private globalComponentsWrapper() {
+ const { components } = useDeckyGlobalComponentsState();
+ if (this.renderedComponents.length != components.size) {
+ this.debug('Rerendering global components');
+ this.renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
+ }
+ return <>{this.renderedComponents}</>;
+ }
+
+ private gamepadRouterWrapper({ children }: { children: ReactElement<any> }) {
+ // Used to store the new replicated routes we create to allow routes to be unpatched.
+
+ const { routes, routePatches } = useDeckyRouterState();
+ // TODO make more redundant
+ if (!children?.props?.children?.[0]?.props?.children) {
+ this.debug('routerWrapper wrong component?', children);
+ return children;
+ }
+ const mainRouteList = children.props.children[0].props.children;
+ const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
+ this.processList(mainRouteList, routes, routePatches, true);
+ this.processList(ingameRouteList, null, routePatches, false);
+
+ this.debug('Rerendered gamepadui routes list');
+ return children;
+ }
+
+ private desktopRouterWrapper({ children }: { children: ReactElement<any> }) {
+ // Used to store the new replicated routes we create to allow routes to be unpatched.
+ this.debug('desktop router wrapper render', children);
+ const { routes, routePatches } = useDeckyRouterState();
+ const routeList = findInReactTree(
+ children,
+ (node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/library/home'),
+ );
+ if (!routeList) {
+ this.debug('routerWrapper wrong component?', children);
+ return children;
+ }
+ const library = children.props.children[1].props.children.props;
+ if (!Array.isArray(library.children)) {
+ library.children = [library.children];
+ }
+ this.debug('library', library);
+ this.processList(library.children, routes, routePatches, true);
+
+ this.debug('Rerendered desktop routes list');
+ return children;
+ }
+
+ private processList(
+ routeList: any[],
+ routes: Map<string, RouterEntry> | null,
+ routePatches: Map<string, Set<RoutePatch>>,
+ save: boolean,
+ ) {
+ const Route = this.Route;
+ this.debug('Route list: ', routeList);
+ if (save) this.routes = routeList;
+ let routerIndex = routeList.length;
+ if (routes) {
+ if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
+ if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
+ const newRouterArray: (ReactElement<any> | JSX.Element)[] = [];
+ routes.forEach(({ component, props }, path) => {
+ newRouterArray.push(
+ <Route path={path} {...props}>
+ <ErrorBoundary>{createElement(component)}</ErrorBoundary>
+ </Route>,
+ );
+ });
+ routeList[routerIndex] = newRouterArray;
+ }
+ }
+ routeList.forEach((route: Route, index: number) => {
+ const replaced = this.toReplace.get(route?.props?.path as string);
+ if (replaced) {
+ routeList[index].props.children = replaced;
+ this.toReplace.delete(route?.props?.path as string);
+ }
+ if (route?.props?.path && routePatches.has(route.props.path as string)) {
+ this.toReplace.set(
+ route?.props?.path as string,
+ // @ts-ignore
+ routeList[index].props.children,
+ );
+ routePatches.get(route.props.path as string)?.forEach((patch) => {
+ const oType = routeList[index].props.children.type;
+ routeList[index].props.children = patch({
+ ...routeList[index].props,
+ children: {
+ ...cloneElement(routeList[index].props.children),
+ type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props),
+ },
+ }).children;
+ routeList[index].props.children[isPatched] = true;
+ });
+ }
+ });
+ }
+
+ addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
+ this.routerState.addRoute(path, component, props);
+ }
+
+ addPatch(path: string, patch: RoutePatch) {
+ return this.routerState.addPatch(path, patch);
+ }
+
+ addGlobalComponent(name: string, component: FC) {
+ this.globalComponentsState.addComponent(name, component);
+ }
+
+ removeGlobalComponent(name: string) {
+ this.globalComponentsState.removeComponent(name);
+ }
+
+ removePatch(path: string, patch: RoutePatch) {
+ this.routerState.removePatch(path, patch);
+ }
+
+ removeRoute(path: string) {
+ this.routerState.removeRoute(path);
+ }
+
+ deinit() {
+ this.modeChangeRegistration?.unregister();
+ this.gamepadRouterPatch?.unpatch();
+ this.desktopRouterPatch?.unpatch();
+ }
+}
+
+export default RouterHook;
diff --git a/frontend/src/hooks/tabs.tsx b/frontend/src/hooks/tabs.tsx
new file mode 100644
index 00000000..4d917b6c
--- /dev/null
+++ b/frontend/src/hooks/tabs.tsx
@@ -0,0 +1,131 @@
+// TabsHook for versions after the Desktop merge
+import {
+ ErrorBoundary,
+ Patch,
+ QuickAccessTab,
+ afterPatch,
+ createReactTreePatcher,
+ findInReactTree,
+ findModuleByExport,
+ getReactRoot,
+} from '@decky/ui';
+
+import { QuickAccessVisibleStateProvider } from '../components/QuickAccessVisibleState';
+import Logger from '../logger';
+
+declare global {
+ interface Window {
+ __TABS_HOOK_INSTANCE: any;
+ }
+}
+
+interface Tab {
+ id: QuickAccessTab | number;
+ title: any;
+ content: any;
+ icon: any;
+}
+
+class TabsHook extends Logger {
+ // private keys = 7;
+ tabs: Tab[] = [];
+ private qamBrowserViewPatch?: Patch;
+ private qamEmbeddedPatch?: Patch;
+
+ constructor() {
+ super('TabsHook');
+
+ this.log('Initialized');
+ window.__TABS_HOOK_INSTANCE?.deinit?.();
+ window.__TABS_HOOK_INSTANCE = this;
+ }
+
+ init() {
+ const qamModule = findModuleByExport((e) => e?.type?.toString?.()?.includes('QuickAccessMenuBrowserView'));
+ const qamBrowserViewRenderer = Object.values(qamModule).find((e: any) =>
+ e?.type?.toString?.()?.includes('QuickAccessMenuBrowserView'),
+ );
+ const qamEmbeddedRenderer = Object.values(qamModule).find((e: any) =>
+ e?.type?.toString?.()?.includes('QuickAccessMenuEmbedded'),
+ );
+
+ const patchHandler = createReactTreePatcher(
+ [(tree) => findInReactTree(tree, (node) => node?.props?.onFocusNavDeactivated)],
+ (args, ret) => {
+ const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
+ this.render(tabs.props.tabs, args[0].visible);
+ return ret;
+ },
+ 'TabsHook',
+ );
+
+ this.qamBrowserViewPatch = afterPatch(qamBrowserViewRenderer, 'type', patchHandler);
+ if (qamEmbeddedRenderer) this.qamEmbeddedPatch = afterPatch(qamEmbeddedRenderer, 'type', patchHandler);
+
+ // Patch already rendered qam
+ const root = getReactRoot(document.getElementById('root') as any);
+ const qamNode =
+ root &&
+ findInReactTree(
+ root,
+ (n: any) =>
+ n.elementType == qamBrowserViewRenderer ||
+ (qamEmbeddedRenderer != null && n.elementType == qamEmbeddedRenderer),
+ ); // need elementType, because type is actually mobx wrapper
+ if (qamNode) {
+ // Only affects this fiber node so we don't need to unpatch here
+ qamNode.type = qamNode.elementType.type;
+ if (qamNode?.alternate) {
+ qamNode.alternate.type = qamNode.type;
+ }
+ }
+ }
+
+ deinit() {
+ this.qamBrowserViewPatch?.unpatch();
+ this.qamEmbeddedPatch?.unpatch();
+ }
+
+ add(tab: Tab) {
+ this.debug('Adding tab', tab.id, 'to render array');
+ this.tabs.push(tab);
+ }
+
+ removeById(id: number) {
+ this.debug('Removing tab', id);
+ this.tabs = this.tabs.filter((tab) => tab.id !== id);
+ }
+
+ render(existingTabs: any[], visible: boolean) {
+ let deckyTabAmount = existingTabs.reduce((prev: any, cur: any) => (cur.decky ? prev + 1 : prev), 0);
+ if (deckyTabAmount == this.tabs.length) {
+ for (let tab of existingTabs) {
+ if (tab?.decky) {
+ if (tab?.qAMVisibilitySetter) {
+ tab?.qAMVisibilitySetter(visible);
+ } else {
+ tab.initialVisibility = visible;
+ }
+ }
+ }
+ return;
+ }
+ for (const { title, icon, content, id } of this.tabs) {
+ const tab: any = {
+ key: id,
+ title,
+ tab: icon,
+ decky: true,
+ initialVisibility: visible,
+ };
+ tab.panel = (
+ <ErrorBoundary>
+ <QuickAccessVisibleStateProvider tab={tab}>{content}</QuickAccessVisibleStateProvider>
+ </ErrorBoundary>
+ );
+ existingTabs.push(tab);
+ }
+ }
+}
+
+export default TabsHook;
diff --git a/frontend/src/hooks/toaster.tsx b/frontend/src/hooks/toaster.tsx
new file mode 100644
index 00000000..9108d9ec
--- /dev/null
+++ b/frontend/src/hooks/toaster.tsx
@@ -0,0 +1,125 @@
+import type { ToastData, ToastNotification } from '@decky/api';
+import {
+ ESteamNotificationAction,
+ ErrorBoundary,
+ Patch,
+ callOriginal,
+ findModuleExport,
+ injectFCTrampoline,
+ replacePatch,
+} from '@decky/ui';
+
+import Toast from '../components/Toast';
+import Logger from '../logger';
+
+declare global {
+ interface Window {
+ __TOASTER_INSTANCE: any;
+ }
+}
+
+class Toaster extends Logger {
+ private toastPatch?: Patch;
+
+ constructor() {
+ super('Toaster');
+
+ window.__TOASTER_INSTANCE?.deinit?.();
+ window.__TOASTER_INSTANCE = this;
+
+ const ValveToastRenderer = findModuleExport((e) => e?.toString?.()?.includes(`controller:"notification",method:`));
+ // TODO find a way to undo this if possible?
+ const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
+ this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
+ if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
+ return args[0].group.notifications.map((notification: any) => (
+ <ErrorBoundary>
+ <Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
+ </ErrorBoundary>
+ ));
+ }
+ return callOriginal;
+ });
+
+ this.log('Initialized');
+ }
+
+ toast(toast: ToastData): ToastNotification {
+ if (toast.sound === undefined) toast.sound = 6;
+ if (toast.playSound === undefined) toast.playSound = true;
+ if (toast.showToast === undefined) toast.showToast = true;
+ if (toast.timestamp === undefined) toast.timestamp = new Date();
+ if (toast.showNewIndicator === undefined) toast.showNewIndicator = true;
+ /* eType 13
+ 13: {
+ proto: m.mu,
+ fnTray: null,
+ showToast: !0,
+ sound: f.PN.ToastMisc,
+ eFeature: l.uX
+ }
+ */
+ let toastData = {
+ nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
+ bNewIndicator: toast.showNewIndicator,
+ rtCreated: Date.now(),
+ eType: toast.eType || 31,
+ eSource: 1, // Client
+ nToastDurationMS: toast.duration || (toast.duration = 5e3),
+ data: toast,
+ decky: true,
+ };
+ let group: any;
+ function fnTray(toast: any, tray: any) {
+ group = {
+ eType: toast.eType,
+ notifications: [toast],
+ };
+ tray.unshift(group);
+ }
+ const info = {
+ showToast: toast.showToast,
+ sound: toast.sound,
+ playSound: toast.playSound,
+ eFeature: 0,
+ toastDurationMS: toastData.nToastDurationMS,
+ bCritical: toast.critical,
+ fnTray,
+ };
+ const self = this;
+ let expirationTimeout: number;
+ const toastResult: ToastNotification = {
+ data: toast,
+ dismiss() {
+ // it checks against the id of notifications[0]
+ try {
+ expirationTimeout && clearTimeout(expirationTimeout);
+ group && window.NotificationStore.RemoveGroupFromTray(group);
+ } catch (e) {
+ self.error('Error while dismissing toast:', e);
+ }
+ },
+ };
+ if (toast.expiration) {
+ expirationTimeout = setTimeout(() => {
+ try {
+ group && window.NotificationStore.RemoveGroupFromTray(group);
+ } catch (e) {
+ this.error('Error while dismissing expired toast:', e);
+ }
+ }, toast.expiration);
+ }
+ try {
+ window.NotificationStore.ProcessNotification(info, toastData, ESteamNotificationAction.New);
+ } catch (e) {
+ this.error('Error while sending toast:', e);
+ }
+ return toastResult;
+ }
+
+ deinit() {
+ this.toastPatch?.unpatch();
+ }
+}
+
+export default Toaster;
diff --git a/frontend/src/hooks/vr-dashboard-bar.tsx b/frontend/src/hooks/vr-dashboard-bar.tsx
new file mode 100644
index 00000000..905aee03
--- /dev/null
+++ b/frontend/src/hooks/vr-dashboard-bar.tsx
@@ -0,0 +1,105 @@
+// Hook the SteamVR Hotbar
+import { ErrorBoundary, Patch, afterPatch, findInReactTree, findModuleExport, getReactRoot } from '@decky/ui';
+import { ReactNode, createElement } from 'react';
+
+import Logger from '../logger';
+
+declare global {
+ interface Window {
+ __VR_DASHBAORD_BAR_HOOK_INSTANCE: any;
+ }
+}
+
+// TODO kinda dumb
+enum VRDashboardBarButtonID {
+ Decky = 999,
+}
+
+export interface VRDashboardBarButton {
+ id: VRDashboardBarButtonID | number;
+ active?: boolean;
+ children?: ReactNode;
+ tooltip?: ReactNode;
+ popupContents?: ReactNode;
+ onActivate?: () => void;
+ refBarPopupHandle?: any; // TODO idk
+ onPopupVisibilityChanged?: (visible: boolean) => void;
+}
+
+class VRDashboardBarHook extends Logger {
+ rightSideButtons: VRDashboardBarButton[] = [];
+ private rendererdRightSideButtons: ReactNode[] = [];
+ private barPatch?: Patch;
+ private barButton?: any;
+
+ constructor() {
+ super('VRDashboardBarHook');
+
+ this.log('Initialized');
+ window.__VR_DASHBAORD_BAR_HOOK_INSTANCE?.deinit?.();
+ window.__VR_DASHBAORD_BAR_HOOK_INSTANCE = this;
+ }
+
+ init() {
+ this.barButton = findModuleExport((e) => e?.render?.toString()?.includes('VRDashboardBarSmallButtonActive'));
+ const barComponent = findModuleExport((e) => e?.type?.toString()?.includes('vrLocation=="dashboard-bar"'));
+
+ this.barPatch = afterPatch(barComponent, 'type', (_, ret) => {
+ const nodes = findInReactTree(ret, (x) => x?.length > 6);
+ if (!window?.securitystore?.IsLockScreenActive?.()) nodes.splice(4, 0, this.rendererdRightSideButtons);
+ return ret;
+ });
+
+ // Patch already rendered bar
+ const root = getReactRoot(document.getElementById('root') as any);
+ const barNode =
+ root &&
+ findInReactTree(
+ root,
+ (n: any) => n.elementType == barComponent || (barComponent != null && n.elementType == barComponent),
+ ); // need elementType, because case where type is actually mobx wrapper
+ if (barNode) {
+ // Only affects this fiber node so we don't need to unpatch here
+ barNode.type = barNode.elementType.type;
+ if (barNode?.alternate) {
+ barNode.alternate.type = barNode.type;
+ }
+ }
+ }
+
+ deinit() {
+ this.barPatch?.unpatch();
+ }
+
+ addRightSideButton(button: VRDashboardBarButton) {
+ this.debug('Adding RSB', button.id, 'to render array');
+ this.rightSideButtons.push(button);
+ this.renderRightSideButtons();
+ }
+
+ removeRightSideButton(id: number) {
+ this.debug('Removing RSB', id);
+ this.rightSideButtons = this.rightSideButtons.filter((button) => button.id !== id);
+ this.renderRightSideButtons();
+ }
+
+ renderRightSideButtons() {
+ const arr = [];
+ for (const button of this.rightSideButtons) {
+ arr.push(
+ createElement(this.barButton, {
+ active: button.active,
+ children: <ErrorBoundary>{button.children}</ErrorBoundary>,
+ tooltip: button.tooltip,
+ popupContents: <ErrorBoundary>{button.popupContents}</ErrorBoundary>,
+ onActivate: button.onActivate,
+ refBarPopupHandle: button.refBarPopupHandle,
+ onPopupVisibilityChanged: button.onPopupVisibilityChanged,
+ }),
+ );
+ }
+ this.rendererdRightSideButtons = arr;
+ }
+}
+
+export default VRDashboardBarHook;