diff options
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | .vscode/tasks.json | 14 | ||||
| -rw-r--r-- | backend/decky_loader/helpers.py | 2 | ||||
| -rw-r--r-- | backend/decky_loader/loader.py | 52 | ||||
| -rw-r--r-- | backend/decky_loader/main.py | 3 | ||||
| -rw-r--r-- | backend/decky_loader/plugin/imports/decky.py (renamed from backend/decky_plugin.py) | 12 | ||||
| -rw-r--r-- | backend/decky_loader/plugin/imports/decky.pyi (renamed from backend/decky_plugin.pyi) | 11 | ||||
| -rw-r--r-- | backend/decky_loader/plugin/plugin.py | 23 | ||||
| -rw-r--r-- | backend/decky_loader/plugin/sandboxed_plugin.py | 32 | ||||
| -rw-r--r-- | backend/decky_loader/utilities.py | 24 | ||||
| -rw-r--r-- | backend/decky_loader/wsrouter.py | 4 | ||||
| -rw-r--r-- | backend/pyproject.toml | 3 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/plugin_list/index.tsx | 12 | ||||
| -rw-r--r-- | frontend/src/logger.ts | 14 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 150 | ||||
| -rw-r--r-- | frontend/src/start.tsx | 18 | ||||
| -rw-r--r-- | frontend/src/wsrouter.ts | 4 |
17 files changed, 239 insertions, 143 deletions
@@ -159,8 +159,8 @@ backend/static .vscode/settings.json # plugins folder for local launches -plugins/* +/plugins/* act/.directory act/artifacts/* bin/act -settings/ +/settings/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ea3ba5f9..3fe52c6f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -37,8 +37,11 @@ "label": "dependencies", "type": "shell", "group": "none", + "dependsOn": [ + "deploy" + ], "detail": "Check for local runs, create a plugins folder", - "command": "rsync -azp --rsh='ssh -p ${config:deckport} ${config:deckkey}' backend/pyproject.toml backend/poetry.lock deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader && ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'python -m ensurepip && python -m pip install --upgrade poetry && cd ${config:deckdir}/homebrew/dev/pluginloader/backend && python -m poetry install'", + "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'python -m ensurepip && python -m pip install --user --upgrade poetry && cd ${config:deckdir}/homebrew/dev/pluginloader/backend && python -m poetry install'", "problemMatcher": [] }, { @@ -105,7 +108,7 @@ "detail": "Deploy dev PluginLoader to deck", "type": "shell", "group": "none", - "command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='backend/decky_loader/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader", + "command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='backend/**/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader", "problemMatcher": [] }, // RUN @@ -117,7 +120,7 @@ "dependsOn": [ "checkforsettings" ], - "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/services; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'", + "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PATH=${config:deckdir}/.local/bin:$PATH; export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/dev/pluginloader/backend; echo '${config:deckpass}' | sudo -SE poetry run sh -c \"cd ${config:deckdir}/homebrew/services; python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py\"'", "problemMatcher": [] }, { @@ -181,7 +184,8 @@ "buildall", "createfolders", "dependencies", - "deploy", + // dependencies runs deploy already + // "deploy", "runpydeck" ], "problemMatcher": [] @@ -190,7 +194,7 @@ "label": "act", "type": "shell", "group": "none", - "detail": "Run the act thing", + "detail": "Build release artifact using local CI", "command": "./act/run-act.sh release", "problemMatcher": [] } diff --git a/backend/decky_loader/helpers.py b/backend/decky_loader/helpers.py index 0cd6518b..76c3db0a 100644 --- a/backend/decky_loader/helpers.py +++ b/backend/decky_loader/helpers.py @@ -34,7 +34,7 @@ def get_csrf_token(): @middleware async def csrf_middleware(request: Request, handler: Handler): - if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): + if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or str(request.rel_url.path) == "/ws" or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): return await handler(request) return Response(text='Forbidden', status=403) diff --git a/backend/decky_loader/loader.py b/backend/decky_loader/loader.py index e5a5cc7b..13e54289 100644 --- a/backend/decky_loader/loader.py +++ b/backend/decky_loader/loader.py @@ -4,15 +4,15 @@ from json.decoder import JSONDecodeError from logging import getLogger from os import listdir, path from pathlib import Path -from traceback import print_exc -from typing import Any, Tuple +from traceback import format_exc, print_exc +from typing import Any, Tuple, Dict from aiohttp import web from os.path import exists from watchdog.events import RegexMatchingEventHandler, DirCreatedEvent, DirModifiedEvent, FileCreatedEvent, FileModifiedEvent # type: ignore from watchdog.observers import Observer # type: ignore -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List if TYPE_CHECKING: from .main import PluginManager @@ -92,13 +92,15 @@ class Loader: server_instance.web_app.add_routes([ web.get("/frontend/{path:.*}", self.handle_frontend_assets), web.get("/locales/{path:.*}", self.handle_frontend_locales), - web.get("/plugins", self.get_plugins), web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle), - web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call), web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_plugin_frontend_assets), - web.post("/plugins/{plugin_name}/reload", self.handle_backend_reload_request) ]) + server_instance.ws.add_route("loader/get_plugins", self.get_plugins) + server_instance.ws.add_route("loader/reload_plugin", self.handle_plugin_backend_reload) + server_instance.ws.add_route("loader/call_plugin_method", self.handle_plugin_method_call) + server_instance.ws.add_route("loader/call_legacy_plugin_method", self.handle_plugin_method_call_legacy) + async def enable_reload_wait(self): if self.live_reload: await sleep(10) @@ -119,9 +121,9 @@ class Loader: self.logger.info(f"Language {req_lang} not available, returning an empty dictionary") return web.json_response(data={}, headers={"Cache-Control": "no-cache"}) - async def get_plugins(self, request: web.Request): + async def get_plugins(self): plugins = list(self.plugins.values()) - return web.json_response([{"name": str(i), "version": i.version} for i in plugins]) + return [{"name": str(i), "version": i.version} for i in plugins] async def handle_plugin_frontend_assets(self, request: web.Request): plugin = self.plugins[request.match_info["plugin_name"]] @@ -173,29 +175,31 @@ class Loader: args = await self.reload_queue.get() self.import_plugin(*args) # type: ignore - async def handle_plugin_method_call(self, request: web.Request): - res = {} - plugin = self.plugins[request.match_info["plugin_name"]] - method_name = request.match_info["method_name"] - try: - method_info = await request.json() - args: Any = method_info["args"] - except JSONDecodeError: - args = {} + async def handle_plugin_method_call_legacy(self, plugin_name: str, method_name: str, kwargs: Dict[Any, Any]): + res: Dict[Any, Any] = {} + plugin = self.plugins[plugin_name] try: if method_name.startswith("_"): - raise RuntimeError("Tried to call private method") - res["result"] = await plugin.execute_method(method_name, args) + raise RuntimeError(f"Plugin {plugin.name} tried to call private method {method_name}") + res["result"] = await plugin.execute_legacy_method(method_name, kwargs) res["success"] = True except Exception as e: res["result"] = str(e) res["success"] = False - return web.json_response(res) + return res - async def handle_backend_reload_request(self, request: web.Request): - plugin_name : str = request.match_info["plugin_name"] + async def handle_plugin_method_call(self, plugin_name: str, method_name: str, *args: List[Any]): plugin = self.plugins[plugin_name] + try: + if method_name.startswith("_"): + raise RuntimeError(f"Plugin {plugin.name} tried to call private method {method_name}") + result = await plugin.execute_method(method_name, *args) + except Exception as e: + self.logger.error(f"Method {method_name} of plugin {plugin.name} failed with the following exception:\n{format_exc()}") + raise e # throw again to pass the error to the frontend + return result - await self.reload_queue.put((plugin.file, plugin.plugin_directory)) + async def handle_plugin_backend_reload(self, plugin_name: str): + plugin = self.plugins[plugin_name] - return web.Response(status=200)
\ No newline at end of file + await self.reload_queue.put((plugin.file, plugin.plugin_directory))
\ No newline at end of file diff --git a/backend/decky_loader/main.py b/backend/decky_loader/main.py index 71bc75f4..bf34ad17 100644 --- a/backend/decky_loader/main.py +++ b/backend/decky_loader/main.py @@ -1,7 +1,6 @@ # Change PyInstaller files permissions import sys from typing import Dict -from wsrouter import WSRouter from .localplatform.localplatform import (chmod, chown, service_stop, service_start, ON_WINDOWS, get_log_level, get_live_reload, get_server_port, get_server_host, get_chown_plugin_path, @@ -32,6 +31,7 @@ from .settings import SettingsManager from .updater import Updater from .utilities import Utilities from .customtypes import UserType +from .wsrouter import WSRouter basicConfig( @@ -102,7 +102,6 @@ class PluginManager: # await self.wait_for_server() logger.debug("Loading plugins") self.plugin_loader.import_plugins() - # await inject_to_tab("SP", "window.syncDeckyPlugins();") if self.settings.getSetting("pluginOrder", None) == None: self.settings.setSetting("pluginOrder", list(self.plugin_loader.plugins.keys())) logger.debug("Did not find pluginOrder setting, set it to default") diff --git a/backend/decky_plugin.py b/backend/decky_loader/plugin/imports/decky.py index 35b872a0..599d142f 100644 --- a/backend/decky_plugin.py +++ b/backend/decky_loader/plugin/imports/decky.py @@ -19,6 +19,8 @@ import subprocess import logging import time +from typing import Dict, Any + """ Constants """ @@ -207,3 +209,13 @@ logger: logging.Logger = logging.getLogger() """The main plugin logger writing to `DECKY_PLUGIN_LOG`.""" logger.setLevel(logging.INFO) + +""" +Event handling +""" +# TODO better docstring im lazy +async def emit_message(message: Dict[Any, Any]) -> None: + """ + Send a message to the frontend. + """ + pass
\ No newline at end of file diff --git a/backend/decky_plugin.pyi b/backend/decky_loader/plugin/imports/decky.pyi index b311a55a..e68b3853 100644 --- a/backend/decky_plugin.pyi +++ b/backend/decky_loader/plugin/imports/decky.pyi @@ -16,6 +16,8 @@ __version__ = '0.1.0' import logging +from typing import Dict, Any + """ Constants """ @@ -171,3 +173,12 @@ Logging logger: logging.Logger """The main plugin logger writing to `DECKY_PLUGIN_LOG`.""" + +""" +Event handling +""" +# TODO better docstring im lazy +async def emit_message(message: Dict[Any, Any]) -> None: + """ + Send a message to the frontend. + """
\ No newline at end of file diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py index 6c338106..01fc048c 100644 --- a/backend/decky_loader/plugin/plugin.py +++ b/backend/decky_loader/plugin/plugin.py @@ -4,11 +4,12 @@ from logging import getLogger from os import path from multiprocessing import Process + from .sandboxed_plugin import SandboxedPlugin from .method_call_request import MethodCallRequest from ..localplatform.localsocket import LocalSocket -from typing import Any, Callable, Coroutine, Dict +from typing import Any, Callable, Coroutine, Dict, List class PluginWrapper: def __init__(self, file: str, plugin_directory: str, plugin_path: str) -> None: @@ -39,6 +40,8 @@ class PluginWrapper: self.emitted_message_callback: Callable[[Dict[Any, Any]], Coroutine[Any, Any, Any]] + self.legacy_method_warning = False + def __str__(self) -> str: return self.name @@ -58,13 +61,27 @@ class PluginWrapper: def set_emitted_message_callback(self, callback: Callable[[Dict[Any, Any]], Coroutine[Any, Any, Any]]): self.emitted_message_callback = callback - async def execute_method(self, method_name: str, kwargs: Dict[Any, Any]): + async def execute_legacy_method(self, method_name: str, kwargs: Dict[Any, Any]): + if not self.legacy_method_warning: + self.legacy_method_warning = True + self.log.warn(f"Plugin {self.name} is using legacy method calls. This will be removed in a future release.") + if self.passive: + raise RuntimeError("This plugin is passive (aka does not implement main.py)") + + request = MethodCallRequest() + await self._socket.get_socket_connection() + await self._socket.write_single_line(dumps({ "method": method_name, "args": kwargs, "id": request.id, "legacy": True }, ensure_ascii=False)) + self._method_call_requests[request.id] = request + + return await request.wait_for_result() + + async def execute_method(self, method_name: str, args: List[Any]): if self.passive: raise RuntimeError("This plugin is passive (aka does not implement main.py)") request = MethodCallRequest() await self._socket.get_socket_connection() - await self._socket.write_single_line(dumps({ "method": method_name, "args": kwargs, "id": request.id }, ensure_ascii=False)) + await self._socket.write_single_line(dumps({ "method": method_name, "args": args, "id": request.id }, ensure_ascii=False)) self._method_call_requests[request.id] = request return await request.wait_for_result() diff --git a/backend/decky_loader/plugin/sandboxed_plugin.py b/backend/decky_loader/plugin/sandboxed_plugin.py index 1c3c5bd2..d07cdc97 100644 --- a/backend/decky_loader/plugin/sandboxed_plugin.py +++ b/backend/decky_loader/plugin/sandboxed_plugin.py @@ -77,17 +77,28 @@ class SandboxedPlugin: keys = [key for key in sysmodules if key.startswith("decky_loader.")] for key in keys: sysmodules[key.replace("decky_loader.", "")] = sysmodules[key] + + from .imports import decky + async def emit_message(message: Dict[Any, Any]): + await self._socket.write_single_line_server(dumps({ + "id": "0", + "payload": message + })) + # copy the docstring over so we don't have to duplicate it + emit_message.__doc__ = decky.emit_message.__doc__ + decky.emit_message = emit_message + sysmodules["decky"] = decky + # provided for compatibility + sysmodules["decky_plugin"] = decky spec = spec_from_file_location("_", self.file) assert spec is not None module = module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) + # TODO fix self weirdness once plugin.json versioning is done. need this before WS release! self.Plugin = module.Plugin - setattr(self.Plugin, "emit_message", self.emit_message) - #TODO: Find how to put emit_message on global namespace so it doesn't pollute Plugin - if hasattr(self.Plugin, "_migration"): get_event_loop().run_until_complete(self.Plugin._migration(self.Plugin)) if hasattr(self.Plugin, "_main"): @@ -124,15 +135,14 @@ class SandboxedPlugin: d: SocketResponseDict = {"res": None, "success": True, "id": data["id"]} try: - d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"]) + if data["legacy"]: + # Legacy kwargs + d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"]) + else: + # New args + d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, *data["args"]) except Exception as e: d["res"] = str(e) d["success"] = False finally: - return dumps(d, ensure_ascii=False) - - async def emit_message(self, message: Dict[Any, Any]): - await self._socket.write_single_line_server(dumps({ - "id": "0", - "payload": message - }))
\ No newline at end of file + return dumps(d, ensure_ascii=False)
\ No newline at end of file diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py index 12dabd68..774f47db 100644 --- a/backend/decky_loader/utilities.py +++ b/backend/decky_loader/utilities.py @@ -59,10 +59,6 @@ class Utilities: self.rdt_proxy_task = None if context: - context.web_app.add_routes([ - web.post("/methods/{method_name}", self._handle_server_method_call) - ]) - context.ws.add_route("utilities/ping", self.ping) context.ws.add_route("utilities/settings/get", self.get_setting) context.ws.add_route("utilities/settings/set", self.set_setting) @@ -81,22 +77,20 @@ class Utilities: context.ws.add_route("utilities/enable_rdt", self.enable_rdt) context.ws.add_route("utilities/get_tab_id", self.get_tab_id) context.ws.add_route("utilities/get_user_info", self.get_user_info) + context.ws.add_route("utilities/http_request", self.http_request) + context.ws.add_route("utilities/_call_legacy_utility", self._call_legacy_utility) - async def _handle_server_method_call(self, request: web.Request): - method_name = request.match_info["method_name"] - try: - args = await request.json() - except JSONDecodeError: - args = {} - res = {} + async def _call_legacy_utility(self, method_name: str, kwargs: Dict[Any, Any]) -> Any: + self.logger.debug(f"Calling utility {method_name} with legacy kwargs"); + res: Dict[Any, Any] = {} try: - r = await self.util_methods[method_name](**args) + r = await self.util_methods[method_name](**kwargs) res["result"] = r res["success"] = True except Exception as e: res["result"] = str(e) res["success"] = False - return web.json_response(res) + return res async def install_plugin(self, artifact: str="", name: str="No name", version: str="dev", hash: str="", install_type: PluginInstallType=PluginInstallType.INSTALL): return await self.context.plugin_browser.request_plugin_install( @@ -121,9 +115,9 @@ class Utilities: async def uninstall_plugin(self, name: str): return await self.context.plugin_browser.uninstall_plugin(name) - async def http_request(self, method: str="", url: str="", **kwargs: Any): + async def http_request(self, method: str, url: str, extra_opts: Any = {}): async with ClientSession() as web: - res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs) + res = await web.request(method, url, ssl=helpers.get_ssl_context(), **extra_opts) text = await res.text() return { "status": res.status, diff --git a/backend/decky_loader/wsrouter.py b/backend/decky_loader/wsrouter.py index 034667ae..cdfe982c 100644 --- a/backend/decky_loader/wsrouter.py +++ b/backend/decky_loader/wsrouter.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from traceback import format_exc -from helpers import get_csrf_token +from .helpers import get_csrf_token class MessageType(IntEnum): ERROR = -1 @@ -43,7 +43,7 @@ Route = Callable[..., Coroutine[Any, Any, Any]] class WSRouter: def __init__(self, loop: AbstractEventLoop, server_instance: Application) -> None: self.loop = loop - self.ws: WebSocketResponse | None + self.ws: WebSocketResponse | None = None self.instance_id = 0 self.routes: Dict[str, Route] = {} # self.subscriptions: Dict[str, Callable[[Any]]] = {} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9fbd2389..5c8ef169 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,8 +6,7 @@ license = "GPLv2" authors = [] packages = [ {include = "decky_loader"}, - {include = "decky_plugin.py"}, - {include = "decky_plugin.pyi"}, + {include = "decky_loader/main.py"} ] include = ["decky_loader/static/*"] diff --git a/frontend/src/components/settings/pages/plugin_list/index.tsx b/frontend/src/components/settings/pages/plugin_list/index.tsx index 09d06d48..6475b40a 100644 --- a/frontend/src/components/settings/pages/plugin_list/index.tsx +++ b/frontend/src/components/settings/pages/plugin_list/index.tsx @@ -35,6 +35,8 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) { type PluginTableData = PluginData & { name: string; hidden: boolean; onHide(): void; onShow(): void }; +const reloadPluginBackend = window.DeckyBackend.callable<[pluginName: string], void>('loader/reload_plugin'); + function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }) { const { t } = useTranslation(); @@ -49,15 +51,9 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> } showContextMenu( <Menu label={t('PluginListIndex.plugin_actions')}> <MenuItem - onSelected={() => { + onSelected={async () => { try { - fetch(`http://127.0.0.1:1337/plugins/${name}/reload`, { - method: 'POST', - credentials: 'include', - headers: { - Authentication: window.deckyAuthToken, - }, - }); + await reloadPluginBackend(name); } catch (err) { console.error('Error Reloading Plugin Backend', err); } diff --git a/frontend/src/logger.ts b/frontend/src/logger.ts index 143bef16..80019fdb 100644 --- a/frontend/src/logger.ts +++ b/frontend/src/logger.ts @@ -18,6 +18,16 @@ export const debug = (name: string, ...args: any[]) => { ); }; +export const warn = (name: string, ...args: any[]) => { + console.warn( + `%c Decky %c ${name} %c`, + 'background: #16a085; color: black;', + 'background: #ffbb00; color: black;', + 'color: blue;', + ...args, + ); +}; + export const error = (name: string, ...args: any[]) => { console.error( `%c Decky %c ${name} %c`, @@ -41,6 +51,10 @@ class Logger { debug(this.name, ...args); } + warn(...args: any[]) { + warn(this.name, ...args); + } + error(...args: any[]) { error(this.name, ...args); } diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 3c9ba818..cba74a9e 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -5,6 +5,7 @@ import { Patch, QuickAccessTab, Router, + findSP, quickAccessMenuClasses, showModal, sleep, @@ -60,7 +61,6 @@ class PluginLoader extends Logger { constructor() { super(PluginLoader.name); this.tabsHook.init(); - this.log('Initialized'); const TabBadge = () => { const { updates, hasLoaderUpdate } = useDeckyState(); @@ -102,9 +102,32 @@ class PluginLoader extends Logger { initFilepickerPatches(); - this.getUserInfo(); + Promise.all([this.getUserInfo(), this.updateVersion()]) + .then(() => this.loadPlugins()) + .then(() => this.checkPluginUpdates()) + .then(() => this.log('Initialized')); + } + + private getPluginsFromBackend = window.DeckyBackend.callable<[], { name: string; version: string }[]>( + 'loader/get_plugins', + ); + + private async loadPlugins() { + // wait for SP window to exist before loading plugins + while (!findSP()) { + await sleep(100); + } + const plugins = await this.getPluginsFromBackend(); + 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)); + } + await Promise.all(pluginLoadPromises); + const loadEnd = performance.now(); + this.log(`Loaded ${plugins.length} plugins in ${loadEnd - loadStart}ms`); - this.updateVersion(); + this.checkPluginUpdates(); } public async getUserInfo() { @@ -217,9 +240,9 @@ class PluginLoader extends Logger { if (val) import('./developer').then((developer) => developer.startup()); }); - //* Grab and set plugin order + // Grab and set plugin order getSetting<string[]>('pluginOrder', []).then((pluginOrder) => { - console.log(pluginOrder); + this.debug('pluginOrder: ', pluginOrder); this.deckyState.setPluginOrder(pluginOrder); }); @@ -236,15 +259,14 @@ class PluginLoader extends Logger { } public unloadPlugin(name: string) { - console.log('Plugin List: ', this.plugins); const plugin = this.plugins.find((plugin) => plugin.name === name); plugin?.onDismount?.(); this.plugins = this.plugins.filter((p) => p !== plugin); this.deckyState.setPlugins(this.plugins); } - public async importPlugin(name: string, version?: string | undefined) { - if (this.reloadLock) { + public async importPlugin(name: string, version?: string | undefined, useQueue: boolean = true) { + if (useQueue && this.reloadLock) { this.log('Reload currently in progress, adding to queue', name); this.pluginReloadQueue.push({ name, version: version }); return; @@ -255,17 +277,21 @@ class PluginLoader extends Logger { this.log(`Trying to load ${name}`); this.unloadPlugin(name); + const startTime = performance.now(); await this.importReactPlugin(name, version); + const endTime = performance.now(); this.deckyState.setPlugins(this.plugins); - this.log(`Loaded ${name}`); + this.log(`Loaded ${name} in ${endTime - startTime}ms`); } catch (e) { throw e; } finally { - this.reloadLock = false; - const nextPlugin = this.pluginReloadQueue.shift(); - if (nextPlugin) { - this.importPlugin(nextPlugin.name, nextPlugin.version); + if (useQueue) { + this.reloadLock = false; + const nextPlugin = this.pluginReloadQueue.shift(); + if (nextPlugin) { + this.importPlugin(nextPlugin.name, nextPlugin.version); + } } } } @@ -337,17 +363,14 @@ class PluginLoader extends Logger { } async callServerMethod(methodName: string, args = {}) { - const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - Authentication: window.deckyAuthToken, - }, - body: JSON.stringify(args), - }); - - return response.json(); + this.warn( + `Calling ${methodName} via callServerMethod, which is deprecated and will be removed in a future release. Please switch to the backend API.`, + ); + return await window.DeckyBackend.call<[methodName: string, kwargs: any], any>( + 'utilities/_call_legacy_utility', + methodName, + args, + ); } openFilePicker( @@ -355,7 +378,7 @@ class PluginLoader extends Logger { selectFiles?: boolean, regex?: RegExp, ): Promise<{ path: string; realpath: string }> { - console.warn('openFilePicker is deprecated and will be removed. Please migrate to openFilePickerV2'); + this.warn('openFilePicker is deprecated and will be removed. Please migrate to openFilePickerV2'); if (selectFiles) { return this.openFilePickerV2(FileSelectionType.FILE, startPath, true, true, regex); } else { @@ -405,45 +428,72 @@ class PluginLoader extends Logger { } createPluginAPI(pluginName: string) { - return { + const pluginAPI = { + backend: { + call<Args extends any[] = any[], Return = void>(method: string, ...args: Args): Promise<Return> { + return window.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); + }, + }, routerHook: this.routerHook, toaster: this.toaster, + // Legacy callServerMethod: this.callServerMethod, openFilePicker: this.openFilePicker, openFilePickerV2: this.openFilePickerV2, + // Legacy async callPluginMethod(methodName: string, args = {}) { - const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - Authentication: window.deckyAuthToken, - }, - body: JSON.stringify({ - args, - }), - }); - - return response.json(); + return window.DeckyBackend.call<[pluginName: string, methodName: string, kwargs: any], any>( + 'loader/call_legacy_plugin_method', + pluginName, + methodName, + args, + ); }, - fetchNoCors(url: string, request: any = {}) { - let args = { method: 'POST', headers: {} }; - const req = { ...args, ...request, url, data: request.body }; + /* 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; - return this.callServerMethod('http_request', req); - }, - executeInTab(tab: string, runAsync: boolean, code: string) { - return this.callServerMethod('execute_in_tab', { - tab, - run_async: runAsync, - code, - }); + 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 window.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() }; + } }, + executeInTab: window.DeckyBackend.callable< + [tab: String, runAsync: Boolean, code: string], + { success: boolean; result: any } + >('utilities/execute_in_tab'), injectCssIntoTab: window.DeckyBackend.callable<[tab: string, style: string], string>( 'utilities/inject_css_into_tab', ), removeCssFromTab: window.DeckyBackend.callable<[tab: string, cssId: string]>('utilities/remove_css_from_tab'), }; + + return pluginAPI; } } diff --git a/frontend/src/start.tsx b/frontend/src/start.tsx index a125e3d4..cc2c02c2 100644 --- a/frontend/src/start.tsx +++ b/frontend/src/start.tsx @@ -10,7 +10,6 @@ declare global { DeckyPluginLoader: PluginLoader; DeckyUpdater?: DeckyUpdater; importDeckyPlugin: Function; - syncDeckyPlugins: Function; deckyHasLoaded: boolean; deckyHasConnectedRDT?: boolean; deckyAuthToken: string; @@ -53,23 +52,6 @@ declare global { window.importDeckyPlugin = function (name: string, version: string) { window.DeckyPluginLoader?.importPlugin(name, version); }; - - window.syncDeckyPlugins = async function () { - const plugins = await ( - await fetch('http://127.0.0.1:1337/plugins', { - credentials: 'include', - headers: { Authentication: window.deckyAuthToken }, - }) - ).json(); - for (const plugin of plugins) { - if (!window.DeckyPluginLoader.hasPlugin(plugin.name)) - window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version); - } - - window.DeckyPluginLoader.checkPluginUpdates(); - }; - - setTimeout(() => window.syncDeckyPlugins(), 5000); })(); export default i18n; diff --git a/frontend/src/wsrouter.ts b/frontend/src/wsrouter.ts index e1d766c3..d3bc45a0 100644 --- a/frontend/src/wsrouter.ts +++ b/frontend/src/wsrouter.ts @@ -1,3 +1,5 @@ +import { sleep } from 'decky-frontend-lib'; + import Logger from './logger'; declare global { @@ -161,6 +163,8 @@ export class WSRouter extends Logger { async onError(error: any) { this.error('WS DISCONNECTED', error); + // TODO queue up lost messages and send them once we connect again + await sleep(5000); await this.connect(); } } |
