feat(*): add v4 webpack api and port everything to it

also restructures a bunch
This commit is contained in:
AAGaming
2024-05-12 15:45:26 -04:00
parent 9c79187d37
commit bffd530bda
52 changed files with 508 additions and 581 deletions

View File

@@ -1,5 +1,5 @@
{
"name": "@decky/frontend",
"name": "@decky/ui",
"version": "4.0.0",
"description": "A library for interacting with the Steam frontend in Decky plugins and elsewhere.",
"main": "dist/index.js",

View File

@@ -8,9 +8,8 @@ export interface ButtonItemProps extends ItemProps {
disabled?: boolean;
}
export const ButtonItem =
(CommonUIModule.ButtonField ||
Object.values(CommonUIModule).find(
(mod: any) =>
mod?.render?.toString()?.includes('"highlightOnFocus","childrenContainerWidth"') ||
mod?.render?.toString()?.includes('childrenContainerWidth:"min"'),
)) as FC<ButtonItemProps>;
) as FC<ButtonItemProps>;

View File

@@ -1,6 +1,6 @@
import { HTMLAttributes, ReactNode, RefAttributes, VFC } from 'react';
import { findModuleChild } from '../webpack';
import { Export, findModuleExport } from '../webpack';
export interface CarouselProps extends HTMLAttributes<HTMLDivElement> {
autoFocus?: boolean;
@@ -20,9 +20,4 @@ export interface CarouselProps extends HTMLAttributes<HTMLDivElement> {
scrollToAlignment?: 'center';
}
export const Carousel = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.render?.toString().includes('setFocusedColumn:')) return m[prop];
}
}) as VFC<CarouselProps & RefAttributes<HTMLDivElement>>;
export const Carousel = findModuleExport((e: Export) => e.render?.toString().includes('setFocusedColumn:')) as VFC<CarouselProps & RefAttributes<HTMLDivElement>>;

View File

@@ -0,0 +1,9 @@
import { Export, findModuleExport } from '../webpack';
import { FC } from 'react';
export interface ControlsListProps {
alignItems?: 'left' | 'right' | 'center';
spacing?: 'standard' | 'extra';
}
export const ControlsList: FC<ControlsListProps> = findModuleExport((e: Export) => e?.toString && e.toString().includes('().ControlsListChild') && e.toString().includes('().ControlsListOuterPanel'));

View File

@@ -1,6 +1,6 @@
import { FC, ReactNode, RefAttributes } from 'react';
import { findModuleChild } from '../webpack';
import { Export, findModuleExport } from '../webpack';
import { FooterLegendProps } from './FooterLegend';
export interface FieldProps extends FooterLegendProps {
@@ -23,9 +23,4 @@ export interface FieldProps extends FooterLegendProps {
onClick?: (e: CustomEvent | MouseEvent) => void;
}
export const Field = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.render?.toString().includes('"shift-children-below"')) return m[prop];
}
}) as FC<FieldProps & RefAttributes<HTMLDivElement>>;
export const Field = findModuleExport((e: Export) => e?.render?.toString().includes('"shift-children-below"')) as FC<FieldProps & RefAttributes<HTMLDivElement>>;

View File

@@ -0,0 +1,13 @@
import { ElementType, FC, ReactNode } from 'react';
import { Export, findModuleExport } from '../webpack';
export interface FocusRingProps {
className?: string;
rootClassName?: string;
render?: ElementType;
children?: ReactNode;
NavigationManager?: any;
}
export const FocusRing = findModuleExport((e: Export) => e?.toString()?.includes('.GetShowDebugFocusRing())')) as FC<FocusRingProps>;

View File

@@ -1,6 +1,6 @@
import { HTMLAttributes, ReactNode, RefAttributes, VFC } from 'react';
import { findModuleChild } from '../webpack';
import { Export, findModuleExport } from '../webpack';
import { FooterLegendProps } from './FooterLegend';
export interface FocusableProps extends HTMLAttributes<HTMLDivElement>, FooterLegendProps {
@@ -13,10 +13,4 @@ export interface FocusableProps extends HTMLAttributes<HTMLDivElement>, FooterLe
onCancel?: (e: CustomEvent) => void;
}
export const Focusable = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.render?.toString()?.includes('["flow-children","onActivate","onCancel","focusClassName",'))
return m[prop];
}
}) as VFC<FocusableProps & RefAttributes<HTMLDivElement>>;
export const Focusable = findModuleExport((e: Export) => e?.render?.toString()?.includes('["flow-children","onActivate","onCancel","focusClassName",')) as VFC<FocusableProps & RefAttributes<HTMLDivElement>>;

View File

@@ -0,0 +1,18 @@
import { CSSProperties, FC } from 'react';
import { Export, findModuleExport } from '../webpack';
export interface MarqueeProps {
play?: boolean;
direction?: 'left' | 'right';
speed?: number;
delay?: number;
fadeLength?: number;
center?: boolean;
resetOnPause?: boolean;
style?: CSSProperties;
className?: string;
children: React.ReactNode;
}
export const Marquee: FC<MarqueeProps> = findModuleExport((e: Export) => e?.toString && e.toString().includes('.Marquee') && e.toString().includes('--fade-length'));

57
src/components/Menu.tsx Executable file
View File

@@ -0,0 +1,57 @@
import { FC, ReactNode } from 'react';
import { fakeRenderComponent } from '../utils';
import { Export, findModuleExport } from '../webpack';
import { FooterLegendProps } from './FooterLegend';
export const showContextMenu: (children: ReactNode, parent?: EventTarget) => void = findModuleExport(
(e: Export) => typeof e === 'function' && e.toString().includes('stopPropagation))'),
);
export interface MenuProps extends FooterLegendProps {
label: string;
onCancel?(): void;
cancelText?: string;
children?: ReactNode;
}
export const Menu: FC<MenuProps> = findModuleExport(
(e: Export) => e?.prototype?.HideIfSubmenu && e?.prototype?.HideMenu,
);
export interface MenuGroupProps {
label: string;
disabled?: boolean;
children?: ReactNode;
}
export const MenuGroup: FC<MenuGroupProps> = findModuleExport(
(e: Export) =>
(e?.toString()?.includes?.('bInGamepadUI:') &&
fakeRenderComponent(() => e({ overview: { appid: 7 } }))?.type?.prototype?.RenderSubMenu) ||
(e?.prototype?.RenderSubMenu && e?.prototype?.ShowSubMenu),
);
export interface MenuItemProps extends FooterLegendProps {
bInteractableItem?: boolean;
onClick?(evt: Event): void;
onSelected?(evt: Event): void;
onMouseEnter?(evt: MouseEvent): void;
onMoveRight?(): void;
selected?: boolean;
disabled?: boolean;
bPlayAudio?: boolean;
tone?: 'positive' | 'emphasis' | 'destructive';
children?: ReactNode;
}
export const MenuItem: FC<MenuItemProps> = findModuleExport(
(e: Export) =>
e?.render?.toString()?.includes('bPlayAudio:') || (e?.prototype?.OnOKButton && e?.prototype?.OnMouseEnter),
);
/*
all().map(m => {
if (typeof m !== "object") return undefined;
for (let prop in m) { if (m[prop]?.prototype?.OK && m[prop]?.prototype?.Cancel && m[prop]?.prototype?.render) return m[prop]}
}).find(x => x)
*/

119
src/components/Modal.tsx Executable file
View File

@@ -0,0 +1,119 @@
import { FC, ReactNode } from 'react';
import { findSP } from '../utils';
import { Export, findModule, findModuleByExport, findModuleExport } from '../webpack';
// All of the popout options + strTitle are related. Proper usage is not yet known...
export interface ShowModalProps {
browserContext?: unknown;
bForcePopOut?: boolean;
bHideActionIcons?: boolean;
bHideMainWindowForPopouts?: boolean;
bNeverPopOut?: boolean;
fnOnClose?: () => void; // Seems to be the same as "closeModal" callback, but only when the modal is a popout. Will no longer work after "Update" invocation!
popupHeight?: number;
popupWidth?: number;
promiseRenderComplete?: Promise<void>; // Invoked once the render is complete. Currently, it seems to be used as image loading success/error callback...
strTitle?: string;
}
export interface ShowModalResult {
// This method will not invoke any of the variations of "closeModal" callbacks!
Close: () => void;
// This method will replace the modal element completely and will not update the callback chains,
// meaning that "closeModal" and etc. will not automatically close the modal anymore (also "fnOnClose"
// will not be even called upon close anymore)! You have to manually call the "Close" method when, for example,
// the "closeModal" is invoked in the newly updated modal:
// <ModalRoot closeModal={() => { console.log("ABOUT TO CLOSE"); showModalRes.Close(); }} />
Update: (modal: ReactNode) => void;
}
const showModalRaw: (
modal: ReactNode,
parent?: EventTarget,
title?: string,
props?: ShowModalProps,
unknown1?: unknown,
hideActions?: { bHideActions?: boolean },
modalManager?: unknown,
) => ShowModalResult = findModuleExport(
(e: Export) =>
typeof e === 'function' && e.toString().includes('props.bDisableBackgroundDismiss') && !e?.prototype?.Cancel,
);
export const showModal = (
modal: ReactNode,
parent?: EventTarget,
props: ShowModalProps = {
strTitle: 'Decky Dialog',
bHideMainWindowForPopouts: false,
},
): ShowModalResult => {
return showModalRaw(modal, parent || findSP(), props.strTitle, props, undefined, {
bHideActions: props.bHideActionIcons,
});
};
export interface ModalRootProps {
children?: ReactNode;
onCancel?(): void;
closeModal?(): void;
onOK?(): void;
onEscKeypress?(): void;
className?: string;
modalClassName?: string;
bAllowFullSize?: boolean;
bDestructiveWarning?: boolean;
bDisableBackgroundDismiss?: boolean;
bHideCloseIcon?: boolean;
bOKDisabled?: boolean;
bCancelDisabled?: boolean;
}
export interface ConfirmModalProps extends ModalRootProps {
onMiddleButton?(): void; // setting this prop will enable the middle button
strTitle?: ReactNode;
strDescription?: ReactNode;
strOKButtonText?: ReactNode;
strCancelButtonText?: ReactNode;
strMiddleButtonText?: ReactNode;
bAlertDialog?: boolean; // This will open a modal with only OK button enabled
bMiddleDisabled?: boolean;
}
export const ConfirmModal = findModuleExport(
(e: Export) => !e?.prototype?.OK && e?.prototype?.Cancel && e?.prototype?.render,
) as FC<ConfirmModalProps>;
export const ModalRoot = Object.values(
findModule((m: any) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.m_mapModalManager && Object.values(m)?.find((x: any) => x?.type)) {
return true;
}
}
return false;
}) || {},
)?.find((x: any) => x?.type?.toString()?.includes('((function(){')) as FC<ModalRootProps>;
interface SimpleModalProps {
active?: boolean;
children: ReactNode;
}
const ModalModule = findModuleByExport((e: Export) => e?.toString().includes('.ModalPosition,fallback:'), 5);
const ModalModuleProps = ModalModule ? Object.values(ModalModule) : [];
export const SimpleModal = ModalModuleProps.find((prop) => {
const string = prop?.toString();
return string?.includes('.ShowPortalModal()') && string?.includes('.OnElementReadyCallbacks.Register(');
}) as FC<SimpleModalProps>;
export const ModalPosition = ModalModuleProps.find((prop) =>
prop?.toString().includes('.ModalPosition,fallback:'),
) as FC<SimpleModalProps>;

24
src/components/Panel.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { FC, ReactNode } from 'react';
import { Export, findModuleDetailsByExport } from '../webpack';
// TODO where did this go?
// export const Panel: FC<{ children?: ReactNode; }> = findModuleExport((e: Export) => {
// if (typeof mod !== 'object' || !mod.__esModule) return undefined;
// return mod.Panel;
// });
export interface PanelSectionProps {
title?: string;
spinner?: boolean;
children?: ReactNode;
}
const [mod, panelSection] = findModuleDetailsByExport((e: Export) => e.toString()?.includes('.PanelSection'));
export const PanelSection = panelSection as FC<PanelSectionProps>;
export interface PanelSectionRowProps {
children?: ReactNode;
}
export const PanelSectionRow = Object.values(mod).filter((exp: any) => !exp?.toString()?.includes('.PanelSection'))[0] as FC<PanelSectionRowProps>;

View File

@@ -0,0 +1,29 @@
import { ReactNode, VFC } from 'react';
import { Export, findModuleExport } from '../webpack';
import { ItemProps } from './Item';
export interface ProgressBarItemProps extends ItemProps {
indeterminate?: boolean;
nTransitionSec?: number;
nProgress?: number;
focusable?: boolean;
}
export interface ProgressBarProps {
indeterminate?: boolean;
nTransitionSec?: number;
nProgress?: number;
focusable?: boolean;
}
export interface ProgressBarWithInfoProps extends ProgressBarItemProps {
sTimeRemaining?: ReactNode;
sOperationText?: ReactNode;
}
export const ProgressBar = findModuleExport((e: Export) => e?.toString()?.includes('.ProgressBar,"standard"==')) as VFC<ProgressBarProps>;
export const ProgressBarWithInfo = findModuleExport((e: Export) => e?.toString()?.includes('.ProgressBarFieldStatus},')) as VFC<ProgressBarWithInfoProps>;
export const ProgressBarItem = findModuleExport((e: Export) => e?.toString()?.includes('"indeterminate","nTransitionSec"')) as VFC<ProgressBarItemProps>;

11
src/components/Scroll.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { FC, ReactNode } from 'react';
import { Export, findModuleByExport, findModuleExport } from '../webpack';
const ScrollingModule = findModuleByExport((e: Export) => e?.render?.toString?.().includes("{case\"x\":"));
const ScrollingModuleProps = ScrollingModule ? Object.values(ScrollingModule) : [];
export const ScrollPanel = ScrollingModuleProps.find((prop: any) => prop?.render?.toString?.().includes("{case\"x\":")) as FC<{ children?: ReactNode }>;
export const ScrollPanelGroup: FC<{ children?: ReactNode }> = findModuleExport((e: Export) => e?.render?.toString().includes(".FocusVisibleChild()),[])"));

View File

@@ -1,6 +1,6 @@
import { ReactNode, VFC } from 'react';
import { Module, findModuleChild } from '../webpack';
import { Export, findModuleExport } from '../webpack';
export interface SidebarNavigationPage {
title: ReactNode;
@@ -23,11 +23,4 @@ export interface SidebarNavigationProps {
onPageRequested?: (page: string) => void;
}
export const SidebarNavigation = findModuleChild((mod: Module) => {
for (let prop in mod) {
if (mod[prop]?.toString()?.includes('"disableRouteReporting"')) {
return mod[prop];
}
}
return null;
}) as VFC<SidebarNavigationProps>;
export const SidebarNavigation = findModuleExport((e: Export) => e?.toString()?.includes('"disableRouteReporting"')) as VFC<SidebarNavigationProps>;

View File

@@ -0,0 +1,5 @@
import { FC, SVGAttributes } from 'react';
import { Export, findModuleExport } from '../webpack';
export const SteamSpinner = findModuleExport((e: Export) => e?.toString?.()?.includes('Steam Spinner') && e?.toString?.()?.includes('src')) as FC<SVGAttributes<SVGElement>>;

View File

@@ -1,7 +1,7 @@
import { FC, ReactNode, createElement, useEffect, useState } from 'react';
import { fakeRenderComponent, findInReactTree, sleep } from '../utils';
import { findModule } from '../webpack';
import { Export, findModuleByExport } from '../webpack';
import { FooterLegendProps } from './FooterLegend';
import { SteamSpinner } from './SteamSpinner';
@@ -98,13 +98,7 @@ const getTabs = async () => {
let oldTabs: any;
try {
const oldTabsModule = findModule((m: any) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.Unbleed) return true;
}
return false;
});
const oldTabsModule = findModuleByExport((e: Export) => e.Unbleed);
if (oldTabsModule)
oldTabs = Object.values(oldTabsModule).find((x: any) => x?.type?.toString()?.includes('((function(){'));
} catch (e) {

25
src/components/index.ts Executable file
View File

@@ -0,0 +1,25 @@
export * from './Button';
export * from './ButtonItem';
export * from './Carousel';
export * from './ControlsList';
export * from './Dialog';
export * from './DialogCheckbox';
export * from './Dropdown';
export * from './Field';
export * from './Focusable';
export * from './FocusRing';
export * from './FooterLegend';
export * from './Marquee';
export * from './Menu';
export * from './Modal';
export * from './Panel';
export * from './ProgressBar';
export * from './SidebarNavigation';
export * from './SliderField';
export * from './Spinner';
export * from './SteamSpinner';
export * from './Tabs';
export * from './TextField';
export * from './Toggle';
export * from './ToggleField';
export * from './Scroll';

View File

@@ -1,6 +1,7 @@
import { CSSProperties, FC, useState } from 'react';
import { ConfirmModal, SliderField, gamepadSliderClasses } from '../deck-components';
import { ConfirmModal, SliderField } from '../components';
import { gamepadSliderClasses } from '../utils/static-classes';
interface ColorPickerModalProps {
closeModal: () => void;

View File

@@ -1,6 +1,6 @@
import { Fragment, JSXElementConstructor, ReactElement, ReactNode, useEffect, useState } from 'react';
import { Field, FieldProps, Focusable, GamepadButton } from '../deck-components';
import { Field, FieldProps, Focusable, GamepadButton } from '../components';
/**
* A ReorderableList entry of type <T>.

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { FC, ImgHTMLAttributes, useState } from 'react';
import { Spinner } from '../deck-components';
import { Spinner } from '../components';
interface SuspensefulImageProps extends ImgHTMLAttributes<HTMLImageElement> {
suspenseWidth?: string | number;

View File

@@ -1,17 +0,0 @@
import { findModuleChild } from '../webpack';
import { FC } from 'react';
export interface ControlsListProps {
alignItems?: 'left' | 'right' | 'center';
spacing?: 'standard' | 'extra';
}
export const ControlsList: FC<ControlsListProps> = findModuleChild((m) => {
if (typeof m !== 'object') return;
for (const prop in m) {
if (m[prop]?.toString && m[prop].toString().includes('().ControlsListChild') && m[prop].toString().includes('().ControlsListOuterPanel')) {
return m[prop];
}
}
return;
});

View File

@@ -1,19 +0,0 @@
import { ElementType, FC, ReactNode } from 'react';
import { findModuleChild } from '../webpack';
export interface FocusRingProps {
className?: string;
rootClassName?: string;
render?: ElementType;
children?: ReactNode;
NavigationManager?: any;
}
export const FocusRing = findModuleChild((m: any) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.toString()?.includes('.GetShowDebugFocusRing())')) return m[prop];
}
return false;
}) as FC<FocusRingProps>;

View File

@@ -1,26 +0,0 @@
import { CSSProperties, FC } from 'react';
import { findModuleChild } from '../webpack';
export interface MarqueeProps {
play?: boolean;
direction?: 'left' | 'right';
speed?: number;
delay?: number;
fadeLength?: number;
center?: boolean;
resetOnPause?: boolean;
style?: CSSProperties;
className?: string;
children: React.ReactNode;
}
export const Marquee: FC<MarqueeProps> = findModuleChild((m) => {
if (typeof m !== 'object') return;
for (const prop in m) {
if (m[prop]?.toString && m[prop].toString().includes('.Marquee') && m[prop].toString().includes('--fade-length')) {
return m[prop];
}
}
return;
});

View File

@@ -1,84 +0,0 @@
import { FC, ReactNode } from 'react';
import { fakeRenderComponent } from '../utils';
import { findModuleChild } from '../webpack';
import { FooterLegendProps } from './FooterLegend';
export const showContextMenu: (children: ReactNode, parent?: EventTarget) => void = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop] === 'function' && m[prop].toString().includes('stopPropagation))')) {
return m[prop];
}
}
});
export interface MenuProps extends FooterLegendProps {
label: string;
onCancel?(): void;
cancelText?: string;
children?: ReactNode;
}
export const Menu: FC<MenuProps> = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.prototype?.HideIfSubmenu && m[prop]?.prototype?.HideMenu) {
return m[prop];
}
}
});
export interface MenuGroupProps {
label: string;
disabled?: boolean;
children?: ReactNode;
}
export const MenuGroup: FC<MenuGroupProps> = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (
(m[prop]?.toString()?.includes?.('bInGamepadUI:') &&
fakeRenderComponent(() => m[prop]({overview: {appid: 7}}))?.type?.prototype?.RenderSubMenu) ||
(m[prop]?.prototype?.RenderSubMenu && m[prop]?.prototype?.ShowSubMenu)
) {
return m[prop];
}
}
});
export interface MenuItemProps extends FooterLegendProps {
bInteractableItem?: boolean;
onClick?(evt: Event): void;
onSelected?(evt: Event): void;
onMouseEnter?(evt: MouseEvent): void;
onMoveRight?(): void;
selected?: boolean;
disabled?: boolean;
bPlayAudio?: boolean;
tone?: 'positive' | 'emphasis' | 'destructive';
children?: ReactNode;
}
export const MenuItem: FC<MenuItemProps> = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (
m[prop]?.render?.toString()?.includes('bPlayAudio:') ||
(m[prop]?.prototype?.OnOKButton && m[prop]?.prototype?.OnMouseEnter)
) {
return m[prop];
}
}
});
/*
all().map(m => {
if (typeof m !== "object") return undefined;
for (let prop in m) { if (m[prop]?.prototype?.OK && m[prop]?.prototype?.Cancel && m[prop]?.prototype?.render) return m[prop]}
}).find(x => x)
*/

View File

@@ -1,178 +0,0 @@
import { FC, ReactNode } from 'react';
import { findSP } from '../utils';
import { findModule, findModuleChild } from '../webpack';
// All of the popout options + strTitle are related. Proper usage is not yet known...
export interface ShowModalProps {
browserContext?: unknown; // This is another Deck Object that is yet to be found
bForcePopOut?: boolean;
bHideActionIcons?: boolean;
bHideMainWindowForPopouts?: boolean;
bNeverPopOut?: boolean;
fnOnClose?: () => void; // Seems to be the same as "closeModal" callback, but only when the modal is a popout. Will no longer work after "Update" invocation!
popupHeight?: number;
popupWidth?: number;
promiseRenderComplete?: Promise<void>; // Invoked once the render is complete. Currently, it seems to be used as image loading success/error callback...
strTitle?: string;
}
export interface ShowModalResult {
// This method will not invoke any of the variations of "closeModal" callbacks!
Close: () => void;
// This method will replace the modal element completely and will not update the callback chains,
// meaning that "closeModal" and etc. will not automatically close the modal anymore (also "fnOnClose"
// will not be even called upon close anymore)! You have to manually call the "Close" method when, for example,
// the "closeModal" is invoked in the newly updated modal:
// <ModalRoot closeModal={() => { console.log("ABOUT TO CLOSE"); showModalRes.Close(); }} />
Update: (modal: ReactNode) => void;
}
const showModalRaw:
| ((
modal: ReactNode,
parent?: EventTarget,
title?: string,
props?: ShowModalProps,
unknown1?: unknown,
hideActions?: { bHideActions?: boolean },
modalManager?: unknown,
) => ShowModalResult)
| void = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (
typeof m[prop] === 'function' &&
m[prop].toString().includes('props.bDisableBackgroundDismiss') &&
!m[prop]?.prototype?.Cancel
) {
return m[prop];
}
}
});
const oldShowModalRaw: ((modal: ReactNode, parent?: EventTarget, props?: ShowModalProps) => ShowModalResult) | void =
findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop] === 'function' && m[prop].toString().includes('bHideMainWindowForPopouts:!0')) {
return m[prop];
}
}
});
export const showModal = (
modal: ReactNode,
parent?: EventTarget,
props: ShowModalProps = {
strTitle: 'Decky Dialog',
bHideMainWindowForPopouts: false,
},
): ShowModalResult => {
if (showModalRaw) {
return showModalRaw(modal, parent || findSP(), props.strTitle, props, undefined, {
bHideActions: props.bHideActionIcons,
});
} else if (oldShowModalRaw) {
return oldShowModalRaw(modal, parent || findSP(), props);
} else {
throw new Error('[DFL:Modals]: Cannot find showModal function');
}
};
export interface ModalRootProps {
children?: ReactNode;
onCancel?(): void;
closeModal?(): void;
onOK?(): void;
onEscKeypress?(): void;
className?: string;
modalClassName?: string;
bAllowFullSize?: boolean;
bDestructiveWarning?: boolean;
bDisableBackgroundDismiss?: boolean;
bHideCloseIcon?: boolean;
bOKDisabled?: boolean;
bCancelDisabled?: boolean;
}
export interface ConfirmModalProps extends ModalRootProps {
onMiddleButton?(): void; // setting this prop will enable the middle button
strTitle?: ReactNode;
strDescription?: ReactNode;
strOKButtonText?: ReactNode;
strCancelButtonText?: ReactNode;
strMiddleButtonText?: ReactNode;
bAlertDialog?: boolean; // This will open a modal with only OK button enabled
bMiddleDisabled?: boolean;
}
export const ConfirmModal = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (!m[prop]?.prototype?.OK && m[prop]?.prototype?.Cancel && m[prop]?.prototype?.render) {
return m[prop];
}
}
}) as FC<ConfirmModalProps>;
// new as of december 2022 on beta
export const ModalRoot = (Object.values(
findModule((m: any) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.m_mapModalManager && Object.values(m)?.find((x: any) => x?.type)) {
return true;
}
}
return false;
}) || {},
)?.find((x: any) => x?.type?.toString()?.includes('((function(){')) ||
// before december 2022 beta
Object.values(
findModule((m: any) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.toString()?.includes('"ModalManager","DialogWrapper"')) {
return true;
}
}
return false;
}) || {},
)?.find((x: any) => x?.type?.toString()?.includes('((function(){')) ||
// old
findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.prototype?.OK && m[prop]?.prototype?.Cancel && m[prop]?.prototype?.render) {
return m[prop];
}
}
})) as FC<ModalRootProps>;
interface SimpleModalProps {
active?: boolean;
children: ReactNode;
}
const ModalModule = findModule((mod: any) => {
if (typeof mod !== 'object') return false;
for (let prop in mod) {
if (Object.keys(mod).length > 4 && mod[prop]?.toString().includes('.ModalPosition,fallback:')) return true;
}
return false;
});
const ModalModuleProps = ModalModule ? Object.values(ModalModule) : [];
export const SimpleModal = ModalModuleProps.find(prop => {
const string = prop?.toString()
return string?.includes(".ShowPortalModal()") && string?.includes(".OnElementReadyCallbacks.Register(")
}) as FC<SimpleModalProps>;
export const ModalPosition = ModalModuleProps.find(prop => prop?.toString().includes(".ModalPosition,fallback:")) as FC<SimpleModalProps>;

View File

@@ -1,33 +0,0 @@
import { FC, ReactNode } from 'react';
import { findModuleChild } from '../webpack';
export const Panel: FC<{ children?: ReactNode; }> = findModuleChild((mod) => {
if (typeof mod !== 'object' || !mod.__esModule) return undefined;
return mod.Panel;
})
export interface PanelSectionProps {
title?: string;
spinner?: boolean;
children?: ReactNode;
}
const [panelSection, mod] = findModuleChild((mod: any) => {
for (let prop in mod) {
if (mod[prop]?.toString()?.includes('.PanelSection')) {
return [mod[prop], mod];
}
}
return null;
});
export const PanelSection = panelSection as FC<PanelSectionProps>;
export interface PanelSectionRowProps {
children?: ReactNode;
}
// New as of Feb 22 2023 Beta || Old
export const PanelSectionRow =
(mod.PanelSectionRow ||
Object.values(mod).filter((exp: any) => !exp?.toString()?.includes('.PanelSection'))[0]) as FC<PanelSectionRowProps>;

View File

@@ -1,44 +0,0 @@
import { ReactNode, VFC } from 'react';
import { findModuleChild } from '../webpack';
import { ItemProps } from './Item';
export interface ProgressBarItemProps extends ItemProps {
indeterminate?: boolean;
nTransitionSec?: number;
nProgress?: number;
focusable?: boolean;
}
export interface ProgressBarProps {
indeterminate?: boolean;
nTransitionSec?: number;
nProgress?: number;
focusable?: boolean;
}
export interface ProgressBarWithInfoProps extends ProgressBarItemProps {
sTimeRemaining?: ReactNode;
sOperationText?: ReactNode;
}
export const ProgressBar = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.toString()?.includes('.ProgressBar,"standard"==')) return m[prop];
}
}) as VFC<ProgressBarProps>;
export const ProgressBarWithInfo = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.toString()?.includes('.ProgressBarFieldStatus},')) return m[prop];
}
}) as VFC<ProgressBarWithInfoProps>;
export const ProgressBarItem = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.toString()?.includes('"indeterminate","nTransitionSec"')) return m[prop];
}
}) as VFC<ProgressBarItemProps>;

View File

@@ -1,22 +0,0 @@
import { FC, ReactNode } from 'react';
import { findModule, findModuleChild } from '../webpack';
const ScrollingModule = findModule((mod) => {
if (typeof mod !== 'object') return false;
for (let prop in mod) {
if (mod[prop]?.render?.toString?.().includes("{case\"x\":")) return true;
}
return false;
});
const ScrollingModuleProps = ScrollingModule ? Object.values(ScrollingModule) : [];
export const ScrollPanel = ScrollingModuleProps.find((prop: any) => prop?.render?.toString?.().includes("{case\"x\":")) as FC<{ children?: ReactNode }>;
export const ScrollPanelGroup: FC<{ children?: ReactNode }> = findModuleChild((mod) => {
if (typeof mod !== 'object') return undefined;
for (let prop in mod) {
if (mod[prop]?.render?.toString().includes(".FocusVisibleChild()),[])")) return mod[prop];
}
});

View File

@@ -1,13 +0,0 @@
import { FC, SVGAttributes } from 'react';
import { findModuleChild } from '../webpack';
export const SteamSpinner = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (
m[prop]?.toString?.()?.includes('Steam Spinner') && m[prop]?.toString?.()?.includes('src')
)
return m[prop];
}
}) as FC<SVGAttributes<SVGElement>>;

View File

@@ -1,3 +1,7 @@
declare global {
var SteamClient: SteamClient;
}
export interface Apps {
RegisterForAppOverviewChanges: any;
RegisterForAppDetails: any;

2
src/globals/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./SteamClient";
export * from "./stores";

34
src/deck-components/index.ts → src/globals/stores.ts Executable file → Normal file
View File

@@ -1,37 +1,5 @@
export * from './Button';
export * from './ButtonItem';
export * from './Carousel';
export * from './ControlsList';
export * from './Dialog';
export * from './DialogCheckbox';
export * from './Dropdown';
export * from './Field';
export * from './Focusable';
export * from './FocusRing';
export * from './FooterLegend';
export * from './Marquee';
export * from './Menu';
export * from './Modal';
export * from './Panel';
export * from './ProgressBar';
export * from './Router';
export * from './SidebarNavigation';
export * from './SliderField';
export * from './Spinner';
export * from './static-classes';
export * from './SteamSpinner';
export * from './Tabs';
export * from './TextField';
export * from './Toggle';
export * from './ToggleField';
export * from './SteamClient';
export * from './Scroll';
import { AppDetails, LogoPosition, SteamAppOverview, SteamClient } from './SteamClient';
import { AppDetails, LogoPosition, SteamAppOverview } from './SteamClient';
declare global {
var SteamClient: SteamClient;
interface Window {
LocalizationManager: {
m_mapTokens: Map<string, string>;

View File

@@ -1,8 +1,10 @@
// export * from './deck-libs';
export * from './custom-components';
export * from './custom-hooks';
export * from './deck-components';
export * from './components';
export * from './deck-hooks';
export * from './modules';
export * from './globals';
export * from './plugin';
export * from './webpack';
export * from './utils';

94
src/logger.ts Normal file
View File

@@ -0,0 +1,94 @@
const bgStyle1 = 'background: #16a085; color: black;';
export const log = (name: string, ...args: any[]) => {
console.log(
`%c @decky/ui %c ${name} %c`,
bgStyle1,
'background: #1abc9c; color: black;',
'background: transparent;',
...args,
);
};
export const group = (name: string, ...args: any[]) => {
console.group(
`%c @decky/ui %c ${name} %c`,
bgStyle1,
'background: #1abc9c; color: black;',
'background: transparent;',
...args,
);
};
export const groupEnd = (name: string, ...args: any[]) => {
console.groupEnd();
if (args?.length > 0) console.log(
`^ %c @decky/ui %c ${name} %c`,
bgStyle1,
'background: #1abc9c; color: black;',
'background: transparent;',
...args,
);
};
export const debug = (name: string, ...args: any[]) => {
console.debug(
`%c @decky/ui %c ${name} %c`,
bgStyle1,
'background: #1abc9c; color: black;',
'color: blue;',
...args,
);
};
export const warn = (name: string, ...args: any[]) => {
console.warn(
`%c @decky/ui %c ${name} %c`,
bgStyle1,
'background: #ffbb00; color: black;',
'color: blue;',
...args,
);
};
export const error = (name: string, ...args: any[]) => {
console.error(
`%c @decky/ui %c ${name} %c`,
bgStyle1,
'background: #FF0000;',
'background: transparent;',
...args,
);
};
class Logger {
constructor(private name: string) {
this.name = name;
}
log(...args: any[]) {
log(this.name, ...args);
}
debug(...args: any[]) {
debug(this.name, ...args);
}
warn(...args: any[]) {
warn(this.name, ...args);
}
error(...args: any[]) {
error(this.name, ...args);
}
group(...args: any[]) {
group(this.name, ...args);
}
groupEnd(...args: any[]) {
groupEnd(this.name, ...args);
}
}
export default Logger;

View File

@@ -1,5 +1,5 @@
import { sleep } from '../utils';
import { Module, findModuleChild } from '../webpack';
import { Export, findModuleExport } from '../webpack';
export enum SideMenu {
None,
@@ -101,12 +101,7 @@ export interface Router {
get MainRunningApp(): AppOverview | undefined;
}
export const Router = findModuleChild((m: Module) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.Navigate && m[prop]?.NavigationManager) return m[prop];
}
}) as Router;
export const Router = findModuleExport((e: Export) => e.Navigate && e.NavigationManager) as Router;
export interface Navigation {
Navigate(path: string): void;
@@ -133,14 +128,7 @@ try {
if (!Router.NavigateToAppProperties || (Router as unknown as any).deckyShim) {
function initInternalNavigators() {
try {
InternalNavigators = findModuleChild((m: any) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.GetNavigator && m[prop]?.SetNavigator) {
return m[prop];
}
}
})?.GetNavigator();
InternalNavigators = findModuleExport((e: Export) => e.GetNavigator && e.SetNavigator)?.GetNavigator();
} catch (e) {
console.error('[DFL:Router]: Failed to init internal navigators, trying again');
}

1
src/modules/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './Router';

View File

@@ -1,5 +1,6 @@
export * from './patcher';
export * from './react';
export * from './static-classes';
declare global {
var FocusNavController: any;

View File

@@ -67,15 +67,15 @@ export function wrapReactClass(node: any, prop: any = 'type') {
export function getReactRoot(o: HTMLElement | Element | Node) {
return (
o[Object.keys(o).find((k) => k.startsWith('__reactContainer$')) as string] ||
o['_reactRootContainer']?._internalRoot?.current
// @ts-expect-error 7053
o[Object.keys(o).find((k) => k.startsWith('__reactContainer$')) as string] || o['_reactRootContainer']?._internalRoot?.current
);
}
export function getReactInstance(o: HTMLElement | Element | Node) {
return (
o[Object.keys(o).find((k) => k.startsWith('__reactFiber')) as string] ||
o[Object.keys(o).find((k) => k.startsWith('__reactInternalInstance')) as string]
// @ts-expect-error 7053
o[Object.keys(o).find((k) => k.startsWith('__reactFiber')) as string] || o[Object.keys(o).find((k) => k.startsWith('__reactInternalInstance')) as string]
);
}

View File

@@ -1,65 +1,99 @@
import Logger from "./logger";
declare global {
interface Window {
webpackJsonp: any;
webpackChunksteamui: any;
}
}
// TODO
const logger = new Logger("Webpack");
// In most case an object with getters for each property. Look for the first call to r.d in the module, usually near or at the top.
export type Module = any;
export type Export = any;
type FilterFn = (module: any) => boolean;
type ExportFilterFn = (moduleExport: any, exportName?: any) => boolean;
type FindFn = (module: any) => any;
export let webpackCache: any = {};
let hasWebpack5 = false;
export let modules: any = [];
if (window.webpackJsonp && !window.webpackJsonp.deckyShimmed) {
// Webpack 4, currently on stable
const wpRequire = window.webpackJsonp.push([
[],
{ get_require: (mod: any, _exports: any, wpRequire: any) => (mod.exports = wpRequire) },
[['get_require']],
]);
delete wpRequire.m.get_require;
delete wpRequire.c.get_require;
webpackCache = wpRequire.c;
} else {
function initModuleCache() {
const startTime = performance.now();
logger.group("Webpack Module Init");
// Webpack 5, currently on beta
hasWebpack5 = true;
const id = Math.random();
let initReq: any;
// Generate a fake module ID
const id = Math.random(); // really should be an int and not a float but who cares
let webpackRequire!: ((id: any) => Module) & {m: object};
// Insert our module in a new chunk.
// The module will then be called with webpack's internal require function as its first argument
window.webpackChunksteamui.push([
[id],
{},
(r: any) => {
initReq = r;
webpackRequire = r;
},
]);
for (let i of Object.keys(initReq.m)) {
logger.log("Initializing all modules. Errors here likely do not matter, as they are usually just failing module side effects.");
// Loop over every module ID
for (let i of Object.keys(webpackRequire.m)) {
try {
webpackCache[i] = initReq(i);
const module = webpackRequire(i);
if (module) {
modules.push(module);
}
} catch (e) {
console.debug("[DFL:Webpack]: Ignoring require error for module", i, e);
logger.debug("Ignoring require error for module", i, e);
}
}
logger.groupEnd(`Modules initialized in ${performance.now() - startTime}ms...`);
}
export const allModules: Module[] = hasWebpack5
? Object.values(webpackCache).filter((x) => x)
: Object.keys(webpackCache)
.map((x) => webpackCache[x].exports)
.filter((x) => x);
initModuleCache();
export const findModule = (filter: FilterFn) => {
for (const m of allModules) {
for (const m of modules) {
if (m.default && filter(m.default)) return m.default;
if (filter(m)) return m;
}
};
export const findModuleDetailsByExport = (filter: ExportFilterFn, minExports?: number): [module: Module | undefined, moduleExport: any, exportName: any] => {
for (const m of modules) {
if (!m) continue;
for (const mod of [m.default, m]) {
if (typeof mod !== 'object') continue;
if (minExports && Object.keys(mod).length < minExports) continue;
for (let exportName in mod) {
if (mod?.[exportName]) {
const filterRes = filter(mod[exportName], exportName);
if (filterRes) {
return [mod, mod[exportName], exportName];
} else {
continue;
}
}
}
}
}
return [undefined, undefined, undefined];
}
export const findModuleByExport = (filter: ExportFilterFn, minExports?: number) => {
return findModuleDetailsByExport(filter, minExports)?.[0];
}
export const findModuleExport = (filter: ExportFilterFn, minExports?: number) => {
return findModuleDetailsByExport(filter, minExports)?.[1];
}
/**
* @deprecated use findModuleExport instead
*/
export const findModuleChild = (filter: FindFn) => {
for (const m of allModules) {
for (const m of modules) {
for (const mod of [m.default, m]) {
const filterRes = filter(mod);
if (filterRes) {
@@ -74,7 +108,7 @@ export const findModuleChild = (filter: FindFn) => {
export const findAllModules = (filter: FilterFn) => {
const out = [];
for (const m of allModules) {
for (const m of modules) {
if (m.default && filter(m.default)) out.push(m.default);
if (filter(m)) out.push(m);
}
@@ -82,7 +116,7 @@ export const findAllModules = (filter: FilterFn) => {
return out;
};
export const CommonUIModule = allModules.find((m: Module) => {
export const CommonUIModule = modules.find((m: Module) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.contextType?._currentValue && Object.keys(m).length > 60) return true;
@@ -90,18 +124,6 @@ export const CommonUIModule = allModules.find((m: Module) => {
return false;
});
export const IconsModule = findModule((m: Module) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.toString && /Spinner\)}\),.\.createElement\(\"path\",{d:\"M18 /.test(m[prop].toString())) return true;
}
return false;
});
export const IconsModule = findModuleByExport(e => e?.toString && /Spinner\)}\),.\.createElement\(\"path\",{d:\"M18 /.test(e.toString()));
export const ReactRouter = allModules.find((m: Module) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.computeRootMatch) return true;
}
return false;
});
export const ReactRouter = findModuleByExport(e => e.computeRootMatch);

View File

@@ -14,7 +14,7 @@
"noImplicitThis": true,
"noImplicitAny": true,
"strict": true,
"suppressImplicitAnyIndexErrors": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
},