summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/Markdown.tsx39
-rw-r--r--frontend/src/components/PluginView.tsx4
-rw-r--r--frontend/src/components/WithSuspense.tsx38
-rw-r--r--frontend/src/components/modals/PluginInstallModal.tsx7
-rw-r--r--frontend/src/components/modals/filepicker/iconCustomizations.ts170
-rw-r--r--frontend/src/components/modals/filepicker/index.tsx160
-rw-r--r--frontend/src/components/modals/filepicker/patches/README.md1
-rw-r--r--frontend/src/components/modals/filepicker/patches/index.ts10
-rw-r--r--frontend/src/components/modals/filepicker/patches/library.ts57
-rw-r--r--frontend/src/components/settings/pages/general/BranchSelect.tsx5
-rw-r--r--frontend/src/components/settings/pages/general/Updater.tsx86
-rw-r--r--frontend/src/components/store/PluginCard.tsx18
-rw-r--r--frontend/src/components/store/Store.tsx7
-rw-r--r--frontend/src/logger.ts6
-rw-r--r--frontend/src/plugin-loader.tsx147
-rw-r--r--frontend/src/store.tsx6
-rw-r--r--frontend/src/tabs-hook.ts34
-rw-r--r--frontend/src/toaster.tsx48
-rw-r--r--frontend/src/updater.ts4
19 files changed, 723 insertions, 124 deletions
diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx
index 7b187f14..278e49cd 100644
--- a/frontend/src/components/Markdown.tsx
+++ b/frontend/src/components/Markdown.tsx
@@ -1,9 +1,42 @@
-import { FunctionComponent } from 'react';
+import { Focusable } from 'decky-frontend-lib';
+import { FunctionComponent, useRef } from 'react';
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
-const Markdown: FunctionComponent<ReactMarkdownOptions> = (props) => {
- return <ReactMarkdown remarkPlugins={[remarkGfm]} {...props} />;
+interface MarkdownProps extends ReactMarkdownOptions {
+ onDismiss?: () => void;
+}
+
+const Markdown: FunctionComponent<MarkdownProps> = (props) => {
+ return (
+ <Focusable>
+ <ReactMarkdown
+ remarkPlugins={[remarkGfm]}
+ components={{
+ div: (nodeProps) => <Focusable {...nodeProps.node.properties}>{nodeProps.children}</Focusable>,
+ a: (nodeProps) => {
+ const aRef = useRef<HTMLAnchorElement>(null);
+ return (
+ // TODO fix focus ring
+ <Focusable
+ onActivate={() => {}}
+ onOKButton={() => {
+ aRef?.current?.click();
+ props.onDismiss?.();
+ }}
+ style={{ display: 'inline' }}
+ >
+ <a ref={aRef} {...nodeProps.node.properties}>
+ {nodeProps.children}
+ </a>
+ </Focusable>
+ );
+ },
+ }}
+ {...props}
+ />
+ </Focusable>
+ );
};
export default Markdown;
diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx
index 67a203c9..0d0a52cf 100644
--- a/frontend/src/components/PluginView.tsx
+++ b/frontend/src/components/PluginView.tsx
@@ -32,8 +32,8 @@ const PluginView: VFC = () => {
.map(({ name, icon }) => (
<PanelSectionRow key={name}>
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
- <div style={{ display: 'flex', justifyContent: 'space-between' }}>
- <div>{icon}</div>
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+ {icon}
<div>{name}</div>
<NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
</div>
diff --git a/frontend/src/components/WithSuspense.tsx b/frontend/src/components/WithSuspense.tsx
new file mode 100644
index 00000000..402f5e5b
--- /dev/null
+++ b/frontend/src/components/WithSuspense.tsx
@@ -0,0 +1,38 @@
+import { Focusable, SteamSpinner } from 'decky-frontend-lib';
+import { FunctionComponent, ReactElement, ReactNode, Suspense } from 'react';
+
+interface WithSuspenseProps {
+ children: ReactNode;
+ route?: boolean;
+}
+
+// Nice little wrapper around Suspense so we don't have to duplicate the styles and code for the loading spinner
+const WithSuspense: FunctionComponent<WithSuspenseProps> = (props) => {
+ const propsCopy = { ...props };
+ delete propsCopy.children;
+ (props.children as ReactElement)?.props && Object.assign((props.children as ReactElement).props, propsCopy); // There is probably a better way to do this but valve does it this way so ¯\_(ツ)_/¯
+ return (
+ <Suspense
+ fallback={
+ <Focusable
+ // needed to enable focus ring so that the focus properly resets on load
+ onActivate={() => {}}
+ style={{
+ overflowY: 'scroll',
+ backgroundColor: 'transparent',
+ ...(props.route && {
+ marginTop: '40px',
+ height: 'calc( 100% - 40px )',
+ }),
+ }}
+ >
+ <SteamSpinner />
+ </Focusable>
+ }
+ >
+ {props.children}
+ </Suspense>
+ );
+};
+
+export default WithSuspense;
diff --git a/frontend/src/components/modals/PluginInstallModal.tsx b/frontend/src/components/modals/PluginInstallModal.tsx
index 2c0c0bba..8b927523 100644
--- a/frontend/src/components/modals/PluginInstallModal.tsx
+++ b/frontend/src/components/modals/PluginInstallModal.tsx
@@ -1,4 +1,4 @@
-import { ModalRoot, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib';
+import { ConfirmModal, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib';
import { FC, useState } from 'react';
interface PluginInstallModalProps {
@@ -14,13 +14,14 @@ interface PluginInstallModalProps {
const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, hash, onOK, onCancel, closeModal }) => {
const [loading, setLoading] = useState<boolean>(false);
return (
- <ModalRoot
+ <ConfirmModal
bOKDisabled={loading}
closeModal={closeModal}
onOK={async () => {
setLoading(true);
await onOK();
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
+ setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
}}
onCancel={async () => {
await onCancel();
@@ -34,7 +35,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha
{!loading && '?'}
</div>
</div>
- </ModalRoot>
+ </ConfirmModal>
);
};
diff --git a/frontend/src/components/modals/filepicker/iconCustomizations.ts b/frontend/src/components/modals/filepicker/iconCustomizations.ts
new file mode 100644
index 00000000..e09c9e67
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/iconCustomizations.ts
@@ -0,0 +1,170 @@
+// https://codesandbox.io/s/react-file-icon-colored-tmwut?file=/src/App.js
+import { FileIconProps } from 'react-file-icon';
+
+type T_FileExtList = string[];
+
+const styleDef: [FileIconProps, T_FileExtList][] = [];
+
+// video ////////////////////////////////////
+const videoStyle = {
+ color: '#f00f0f',
+};
+const videoExtList = [
+ 'avi',
+ '3g2',
+ '3gp',
+ 'aep',
+ 'asf',
+ 'flv',
+ 'm4v',
+ 'mkv',
+ 'mov',
+ 'mp4',
+ 'mpeg',
+ 'mpg',
+ 'ogv',
+ 'pr',
+ 'swfw',
+ 'webm',
+ 'wmv',
+ 'swf',
+ 'rm',
+];
+
+styleDef.push([videoStyle, videoExtList]);
+
+// image ////////////////////////////////////
+const imageStyle = {
+ color: '#d18f00',
+};
+
+const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff'];
+
+styleDef.push([imageStyle, imageExtList]);
+
+// zip ////////////////////////////////////
+const zipStyle = {
+ color: '#f7b500',
+ labelTextColor: '#000',
+ // glyphColor: "#de9400"
+};
+
+const zipExtList = ['zip', 'zipx', '7zip', 'tar', 'sitx', 'gz', 'rar'];
+
+styleDef.push([zipStyle, zipExtList]);
+
+// audio ////////////////////////////////////
+const audioStyle = {
+ color: '#f00f0f',
+};
+
+const audioExtList = ['aac', 'aif', 'aiff', 'flac', 'm4a', 'mid', 'mp3', 'ogg', 'wav'];
+
+styleDef.push([audioStyle, audioExtList]);
+
+// text ////////////////////////////////////
+const textStyle = {
+ color: '#ffffff',
+ glyphColor: '#787878',
+};
+
+const textExtList = ['cue', 'odt', 'md', 'rtf', 'txt', 'tex', 'wpd', 'wps', 'xlr', 'fodt'];
+
+styleDef.push([textStyle, textExtList]);
+
+// system ////////////////////////////////////
+const systemStyle = {
+ color: '#111',
+};
+
+const systemExtList = ['exe', 'ini', 'dll', 'plist', 'sys'];
+
+styleDef.push([systemStyle, systemExtList]);
+
+// srcCode ////////////////////////////////////
+const srcCodeStyle = {
+ glyphColor: '#787878',
+ color: '#ffffff',
+};
+
+const srcCodeExtList = [
+ 'asp',
+ 'aspx',
+ 'c',
+ 'cpp',
+ 'cs',
+ 'css',
+ 'scss',
+ 'py',
+ 'json',
+ 'htm',
+ 'html',
+ 'java',
+ 'yml',
+ 'php',
+ 'js',
+ 'ts',
+ 'rb',
+ 'jsx',
+ 'tsx',
+];
+
+styleDef.push([srcCodeStyle, srcCodeExtList]);
+
+// vector ////////////////////////////////////
+const vectorStyle = {
+ color: '#ffe600',
+};
+
+const vectorExtList = ['dwg', 'dxf', 'ps', 'svg', 'eps'];
+
+styleDef.push([vectorStyle, vectorExtList]);
+
+// font ////////////////////////////////////
+const fontStyle = {
+ color: '#555',
+};
+
+const fontExtList = ['fnt', 'ttf', 'otf', 'fon', 'eot', 'woff'];
+
+styleDef.push([fontStyle, fontExtList]);
+
+// objectModel ////////////////////////////////////
+const objectModelStyle = {
+ color: '#bf6a02',
+ glyphColor: '#bf6a02',
+};
+
+const objectModelExtList = ['3dm', '3ds', 'max', 'obj', 'pkg'];
+
+styleDef.push([objectModelStyle, objectModelExtList]);
+
+// sheet ////////////////////////////////////
+const sheetStyle = {
+ color: '#2a6e00',
+};
+
+const sheetExtList = ['csv', 'fods', 'ods', 'xlr'];
+
+styleDef.push([sheetStyle, sheetExtList]);
+
+// const defaultStyle: Record<string, FileIconProps> = {
+// pdf: {
+// glyphColor: "white",
+// color: "#D93831"
+// }
+// };
+
+//////////////////////////////////////////////////
+
+function createStyleObj(extList: T_FileExtList, styleObj: Partial<FileIconProps>) {
+ return Object.fromEntries(
+ extList.map((ext) => {
+ return [ext, { ...styleObj, glyphColor: 'white' }];
+ }),
+ );
+}
+
+export const styleDefObj = styleDef.reduce((acc, [fileStyle, fileExtList]) => {
+ return { ...acc, ...createStyleObj(fileExtList, fileStyle) };
+});
diff --git a/frontend/src/components/modals/filepicker/index.tsx b/frontend/src/components/modals/filepicker/index.tsx
new file mode 100644
index 00000000..dcf179a3
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/index.tsx
@@ -0,0 +1,160 @@
+import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib';
+import { useEffect } from 'react';
+import { FunctionComponent, useState } from 'react';
+import { FileIcon, defaultStyles } from 'react-file-icon';
+import { FaArrowUp, FaFolder } from 'react-icons/fa';
+
+import Logger from '../../../logger';
+import { styleDefObj } from './iconCustomizations';
+
+const logger = new Logger('FilePicker');
+
+export interface FilePickerProps {
+ startPath: string;
+ includeFiles?: boolean;
+ regex?: RegExp;
+ onSubmit: (val: { path: string; realpath: string }) => void;
+ closeModal?: () => void;
+}
+
+interface File {
+ isdir: boolean;
+ name: string;
+ realpath: string;
+}
+
+interface FileListing {
+ realpath: string;
+ files: File[];
+}
+
+function getList(
+ path: string,
+ includeFiles: boolean = true,
+): Promise<{ result: FileListing | string; success: boolean }> {
+ return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles });
+}
+
+const iconStyles = {
+ paddingRight: '10px',
+ width: '1em',
+};
+
+const FilePicker: FunctionComponent<FilePickerProps> = ({
+ startPath,
+ includeFiles = true,
+ regex,
+ onSubmit,
+ closeModal,
+}) => {
+ if (startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
+ const [path, setPath] = useState<string>(startPath);
+ const [listing, setListing] = useState<FileListing>({ files: [], realpath: path });
+ const [error, setError] = useState<string | null>(null);
+ const [loading, setLoading] = useState<boolean>(true);
+
+ useEffect(() => {
+ (async () => {
+ if (error) setError(null);
+ setLoading(true);
+ const listing = await getList(path, includeFiles);
+ if (!listing.success) {
+ setListing({ files: [], realpath: path });
+ setLoading(false);
+ setError(listing.result as string);
+ logger.error(listing.result);
+ return;
+ }
+ setLoading(false);
+ setListing(listing.result as FileListing);
+ logger.log('reloaded', path, listing);
+ })();
+ }, [path]);
+
+ return (
+ <div className="deckyFilePicker">
+ <Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}>
+ <DialogButton
+ style={{
+ minWidth: 'unset',
+ width: '40px',
+ flexGrow: '0',
+ borderRadius: 'unset',
+ margin: '0',
+ padding: '10px',
+ }}
+ onClick={() => {
+ const newPathArr = path.split('/');
+ newPathArr.pop();
+ let newPath = newPathArr.join('/');
+ if (newPath == '') newPath = '/';
+ setPath(newPath);
+ }}
+ >
+ <FaArrowUp />
+ </DialogButton>
+ <div style={{ flexGrow: '1', width: '100%' }}>
+ <TextField
+ value={path}
+ onChange={(e) => {
+ e.target.value && setPath(e.target.value);
+ }}
+ style={{ height: '100%' }}
+ />
+ </div>
+ </Focusable>
+ <Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}>
+ {loading && <SteamSpinner style={{ height: '100%' }} />}
+ {!loading &&
+ listing.files
+ .filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name)))
+ .map((file) => {
+ let extension = file.realpath.split('.').pop() as string;
+ return (
+ <DialogButton
+ style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
+ onClick={() => {
+ const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
+ if (file.isdir) setPath(fullPath);
+ else {
+ onSubmit({ path: fullPath, realpath: file.realpath });
+ closeModal?.();
+ }
+ }}
+ >
+ <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
+ {file.isdir ? (
+ <FaFolder style={iconStyles} />
+ ) : (
+ <div style={iconStyles}>
+ {file.realpath.includes('.') ? (
+ <FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
+ ) : (
+ <FileIcon />
+ )}
+ </div>
+ )}
+ {file.name}
+ </div>
+ </DialogButton>
+ );
+ })}
+ {error}
+ </Focusable>
+ {!loading && !error && !includeFiles && (
+ <DialogButton
+ className="Primary"
+ style={{ marginTop: '10px', alignSelf: 'flex-end' }}
+ onClick={() => {
+ onSubmit({ path, realpath: listing.realpath });
+ closeModal?.();
+ }}
+ >
+ Use this folder
+ </DialogButton>
+ )}
+ </div>
+ );
+};
+
+export default FilePicker;
diff --git a/frontend/src/components/modals/filepicker/patches/README.md b/frontend/src/components/modals/filepicker/patches/README.md
new file mode 100644
index 00000000..154914c5
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/patches/README.md
@@ -0,0 +1 @@
+This directory contains patches that replace Valve's broken file picker with ours.
diff --git a/frontend/src/components/modals/filepicker/patches/index.ts b/frontend/src/components/modals/filepicker/patches/index.ts
new file mode 100644
index 00000000..310bfbf8
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/patches/index.ts
@@ -0,0 +1,10 @@
+import library from './library';
+let patches: Function[] = [];
+
+export function deinitFilepickerPatches() {
+ patches.forEach((unpatch) => unpatch());
+}
+
+export async function initFilepickerPatches() {
+ patches.push(await library());
+}
diff --git a/frontend/src/components/modals/filepicker/patches/library.ts b/frontend/src/components/modals/filepicker/patches/library.ts
new file mode 100644
index 00000000..c9c7d53c
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/patches/library.ts
@@ -0,0 +1,57 @@
+import { Patch, findModuleChild, replacePatch } from 'decky-frontend-lib';
+
+declare global {
+ interface Window {
+ SteamClient: any;
+ appDetailsStore: any;
+ }
+}
+
+let patch: Patch;
+
+function rePatch() {
+ // If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur within the first 20s of the last patch
+ patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => {
+ try {
+ const details = window.appDetailsStore.GetAppDetails(appid);
+ console.log(details);
+ // strShortcutStartDir
+ const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', ''));
+ console.log('user selected', file);
+ window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path));
+ const pathArr = file.path.split('/');
+ pathArr.pop();
+ const folder = pathArr.join('/');
+ window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder));
+ } catch (e) {
+ console.error(e);
+ }
+ });
+}
+
+// TODO type and add to frontend-lib
+const History = findModuleChild((m) => {
+ if (typeof m !== 'object') return undefined;
+ for (let prop in m) {
+ if (m[prop]?.m_history) return m[prop].m_history;
+ }
+});
+
+export default async function libraryPatch() {
+ try {
+ rePatch();
+ const unlisten = History.listen(() => {
+ if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) {
+ rePatch();
+ }
+ });
+
+ return () => {
+ patch.unpatch();
+ unlisten();
+ };
+ } catch (e) {
+ console.error('Error patching library file picker', e);
+ }
+ return () => {};
+}
diff --git a/frontend/src/components/settings/pages/general/BranchSelect.tsx b/frontend/src/components/settings/pages/general/BranchSelect.tsx
index d803f604..154bff9c 100644
--- a/frontend/src/components/settings/pages/general/BranchSelect.tsx
+++ b/frontend/src/components/settings/pages/general/BranchSelect.tsx
@@ -1,9 +1,12 @@
import { Dropdown, Field } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
+import Logger from '../../../../logger';
import { callUpdaterMethod } from '../../../../updater';
import { useSetting } from '../../../../utils/hooks/useSetting';
+const logger = new Logger('BranchSelect');
+
enum UpdateBranch {
Stable,
Prerelease,
@@ -28,7 +31,7 @@ const BranchSelect: FunctionComponent<{}> = () => {
onChange={async (newVal) => {
await setSelectedBranch(newVal.data);
callUpdaterMethod('check_for_updates');
- console.log('switching branches!');
+ logger.log('switching branches!');
}}
/>
</Field>
diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx
index 7056ed13..b4ea8536 100644
--- a/frontend/src/components/settings/pages/general/Updater.tsx
+++ b/frontend/src/components/settings/pages/general/Updater.tsx
@@ -1,4 +1,13 @@
-import { Carousel, DialogButton, Field, Focusable, ProgressBarWithInfo, Spinner, showModal } from 'decky-frontend-lib';
+import {
+ Carousel,
+ DialogButton,
+ Field,
+ FocusRing,
+ Focusable,
+ ProgressBarWithInfo,
+ Spinner,
+ showModal,
+} from 'decky-frontend-lib';
import { useCallback } from 'react';
import { Suspense, lazy } from 'react';
import { useEffect, useState } from 'react';
@@ -7,49 +16,48 @@ import { FaArrowDown } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
+import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
-// import ReactMarkdown from 'react-markdown'
-// import remarkGfm from 'remark-gfm'
-
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
return (
<Focusable onCancelButton={closeModal}>
- <Carousel
- fnItemRenderer={(id: number) => (
- <Focusable
- onActivate={() => {}}
- style={{
- marginTop: '40px',
- height: 'calc( 100% - 40px )',
- overflowY: 'scroll',
- display: 'flex',
- justifyContent: 'center',
- margin: '40px',
- }}
- >
- <div>
- <h1>{versionInfo?.all?.[id]?.name}</h1>
- {versionInfo?.all?.[id]?.body ? (
- <Suspense fallback={<Spinner style={{ width: '24', height: '24' }} />}>
- <MarkdownRenderer>{versionInfo.all[id].body}</MarkdownRenderer>
- </Suspense>
- ) : (
- 'no patch notes for this version'
- )}
- </div>
- </Focusable>
- )}
- fnGetId={(id) => id}
- nNumItems={versionInfo?.all?.length}
- nHeight={window.innerHeight - 150}
- nItemHeight={window.innerHeight - 200}
- nItemMarginX={0}
- initialColumn={0}
- autoFocus={true}
- fnGetColumnWidth={() => window.innerWidth}
- />
+ <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}</h1>
+ {versionInfo?.all?.[id]?.body ? (
+ <WithSuspense>
+ <MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
+ </WithSuspense>
+ ) : (
+ 'no patch notes for this version'
+ )}
+ </div>
+ </Focusable>
+ )}
+ fnGetId={(id) => id}
+ nNumItems={versionInfo?.all?.length}
+ nHeight={window.innerHeight - 40}
+ nItemHeight={window.innerHeight - 40}
+ nItemMarginX={0}
+ initialColumn={0}
+ autoFocus={true}
+ fnGetColumnWidth={() => window.innerWidth}
+ />
+ </FocusRing>
</Focusable>
);
}
@@ -126,7 +134,7 @@ export default function UpdaterSettings() {
) : (
<ProgressBarWithInfo
layout="inline"
- bottomSeparator={false}
+ bottomSeparator="none"
nProgress={updateProgress}
indeterminate={reloading}
sOperationText={reloading ? 'Reloading' : 'Updating'}
diff --git a/frontend/src/components/store/PluginCard.tsx b/frontend/src/components/store/PluginCard.tsx
index a6e9458a..0155ff99 100644
--- a/frontend/src/components/store/PluginCard.tsx
+++ b/frontend/src/components/store/PluginCard.tsx
@@ -113,8 +113,22 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}}
className="deckyStoreCardInfo"
>
- <p className={joinClassNames(staticClasses.PanelSectionRow)}>
- <span>Author: {plugin.author}</span>
+ <p
+ className={joinClassNames(staticClasses.PanelSectionRow)}
+ style={{ marginTop: '0px', marginLeft: '16px' }}
+ >
+ <span style={{ paddingLeft: '0px' }}>Author: {plugin.author}</span>
+ </p>
+ <p
+ className={joinClassNames(staticClasses.PanelSectionRow)}
+ style={{
+ marginLeft: '16px',
+ marginTop: '0px',
+ marginBottom: '0px',
+ marginRight: '16px',
+ }}
+ >
+ <span style={{ paddingLeft: '0px' }}>{plugin.description}</span>
</p>
<p
className={joinClassNames('deckyStoreCardTagsContainer', staticClasses.PanelSectionRow)}
diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx
index fd582edd..cb6f34ad 100644
--- a/frontend/src/components/store/Store.tsx
+++ b/frontend/src/components/store/Store.tsx
@@ -1,9 +1,12 @@
import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
+import Logger from '../../logger';
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store';
import PluginCard from './PluginCard';
+const logger = new Logger('FilePicker');
+
const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null);
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
@@ -11,12 +14,12 @@ const StorePage: FC<{}> = () => {
useEffect(() => {
(async () => {
const res = await getPluginList();
- console.log(res);
+ logger.log('got data!', res);
setData(res);
})();
(async () => {
const res = await getLegacyPluginList();
- console.log(res);
+ logger.log('got legacy data!', res);
setLegacyData(res);
})();
}, []);
diff --git a/frontend/src/logger.ts b/frontend/src/logger.ts
index 22036362..143bef16 100644
--- a/frontend/src/logger.ts
+++ b/frontend/src/logger.ts
@@ -19,7 +19,7 @@ export const debug = (name: string, ...args: any[]) => {
};
export const error = (name: string, ...args: any[]) => {
- console.log(
+ console.error(
`%c Decky %c ${name} %c`,
'background: #16a085; color: black;',
'background: #FF0000;',
@@ -40,6 +40,10 @@ class Logger {
debug(...args: any[]) {
debug(this.name, ...args);
}
+
+ error(...args: any[]) {
+ error(this.name, ...args);
+ }
}
export default Logger;
diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index 4d3415c8..e7fc7031 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -1,13 +1,27 @@
-import { ModalRoot, QuickAccessTab, Router, SteamSpinner, showModal, sleep, staticClasses } from 'decky-frontend-lib';
-import { Suspense, lazy } from 'react';
+import {
+ ConfirmModal,
+ ModalRoot,
+ Patch,
+ QuickAccessTab,
+ Router,
+ callOriginal,
+ findModuleChild,
+ replacePatch,
+ showModal,
+ sleep,
+ staticClasses,
+} from 'decky-frontend-lib';
+import { lazy } from 'react';
import { FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin';
+import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
import PluginInstallModal from './components/modals/PluginInstallModal';
import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView';
import TitleView from './components/TitleView';
+import WithSuspense from './components/WithSuspense';
import Logger from './logger';
import { Plugin } from './plugin';
import RouterHook from './router-hook';
@@ -16,6 +30,11 @@ import TabsHook from './tabs-hook';
import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater';
+const StorePage = lazy(() => import('./components/store/Store'));
+const SettingsPage = lazy(() => import('./components/settings'));
+
+const FilePicker = lazy(() => import('./components/modals/filepicker'));
+
declare global {
interface Window {}
}
@@ -32,11 +51,13 @@ class PluginLoader extends Logger {
// stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: { name: string; version?: string }[] = [];
+ private focusWorkaroundPatch?: Patch;
+
constructor() {
super(PluginLoader.name);
this.log('Initialized');
- const TabIcon = () => {
+ const TabBadge = () => {
const { updates, hasLoaderUpdate } = useDeckyState();
return <NotificationBadge show={(updates && updates.size > 0) || hasLoaderUpdate} />;
};
@@ -53,57 +74,72 @@ class PluginLoader extends Logger {
icon: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<FaPlug />
- <TabIcon />
+ <TabBadge />
</DeckyStateContextProvider>
),
});
- const StorePage = lazy(() => import('./components/store/Store'));
- const SettingsPage = lazy(() => import('./components/settings'));
-
this.routerHook.addRoute('/decky/store', () => (
- <Suspense
- fallback={
- <div
- style={{
- marginTop: '40px',
- height: 'calc( 100% - 40px )',
- overflowY: 'scroll',
- }}
- >
- <SteamSpinner />
- </div>
- }
- >
+ <WithSuspense route={true}>
<StorePage />
- </Suspense>
+ </WithSuspense>
));
this.routerHook.addRoute('/decky/settings', () => {
return (
<DeckyStateContextProvider deckyState={this.deckyState}>
- <Suspense
- fallback={
- <div
- style={{
- marginTop: '40px',
- height: 'calc( 100% - 40px )',
- overflowY: 'scroll',
- }}
- >
- <SteamSpinner />
- </div>
- }
- >
+ <WithSuspense route={true}>
<SettingsPage />
- </Suspense>
+ </WithSuspense>
</DeckyStateContextProvider>
);
});
+
+ initFilepickerPatches();
+
+ this.updateVersion();
+
+ const self = this;
+
+ try {
+ // TODO remove all of this once Valve fixes the bug
+ const focusManager = findModuleChild((m) => {
+ if (typeof m !== 'object') return false;
+ for (let prop in m) {
+ if (m[prop]?.prototype?.TakeFocus) return m[prop];
+ }
+ return false;
+ });
+
+ this.focusWorkaroundPatch = replacePatch(focusManager.prototype, 'TakeFocus', function () {
+ // @ts-ignore
+ const classList = this.m_node?.m_element.classList;
+ if (
+ // @ts-ignore
+ (this.m_node?.m_element && classList.contains(staticClasses.TabGroupPanel)) ||
+ classList.contains('FriendsListTab') ||
+ classList.contains('FriendsTabList') ||
+ classList.contains('FriendsListAndChatsSteamDeck')
+ ) {
+ self.debug('Intercepted friends re-focus');
+ return true;
+ }
+
+ return callOriginal;
+ });
+ } catch (e) {
+ this.error('Friends focus patch failed', e);
+ }
}
- public async notifyUpdates() {
+ public async updateVersion() {
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
this.deckyState.setVersionInfo(versionInfo);
+
+ return versionInfo;
+ }
+
+ public async notifyUpdates() {
+ const versionInfo = await this.updateVersion();
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.toaster.toast({
title: 'Decky',
@@ -147,7 +183,7 @@ class PluginLoader extends Logger {
public uninstallPlugin(name: string) {
showModal(
- <ModalRoot
+ <ConfirmModal
onOK={async () => {
await this.callServerMethod('uninstall_plugin', { name });
}}
@@ -158,7 +194,7 @@ class PluginLoader extends Logger {
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
Uninstall {name}?
</div>
- </ModalRoot>,
+ </ConfirmModal>,
);
}
@@ -176,6 +212,8 @@ class PluginLoader extends Logger {
public deinit() {
this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings');
+ deinitFilepickerPatches();
+ this.focusWorkaroundPatch?.unpatch();
}
public unloadPlugin(name: string) {
@@ -225,7 +263,8 @@ class PluginLoader extends Logger {
},
});
if (res.ok) {
- let plugin = await eval(await res.text())(this.createPluginAPI(name));
+ let plugin_export = await eval(await res.text());
+ let plugin = plugin_export(this.createPluginAPI(name));
this.plugins.push({
...plugin,
name: name,
@@ -257,11 +296,41 @@ class PluginLoader extends Logger {
return response.json();
}
+ openFilePicker(
+ startPath: string,
+ includeFiles?: boolean,
+ regex?: RegExp,
+ ): Promise<{ path: string; realpath: string }> {
+ return new Promise((resolve, reject) => {
+ const Content = ({ closeModal }: { closeModal?: () => void }) => (
+ // Purposely outside of the FilePicker component as lazy-loaded ModalRoots don't focus correctly
+ <ModalRoot
+ onCancel={() => {
+ reject('User canceled');
+ closeModal?.();
+ }}
+ >
+ <WithSuspense>
+ <FilePicker
+ startPath={startPath}
+ includeFiles={includeFiles}
+ regex={regex}
+ onSubmit={resolve}
+ closeModal={closeModal}
+ />
+ </WithSuspense>
+ </ModalRoot>
+ );
+ showModal(<Content />);
+ });
+ }
+
createPluginAPI(pluginName: string) {
return {
routerHook: this.routerHook,
toaster: this.toaster,
callServerMethod: this.callServerMethod,
+ openFilePicker: this.openFilePicker,
async callPluginMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
method: 'POST',
diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx
index 12c8972d..bdaae6f2 100644
--- a/frontend/src/store.tsx
+++ b/frontend/src/store.tsx
@@ -1,4 +1,4 @@
-import { ModalRoot, showModal, staticClasses } from 'decky-frontend-lib';
+import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib';
import { Plugin } from './plugin';
@@ -51,7 +51,7 @@ export async function installFromURL(url: string) {
export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
showModal(
- <ModalRoot
+ <ConfirmModal
onOK={() => {
window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin.artifact,
@@ -70,7 +70,7 @@ export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVe
You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues.
Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the
touchscreen.
- </ModalRoot>,
+ </ConfirmModal>,
);
}
diff --git a/frontend/src/tabs-hook.ts b/frontend/src/tabs-hook.ts
index be413de0..e75e043d 100644
--- a/frontend/src/tabs-hook.ts
+++ b/frontend/src/tabs-hook.ts
@@ -47,18 +47,31 @@ class TabsHook extends Logger {
const self = this;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let scrollRoot: any;
- let currentNode = tree;
+ async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
+ if (iters >= 30) {
+ self.error(
+ 'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
+ );
+ return null;
+ }
+ currentNode = currentNode?.child;
+ if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
+ self.log(`Scroll root was found in ${iters} recursion cycles`);
+ return currentNode;
+ }
+ if (!currentNode) return null;
+ if (currentNode.sibling) {
+ let node = await findScrollRoot(currentNode.sibling, iters + 1);
+ if (node !== null) return node;
+ }
+ return await findScrollRoot(currentNode, iters + 1);
+ }
(async () => {
- let iters = 0;
+ scrollRoot = await findScrollRoot(tree, 0);
while (!scrollRoot) {
- iters++;
- currentNode = currentNode?.child;
- if (iters >= 30 || !currentNode) {
- iters = 0;
- currentNode = tree;
- await sleep(5000);
- }
- if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) scrollRoot = currentNode;
+ this.log('Failed to find scroll root node, reattempting in 5 seconds');
+ await sleep(5000);
+ scrollRoot = await findScrollRoot(tree, 0);
}
let newQA: any;
let newQATabRenderer: any;
@@ -101,6 +114,7 @@ class TabsHook extends Logger {
});
this.cNode = scrollRoot;
this.cNode.stateNode.forceUpdate();
+ this.log('Finished initial injection');
})();
}
diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx
index f42eb3f5..8eea35bc 100644
--- a/frontend/src/toaster.tsx
+++ b/frontend/src/toaster.tsx
@@ -1,4 +1,5 @@
import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
+import { ReactNode } from 'react';
import Toast from './components/Toast';
import Logger from './logger';
@@ -14,6 +15,7 @@ class Toaster extends Logger {
private instanceRetPatch?: Patch;
private node: any;
private settingsModule: any;
+ private ready: boolean = false;
constructor() {
super('Toaster');
@@ -24,14 +26,8 @@ class Toaster extends Logger {
}
async init() {
- this.settingsModule = findModuleChild((m) => {
- if (typeof m !== 'object') return undefined;
- for (let prop in m) {
- if (typeof m[prop]?.settings?.bDisableToastsInGame !== 'undefined') return m[prop];
- }
- });
-
let instance: any;
+
while (true) {
instance = findInReactTree(
(document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
@@ -43,13 +39,26 @@ class Toaster extends Logger {
}
this.node = instance.return.return;
+ let toast: any;
+ let renderedToast: ReactNode = null;
this.node.stateNode.render = (...args: any[]) => {
const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
if (ret) {
this.instanceRetPatch = afterPatch(ret, 'type', (_: any, ret: any) => {
- if (ret?.props?.children[1]?.children?.props?.notification?.decky) {
- const toast = ret.props.children[1].children.props.notification;
- ret.props.children[1].children.type = () => <Toast toast={toast} />;
+ if (ret?.props?.children[1]?.children?.props) {
+ const currentToast = ret.props.children[1].children.props.notification;
+ if (currentToast?.decky) {
+ if (currentToast == toast) {
+ ret.props.children[1].children = renderedToast;
+ } else {
+ toast = currentToast;
+ renderedToast = <Toast toast={toast} />;
+ ret.props.children[1].children = renderedToast;
+ }
+ } else {
+ toast = null;
+ renderedToast = null;
+ }
}
return ret;
});
@@ -57,11 +66,21 @@ class Toaster extends Logger {
return ret;
};
this.node.stateNode.forceUpdate();
+ this.settingsModule = findModuleChild((m) => {
+ if (typeof m !== 'object') return undefined;
+ for (let prop in m) {
+ if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
+ }
+ });
this.log('Initialized');
+ this.ready = true;
}
- toast(toast: ToastData) {
- const settings = this.settingsModule.settings;
+ async toast(toast: ToastData) {
+ while (!this.ready) {
+ await sleep(100);
+ }
+ const settings = this.settingsModule?.settings;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
@@ -73,13 +92,12 @@ class Toaster extends Logger {
// @ts-ignore
toastData.data.appid = () => 0;
if (
- (settings.bDisableAllToasts && !toast.critical) ||
- (settings.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
+ (settings?.bDisableAllToasts && !toast.critical) ||
+ (settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
)
return;
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
- window.NotificationStore.m_rgNotificationToasts.pop();
}
deinit() {
diff --git a/frontend/src/updater.ts b/frontend/src/updater.ts
index ff9cb591..2c0b66fe 100644
--- a/frontend/src/updater.ts
+++ b/frontend/src/updater.ts
@@ -1,5 +1,3 @@
-import { sleep } from 'decky-frontend-lib';
-
export enum Branches {
Release,
Prerelease,
@@ -46,6 +44,4 @@ export async function callUpdaterMethod(methodName: string, args = {}) {
export async function finishUpdate() {
callUpdaterMethod('do_restart');
- await sleep(3000);
- location.reload();
}