mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-17 00:37:49 +00:00
add file picker, add library file picker patch, bump lib, logger tweaks
This commit is contained in:
@@ -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;
|
||||
@@ -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) };
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
This directory contains patches that replace Valve's broken file picker with ours.
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user