Rewrite router/tabs/toaster hooks (#661)

This commit is contained in:
AAGaming
2024-08-05 14:07:10 -04:00
committed by GitHub
parent 75aa1e4851
commit 131f0961ff
18 changed files with 606 additions and 572 deletions
+1 -1
View File
@@ -415,7 +415,7 @@ CLOSEABLE_URLS = ["about:blank", "data:text/html,%3Cbody%3E%3C%2Fbody%3E"] # Clo
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()
+1
View File
@@ -209,6 +209,7 @@ class PluginManager:
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:
+2 -2
View File
@@ -197,9 +197,9 @@ class Utilities:
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,
+2 -2
View File
@@ -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",
+10 -10
View File
@@ -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
+38 -36
View File
@@ -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;
-57
View File
@@ -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;
@@ -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>
);
};
+5 -5
View File
@@ -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) {
+79 -23
View File
@@ -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';
import Logger from '../logger';
const logger = new Logger('ToastRenderer');
// TODO there are more of these
export enum ToastLocation {
/** Big Picture popup toasts */
GAMEPADUI_POPUP = 1,
/** QAM Notifications tab */
GAMEPADUI_QAM = 3,
}
interface ToastProps {
toast: ToastData;
newIndicator?: boolean;
}
export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
interface ToastRendererProps extends ToastProps {
location: ToastLocation;
}
if (mod.ToastPlaceholder) {
return true;
}
const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
return false;
});
// These are memoized as they like to randomly rerender
const templateClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ShortTemplate) {
return true;
}
return false;
});
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>
);
};
});
export default Toast;
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 ToastRenderer;
@@ -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());
}
@@ -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 />,
});
}
}
+10 -3
View File
@@ -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() {
+16 -3
View File
@@ -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);
+32 -13
View File
@@ -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],
+288 -108
View File
@@ -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;
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;
}
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');
}
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;
});
}
} else {
this.error('Failed to find router stack module');
}
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;
}
});
}
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);
errorBoundaryNode?.stateNode?._deckyForceRerender?.();
}
}
this.debug('Rerendered routes list');
return children;
};
let renderedComponents: ReactElement[] = [];
const DeckyGlobalComponentsWrapper = () => {
const { components } = useDeckyGlobalComponentsState();
if (renderedComponents.length != components.size) {
this.debug('Rerendering global components');
renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
// 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;
}
return <>{renderedComponents}</>;
};
// 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?.();
}
}
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>,
);
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
}
public async waitForUnlock() {
try {
while (window?.securitystore?.IsLockScreenActive?.()) {
await sleep(500);
}
} catch (e) {
this.warn('Error while checking if unlocked:', e);
}
}
public handleDesktopRouterRender(_: any, ret: any) {
const DeckyDesktopRouterWrapper = this.DeckyDesktopRouterWrapper;
const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
this.debug('desktop router render', ret);
if (ret._decky) {
return ret;
}
const returnVal = (
<>
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
<DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>
</>
);
(returnVal as any)._decky = true;
return returnVal;
}
public handleGamepadRouterRender(_: any, ret: any) {
const DeckyGamepadRouterWrapper = this.DeckyGamepadRouterWrapper;
const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
if (ret._decky) {
return ret;
}
const returnVal = (
<>
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
<DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>
</>
);
(returnVal as any)._decky = true;
return returnVal;
}
private globalComponentsWrapper() {
const { components } = useDeckyGlobalComponentsState();
if (this.renderedComponents.length != components.size) {
this.debug('Rerendering global components');
this.renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
}
return <>{this.renderedComponents}</>;
}
private gamepadRouterWrapper({ children }: { children: ReactElement }) {
// 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>,
);
});
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;
});
}
});
}
@@ -175,8 +354,9 @@ class RouterHook extends Logger {
}
deinit() {
this.wrapperPatch.unpatch();
this.routerPatch?.unpatch();
this.modeChangeRegistration?.unregister();
this.gamepadRouterPatch?.unpatch();
this.desktopRouterPatch?.unpatch();
}
}
+34 -75
View File
@@ -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) {
+79 -159
View File
@@ -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;
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?.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;
}
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();
}
}