diff --git a/backend/decky_loader/injector.py b/backend/decky_loader/injector.py index 26b0fc01d..7d1a40c10 100644 --- a/backend/decky_loader/injector.py +++ b/backend/decky_loader/injector.py @@ -415,7 +415,7 @@ async def get_tab_lambda(test: Callable[[Tab], bool]) -> Tab: DO_NOT_CLOSE_URLS = ["Valve Steam Gamepad/default", "Valve%20Steam%20Gamepad"] # Steam Big Picture Mode tab def tab_is_gamepadui(t: Tab) -> bool: - return "https://steamloopback.host/routes/" in t.url and t.title in SHARED_CTX_NAMES + return ("https://steamloopback.host/routes/" in t.url or "https://steamloopback.host/index.html" in t.url) and t.title in SHARED_CTX_NAMES async def get_gamepadui_tab() -> Tab: tabs = await get_tabs() diff --git a/backend/decky_loader/main.py b/backend/decky_loader/main.py index 973919f1c..5033126ed 100644 --- a/backend/decky_loader/main.py +++ b/backend/decky_loader/main.py @@ -209,6 +209,7 @@ async def inject_javascript(self, tab: Tab, first: bool=False, request: Request| await tab.close_websocket() self.js_ctx_tab = None await restart_webhelper() + await sleep(1) # To give CEF enough time to close down the websocket return # We'll catch the next tab in the main loop await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/index.js?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (get_loader_version(), ), False, False, False) except: diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py index a90244a1b..4850cdef1 100644 --- a/backend/decky_loader/utilities.py +++ b/backend/decky_loader/utilities.py @@ -197,9 +197,9 @@ async def http_request(self, req: Request) -> StreamResponse: self.logger.debug(f"Finished stream for {url}") return res - async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}): + async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}, timeout: int | None = None): async with ClientSession() as web: - res = await web.request(method, url, ssl=helpers.get_ssl_context(), **extra_opts) + res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts) text = await res.text() return { "status": res.status, diff --git a/frontend/package.json b/frontend/package.json index e2d567a9b..a29690504 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "localize": "i18next" }, "devDependencies": { - "@decky/api": "^1.1.0", + "@decky/api": "^1.1.1", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-json": "^6.1.0", @@ -47,7 +47,7 @@ } }, "dependencies": { - "@decky/ui": "^4.6.0", + "@decky/ui": "^4.7.0", "filesize": "^10.1.2", "i18next": "^23.11.5", "i18next-http-backend": "^2.5.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 850ee78f9..b8945bb0c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@decky/ui': - specifier: ^4.6.0 - version: 4.6.0 + specifier: ^4.7.0 + version: 4.7.0 filesize: specifier: ^10.1.2 version: 10.1.2 @@ -37,8 +37,8 @@ importers: version: 4.0.0 devDependencies: '@decky/api': - specifier: ^1.1.0 - version: 1.1.0 + specifier: ^1.1.1 + version: 1.1.1 '@rollup/plugin-commonjs': specifier: ^26.0.1 version: 26.0.1(rollup@4.18.0) @@ -212,11 +212,11 @@ packages: resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} engines: {node: '>=6.9.0'} - '@decky/api@1.1.0': - resolution: {integrity: sha512-ECCLeI+xj13b89931S/ww1pM3Hgo7utseiww8HXkITkl4OkRfGSO/jtm0srNZPZpkoNyD5k6raXBbDQ02zgAFg==} + '@decky/api@1.1.1': + resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==} - '@decky/ui@4.6.0': - resolution: {integrity: sha512-hGofSF1VeBxZ6ewA1Fq9iAsg50hxSLcNSsSNWS6N9E5UzdeEhd/1/6PIExHbtnSnMQGJ3lk9FaBBaz6IbG0Mvg==} + '@decky/ui@4.7.0': + resolution: {integrity: sha512-klNWF5tnZVqzuUgFbw+pThiZjK7gKEtwbEZAo4aAuPJSVobpl/euTx9NAxY95QPCFMDgxCo6X6ioEA2nMfHfLA==} '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} @@ -2287,9 +2287,9 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 - '@decky/api@1.1.0': {} + '@decky/api@1.1.1': {} - '@decky/ui@4.6.0': {} + '@decky/ui@4.7.0': {} '@esbuild/aix-ppc64@0.20.2': optional: true diff --git a/frontend/src/components/DeckyIcon.tsx b/frontend/src/components/DeckyIcon.tsx index 515bd847a..f07f46d3e 100644 --- a/frontend/src/components/DeckyIcon.tsx +++ b/frontend/src/components/DeckyIcon.tsx @@ -1,37 +1,39 @@ -export default function DeckyIcon() { - return ( - - - +import { FC, SVGAttributes } from 'react'; - - - - - - ); -} +const DeckyIcon: FC> = (props) => ( + + + + + + + + + +); + +export default DeckyIcon; diff --git a/frontend/src/components/DeckyToaster.tsx b/frontend/src/components/DeckyToaster.tsx deleted file mode 100644 index 056f1dd7a..000000000 --- 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 = () => { - const { toasts, removeToast } = useDeckyToasterState(); - const [renderedToast, setRenderedToast] = useState(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: , 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 ( -
- {renderedToast && renderedToast.component} -
- ); -}; - -export default DeckyToaster; diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx deleted file mode 100644 index ebe90b238..000000000 --- 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; -} - -export class DeckyToasterState { - private _toasts: Set = 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(null as any); - -export const useDeckyToasterState = () => useContext(DeckyToasterContext); - -interface Props { - deckyToasterState: DeckyToasterState; - children: ReactNode; -} - -export const DeckyToasterStateContextProvider: FC = ({ children, deckyToasterState }) => { - const [publicDeckyToasterState, setPublicDeckyToasterState] = useState({ - ...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 ( - - {children} - - ); -}; diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx index 8b45aae49..0cb82b7f6 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 79e3d8648..e86e93378 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 = ({ toast }) => { +const GamepadUIPopupToast: FC> = memo(({ toast }) => { return (
{toast.logo &&
{toast.logo}
}
@@ -43,6 +44,61 @@ const Toast: FunctionComponent = ({ toast }) => {
); -}; +}); + +const GamepadUIQAMToast: FC = memo(({ toast, newIndicator }) => { + // The fields aren't mismatched, the logic for these is just a bit weird. + return ( + { + toast.onClick?.(); + Navigation.CloseSideMenus(); + }} + className={joinClassNames( + templateClasses.StandardTemplateContainer, + toast.className || '', + 'DeckyGamepadUIQAMToast', + )} + > +
+ {toast.logo &&
{toast.logo}
} +
+
+ {toast.icon &&
{toast.icon}
} + {toast.title &&
{toast.title}
} + {/* timestamp should always be defined by toaster */} + {/* TODO check how valve does this */} + {toast.timestamp && ( +
+ {toast.timestamp.toLocaleTimeString(undefined, { timeStyle: 'short' })} +
+ )} +
+ {toast.body &&
{toast.body}
} + {toast.subtext &&
{toast.subtext}
} +
+ {newIndicator && ( +
+ + + +
+ )} +
+
+ ); +}); + +export const ToastRenderer: FC = memo(({ toast, location, newIndicator }) => { + switch (location) { + default: + logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIQAMToast.`); + return ; + case ToastLocation.GAMEPADUI_POPUP: + return ; + case ToastLocation.GAMEPADUI_QAM: + return ; + } +}); -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 310bfbf8d..fa0f0bb08 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 eb572614b..8f02c2073 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() { { - DeckyPluginLoader.toaster.toast({ + const downloadToast = DeckyPluginLoader.toaster.toast({ title: t('Testing.start_download_toast', { id: version.id }), body: null, + icon: , }); 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: , }); } } diff --git a/frontend/src/errorboundary-hook.tsx b/frontend/src/errorboundary-hook.tsx index 8c96d9f4e..95be77abc 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 4ea223180..029a731cb 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 f7d362a7b..f03877faf 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: , body: ( ), - onClick: () => Router.Navigate('/decky/settings'), + logo: , + icon: , + 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: , body: ( ), - onClick: () => Router.Navigate('/decky/settings/plugins'), + logo: , + icon: , + 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 e33259131..8cffb738d 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(); + private desktopRouterPatch?: Patch; + private gamepadRouterPatch?: Patch; + private modeChangeRegistration?: any; + private patchedModes = new Set(); 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 | null, - routePatches: Map>, - 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( - - {createElement(component)} - , - ); - }); - 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(); - 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) => ); + 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 = ( - - {ret} - - ); - return returnVal; - }); - this.memoizedRouter = memo(this.router.type); - this.memoizedRouter.isDeckyRouter = true; - } - ret.props.children.props.children.push( - - - , + } 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)} + , ); - 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 166431655..493faa6bf 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 4bc087720..e45b14a47 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 = 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', () => ( - // - // - // - // )); - 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) => ( + + )); } - 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 = ; - 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(); } }