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 <jesse_bofill@yahoo.com>

* 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 <hudson.samuels@gmail.com>

* 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 <marios8543@gmail.com>
Co-authored-by: EMERALD <hudson.samuels@gmail.com>
---
 frontend/src/plugin-loader.tsx | 104 +++++++++++++++++++++++++++++++++++------
 1 file changed, 89 insertions(+), 15 deletions(-)

(limited to 'frontend/src/plugin-loader.tsx')

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()) {
-- 
cgit v1.2.3

