summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/pnpm-lock.yaml8
-rw-r--r--frontend/src/components/DeckyDesktopSidebar.tsx72
-rw-r--r--frontend/src/components/DeckyDesktopUI.tsx44
-rw-r--r--frontend/src/components/DeckyGlobalComponentsState.tsx27
-rw-r--r--frontend/src/components/DeckyRouterState.tsx30
-rw-r--r--frontend/src/components/DeckyState.tsx11
-rw-r--r--frontend/src/components/Markdown.tsx5
-rw-r--r--frontend/src/components/PluginView.tsx10
-rw-r--r--frontend/src/components/QuickAccessVisibleState.tsx2
-rw-r--r--frontend/src/components/TitleView.tsx29
-rw-r--r--frontend/src/components/modals/MultiplePluginsInstallModal.tsx5
-rw-r--r--frontend/src/components/modals/PluginInstallModal.tsx5
-rw-r--r--frontend/src/components/settings/index.tsx17
-rw-r--r--frontend/src/components/settings/pages/general/Updater.tsx88
-rw-r--r--frontend/src/enums.ts4
-rw-r--r--frontend/src/plugin-loader.tsx40
-rw-r--r--frontend/src/router-hook.tsx220
17 files changed, 442 insertions, 175 deletions
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index cc107eea..e8aacd2b 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -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,8 +218,8 @@ 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.8.1':
resolution: {integrity: sha512-lM4jdeyHjIbxHWxDBhbk+GQvdIT50p6RW9DC+oWSWXlaNWU/iG+8aUAcnfxygFkTP43EkCgjFASsYTfB55CMXA==}
@@ -2817,7 +2817,7 @@ 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.8.1': {}
diff --git a/frontend/src/components/DeckyDesktopSidebar.tsx b/frontend/src/components/DeckyDesktopSidebar.tsx
new file mode 100644
index 00000000..f159652b
--- /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<boolean>(!desktopMenuOpen);
+ const [openAnimStart, setOpenAnimStart] = useState<boolean>(desktopMenuOpen);
+ const closedInterval = useRef<number | null>(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 (
+ <>
+ <div
+ className="deckyDesktopSidebarDim"
+ style={{
+ position: 'absolute',
+ height: 'calc(100% - 78px - 50px)',
+ width: '100%',
+ top: '78px',
+ left: '0px',
+ zIndex: 998,
+ background: 'rgba(0, 0, 0, 0.7)',
+ opacity: openAnimStart ? 1 : 0,
+ display: desktopMenuOpen || !closed ? 'flex' : 'none',
+ transition: 'opacity 0.4s cubic-bezier(0.65, 0, 0.35, 1)',
+ }}
+ onClick={() => setDesktopMenuOpen(false)}
+ />
+
+ <div
+ className="deckyDesktopSidebar"
+ style={{
+ position: 'absolute',
+ height: 'calc(100% - 78px - 50px)',
+ width: '350px',
+ paddingLeft: '16px',
+ top: '78px',
+ right: '0px',
+ zIndex: 999,
+ transition: 'transform 0.4s cubic-bezier(0.65, 0, 0.35, 1)',
+ transform: openAnimStart ? 'translateX(0px)' : 'translateX(366px)',
+ overflowY: 'scroll',
+ // prevents chromium border jank
+ display: desktopMenuOpen || !closed ? 'flex' : 'none',
+ flexDirection: 'column',
+ background: '#171d25',
+ }}
+ >
+ <QuickAccessVisibleState.Provider value={desktopMenuOpen || !closed}>
+ <PluginView desktop={true} />
+ </QuickAccessVisibleState.Provider>
+ </div>
+ </>
+ );
+};
+
+export default DeckyDesktopSidebar;
diff --git a/frontend/src/components/DeckyDesktopUI.tsx b/frontend/src/components/DeckyDesktopUI.tsx
new file mode 100644
index 00000000..fde33c0f
--- /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 (
+ <>
+ <style>
+ {`
+ .deckyDesktopIcon {
+ color: #67707b;
+ }
+ .deckyDesktopIcon:hover {
+ color: #fff;
+ }
+ `}
+ </style>
+ <DeckyIcon
+ className="deckyDesktopIcon"
+ width={24}
+ height={24}
+ onClick={() => 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
+ }
+ />
+ <DeckyDesktopSidebar />
+ </>
+ );
+};
+
+export default DeckyDesktopUI;
diff --git a/frontend/src/components/DeckyGlobalComponentsState.tsx b/frontend/src/components/DeckyGlobalComponentsState.tsx
index 475d1e4a..4088f7b1 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<string, FC>;
+ components: Map<UIMode, Map<string, FC>>;
}
export class DeckyGlobalComponentsState {
// TODO a set would be better
- private _components = new Map<string, FC>();
+ private _components = new Map<UIMode, Map<string, FC>>([
+ [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<DeckyGlobalComponentsContext>(null as any);
diff --git a/frontend/src/components/DeckyRouterState.tsx b/frontend/src/components/DeckyRouterState.tsx
index 426ed731..f13855a4 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<RouteProps, 'path' | 'children'>;
component: ComponentType;
@@ -10,12 +12,16 @@ export type RoutePatch = (route: RouteProps) => RouteProps;
interface PublicDeckyRouterState {
routes: Map<string, RouterEntry>;
- routePatches: Map<string, Set<RoutePatch>>;
+ routePatches: Map<UIMode, Map<string, Set<RoutePatch>>>;
}
export class DeckyRouterState {
private _routes = new Map<string, RouterEntry>();
- private _routePatches = new Map<string, Set<RoutePatch>>();
+ // Update when support for new UIModes is added
+ private _routePatches = new Map<UIMode, Map<string, Set<RoutePatch>>>([
+ [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 75106e62..ddd8e052 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<DeckyStateContext>(null as any);
@@ -155,6 +164,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ 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 (
<DeckyStateContext.Provider
@@ -165,6 +175,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
setActivePlugin,
closeActivePlugin,
setPluginOrder,
+ setDesktopMenuOpen,
}}
>
{children}
diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx
index cf6657aa..d6201980 100644
--- a/frontend/src/components/Markdown.tsx
+++ b/frontend/src/components/Markdown.tsx
@@ -24,6 +24,11 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
props.onDismiss?.();
Navigation.NavigateToExternalWeb(aRef.current!.href);
}}
+ onClick={(e) => {
+ e.preventDefault();
+ props.onDismiss?.();
+ Navigation.NavigateToExternalWeb(aRef.current!.href);
+ }}
style={{ display: 'inline' }}
>
<a ref={aRef} {...nodeProps.node.properties}>
diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx
index 19afbca5..e36df3cb 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<PluginViewProps> = ({ 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 (
<Focusable onCancelButton={closeActivePlugin}>
- <TitleView />
+ <TitleView desktop={desktop} />
<div style={{ height: '100%', paddingTop: '16px' }}>
<ErrorBoundary>{(visible || activePlugin.alwaysRender) && activePlugin.content}</ErrorBoundary>
</div>
@@ -36,7 +40,7 @@ const PluginView: FC = () => {
}
return (
<>
- <TitleView />
+ <TitleView desktop={desktop} />
<div
style={{
paddingTop: '16px',
diff --git a/frontend/src/components/QuickAccessVisibleState.tsx b/frontend/src/components/QuickAccessVisibleState.tsx
index f5c05061..e145bd84 100644
--- a/frontend/src/components/QuickAccessVisibleState.tsx
+++ b/frontend/src/components/QuickAccessVisibleState.tsx
@@ -1,6 +1,6 @@
import { FC, ReactNode, createContext, useContext, useState } from 'react';
-const QuickAccessVisibleState = createContext<boolean>(false);
+export const QuickAccessVisibleState = createContext<boolean>(false);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx
index 0cb82b7f..8ddb242d 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<TitleViewProps> = ({ 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 = () => {
<Focusable style={titleStyles} className={staticClasses.Title}>
<div style={{ marginRight: 'auto', flex: 0.9 }}>Decky</div>
<DialogButton
- style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
+ style={buttonStyles}
onClick={onStoreClick}
onOKActionDescription={t('TitleView.decky_store_desc')}
>
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<DialogButton
- style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
+ style={buttonStyles}
onClick={onSettingsClick}
onOKActionDescription={t('TitleView.settings_desc')}
>
@@ -52,10 +68,7 @@ const TitleView: FC = () => {
return (
<Focusable className={staticClasses.Title} style={titleStyles}>
- <DialogButton
- style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
- onClick={closeActivePlugin}
- >
+ <DialogButton style={buttonStyles} onClick={closeActivePlugin}>
<FaArrowLeft style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
{activePlugin?.titleView || <div style={{ flex: 0.9 }}>{activePlugin.name}</div>}
diff --git a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
index ba49ba92..d6f163f7 100644
--- a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
+++ b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
@@ -80,7 +80,10 @@ const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
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 227bd818..ec353279 100644
--- a/frontend/src/components/modals/PluginInstallModal.tsx
+++ b/frontend/src/components/modals/PluginInstallModal.tsx
@@ -51,7 +51,10 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
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 d6d98645..cb5096f5 100644
--- a/frontend/src/components/settings/index.tsx
+++ b/frontend/src/components/settings/index.tsx
@@ -53,5 +53,20 @@ export default function SettingsPage() {
},
];
- return <SidebarNavigation pages={pages} />;
+ return (
+ <div className="deckySettingsHeightHack">
+ <style>
+ {/* hacky fix to work around height: 720px in desktop ui */}
+ {`
+ .deckySettingsHeightHack {
+ height: 100% !important;
+ }
+ .deckySettingsHeightHack > div {
+ height: 100% !important;
+ }
+ `}
+ </style>
+ <SidebarNavigation pages={pages} />
+ </div>
+ );
}
diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx
index 59756a57..89b6d6ee 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<HTMLDivElement>();
const { t } = useTranslation();
+ // TODO proper desktop scrolling
return (
- <Focusable onCancelButton={closeModal}>
+ <Focusable ref={outerRef} onCancelButton={closeModal}>
<FocusRing>
- <Carousel
- fnItemRenderer={(id: number) => (
- <Focusable
- style={{
- marginTop: '40px',
- height: 'calc( 100% - 40px )',
- overflowY: 'scroll',
- display: 'flex',
- justifyContent: 'center',
- margin: '40px',
- }}
- >
- <div>
- <h1>{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}</h1>
- {versionInfo?.all?.[id]?.body ? (
- <WithSuspense>
- <MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
- </WithSuspense>
- ) : (
- t('Updater.no_patch_notes_desc')
- )}
- </div>
- </Focusable>
- )}
- 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 && (
+ <Carousel
+ fnItemRenderer={(id: number) => (
+ <Focusable
+ style={{
+ marginTop: '40px',
+ height: 'calc( 100% - 40px )',
+ overflowY: 'scroll',
+ display: 'flex',
+ justifyContent: 'center',
+ margin: '40px',
+ }}
+ >
+ <div>
+ <h1>{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}</h1>
+ {versionInfo?.all?.[id]?.body ? (
+ <WithSuspense>
+ <MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
+ </WithSuspense>
+ ) : (
+ t('Updater.no_patch_notes_desc')
+ )}
+ </div>
+ </Focusable>
+ )}
+ 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}
+ />
+ )}
</FocusRing>
</Focusable>
);
@@ -72,6 +75,8 @@ export default function UpdaterSettings() {
const [updateProgress, setUpdateProgress] = useState<number>(-1);
const [reloading, setReloading] = useState<boolean>(false);
+ const [windowRef, win] = useWindowRef<HTMLDivElement>();
+
const { t } = useTranslation();
useEffect(() => {
@@ -91,11 +96,12 @@ export default function UpdaterSettings() {
}, []);
const showPatchNotes = useCallback(() => {
- showModal(<PatchNotesModal versionInfo={versionInfo} />);
- }, [versionInfo]);
+ // TODO set width and height on desktop - needs fixing in DFL?
+ showModal(<PatchNotesModal versionInfo={versionInfo} />, win!);
+ }, [versionInfo, win]);
return (
- <>
+ <div ref={windowRef}>
<Field
onOptionsActionDescription={versionInfo?.all ? t('Updater.patch_notes_desc') : undefined}
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
@@ -164,6 +170,6 @@ export default function UpdaterSettings() {
</Suspense>
</InlinePatchNotes>
)}
- </>
+ </div>
);
}
diff --git a/frontend/src/enums.ts b/frontend/src/enums.ts
new file mode 100644
index 00000000..dd696440
--- /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 cb1bb270..442f077e 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 (
+ <DeckyStateContextProvider deckyState={this.deckyState}>
+ <DeckyDesktopUI />
+ </DeckyStateContextProvider>
+ );
+ },
+ 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 8dcdd9b8..e9ac9e0f 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, ReactElement[]>([
+ [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 = (
<>
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
+ <style>
+ {`
+ .deckyDesktopDialogPaddingHack + * .DialogContent_InnerWidth {
+ max-width: unset !important;
+ }
+ `}
+ </style>
+ <div className="deckyDesktopDialogPaddingHack" />
<DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
- <DeckyGlobalComponentsWrapper />
+ <DeckyGlobalComponentsWrapper uiMode={UIMode.Desktop} />
</DeckyGlobalComponentsStateContextProvider>
</>
);
@@ -219,7 +230,7 @@ class RouterHook extends Logger {
<DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
- <DeckyGlobalComponentsWrapper />
+ <DeckyGlobalComponentsWrapper uiMode={UIMode.BigPicture} />
</DeckyGlobalComponentsStateContextProvider>
</>
);
@@ -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) => <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) => <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<string, RouterEntry> | null,
- routePatches: Map<string, Set<RoutePatch>>,
+ routes: Map<string, RouterEntry> | null | undefined,
+ routePatches: Map<string, Set<RoutePatch>> | 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(
- <Route path={path} {...props}>
+ <RouteComponent path={path} {...props}>
<ErrorBoundary>{createElement(component)}</ErrorBoundary>
- </Route>,
+ </RouteComponent>,
);
});
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) {