diff --git a/.vscode/defsettings.json b/.vscode/defsettings.json index b7c151228..3613c109b 100644 --- a/.vscode/defsettings.json +++ b/.vscode/defsettings.json @@ -1,8 +1,8 @@ { - "deckip" : "0.0.0.0", + "deckip" : "192.168.1.69", "deckport" : "22", "deckuser" : "deck", - "deckpass" : "ssap", + "deckpass" : "deck", "deckkey" : "-i ${env:HOME}/.ssh/id_rsa", "deckdir" : "/home/deck" } diff --git a/backend/decky_loader/loader.py b/backend/decky_loader/loader.py index e2e619f76..d4799baec 100644 --- a/backend/decky_loader/loader.py +++ b/backend/decky_loader/loader.py @@ -78,6 +78,7 @@ def __init__(self, server_instance: PluginManager, ws: WSRouter, plugin_path: st 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 @@ async def handle_frontend_locales(self, request: web.Request): 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 @@ async def plugin_emitted_event(event: str, args: Any): 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") diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py index 61de4b1f4..a7edaa459 100644 --- a/backend/decky_loader/plugin/plugin.py +++ b/backend/decky_loader/plugin/plugin.py @@ -41,6 +41,7 @@ def __init__(self, file: str, plugin_directory: str, plugin_path: str, emit_call 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 69c69fe6a..ea797dada 100644 --- a/backend/decky_loader/utilities.py +++ b/backend/decky_loader/utilities.py @@ -80,6 +80,8 @@ def __init__(self, context: PluginManager) -> None: 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 @@ async def http_request(self, req: Request) -> StreamResponse: 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 @@ async def filepicker_ls(self, "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,22 @@ async def get_user_info(self) -> Dict[str, str]: 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): + 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) + + plugin = self.context.plugin_loader.plugins[name] + plugin.start() + await self.context.plugin_loader.dispatch_plugin(plugin.name, plugin.version, plugin.load_type) diff --git a/frontend/src/components/DeckyState.tsx b/frontend/src/components/DeckyState.tsx index d2ac63aec..3d6e9dc47 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[]; + disabled: 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, + disabled: 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(); } diff --git a/frontend/src/components/modals/PluginDisablelModal.tsx b/frontend/src/components/modals/PluginDisablelModal.tsx new file mode 100644 index 000000000..c455f9fa4 --- /dev/null +++ b/frontend/src/components/modals/PluginDisablelModal.tsx @@ -0,0 +1,46 @@ +import { ConfirmModal, Spinner } from '@decky/ui'; +import { FC, useState } from 'react'; + +import { disablePlugin } from '../../plugin'; + +interface PluginUninstallModalProps { + 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); + + //not sure about this yet + + // 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(); + await DeckyPluginLoader.hiddenPluginsService.invalidate(); + closeModal?.(); + }} + bOKDisabled={disabling} + bCancelDisabled={disabling} + strTitle={ +
+ {title} + {disabling && } +
+ } + strOKButtonText={buttonText} + > + {description} +
+ ); +}; + +export default PluginDisableModal; diff --git a/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx b/frontend/src/components/settings/pages/plugin_list/PluginListLabel.tsx index fec03e568..416d693e5 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 { FaEyeSlash, FaLock, FaMoon } 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 9a7cb0764..b21e49339 100644 --- a/frontend/src/components/settings/pages/plugin_list/index.tsx +++ b/frontend/src/components/settings/pages/plugin_list/index.tsx @@ -13,7 +13,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa'; -import { InstallType } from '../../../../plugin'; +import { enablePlugin, InstallType } from '../../../../plugin'; import { StorePluginVersion, getPluginList, @@ -35,6 +35,7 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) { type PluginTableData = PluginData & { name: string; + disabled: boolean; frozen: boolean; onFreeze(): void; onUnfreeze(): void; @@ -54,7 +55,7 @@ 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( @@ -82,6 +83,25 @@ function PluginInteractables(props: { entry: ReorderableEntry } > {t('PluginListIndex.uninstall')} + {disabled ? + enablePlugin(name)} + > + {t('PluginListIndex.plugin_enable')} + : + + DeckyPluginLoader.disablePlugin( + name, + t('PluginLoader.plugin_disable.title', { name }), + t('PluginLoader.plugin_disable.button'), + t('PluginLoader.plugin_disable.desc', { name }), + ) + } + > + {t('PluginListIndex.plugin_disable')} + + } {hidden ? ( {t('PluginListIndex.show')} ) : ( @@ -147,10 +167,11 @@ type PluginData = { }; export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { - const { plugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState(); + const { installedPlugins, disabled, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = useDeckyState(); + const [_, setPluginOrderSetting] = useSetting( 'pluginOrder', - plugins.map((plugin) => plugin.name), + installedPlugins.map((plugin) => plugin.name), ); const { t } = useTranslation(); @@ -164,15 +185,17 @@ 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: