diff options
Diffstat (limited to 'frontend/src/hooks')
| -rw-r--r-- | frontend/src/hooks/router.tsx | 359 | ||||
| -rw-r--r-- | frontend/src/hooks/tabs.tsx | 131 | ||||
| -rw-r--r-- | frontend/src/hooks/toaster.tsx | 125 | ||||
| -rw-r--r-- | frontend/src/hooks/vr-dashboard-bar.tsx | 105 |
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; |
