summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/DeckyState.tsx18
-rw-r--r--frontend/src/components/PluginView.tsx46
-rw-r--r--frontend/src/components/modals/MultiplePluginsInstallModal.tsx7
-rw-r--r--frontend/src/components/modals/PluginDisableModal.tsx39
-rw-r--r--frontend/src/components/modals/PluginInstallModal.tsx11
-rw-r--r--frontend/src/components/modals/PluginUninstallModal.tsx12
-rw-r--r--frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx19
-rw-r--r--frontend/src/components/settings/pages/plugin_list/index.tsx84
-rw-r--r--frontend/src/components/settings/pages/testing/index.tsx6
-rw-r--r--frontend/src/components/store/PluginCard.tsx19
-rw-r--r--frontend/src/components/store/Store.tsx2
-rw-r--r--frontend/src/plugin-loader.tsx104
-rw-r--r--frontend/src/plugin.ts4
-rw-r--r--frontend/src/store.tsx11
14 files changed, 312 insertions, 70 deletions
diff --git a/frontend/src/components/DeckyState.tsx b/frontend/src/components/DeckyState.tsx
index d2ac63ae..d1b558c1 100644
--- a/frontend/src/components/DeckyState.tsx
+++ b/frontend/src/components/DeckyState.tsx
@@ -1,12 +1,14 @@
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
import { DEFAULT_NOTIFICATION_SETTINGS, NotificationSettings } from '../notification-service';
-import { Plugin } from '../plugin';
+import { DisabledPlugin, Plugin } from '../plugin';
import { PluginUpdateMapping } from '../store';
import { VerInfo } from '../updater';
interface PublicDeckyState {
plugins: Plugin[];
+ disabledPlugins: DisabledPlugin[];
+ installedPlugins: (Plugin | DisabledPlugin)[];
pluginOrder: string[];
frozenPlugins: string[];
hiddenPlugins: string[];
@@ -26,6 +28,8 @@ export interface UserInfo {
export class DeckyState {
private _plugins: Plugin[] = [];
+ private _disabledPlugins: DisabledPlugin[] = [];
+ private _installedPlugins: (Plugin | DisabledPlugin)[] = [];
private _pluginOrder: string[] = [];
private _frozenPlugins: string[] = [];
private _hiddenPlugins: string[] = [];
@@ -42,6 +46,8 @@ export class DeckyState {
publicState(): PublicDeckyState {
return {
plugins: this._plugins,
+ disabledPlugins: this._disabledPlugins,
+ installedPlugins: this._installedPlugins,
pluginOrder: this._pluginOrder,
frozenPlugins: this._frozenPlugins,
hiddenPlugins: this._hiddenPlugins,
@@ -62,6 +68,13 @@ export class DeckyState {
setPlugins(plugins: Plugin[]) {
this._plugins = plugins;
+ this._installedPlugins = [...plugins, ...this._disabledPlugins];
+ this.notifyUpdate();
+ }
+
+ setDisabledPlugins(disabledPlugins: DisabledPlugin[]) {
+ this._disabledPlugins = disabledPlugins;
+ this._installedPlugins = [...this._plugins, ...disabledPlugins];
this.notifyUpdate();
}
@@ -125,6 +138,7 @@ interface DeckyStateContext extends PublicDeckyState {
setIsLoaderUpdating(hasUpdate: boolean): void;
setActivePlugin(name: string): void;
setPluginOrder(pluginOrder: string[]): void;
+ setDisabledPlugins(disabled: DisabledPlugin[]): void;
closeActivePlugin(): void;
}
@@ -163,6 +177,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
const setActivePlugin = deckyState.setActivePlugin.bind(deckyState);
const closeActivePlugin = deckyState.closeActivePlugin.bind(deckyState);
const setPluginOrder = deckyState.setPluginOrder.bind(deckyState);
+ const setDisabledPlugins = deckyState.setDisabledPlugins.bind(deckyState);
return (
<DeckyStateContext.Provider
@@ -173,6 +188,7 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
setActivePlugin,
closeActivePlugin,
setPluginOrder,
+ setDisabledPlugins,
}}
>
{children}
diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx
index 1d39972e..ffaa176a 100644
--- a/frontend/src/components/PluginView.tsx
+++ b/frontend/src/components/PluginView.tsx
@@ -1,7 +1,7 @@
import { ButtonItem, ErrorBoundary, Focusable, PanelSection, PanelSectionRow } from '@decky/ui';
import { FC, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { FaEyeSlash } from 'react-icons/fa';
+import { FaBan, FaEyeSlash } from 'react-icons/fa';
import { useDeckyState } from './DeckyState';
import NotificationBadge from './NotificationBadge';
@@ -9,8 +9,16 @@ import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: FC = () => {
- const { plugins, hiddenPlugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } =
- useDeckyState();
+ const {
+ plugins,
+ disabledPlugins,
+ hiddenPlugins,
+ updates,
+ activePlugin,
+ pluginOrder,
+ setActivePlugin,
+ closeActivePlugin,
+ } = useDeckyState();
const visible = useQuickAccessVisible();
const { t } = useTranslation();
@@ -21,7 +29,9 @@ const PluginView: FC = () => {
.sort((a, b) => pluginOrder.indexOf(a.name) - pluginOrder.indexOf(b.name))
.filter((p) => p.content)
.filter(({ name }) => !hiddenPlugins.includes(name));
- }, [plugins, pluginOrder]);
+ }, [plugins, pluginOrder, hiddenPlugins]);
+
+ const numberOfHidden = hiddenPlugins.filter((name) => !!plugins.find((p) => p.name === name)).length;
if (activePlugin) {
return (
@@ -53,12 +63,28 @@ const PluginView: FC = () => {
</ButtonItem>
</PanelSectionRow>
))}
- {hiddenPlugins.length > 0 && (
- <div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem', marginTop: '10px' }}>
- <FaEyeSlash />
- <div>{t('PluginView.hidden', { count: hiddenPlugins.length })}</div>
- </div>
- )}
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'absolute',
+ justifyContent: 'center',
+ padding: '5px 0px',
+ }}
+ >
+ {numberOfHidden > 0 && (
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem' }}>
+ <FaEyeSlash />
+ <div>{t('PluginView.hidden', { count: numberOfHidden })}</div>
+ </div>
+ )}
+ {disabledPlugins.length > 0 && (
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem' }}>
+ <FaBan />
+ <div>{t('PluginView.disabled', { count: disabledPlugins.length })}</div>
+ </div>
+ )}
+ </div>
</PanelSection>
</div>
</>
diff --git a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
index 9c86f3db..e5c1c647 100644
--- a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
+++ b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
@@ -3,10 +3,11 @@ import { FC, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaCheck, FaDownload } from 'react-icons/fa';
-import { InstallType, InstallTypeTranslationMapping } from '../../plugin';
+import { DisabledPlugin, InstallType, InstallTypeTranslationMapping } from '../../plugin';
interface MultiplePluginsInstallModalProps {
requests: { name: string; version: string; hash: string; install_type: InstallType }[];
+ disabledPlugins: DisabledPlugin[];
onOK(): void | Promise<void>;
onCancel(): void | Promise<void>;
closeModal?(): void;
@@ -17,6 +18,7 @@ type TitleTranslationMapping = 'mixed' | (typeof InstallTypeTranslationMapping)[
const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
requests,
+ disabledPlugins,
onOK,
onCancel,
closeModal,
@@ -116,10 +118,11 @@ const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
version,
});
+ const disabled = disabledPlugins.some((p) => p.name === name);
return (
<li key={i} style={{ display: 'flex', flexDirection: 'column' }}>
<span>
- {description}{' '}
+ {disabled ? `${description} - ${t('PluginInstallModal.disabled')}` : description}{' '}
{(pluginsCompleted.includes(name) && <FaCheck />) || (name === pluginInProgress && <FaDownload />)}
</span>
{hash === 'False' && (
diff --git a/frontend/src/components/modals/PluginDisableModal.tsx b/frontend/src/components/modals/PluginDisableModal.tsx
new file mode 100644
index 00000000..16ddd4bf
--- /dev/null
+++ b/frontend/src/components/modals/PluginDisableModal.tsx
@@ -0,0 +1,39 @@
+import { ConfirmModal, Spinner } from '@decky/ui';
+import { FC, useState } from 'react';
+
+import { disablePlugin } from '../../plugin';
+
+interface PluginDisableModalProps {
+ name: string;
+ title: string;
+ buttonText: string;
+ description: string;
+ closeModal?(): void;
+}
+
+const PluginDisableModal: FC<PluginDisableModalProps> = ({ name, title, buttonText, description, closeModal }) => {
+ const [disabling, setDisabling] = useState<boolean>(false);
+ return (
+ <ConfirmModal
+ closeModal={closeModal}
+ onOK={async () => {
+ setDisabling(true);
+ await disablePlugin(name);
+ closeModal?.();
+ }}
+ bOKDisabled={disabling}
+ bCancelDisabled={disabling}
+ strTitle={
+ <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', width: '100%' }}>
+ {title}
+ {disabling && <Spinner width="24px" height="24px" style={{ marginLeft: 'auto' }} />}
+ </div>
+ }
+ strOKButtonText={buttonText}
+ >
+ {description}
+ </ConfirmModal>
+ );
+};
+
+export default PluginDisableModal;
diff --git a/frontend/src/components/modals/PluginInstallModal.tsx b/frontend/src/components/modals/PluginInstallModal.tsx
index 16419d91..0075fce5 100644
--- a/frontend/src/components/modals/PluginInstallModal.tsx
+++ b/frontend/src/components/modals/PluginInstallModal.tsx
@@ -9,6 +9,7 @@ interface PluginInstallModalProps {
version: string;
hash: string;
installType: InstallType;
+ disabled?: boolean;
onOK(): void;
onCancel(): void;
closeModal?(): void;
@@ -19,6 +20,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
version,
hash,
installType,
+ disabled,
onOK,
onCancel,
closeModal,
@@ -45,6 +47,10 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
}, []);
const installTypeTranslationKey = InstallTypeTranslationMapping[installType];
+ const description = t(`PluginInstallModal.${installTypeTranslationKey}.desc`, {
+ artifact: artifact,
+ version: version,
+ });
return (
<ConfirmModal
@@ -118,10 +124,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
// t('PluginInstallModal.update.desc')
// t('PluginInstallModal.downgrade.desc')
// t('PluginInstallModal.overwrite.desc')
- t(`PluginInstallModal.${installTypeTranslationKey}.desc`, {
- artifact: artifact,
- version: version,
- })
+ disabled ? `${description} ${t('PluginInstallModal.disabled')}` : description
}
</div>
{hash == 'False' && <span style={{ color: 'red' }}>{t('PluginInstallModal.no_hash')}</span>}
diff --git a/frontend/src/components/modals/PluginUninstallModal.tsx b/frontend/src/components/modals/PluginUninstallModal.tsx
index be479859..37d3d789 100644
--- a/frontend/src/components/modals/PluginUninstallModal.tsx
+++ b/frontend/src/components/modals/PluginUninstallModal.tsx
@@ -2,8 +2,10 @@ import { ConfirmModal, Spinner } from '@decky/ui';
import { FC, useState } from 'react';
import { uninstallPlugin } from '../../plugin';
+import { DeckyState } from '../DeckyState';
interface PluginUninstallModalProps {
+ deckyState: DeckyState;
name: string;
title: string;
buttonText: string;
@@ -11,7 +13,14 @@ interface PluginUninstallModalProps {
closeModal?(): void;
}
-const PluginUninstallModal: FC<PluginUninstallModalProps> = ({ name, title, buttonText, description, closeModal }) => {
+const PluginUninstallModal: FC<PluginUninstallModalProps> = ({
+ name,
+ title,
+ buttonText,
+ description,
+ deckyState,
+ closeModal,
+}) => {
const [uninstalling, setUninstalling] = useState<boolean>(false);
return (
<ConfirmModal
@@ -19,6 +28,7 @@ const PluginUninstallModal: FC<PluginUninstallModalProps> = ({ name, title, butt
onOK={async () => {
setUninstalling(true);
await uninstallPlugin(name);
+ deckyState.setDisabledPlugins(deckyState.publicState().disabledPlugins.filter((d) => d.name !== name));
// uninstalling a plugin resets the hidden setting for it server-side
// we invalidate here so if you re-install it, you won't have an out-of-date hidden filter
await DeckyPluginLoader.frozenPluginsService.invalidate();
diff --git a/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx b/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx
index fec03e56..59171b39 100644
--- a/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx
+++ b/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx
@@ -1,15 +1,16 @@
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
-import { FaEyeSlash, FaLock } from 'react-icons/fa';
+import { FaBan, FaEyeSlash, FaLock } from 'react-icons/fa';
interface PluginListLabelProps {
frozen: boolean;
hidden: boolean;
+ disabled: boolean;
name: string;
version?: string;
}
-const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, version }) => {
+const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, version, disabled }) => {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
@@ -43,6 +44,20 @@ const PluginListLabel: FC<PluginListLabelProps> = ({ name, frozen, hidden, versi
{t('PluginListLabel.hidden')}
</div>
)}
+ {disabled && (
+ <div
+ style={{
+ fontSize: '0.8rem',
+ color: '#dcdedf',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '10px',
+ }}
+ >
+ <FaBan />
+ {t('PluginListLabel.disabled')}
+ </div>
+ )}
</div>
);
};
diff --git a/frontend/src/components/settings/pages/plugin_list/index.tsx b/frontend/src/components/settings/pages/plugin_list/index.tsx
index 9a7cb076..43d79709 100644
--- a/frontend/src/components/settings/pages/plugin_list/index.tsx
+++ b/frontend/src/components/settings/pages/plugin_list/index.tsx
@@ -2,9 +2,11 @@ import {
DialogBody,
DialogButton,
DialogControlsSection,
+ Focusable,
GamepadEvent,
Menu,
MenuItem,
+ NavEntryPositionPreferences,
ReorderableEntry,
ReorderableList,
showContextMenu,
@@ -13,7 +15,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa';
-import { InstallType } from '../../../../plugin';
+import { InstallType, enablePlugin } from '../../../../plugin';
import {
StorePluginVersion,
getPluginList,
@@ -35,6 +37,7 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) {
type PluginTableData = PluginData & {
name: string;
+ disabled: boolean;
frozen: boolean;
onFreeze(): void;
onUnfreeze(): void;
@@ -54,22 +57,25 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
return null;
}
- const { name, update, version, onHide, onShow, hidden, onFreeze, onUnfreeze, frozen, isDeveloper } = props.entry.data;
+ const { name, update, version, onHide, onShow, hidden, onFreeze, onUnfreeze, frozen, isDeveloper, disabled } =
+ props.entry.data;
const showCtxMenu = (e: MouseEvent | GamepadEvent) => {
showContextMenu(
<Menu label={t('PluginListIndex.plugin_actions')}>
- <MenuItem
- onSelected={async () => {
- try {
- await reloadPluginBackend(name);
- } catch (err) {
- console.error('Error Reloading Plugin Backend', err);
- }
- }}
- >
- {t('PluginListIndex.reload')}
- </MenuItem>
+ {!disabled && (
+ <MenuItem
+ onSelected={async () => {
+ try {
+ await reloadPluginBackend(name);
+ } catch (err) {
+ console.error('Error Reloading Plugin Backend', err);
+ }
+ }}
+ >
+ {t('PluginListIndex.reload')}
+ </MenuItem>
+ )}
<MenuItem
onSelected={() =>
DeckyPluginLoader.uninstallPlugin(
@@ -82,11 +88,28 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
>
{t('PluginListIndex.uninstall')}
</MenuItem>
- {hidden ? (
- <MenuItem onSelected={onShow}>{t('PluginListIndex.show')}</MenuItem>
+ {disabled ? (
+ <MenuItem onSelected={() => enablePlugin(name)}>{t('PluginListIndex.enable')}</MenuItem>
) : (
- <MenuItem onSelected={onHide}>{t('PluginListIndex.hide')}</MenuItem>
+ <MenuItem
+ onSelected={() =>
+ DeckyPluginLoader.disablePlugin(
+ name,
+ t('PluginLoader.plugin_disable.title', { name }),
+ t('PluginLoader.plugin_disable.button'),
+ t('PluginLoader.plugin_disable.desc', { name }),
+ )
+ }
+ >
+ {t('PluginListIndex.disable')}
+ </MenuItem>
)}
+ {!disabled &&
+ (hidden ? (
+ <MenuItem onSelected={onShow}>{t('PluginListIndex.show')}</MenuItem>
+ ) : (
+ <MenuItem onSelected={onHide}>{t('PluginListIndex.hide')}</MenuItem>
+ ))}
{frozen ? (
<MenuItem onSelected={onUnfreeze}>{t('PluginListIndex.unfreeze')}</MenuItem>
) : (
@@ -98,7 +121,7 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
};
return (
- <>
+ <Focusable navEntryPreferPosition={NavEntryPositionPreferences.MAINTAIN_X} style={{ display: 'flex' }}>
{update ? (
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
@@ -137,7 +160,7 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
>
<FaEllipsisH />
</DialogButton>
- </>
+ </Focusable>
);
}
@@ -147,16 +170,18 @@ type PluginData = {
};
export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
- const { plugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState();
+ const { installedPlugins, disabledPlugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } =
+ useDeckyState();
+
const [_, setPluginOrderSetting] = useSetting<string[]>(
'pluginOrder',
- plugins.map((plugin) => plugin.name),
+ installedPlugins.map((plugin) => plugin.name),
);
const { t } = useTranslation();
useEffect(() => {
DeckyPluginLoader.checkPluginUpdates();
- }, []);
+ }, [installedPlugins, frozenPlugins]);
const [pluginEntries, setPluginEntries] = useState<ReorderableEntry<PluginTableData>[]>([]);
const hiddenPluginsService = DeckyPluginLoader.hiddenPluginsService;
@@ -164,15 +189,24 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
useEffect(() => {
setPluginEntries(
- plugins.map(({ name, version }) => {
+ installedPlugins.map(({ name, version }) => {
const frozen = frozenPlugins.includes(name);
const hidden = hiddenPlugins.includes(name);
return {
- label: <PluginListLabel name={name} frozen={frozen} hidden={hidden} version={version} />,
+ label: (
+ <PluginListLabel
+ name={name}
+ frozen={frozen}
+ hidden={hidden}
+ version={version}
+ disabled={disabledPlugins.find((p) => p.name == name) !== undefined}
+ />
+ ),
position: pluginOrder.indexOf(name),
data: {
name,
+ disabled: disabledPlugins.some((disabledPlugin) => disabledPlugin.name === name),
frozen,
hidden,
isDeveloper,
@@ -186,9 +220,9 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) {
};
}),
);
- }, [plugins, updates, hiddenPlugins]);
+ }, [installedPlugins, updates, hiddenPlugins, disabledPlugins]);
- if (plugins.length === 0) {
+ if (installedPlugins.length === 0) {
return (
<div>
<p>{t('PluginListIndex.no_plugin')}</p>
diff --git a/frontend/src/components/settings/pages/testing/index.tsx b/frontend/src/components/settings/pages/testing/index.tsx
index 8f02c207..3c032361 100644
--- a/frontend/src/components/settings/pages/testing/index.tsx
+++ b/frontend/src/components/settings/pages/testing/index.tsx
@@ -4,6 +4,7 @@ import {
DialogControlsSection,
Field,
Focusable,
+ NavEntryPositionPreferences,
Navigation,
ProgressBar,
SteamSpinner,
@@ -87,7 +88,10 @@ export default function TestingVersionList() {
</>
}
>
- <Focusable style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}>
+ <Focusable
+ style={{ height: '40px', marginLeft: 'auto', display: 'flex' }}
+ navEntryPreferPosition={NavEntryPositionPreferences.MAINTAIN_X}
+ >
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={async () => {
diff --git a/frontend/src/components/store/PluginCard.tsx b/frontend/src/components/store/PluginCard.tsx
index f64abd09..a47207c9 100644
--- a/frontend/src/components/store/PluginCard.tsx
+++ b/frontend/src/components/store/PluginCard.tsx
@@ -1,15 +1,23 @@
-import { ButtonItem, Dropdown, Focusable, PanelSectionRow, SingleDropdownOption, SuspensefulImage } from '@decky/ui';
+import {
+ ButtonItem,
+ Dropdown,
+ Focusable,
+ NavEntryPositionPreferences,
+ PanelSectionRow,
+ SingleDropdownOption,
+ SuspensefulImage,
+} from '@decky/ui';
import { CSSProperties, FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaArrowDown, FaArrowUp, FaCheck, FaDownload, FaRecycle } from 'react-icons/fa';
-import { InstallType, Plugin } from '../../plugin';
+import { DisabledPlugin, InstallType, Plugin } from '../../plugin';
import { StorePlugin, requestPluginInstall } from '../../store';
import ExternalLink from '../ExternalLink';
interface PluginCardProps {
storePlugin: StorePlugin;
- installedPlugin: Plugin | undefined;
+ installedPlugin: Plugin | DisabledPlugin | undefined;
}
const PluginCard: FC<PluginCardProps> = ({ storePlugin, installedPlugin }) => {
@@ -139,7 +147,10 @@ const PluginCard: FC<PluginCardProps> = ({ storePlugin, installedPlugin }) => {
</div>
<div className="deckyStoreCardButtonRow">
<PanelSectionRow>
- <Focusable style={{ display: 'flex', gap: '5px', padding: 0 }}>
+ <Focusable
+ style={{ display: 'flex', gap: '5px', padding: 0 }}
+ navEntryPreferPosition={NavEntryPositionPreferences.MAINTAIN_X}
+ >
<div
className="deckyStoreCardInstallContainer"
style={
diff --git a/frontend/src/components/store/Store.tsx b/frontend/src/components/store/Store.tsx
index 3209ba08..72187cbc 100644
--- a/frontend/src/components/store/Store.tsx
+++ b/frontend/src/components/store/Store.tsx
@@ -105,7 +105,7 @@ const BrowseTab: FC<{ setPluginCount: Dispatch<SetStateAction<number | null>> }>
})();
}, []);
- const { plugins: installedPlugins } = useDeckyState();
+ const { installedPlugins } = useDeckyState();
return (
<>
diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index 2bdfcec1..fd4dc1c0 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -19,6 +19,7 @@ import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from '
import { File, FileSelectionType } from './components/modals/filepicker';
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
import MultiplePluginsInstallModal from './components/modals/MultiplePluginsInstallModal';
+import PluginDisableModal from './components/modals/PluginDisableModal';
import PluginInstallModal from './components/modals/PluginInstallModal';
import PluginUninstallModal from './components/modals/PluginUninstallModal';
import NotificationBadge from './components/NotificationBadge';
@@ -30,7 +31,7 @@ import { FrozenPluginService } from './frozen-plugins-service';
import { HiddenPluginsService } from './hidden-plugins-service';
import Logger from './logger';
import { NotificationService } from './notification-service';
-import { InstallType, Plugin, PluginLoadType } from './plugin';
+import { DisabledPlugin, InstallType, Plugin, PluginLoadType } from './plugin';
import RouterHook from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForPluginUpdates } from './store';
@@ -91,6 +92,7 @@ class PluginLoader extends Logger {
DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this));
DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this));
DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this));
+ DeckyBackend.addEventListener('loader/disable_plugin', this.doDisablePlugin.bind(this));
DeckyBackend.addEventListener('loader/add_plugin_install_prompt', this.addPluginInstallPrompt.bind(this));
DeckyBackend.addEventListener(
'loader/add_multiple_plugins_install_prompt',
@@ -175,7 +177,7 @@ class PluginLoader extends Logger {
private getPluginsFromBackend = DeckyBackend.callable<
[],
- { name: string; version: string; load_type: PluginLoadType }[]
+ { name: string; version: string; load_type: PluginLoadType; disabled: boolean }[]
>('loader/get_plugins');
private restartWebhelper = DeckyBackend.callable<[], void>('utilities/restart_webhelper');
@@ -198,10 +200,16 @@ class PluginLoader extends Logger {
this.runCrashChecker();
const plugins = await this.getPluginsFromBackend();
const pluginLoadPromises = [];
+ const disabledPlugins: DisabledPlugin[] = [];
const loadStart = performance.now();
for (const plugin of plugins) {
- if (!this.hasPlugin(plugin.name))
- pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, plugin.load_type, false));
+ if (plugin.disabled) {
+ disabledPlugins.push({ name: plugin.name, version: plugin.version });
+ this.deckyState.setDisabledPlugins(disabledPlugins);
+ } else {
+ if (!this.hasPlugin(plugin.name))
+ pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, plugin.load_type, false));
+ }
}
await Promise.all(pluginLoadPromises);
const loadEnd = performance.now();
@@ -252,7 +260,9 @@ class PluginLoader extends Logger {
public async checkPluginUpdates() {
const frozenPlugins = this.deckyState.publicState().frozenPlugins;
- const updates = await checkForPluginUpdates(this.plugins.filter((p) => !frozenPlugins.includes(p.name)));
+ const updates = await checkForPluginUpdates(
+ this.deckyState.publicState().installedPlugins.filter((p) => !frozenPlugins.includes(p.name)),
+ );
this.deckyState.setUpdates(updates);
return updates;
}
@@ -290,6 +300,7 @@ class PluginLoader extends Logger {
version={version}
hash={hash}
installType={install_type}
+ disabled={this.deckyState.publicState().disabledPlugins.some((p) => p.name === artifact)}
onOK={() => DeckyBackend.call<[string]>('utilities/confirm_plugin_install', request_id)}
onCancel={() => DeckyBackend.call<[string]>('utilities/cancel_plugin_install', request_id)}
/>,
@@ -303,6 +314,7 @@ class PluginLoader extends Logger {
showModal(
<MultiplePluginsInstallModal
requests={requests}
+ disabledPlugins={this.deckyState.publicState().disabledPlugins}
onOK={() => DeckyBackend.call<[string]>('utilities/confirm_plugin_install', request_id)}
onCancel={() => DeckyBackend.call<[string]>('utilities/cancel_plugin_install', request_id)}
/>,
@@ -310,7 +322,19 @@ class PluginLoader extends Logger {
}
public uninstallPlugin(name: string, title: string, buttonText: string, description: string) {
- showModal(<PluginUninstallModal name={name} title={title} buttonText={buttonText} description={description} />);
+ showModal(
+ <PluginUninstallModal
+ name={name}
+ title={title}
+ buttonText={buttonText}
+ description={description}
+ deckyState={this.deckyState}
+ />,
+ );
+ }
+
+ public disablePlugin(name: string, title: string, buttonText: string, description: string) {
+ showModal(<PluginDisableModal name={name} title={title} buttonText={buttonText} description={description} />);
}
public hasPlugin(name: string) {
@@ -351,6 +375,19 @@ class PluginLoader extends Logger {
this.errorBoundaryHook.deinit();
}
+ public doDisablePlugin(name: string) {
+ const plugin = this.plugins.find((plugin) => plugin.name === name);
+ if (plugin == undefined) return;
+
+ plugin?.onDismount?.();
+ this.plugins = this.plugins.filter((p) => p !== plugin);
+ this.deckyState.setDisabledPlugins([
+ ...this.deckyState.publicState().disabledPlugins,
+ { name: plugin.name, version: plugin.version },
+ ]);
+ this.deckyState.setPlugins(this.plugins);
+ }
+
public unloadPlugin(name: string, skipStateUpdate: boolean = false) {
const plugin = this.plugins.find((plugin) => plugin.name === name);
plugin?.onDismount?.();
@@ -363,6 +400,7 @@ class PluginLoader extends Logger {
version?: string | undefined,
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
useQueue: boolean = true,
+ timeoutMS?: number,
) {
if (useQueue && this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name);
@@ -376,9 +414,11 @@ class PluginLoader extends Logger {
this.unloadPlugin(name, true);
const startTime = performance.now();
- await this.importReactPlugin(name, version, loadType);
+
+ await this.importReactPlugin(name, version, loadType, timeoutMS);
const endTime = performance.now();
+ this.deckyState.setDisabledPlugins(this.deckyState.publicState().disabledPlugins.filter((d) => d.name !== name));
this.deckyState.setPlugins(this.plugins);
this.log(`Loaded ${name} in ${endTime - startTime}ms`);
} catch (e) {
@@ -388,7 +428,7 @@ class PluginLoader extends Logger {
this.reloadLock = false;
const nextPlugin = this.pluginReloadQueue.shift();
if (nextPlugin) {
- this.importPlugin(nextPlugin.name, nextPlugin.version, loadType);
+ this.importPlugin(nextPlugin.name, nextPlugin.version, nextPlugin.loadType, true, timeoutMS);
}
}
}
@@ -398,12 +438,28 @@ class PluginLoader extends Logger {
name: string,
version?: string,
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
+ timeoutMS?: number,
) {
let spExists = this.checkForSP();
+ const timeoutException = new Error(
+ `${name} failed to load within ${timeoutMS ? `${timeoutMS / 1000} second` : ''} time limit`,
+ );
+ let timeout: number | undefined;
+
try {
switch (loadType) {
case PluginLoadType.ESMODULE_V1:
- const plugin_exports = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js?t=${Date.now()}`);
+ const importJS = () => import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js?t=${Date.now()}`);
+
+ const promise =
+ timeoutMS === undefined
+ ? importJS()
+ : Promise.race([
+ importJS(),
+ new Promise((_, reject) => (timeout = setTimeout(() => reject(timeoutException), timeoutMS))),
+ ]);
+
+ const plugin_exports = await promise;
let plugin = plugin_exports.default();
this.plugins.push({
@@ -415,12 +471,26 @@ class PluginLoader extends Logger {
break;
case PluginLoadType.LEGACY_EVAL_IIFE:
- let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
- credentials: 'include',
- headers: {
- 'X-Decky-Auth': deckyAuthToken,
- },
- });
+ const fetchJS = async () => {
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ if (timeoutMS !== undefined) timeout = setTimeout(() => controller.abort(), timeoutMS);
+
+ try {
+ return await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
+ credentials: 'include',
+ headers: {
+ 'X-Decky-Auth': deckyAuthToken,
+ },
+ signal,
+ });
+ } catch (e: any) {
+ throw 'name' in e && e.name === 'AbortError' ? timeoutException : e;
+ }
+ };
+
+ let res = await fetchJS();
if (res.ok) {
let plugin_export: (serverAPI: any) => Plugin = await eval(
(await res.text()) + `\n//# sourceURL=decky://decky/legacy_plugin/${encodeURIComponent(name)}/index.js`,
@@ -439,6 +509,8 @@ class PluginLoader extends Logger {
throw new Error(`${name} has no defined loadType.`);
}
} catch (e) {
+ if (e === timeoutException) throw timeoutException;
+
this.error('Error loading plugin ' + name, e);
const TheError: FC<{}> = () => (
<PanelSection>
@@ -481,6 +553,8 @@ class PluginLoader extends Logger {
body: '' + e,
icon: <FaExclamationCircle />,
});
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
}
if (spExists && !this.checkForSP()) {
diff --git a/frontend/src/plugin.ts b/frontend/src/plugin.ts
index f2b99f71..746ef29e 100644
--- a/frontend/src/plugin.ts
+++ b/frontend/src/plugin.ts
@@ -15,6 +15,8 @@ export interface Plugin {
titleView?: JSX.Element;
}
+export type DisabledPlugin = Pick<Plugin, 'name' | 'version'>;
+
export enum InstallType {
INSTALL,
REINSTALL,
@@ -56,3 +58,5 @@ type installPluginsArgs = [
export let installPlugins = DeckyBackend.callable<installPluginsArgs>('utilities/install_plugins');
export let uninstallPlugin = DeckyBackend.callable<[name: string]>('utilities/uninstall_plugin');
+export let enablePlugin = DeckyBackend.callable<[name: string]>('utilities/enable_plugin');
+export let disablePlugin = DeckyBackend.callable<[name: string]>('utilities/disable_plugin');
diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx
index dfd9b04b..33c384a5 100644
--- a/frontend/src/store.tsx
+++ b/frontend/src/store.tsx
@@ -1,6 +1,6 @@
-import { compare } from 'compare-versions';
+import { compare, validate } from 'compare-versions';
-import { InstallType, Plugin, installPlugin, installPlugins } from './plugin';
+import { DisabledPlugin, InstallType, Plugin, installPlugin, installPlugins } from './plugin';
import { getSetting, setSetting } from './utils/settings';
export enum Store {
@@ -113,18 +113,21 @@ export async function requestMultiplePluginInstalls(requests: PluginInstallReque
);
}
-export async function checkForPluginUpdates(plugins: Plugin[]): Promise<PluginUpdateMapping> {
+export async function checkForPluginUpdates(plugins: (Plugin | DisabledPlugin)[]): Promise<PluginUpdateMapping> {
const serverData = await getPluginList();
const updateMap = new Map<string, StorePluginVersion>();
for (let plugin of plugins) {
const remotePlugin = serverData?.find((x) => x.name == plugin.name);
//FIXME: Ugly hack since plugin.version might be null during evaluation,
//so this will set the older version possible
- const curVer = plugin.version ? plugin.version : '0.0';
+ const curVer = plugin.version ? plugin.version : '0.0.0';
+
if (
remotePlugin &&
remotePlugin.versions?.length > 0 &&
plugin.version != remotePlugin?.versions?.[0]?.name &&
+ validate(remotePlugin.versions?.[0]?.name) &&
+ validate(curVer) &&
compare(remotePlugin?.versions?.[0]?.name, curVer, '>')
) {
updateMap.set(plugin.name, remotePlugin.versions[0]);