diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/WithSuspense.tsx | 32 | ||||
| -rw-r--r-- | frontend/src/components/modals/filepicker/iconCustomizations.ts | 170 | ||||
| -rw-r--r-- | frontend/src/components/modals/filepicker/index.tsx | 159 | ||||
| -rw-r--r-- | frontend/src/components/modals/filepicker/patches/README.md | 1 | ||||
| -rw-r--r-- | frontend/src/components/modals/filepicker/patches/index.ts | 10 | ||||
| -rw-r--r-- | frontend/src/components/modals/filepicker/patches/library.ts | 32 | ||||
| -rw-r--r-- | frontend/src/logger.ts | 6 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 83 | ||||
| -rw-r--r-- | frontend/src/store.tsx | 6 |
9 files changed, 460 insertions, 39 deletions
diff --git a/frontend/src/components/WithSuspense.tsx b/frontend/src/components/WithSuspense.tsx new file mode 100644 index 00000000..7460aa3d --- /dev/null +++ b/frontend/src/components/WithSuspense.tsx @@ -0,0 +1,32 @@ +import { SteamSpinner } from 'decky-frontend-lib'; +import { FunctionComponent, ReactElement, ReactNode, Suspense } from 'react'; + +interface WithSuspenseProps { + children: ReactNode; +} + +// 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={ + <div + style={{ + marginTop: '40px', + height: 'calc( 100% - 40px )', + overflowY: 'scroll', + }} + > + <SteamSpinner /> + </div> + } + > + {props.children} + </Suspense> + ); +}; + +export default WithSuspense; 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..0847bd14 --- /dev/null +++ b/frontend/src/components/modals/filepicker/index.tsx @@ -0,0 +1,159 @@ +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(); + const newPath = newPathArr.join('/'); + 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}/${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..8792900d --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/library.ts @@ -0,0 +1,32 @@ +import { replacePatch, sleep } from 'decky-frontend-lib'; + +declare global { + interface Window { + SteamClient: any; + appDetailsStore: any; + } +} + +export default async function libraryPatch() { + await sleep(10000); // If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so wait 10s + const 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); + } + }); + + return () => { + patch.unpatch(); + }; +} 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..493e5935 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -1,13 +1,15 @@ -import { ModalRoot, QuickAccessTab, Router, SteamSpinner, showModal, sleep, staticClasses } from 'decky-frontend-lib'; -import { Suspense, lazy } from 'react'; +import { ConfirmModal, ModalRoot, QuickAccessTab, Router, 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 +18,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 {} } @@ -58,47 +65,22 @@ class PluginLoader extends Logger { ), }); - 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> <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> <SettingsPage /> - </Suspense> + </WithSuspense> </DeckyStateContextProvider> ); }); + + initFilepickerPatches(); } public async notifyUpdates() { @@ -147,7 +129,7 @@ class PluginLoader extends Logger { public uninstallPlugin(name: string) { showModal( - <ModalRoot + <ConfirmModal onOK={async () => { await this.callServerMethod('uninstall_plugin', { name }); }} @@ -158,7 +140,7 @@ class PluginLoader extends Logger { <div className={staticClasses.Title} style={{ flexDirection: 'column' }}> Uninstall {name}? </div> - </ModalRoot>, + </ConfirmModal>, ); } @@ -176,6 +158,7 @@ class PluginLoader extends Logger { public deinit() { this.routerHook.removeRoute('/decky/store'); this.routerHook.removeRoute('/decky/settings'); + deinitFilepickerPatches(); } public unloadPlugin(name: string) { @@ -257,11 +240,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>, ); } |
