From 9f586a1b97cf9069fbfbeee17e3909baf9e95f66 Mon Sep 17 00:00:00 2001 From: jbofill <74568881+jessebofill@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:29:08 -0700 Subject: Feat: Disable plugins (#850) * implement base frontend changes necessary for plugin disabling * implement frontend diisable functions/ modal * plugin disable boilerplate / untested * Feat disable plugins (#810) * implement base frontend changes necessary for plugin disabling * implement frontend diisable functions/ modal --------- Co-authored-by: Jesse Bofill * fix mistakes * add frontend * working plugin disable, not tested extensively * fix uninstalled hidden plugins remaining in list * hide plugin irrelevant plugin setting menu option when disabled * fix hidden plugin issues * reset disabled plugin on uninstall * fix plugin load on reenable * move disable settings uninstall cleanup * add engilsh tranlsations for enable/ disable elements * fix bug where wrong loadType can get passed to importPlugin * show correct number of hidden plugins if plugin is both hidden and disabled * fix: get fresh list of plugin updates when changed in settings plugin list * fix: fix invalid semver plugin version from preventing latest updates * retain x position when changing focus in list items that have multiple horizontal focusables * correction to pluging version checking validation * make sure disabled plugins get checked for updates * show number of disabled plugins at bottom of plugin view * add notice to update modals that disabled plugins will be enabled upon installation * run formatter * Update backend/decky_loader/locales/en-US.json Co-authored-by: EMERALD * chore: correct filename typo * chore: change disabled icon * chore: revert accidental defsettings changes * format * add timeout to frontend importPlugin if a request hangs this prevent it from blocking other plugin loads. backend diaptch_plugin which calls this for individual plugin load (as opposed to batch) is set to 15s. other callers of importPlugin are not using timeout, same as before. * fix plugin update checking loop --------- Co-authored-by: marios Co-authored-by: EMERALD --- frontend/src/components/DeckyState.tsx | 18 +++- frontend/src/components/PluginView.tsx | 46 +++++++-- .../modals/MultiplePluginsInstallModal.tsx | 7 +- .../src/components/modals/PluginDisableModal.tsx | 39 ++++++++ .../src/components/modals/PluginInstallModal.tsx | 11 ++- .../src/components/modals/PluginUninstallModal.tsx | 12 ++- .../settings/pages/plugin_list/PluginListLabel.tsx | 19 +++- .../settings/pages/plugin_list/index.tsx | 84 ++++++++++++----- .../components/settings/pages/testing/index.tsx | 6 +- frontend/src/components/store/PluginCard.tsx | 19 +++- frontend/src/components/store/Store.tsx | 2 +- frontend/src/plugin-loader.tsx | 104 ++++++++++++++++++--- frontend/src/plugin.ts | 4 + frontend/src/store.tsx | 11 ++- 14 files changed, 312 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/modals/PluginDisableModal.tsx (limited to 'frontend') 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 = ({ 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 ( = ({ 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 = () => { ))} - {hiddenPlugins.length > 0 && ( -
- -
{t('PluginView.hidden', { count: hiddenPlugins.length })}
-
- )} +
+ {numberOfHidden > 0 && ( +
+ +
{t('PluginView.hidden', { count: numberOfHidden })}
+
+ )} + {disabledPlugins.length > 0 && ( +
+ +
{t('PluginView.disabled', { count: disabledPlugins.length })}
+
+ )} +
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; onCancel(): void | Promise; closeModal?(): void; @@ -17,6 +18,7 @@ type TitleTranslationMapping = 'mixed' | (typeof InstallTypeTranslationMapping)[ const MultiplePluginsInstallModal: FC = ({ requests, + disabledPlugins, onOK, onCancel, closeModal, @@ -116,10 +118,11 @@ const MultiplePluginsInstallModal: FC = ({ version, }); + const disabled = disabledPlugins.some((p) => p.name === name); return (
  • - {description}{' '} + {disabled ? `${description} - ${t('PluginInstallModal.disabled')}` : description}{' '} {(pluginsCompleted.includes(name) && ) || (name === pluginInProgress && )} {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 = ({ name, title, buttonText, description, closeModal }) => { + const [disabling, setDisabling] = useState(false); + return ( + { + setDisabling(true); + await disablePlugin(name); + closeModal?.(); + }} + bOKDisabled={disabling} + bCancelDisabled={disabling} + strTitle={ +
    + {title} + {disabling && } +
    + } + strOKButtonText={buttonText} + > + {description} +
    + ); +}; + +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 = ({ version, hash, installType, + disabled, onOK, onCancel, closeModal, @@ -45,6 +47,10 @@ const PluginInstallModal: FC = ({ }, []); const installTypeTranslationKey = InstallTypeTranslationMapping[installType]; + const description = t(`PluginInstallModal.${installTypeTranslationKey}.desc`, { + artifact: artifact, + version: version, + }); return ( = ({ // 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 } {hash == 'False' && {t('PluginInstallModal.no_hash')}} 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 = ({ name, title, buttonText, description, closeModal }) => { +const PluginUninstallModal: FC = ({ + name, + title, + buttonText, + description, + deckyState, + closeModal, +}) => { const [uninstalling, setUninstalling] = useState(false); return ( = ({ 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 = ({ name, frozen, hidden, version }) => { +const PluginListLabel: FC = ({ name, frozen, hidden, version, disabled }) => { const { t } = useTranslation(); return (
    @@ -43,6 +44,20 @@ const PluginListLabel: FC = ({ name, frozen, hidden, versi {t('PluginListLabel.hidden')}
    )} + {disabled && ( +
    + + {t('PluginListLabel.disabled')} +
    + )} ); }; 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 } 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( - { - try { - await reloadPluginBackend(name); - } catch (err) { - console.error('Error Reloading Plugin Backend', err); - } - }} - > - {t('PluginListIndex.reload')} - + {!disabled && ( + { + try { + await reloadPluginBackend(name); + } catch (err) { + console.error('Error Reloading Plugin Backend', err); + } + }} + > + {t('PluginListIndex.reload')} + + )} DeckyPluginLoader.uninstallPlugin( @@ -82,11 +88,28 @@ function PluginInteractables(props: { entry: ReorderableEntry } > {t('PluginListIndex.uninstall')} - {hidden ? ( - {t('PluginListIndex.show')} + {disabled ? ( + enablePlugin(name)}>{t('PluginListIndex.enable')} ) : ( - {t('PluginListIndex.hide')} + + DeckyPluginLoader.disablePlugin( + name, + t('PluginLoader.plugin_disable.title', { name }), + t('PluginLoader.plugin_disable.button'), + t('PluginLoader.plugin_disable.desc', { name }), + ) + } + > + {t('PluginListIndex.disable')} + )} + {!disabled && + (hidden ? ( + {t('PluginListIndex.show')} + ) : ( + {t('PluginListIndex.hide')} + ))} {frozen ? ( {t('PluginListIndex.unfreeze')} ) : ( @@ -98,7 +121,7 @@ function PluginInteractables(props: { entry: ReorderableEntry } }; return ( - <> + {update ? ( } > - + ); } @@ -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( 'pluginOrder', - plugins.map((plugin) => plugin.name), + installedPlugins.map((plugin) => plugin.name), ); const { t } = useTranslation(); useEffect(() => { DeckyPluginLoader.checkPluginUpdates(); - }, []); + }, [installedPlugins, frozenPlugins]); const [pluginEntries, setPluginEntries] = useState[]>([]); 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: