summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorAAGaming <aagaming@riseup.net>2024-08-05 14:07:10 -0400
committerGitHub <noreply@github.com>2024-08-05 14:07:10 -0400
commit131f0961ff451ec47376483178e092c8d7403b27 (patch)
tree4d2ea34e8220e14c4b820cc1ad38face7193f6fe /frontend
parent75aa1e4851445646994ba3a61ff41325403359fb (diff)
downloaddecky-loader-131f0961ff451ec47376483178e092c8d7403b27.tar.gz
decky-loader-131f0961ff451ec47376483178e092c8d7403b27.zip
Rewrite router/tabs/toaster hooks (#661)
Diffstat (limited to 'frontend')
-rw-r--r--frontend/package.json4
-rw-r--r--frontend/pnpm-lock.yaml20
-rw-r--r--frontend/src/components/DeckyIcon.tsx74
-rw-r--r--frontend/src/components/DeckyToaster.tsx57
-rw-r--r--frontend/src/components/DeckyToasterState.tsx69
-rw-r--r--frontend/src/components/TitleView.tsx10
-rw-r--r--frontend/src/components/Toast.tsx102
-rw-r--r--frontend/src/components/modals/filepicker/patches/index.ts8
-rw-r--r--frontend/src/components/settings/pages/testing/index.tsx7
-rw-r--r--frontend/src/errorboundary-hook.tsx13
-rw-r--r--frontend/src/index.ts19
-rw-r--r--frontend/src/plugin-loader.tsx45
-rw-r--r--frontend/src/router-hook.tsx394
-rw-r--r--frontend/src/tabs-hook.tsx109
-rw-r--r--frontend/src/toaster.tsx238
15 files changed, 601 insertions, 568 deletions
diff --git a/frontend/package.json b/frontend/package.json
index e2d567a9..a2969050 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 850ee78f..b8945bb0 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 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();
}
}