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 DO_NOT_CLOSE_URLS = ["Valve Steam Gamepad/default", "Valve%20Steam%20Gamepad"] # Steam Big Picture Mode tab
def tab_is_gamepadui(t: Tab) -> bool: 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: async def get_gamepadui_tab() -> Tab:
tabs = await get_tabs() tabs = await get_tabs()
+1
View File
@@ -209,6 +209,7 @@ class PluginManager:
await tab.close_websocket() await tab.close_websocket()
self.js_ctx_tab = None self.js_ctx_tab = None
await restart_webhelper() 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 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) 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: except:
+2 -2
View File
@@ -197,9 +197,9 @@ class Utilities:
self.logger.debug(f"Finished stream for {url}") self.logger.debug(f"Finished stream for {url}")
return res 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: 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() text = await res.text()
return { return {
"status": res.status, "status": res.status,
+2 -2
View File
@@ -13,7 +13,7 @@
"localize": "i18next" "localize": "i18next"
}, },
"devDependencies": { "devDependencies": {
"@decky/api": "^1.1.0", "@decky/api": "^1.1.1",
"@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-image": "^3.0.3", "@rollup/plugin-image": "^3.0.3",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
@@ -47,7 +47,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@decky/ui": "^4.6.0", "@decky/ui": "^4.7.0",
"filesize": "^10.1.2", "filesize": "^10.1.2",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-http-backend": "^2.5.2", "i18next-http-backend": "^2.5.2",
+10 -10
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@decky/ui': '@decky/ui':
specifier: ^4.6.0 specifier: ^4.7.0
version: 4.6.0 version: 4.7.0
filesize: filesize:
specifier: ^10.1.2 specifier: ^10.1.2
version: 10.1.2 version: 10.1.2
@@ -37,8 +37,8 @@ importers:
version: 4.0.0 version: 4.0.0
devDependencies: devDependencies:
'@decky/api': '@decky/api':
specifier: ^1.1.0 specifier: ^1.1.1
version: 1.1.0 version: 1.1.1
'@rollup/plugin-commonjs': '@rollup/plugin-commonjs':
specifier: ^26.0.1 specifier: ^26.0.1
version: 26.0.1(rollup@4.18.0) version: 26.0.1(rollup@4.18.0)
@@ -212,11 +212,11 @@ packages:
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@decky/api@1.1.0': '@decky/api@1.1.1':
resolution: {integrity: sha512-ECCLeI+xj13b89931S/ww1pM3Hgo7utseiww8HXkITkl4OkRfGSO/jtm0srNZPZpkoNyD5k6raXBbDQ02zgAFg==} resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==}
'@decky/ui@4.6.0': '@decky/ui@4.7.0':
resolution: {integrity: sha512-hGofSF1VeBxZ6ewA1Fq9iAsg50hxSLcNSsSNWS6N9E5UzdeEhd/1/6PIExHbtnSnMQGJ3lk9FaBBaz6IbG0Mvg==} resolution: {integrity: sha512-klNWF5tnZVqzuUgFbw+pThiZjK7gKEtwbEZAo4aAuPJSVobpl/euTx9NAxY95QPCFMDgxCo6X6ioEA2nMfHfLA==}
'@esbuild/aix-ppc64@0.20.2': '@esbuild/aix-ppc64@0.20.2':
resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==}
@@ -2287,9 +2287,9 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.7 '@babel/helper-validator-identifier': 7.24.7
to-fast-properties: 2.0.0 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': '@esbuild/aix-ppc64@0.20.2':
optional: true optional: true
+38 -36
View File
@@ -1,37 +1,39 @@
export default function DeckyIcon() { import { FC, SVGAttributes } from 'react';
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"
/>
<ellipse const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => (
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)" <svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456" {...props}>
style={{ fill: 'none' }} <g>
cx="154.33" <path
cy="211.33" style={{ fill: 'none' }}
rx="69.33" 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
ry="69.33" 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
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" /> 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
<path C226.38,87.12,191.11,72.51,154.33,72.51z"
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 <ellipse
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 transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
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 style={{ fill: 'none' }}
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 cx="154.33"
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 cy="211.33"
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 rx="69.33"
c7.18,0,13,5.82,13,13V271z" ry="69.33"
/> />
</g> <path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
</svg> <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 { CSSProperties, FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BsGearFill } from 'react-icons/bs'; import { BsGearFill } from 'react-icons/bs';
@@ -19,13 +19,13 @@ const TitleView: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const onSettingsClick = () => { const onSettingsClick = () => {
Router.CloseSideMenus(); Navigation.Navigate('/decky/settings');
Router.Navigate('/decky/settings'); Navigation.CloseSideMenus();
}; };
const onStoreClick = () => { const onStoreClick = () => {
Router.CloseSideMenus(); Navigation.Navigate('/decky/store');
Router.Navigate('/decky/store'); Navigation.CloseSideMenus();
}; };
if (activePlugin === null) { if (activePlugin === null) {
+79 -23
View File
@@ -1,37 +1,38 @@
import type { ToastData } from '@decky/api'; import type { ToastData } from '@decky/api';
import { findModule, joinClassNames } from '@decky/ui'; import { Focusable, Navigation, findClassModule, joinClassNames } from '@decky/ui';
import { FunctionComponent } from 'react'; 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 { interface ToastProps {
toast: ToastData; toast: ToastData;
newIndicator?: boolean;
} }
export const toastClasses = findModule((mod) => { interface ToastRendererProps extends ToastProps {
if (typeof mod !== 'object') return false; location: ToastLocation;
}
if (mod.ToastPlaceholder) { const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
return true;
}
return false; // These are memoized as they like to randomly rerender
});
const templateClasses = findModule((mod) => { const GamepadUIPopupToast: FC<Omit<ToastProps, 'newIndicator'>> = memo(({ toast }) => {
if (typeof mod !== 'object') return false;
if (mod.ShortTemplate) {
return true;
}
return false;
});
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return ( return (
<div <div
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties} style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
onClick={toast.onClick} 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>} {toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}> <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
@@ -43,6 +44,61 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
</div> </div>
</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'; // import library from './library';
let patches: Function[] = []; // let patches: Function[] = [];
export function deinitFilepickerPatches() { export function deinitFilepickerPatches() {
patches.forEach((unpatch) => unpatch()); // patches.forEach((unpatch) => unpatch());
} }
export async function initFilepickerPatches() { export async function initFilepickerPatches() {
patches.push(await library()); // patches.push(await library());
} }
@@ -10,7 +10,7 @@ import {
} from '@decky/ui'; } from '@decky/ui';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { setSetting } from '../../../../utils/settings';
import { UpdateBranch } from '../general/BranchSelect'; import { UpdateBranch } from '../general/BranchSelect';
@@ -91,17 +91,20 @@ export default function TestingVersionList() {
<DialogButton <DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }} style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={async () => { onClick={async () => {
DeckyPluginLoader.toaster.toast({ const downloadToast = DeckyPluginLoader.toaster.toast({
title: t('Testing.start_download_toast', { id: version.id }), title: t('Testing.start_download_toast', { id: version.id }),
body: null, body: null,
icon: <FaFlask />,
}); });
try { try {
await downloadTestingVersion(version.id, version.head_sha); await downloadTestingVersion(version.id, version.head_sha);
downloadToast.dismiss();
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
DeckyPluginLoader.toaster.toast({ DeckyPluginLoader.toaster.toast({
title: t('Testing.error'), title: t('Testing.error'),
body: `${e.name}: ${e.message}`, body: `${e.name}: ${e.message}`,
icon: <FaFlask />,
}); });
} }
} }
+10 -3
View File
@@ -22,9 +22,7 @@ class ErrorBoundaryHook extends Logger {
this.log('Initialized'); this.log('Initialized');
window.__ERRORBOUNDARY_HOOK_INSTANCE?.deinit?.(); window.__ERRORBOUNDARY_HOOK_INSTANCE?.deinit?.();
window.__ERRORBOUNDARY_HOOK_INSTANCE = this; window.__ERRORBOUNDARY_HOOK_INSTANCE = this;
}
init() {
// valve writes only the sanest of code // valve writes only the sanest of code
const exp = /^\(\)=>\(.\|\|.\(new .\),.\)$/; const exp = /^\(\)=>\(.\|\|.\(new .\),.\)$/;
const initErrorReportingStore = findModuleExport( const initErrorReportingStore = findModuleExport(
@@ -71,11 +69,16 @@ class ErrorBoundaryHook extends Logger {
}); });
if (!ErrorBoundary) { if (!ErrorBoundary) {
this.error('could not find ValveErrorBoundary'); this.error('@decky/ui could not find ErrorBoundary, skipping patch');
return; return;
} }
this.errorBoundaryPatch = replacePatch(ErrorBoundary.prototype, 'render', function (this: any) { 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) { if (this.state.error) {
const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore; const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore;
return ( return (
@@ -89,6 +92,10 @@ class ErrorBoundaryHook extends Logger {
} }
return callOriginal; 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() { public temporarilyDisableReporting() {
+16 -3
View File
@@ -5,13 +5,26 @@ interface Window {
} }
(async () => { (async () => {
// Wait for react to definitely be loaded // Wait for main webpack chunks to definitely be loaded
while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) { 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. 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) { if (!window.SP_REACT) {
console.debug('[Decky:Boot] Setting up React globals...'); console.debug('[Decky:Boot] Setting up Webpack & React globals...');
// deliberate partial import // deliberate partial import
const DFLWebpack = await import('@decky/ui/dist/webpack'); const DFLWebpack = await import('@decky/ui/dist/webpack');
window.SP_REACT = DFLWebpack.findModule((m) => m.Component && m.PureComponent && m.useLayoutEffect); 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 { import {
ModalRoot, ModalRoot,
Navigation,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
QuickAccessTab, QuickAccessTab,
Router,
findSP, findSP,
quickAccessMenuClasses, quickAccessMenuClasses,
showModal, showModal,
sleep, sleep,
} from '@decky/ui'; } from '@decky/ui';
import { FC, lazy } from 'react'; 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 { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
import { File, FileSelectionType } from './components/modals/filepicker'; import { File, FileSelectionType } from './components/modals/filepicker';
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches'; import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
@@ -28,7 +30,7 @@ import { HiddenPluginsService } from './hidden-plugins-service';
import Logger from './logger'; import Logger from './logger';
import { NotificationService } from './notification-service'; import { NotificationService } from './notification-service';
import { InstallType, Plugin, PluginLoadType } from './plugin'; import { InstallType, Plugin, PluginLoadType } from './plugin';
import RouterHook from './router-hook'; import RouterHook, { UIMode } from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForPluginUpdates } from './store'; import { checkForPluginUpdates } from './store';
import TabsHook from './tabs-hook'; import TabsHook from './tabs-hook';
@@ -79,11 +81,12 @@ class PluginLoader extends Logger {
// stores a list of plugin names which requested to be reloaded // stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: { name: string; version?: string }[] = []; private pluginReloadQueue: { name: string; version?: string }[] = [];
private loaderUpdateToast?: ToastNotification;
private pluginUpdateToast?: ToastNotification;
constructor() { constructor() {
super(PluginLoader.name); super(PluginLoader.name);
this.errorBoundaryHook.init();
DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this)); DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this));
DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this)); DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this));
DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this)); DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this));
@@ -175,9 +178,19 @@ class PluginLoader extends Logger {
>('loader/get_plugins'); >('loader/get_plugins');
private async loadPlugins() { private async loadPlugins() {
// wait for SP window to exist before loading plugins let registration: any;
while (!findSP()) { const uiMode = await new Promise(
await sleep(100); (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 plugins = await this.getPluginsFromBackend();
const pluginLoadPromises = []; const pluginLoadPromises = [];
@@ -211,7 +224,9 @@ class PluginLoader extends Logger {
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) { if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.deckyState.setHasLoaderUpdate(true); this.deckyState.setHasLoaderUpdate(true);
if (this.notificationService.shouldNotify('deckyUpdates')) { 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" />, title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
body: ( body: (
<TranslationHelper <TranslationHelper
@@ -220,7 +235,9 @@ class PluginLoader extends Logger {
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }} 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() { public async notifyPluginUpdates() {
const updates = await this.checkPluginUpdates(); const updates = await this.checkPluginUpdates();
if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) { 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" />, title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
body: ( body: (
<TranslationHelper <TranslationHelper
@@ -248,7 +266,9 @@ class PluginLoader extends Logger {
i18nArgs={{ count: updates.size }} 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; method = request.method;
delete req.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 { try {
const ret = await DeckyBackend.call< const ret = await DeckyBackend.call<
[method: string, url: string, extra_opts?: any], [method: string, url: string, extra_opts?: any],
+288 -108
View File
@@ -1,5 +1,14 @@
import { ErrorBoundary, Focusable, Patch, afterPatch } from '@decky/ui'; import {
import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react'; 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 type { Route } from 'react-router';
import { import {
@@ -22,16 +31,26 @@ declare global {
} }
} }
export enum UIMode {
BigPicture = 4,
Desktop = 7,
}
const isPatched = Symbol('is patched'); const isPatched = Symbol('is patched');
class RouterHook extends Logger { class RouterHook extends Logger {
private router: any;
private memoizedRouter: any;
private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState(); private routerState: DeckyRouterState = new DeckyRouterState();
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState(); private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch; private renderedComponents: ReactElement[] = [];
private routerPatch?: Patch; 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[]; public routes?: any[];
constructor() { constructor() {
@@ -41,112 +60,272 @@ class RouterHook extends Logger {
window.__ROUTER_HOOK_INSTANCE?.deinit?.(); window.__ROUTER_HOOK_INSTANCE?.deinit?.();
window.__ROUTER_HOOK_INSTANCE = this; window.__ROUTER_HOOK_INSTANCE = this;
this.gamepadWrapper = Focusable; const reactRouterStackModule = findModuleByExport((e) => e == 'router-backstack', 20);
if (reactRouterStackModule) {
let Route: new () => Route; this.Route =
// Used to store the new replicated routes we create to allow routes to be unpatched. Object.values(reactRouterStackModule).find(
const processList = ( (e) => typeof e == 'function' && /routePath:.\.match\?\.path./.test(e.toString()),
routeList: any[], ) ||
routes: Map<string, RouterEntry> | null, Object.values(reactRouterStackModule).find(
routePatches: Map<string, Set<RoutePatch>>, (e) => typeof e == 'function' && /routePath:null===\(.=.\.match\)/.test(e.toString()),
save: boolean, );
) => { if (!this.Route) {
this.debug('Route list: ', routeList); this.error('Failed to find Route component');
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) => { } else {
const replaced = toReplace.get(route?.props?.path as string); this.error('Failed to find router stack module');
if (replaced) { }
routeList[index].props.children = replaced;
toReplace.delete(route?.props?.path as string); this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
} this.debug(`UI mode changed to ${mode}`);
if (route?.props?.path && routePatches.has(route.props.path as string)) { if (this.patchedModes.has(mode)) return;
toReplace.set( this.patchedModes.add(mode);
route?.props?.path as string, this.debug(`Patching router for UI mode ${mode}`);
// @ts-ignore switch (mode) {
routeList[index].props.children, case UIMode.BigPicture:
); this.debug('Patching gamepad router');
routePatches.get(route.props.path as string)?.forEach((patch) => { this.patchGamepadRouter();
const oType = routeList[index].props.children.type; break;
routeList[index].props.children = patch({ // Not fully implemented yet
...routeList[index].props, // case UIMode.Desktop:
children: { // this.debug("Patching desktop router");
...cloneElement(routeList[index].props.children), // this.patchDesktopRouter();
type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props), // break;
}, default:
}).children; this.warn(`Router patch not implemented for UI mode ${mode}`);
routeList[index].props.children[isPatched] = true; break;
}); }
} });
}
private async patchGamepadRouter() {
const root = getReactRoot(document.getElementById('root') as any);
const findRouterNode = () =>
findInReactTree(
root,
(node) =>
typeof node?.pendingProps?.loggedIn == 'undefined' && node?.type?.toString().includes('Settings.Root()'),
);
await this.waitForUnlock();
let routerNode = findRouterNode();
while (!routerNode) {
this.warn('Failed to find Router node, reattempting in 5 seconds.');
await sleep(5000);
await this.waitForUnlock();
routerNode = findRouterNode();
}
if (routerNode) {
// Patch the component globally
this.gamepadRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleGamepadRouterRender.bind(this));
// Swap out the current instance
routerNode.type = routerNode.elementType.type;
if (routerNode?.alternate) {
routerNode.alternate.type = routerNode.type;
}
// Force a full rerender via our custom error boundary
const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
walkable: ['return'],
}); });
}; errorBoundaryNode?.stateNode?._deckyForceRerender?.();
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'); // Currently unused
return children; // @ts-expect-error 6133
}; private async patchDesktopRouter() {
const root = getReactRoot(document.getElementById('root') as any);
let renderedComponents: ReactElement[] = []; const findRouterNode = () =>
findInReactTree(root, (node) => node?.elementType?.type?.toString()?.includes('bShowDesktopUIContent:'));
const DeckyGlobalComponentsWrapper = () => { let routerNode = findRouterNode();
const { components } = useDeckyGlobalComponentsState(); while (!routerNode) {
if (renderedComponents.length != components.size) { this.warn('Failed to find Router node, reattempting in 5 seconds.');
this.debug('Rerendering global components'); await sleep(5000);
renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />); 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) => { public async waitForUnlock() {
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) { try {
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2; while (window?.securitystore?.IsLockScreenActive?.()) {
const potentialSettingsRootString = await sleep(500);
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;
}
} }
} 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; 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() { deinit() {
this.wrapperPatch.unpatch(); this.modeChangeRegistration?.unregister();
this.routerPatch?.unpatch(); this.gamepadRouterPatch?.unpatch();
this.desktopRouterPatch?.unpatch();
} }
} }
+34 -75
View File
@@ -1,5 +1,14 @@
// TabsHook for versions after the Desktop merge // 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 { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
import Logger from './logger'; import Logger from './logger';
@@ -20,7 +29,6 @@ interface Tab {
class TabsHook extends Logger { class TabsHook extends Logger {
// private keys = 7; // private keys = 7;
tabs: Tab[] = []; tabs: Tab[] = [];
private qAMRoot?: any;
private qamPatch?: Patch; private qamPatch?: Patch;
constructor() { constructor() {
@@ -32,87 +40,38 @@ class TabsHook extends Logger {
} }
init() { init() {
const tree = getReactRoot(document.getElementById('root') as any); // TODO patch the "embedded" renderer in this module too (seems to be for VR? unsure)
let qAMRoot: any; const qamModule = findModuleByExport((e) => e?.type?.toString()?.includes('QuickAccessMenuBrowserView'));
const findQAMRoot = (currentNode: any, iters: number): any => { const qamRenderer = Object.values(qamModule).find((e: any) =>
if (iters >= 80) { e?.type?.toString()?.includes('QuickAccessMenuBrowserView'),
// 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);
}
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; return ret;
}); },
'TabsHook',
);
if (qAMRoot.return.alternate) { this.qamPatch = afterPatch(qamRenderer, 'type', patchHandler);
qAMRoot.return.alternate.type = qAMRoot.return.type;
// 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() { deinit() {
this.qamPatch?.unpatch(); this.qamPatch?.unpatch();
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
} }
add(tab: Tab) { add(tab: Tab) {
+79 -159
View File
@@ -1,19 +1,16 @@
import type { ToastData } from '@decky/api'; import type { ToastData, ToastNotification } from '@decky/api';
import { import { Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui';
Export,
Patch,
afterPatch,
findClassByName,
findInReactTree,
findModuleExport,
getReactRoot,
sleep,
} from '@decky/ui';
import { ReactNode } from 'react';
import Toast from './components/Toast'; import Toast from './components/Toast';
import Logger from './logger'; import Logger from './logger';
// TODO export
enum ToastType {
New,
Update,
Remove,
}
declare global { declare global {
interface Window { interface Window {
__TOASTER_INSTANCE: any; __TOASTER_INSTANCE: any;
@@ -23,176 +20,99 @@ declare global {
} }
class Toaster extends Logger { class Toaster extends Logger {
// private routerHook: RouterHook; private toastPatch?: Patch;
// 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;
constructor() { constructor() {
super('Toaster'); super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.(); window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this; window.__TOASTER_INSTANCE = this;
this.init();
}
async init() { const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
// this.routerHook.addGlobalComponent('DeckyToaster', () => ( // TODO find a way to undo this if possible?
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}> const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
// <DeckyToaster /> this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
// </DeckyToasterStateContextProvider> if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
// )); return args[0].group.notifications.map((notification: any) => (
let instance: any; <Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
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 ( return callOriginal;
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);
this.log('Initialized'); this.log('Initialized');
this.finishStartup?.();
} }
async toast(toast: ToastData) { toast(toast: ToastData): ToastNotification {
// toast.duration = toast.duration || 5e3; if (toast.sound === undefined) toast.sound = 6;
// this.toasterState.addToast(toast); if (toast.playSound === undefined) toast.playSound = true;
await this.ready; 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 = { let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++, nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
bNewIndicator: toast.showNewIndicator,
rtCreated: Date.now(), rtCreated: Date.now(),
eType: toast.eType || 11, eType: toast.eType || 13,
eSource: 1, // Client
nToastDurationMS: toast.duration || (toast.duration = 5e3), nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast, data: toast,
decky: true, decky: true,
}; };
// @ts-ignore let group: any;
toastData.data.appid = () => 0; function fnTray(toast: any, tray: any) {
if (toast.sound === undefined) toast.sound = 6; group = {
if (toast.playSound === undefined) toast.playSound = true; eType: toast.eType,
if (toast.showToast === undefined) toast.showToast = true; notifications: [toast],
if ( };
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) || tray.unshift(group);
(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();
} }
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() { deinit() {
this.toasterPatch?.unpatch(); this.toastPatch?.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');
} }
} }