Implement legacy & modern plugin method calls over WS

This version builds fine and runs all of the 14 plugins I have installed perfectly, so we're really close to having this done.
This commit is contained in:
AAGaming
2023-12-30 00:46:59 -05:00
parent 6042ca56b8
commit 6522ebf0ca
17 changed files with 240 additions and 144 deletions
+2 -2
View File
@@ -159,8 +159,8 @@ backend/static
.vscode/settings.json .vscode/settings.json
# plugins folder for local launches # plugins folder for local launches
plugins/* /plugins/*
act/.directory act/.directory
act/artifacts/* act/artifacts/*
bin/act bin/act
settings/ /settings/
+9 -5
View File
@@ -37,8 +37,11 @@
"label": "dependencies", "label": "dependencies",
"type": "shell", "type": "shell",
"group": "none", "group": "none",
"dependsOn": [
"deploy"
],
"detail": "Check for local runs, create a plugins folder", "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": [] "problemMatcher": []
}, },
{ {
@@ -105,7 +108,7 @@
"detail": "Deploy dev PluginLoader to deck", "detail": "Deploy dev PluginLoader to deck",
"type": "shell", "type": "shell",
"group": "none", "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": [] "problemMatcher": []
}, },
// RUN // RUN
@@ -117,7 +120,7 @@
"dependsOn": [ "dependsOn": [
"checkforsettings" "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": [] "problemMatcher": []
}, },
{ {
@@ -181,7 +184,8 @@
"buildall", "buildall",
"createfolders", "createfolders",
"dependencies", "dependencies",
"deploy", // dependencies runs deploy already
// "deploy",
"runpydeck" "runpydeck"
], ],
"problemMatcher": [] "problemMatcher": []
@@ -190,7 +194,7 @@
"label": "act", "label": "act",
"type": "shell", "type": "shell",
"group": "none", "group": "none",
"detail": "Run the act thing", "detail": "Build release artifact using local CI",
"command": "./act/run-act.sh release", "command": "./act/run-act.sh release",
"problemMatcher": [] "problemMatcher": []
} }
+1 -1
View File
@@ -34,7 +34,7 @@ def get_csrf_token():
@middleware @middleware
async def csrf_middleware(request: Request, handler: Handler): 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 await handler(request)
return Response(text='Forbidden', status=403) return Response(text='Forbidden', status=403)
+28 -24
View File
@@ -4,15 +4,15 @@ from json.decoder import JSONDecodeError
from logging import getLogger from logging import getLogger
from os import listdir, path from os import listdir, path
from pathlib import Path from pathlib import Path
from traceback import print_exc from traceback import format_exc, print_exc
from typing import Any, Tuple from typing import Any, Tuple, Dict
from aiohttp import web from aiohttp import web
from os.path import exists from os.path import exists
from watchdog.events import RegexMatchingEventHandler, DirCreatedEvent, DirModifiedEvent, FileCreatedEvent, FileModifiedEvent # type: ignore from watchdog.events import RegexMatchingEventHandler, DirCreatedEvent, DirModifiedEvent, FileCreatedEvent, FileModifiedEvent # type: ignore
from watchdog.observers import Observer # type: ignore from watchdog.observers import Observer # type: ignore
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List
if TYPE_CHECKING: if TYPE_CHECKING:
from .main import PluginManager from .main import PluginManager
@@ -92,13 +92,15 @@ class Loader:
server_instance.web_app.add_routes([ server_instance.web_app.add_routes([
web.get("/frontend/{path:.*}", self.handle_frontend_assets), web.get("/frontend/{path:.*}", self.handle_frontend_assets),
web.get("/locales/{path:.*}", self.handle_frontend_locales), 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.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.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): async def enable_reload_wait(self):
if self.live_reload: if self.live_reload:
await sleep(10) await sleep(10)
@@ -119,9 +121,9 @@ class Loader:
self.logger.info(f"Language {req_lang} not available, returning an empty dictionary") self.logger.info(f"Language {req_lang} not available, returning an empty dictionary")
return web.json_response(data={}, headers={"Cache-Control": "no-cache"}) 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()) 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): async def handle_plugin_frontend_assets(self, request: web.Request):
plugin = self.plugins[request.match_info["plugin_name"]] plugin = self.plugins[request.match_info["plugin_name"]]
@@ -173,29 +175,31 @@ class Loader:
args = await self.reload_queue.get() args = await self.reload_queue.get()
self.import_plugin(*args) # type: ignore self.import_plugin(*args) # type: ignore
async def handle_plugin_method_call(self, request: web.Request): async def handle_plugin_method_call_legacy(self, plugin_name: str, method_name: str, kwargs: Dict[Any, Any]):
res = {} res: Dict[Any, Any] = {}
plugin = self.plugins[request.match_info["plugin_name"]] plugin = self.plugins[plugin_name]
method_name = request.match_info["method_name"]
try:
method_info = await request.json()
args: Any = method_info["args"]
except JSONDecodeError:
args = {}
try: try:
if method_name.startswith("_"): if method_name.startswith("_"):
raise RuntimeError("Tried to call private method") raise RuntimeError(f"Plugin {plugin.name} tried to call private method {method_name}")
res["result"] = await plugin.execute_method(method_name, args) res["result"] = await plugin.execute_legacy_method(method_name, kwargs)
res["success"] = True res["success"] = True
except Exception as e: except Exception as e:
res["result"] = str(e) res["result"] = str(e)
res["success"] = False res["success"] = False
return web.json_response(res) return res
async def handle_backend_reload_request(self, request: web.Request): async def handle_plugin_method_call(self, plugin_name: str, method_name: str, *args: List[Any]):
plugin_name : str = request.match_info["plugin_name"] 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
async def handle_plugin_backend_reload(self, plugin_name: str):
plugin = self.plugins[plugin_name] plugin = self.plugins[plugin_name]
await self.reload_queue.put((plugin.file, plugin.plugin_directory)) await self.reload_queue.put((plugin.file, plugin.plugin_directory))
return web.Response(status=200)
+1 -2
View File
@@ -1,7 +1,6 @@
# Change PyInstaller files permissions # Change PyInstaller files permissions
import sys import sys
from typing import Dict from typing import Dict
from wsrouter import WSRouter
from .localplatform.localplatform import (chmod, chown, service_stop, service_start, from .localplatform.localplatform import (chmod, chown, service_stop, service_start,
ON_WINDOWS, get_log_level, get_live_reload, ON_WINDOWS, get_log_level, get_live_reload,
get_server_port, get_server_host, get_chown_plugin_path, get_server_port, get_server_host, get_chown_plugin_path,
@@ -32,6 +31,7 @@ from .settings import SettingsManager
from .updater import Updater from .updater import Updater
from .utilities import Utilities from .utilities import Utilities
from .customtypes import UserType from .customtypes import UserType
from .wsrouter import WSRouter
basicConfig( basicConfig(
@@ -102,7 +102,6 @@ class PluginManager:
# await self.wait_for_server() # await self.wait_for_server()
logger.debug("Loading plugins") logger.debug("Loading plugins")
self.plugin_loader.import_plugins() self.plugin_loader.import_plugins()
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
if self.settings.getSetting("pluginOrder", None) == None: if self.settings.getSetting("pluginOrder", None) == None:
self.settings.setSetting("pluginOrder", list(self.plugin_loader.plugins.keys())) self.settings.setSetting("pluginOrder", list(self.plugin_loader.plugins.keys()))
logger.debug("Did not find pluginOrder setting, set it to default") logger.debug("Did not find pluginOrder setting, set it to default")
@@ -19,6 +19,8 @@ import subprocess
import logging import logging
import time import time
from typing import Dict, Any
""" """
Constants Constants
""" """
@@ -207,3 +209,13 @@ logger: logging.Logger = logging.getLogger()
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`.""" """The main plugin logger writing to `DECKY_PLUGIN_LOG`."""
logger.setLevel(logging.INFO) 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
@@ -16,6 +16,8 @@ __version__ = '0.1.0'
import logging import logging
from typing import Dict, Any
""" """
Constants Constants
""" """
@@ -171,3 +173,12 @@ Logging
logger: logging.Logger logger: logging.Logger
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`.""" """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.
"""
+20 -3
View File
@@ -4,11 +4,12 @@ from logging import getLogger
from os import path from os import path
from multiprocessing import Process from multiprocessing import Process
from .sandboxed_plugin import SandboxedPlugin from .sandboxed_plugin import SandboxedPlugin
from .method_call_request import MethodCallRequest from .method_call_request import MethodCallRequest
from ..localplatform.localsocket import LocalSocket from ..localplatform.localsocket import LocalSocket
from typing import Any, Callable, Coroutine, Dict from typing import Any, Callable, Coroutine, Dict, List
class PluginWrapper: class PluginWrapper:
def __init__(self, file: str, plugin_directory: str, plugin_path: str) -> None: 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.emitted_message_callback: Callable[[Dict[Any, Any]], Coroutine[Any, Any, Any]]
self.legacy_method_warning = False
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
@@ -58,13 +61,27 @@ class PluginWrapper:
def set_emitted_message_callback(self, callback: Callable[[Dict[Any, Any]], Coroutine[Any, Any, Any]]): def set_emitted_message_callback(self, callback: Callable[[Dict[Any, Any]], Coroutine[Any, Any, Any]]):
self.emitted_message_callback = callback 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: if self.passive:
raise RuntimeError("This plugin is passive (aka does not implement main.py)") raise RuntimeError("This plugin is passive (aka does not implement main.py)")
request = MethodCallRequest() request = MethodCallRequest()
await self._socket.get_socket_connection() 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": 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": args, "id": request.id }, ensure_ascii=False))
self._method_call_requests[request.id] = request self._method_call_requests[request.id] = request
return await request.wait_for_result() return await request.wait_for_result()
+20 -10
View File
@@ -78,16 +78,27 @@ class SandboxedPlugin:
for key in keys: for key in keys:
sysmodules[key.replace("decky_loader.", "")] = sysmodules[key] 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) spec = spec_from_file_location("_", self.file)
assert spec is not None assert spec is not None
module = module_from_spec(spec) module = module_from_spec(spec)
assert spec.loader is not None assert spec.loader is not None
spec.loader.exec_module(module) spec.loader.exec_module(module)
# TODO fix self weirdness once plugin.json versioning is done. need this before WS release!
self.Plugin = module.Plugin 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"): if hasattr(self.Plugin, "_migration"):
get_event_loop().run_until_complete(self.Plugin._migration(self.Plugin)) get_event_loop().run_until_complete(self.Plugin._migration(self.Plugin))
if hasattr(self.Plugin, "_main"): if hasattr(self.Plugin, "_main"):
@@ -124,15 +135,14 @@ class SandboxedPlugin:
d: SocketResponseDict = {"res": None, "success": True, "id": data["id"]} d: SocketResponseDict = {"res": None, "success": True, "id": data["id"]}
try: 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: except Exception as e:
d["res"] = str(e) d["res"] = str(e)
d["success"] = False d["success"] = False
finally: finally:
return dumps(d, ensure_ascii=False) 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
}))
+9 -15
View File
@@ -59,10 +59,6 @@ class Utilities:
self.rdt_proxy_task = None self.rdt_proxy_task = None
if context: 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/ping", self.ping)
context.ws.add_route("utilities/settings/get", self.get_setting) context.ws.add_route("utilities/settings/get", self.get_setting)
context.ws.add_route("utilities/settings/set", self.set_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/enable_rdt", self.enable_rdt)
context.ws.add_route("utilities/get_tab_id", self.get_tab_id) 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/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): async def _call_legacy_utility(self, method_name: str, kwargs: Dict[Any, Any]) -> Any:
method_name = request.match_info["method_name"] self.logger.debug(f"Calling utility {method_name} with legacy kwargs");
res: Dict[Any, Any] = {}
try: try:
args = await request.json() r = await self.util_methods[method_name](**kwargs)
except JSONDecodeError:
args = {}
res = {}
try:
r = await self.util_methods[method_name](**args)
res["result"] = r res["result"] = r
res["success"] = True res["success"] = True
except Exception as e: except Exception as e:
res["result"] = str(e) res["result"] = str(e)
res["success"] = False 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): 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( return await self.context.plugin_browser.request_plugin_install(
@@ -121,9 +115,9 @@ class Utilities:
async def uninstall_plugin(self, name: str): async def uninstall_plugin(self, name: str):
return await self.context.plugin_browser.uninstall_plugin(name) 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: 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() text = await res.text()
return { return {
"status": res.status, "status": res.status,
+2 -2
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from traceback import format_exc from traceback import format_exc
from helpers import get_csrf_token from .helpers import get_csrf_token
class MessageType(IntEnum): class MessageType(IntEnum):
ERROR = -1 ERROR = -1
@@ -43,7 +43,7 @@ Route = Callable[..., Coroutine[Any, Any, Any]]
class WSRouter: class WSRouter:
def __init__(self, loop: AbstractEventLoop, server_instance: Application) -> None: def __init__(self, loop: AbstractEventLoop, server_instance: Application) -> None:
self.loop = loop self.loop = loop
self.ws: WebSocketResponse | None self.ws: WebSocketResponse | None = None
self.instance_id = 0 self.instance_id = 0
self.routes: Dict[str, Route] = {} self.routes: Dict[str, Route] = {}
# self.subscriptions: Dict[str, Callable[[Any]]] = {} # self.subscriptions: Dict[str, Callable[[Any]]] = {}
+1 -2
View File
@@ -6,8 +6,7 @@ license = "GPLv2"
authors = [] authors = []
packages = [ packages = [
{include = "decky_loader"}, {include = "decky_loader"},
{include = "decky_plugin.py"}, {include = "decky_loader/main.py"}
{include = "decky_plugin.pyi"},
] ]
include = ["decky_loader/static/*"] include = ["decky_loader/static/*"]
@@ -35,6 +35,8 @@ async function reinstallPlugin(pluginName: string, currentVersion?: string) {
type PluginTableData = PluginData & { name: string; hidden: boolean; onHide(): void; onShow(): void }; 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> }) { function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -49,15 +51,9 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginTableData> }
showContextMenu( showContextMenu(
<Menu label={t('PluginListIndex.plugin_actions')}> <Menu label={t('PluginListIndex.plugin_actions')}>
<MenuItem <MenuItem
onSelected={() => { onSelected={async () => {
try { try {
fetch(`http://127.0.0.1:1337/plugins/${name}/reload`, { await reloadPluginBackend(name);
method: 'POST',
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
} catch (err) { } catch (err) {
console.error('Error Reloading Plugin Backend', err); console.error('Error Reloading Plugin Backend', err);
} }
+14
View File
@@ -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[]) => { export const error = (name: string, ...args: any[]) => {
console.error( console.error(
`%c Decky %c ${name} %c`, `%c Decky %c ${name} %c`,
@@ -41,6 +51,10 @@ class Logger {
debug(this.name, ...args); debug(this.name, ...args);
} }
warn(...args: any[]) {
warn(this.name, ...args);
}
error(...args: any[]) { error(...args: any[]) {
error(this.name, ...args); error(this.name, ...args);
} }
+100 -50
View File
@@ -5,6 +5,7 @@ import {
Patch, Patch,
QuickAccessTab, QuickAccessTab,
Router, Router,
findSP,
quickAccessMenuClasses, quickAccessMenuClasses,
showModal, showModal,
sleep, sleep,
@@ -60,7 +61,6 @@ class PluginLoader extends Logger {
constructor() { constructor() {
super(PluginLoader.name); super(PluginLoader.name);
this.tabsHook.init(); this.tabsHook.init();
this.log('Initialized');
const TabBadge = () => { const TabBadge = () => {
const { updates, hasLoaderUpdate } = useDeckyState(); const { updates, hasLoaderUpdate } = useDeckyState();
@@ -102,9 +102,32 @@ class PluginLoader extends Logger {
initFilepickerPatches(); initFilepickerPatches();
this.getUserInfo(); Promise.all([this.getUserInfo(), this.updateVersion()])
.then(() => this.loadPlugins())
.then(() => this.checkPluginUpdates())
.then(() => this.log('Initialized'));
}
this.updateVersion(); 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.checkPluginUpdates();
} }
public async getUserInfo() { public async getUserInfo() {
@@ -217,9 +240,9 @@ class PluginLoader extends Logger {
if (val) import('./developer').then((developer) => developer.startup()); if (val) import('./developer').then((developer) => developer.startup());
}); });
//* Grab and set plugin order // Grab and set plugin order
getSetting<string[]>('pluginOrder', []).then((pluginOrder) => { getSetting<string[]>('pluginOrder', []).then((pluginOrder) => {
console.log(pluginOrder); this.debug('pluginOrder: ', pluginOrder);
this.deckyState.setPluginOrder(pluginOrder); this.deckyState.setPluginOrder(pluginOrder);
}); });
@@ -236,15 +259,14 @@ class PluginLoader extends Logger {
} }
public unloadPlugin(name: string) { public unloadPlugin(name: string) {
console.log('Plugin List: ', this.plugins);
const plugin = this.plugins.find((plugin) => plugin.name === name); const plugin = this.plugins.find((plugin) => plugin.name === name);
plugin?.onDismount?.(); plugin?.onDismount?.();
this.plugins = this.plugins.filter((p) => p !== plugin); this.plugins = this.plugins.filter((p) => p !== plugin);
this.deckyState.setPlugins(this.plugins); this.deckyState.setPlugins(this.plugins);
} }
public async importPlugin(name: string, version?: string | undefined) { public async importPlugin(name: string, version?: string | undefined, useQueue: boolean = true) {
if (this.reloadLock) { if (useQueue && this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name); this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push({ name, version: version }); this.pluginReloadQueue.push({ name, version: version });
return; return;
@@ -255,17 +277,21 @@ class PluginLoader extends Logger {
this.log(`Trying to load ${name}`); this.log(`Trying to load ${name}`);
this.unloadPlugin(name); this.unloadPlugin(name);
const startTime = performance.now();
await this.importReactPlugin(name, version); await this.importReactPlugin(name, version);
const endTime = performance.now();
this.deckyState.setPlugins(this.plugins); this.deckyState.setPlugins(this.plugins);
this.log(`Loaded ${name}`); this.log(`Loaded ${name} in ${endTime - startTime}ms`);
} catch (e) { } catch (e) {
throw e; throw e;
} finally { } finally {
this.reloadLock = false; if (useQueue) {
const nextPlugin = this.pluginReloadQueue.shift(); this.reloadLock = false;
if (nextPlugin) { const nextPlugin = this.pluginReloadQueue.shift();
this.importPlugin(nextPlugin.name, nextPlugin.version); if (nextPlugin) {
this.importPlugin(nextPlugin.name, nextPlugin.version);
}
} }
} }
} }
@@ -337,17 +363,14 @@ class PluginLoader extends Logger {
} }
async callServerMethod(methodName: string, args = {}) { async callServerMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, { this.warn(
method: 'POST', `Calling ${methodName} via callServerMethod, which is deprecated and will be removed in a future release. Please switch to the backend API.`,
credentials: 'include', );
headers: { return await window.DeckyBackend.call<[methodName: string, kwargs: any], any>(
'Content-Type': 'application/json', 'utilities/_call_legacy_utility',
Authentication: window.deckyAuthToken, methodName,
}, args,
body: JSON.stringify(args), );
});
return response.json();
} }
openFilePicker( openFilePicker(
@@ -355,7 +378,7 @@ class PluginLoader extends Logger {
selectFiles?: boolean, selectFiles?: boolean,
regex?: RegExp, regex?: RegExp,
): Promise<{ path: string; realpath: string }> { ): 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) { if (selectFiles) {
return this.openFilePickerV2(FileSelectionType.FILE, startPath, true, true, regex); return this.openFilePickerV2(FileSelectionType.FILE, startPath, true, true, regex);
} else { } else {
@@ -405,45 +428,72 @@ class PluginLoader extends Logger {
} }
createPluginAPI(pluginName: string) { 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, routerHook: this.routerHook,
toaster: this.toaster, toaster: this.toaster,
// Legacy
callServerMethod: this.callServerMethod, callServerMethod: this.callServerMethod,
openFilePicker: this.openFilePicker, openFilePicker: this.openFilePicker,
openFilePickerV2: this.openFilePickerV2, openFilePickerV2: this.openFilePickerV2,
// Legacy
async callPluginMethod(methodName: string, args = {}) { async callPluginMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, { return window.DeckyBackend.call<[pluginName: string, methodName: string, kwargs: any], any>(
method: 'POST', 'loader/call_legacy_plugin_method',
credentials: 'include', pluginName,
headers: { methodName,
'Content-Type': 'application/json', args,
Authentication: window.deckyAuthToken, );
},
body: JSON.stringify({
args,
}),
});
return response.json();
}, },
fetchNoCors(url: string, request: any = {}) { /* TODO replace with the following flow (or similar) so we can reuse the JS Fetch API
let args = { method: 'POST', headers: {} }; frontend --request URL only--> backend (ws method)
const req = { ...args, ...request, url, data: request.body }; 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; req?.body && delete req.body;
return this.callServerMethod('http_request', req); if (!request.method) {
}, method = 'POST';
executeInTab(tab: string, runAsync: boolean, code: string) { } else {
return this.callServerMethod('execute_in_tab', { method = request.method;
tab, delete req.method;
run_async: runAsync, }
code, // 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>( injectCssIntoTab: window.DeckyBackend.callable<[tab: string, style: string], string>(
'utilities/inject_css_into_tab', 'utilities/inject_css_into_tab',
), ),
removeCssFromTab: window.DeckyBackend.callable<[tab: string, cssId: string]>('utilities/remove_css_from_tab'), removeCssFromTab: window.DeckyBackend.callable<[tab: string, cssId: string]>('utilities/remove_css_from_tab'),
}; };
return pluginAPI;
} }
} }
-18
View File
@@ -10,7 +10,6 @@ declare global {
DeckyPluginLoader: PluginLoader; DeckyPluginLoader: PluginLoader;
DeckyUpdater?: DeckyUpdater; DeckyUpdater?: DeckyUpdater;
importDeckyPlugin: Function; importDeckyPlugin: Function;
syncDeckyPlugins: Function;
deckyHasLoaded: boolean; deckyHasLoaded: boolean;
deckyHasConnectedRDT?: boolean; deckyHasConnectedRDT?: boolean;
deckyAuthToken: string; deckyAuthToken: string;
@@ -53,23 +52,6 @@ declare global {
window.importDeckyPlugin = function (name: string, version: string) { window.importDeckyPlugin = function (name: string, version: string) {
window.DeckyPluginLoader?.importPlugin(name, version); 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; export default i18n;
+4
View File
@@ -1,3 +1,5 @@
import { sleep } from 'decky-frontend-lib';
import Logger from './logger'; import Logger from './logger';
declare global { declare global {
@@ -161,6 +163,8 @@ export class WSRouter extends Logger {
async onError(error: any) { async onError(error: any) {
this.error('WS DISCONNECTED', error); this.error('WS DISCONNECTED', error);
// TODO queue up lost messages and send them once we connect again
await sleep(5000);
await this.connect(); await this.connect();
} }
} }