From 6d2e9365c0fb1bea804743245d79a5b97e3af108 Mon Sep 17 00:00:00 2001
From: AAGaming <aagaming@riseup.net>
Date: Wed, 21 Feb 2024 01:08:25 -0500
Subject: more major websocket progress

---
 frontend/src/plugin-loader.tsx | 321 ++++++++++++++++++++++++++---------------
 1 file changed, 203 insertions(+), 118 deletions(-)

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

diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index 43073385..fd5762ea 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -2,7 +2,6 @@ import {
   ModalRoot,
   PanelSection,
   PanelSectionRow,
-  Patch,
   QuickAccessTab,
   Router,
   findSP,
@@ -26,7 +25,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 } from './plugin';
+import { InstallType, Plugin, PluginLoadType } from './plugin';
 import RouterHook from './router-hook';
 import { deinitSteamFixes, initSteamFixes } from './steamfixes';
 import { checkForPluginUpdates } from './store';
@@ -41,6 +40,18 @@ const SettingsPage = lazy(() => import('./components/settings'));
 
 const FilePicker = lazy(() => import('./components/modals/filepicker'));
 
+declare global {
+  interface Window {
+    __DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyPluginBackendAPIInit?: {
+      connect: (version: number, key: string) => any; // Returns the backend API used above, no real point adding types to this.
+    };
+  }
+}
+
+const callPluginMethod = DeckyBackend.callable<[pluginName: string, method: string, ...args: any], any>(
+  'loader/call_plugin_method',
+);
+
 class PluginLoader extends Logger {
   private plugins: Plugin[] = [];
   private tabsHook: TabsHook = new TabsHook();
@@ -55,11 +66,21 @@ class PluginLoader extends Logger {
   private reloadLock: boolean = false;
   // stores a list of plugin names which requested to be reloaded
   private pluginReloadQueue: { name: string; version?: string }[] = [];
-
-  private focusWorkaroundPatch?: Patch;
+  private apiKeys: Map<string, string> = new Map();
 
   constructor() {
     super(PluginLoader.name);
+    console.log(import.meta.url);
+
+    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/add_plugin_install_prompt', this.addPluginInstallPrompt.bind(this));
+    DeckyBackend.addEventListener(
+      'loader/add_multiple_plugins_install_prompt',
+      this.addMultiplePluginsInstallPrompt.bind(this),
+    );
+
     this.tabsHook.init();
 
     const TabBadge = () => {
@@ -108,7 +129,10 @@ class PluginLoader extends Logger {
       .then(() => this.log('Initialized'));
   }
 
-  private getPluginsFromBackend = DeckyBackend.callable<[], { name: string; version: string }[]>('loader/get_plugins');
+  private getPluginsFromBackend = DeckyBackend.callable<
+    [],
+    { name: string; version: string; load_type: PluginLoadType }[]
+  >('loader/get_plugins');
 
   private async loadPlugins() {
     // wait for SP window to exist before loading plugins
@@ -119,7 +143,8 @@ class PluginLoader extends Logger {
     const pluginLoadPromises = [];
     const loadStart = performance.now();
     for (const plugin of plugins) {
-      if (!this.hasPlugin(plugin.name)) pluginLoadPromises.push(this.importPlugin(plugin.name, plugin.version, false));
+      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();
@@ -256,7 +281,6 @@ class PluginLoader extends Logger {
     this.routerHook.removeRoute('/decky/settings');
     deinitSteamFixes();
     deinitFilepickerPatches();
-    this.focusWorkaroundPatch?.unpatch();
   }
 
   public unloadPlugin(name: string) {
@@ -266,7 +290,12 @@ class PluginLoader extends Logger {
     this.deckyState.setPlugins(this.plugins);
   }
 
-  public async importPlugin(name: string, version?: string | undefined, useQueue: boolean = true) {
+  public async importPlugin(
+    name: string,
+    version?: string | undefined,
+    loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
+    useQueue: boolean = true,
+  ) {
     if (useQueue && this.reloadLock) {
       this.log('Reload currently in progress, adding to queue', name);
       this.pluginReloadQueue.push({ name, version: version });
@@ -279,7 +308,7 @@ class PluginLoader extends Logger {
 
       this.unloadPlugin(name);
       const startTime = performance.now();
-      await this.importReactPlugin(name, version);
+      await this.importReactPlugin(name, version, loadType);
       const endTime = performance.now();
 
       this.deckyState.setPlugins(this.plugins);
@@ -297,70 +326,94 @@ class PluginLoader extends Logger {
     }
   }
 
-  private async importReactPlugin(name: string, version?: string) {
-    let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
-      credentials: 'include',
-      headers: {
-        Authentication: deckyAuthToken,
-      },
-    });
-
-    if (res.ok) {
-      try {
-        let plugin_export = await eval(await res.text());
-        let plugin = plugin_export(this.createPluginAPI(name));
-        this.plugins.push({
-          ...plugin,
-          name: name,
-          version: version,
-        });
-      } catch (e) {
-        this.error('Error loading plugin ' + name, e);
-        const TheError: FC<{}> = () => (
-          <PanelSection>
-            <PanelSectionRow>
-              <div
-                className={quickAccessMenuClasses.FriendsTitle}
-                style={{ display: 'flex', justifyContent: 'center' }}
-              >
-                <TranslationHelper trans_class={TranslationClass.PLUGIN_LOADER} trans_text="error" />
-              </div>
-            </PanelSectionRow>
-            <PanelSectionRow>
-              <pre style={{ overflowX: 'scroll' }}>
-                <code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
-              </pre>
-            </PanelSectionRow>
-            <PanelSectionRow>
-              <div className={quickAccessMenuClasses.Text}>
-                <TranslationHelper
-                  trans_class={TranslationClass.PLUGIN_LOADER}
-                  trans_text="plugin_error_uninstall"
-                  i18n_args={{ name: name }}
-                />
-              </div>
-            </PanelSectionRow>
-          </PanelSection>
-        );
-        this.plugins.push({
-          name: name,
-          version: version,
-          content: <TheError />,
-          icon: <FaExclamationCircle />,
-        });
-        this.toaster.toast({
-          title: (
-            <TranslationHelper
-              trans_class={TranslationClass.PLUGIN_LOADER}
-              trans_text="plugin_load_error.toast"
-              i18n_args={{ name: name }}
-            />
-          ),
-          body: '' + e,
-          icon: <FaExclamationCircle />,
-        });
+  private async importReactPlugin(
+    name: string,
+    version?: string,
+    loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
+  ) {
+    try {
+      switch (loadType) {
+        case PluginLoadType.ESMODULE_V1:
+          const uuid = this.initPluginBackendAPIConnection(name);
+          let plugin_export: () => Plugin;
+          try {
+            plugin_export = await import(`http://127.0.0.1:1337/plugins/${name}/dist/index.js#apiKey=${uuid}`);
+          } finally {
+            this.destroyPluginBackendAPIConnection(uuid);
+          }
+          let plugin = plugin_export();
+
+          this.plugins.push({
+            ...plugin,
+            name: name,
+            version: version,
+          });
+          break;
+
+        case PluginLoadType.LEGACY_EVAL_IIFE:
+          let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
+            credentials: 'include',
+            headers: {
+              Authentication: deckyAuthToken,
+            },
+          });
+          if (res.ok) {
+            let plugin_export: (serverAPI: any) => Plugin = await eval(await res.text());
+            let plugin = plugin_export(this.createLegacyPluginAPI(name));
+            this.plugins.push({
+              ...plugin,
+              name: name,
+              version: version,
+            });
+          } else throw new Error(`${name} frontend_bundle not OK`);
+          break;
+
+        default:
+          throw new Error(`${name} has no defined loadType.`);
       }
-    } else throw new Error(`${name} frontend_bundle not OK`);
+    } catch (e) {
+      this.error('Error loading plugin ' + name, e);
+      const TheError: FC<{}> = () => (
+        <PanelSection>
+          <PanelSectionRow>
+            <div className={quickAccessMenuClasses.FriendsTitle} style={{ display: 'flex', justifyContent: 'center' }}>
+              <TranslationHelper trans_class={TranslationClass.PLUGIN_LOADER} trans_text="error" />
+            </div>
+          </PanelSectionRow>
+          <PanelSectionRow>
+            <pre style={{ overflowX: 'scroll' }}>
+              <code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
+            </pre>
+          </PanelSectionRow>
+          <PanelSectionRow>
+            <div className={quickAccessMenuClasses.Text}>
+              <TranslationHelper
+                trans_class={TranslationClass.PLUGIN_LOADER}
+                trans_text="plugin_error_uninstall"
+                i18n_args={{ name: name }}
+              />
+            </div>
+          </PanelSectionRow>
+        </PanelSection>
+      );
+      this.plugins.push({
+        name: name,
+        version: version,
+        content: <TheError />,
+        icon: <FaExclamationCircle />,
+      });
+      this.toaster.toast({
+        title: (
+          <TranslationHelper
+            trans_class={TranslationClass.PLUGIN_LOADER}
+            trans_text="plugin_load_error.toast"
+            i18n_args={{ name: name }}
+          />
+        ),
+        body: '' + e,
+        icon: <FaExclamationCircle />,
+      });
+    }
   }
 
   async callServerMethod(methodName: string, args = {}) {
@@ -374,20 +427,20 @@ class PluginLoader extends Logger {
     );
   }
 
-  openFilePicker(
+  openFilePickerLegacy(
     startPath: string,
     selectFiles?: boolean,
     regex?: RegExp,
   ): Promise<{ path: string; realpath: string }> {
     this.warn('openFilePicker is deprecated and will be removed. Please migrate to openFilePickerV2');
     if (selectFiles) {
-      return this.openFilePickerV2(FileSelectionType.FILE, startPath, true, true, regex);
+      return this.openFilePicker(FileSelectionType.FILE, startPath, true, true, regex);
     } else {
-      return this.openFilePickerV2(FileSelectionType.FOLDER, startPath, false, true, regex);
+      return this.openFilePicker(FileSelectionType.FOLDER, startPath, false, true, regex);
     }
   }
 
-  openFilePickerV2(
+  openFilePicker(
     select: FileSelectionType,
     startPath: string,
     includeFiles?: boolean,
@@ -428,27 +481,84 @@ class PluginLoader extends Logger {
     });
   }
 
-  createPluginAPI(pluginName: string) {
-    const pluginAPI = {
-      backend: {
-        call<Args extends any[] = any[], Return = void>(method: string, ...args: Args): Promise<Return> {
-          return DeckyBackend.call<[pluginName: string, method: string, ...args: Args], Return>(
-            'loader/call_plugin_method',
-            pluginName,
-            method,
-            ...args,
-          );
-        },
-        callable<Args extends any[] = any[], Return = void>(method: string): (...args: Args) => Promise<Return> {
-          return (...args) => pluginAPI.backend.call<Args, Return>(method, ...args);
-        },
+  /* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API
+        frontend --request URL only--> backend (ws method)
+        backend --new temporary backend URL--> frontend (ws response)
+        frontend <--> backend <--> target URL (over http!)
+      */
+  async fetchNoCors(url: string, request: any = {}) {
+    let method: string;
+    const req = { headers: {}, ...request, data: request.body };
+    req?.body && delete req.body;
+    if (!request.method) {
+      method = 'POST';
+    } else {
+      method = request.method;
+      delete req.method;
+    }
+    // this is terrible but a. we're going to redo this entire method anyway and b. it was already terrible
+    try {
+      const ret = await DeckyBackend.call<
+        [method: string, url: string, extra_opts?: any],
+        { status: number; headers: { [key: string]: string }; body: string }
+      >('utilities/http_request', method, url, req);
+      return { success: true, result: ret };
+    } catch (e) {
+      return { success: false, result: e?.toString() };
+    }
+  }
+
+  destroyPluginBackendAPIConnection(uuid: string) {
+    if (this.apiKeys.delete(uuid)) {
+      this.debug(`backend api connection init data destroyed for ${uuid}`);
+    }
+  }
+
+  initPluginBackendAPI() {
+    // Things will break *very* badly if plugin code touches this outside of @decky/backend, so lets make that clear.
+    window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyPluginBackendAPIInit = {
+      connect: (version: number, key: string) => {
+        if (!this.apiKeys.has(key)) {
+          throw new Error(`Backend API key ${key} is invalid.`);
+        }
+
+        const pluginName = this.apiKeys.get(key)!;
+
+        if (version <= 0) {
+          this.destroyPluginBackendAPIConnection(key);
+          throw new Error(`UUID ${key} requested invalid backend api version ${version}.`);
+        }
+
+        const backendAPI = {
+          call: (methodName: string, ...args: any) => {
+            return callPluginMethod(pluginName, methodName, ...args);
+          },
+          callable: (methodName: string) => {
+            return (...args: any) => callPluginMethod(pluginName, methodName, ...args);
+          },
+        };
+
+        this.destroyPluginBackendAPIConnection(key);
+        return backendAPI;
       },
+    };
+  }
+
+  initPluginBackendAPIConnection(pluginName: string) {
+    const key = crypto.randomUUID();
+    this.apiKeys.set(key, pluginName);
+
+    return key;
+  }
+
+  createLegacyPluginAPI(pluginName: string) {
+    const pluginAPI = {
       routerHook: this.routerHook,
       toaster: this.toaster,
       // Legacy
       callServerMethod: this.callServerMethod,
-      openFilePicker: this.openFilePicker,
-      openFilePickerV2: this.openFilePickerV2,
+      openFilePicker: this.openFilePickerLegacy,
+      openFilePickerV2: this.openFilePicker,
       // Legacy
       async callPluginMethod(methodName: string, args = {}) {
         return DeckyBackend.call<[pluginName: string, methodName: string, kwargs: any], any>(
@@ -458,32 +568,7 @@ class PluginLoader extends Logger {
           args,
         );
       },
-      /* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API
-        frontend --request URL only--> backend (ws method)
-        backend --new temporary backend URL--> frontend (ws response)
-        frontend <--> backend <--> target URL (over http!)
-      */
-      async fetchNoCors(url: string, request: any = {}) {
-        let method: string;
-        const req = { headers: {}, ...request, data: request.body };
-        req?.body && delete req.body;
-        if (!request.method) {
-          method = 'POST';
-        } else {
-          method = request.method;
-          delete req.method;
-        }
-        // this is terrible but a. we're going to redo this entire method anyway and b. it was already terrible
-        try {
-          const ret = await DeckyBackend.call<
-            [method: string, url: string, extra_opts?: any],
-            { status: number; headers: { [key: string]: string }; body: string }
-          >('utilities/http_request', method, url, req);
-          return { success: true, result: ret };
-        } catch (e) {
-          return { success: false, result: e?.toString() };
-        }
-      },
+      fetchNoCors: this.fetchNoCors,
       executeInTab: DeckyBackend.callable<
         [tab: String, runAsync: Boolean, code: string],
         { success: boolean; result: any }
-- 
cgit v1.2.3

