diff options
| author | AAGaming <aagaming@riseup.net> | 2024-08-05 14:07:10 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-08-05 14:07:10 -0400 |
| commit | 131f0961ff451ec47376483178e092c8d7403b27 (patch) | |
| tree | 4d2ea34e8220e14c4b820cc1ad38face7193f6fe /frontend/src | |
| parent | 75aa1e4851445646994ba3a61ff41325403359fb (diff) | |
| download | decky-loader-131f0961ff451ec47376483178e092c8d7403b27.tar.gz decky-loader-131f0961ff451ec47376483178e092c8d7403b27.zip | |
Rewrite router/tabs/toaster hooks (#661)
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/DeckyIcon.tsx | 74 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToaster.tsx | 57 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToasterState.tsx | 69 | ||||
| -rw-r--r-- | frontend/src/components/TitleView.tsx | 10 | ||||
| -rw-r--r-- | frontend/src/components/Toast.tsx | 102 | ||||
| -rw-r--r-- | frontend/src/components/modals/filepicker/patches/index.ts | 8 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/testing/index.tsx | 7 | ||||
| -rw-r--r-- | frontend/src/errorboundary-hook.tsx | 13 | ||||
| -rw-r--r-- | frontend/src/index.ts | 19 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 45 | ||||
| -rw-r--r-- | frontend/src/router-hook.tsx | 394 | ||||
| -rw-r--r-- | frontend/src/tabs-hook.tsx | 109 | ||||
| -rw-r--r-- | frontend/src/toaster.tsx | 238 |
13 files changed, 589 insertions, 556 deletions
diff --git a/frontend/src/components/DeckyIcon.tsx b/frontend/src/components/DeckyIcon.tsx index 515bd847..f07f46d3 100644 --- a/frontend/src/components/DeckyIcon.tsx +++ b/frontend/src/components/DeckyIcon.tsx @@ -1,37 +1,39 @@ -export default function DeckyIcon() { - return ( - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456"> - <g> - <path - style={{ fill: 'none' }} - d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18 - c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32 - c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77 - c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05 - C226.38,87.12,191.11,72.51,154.33,72.51z" - /> +import { FC, SVGAttributes } from 'react'; - <ellipse - transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)" - style={{ fill: 'none' }} - cx="154.33" - cy="211.33" - rx="69.33" - ry="69.33" - /> - <path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" /> - <path - style={{ fill: 'currentColor' }} - d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107 - C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33 - c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77 - c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29 - c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29 - c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61 - c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52 - c7.18,0,13,5.82,13,13V271z" - /> - </g> - </svg> - ); -} +const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => ( + <svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456" {...props}> + <g> + <path + style={{ fill: 'none' }} + d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18 + c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32 + c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77 + c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05 + C226.38,87.12,191.11,72.51,154.33,72.51z" + /> + + <ellipse + transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)" + style={{ fill: 'none' }} + cx="154.33" + cy="211.33" + rx="69.33" + ry="69.33" + /> + <path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" /> + <path + style={{ fill: 'currentColor' }} + d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107 + C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33 + c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77 + c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29 + c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29 + c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61 + c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52 + c7.18,0,13,5.82,13,13V271z" + /> + </g> + </svg> +); + +export default DeckyIcon; diff --git a/frontend/src/components/DeckyToaster.tsx b/frontend/src/components/DeckyToaster.tsx deleted file mode 100644 index 056f1dd7..00000000 --- a/frontend/src/components/DeckyToaster.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ToastData } from '@decky/api'; -import { joinClassNames } from '@decky/ui'; -import { FC, ReactElement, useEffect, useState } from 'react'; - -import { useDeckyToasterState } from './DeckyToasterState'; -import Toast, { toastClasses } from './Toast'; - -interface DeckyToasterProps {} - -interface RenderedToast { - component: ReactElement; - data: ToastData; -} - -const DeckyToaster: FC<DeckyToasterProps> = () => { - const { toasts, removeToast } = useDeckyToasterState(); - const [renderedToast, setRenderedToast] = useState<RenderedToast | null>(null); - console.log(toasts); - if (toasts.size > 0) { - const [activeToast] = toasts; - if (!renderedToast || activeToast != renderedToast.data) { - // TODO play toast soundReactElement - console.log('rendering toast', activeToast); - setRenderedToast({ component: <Toast key={Math.random()} toast={activeToast} />, data: activeToast }); - } - } else { - if (renderedToast) setRenderedToast(null); - } - useEffect(() => { - // not actually node but TS is shit - let interval: number | null; - if (renderedToast) { - interval = setTimeout( - () => { - interval = null; - console.log('clear toast', renderedToast.data); - removeToast(renderedToast.data); - }, - (renderedToast.data.duration || 5e3) + 1000, - ); - console.log('set int', interval); - } - return () => { - if (interval) { - console.log('clearing int', interval); - clearTimeout(interval); - } - }; - }, [renderedToast]); - return ( - <div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}> - {renderedToast && renderedToast.component} - </div> - ); -}; - -export default DeckyToaster; diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx deleted file mode 100644 index ebe90b23..00000000 --- a/frontend/src/components/DeckyToasterState.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ToastData } from '@decky/api'; -import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react'; - -interface PublicDeckyToasterState { - toasts: Set<ToastData>; -} - -export class DeckyToasterState { - private _toasts: Set<ToastData> = new Set(); - - public eventBus = new EventTarget(); - - publicState(): PublicDeckyToasterState { - return { toasts: this._toasts }; - } - - addToast(toast: ToastData) { - this._toasts.add(toast); - this.notifyUpdate(); - } - - removeToast(toast: ToastData) { - this._toasts.delete(toast); - this.notifyUpdate(); - } - - private notifyUpdate() { - this.eventBus.dispatchEvent(new Event('update')); - } -} - -interface DeckyToasterContext extends PublicDeckyToasterState { - addToast(toast: ToastData): void; - removeToast(toast: ToastData): void; -} - -const DeckyToasterContext = createContext<DeckyToasterContext>(null as any); - -export const useDeckyToasterState = () => useContext(DeckyToasterContext); - -interface Props { - deckyToasterState: DeckyToasterState; - children: ReactNode; -} - -export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => { - const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({ - ...deckyToasterState.publicState(), - }); - - useEffect(() => { - function onUpdate() { - setPublicDeckyToasterState({ ...deckyToasterState.publicState() }); - } - - deckyToasterState.eventBus.addEventListener('update', onUpdate); - - return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate); - }, []); - - const addToast = deckyToasterState.addToast.bind(deckyToasterState); - const removeToast = deckyToasterState.removeToast.bind(deckyToasterState); - - return ( - <DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}> - {children} - </DeckyToasterContext.Provider> - ); -}; diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx index 8b45aae4..0cb82b7f 100644 --- a/frontend/src/components/TitleView.tsx +++ b/frontend/src/components/TitleView.tsx @@ -1,4 +1,4 @@ -import { DialogButton, Focusable, Router, staticClasses } from '@decky/ui'; +import { DialogButton, Focusable, Navigation, staticClasses } from '@decky/ui'; import { CSSProperties, FC } from 'react'; import { useTranslation } from 'react-i18next'; import { BsGearFill } from 'react-icons/bs'; @@ -19,13 +19,13 @@ const TitleView: FC = () => { const { t } = useTranslation(); const onSettingsClick = () => { - Router.CloseSideMenus(); - Router.Navigate('/decky/settings'); + Navigation.Navigate('/decky/settings'); + Navigation.CloseSideMenus(); }; const onStoreClick = () => { - Router.CloseSideMenus(); - Router.Navigate('/decky/store'); + Navigation.Navigate('/decky/store'); + Navigation.CloseSideMenus(); }; if (activePlugin === null) { diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index 79e3d864..e86e9337 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -1,37 +1,38 @@ import type { ToastData } from '@decky/api'; -import { findModule, joinClassNames } from '@decky/ui'; -import { FunctionComponent } from 'react'; +import { Focusable, Navigation, findClassModule, joinClassNames } from '@decky/ui'; +import { FC, memo } from 'react'; -interface ToastProps { - toast: ToastData; -} +import Logger from '../logger'; -export const toastClasses = findModule((mod) => { - if (typeof mod !== 'object') return false; +const logger = new Logger('ToastRenderer'); - if (mod.ToastPlaceholder) { - return true; - } +// TODO there are more of these +export enum ToastLocation { + /** Big Picture popup toasts */ + GAMEPADUI_POPUP = 1, + /** QAM Notifications tab */ + GAMEPADUI_QAM = 3, +} - return false; -}); +interface ToastProps { + toast: ToastData; + newIndicator?: boolean; +} -const templateClasses = findModule((mod) => { - if (typeof mod !== 'object') return false; +interface ToastRendererProps extends ToastProps { + location: ToastLocation; +} - if (mod.ShortTemplate) { - return true; - } +const templateClasses = findClassModule((m) => m.ShortTemplate) || {}; - return false; -}); +// These are memoized as they like to randomly rerender -const Toast: FunctionComponent<ToastProps> = ({ toast }) => { +const GamepadUIPopupToast: FC<Omit<ToastProps, 'newIndicator'>> = memo(({ toast }) => { return ( <div style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties} onClick={toast.onClick} - className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')} + className={joinClassNames(templateClasses.ShortTemplate, toast.className || '', 'DeckyGamepadUIPopupToast')} > {toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>} <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}> @@ -43,6 +44,61 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => { </div> </div> ); -}; +}); + +const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast, newIndicator }) => { + // The fields aren't mismatched, the logic for these is just a bit weird. + return ( + <Focusable + onActivate={() => { + toast.onClick?.(); + Navigation.CloseSideMenus(); + }} + className={joinClassNames( + templateClasses.StandardTemplateContainer, + toast.className || '', + 'DeckyGamepadUIQAMToast', + )} + > + <div className={templateClasses.StandardTemplate}> + {toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>} + <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}> + <div className={templateClasses.Header}> + {toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>} + {toast.title && <div className={templateClasses.Title}>{toast.title}</div>} + {/* timestamp should always be defined by toaster */} + {/* TODO check how valve does this */} + {toast.timestamp && ( + <div className={templateClasses.Timestamp}> + {toast.timestamp.toLocaleTimeString(undefined, { timeStyle: 'short' })} + </div> + )} + </div> + {toast.body && <div className={templateClasses.StandardNotificationDescription}>{toast.body}</div>} + {toast.subtext && <div className={templateClasses.StandardNotificationSubText}>{toast.subtext}</div>} + </div> + {newIndicator && ( + <div className={templateClasses.NewIndicator}> + <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none"> + <circle fill="currentColor" cx="25" cy="25" r="25"></circle> + </svg> + </div> + )} + </div> + </Focusable> + ); +}); + +export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location, newIndicator }) => { + switch (location) { + default: + logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIQAMToast.`); + return <GamepadUIQAMToast toast={toast} newIndicator={false} />; + case ToastLocation.GAMEPADUI_POPUP: + return <GamepadUIPopupToast toast={toast} />; + case ToastLocation.GAMEPADUI_QAM: + return <GamepadUIQAMToast toast={toast} newIndicator={newIndicator} />; + } +}); -export default Toast; +export default ToastRenderer; diff --git a/frontend/src/components/modals/filepicker/patches/index.ts b/frontend/src/components/modals/filepicker/patches/index.ts index 310bfbf8..fa0f0bb0 100644 --- a/frontend/src/components/modals/filepicker/patches/index.ts +++ b/frontend/src/components/modals/filepicker/patches/index.ts @@ -1,10 +1,10 @@ -import library from './library'; -let patches: Function[] = []; +// import library from './library'; +// let patches: Function[] = []; export function deinitFilepickerPatches() { - patches.forEach((unpatch) => unpatch()); + // patches.forEach((unpatch) => unpatch()); } export async function initFilepickerPatches() { - patches.push(await library()); + // patches.push(await library()); } diff --git a/frontend/src/components/settings/pages/testing/index.tsx b/frontend/src/components/settings/pages/testing/index.tsx index eb572614..8f02c207 100644 --- a/frontend/src/components/settings/pages/testing/index.tsx +++ b/frontend/src/components/settings/pages/testing/index.tsx @@ -10,7 +10,7 @@ import { } from '@decky/ui'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FaDownload, FaInfo } from 'react-icons/fa'; +import { FaDownload, FaFlask, FaInfo } from 'react-icons/fa'; import { setSetting } from '../../../../utils/settings'; import { UpdateBranch } from '../general/BranchSelect'; @@ -91,17 +91,20 @@ export default function TestingVersionList() { <DialogButton style={{ height: '40px', minWidth: '60px', marginRight: '10px' }} onClick={async () => { - DeckyPluginLoader.toaster.toast({ + const downloadToast = DeckyPluginLoader.toaster.toast({ title: t('Testing.start_download_toast', { id: version.id }), body: null, + icon: <FaFlask />, }); try { await downloadTestingVersion(version.id, version.head_sha); + downloadToast.dismiss(); } catch (e) { if (e instanceof Error) { DeckyPluginLoader.toaster.toast({ title: t('Testing.error'), body: `${e.name}: ${e.message}`, + icon: <FaFlask />, }); } } diff --git a/frontend/src/errorboundary-hook.tsx b/frontend/src/errorboundary-hook.tsx index 8c96d9f4..95be77ab 100644 --- a/frontend/src/errorboundary-hook.tsx +++ b/frontend/src/errorboundary-hook.tsx @@ -22,9 +22,7 @@ class ErrorBoundaryHook extends Logger { this.log('Initialized'); window.__ERRORBOUNDARY_HOOK_INSTANCE?.deinit?.(); window.__ERRORBOUNDARY_HOOK_INSTANCE = this; - } - init() { // valve writes only the sanest of code const exp = /^\(\)=>\(.\|\|.\(new .\),.\)$/; const initErrorReportingStore = findModuleExport( @@ -71,11 +69,16 @@ class ErrorBoundaryHook extends Logger { }); if (!ErrorBoundary) { - this.error('could not find ValveErrorBoundary'); + this.error('@decky/ui could not find ErrorBoundary, skipping patch'); return; } this.errorBoundaryPatch = replacePatch(ErrorBoundary.prototype, 'render', function (this: any) { + if (this.state._deckyForceRerender) { + const stateClone = { ...this.state, _deckyForceRerender: null }; + this.setState(stateClone); + return null; + } if (this.state.error) { const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore; return ( @@ -89,6 +92,10 @@ class ErrorBoundaryHook extends Logger { } return callOriginal; }); + // Small hack that gives us a lot more flexibility to force rerenders. + ErrorBoundary.prototype._deckyForceRerender = function (this: any) { + this.setState({ ...this.state, _deckyForceRerender: true }); + }; } public temporarilyDisableReporting() { diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 4ea22318..029a731c 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -5,13 +5,26 @@ interface Window { } (async () => { - // Wait for react to definitely be loaded - while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) { + // Wait for main webpack chunks to definitely be loaded + console.time('[Decky:Boot] Waiting for main Webpack chunks...'); + while (!window.webpackChunksteamui || window.webpackChunksteamui.length < 8) { await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here. } + console.timeEnd('[Decky:Boot] Waiting for main Webpack chunks...'); + + // Wait for the React root to be mounted + console.time('[Decky:Boot] Waiting for React root mount...'); + let root; + while ( + !(root = document.getElementById('root')) || + !(root as any)[Object.keys(root).find((k) => k.startsWith('__reactContainer$')) as string] + ) { + await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here. + } + console.timeEnd('[Decky:Boot] Waiting for React root mount...'); if (!window.SP_REACT) { - console.debug('[Decky:Boot] Setting up React globals...'); + console.debug('[Decky:Boot] Setting up Webpack & React globals...'); // deliberate partial import const DFLWebpack = await import('@decky/ui/dist/webpack'); window.SP_REACT = DFLWebpack.findModule((m) => m.Component && m.PureComponent && m.useLayoutEffect); diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index f7d362a7..f03877fa 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -1,17 +1,19 @@ +import { ToastNotification } from '@decky/api'; import { ModalRoot, + Navigation, PanelSection, PanelSectionRow, QuickAccessTab, - Router, findSP, quickAccessMenuClasses, showModal, sleep, } from '@decky/ui'; import { FC, lazy } from 'react'; -import { FaExclamationCircle, FaPlug } from 'react-icons/fa'; +import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa'; +import DeckyIcon from './components/DeckyIcon'; import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState'; import { File, FileSelectionType } from './components/modals/filepicker'; import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches'; @@ -28,7 +30,7 @@ import { HiddenPluginsService } from './hidden-plugins-service'; import Logger from './logger'; import { NotificationService } from './notification-service'; import { InstallType, Plugin, PluginLoadType } from './plugin'; -import RouterHook from './router-hook'; +import RouterHook, { UIMode } from './router-hook'; import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { checkForPluginUpdates } from './store'; import TabsHook from './tabs-hook'; @@ -79,11 +81,12 @@ class PluginLoader extends Logger { // stores a list of plugin names which requested to be reloaded private pluginReloadQueue: { name: string; version?: string }[] = []; + private loaderUpdateToast?: ToastNotification; + private pluginUpdateToast?: ToastNotification; + constructor() { super(PluginLoader.name); - this.errorBoundaryHook.init(); - DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this)); DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this)); DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this)); @@ -175,9 +178,19 @@ class PluginLoader extends Logger { >('loader/get_plugins'); private async loadPlugins() { - // wait for SP window to exist before loading plugins - while (!findSP()) { - await sleep(100); + let registration: any; + const uiMode = await new Promise( + (r) => + (registration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => { + r(mode); + registration.unregister(); + })), + ); + if (uiMode == UIMode.BigPicture) { + // wait for SP window to exist before loading plugins + while (!findSP()) { + await sleep(100); + } } const plugins = await this.getPluginsFromBackend(); const pluginLoadPromises = []; @@ -211,7 +224,9 @@ class PluginLoader extends Logger { if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) { this.deckyState.setHasLoaderUpdate(true); if (this.notificationService.shouldNotify('deckyUpdates')) { - this.toaster.toast({ + this.loaderUpdateToast && this.loaderUpdateToast.dismiss(); + await this.routerHook.waitForUnlock(); + this.loaderUpdateToast = this.toaster.toast({ title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />, body: ( <TranslationHelper @@ -220,7 +235,9 @@ class PluginLoader extends Logger { i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }} /> ), - onClick: () => Router.Navigate('/decky/settings'), + logo: <DeckyIcon />, + icon: <FaDownload />, + onClick: () => Navigation.Navigate('/decky/settings'), }); } } @@ -239,7 +256,8 @@ class PluginLoader extends Logger { public async notifyPluginUpdates() { const updates = await this.checkPluginUpdates(); if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) { - this.toaster.toast({ + this.pluginUpdateToast && this.pluginUpdateToast.dismiss(); + this.pluginUpdateToast = this.toaster.toast({ title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />, body: ( <TranslationHelper @@ -248,7 +266,9 @@ class PluginLoader extends Logger { i18nArgs={{ count: updates.size }} /> ), - onClick: () => Router.Navigate('/decky/settings/plugins'), + logo: <DeckyIcon />, + icon: <FaDownload />, + onClick: () => Navigation.Navigate('/decky/settings/plugins'), }); } } @@ -559,7 +579,6 @@ class PluginLoader extends Logger { method = request.method; delete req.method; } - // this is terrible but a. we're going to redo this entire method anyway and b. it was already terrible try { const ret = await DeckyBackend.call< [method: string, url: string, extra_opts?: any], diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx index e3325913..8cffb738 100644 --- a/frontend/src/router-hook.tsx +++ b/frontend/src/router-hook.tsx @@ -1,5 +1,14 @@ -import { ErrorBoundary, Focusable, Patch, afterPatch } from '@decky/ui'; -import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react'; +import { + ErrorBoundary, + Patch, + afterPatch, + findInReactTree, + findInTree, + findModuleByExport, + getReactRoot, + sleep, +} from '@decky/ui'; +import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react'; import type { Route } from 'react-router'; import { @@ -22,16 +31,26 @@ declare global { } } +export enum UIMode { + BigPicture = 4, + Desktop = 7, +} + const isPatched = Symbol('is patched'); class RouterHook extends Logger { - private router: any; - private memoizedRouter: any; - private gamepadWrapper: any; private routerState: DeckyRouterState = new DeckyRouterState(); private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState(); - private wrapperPatch: Patch; - private routerPatch?: Patch; + 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<string, ReactNode>(); + private desktopRouterPatch?: Patch; + private gamepadRouterPatch?: Patch; + private modeChangeRegistration?: any; + private patchedModes = new Set<number>(); public routes?: any[]; constructor() { @@ -41,112 +60,272 @@ class RouterHook extends Logger { window.__ROUTER_HOOK_INSTANCE?.deinit?.(); window.__ROUTER_HOOK_INSTANCE = this; - this.gamepadWrapper = Focusable; + const reactRouterStackModule = findModuleByExport((e) => e == 'router-backstack', 20); + if (reactRouterStackModule) { + this.Route = + Object.values(reactRouterStackModule).find( + (e) => typeof e == 'function' && /routePath:.\.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'); + } - let Route: new () => Route; - // Used to store the new replicated routes we create to allow routes to be unpatched. - const processList = ( - routeList: any[], - routes: Map<string, RouterEntry> | null, - routePatches: Map<string, Set<RoutePatch>>, - save: boolean, - ) => { - 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( - <Route path={path} {...props}> - <ErrorBoundary>{createElement(component)}</ErrorBoundary> - </Route>, - ); - }); - routeList[routerIndex] = newRouterArray; - } + this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => { + 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 UIMode.BigPicture: + 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; } - routeList.forEach((route: Route, index: number) => { - const replaced = toReplace.get(route?.props?.path as string); - if (replaced) { - routeList[index].props.children = replaced; - toReplace.delete(route?.props?.path as string); - } - if (route?.props?.path && routePatches.has(route.props.path as string)) { - 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; - }); - } + }); + } + + 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'], }); - }; - let toReplace = new Map<string, ReactNode>(); - const DeckyWrapper = ({ children }: { children: ReactElement }) => { - const { routes, routePatches } = useDeckyRouterState(); - const mainRouteList = children.props.children[0].props.children; - const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning - processList(mainRouteList, routes, routePatches, true); - processList(ingameRouteList, null, routePatches, false); - - this.debug('Rerendered routes list'); - return children; - }; + errorBoundaryNode?.stateNode?._deckyForceRerender?.(); + } + } - let renderedComponents: ReactElement[] = []; + // 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?.(); + } + } - const DeckyGlobalComponentsWrapper = () => { - const { components } = useDeckyGlobalComponentsState(); - if (renderedComponents.length != components.size) { - this.debug('Rerendering global components'); - renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />); + public async waitForUnlock() { + try { + while (window?.securitystore?.IsLockScreenActive?.()) { + await sleep(500); } - return <>{renderedComponents}</>; - }; - - this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => { - if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) { - const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2; - const potentialSettingsRootString = - ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || ''; - if (potentialSettingsRootString?.includes('Settings.Root()')) { - if (!this.router) { - this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type; - this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => { - if (!Route) - Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type; - const returnVal = ( - <DeckyRouterStateContextProvider deckyRouterState={this.routerState}> - <DeckyWrapper>{ret}</DeckyWrapper> - </DeckyRouterStateContextProvider> - ); - return returnVal; - }); - this.memoizedRouter = memo(this.router.type); - this.memoizedRouter.isDeckyRouter = true; - } - ret.props.children.props.children.push( - <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}> - <DeckyGlobalComponentsWrapper /> - </DeckyGlobalComponentsStateContextProvider>, + } 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 }) { + // 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<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 | JSX.Element)[] = []; + routes.forEach(({ component, props }, path) => { + newRouterArray.push( + <Route path={path} {...props}> + <ErrorBoundary>{createElement(component)}</ErrorBoundary> + </Route>, ); - ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter; - } + }); + 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; + }); } - return ret; }); } @@ -175,8 +354,9 @@ class RouterHook extends Logger { } deinit() { - this.wrapperPatch.unpatch(); - this.routerPatch?.unpatch(); + this.modeChangeRegistration?.unregister(); + this.gamepadRouterPatch?.unpatch(); + this.desktopRouterPatch?.unpatch(); } } diff --git a/frontend/src/tabs-hook.tsx b/frontend/src/tabs-hook.tsx index 16643165..493faa6b 100644 --- a/frontend/src/tabs-hook.tsx +++ b/frontend/src/tabs-hook.tsx @@ -1,5 +1,14 @@ // TabsHook for versions after the Desktop merge -import { ErrorBoundary, Patch, QuickAccessTab, afterPatch, findInReactTree, getReactRoot, sleep } from '@decky/ui'; +import { + ErrorBoundary, + Patch, + QuickAccessTab, + afterPatch, + createReactTreePatcher, + findInReactTree, + findModuleByExport, + getReactRoot, +} from '@decky/ui'; import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState'; import Logger from './logger'; @@ -20,7 +29,6 @@ interface Tab { class TabsHook extends Logger { // private keys = 7; tabs: Tab[] = []; - private qAMRoot?: any; private qamPatch?: Patch; constructor() { @@ -32,87 +40,38 @@ class TabsHook extends Logger { } init() { - const tree = getReactRoot(document.getElementById('root') as any); - let qAMRoot: any; - const findQAMRoot = (currentNode: any, iters: number): any => { - if (iters >= 80) { - // currently 67 - return null; - } - if ( - (typeof currentNode?.memoizedProps?.visible == 'boolean' || - typeof currentNode?.memoizedProps?.active == 'boolean') && - currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView') - ) { - this.log(`QAM root was found in ${iters} recursion cycles`); - return currentNode; - } - if (currentNode.child) { - let node = findQAMRoot(currentNode.child, iters + 1); - if (node !== null) return node; - } - if (currentNode.sibling) { - let node = findQAMRoot(currentNode.sibling, iters + 1); - if (node !== null) return node; - } - return null; - }; - (async () => { - qAMRoot = findQAMRoot(tree, 0); - while (!qAMRoot) { - this.error( - 'Failed to find QAM root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.', - ); - await sleep(5000); - qAMRoot = findQAMRoot(tree, 0); - } - this.qAMRoot = qAMRoot; - let patchedInnerQAM: any; - this.qamPatch = afterPatch(qAMRoot.return, 'type', (_: any, ret: any) => { - try { - if (!qAMRoot?.child) { - qAMRoot = findQAMRoot(tree, 0); - this.qAMRoot = qAMRoot; - } - if (qAMRoot?.child && !qAMRoot?.child?.type?.decky) { - afterPatch(qAMRoot.child, 'type', (_: any, ret: any) => { - try { - const qamTabsRenderer = findInReactTree(ret, (x) => x?.props?.onFocusNavDeactivated); - if (patchedInnerQAM) { - qamTabsRenderer.type = patchedInnerQAM; - } else { - afterPatch(qamTabsRenderer, 'type', (innerArgs: any, ret: any) => { - const tabs = findInReactTree(ret, (x) => x?.props?.tabs); - this.render(tabs.props.tabs, innerArgs[0].visible); - return ret; - }); - patchedInnerQAM = qamTabsRenderer.type; - } - } catch (e) { - this.error('Error patching QAM inner', e); - } - return ret; - }); - qAMRoot.child.type.decky = true; - qAMRoot.child.alternate.type = qAMRoot.child.type; - } - } catch (e) { - this.error('Error patching QAM', e); - } + // TODO patch the "embedded" renderer in this module too (seems to be for VR? unsure) + const qamModule = findModuleByExport((e) => e?.type?.toString()?.includes('QuickAccessMenuBrowserView')); + const qamRenderer = Object.values(qamModule).find((e: any) => + e?.type?.toString()?.includes('QuickAccessMenuBrowserView'), + ); + 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', + ); - if (qAMRoot.return.alternate) { - qAMRoot.return.alternate.type = qAMRoot.return.type; + this.qamPatch = afterPatch(qamRenderer, 'type', patchHandler); + + // Patch already rendered qam + const root = getReactRoot(document.getElementById('root') as any); + const qamNode = root && findInReactTree(root, (n: any) => n.elementType == qamRenderer); // 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; } - this.log('Finished initial injection'); - })(); + } } deinit() { this.qamPatch?.unpatch(); - this.qAMRoot.return.alternate.type = this.qAMRoot.return.type; } add(tab: Tab) { diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx index 4bc08772..e45b14a4 100644 --- a/frontend/src/toaster.tsx +++ b/frontend/src/toaster.tsx @@ -1,19 +1,16 @@ -import type { ToastData } from '@decky/api'; -import { - Export, - Patch, - afterPatch, - findClassByName, - findInReactTree, - findModuleExport, - getReactRoot, - sleep, -} from '@decky/ui'; -import { ReactNode } from 'react'; +import type { ToastData, ToastNotification } from '@decky/api'; +import { 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; @@ -23,176 +20,99 @@ declare global { } class Toaster extends Logger { - // private routerHook: RouterHook; - // private toasterState: DeckyToasterState = new DeckyToasterState(); - private node: any; - private rNode: any; - private audioModule: any; - private finishStartup?: () => void; - private ready: Promise<void> = new Promise((res) => (this.finishStartup = res)); - private toasterPatch?: Patch; + private toastPatch?: Patch; constructor() { super('Toaster'); - // this.routerHook = routerHook; window.__TOASTER_INSTANCE?.deinit?.(); window.__TOASTER_INSTANCE = this; - this.init(); - } - async init() { - // this.routerHook.addGlobalComponent('DeckyToaster', () => ( - // <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}> - // <DeckyToaster /> - // </DeckyToasterStateContextProvider> - // )); - let instance: any; - const tree = getReactRoot(document.getElementById('root') as any); - const toasterClass1 = findClassByName('GamepadToastPlaceholder'); - const toasterClass2 = findClassByName('ToastPlaceholder'); - const toasterClass3 = findClassByName('ToastPopup'); - const toasterClass4 = findClassByName('GamepadToastPopup'); - const findToasterRoot = (currentNode: any, iters: number): any => { - if (iters >= 80) { - // currently 66 - return null; - } - if ( - currentNode?.memoizedProps?.className?.startsWith?.(toasterClass1) || - currentNode?.memoizedProps?.className?.startsWith?.(toasterClass2) || - currentNode?.memoizedProps?.className?.startsWith?.(toasterClass3) || - currentNode?.memoizedProps?.className?.startsWith?.(toasterClass4) - ) { - this.log(`Toaster root was found in ${iters} recursion cycles`); - return currentNode; + 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) => ( + <Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} /> + )); } - if (currentNode.sibling) { - let node = findToasterRoot(currentNode.sibling, iters + 1); - if (node !== null) return node; - } - if (currentNode.child) { - let node = findToasterRoot(currentNode.child, iters + 1); - if (node !== null) return node; - } - return null; - }; - instance = findToasterRoot(tree, 0); - while (!instance) { - this.warn( - 'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.', - ); - await sleep(5000); - instance = findToasterRoot(tree, 0); - } - this.node = instance.return; - this.rNode = findInReactTree( - this.node.return.return, - (node) => node?.stateNode && node.type?.InstallErrorReportingStore, - ); - let toast: any; - let renderedToast: ReactNode = null; - let innerPatched: any; - const repatch = () => { - if (this.node && !this.node.type.decky) { - this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => { - const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss); - if (innerPatched) { - inner.type = innerPatched; - } else { - afterPatch(inner, 'type', (innerArgs: any, ret: any) => { - const currentToast = innerArgs[0]?.notification; - if (currentToast?.decky) { - if (currentToast == toast) { - ret.props.children = renderedToast; - } else { - toast = currentToast; - renderedToast = <Toast toast={toast.data} />; - ret.props.children = renderedToast; - } - } else { - toast = null; - renderedToast = null; - } - return ret; - }); - innerPatched = inner.type; - } - return ret; - }); - this.node.type.decky = true; - this.node.alternate.type = this.node.type; - } - }; - const oRender = Object.getPrototypeOf(this.rNode.stateNode).render; - let int: number | undefined; - this.rNode.stateNode.render = (...args: any[]) => { - const ret = oRender.call(this.rNode.stateNode, ...args); - if (ret && !this?.node?.return?.return) { - int && clearInterval(int); - int = setInterval(() => { - const n = findToasterRoot(tree, 0); - if (n?.return) { - clearInterval(int); - this.node = n.return; - this.rNode = this.node.return; - repatch(); - } else { - this.error('Failed to re-grab Toaster node, trying again...'); - } - }, 1200); - } - repatch(); - return ret; - }; - - this.rNode.stateNode.shouldComponentUpdate = () => true; - this.rNode.stateNode.forceUpdate(); - delete this.rNode.stateNode.shouldComponentUpdate; - - this.audioModule = findModuleExport((e: Export) => e.PlayNavSound && e.RegisterCallbackOnPlaySound); + return callOriginal; + }); this.log('Initialized'); - this.finishStartup?.(); } - async toast(toast: ToastData) { - // toast.duration = toast.duration || 5e3; - // this.toasterState.addToast(toast); - await this.ready; + 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 || 11, + eType: toast.eType || 13, + eSource: 1, // Client nToastDurationMS: toast.duration || (toast.duration = 5e3), data: toast, decky: true, }; - // @ts-ignore - toastData.data.appid = () => 0; - if (toast.sound === undefined) toast.sound = 6; - if (toast.playSound === undefined) toast.playSound = true; - if (toast.showToast === undefined) toast.showToast = true; - if ( - (window.settingsStore.settings.bDisableAllToasts && !toast.critical) || - (window.settingsStore.settings.bDisableToastsInGame && - !toast.critical && - window.NotificationStore.BIsUserInGame()) - ) - return; - if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound); - if (toast.showToast) { - window.NotificationStore.m_rgNotificationToasts.push(toastData); - window.NotificationStore.DispatchNextToast(); + 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, + 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); } + window.NotificationStore.ProcessNotification(info, toastData, ToastType.New); + return toastResult; } deinit() { - this.toasterPatch?.unpatch(); - this.node.alternate.type = this.node.type; - delete this.rNode.stateNode.render; - this.ready = new Promise((res) => (this.finishStartup = res)); - // this.routerHook.removeGlobalComponent('DeckyToaster'); + this.toastPatch?.unpatch(); } } |
