From e82a55e9582fbd6d84f3f4510b04480c35e41fe4 Mon Sep 17 00:00:00 2001 From: AAGaming Date: Mon, 26 Oct 2026 23:50:59 -0400 Subject: feat: initial VERY VERY incomplete steam frame support --- frontend/pnpm-lock.yaml | 12 +- frontend/pnpm-workspace.yaml | 7 +- frontend/src/components/NotificationBadge.tsx | 55 +++- frontend/src/components/PluginView.tsx | 40 ++- .../src/components/QuickAccessVisibleState.tsx | 17 +- .../components/settings/pages/developer/index.tsx | 33 +- frontend/src/developer.tsx | 42 ++- frontend/src/hooks/router.tsx | 359 +++++++++++++++++++++ frontend/src/hooks/tabs.tsx | 131 ++++++++ frontend/src/hooks/toaster.tsx | 125 +++++++ frontend/src/hooks/vr-dashboard-bar.tsx | 105 ++++++ frontend/src/plugin-loader.tsx | 56 +++- frontend/src/router-hook.tsx | 359 --------------------- frontend/src/tabs-hook.tsx | 131 -------- frontend/src/toaster.tsx | 126 -------- 15 files changed, 895 insertions(+), 703 deletions(-) create mode 100644 frontend/src/hooks/router.tsx create mode 100644 frontend/src/hooks/tabs.tsx create mode 100644 frontend/src/hooks/toaster.tsx create mode 100644 frontend/src/hooks/vr-dashboard-bar.tsx delete mode 100644 frontend/src/router-hook.tsx delete mode 100644 frontend/src/tabs-hook.tsx delete mode 100644 frontend/src/toaster.tsx (limited to 'frontend') diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 57e28d59..37d7ff8e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,13 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@decky/ui': link:../../lib + importers: .: dependencies: '@decky/ui': - specifier: ^4.15.1 - version: 4.15.1 + specifier: link:../../lib + version: link:../../lib compare-versions: specifier: ^6.1.1 version: 6.1.1 @@ -222,9 +225,6 @@ packages: '@decky/api@1.1.3': resolution: {integrity: sha512-XsPCZxfxk5I1UtylIUN3qaWQI31siQbKfbLIskkI5innEatY1m4NQqBv/6hwPaO9mKMbdqYpnh5PSJDeMEOOBA==} - '@decky/ui@4.15.1': - resolution: {integrity: sha512-pIOpN9cm+90oaiQvLpAao8GfuflTC1tbZh1erMUKZ7IHeFYb/vrEhpI/v/avNWOySWFxGl0iVsDALvwIu2l1kg==} - '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -2311,8 +2311,6 @@ snapshots: '@decky/api@1.1.3': {} - '@decky/ui@4.15.1': {} - '@esbuild/aix-ppc64@0.20.2': optional: true diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index a6e847b3..80cb4cd7 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,5 +1,6 @@ -minimumReleaseAgeExclude: -- "@decky/api" -- "@decky/ui" allowBuilds: esbuild: true + +minimumReleaseAgeExclude: + - '@decky/api' + - '@decky/ui' diff --git a/frontend/src/components/NotificationBadge.tsx b/frontend/src/components/NotificationBadge.tsx index 3c5d8215..1d14d576 100644 --- a/frontend/src/components/NotificationBadge.tsx +++ b/frontend/src/components/NotificationBadge.tsx @@ -2,23 +2,52 @@ import { CSSProperties, FunctionComponent } from 'react'; interface NotificationBadgeProps { show?: boolean; + vr?: boolean; style?: CSSProperties; } -const NotificationBadge: FunctionComponent = ({ show, style }) => { +const NotificationBadge: FunctionComponent = ({ show, style, vr }) => { return show ? ( -
+ vr ? ( + // whoever made transform:scale completely break absolute positioning goes straight to super turbo hell this took me an hour +
+
+
+ ) : ( +
+ ) ) : null; }; diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index fbafaf05..27c1f2f1 100644 --- a/frontend/src/components/PluginView.tsx +++ b/frontend/src/components/PluginView.tsx @@ -1,4 +1,12 @@ -import { ButtonItem, ErrorBoundary, Focusable, PanelSection, PanelSectionRow } from '@decky/ui'; +import { + ButtonItem, + ErrorBoundary, + Focusable, + PanelSection, + PanelSectionRow, + joinClassNames, + vrDashboardClasses, +} from '@decky/ui'; import { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { FaBan, FaEyeSlash } from 'react-icons/fa'; @@ -8,7 +16,7 @@ import NotificationBadge from './NotificationBadge'; import { useQuickAccessVisible } from './QuickAccessVisibleState'; import TitleView from './TitleView'; -const PluginView: FC = () => { +const PluginView: FC<{ vr?: boolean }> = ({ vr }) => { const { plugins, disabledPlugins, @@ -40,20 +48,38 @@ const PluginView: FC = () => { if (activePlugin) { return ( - + -
+
{(visible || activePlugin.alwaysRender) && activePlugin.content}
); } return ( - <> +
@@ -92,7 +118,7 @@ const PluginView: FC = () => {
- +
); }; diff --git a/frontend/src/components/QuickAccessVisibleState.tsx b/frontend/src/components/QuickAccessVisibleState.tsx index f90c24b4..83e45177 100644 --- a/frontend/src/components/QuickAccessVisibleState.tsx +++ b/frontend/src/components/QuickAccessVisibleState.tsx @@ -1,16 +1,21 @@ import { FC, PropsWithChildren, createContext, useContext, useState } from 'react'; -const QuickAccessVisibleState = createContext(false); +export const QuickAccessVisibleState = createContext(false); export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState); -export const QuickAccessVisibleStateProvider: FC> = ({ children, tab }) => { - const initial = tab.initialVisibility; +export const QuickAccessVisibleStateProvider: FC> = ({ + children, + tab, + initialVal, +}) => { + const initial = initialVal || tab?.initialVisibility; const [visible, setVisible] = useState(initial); // HACK but i can't think of a better way to do this - tab.qAMVisibilitySetter = (val: boolean) => { - if (val != visible) setVisible(val); - }; + if (tab) + tab.qAMVisibilitySetter = (val: boolean) => { + if (val != visible) setVisible(val); + }; return {children}; }; diff --git a/frontend/src/components/settings/pages/developer/index.tsx b/frontend/src/components/settings/pages/developer/index.tsx index 17187675..b6a337f0 100644 --- a/frontend/src/components/settings/pages/developer/index.tsx +++ b/frontend/src/components/settings/pages/developer/index.tsx @@ -9,7 +9,7 @@ import { Toggle, showModal, } from '@decky/ui'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaFileArchive, FaLink, FaReact, FaSteamSymbol, FaTerminal } from 'react-icons/fa'; @@ -49,7 +49,6 @@ export default function DeveloperSettings() { const [acceptedWarning, setAcceptedWarning] = useSetting('developer.warn.third_party', false); const waitTime = acceptedWarning ? 0 : 5; const [pluginURL, setPluginURL] = useState(''); - const textRef = useRef(null); const { t } = useTranslation(); return ( @@ -82,13 +81,6 @@ export default function DeveloperSettings() { setPluginURL(e?.target.value)} - /> - } icon={} > + setPluginURL(e?.target.value)} + /> {t('SettingsDeveloperIndex.header')} @@ -160,18 +157,7 @@ export default function DeveloperSettings() { - {t('SettingsDeveloperIndex.react_devtools.desc')} -
-
-
- setReactDevtoolsIP(e?.target.value)} - /> -
- + {t('SettingsDeveloperIndex.react_devtools.desc')} } icon={} > @@ -184,6 +170,11 @@ export default function DeveloperSettings() { }} />
+ setReactDevtoolsIP(e?.target.value)} + />
); diff --git a/frontend/src/developer.tsx b/frontend/src/developer.tsx index 5df6de07..6e1b9599 100644 --- a/frontend/src/developer.tsx +++ b/frontend/src/developer.tsx @@ -9,20 +9,13 @@ const logger = new Logger('DeveloperMode'); let removeSettingsObserver: () => void = () => {}; -declare global { - interface Window { - settingsStore: any; - } -} - export async function setShowValveInternal(show: boolean) { if (show) { - const mobx = - window.settingsStore[ - Object.getOwnPropertySymbols(window.settingsStore).find( - (x) => x.toString() == 'Symbol(mobx administration)', - ) as any - ]; + const mobx = (window.settingsStore as any)[ + Object.getOwnPropertySymbols(window.settingsStore).find( + (x) => x.toString() == 'Symbol(mobx administration)', + ) as any + ]; removeSettingsObserver = (mobx.observe_ || mobx.observe).call(mobx, (e: any) => { e.newValue.bIsValveEmail = true; @@ -37,17 +30,18 @@ export async function setShowValveInternal(show: boolean) { } } -export async function setShouldConnectToReactDevTools(enable: boolean) { - DeckyPluginLoader.toaster.toast({ - title: enable ? ( - - ) : ( - - ), - body: , - icon: , - }); - await sleep(5000); +export async function setShouldConnectToReactDevTools(enable: boolean, skipWait?: boolean) { + skipWait || + DeckyPluginLoader.toaster.toast({ + title: enable ? ( + + ) : ( + + ), + body: , + icon: , + }); + skipWait || (await sleep(5000)); return enable ? DeckyBackend.call('utilities/enable_rdt') : DeckyBackend.call('utilities/disable_rdt'); } @@ -58,5 +52,5 @@ export async function startup() { if (isValveInternalEnabled) setShowValveInternal(isValveInternalEnabled); if ((isRDTEnabled && !window.deckyHasConnectedRDT) || (!isRDTEnabled && window.deckyHasConnectedRDT)) - setShouldConnectToReactDevTools(isRDTEnabled); + setShouldConnectToReactDevTools(isRDTEnabled, true); } 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[] = []; + 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(); + private desktopRouterPatch?: Patch; + private gamepadRouterPatch?: Patch; + private modeChangeRegistration?: any; + private patchedModes = new Set(); + 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 = ( + <> + + {ret} + + + + + + ); + (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 = ( + <> + + {ret} + + + + + + ); + (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) => ); + } + return <>{this.renderedComponents}; + } + + private gamepadRouterWrapper({ children }: { children: ReactElement }) { + // 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 }) { + // 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 | null, + routePatches: Map>, + 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 | JSX.Element)[] = []; + routes.forEach(({ component, props }, path) => { + newRouterArray.push( + + {createElement(component)} + , + ); + }); + 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 = ( + + {content} + + ); + 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) => ( + + + + )); + } + 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: {button.children}, + tooltip: button.tooltip, + popupContents: {button.popupContents}, + onActivate: button.onActivate, + refBarPopupHandle: button.refBarPopupHandle, + onPopupVisibilityChanged: button.onPopupVisibilityChanged, + }), + ); + } + this.rendererdRightSideButtons = arr; + } +} + +export default VRDashboardBarHook; diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 81b96dcc..0edcb5f7 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -7,9 +7,11 @@ import { PanelSectionRow, QuickAccessTab, findSP, + joinClassNames, quickAccessMenuClasses, showModal, sleep, + vrDashboardClasses, } from '@decky/ui'; import { FC, lazy } from 'react'; import { FaDownload, FaExclamationCircle, FaExclamationTriangle, FaPlug } from 'react-icons/fa'; @@ -24,19 +26,20 @@ import PluginInstallModal from './components/modals/PluginInstallModal'; import PluginUninstallModal from './components/modals/PluginUninstallModal'; import NotificationBadge from './components/NotificationBadge'; import PluginView from './components/PluginView'; -import { useQuickAccessVisible } from './components/QuickAccessVisibleState'; +import { QuickAccessVisibleStateProvider, useQuickAccessVisible } from './components/QuickAccessVisibleState'; import WithSuspense from './components/WithSuspense'; import ErrorBoundaryHook from './errorboundary-hook'; import { FrozenPluginService } from './frozen-plugins-service'; import { HiddenPluginsService } from './hidden-plugins-service'; +import RouterHook from './hooks/router'; +import TabsHook from './hooks/tabs'; +import Toaster from './hooks/toaster'; +import VRDashboardBarHook from './hooks/vr-dashboard-bar'; import Logger from './logger'; import { NotificationService } from './notification-service'; import { DisabledPlugin, InstallType, Plugin, PluginLoadType } from './plugin'; -import RouterHook from './router-hook'; import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { PluginUpdateMapping, checkForPluginUpdates } from './store'; -import TabsHook from './tabs-hook'; -import Toaster from './toaster'; import { getVersionInfo } from './updater'; import { getPluginDisplayName } from './utils/pluginHelpers'; import { getSetting, setSetting } from './utils/settings'; @@ -70,6 +73,7 @@ class PluginLoader extends Logger { private plugins: Plugin[] = []; public errorBoundaryHook: ErrorBoundaryHook = new ErrorBoundaryHook(); private tabsHook: TabsHook = new TabsHook(); + private vrDashboardBarHook: VRDashboardBarHook = new VRDashboardBarHook(); // TODO public? unsure public routerHook: RouterHook = new RouterHook(); public toaster: Toaster = new Toaster(); private deckyState: DeckyState = new DeckyState(); @@ -105,10 +109,11 @@ class PluginLoader extends Logger { DeckyBackend.addEventListener(`loader/plugin_event`, this.pluginEventListener); this.tabsHook.init(); + this.vrDashboardBarHook.init(); - const TabBadge = () => { + const TabBadge = ({ vr }: { vr?: boolean }) => { const { updates, hasLoaderUpdate } = useDeckyState(); - return 0) || hasLoaderUpdate} />; + return 0) || hasLoaderUpdate} />; }; this.tabsHook.add({ @@ -127,6 +132,44 @@ class PluginLoader extends Logger { ), }); + this.vrDashboardBarHook.addRightSideButton({ + id: 999, + tooltip: 'Decky', + popupContents: ( + + {' '} + {/*for compat only, these don't render when inactive*/} + +
+ +
+
+
+ ), + children: ( + +
+ + + +
+
+ ), + }); + this.routerHook.addRoute('/decky/store', () => ( @@ -399,6 +442,7 @@ class PluginLoader extends Logger { deinitFilepickerPatches(); this.routerHook.deinit(); this.tabsHook.deinit(); + this.vrDashboardBarHook.deinit(); this.toaster.deinit(); this.errorBoundaryHook.deinit(); } diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx deleted file mode 100644 index a7b6ec92..00000000 --- a/frontend/src/router-hook.tsx +++ /dev/null @@ -1,359 +0,0 @@ -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[] = []; - 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(); - private desktopRouterPatch?: Patch; - private gamepadRouterPatch?: Patch; - private modeChangeRegistration?: any; - private patchedModes = new Set(); - 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 UIMode.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 = ( - <> - - {ret} - - - - - - ); - (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 = ( - <> - - {ret} - - - - - - ); - (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) => ); - } - return <>{this.renderedComponents}; - } - - private gamepadRouterWrapper({ children }: { children: ReactElement }) { - // 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 }) { - // 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 | null, - routePatches: Map>, - 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 | JSX.Element)[] = []; - routes.forEach(({ component, props }, path) => { - newRouterArray.push( - - {createElement(component)} - , - ); - }); - 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/tabs-hook.tsx b/frontend/src/tabs-hook.tsx deleted file mode 100644 index ecb60f30..00000000 --- a/frontend/src/tabs-hook.tsx +++ /dev/null @@ -1,131 +0,0 @@ -// 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 = ( - - {content} - - ); - existingTabs.push(tab); - } - } -} - -export default TabsHook; diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx deleted file mode 100644 index 99f8023e..00000000 --- a/frontend/src/toaster.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import type { ToastData, ToastNotification } from '@decky/api'; -import { ErrorBoundary, Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui'; - -import Toast from './components/Toast'; -import Logger from './logger'; - -// TODO export -enum ToastType { - New, - Update, - Remove, -} - -declare global { - interface Window { - __TOASTER_INSTANCE: any; - settingsStore: any; - NotificationStore: 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) => ( - - - - )); - } - 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, ToastType.New); - } catch (e) { - this.error('Error while sending toast:', e); - } - return toastResult; - } - - deinit() { - this.toastPatch?.unpatch(); - } -} - -export default Toaster; -- cgit v1.2.3