diff options
19 files changed, 378 insertions, 79 deletions
diff --git a/backend/decky_loader/browser.py b/backend/decky_loader/browser.py index 975a917a..fe8ae71a 100644 --- a/backend/decky_loader/browser.py +++ b/backend/decky_loader/browser.py @@ -150,6 +150,7 @@ class PluginBrowser: # plugins_snapshot = self.plugins.copy() # snapshot_string = pformat(plugins_snapshot) # logger.debug("current plugins: %s", snapshot_string) + if name in self.plugins: logger.debug("Plugin %s was found", name) await self.plugins[name].stop(uninstall=True) @@ -345,5 +346,10 @@ class PluginBrowser: if name in plugin_order: plugin_order.remove(name) self.settings.setSetting("pluginOrder", plugin_order) + + disabled_plugins: List[str] = self.settings.getSetting("disabled_plugins", []) + if name in disabled_plugins: + disabled_plugins.remove(name) + self.settings.setSetting("disabled_plugins", disabled_plugins) logger.debug("Removed any settings for plugin %s", name) diff --git a/backend/decky_loader/loader.py b/backend/decky_loader/loader.py index e2e619f7..4574cd1d 100644 --- a/backend/decky_loader/loader.py +++ b/backend/decky_loader/loader.py @@ -78,6 +78,7 @@ class Loader: self.live_reload = live_reload self.reload_queue: ReloadQueue = Queue() self.loop.create_task(self.handle_reloads()) + self.context: PluginManager = server_instance if live_reload: self.observer = Observer() @@ -130,7 +131,7 @@ class Loader: async def get_plugins(self): plugins = list(self.plugins.values()) - return [{"name": str(i), "version": i.version, "load_type": i.load_type} for i in plugins] + return [{"name": str(i), "version": i.version, "load_type": i.load_type, "disabled": i.disabled} for i in plugins] async def handle_plugin_dist(self, request: web.Request): plugin = self.plugins[request.match_info["plugin_name"]] @@ -164,6 +165,10 @@ class Loader: await self.ws.emit(f"loader/plugin_event", {"plugin": plugin.name, "event": event, "args": args}) plugin = PluginWrapper(file, plugin_directory, self.plugin_path, plugin_emitted_event) + if hasattr(self.context, "utilities") and plugin.name in await self.context.utilities.get_setting("disabled_plugins",[]): + plugin.disabled = True + self.plugins[plugin.name] = plugin + return if plugin.name in self.plugins: if not "debug" in plugin.flags and refresh: self.logger.info(f"Plugin {plugin.name} is already loaded and has requested to not be re-loaded") @@ -183,7 +188,7 @@ class Loader: print_exc() async def dispatch_plugin(self, name: str, version: str | None, load_type: int = PluginLoadType.ESMODULE_V1.value): - await self.ws.emit("loader/import_plugin", name, version, load_type) + await self.ws.emit("loader/import_plugin", name, version, load_type, True, 15000) async def import_plugins(self): self.logger.info(f"import plugins from {self.plugin_path}") diff --git a/backend/decky_loader/locales/en-US.json b/backend/decky_loader/locales/en-US.json index 836f4878..1f87fe3b 100644 --- a/backend/decky_loader/locales/en-US.json +++ b/backend/decky_loader/locales/en-US.json @@ -102,6 +102,7 @@ }, "no_hash": "This plugin does not have a hash, you are installing it at your own risk.", "not_installed": "(not installed)", + "disabled": "The plugin will be re-enabled after installation", "overwrite": { "button_idle": "Overwrite", "button_processing": "Overwriting", @@ -133,10 +134,13 @@ "uninstall": "Uninstall", "update_all_one": "Update 1 plugin", "update_all_other": "Update {{count}} plugins", - "update_to": "Update to {{name}}" + "update_to": "Update to {{name}}", + "disable": "Disable", + "enable": "Enable" }, "PluginListLabel": { - "hidden": "Hidden from the quick access menu" + "hidden": "Hidden from the quick access menu", + "disabled": "Plugin disabled" }, "PluginLoader": { "decky_title": "Decky", @@ -152,12 +156,23 @@ "desc": "Are you sure you want to uninstall {{name}}?", "title": "Uninstall {{name}}" }, + "plugin_disable": { + "button": "Disable", + "desc": "Are you sure you want to disable {{name}}?", + "title": "Disable {{name}}", + "error": "Error disabling {{name}}" + }, + "plugin_enable": { + "error": "Error enabling {{name}}" + }, "plugin_update_one": "Updates available for 1 plugin!", "plugin_update_other": "Updates available for {{count}} plugins!" }, "PluginView": { "hidden_one": "1 plugin is hidden from this list", - "hidden_other": "{{count}} plugins are hidden from this list" + "hidden_other": "{{count}} plugins are hidden from this list", + "disabled_one": "1 plugin is disabled", + "disabled_other": "{{count}} plugins are disabled" }, "RemoteDebugging": { "remote_cef": { diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py index 61de4b1f..a7edaa45 100644 --- a/backend/decky_loader/plugin/plugin.py +++ b/backend/decky_loader/plugin/plugin.py @@ -41,6 +41,7 @@ class PluginWrapper: self.author = json["author"] self.flags = json["flags"] self.api_version = json["api_version"] if "api_version" in json else 0 + self.disabled = False self.passive = not path.isfile(self.file) diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py index 69c69fe6..75593fd5 100644 --- a/backend/decky_loader/utilities.py +++ b/backend/decky_loader/utilities.py @@ -1,5 +1,5 @@ from __future__ import annotations -from os import stat_result +from os import path, stat_result import uuid from urllib.parse import unquote from json.decoder import JSONDecodeError @@ -8,7 +8,7 @@ import re from traceback import format_exc from stat import FILE_ATTRIBUTE_HIDDEN # pyright: ignore [reportAttributeAccessIssue, reportUnknownVariableType] -from asyncio import StreamReader, StreamWriter, start_server, gather, open_connection +from asyncio import StreamReader, StreamWriter, sleep, start_server, gather, open_connection from aiohttp import ClientSession, hdrs from aiohttp.web import Request, StreamResponse, Response, json_response, post from typing import TYPE_CHECKING, Callable, Coroutine, Dict, Any, List, TypedDict @@ -80,6 +80,8 @@ class Utilities: context.ws.add_route("utilities/restart_webhelper", self.restart_webhelper) context.ws.add_route("utilities/close_cef_socket", self.close_cef_socket) context.ws.add_route("utilities/_call_legacy_utility", self._call_legacy_utility) + context.ws.add_route("utilities/enable_plugin", self.enable_plugin) + context.ws.add_route("utilities/disable_plugin", self.disable_plugin) context.web_app.add_routes([ post("/methods/{method_name}", self._handle_legacy_server_method_call) @@ -214,7 +216,7 @@ class Utilities: async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}, timeout: int | None = None): async with ClientSession() as web: - res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts) + res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts) # type: ignore text = await res.text() return { "status": res.status, @@ -390,7 +392,6 @@ class Utilities: "total": len(all), } - # Based on https://stackoverflow.com/a/46422554/13174603 def start_rdt_proxy(self, ip: str, port: int): async def pipe(reader: StreamReader, writer: StreamWriter): @@ -474,3 +475,32 @@ class Utilities: async def get_tab_id(self, name: str): return (await get_tab(name)).id + + async def disable_plugin(self, name: str): + disabled_plugins: List[str] = await self.get_setting("disabled_plugins", []) + if name not in disabled_plugins: + disabled_plugins.append(name) + await self.set_setting("disabled_plugins", disabled_plugins) + + await self.context.plugin_loader.plugins[name].stop() + await self.context.ws.emit("loader/disable_plugin", name) + + async def enable_plugin(self, name: str): + plugin_folder = self.context.plugin_browser.find_plugin_folder(name) + assert plugin_folder is not None + plugin_dir = path.join(self.context.plugin_browser.plugin_path, plugin_folder) + + if name in self.context.plugin_loader.plugins: + plugin = self.context.plugin_loader.plugins[name] + if plugin.proc and plugin.proc.is_alive(): + await plugin.stop() + self.context.plugin_loader.plugins.pop(name, None) + await sleep(1) + + disabled_plugins: List[str] = await self.get_setting("disabled_plugins", []) + + if name in disabled_plugins: + disabled_plugins.remove(name) + await self.set_setting("disabled_plugins", disabled_plugins) + + await self.context.plugin_loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_folder)
\ No newline at end of file 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]); |
