summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorAAGaming <aa@mail.catvibers.me>2022-10-24 19:14:56 -0400
committerGitHub <noreply@github.com>2022-10-24 16:14:56 -0700
commit84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10 (patch)
tree20b13066c6256cc6ca1beac085094c7964226a37 /frontend
parent2e6b3834da357c7e81821ce60bad36f54dd9fa6e (diff)
downloaddecky-loader-84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10.tar.gz
decky-loader-84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10.zip
preview 10/21/2022 fixes (#234)
* initial fixes: everything working except toasts and patch notes * tabshook changes, disable toaster for now * prettier * oops * implement custom toaster because I am tired of Valve's shit also fix QAM not injecting sometimes * remove extra logging * add findSP, fix patch notes, fix vscode screwup * fix patch notes * show error when plugin frontends fail to load * add get_tab_lambda * add css and has_element helpers to Tab * small modals fixup * Don't forceUpdate QuickAccess on stable * add routes prop used to get tabs component * add more dev utils to DFL global
Diffstat (limited to 'frontend')
-rw-r--r--frontend/package.json2
-rw-r--r--frontend/pnpm-lock.yaml8
-rw-r--r--frontend/src/components/DeckyGlobalComponentsState.tsx74
-rw-r--r--frontend/src/components/DeckyToaster.tsx54
-rw-r--r--frontend/src/components/DeckyToasterState.tsx69
-rw-r--r--frontend/src/components/Markdown.tsx4
-rw-r--r--frontend/src/components/QuickAccessVisibleState.tsx28
-rw-r--r--frontend/src/components/Toast.tsx27
-rw-r--r--frontend/src/components/settings/pages/general/Updater.tsx9
-rw-r--r--frontend/src/developer.tsx8
-rw-r--r--frontend/src/plugin-loader.tsx23
-rw-r--r--frontend/src/router-hook.tsx89
-rw-r--r--frontend/src/tabs-hook.tsx143
-rw-r--r--frontend/src/toaster.tsx211
-rw-r--r--frontend/src/utils/windows.ts7
15 files changed, 527 insertions, 229 deletions
diff --git a/frontend/package.json b/frontend/package.json
index 0f28be7a..4c8f4950 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -41,7 +41,7 @@
}
},
"dependencies": {
- "decky-frontend-lib": "^3.6.0",
+ "decky-frontend-lib": "^3.7.0",
"react-file-icon": "^1.2.0",
"react-icons": "^4.4.0",
"react-markdown": "^8.0.3",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 18fc7f36..2bd08f42 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -10,7 +10,7 @@ specifiers:
'@types/react-file-icon': ^1.0.1
'@types/react-router': 5.1.18
'@types/webpack': ^5.28.0
- decky-frontend-lib: ^3.6.0
+ decky-frontend-lib: ^3.7.0
husky: ^8.0.1
import-sort-style-module: ^6.0.0
inquirer: ^8.2.4
@@ -30,7 +30,7 @@ specifiers:
typescript: ^4.7.4
dependencies:
- decky-frontend-lib: 3.6.0
+ decky-frontend-lib: 3.7.0
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
react-icons: 4.4.0_react@16.14.0
react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u
@@ -944,8 +944,8 @@ packages:
dependencies:
ms: 2.1.2
- /decky-frontend-lib/3.6.0:
- resolution: {integrity: sha512-X3VbTbmW7TnBwPW0ui0xjSVoa2UsuKPwI6nFi7LY2ZzmNytCfszk+ZfJSBm2lD2fqV+btqJzr0qFnWFl+bgjEA==}
+ /decky-frontend-lib/3.7.0:
+ resolution: {integrity: sha512-kq002s74XRrtd0LbN9eVLPtwYQOQhAINKE8X0hsbcz3SVK7wvStVEmepku2m9kONUPN7m+H9eYRqJFGutQybSg==}
dependencies:
minimist: 1.2.7
dev: false
diff --git a/frontend/src/components/DeckyGlobalComponentsState.tsx b/frontend/src/components/DeckyGlobalComponentsState.tsx
new file mode 100644
index 00000000..fe45588b
--- /dev/null
+++ b/frontend/src/components/DeckyGlobalComponentsState.tsx
@@ -0,0 +1,74 @@
+import { FC, createContext, useContext, useEffect, useState } from 'react';
+
+interface PublicDeckyGlobalComponentsState {
+ components: Map<string, FC>;
+}
+
+export class DeckyGlobalComponentsState {
+ // TODO a set would be better
+ private _components = new Map<string, FC>();
+
+ public eventBus = new EventTarget();
+
+ publicState(): PublicDeckyGlobalComponentsState {
+ return { components: this._components };
+ }
+
+ addComponent(path: string, component: FC) {
+ this._components.set(path, component);
+ this.notifyUpdate();
+ }
+
+ removeComponent(path: string) {
+ this._components.delete(path);
+ this.notifyUpdate();
+ }
+
+ private notifyUpdate() {
+ this.eventBus.dispatchEvent(new Event('update'));
+ }
+}
+
+interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
+ addComponent(path: string, component: FC): void;
+ removeComponent(path: string): void;
+}
+
+const DeckyGlobalComponentsContext = createContext<DeckyGlobalComponentsContext>(null as any);
+
+export const useDeckyGlobalComponentsState = () => useContext(DeckyGlobalComponentsContext);
+
+interface Props {
+ deckyGlobalComponentsState: DeckyGlobalComponentsState;
+}
+
+export const DeckyGlobalComponentsStateContextProvider: FC<Props> = ({
+ children,
+ deckyGlobalComponentsState: deckyGlobalComponentsState,
+}) => {
+ const [publicDeckyGlobalComponentsState, setPublicDeckyGlobalComponentsState] =
+ useState<PublicDeckyGlobalComponentsState>({
+ ...deckyGlobalComponentsState.publicState(),
+ });
+
+ useEffect(() => {
+ function onUpdate() {
+ setPublicDeckyGlobalComponentsState({ ...deckyGlobalComponentsState.publicState() });
+ }
+
+ deckyGlobalComponentsState.eventBus.addEventListener('update', onUpdate);
+
+ return () => deckyGlobalComponentsState.eventBus.removeEventListener('update', onUpdate);
+ }, []);
+
+ const addComponent = deckyGlobalComponentsState.addComponent.bind(deckyGlobalComponentsState);
+ const removeComponent = deckyGlobalComponentsState.removeComponent.bind(deckyGlobalComponentsState);
+
+ return (
+ <DeckyGlobalComponentsContext.Provider
+ value={{ ...publicDeckyGlobalComponentsState, addComponent, removeComponent }}
+ >
+ {children}
+ </DeckyGlobalComponentsContext.Provider>
+ );
+};
diff --git a/frontend/src/components/DeckyToaster.tsx b/frontend/src/components/DeckyToaster.tsx
new file mode 100644
index 00000000..eaee75eb
--- /dev/null
+++ b/frontend/src/components/DeckyToaster.tsx
@@ -0,0 +1,54 @@
+import { ToastData, joinClassNames } from 'decky-frontend-lib';
+import { FC, useEffect, useState } from 'react';
+import { ReactElement } from 'react-markdown/lib/react-markdown';
+
+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 sound
+ 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: NodeJS.Timer | null;
+ if (renderedToast) {
+ interval = setTimeout(() => {
+ interval = null;
+ console.log('clear toast', renderedToast.data);
+ removeToast(renderedToast.data);
+ }, (renderedToast.data.duration || 5e3) + 1000);
+ console.log('set int', interval);
+ }
+ return () => {
+ if (interval) {
+ console.log('clearing int', interval);
+ clearTimeout(interval);
+ }
+ };
+ }, [renderedToast]);
+ return (
+ <div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}>
+ {renderedToast && renderedToast.component}
+ </div>
+ );
+};
+
+export default DeckyToaster;
diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx
new file mode 100644
index 00000000..8732d7f8
--- /dev/null
+++ b/frontend/src/components/DeckyToasterState.tsx
@@ -0,0 +1,69 @@
+import { ToastData } from 'decky-frontend-lib';
+import { FC, createContext, useContext, useEffect, useState } from 'react';
+
+interface PublicDeckyToasterState {
+ toasts: Set<ToastData>;
+}
+
+export class DeckyToasterState {
+ // TODO a set would be better
+ 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;
+}
+
+export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
+ const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({
+ ...deckyToasterState.publicState(),
+ });
+
+ useEffect(() => {
+ function onUpdate() {
+ setPublicDeckyToasterState({ ...deckyToasterState.publicState() });
+ }
+
+ deckyToasterState.eventBus.addEventListener('update', onUpdate);
+
+ return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate);
+ }, []);
+
+ const addToast = deckyToasterState.addToast.bind(deckyToasterState);
+ const removeToast = deckyToasterState.removeToast.bind(deckyToasterState);
+
+ return (
+ <DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}>
+ {children}
+ </DeckyToasterContext.Provider>
+ );
+};
diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx
index 278e49cd..045b90a2 100644
--- a/frontend/src/components/Markdown.tsx
+++ b/frontend/src/components/Markdown.tsx
@@ -1,4 +1,4 @@
-import { Focusable } from 'decky-frontend-lib';
+import { Focusable, Router } from 'decky-frontend-lib';
import { FunctionComponent, useRef } from 'react';
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -21,8 +21,8 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
<Focusable
onActivate={() => {}}
onOKButton={() => {
- aRef?.current?.click();
props.onDismiss?.();
+ Router.NavigateToExternalWeb(aRef.current!.href);
}}
style={{ display: 'inline' }}
>
diff --git a/frontend/src/components/QuickAccessVisibleState.tsx b/frontend/src/components/QuickAccessVisibleState.tsx
index b5ee3b98..4df7e1a1 100644
--- a/frontend/src/components/QuickAccessVisibleState.tsx
+++ b/frontend/src/components/QuickAccessVisibleState.tsx
@@ -1,13 +1,27 @@
-import { FC, createContext, useContext } from 'react';
+import { FC, createContext, useContext, useEffect, useRef, useState } from 'react';
const QuickAccessVisibleState = createContext<boolean>(true);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
-interface Props {
- visible: boolean;
-}
-
-export const QuickAccessVisibleStateProvider: FC<Props> = ({ children, visible }) => {
- return <QuickAccessVisibleState.Provider value={visible}>{children}</QuickAccessVisibleState.Provider>;
+export const QuickAccessVisibleStateProvider: FC<{}> = ({ children }) => {
+ const divRef = useRef<HTMLDivElement>(null);
+ const [visible, setVisible] = useState<boolean>(false);
+ useEffect(() => {
+ const doc: Document | void | null = divRef?.current?.ownerDocument;
+ if (!doc) return;
+ setVisible(doc.visibilityState == 'visible');
+ const onChange = (e: Event) => {
+ setVisible(doc.visibilityState == 'visible');
+ };
+ doc.addEventListener('visibilitychange', onChange);
+ return () => {
+ doc.removeEventListener('visibilitychange', onChange);
+ };
+ }, [divRef]);
+ return (
+ <div ref={divRef}>
+ <QuickAccessVisibleState.Provider value={visible}>{children}</QuickAccessVisibleState.Provider>
+ </div>
+ );
};
diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx
index 01a436d7..e7a220c2 100644
--- a/frontend/src/components/Toast.tsx
+++ b/frontend/src/components/Toast.tsx
@@ -2,13 +2,10 @@ import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
interface ToastProps {
- toast: {
- data: ToastData;
- nToastDurationMS: number;
- };
+ toast: ToastData;
}
-const toastClasses = findModule((mod) => {
+export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ToastPlaceholder) {
@@ -30,21 +27,19 @@ const templateClasses = findModule((mod) => {
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return (
- <div
- style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
- className={toastClasses.toastEnter}
- >
+ <div className={toastClasses.ToastPopup}>
<div
- onClick={toast.data.onClick}
- className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')}
+ style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
+ onClick={toast.onClick}
+ className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
>
- {toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</div>}
- <div className={joinClassNames(templateClasses.Content, toast.data.contentClassName || '')}>
+ {toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
+ <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
<div className={templateClasses.Header}>
- {toast.data.icon && <div className={templateClasses.Icon}>{toast.data.icon}</div>}
- <div className={templateClasses.Title}>{toast.data.title}</div>
+ {toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
+ <div className={templateClasses.Title}>{toast.title}</div>
</div>
- <div className={templateClasses.Body}>{toast.data.body}</div>
+ <div className={templateClasses.Body}>{toast.body}</div>
</div>
</div>
</div>
diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx
index b4ea8536..f617e0ff 100644
--- a/frontend/src/components/settings/pages/general/Updater.tsx
+++ b/frontend/src/components/settings/pages/general/Updater.tsx
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
+import { findSP } from '../../../../utils/windows';
import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
import WithSuspense from '../../../WithSuspense';
@@ -21,6 +22,7 @@ import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
+ const SP = findSP();
return (
<Focusable onCancelButton={closeModal}>
<FocusRing>
@@ -50,12 +52,13 @@ function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | n
)}
fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length}
- nHeight={window.innerHeight - 40}
- nItemHeight={window.innerHeight - 40}
+ nHeight={SP.innerHeight - 40}
+ nItemHeight={SP.innerHeight - 40}
nItemMarginX={0}
initialColumn={0}
autoFocus={true}
- fnGetColumnWidth={() => window.innerWidth}
+ fnGetColumnWidth={() => SP.innerWidth}
+ name="Decky Updates"
/>
</FocusRing>
</Focusable>
diff --git a/frontend/src/developer.tsx b/frontend/src/developer.tsx
index b1fe74d6..3b2812fc 100644
--- a/frontend/src/developer.tsx
+++ b/frontend/src/developer.tsx
@@ -1,6 +1,9 @@
import {
ReactRouter,
Router,
+ fakeRenderComponent,
+ findInReactTree,
+ findInTree,
findModule,
findModuleChild,
gamepadDialogClasses,
@@ -71,6 +74,11 @@ export async function startup() {
window.DFL = {
findModuleChild,
findModule,
+ ReactUtils: {
+ fakeRenderComponent,
+ findInReactTree,
+ findInTree,
+ },
Router,
ReactRouter,
classes: {
diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index 400e7484..92c634c9 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -9,7 +9,7 @@ import {
staticClasses,
} from 'decky-frontend-lib';
import { lazy } from 'react';
-import { FaPlug } from 'react-icons/fa';
+import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin';
@@ -41,7 +41,7 @@ class PluginLoader extends Logger {
private tabsHook: TabsHook = new TabsHook();
// private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook();
- public toaster: Toaster = new Toaster();
+ public toaster: Toaster = new Toaster(this.routerHook);
private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false;
@@ -233,13 +233,18 @@ class PluginLoader extends Logger {
},
});
if (res.ok) {
- let plugin_export = await eval(await res.text());
- let plugin = plugin_export(this.createPluginAPI(name));
- this.plugins.push({
- ...plugin,
- name: name,
- version: version,
- });
+ try {
+ let plugin_export = await eval(await res.text());
+ let plugin = plugin_export(this.createPluginAPI(name));
+ this.plugins.push({
+ ...plugin,
+ name: name,
+ version: version,
+ });
+ } catch (e) {
+ this.error('Error loading plugin ' + name, e);
+ this.toaster.toast({ title: 'Error loading ' + name, body: '' + e, icon: <FaExclamationCircle /> });
+ }
} else throw new Error(`${name} frontend_bundle not OK`);
}
diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx
index 8414db2c..bf3ae0cb 100644
--- a/frontend/src/router-hook.tsx
+++ b/frontend/src/router-hook.tsx
@@ -1,8 +1,13 @@
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
-import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
+import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import type { Route } from 'react-router';
import {
+ DeckyGlobalComponentsState,
+ DeckyGlobalComponentsStateContextProvider,
+ useDeckyGlobalComponentsState,
+} from './components/DeckyGlobalComponentsState';
+import {
DeckyRouterState,
DeckyRouterStateContextProvider,
RoutePatch,
@@ -22,8 +27,10 @@ class RouterHook extends Logger {
private memoizedRouter: any;
private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState();
+ private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch;
private routerPatch?: Patch;
+ public routes?: any[];
constructor() {
super('RouterHook');
@@ -42,24 +49,28 @@ class RouterHook extends Logger {
let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched.
- let toReplace = new Map<string, ReactNode>();
- const DeckyWrapper = ({ children }: { children: ReactElement }) => {
- const { routes, routePatches } = useDeckyRouterState();
-
- const routeList = children.props.children[0].props.children;
-
+ 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 (!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[] = [];
- routes.forEach(({ component, props }, path) => {
- newRouterArray.push(
- <Route path={path} {...props}>
- {createElement(component)}
- </Route>,
- );
- });
- routeList[routerIndex] = newRouterArray;
+ 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[] = [];
+ routes.forEach(({ component, props }, path) => {
+ newRouterArray.push(
+ <Route path={path} {...props}>
+ {createElement(component)}
+ </Route>,
+ );
+ });
+ routeList[routerIndex] = newRouterArray;
+ }
}
routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string);
@@ -85,19 +96,40 @@ class RouterHook extends Logger {
});
}
});
+ };
+ let toReplace = new Map<string, ReactNode>();
+ const DeckyWrapper = ({ children }: { children: ReactElement }) => {
+ const { routes, routePatches } = useDeckyRouterState();
+ const mainRouteList = children.props.children[0].props.children;
+ const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
+ processList(mainRouteList, routes, routePatches, true);
+ processList(ingameRouteList, null, routePatches, false);
+
this.debug('Rerendered routes list');
return children;
};
+ 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 />);
+ }
+ return <>{renderedComponents}</>;
+ };
+
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
- if (ret?.props?.children?.props?.children?.length == 5) {
+ 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;
if (
- ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
+ ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
?.toString()
?.includes('GamepadUI.Settings.Root()')
) {
if (!this.router) {
- this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
+ 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;
@@ -111,7 +143,12 @@ class RouterHook extends Logger {
this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true;
}
- ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
+ ret.props.children.props.children.push(
+ <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
+ <DeckyGlobalComponentsWrapper />
+ </DeckyGlobalComponentsStateContextProvider>,
+ );
+ ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
}
}
return ret;
@@ -126,6 +163,14 @@ class RouterHook extends Logger {
return this.routerState.addPatch(path, patch);
}
+ addGlobalComponent(name: string, component: FC) {
+ this.globalComponentsState.addComponent(name, component);
+ }
+
+ removeGlobalComponent(name: string) {
+ this.globalComponentsState.removeComponent(name);
+ }
+
removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch);
}
diff --git a/frontend/src/tabs-hook.tsx b/frontend/src/tabs-hook.tsx
index c5072e27..5929b8a0 100644
--- a/frontend/src/tabs-hook.tsx
+++ b/frontend/src/tabs-hook.tsx
@@ -1,5 +1,4 @@
-import { Patch, QuickAccessTab, afterPatch, sleep } from 'decky-frontend-lib';
-import { memo } from 'react';
+import { QuickAccessTab, quickAccessMenuClasses, sleep } from 'decky-frontend-lib';
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
import Logger from './logger';
@@ -28,15 +27,7 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
- private quickAccess: any;
- private tabRenderer: any;
- private memoizedQuickAccess: any;
- private cNode: any;
-
- private qAPTree: any;
- private rendererTree: any;
-
- private cNodePatch?: Patch;
+ private oFilter: (...args: any[]) => any;
constructor() {
super('TabsHook');
@@ -46,84 +37,63 @@ class TabsHook extends Logger {
window.__TABS_HOOK_INSTANCE = this;
const self = this;
- const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
- let scrollRoot: any;
- async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
- if (iters >= 30) {
- self.error(
- 'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
- );
- return null;
- }
- currentNode = currentNode?.child;
- if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
- self.log(`Scroll root was found in ${iters} recursion cycles`);
- return currentNode;
- }
- if (!currentNode) return null;
- if (currentNode.sibling) {
- let node = await findScrollRoot(currentNode.sibling, iters + 1);
- if (node !== null) return node;
- }
- return await findScrollRoot(currentNode, iters + 1);
- }
- (async () => {
- scrollRoot = await findScrollRoot(tree, 0);
- while (!scrollRoot) {
- this.log('Failed to find scroll root node, reattempting in 5 seconds');
- await sleep(5000);
- scrollRoot = await findScrollRoot(tree, 0);
+ const oFilter = (this.oFilter = Array.prototype.filter);
+ Array.prototype.filter = function patchedFilter(...args: any[]) {
+ if (isTabsArray(this)) {
+ self.render(this);
}
- let newQA: any;
- let newQATabRenderer: any;
- this.cNodePatch = afterPatch(scrollRoot.stateNode, 'render', (_: any, ret: any) => {
- if (!this.quickAccess && ret.props.children.props.children[4]) {
- this.quickAccess = ret?.props?.children?.props?.children[4].type;
- newQA = (...args: any) => {
- const ret = this.quickAccess.type(...args);
- if (ret) {
- if (!newQATabRenderer) {
- this.tabRenderer = ret.props.children[1].children.type;
- newQATabRenderer = (...qamArgs: any[]) => {
- const oFilter = Array.prototype.filter;
- Array.prototype.filter = function (...args: any[]) {
- if (isTabsArray(this)) {
- self.render(this, qamArgs[0].visible);
- }
- // @ts-ignore
- return oFilter.call(this, ...args);
- };
- // TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
- const ret = this.tabRenderer(...qamArgs);
- Array.prototype.filter = oFilter;
- return ret;
- };
- }
- this.rendererTree = ret.props.children[1].children;
- ret.props.children[1].children.type = newQATabRenderer;
- }
- return ret;
- };
- this.memoizedQuickAccess = memo(newQA);
- this.memoizedQuickAccess.isDeckyQuickAccess = true;
- }
- if (ret.props.children.props.children[4]) {
- this.qAPTree = ret.props.children.props.children[4];
- ret.props.children.props.children[4].type = this.memoizedQuickAccess;
+ // @ts-ignore
+ return oFilter.call(this, ...args);
+ };
+
+ if (document.title != 'SP')
+ try {
+ const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
+ let qAMRoot: any;
+ async function findQAMRoot(currentNode: any, iters: number): Promise<any> {
+ if (iters >= 60) {
+ // currently 44
+ return null;
+ }
+ currentNode = currentNode?.child;
+ if (
+ currentNode?.memoizedProps?.className &&
+ currentNode?.memoizedProps?.className.startsWith(quickAccessMenuClasses.ViewPlaceholder)
+ ) {
+ self.log(`QAM root was found in ${iters} recursion cycles`);
+ return currentNode;
+ }
+ if (!currentNode) return null;
+ if (currentNode.sibling) {
+ let node = await findQAMRoot(currentNode.sibling, iters + 1);
+ if (node !== null) return node;
+ }
+ return await findQAMRoot(currentNode, iters + 1);
}
- return ret;
- });
- this.cNode = scrollRoot;
- this.cNode.stateNode.forceUpdate();
- this.log('Finished initial injection');
- })();
+ (async () => {
+ qAMRoot = await 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 = await findQAMRoot(tree, 0);
+ }
+
+ while (!qAMRoot?.stateNode?.forceUpdate) {
+ qAMRoot = qAMRoot.return;
+ }
+ qAMRoot.stateNode.shouldComponentUpdate = () => true;
+ qAMRoot.stateNode.forceUpdate();
+ delete qAMRoot.stateNode.shouldComponentUpdate;
+ })();
+ } catch (e) {
+ this.log('Failed to rerender QAM', e);
+ }
}
deinit() {
- this.cNodePatch?.unpatch();
- if (this.qAPTree) this.qAPTree.type = this.quickAccess;
- if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
- if (this.cNode) this.cNode.stateNode.forceUpdate();
+ Array.prototype.filter = this.oFilter;
}
add(tab: Tab) {
@@ -136,13 +106,14 @@ class TabsHook extends Logger {
this.tabs = this.tabs.filter((tab) => tab.id !== id);
}
- render(existingTabs: any[], visible: boolean) {
+ render(existingTabs: any[]) {
for (const { title, icon, content, id } of this.tabs) {
existingTabs.push({
key: id,
title,
tab: icon,
- panel: <QuickAccessVisibleStateProvider visible={visible}>{content}</QuickAccessVisibleStateProvider>,
+ decky: true,
+ panel: <QuickAccessVisibleStateProvider>{content}</QuickAccessVisibleStateProvider>,
});
}
}
diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx
index d7c0584f..94b08d70 100644
--- a/frontend/src/toaster.tsx
+++ b/frontend/src/toaster.tsx
@@ -1,8 +1,10 @@
-import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
-import { ReactNode } from 'react';
+import { Patch, ToastData, sleep } from 'decky-frontend-lib';
+import DeckyToaster from './components/DeckyToaster';
+import { DeckyToasterState, DeckyToasterStateContextProvider } from './components/DeckyToasterState';
import Toast from './components/Toast';
import Logger from './logger';
+import RouterHook from './router-hook';
declare global {
interface Window {
@@ -13,12 +15,15 @@ declare global {
class Toaster extends Logger {
private instanceRetPatch?: Patch;
+ private routerHook: RouterHook;
+ private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private settingsModule: any;
private ready: boolean = false;
- constructor() {
+ constructor(routerHook: RouterHook) {
super('Toaster');
+ this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
@@ -26,87 +31,135 @@ class Toaster extends Logger {
}
async init() {
- let instance: any;
-
- while (true) {
- instance = findInReactTree(
- (document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
- (x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
- );
- if (instance) break;
- this.debug('finding instance');
- await sleep(2000);
- }
-
- this.node = instance.return.return;
- let toast: any;
- let renderedToast: ReactNode = null;
- this.node.stateNode.render = (...args: any[]) => {
- const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
- if (ret) {
- this.instanceRetPatch = afterPatch(ret, 'type', (_: any, ret: any) => {
- if (ret?.props?.children[1]?.children?.props) {
- const currentToast = ret.props.children[1].children.props.notification;
- if (currentToast?.decky) {
- if (currentToast == toast) {
- ret.props.children[1].children = renderedToast;
- } else {
- toast = currentToast;
- renderedToast = <Toast toast={toast} />;
- ret.props.children[1].children = renderedToast;
- }
- } else {
- toast = null;
- renderedToast = null;
- }
- }
- return ret;
- });
- this.node.stateNode.shouldComponentUpdate = () => {
- return false;
- };
- delete this.node.stateNode.render;
- }
- return ret;
- };
- this.settingsModule = findModuleChild((m) => {
- if (typeof m !== 'object') return undefined;
- for (let prop in m) {
- if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
- }
- });
- this.log('Initialized');
- this.ready = true;
+ this.routerHook.addGlobalComponent('DeckyToaster', () => (
+ <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
+ <DeckyToaster />
+ </DeckyToasterStateContextProvider>
+ ));
+ // let instance: any;
+ // while (true) {
+ // instance = findInReactTree(
+ // (document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
+ // (x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
+ // );
+ // if (instance) break;
+ // this.debug('finding instance');
+ // await sleep(2000);
+ // }
+ // // const windowManager = findModuleChild((m) => {
+ // // if (typeof m !== 'object') return false;
+ // // for (let prop in m) {
+ // // if (m[prop]?.prototype?.GetRenderElement) return m[prop];
+ // // }
+ // // return false;
+ // // });
+ // this.node = instance.return.return;
+ // let toast: any;
+ // let renderedToast: ReactNode = null;
+ // console.log(instance, this.node);
+ // // replacePatch(window.SteamClient.BrowserView, "Destroy", (args: any[]) => {
+ // // console.debug("destroy", args)
+ // // return callOriginal;
+ // // })
+ // // let node = this.node.child.updateQueue.lastEffect;
+ // // while (node.next && !node.deckyPatched) {
+ // // node = node.next;
+ // // if (node.deps[1] == "notificationtoasts") {
+ // // console.log("Deleting destroy");
+ // // node.deckyPatched = true;
+ // // node.create = () => {console.debug("VVVVVVVVVVV")};
+ // // node.destroy = () => {console.debug("AAAAAAAAAAAAAAAAaaaaaaaaaaaaaaa")};
+ // // }
+ // // }
+ // this.node.stateNode.render = (...args: any[]) => {
+ // const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
+ // console.log('toast', ret);
+ // if (ret) {
+ // console.log(ret)
+ // // this.instanceRetPatch = replacePatch(ret, 'type', (innerArgs: any) => {
+ // // console.log("inner toast", innerArgs)
+ // // // @ts-ignore
+ // // const oldEffect = window.SP_REACT.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current.useEffect;
+ // // // @ts-ignore
+ // // window.SP_REACT.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current.useEffect = (effect, deps) => {
+ // // console.log(effect, deps)
+ // // if (deps?.[1] == "notificationtoasts") {
+ // // console.log("run")
+ // // effect();
+ // // }
+ // // return oldEffect(effect, deps);
+ // // }
+ // // const ret = this.instanceRetPatch?.original(...args);
+ // // console.log("inner ret", ret)
+ // // // @ts-ignore
+ // // window.SP_REACT.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current.useEffect = oldEffect;
+ // // return ret
+ // // });
+ // }
+ // // console.log("toast ret", ret)
+ // // if (ret?.props?.children[1]?.children?.props) {
+ // // const currentToast = ret.props.children[1].children.props.notification;
+ // // if (currentToast?.decky) {
+ // // if (currentToast == toast) {
+ // // ret.props.children[1].children = renderedToast;
+ // // } else {
+ // // toast = currentToast;
+ // // renderedToast = <Toast toast={toast} />;
+ // // ret.props.children[1].children = renderedToast;
+ // // }
+ // // } else {
+ // // toast = null;
+ // // renderedToast = null;
+ // // }
+ // // }
+ // // return ret;
+ // // });
+ // // }
+ // return ret;
+ // };
+ // this.settingsModule = findModuleChild((m) => {
+ // if (typeof m !== 'object') return undefined;
+ // for (let prop in m) {
+ // if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
+ // }
+ // });
+ // // const idx = FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.findIndex((x: any) => x.m_ID == "ToastContainer");
+ // // if (idx > -1) {
+ // // FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.splice(idx, 1)
+ // // }
+ // this.node.stateNode.forceUpdate();
+ // this.node.stateNode.shouldComponentUpdate = () => {
+ // return false;
+ // };
+ // this.log('Initialized');
+ // this.ready = true;
}
- async toast(toast: ToastData) {
- while (!this.ready) {
- await sleep(100);
- }
- const settings = this.settingsModule?.settings;
- let toastData = {
- nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
- rtCreated: Date.now(),
- eType: 15,
- nToastDurationMS: toast.duration || 5e3,
- data: toast,
- decky: true,
- };
- // @ts-ignore
- toastData.data.appid = () => 0;
- if (
- (settings?.bDisableAllToasts && !toast.critical) ||
- (settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
- )
- return;
- window.NotificationStore.m_rgNotificationToasts.push(toastData);
- window.NotificationStore.DispatchNextToast();
+ toast(toast: ToastData) {
+ toast.duration = toast.duration || 5e3;
+ this.toasterState.addToast(toast);
+ // const settings = this.settingsModule?.settings;
+ // let toastData = {
+ // nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
+ // rtCreated: Date.now(),
+ // eType: 15,
+ // nToastDurationMS: toast.duration || 5e3,
+ // data: toast,
+ // decky: true,
+ // };
+ // // @ts-ignore
+ // toastData.data.appid = () => 0;
+ // if (
+ // (settings?.bDisableAllToasts && !toast.critical) ||
+ // (settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
+ // )
+ // return;
+ // window.NotificationStore.m_rgNotificationToasts.push(toastData);
+ // window.NotificationStore.DispatchNextToast();
}
deinit() {
- this.instanceRetPatch?.unpatch();
- this.node && delete this.node.stateNode.shouldComponentUpdate;
- this.node && this.node.stateNode.forceUpdate();
+ this.routerHook.removeGlobalComponent('DeckyToaster');
}
}
diff --git a/frontend/src/utils/windows.ts b/frontend/src/utils/windows.ts
new file mode 100644
index 00000000..2b5181d8
--- /dev/null
+++ b/frontend/src/utils/windows.ts
@@ -0,0 +1,7 @@
+export function findSP(): Window {
+ // old (SP as host)
+ if (document.title == 'SP') return window;
+ // new (SP as popup)
+ return FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.find((x: any) => x.m_ID == 'root_1_').Root
+ .Element.ownerDocument.defaultView;
+}