diff --git a/frontend/package.json b/frontend/package.json index 1c75c10a5..0e935bd5e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,7 +49,7 @@ } }, "dependencies": { - "@decky/ui": "^4.7.4", + "@decky/ui": "^4.8.0", "compare-versions": "^6.1.1", "filesize": "^10.1.2", "i18next": "^23.11.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 491c9e1d8..61ec555a3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@decky/ui': - specifier: ^4.7.4 - version: 4.7.4 + specifier: ^4.8.0 + version: 4.8.0 compare-versions: specifier: ^6.1.1 version: 6.1.1 @@ -41,7 +41,7 @@ importers: devDependencies: '@decky/api': specifier: ^1.1.1 - version: 1.1.1 + version: 1.1.2 '@rollup/plugin-commonjs': specifier: ^26.0.1 version: 26.0.1(rollup@4.18.0) @@ -218,11 +218,11 @@ packages: resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} engines: {node: '>=6.9.0'} - '@decky/api@1.1.1': - resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==} + '@decky/api@1.1.2': + resolution: {integrity: sha512-lTMqRpHOrGTCyH2c1jJvkmWhOq2dcnX5/ioHbfCVmyQOBik1OM1BnzF1uROsnNDC5GkRvl3J/ATqYp6vhYpRqw==} - '@decky/ui@4.7.4': - resolution: {integrity: sha512-ziCP3akLJVYG5FFoS0ao9MYEYJ09g44FP4xMmOUFe8SFRjC9BqBvbJBF0+OUOKgp2C7SJ0rNPSIFm4RwCUvoug==} + '@decky/ui@4.8.0': + resolution: {integrity: sha512-PITTeVIyITTkQxAh3nQ/I+QFvDPTjDMwgAZ0bnAq9vMwmVrL2s5S2oDUIiV84Ux9bZL7rF2VVoR7feM2ErSjIg==} '@electron/get@2.0.3': resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} @@ -2817,9 +2817,9 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 - '@decky/api@1.1.1': {} + '@decky/api@1.1.2': {} - '@decky/ui@4.7.4': {} + '@decky/ui@4.8.0': {} '@electron/get@2.0.3': dependencies: diff --git a/frontend/src/components/DeckyDesktopSidebar.tsx b/frontend/src/components/DeckyDesktopSidebar.tsx new file mode 100644 index 000000000..f159652b0 --- /dev/null +++ b/frontend/src/components/DeckyDesktopSidebar.tsx @@ -0,0 +1,72 @@ +import { FC, useEffect, useRef, useState } from 'react'; + +import { useDeckyState } from './DeckyState'; +import PluginView from './PluginView'; +import { QuickAccessVisibleState } from './QuickAccessVisibleState'; + +const DeckyDesktopSidebar: FC = () => { + const { desktopMenuOpen, setDesktopMenuOpen } = useDeckyState(); + const [closed, setClosed] = useState(!desktopMenuOpen); + const [openAnimStart, setOpenAnimStart] = useState(desktopMenuOpen); + const closedInterval = useRef(null); + + useEffect(() => { + const anim = requestAnimationFrame(() => setOpenAnimStart(desktopMenuOpen)); + return () => cancelAnimationFrame(anim); + }, [desktopMenuOpen]); + + useEffect(() => { + closedInterval.current && clearTimeout(closedInterval.current); + if (desktopMenuOpen) { + setClosed(false); + } else { + closedInterval.current = setTimeout(() => setClosed(true), 500); + } + }, [desktopMenuOpen]); + return ( + <> +
setDesktopMenuOpen(false)} + /> + +
+ + + +
+ + ); +}; + +export default DeckyDesktopSidebar; diff --git a/frontend/src/components/DeckyDesktopUI.tsx b/frontend/src/components/DeckyDesktopUI.tsx new file mode 100644 index 000000000..fde33c0f6 --- /dev/null +++ b/frontend/src/components/DeckyDesktopUI.tsx @@ -0,0 +1,44 @@ +import { CSSProperties, FC } from 'react'; + +import DeckyDesktopSidebar from './DeckyDesktopSidebar'; +import DeckyIcon from './DeckyIcon'; +import { useDeckyState } from './DeckyState'; + +const DeckyDesktopUI: FC = () => { + const { desktopMenuOpen, setDesktopMenuOpen } = useDeckyState(); + return ( + <> + + setDesktopMenuOpen(!desktopMenuOpen)} + style={ + { + position: 'absolute', + top: '36px', // nav text is 34px but 36px looks nicer to me + right: '10px', // <- is 16px but 10px looks nicer to me + width: '24px', + height: '24px', + cursor: 'pointer', + transition: 'color 0.3s linear', + '-webkit-app-region': 'no-drag', + } as CSSProperties + } + /> + + + ); +}; + +export default DeckyDesktopUI; diff --git a/frontend/src/components/DeckyGlobalComponentsState.tsx b/frontend/src/components/DeckyGlobalComponentsState.tsx index 475d1e4ad..4088f7b1a 100644 --- a/frontend/src/components/DeckyGlobalComponentsState.tsx +++ b/frontend/src/components/DeckyGlobalComponentsState.tsx @@ -1,12 +1,17 @@ import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react'; +import { UIMode } from '../enums'; + interface PublicDeckyGlobalComponentsState { - components: Map; + components: Map>; } export class DeckyGlobalComponentsState { // TODO a set would be better - private _components = new Map(); + private _components = new Map>([ + [UIMode.BigPicture, new Map()], + [UIMode.Desktop, new Map()], + ]); public eventBus = new EventTarget(); @@ -14,13 +19,19 @@ export class DeckyGlobalComponentsState { return { components: this._components }; } - addComponent(path: string, component: FC) { - this._components.set(path, component); + addComponent(path: string, component: FC, uiMode: UIMode) { + const components = this._components.get(uiMode); + if (!components) throw new Error(`UI mode ${uiMode} not supported.`); + + components.set(path, component); this.notifyUpdate(); } - removeComponent(path: string) { - this._components.delete(path); + removeComponent(path: string, uiMode: UIMode) { + const components = this._components.get(uiMode); + if (!components) throw new Error(`UI mode ${uiMode} not supported.`); + + components.delete(path); this.notifyUpdate(); } @@ -30,8 +41,8 @@ export class DeckyGlobalComponentsState { } interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState { - addComponent(path: string, component: FC): void; - removeComponent(path: string): void; + addComponent(path: string, component: FC, uiMode: UIMode): void; + removeComponent(path: string, uiMode: UIMode): void; } const DeckyGlobalComponentsContext = createContext(null as any); diff --git a/frontend/src/components/DeckyRouterState.tsx b/frontend/src/components/DeckyRouterState.tsx index 426ed731c..f13855a4c 100644 --- a/frontend/src/components/DeckyRouterState.tsx +++ b/frontend/src/components/DeckyRouterState.tsx @@ -1,6 +1,8 @@ import { ComponentType, FC, ReactNode, createContext, useContext, useEffect, useState } from 'react'; import type { RouteProps } from 'react-router'; +import { UIMode } from '../enums'; + export interface RouterEntry { props: Omit; component: ComponentType; @@ -10,12 +12,16 @@ export type RoutePatch = (route: RouteProps) => RouteProps; interface PublicDeckyRouterState { routes: Map; - routePatches: Map>; + routePatches: Map>>; } export class DeckyRouterState { private _routes = new Map(); - private _routePatches = new Map>(); + // Update when support for new UIModes is added + private _routePatches = new Map>>([ + [UIMode.BigPicture, new Map()], + [UIMode.Desktop, new Map()], + ]); public eventBus = new EventTarget(); @@ -28,22 +34,26 @@ export class DeckyRouterState { this.notifyUpdate(); } - addPatch(path: string, patch: RoutePatch) { - let patchList = this._routePatches.get(path); + addPatch(path: string, patch: RoutePatch, uiMode: UIMode) { + const patchesForMode = this._routePatches.get(uiMode); + if (!patchesForMode) throw new Error(`UI mode ${uiMode} not supported.`); + let patchList = patchesForMode.get(path); if (!patchList) { patchList = new Set(); - this._routePatches.set(path, patchList); + patchesForMode.set(path, patchList); } patchList.add(patch); this.notifyUpdate(); return patch; } - removePatch(path: string, patch: RoutePatch) { - const patchList = this._routePatches.get(path); + removePatch(path: string, patch: RoutePatch, uiMode: UIMode) { + const patchesForMode = this._routePatches.get(uiMode); + if (!patchesForMode) throw new Error(`UI mode ${uiMode} not supported.`); + const patchList = patchesForMode.get(path); patchList?.delete(patch); if (patchList?.size == 0) { - this._routePatches.delete(path); + patchesForMode.delete(path); } this.notifyUpdate(); } @@ -60,8 +70,8 @@ export class DeckyRouterState { interface DeckyRouterStateContext extends PublicDeckyRouterState { addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void; - addPatch(path: string, patch: RoutePatch): RoutePatch; - removePatch(path: string, patch: RoutePatch): void; + addPatch(path: string, patch: RoutePatch, uiMode?: UIMode): RoutePatch; + removePatch(path: string, patch: RoutePatch, uiMode?: UIMode): void; removeRoute(path: string): void; } diff --git a/frontend/src/components/DeckyState.tsx b/frontend/src/components/DeckyState.tsx index 75106e626..ddd8e0521 100644 --- a/frontend/src/components/DeckyState.tsx +++ b/frontend/src/components/DeckyState.tsx @@ -17,6 +17,7 @@ interface PublicDeckyState { versionInfo: VerInfo | null; notificationSettings: NotificationSettings; userInfo: UserInfo | null; + desktopMenuOpen: boolean; } export interface UserInfo { @@ -36,6 +37,7 @@ export class DeckyState { private _versionInfo: VerInfo | null = null; private _notificationSettings = DEFAULT_NOTIFICATION_SETTINGS; private _userInfo: UserInfo | null = null; + private _desktopMenuOpen: boolean = false; public eventBus = new EventTarget(); @@ -52,6 +54,7 @@ export class DeckyState { versionInfo: this._versionInfo, notificationSettings: this._notificationSettings, userInfo: this._userInfo, + desktopMenuOpen: this._desktopMenuOpen, }; } @@ -115,6 +118,11 @@ export class DeckyState { this.notifyUpdate(); } + setDesktopMenuOpen(open: boolean) { + this._desktopMenuOpen = open; + this.notifyUpdate(); + } + private notifyUpdate() { this.eventBus.dispatchEvent(new Event('update')); } @@ -126,6 +134,7 @@ interface DeckyStateContext extends PublicDeckyState { setActivePlugin(name: string): void; setPluginOrder(pluginOrder: string[]): void; closeActivePlugin(): void; + setDesktopMenuOpen(open: boolean): void; } const DeckyStateContext = createContext(null as any); @@ -155,6 +164,7 @@ export const DeckyStateContextProvider: FC = ({ children, deckyState }) = const setActivePlugin = deckyState.setActivePlugin.bind(deckyState); const closeActivePlugin = deckyState.closeActivePlugin.bind(deckyState); const setPluginOrder = deckyState.setPluginOrder.bind(deckyState); + const setDesktopMenuOpen = deckyState.setDesktopMenuOpen.bind(deckyState); return ( = ({ children, deckyState }) = setActivePlugin, closeActivePlugin, setPluginOrder, + setDesktopMenuOpen, }} > {children} diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx index cf6657aa5..d6201980c 100644 --- a/frontend/src/components/Markdown.tsx +++ b/frontend/src/components/Markdown.tsx @@ -24,6 +24,11 @@ const Markdown: FunctionComponent = (props) => { props.onDismiss?.(); Navigation.NavigateToExternalWeb(aRef.current!.href); }} + onClick={(e) => { + e.preventDefault(); + props.onDismiss?.(); + Navigation.NavigateToExternalWeb(aRef.current!.href); + }} style={{ display: 'inline' }} > diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index 19afbca5f..e36df3cb2 100644 --- a/frontend/src/components/PluginView.tsx +++ b/frontend/src/components/PluginView.tsx @@ -9,7 +9,11 @@ import NotificationBadge from './NotificationBadge'; import { useQuickAccessVisible } from './QuickAccessVisibleState'; import TitleView from './TitleView'; -const PluginView: FC = () => { +interface PluginViewProps { + desktop?: boolean; +} + +const PluginView: FC = ({ desktop = false }) => { const { hiddenPlugins } = useDeckyState(); const { plugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } = useDeckyState(); const visible = useQuickAccessVisible(); @@ -27,7 +31,7 @@ const PluginView: FC = () => { if (activePlugin) { return ( - +
{(visible || activePlugin.alwaysRender) && activePlugin.content}
@@ -36,7 +40,7 @@ const PluginView: FC = () => { } return ( <> - +
(false); +export const QuickAccessVisibleState = createContext(false); export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState); diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx index 0cb82b7f6..8ddb242d9 100644 --- a/frontend/src/components/TitleView.tsx +++ b/frontend/src/components/TitleView.tsx @@ -14,18 +14,34 @@ const titleStyles: CSSProperties = { top: '0px', }; -const TitleView: FC = () => { - const { activePlugin, closeActivePlugin } = useDeckyState(); +interface TitleViewProps { + desktop?: boolean; +} + +const TitleView: FC = ({ desktop }) => { + const { activePlugin, closeActivePlugin, setDesktopMenuOpen } = useDeckyState(); const { t } = useTranslation(); const onSettingsClick = () => { Navigation.Navigate('/decky/settings'); Navigation.CloseSideMenus(); + setDesktopMenuOpen(false); }; const onStoreClick = () => { Navigation.Navigate('/decky/store'); Navigation.CloseSideMenus(); + setDesktopMenuOpen(false); + }; + + const buttonStyles = { + height: '28px', + width: '40px', + minWidth: 0, + padding: desktop ? '' : '10px 12px', + display: 'flex', + alignItems: desktop ? 'center' : '', + justifyContent: desktop ? 'center' : '', }; if (activePlugin === null) { @@ -33,14 +49,14 @@ const TitleView: FC = () => {
Decky
@@ -52,10 +68,7 @@ const TitleView: FC = () => { return ( - + {activePlugin?.titleView ||
{activePlugin.name}
} diff --git a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx index ba49ba927..d6f163f7a 100644 --- a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx +++ b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx @@ -80,7 +80,10 @@ const MultiplePluginsInstallModal: FC = ({ onOK={async () => { setLoading(true); await onOK(); - setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250); + setTimeout(() => { + Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky); + DeckyPluginLoader.setDesktopMenuOpen(true); + }, 250); setTimeout(() => DeckyPluginLoader.checkPluginUpdates(), 1000); }} onCancel={async () => { diff --git a/frontend/src/components/modals/PluginInstallModal.tsx b/frontend/src/components/modals/PluginInstallModal.tsx index 227bd818f..ec3532799 100644 --- a/frontend/src/components/modals/PluginInstallModal.tsx +++ b/frontend/src/components/modals/PluginInstallModal.tsx @@ -51,7 +51,10 @@ const PluginInstallModal: FC = ({ onOK={async () => { setLoading(true); await onOK(); - setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250); + setTimeout(() => { + Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky); + DeckyPluginLoader.setDesktopMenuOpen(true); + }, 250); setTimeout(() => DeckyPluginLoader.checkPluginUpdates(), 1000); }} onCancel={async () => { diff --git a/frontend/src/components/settings/index.tsx b/frontend/src/components/settings/index.tsx index d6d986457..cb5096f5d 100644 --- a/frontend/src/components/settings/index.tsx +++ b/frontend/src/components/settings/index.tsx @@ -53,5 +53,20 @@ export default function SettingsPage() { }, ]; - return ; + return ( +
+ + +
+ ); } diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx index 59756a572..89b6d6eee 100644 --- a/frontend/src/components/settings/pages/general/Updater.tsx +++ b/frontend/src/components/settings/pages/general/Updater.tsx @@ -6,8 +6,8 @@ import { Focusable, ProgressBarWithInfo, Spinner, - findSP, showModal, + useWindowRef, } from '@decky/ui'; import { Suspense, lazy, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,45 +21,48 @@ import WithSuspense from '../../../WithSuspense'; const MarkdownRenderer = lazy(() => import('../../../Markdown')); function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) { - const SP = findSP(); + const [outerRef, win] = useWindowRef(); const { t } = useTranslation(); + // TODO proper desktop scrolling return ( - + - ( - -
-

{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}

- {versionInfo?.all?.[id]?.body ? ( - - {versionInfo.all[id].body} - - ) : ( - t('Updater.no_patch_notes_desc') - )} -
-
- )} - fnGetId={(id) => id} - nNumItems={versionInfo?.all?.length} - nHeight={SP.innerHeight - 40} - nItemHeight={SP.innerHeight - 40} - nItemMarginX={0} - initialColumn={0} - autoFocus={true} - fnGetColumnWidth={() => SP.innerWidth} - name={t('Updater.decky_updates') as string} - /> + {win && ( + ( + +
+

{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}

+ {versionInfo?.all?.[id]?.body ? ( + + {versionInfo.all[id].body} + + ) : ( + t('Updater.no_patch_notes_desc') + )} +
+
+ )} + fnGetId={(id) => id} + nNumItems={versionInfo?.all?.length} + nHeight={(win?.innerHeight || 800) - 40} + nItemHeight={(win?.innerHeight || 800) - 40} + nItemMarginX={0} + initialColumn={0} + autoFocus={true} + fnGetColumnWidth={() => win?.innerHeight || 1280} + name={t('Updater.decky_updates') as string} + /> + )}
); @@ -72,6 +75,8 @@ export default function UpdaterSettings() { const [updateProgress, setUpdateProgress] = useState(-1); const [reloading, setReloading] = useState(false); + const [windowRef, win] = useWindowRef(); + const { t } = useTranslation(); useEffect(() => { @@ -91,11 +96,12 @@ export default function UpdaterSettings() { }, []); const showPatchNotes = useCallback(() => { - showModal(); - }, [versionInfo]); + // TODO set width and height on desktop - needs fixing in DFL? + showModal(, win!); + }, [versionInfo, win]); return ( - <> +
)} - +
); } diff --git a/frontend/src/enums.ts b/frontend/src/enums.ts new file mode 100644 index 000000000..dd6964408 --- /dev/null +++ b/frontend/src/enums.ts @@ -0,0 +1,4 @@ +export enum UIMode { + BigPicture = 4, + Desktop = 7, +} diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index cb1bb2704..442f077e0 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -1,4 +1,4 @@ -import { ToastNotification } from '@decky/api'; +import type { ToastNotification } from '@decky/api'; import { ModalRoot, Navigation, @@ -13,6 +13,7 @@ import { import { FC, lazy } from 'react'; import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa'; +import DeckyDesktopUI from './components/DeckyDesktopUI'; import DeckyIcon from './components/DeckyIcon'; import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState'; import { File, FileSelectionType } from './components/modals/filepicker'; @@ -24,13 +25,14 @@ import NotificationBadge from './components/NotificationBadge'; import PluginView from './components/PluginView'; import { useQuickAccessVisible } from './components/QuickAccessVisibleState'; import WithSuspense from './components/WithSuspense'; +import { UIMode } from './enums'; import ErrorBoundaryHook from './errorboundary-hook'; import { FrozenPluginService } from './frozen-plugins-service'; import { HiddenPluginsService } from './hidden-plugins-service'; import Logger from './logger'; import { NotificationService } from './notification-service'; import { InstallType, Plugin, PluginLoadType } from './plugin'; -import RouterHook, { UIMode } from './router-hook'; +import RouterHook from './router-hook'; import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { checkForPluginUpdates } from './store'; import TabsHook from './tabs-hook'; @@ -160,6 +162,21 @@ class PluginLoader extends Logger { ); }); + // needs the 1s wait or the entire app becomes drag target lol + sleep(1000).then(() => + this.routerHook.addGlobalComponent( + 'DeckyDesktopUI', + () => { + return ( + + + + ); + }, + UIMode.Desktop, + ), + ); + initSteamFixes(); initFilepickerPatches(); @@ -362,6 +379,7 @@ class PluginLoader extends Logger { public deinit() { this.routerHook.removeRoute('/decky/store'); this.routerHook.removeRoute('/decky/settings'); + this.routerHook.removeGlobalComponent('DeckyDesktopUI', UIMode.Desktop); deinitSteamFixes(); deinitFilepickerPatches(); this.routerHook.deinit(); @@ -627,8 +645,8 @@ class PluginLoader extends Logger { // Things will break *very* badly if plugin code touches this outside of @decky/api, so lets make that clear. window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = { connect: (version: number, pluginName: string) => { - if (version < 1 || version > 2) { - console.warn(`Plugin ${pluginName} requested unsupported api version ${version}.`); + if (version < 1 || version > 3) { + console.warn(`Plugin ${pluginName} requested unsupported API version ${version}.`); } const eventListeners: listenerMap = new Map(); @@ -671,12 +689,20 @@ class PluginLoader extends Logger { _version: 1, } as any; + // adds useQuickAccessVisible if (version >= 2) { backendAPI._version = 2; backendAPI.useQuickAccessVisible = useQuickAccessVisible; } - this.debug(`${pluginName} connected to loader API.`); + // adds uiMode param to route patching and global component functions. no functional changes, but we should warn anyway. + if (version >= 3) { + backendAPI._version = 3; + } + + this.debug( + `${pluginName} connected to loader API version ${backendAPI._version} (requested version ${version}).`, + ); return backendAPI; }, }; @@ -733,6 +759,10 @@ class PluginLoader extends Logger { return pluginAPI; } + + public setDesktopMenuOpen(open: boolean) { + this.deckyState.setDesktopMenuOpen(open); + } } export default PluginLoader; diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx index 92e3736cc..a98749f17 100644 --- a/frontend/src/router-hook.tsx +++ b/frontend/src/router-hook.tsx @@ -6,7 +6,9 @@ import { findInTree, findModuleByExport, getReactRoot, + injectFCTrampoline, sleep, + wrapReactType, } from '@decky/ui'; import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react'; import type { Route } from 'react-router'; @@ -23,6 +25,7 @@ import { RouterEntry, useDeckyRouterState, } from './components/DeckyRouterState'; +import { UIMode } from './enums'; import Logger from './logger'; declare global { @@ -31,18 +34,18 @@ declare global { } } -export enum UIMode { - BigPicture = 4, - Desktop = 7, -} - const isPatched = Symbol('is patched'); class RouterHook extends Logger { private routerState: DeckyRouterState = new DeckyRouterState(); private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState(); - private renderedComponents: ReactElement[] = []; + private renderedComponents = new Map([ + [UIMode.BigPicture, []], + [UIMode.Desktop, []], + ]); private Route: any; + private DesktopRoute: any; + private wrappedDesktopLibraryMemo?: any; private DeckyGamepadRouterWrapper = this.gamepadRouterWrapper.bind(this); private DeckyDesktopRouterWrapper = this.desktopRouterWrapper.bind(this); private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this); @@ -76,6 +79,21 @@ class RouterHook extends Logger { this.error('Failed to find router stack module'); } + const routerModule = findModuleByExport((e) => e?.displayName == 'Router'); + if (routerModule) { + this.DesktopRoute = Object.values(routerModule).find( + (e) => + typeof e == 'function' && + e?.prototype?.render?.toString()?.includes('props.computedMatch') && + e?.prototype?.render?.toString()?.includes('.Children.count('), + ); + if (!this.DesktopRoute) { + this.error('Failed to find DesktopRoute component'); + } + } else { + this.error('Failed to find router module, desktop routes will not work'); + } + this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => { this.debug(`UI mode changed to ${mode}`); if (this.patchedModes.has(mode)) return; @@ -88,7 +106,7 @@ class RouterHook extends Logger { break; // Not fully implemented yet case UIMode.Desktop: - this.debug("Patching desktop router"); + this.debug('Patching desktop router'); this.patchDesktopRouter(); break; default: @@ -109,7 +127,7 @@ class RouterHook extends Logger { await this.waitForUnlock(); let routerNode = findRouterNode(); while (!routerNode) { - this.warn('Failed to find Router node, reattempting in 5 seconds.'); + this.warn('Failed to find GamepadUI Router node, reattempting in 5 seconds.'); await sleep(5000); await this.waitForUnlock(); routerNode = findRouterNode(); @@ -130,49 +148,34 @@ class RouterHook extends Logger { } } - // Currently unused private async patchDesktopRouter() { const root = getReactRoot(document.getElementById('root') as any); const findRouterNode = () => - findInReactTree(root, (node) => node?.elementType?.type?.toString()?.includes('bShowDesktopUIContent:')); + findInReactTree(root, (node) => { + const typeStr = node?.elementType?.toString(); + return ( + typeStr && + typeStr?.includes('.IsMainDesktopWindow') && + typeStr?.includes('.IN_STEAMUI_SHARED_CONTEXT') && + typeStr?.includes('.ContentFrame') && + typeStr?.includes('.Console()') + ); + }); let routerNode = findRouterNode(); while (!routerNode) { - this.warn('Failed to find Router node, reattempting in 5 seconds.'); + this.warn('Failed to find DesktopUI Router node, reattempting in 5 seconds.'); await sleep(5000); routerNode = findRouterNode(); } if (routerNode) { - // this.debug("desktop router node", routerNode); // Patch the component globally - this.desktopRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleDesktopRouterRender.bind(this)); - // Swap out the current instance - routerNode.type = routerNode.elementType.type; - if (routerNode?.alternate) { - routerNode.alternate.type = routerNode.type; - } + const patchedRenderer = injectFCTrampoline(routerNode.elementType); + this.desktopRouterPatch = afterPatch(patchedRenderer, 'component', this.handleDesktopRouterRender.bind(this)); // 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?.(); } } @@ -196,10 +199,18 @@ class RouterHook extends Logger { const returnVal = ( <> + +
{ret} - + ); @@ -219,7 +230,7 @@ class RouterHook extends Logger { {ret} - + ); @@ -227,13 +238,21 @@ class RouterHook extends Logger { return returnVal; } - private globalComponentsWrapper() { + private globalComponentsWrapper({ uiMode }: { uiMode: UIMode }) { const { components } = useDeckyGlobalComponentsState(); - if (this.renderedComponents.length != components.size) { - this.debug('Rerendering global components'); - this.renderedComponents = Array.from(components.values()).map((GComponent) => ); + const componentsForMode = components.get(uiMode); + if (!componentsForMode) { + this.warn(`Couldn't find global components map for uimode ${uiMode}`); + return null; + } + if (!this.renderedComponents.has(uiMode) || this.renderedComponents.get(uiMode)?.length != componentsForMode.size) { + this.debug('Rerendering global components for uiMode', uiMode); + this.renderedComponents.set( + uiMode, + Array.from(componentsForMode.values()).map((GComponent) => ), + ); } - return <>{this.renderedComponents}; + return <>{this.renderedComponents.get(uiMode)}; } private gamepadRouterWrapper({ children }: { children: ReactElement }) { @@ -247,8 +266,8 @@ class RouterHook extends Logger { } 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.processList(mainRouteList, routes, routePatches.get(UIMode.BigPicture), true, this.Route); + this.processList(ingameRouteList, null, routePatches.get(UIMode.BigPicture), false, this.Route); this.debug('Rerendered gamepadui routes list'); return children; @@ -256,22 +275,38 @@ class RouterHook extends Logger { 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( + const mainRouteList = findInReactTree( children, - (node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/library/home'), + (node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/console'), ); - if (!routeList) { + if (!mainRouteList) { 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.processList(mainRouteList, routes, routePatches.get(UIMode.Desktop), true, this.DesktopRoute); + const libraryRouteWrapper = mainRouteList.find( + (r: any) => r?.props && 'cm' in r.props && 'bShowDesktopUIContent' in r.props, + ); + if (!this.wrappedDesktopLibraryMemo) { + wrapReactType(libraryRouteWrapper); + afterPatch(libraryRouteWrapper.type, 'type', (_, ret) => { + const { routePatches } = useDeckyRouterState(); + const libraryRouteList = findInReactTree( + ret, + (node) => node?.length > 1 && node?.find((elem: any) => elem?.props?.path == '/library/downloads'), + ); + if (!libraryRouteList) { + this.warn('failed to find library route list', ret); + return ret; + } + this.processList(libraryRouteList, null, routePatches.get(UIMode.Desktop), false, this.DesktopRoute); + return ret; + }); + this.wrappedDesktopLibraryMemo = libraryRouteWrapper.type; + } else { + libraryRouteWrapper.type = this.wrappedDesktopLibraryMemo; } - this.debug('library', library); - this.processList(library.children, routes, routePatches, true); this.debug('Rerendered desktop routes list'); return children; @@ -279,11 +314,11 @@ class RouterHook extends Logger { private processList( routeList: any[], - routes: Map | null, - routePatches: Map>, + routes: Map | null | undefined, + routePatches: Map> | null | undefined, save: boolean, + RouteComponent: any, ) { - const Route = this.Route; this.debug('Route list: ', routeList); if (save) this.routes = routeList; let routerIndex = routeList.length; @@ -293,59 +328,60 @@ class RouterHook extends Logger { const newRouterArray: (ReactElement | JSX.Element)[] = []; routes.forEach(({ component, props }, path) => { newRouterArray.push( - + {createElement(component)} - , + , ); }); 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; - }); - } - }); + routePatches && + 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; + }); + } + }); } addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) { this.routerState.addRoute(path, component, props); } - addPatch(path: string, patch: RoutePatch) { - return this.routerState.addPatch(path, patch); + addPatch(path: string, patch: RoutePatch, uiMode: UIMode = UIMode.BigPicture) { + return this.routerState.addPatch(path, patch, uiMode); } - addGlobalComponent(name: string, component: FC) { - this.globalComponentsState.addComponent(name, component); + addGlobalComponent(name: string, component: FC, uiMode: UIMode = UIMode.BigPicture) { + this.globalComponentsState.addComponent(name, component, uiMode); } - removeGlobalComponent(name: string) { - this.globalComponentsState.removeComponent(name); + removeGlobalComponent(name: string, uiMode: UIMode = UIMode.BigPicture) { + this.globalComponentsState.removeComponent(name, uiMode); } - removePatch(path: string, patch: RoutePatch) { - this.routerState.removePatch(path, patch); + removePatch(path: string, patch: RoutePatch, uiMode: UIMode = UIMode.BigPicture) { + this.routerState.removePatch(path, patch, uiMode); } removeRoute(path: string) {