summaryrefslogtreecommitdiff
path: root/backend
diff options
context:
space:
mode:
authorJonas Dellinger <jonas@dellinger.dev>2022-05-13 19:14:47 +0200
committerJonas Dellinger <jonas@dellinger.dev>2022-05-13 19:14:47 +0200
commit74438a31458af8bddd08d90eacc6d63677bab844 (patch)
treea7bfc044941f65c7f9971c5386c463eac31be768 /backend
parent945db5de4788feefebc845817752472419051640 (diff)
downloaddecky-loader-74438a31458af8bddd08d90eacc6d63677bab844.tar.gz
decky-loader-74438a31458af8bddd08d90eacc6d63677bab844.zip
Work on react frontend loader
Diffstat (limited to 'backend')
-rw-r--r--backend/browser.py89
-rw-r--r--backend/injector.py95
-rw-r--r--backend/loader.py162
-rw-r--r--backend/main.py85
-rw-r--r--backend/plugin.py109
-rw-r--r--backend/utilities.py106
6 files changed, 646 insertions, 0 deletions
diff --git a/backend/browser.py b/backend/browser.py
new file mode 100644
index 00000000..ffec26b3
--- /dev/null
+++ b/backend/browser.py
@@ -0,0 +1,89 @@
+from injector import get_tab
+from logging import getLogger
+from os import path, rename
+from shutil import rmtree
+from aiohttp import ClientSession, web
+from io import BytesIO
+from zipfile import ZipFile
+from concurrent.futures import ProcessPoolExecutor
+from asyncio import get_event_loop
+from time import time
+from hashlib import sha256
+from subprocess import Popen
+
+class PluginInstallContext:
+ def __init__(self, gh_url, version, hash) -> None:
+ self.gh_url = gh_url
+ self.version = version
+ self.hash = hash
+
+class PluginBrowser:
+ def __init__(self, plugin_path, server_instance, store_url) -> None:
+ self.log = getLogger("browser")
+ self.plugin_path = plugin_path
+ self.store_url = store_url
+ self.install_requests = {}
+
+ server_instance.add_routes([
+ web.post("/browser/install_plugin", self.install_plugin),
+ web.get("/browser/iframe", self.redirect_to_store)
+ ])
+
+ def _unzip_to_plugin_dir(self, zip, name, hash):
+ zip_hash = sha256(zip.getbuffer()).hexdigest()
+ if zip_hash != hash:
+ return False
+ zip_file = ZipFile(zip)
+ zip_file.extractall(self.plugin_path)
+ rename(path.join(self.plugin_path, zip_file.namelist()[0]), path.join(self.plugin_path, name))
+ Popen(["chown", "-R", "deck:deck", self.plugin_path])
+ Popen(["chmod", "-R", "555", self.plugin_path])
+ return True
+
+ async def _install(self, artifact, version, hash):
+ name = artifact.split("/")[-1]
+ rmtree(path.join(self.plugin_path, name), ignore_errors=True)
+ self.log.info(f"Installing {artifact} (Version: {version})")
+ async with ClientSession() as client:
+ url = f"https://github.com/{artifact}/archive/refs/tags/{version}.zip"
+ self.log.debug(f"Fetching {url}")
+ res = await client.get(url)
+ if res.status == 200:
+ self.log.debug("Got 200. Reading...")
+ data = await res.read()
+ self.log.debug(f"Read {len(data)} bytes")
+ res_zip = BytesIO(data)
+ with ProcessPoolExecutor() as executor:
+ self.log.debug("Unzipping...")
+ ret = await get_event_loop().run_in_executor(
+ executor,
+ self._unzip_to_plugin_dir,
+ res_zip,
+ name,
+ hash
+ )
+ if ret:
+ self.log.info(f"Installed {artifact} (Version: {version})")
+ else:
+ self.log.fatal(f"SHA-256 Mismatch!!!! {artifact} (Version: {version})")
+ else:
+ self.log.fatal(f"Could not fetch from github. {await res.text()}")
+
+ async def redirect_to_store(self, request):
+ return web.Response(status=302, headers={"Location": self.store_url})
+
+ async def install_plugin(self, request):
+ data = await request.post()
+ get_event_loop().create_task(self.request_plugin_install(data["artifact"], data["version"], data["hash"]))
+ return web.Response(text="Requested plugin install")
+
+ async def request_plugin_install(self, artifact, version, hash):
+ request_id = str(time())
+ self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
+ tab = await get_tab("QuickAccess")
+ await tab.open_websocket()
+ await tab.evaluate_js(f"addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
+
+ async def confirm_plugin_install(self, request_id):
+ request = self.install_requests.pop(request_id)
+ await self._install(request.gh_url, request.version, request.hash) \ No newline at end of file
diff --git a/backend/injector.py b/backend/injector.py
new file mode 100644
index 00000000..16ced852
--- /dev/null
+++ b/backend/injector.py
@@ -0,0 +1,95 @@
+#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
+
+from asyncio import sleep
+from logging import debug, getLogger
+from traceback import format_exc
+
+from aiohttp import ClientSession
+
+BASE_ADDRESS = "http://localhost:8080"
+
+logger = getLogger("Injector")
+
+class Tab:
+ def __init__(self, res) -> None:
+ self.title = res["title"]
+ self.id = res["id"]
+ self.ws_url = res["webSocketDebuggerUrl"]
+
+ self.websocket = None
+ self.client = None
+
+ async def open_websocket(self):
+ self.client = ClientSession()
+ self.websocket = await self.client.ws_connect(self.ws_url)
+
+ async def listen_for_message(self):
+ async for message in self.websocket:
+ yield message
+
+ async def _send_devtools_cmd(self, dc, receive=True):
+ if self.websocket:
+ await self.websocket.send_json(dc)
+ return (await self.websocket.receive_json()) if receive else None
+ raise RuntimeError("Websocket not opened")
+
+ async def evaluate_js(self, js, run_async=False):
+ await self.open_websocket()
+ res = await self._send_devtools_cmd({
+ "id": 1,
+ "method": "Runtime.evaluate",
+ "params": {
+ "expression": js,
+ "userGesture": True,
+ "awaitPromise": run_async
+ }
+ })
+
+ await self.client.close()
+ return res
+
+ def __repr__(self):
+ return self.title
+
+async def get_tabs():
+ async with ClientSession() as web:
+ res = {}
+
+ while True:
+ try:
+ res = await web.get(f"{BASE_ADDRESS}/json")
+ break
+ except:
+ logger.debug("Steam isn't available yet. Wait for a moment...")
+ logger.debug(format_exc())
+ await sleep(5)
+
+ if res.status == 200:
+ r = await res.json()
+ return [Tab(i) for i in r]
+ else:
+ raise Exception(f"/json did not return 200. {await r.text()}")
+
+async def get_tab(tab_name):
+ tabs = await get_tabs()
+ tab = next((i for i in tabs if i.title == tab_name), None)
+ if not tab:
+ raise ValueError(f"Tab {tab_name} not found")
+ return tab
+
+async def inject_to_tab(tab_name, js, run_async=False):
+ tab = await get_tab(tab_name)
+
+ return await tab.evaluate_js(js, run_async)
+
+async def tab_has_global_var(tab_name, var_name):
+ try:
+ tab = await get_tab(tab_name)
+ except ValueError:
+ return False
+ res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False)
+
+ if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
+ return False
+
+ return res["result"]["result"]["value"]
diff --git a/backend/loader.py b/backend/loader.py
new file mode 100644
index 00000000..e68842e2
--- /dev/null
+++ b/backend/loader.py
@@ -0,0 +1,162 @@
+from asyncio import Queue
+from logging import getLogger
+from os import listdir, path
+from pathlib import Path
+from time import time
+from traceback import print_exc
+
+from aiohttp import web
+from aiohttp_jinja2 import template
+from genericpath import exists
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers.polling import PollingObserver as Observer
+
+from injector import inject_to_tab
+from plugin import PluginWrapper
+
+
+class FileChangeHandler(FileSystemEventHandler):
+ def __init__(self, queue, plugin_path) -> None:
+ super().__init__()
+ self.logger = getLogger("file-watcher")
+ self.plugin_path = plugin_path
+ self.queue = queue
+
+ def maybe_reload(self, src_path):
+ plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0]
+ self.logger.info(path.join(self.plugin_path, plugin_dir, "plugin.json"))
+ if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")):
+ self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True))
+
+ def on_created(self, event):
+ src_path = event.src_path
+ if "__pycache__" in src_path:
+ return
+
+ # check to make sure this isn't a directory
+ if path.isdir(src_path):
+ return
+
+ # get the directory name of the plugin so that we can find its "main.py" and reload it; the
+ # file that changed is not necessarily the one that needs to be reloaded
+ self.logger.debug(f"file created: {src_path}")
+ self.maybe_reload(src_path)
+
+ def on_modified(self, event):
+ src_path = event.src_path
+ if "__pycache__" in src_path:
+ return
+
+ # check to make sure this isn't a directory
+ if path.isdir(src_path):
+ return
+
+ # get the directory name of the plugin so that we can find its "main.py" and reload it; the
+ # file that changed is not necessarily the one that needs to be reloaded
+ self.logger.debug(f"file modified: {src_path}")
+ self.maybe_reload(src_path)
+
+class Loader:
+ def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> None:
+ self.loop = loop
+ self.logger = getLogger("Loader")
+ self.plugin_path = plugin_path
+ self.logger.info(f"plugin_path: {self.plugin_path}")
+ self.plugins = {}
+ self.import_plugins()
+
+ if live_reload:
+ self.reload_queue = Queue()
+ self.observer = Observer()
+ self.observer.schedule(FileChangeHandler(self.reload_queue, plugin_path), self.plugin_path, recursive=True)
+ self.observer.start()
+ self.loop.create_task(self.handle_reloads())
+
+ server_instance.add_routes([
+ web.get("/plugins", self.handle_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.post("/methods/{method_name}", self.handle_server_method_call)
+ ])
+
+ def handle_plugins(self, request):
+ plugins = list(map(lambda kv: dict([("name", kv[0])]), self.plugins.items()))
+ return web.json_response(plugins)
+
+ def handle_frontend_bundle(self, request):
+ plugin = self.plugins[request.match_info["plugin_name"]]
+
+ with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.frontend_bundle), 'r') as bundle:
+ return web.Response(text=bundle.read(), content_type="application/javascript")
+
+ def import_plugin(self, file, plugin_directory, refresh=False):
+ try:
+ plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
+ if plugin.name in self.plugins:
+ if not "debug" in plugin.flags and refresh:
+ self.logger.info(f"Plugin {plugin.name} is already loaded and has requested to not be re-loaded")
+ return
+ else:
+ self.plugins[plugin.name].stop()
+ self.plugins.pop(plugin.name, None)
+ if plugin.passive:
+ self.logger.info(f"Plugin {plugin.name} is passive")
+ self.plugins[plugin.name] = plugin.start()
+ self.logger.info(f"Loaded {plugin.name}")
+ if refresh:
+ self.loop.create_task(self.reload_frontend_plugin(plugin.name))
+ except Exception as e:
+ self.logger.error(f"Could not load {file}. {e}")
+ print_exc()
+
+ async def reload_frontend_plugin(self, name):
+ await inject_to_tab("SP", f"window.DeckyPluginLoader?.loadPlugin('{name}')")
+
+ def import_plugins(self):
+ self.logger.info(f"import plugins from {self.plugin_path}")
+
+ directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))]
+ for directory in directories:
+ self.logger.info(f"found plugin: {directory}")
+ self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory)
+
+ async def handle_reloads(self):
+ while True:
+ args = await self.reload_queue.get()
+ self.import_plugin(*args)
+
+ async def handle_server_method_call(self, request):
+ method_name = request.match_info["method_name"]
+ method_info = await request.json()
+ args = method_info["args"]
+
+ res = {}
+ try:
+ r = await self.utilities.util_methods[method_name](**args)
+ res["result"] = r
+ res["success"] = True
+ except Exception as e:
+ res["result"] = str(e)
+ res["success"] = False
+
+ return web.json_response(res)
+
+ async def handle_plugin_method_call(self, request):
+ res = {}
+ plugin = self.plugins[request.match_info["plugin_name"]]
+ method_name = request.match_info["method_name"]
+
+ method_info = await request.json()
+ args = method_info["args"]
+
+ try:
+ if method_name.startswith("_"):
+ raise RuntimeError("Tried to call private method")
+
+ res["result"] = await plugin.execute_method(method_name, args)
+ res["success"] = True
+ except Exception as e:
+ res["result"] = str(e)
+ res["success"] = False
+
+ return web.json_response(res)
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 00000000..7e10165e
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,85 @@
+from logging import DEBUG, INFO, basicConfig, getLogger
+from os import getenv
+
+CONFIG = {
+ "plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
+ "chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
+ "server_host": getenv("SERVER_HOST", "127.0.0.1"),
+ "server_port": int(getenv("SERVER_PORT", "1337")),
+ "live_reload": getenv("LIVE_RELOAD", "1") == "1",
+ "log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
+ "store_url": getenv("STORE_URL", "https://sdh.tzatzi.me/browse")
+}
+
+basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
+
+from asyncio import get_event_loop, sleep
+from json import dumps, loads
+from os import path
+from subprocess import Popen
+
+import aiohttp_cors
+from aiohttp.web import Application, run_app, static
+from aiohttp_jinja2 import setup as jinja_setup
+from jinja2 import FileSystemLoader
+
+from browser import PluginBrowser
+from injector import get_tab, inject_to_tab, tab_has_global_var
+from loader import Loader
+from utilities import Utilities
+
+logger = getLogger("Main")
+
+async def chown_plugin_dir(_):
+ Popen(["chown", "-R", "deck:deck", CONFIG["plugin_path"]])
+ Popen(["chmod", "-R", "555", CONFIG["plugin_path"]])
+
+class PluginManager:
+ def __init__(self) -> None:
+ self.loop = get_event_loop()
+ self.web_app = Application()
+ self.cors = aiohttp_cors.setup(self.web_app, defaults={
+ "https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
+ allow_headers="*")
+ })
+ self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
+ self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, CONFIG["store_url"])
+ self.utilities = Utilities(self)
+
+ jinja_setup(self.web_app)
+ self.web_app.on_startup.append(self.inject_javascript)
+
+ if CONFIG["chown_plugin_path"] == True:
+ self.web_app.on_startup.append(chown_plugin_dir)
+
+ self.loop.create_task(self.loader_reinjector())
+
+ self.loop.set_exception_handler(self.exception_handler)
+
+ for route in list(self.web_app.router.routes()):
+ self.cors.add(route)
+
+ def exception_handler(self, loop, context):
+ if context["message"] == "Unclosed connection":
+ return
+ loop.default_exception_handler(context)
+
+ async def loader_reinjector(self):
+ while True:
+ await sleep(1)
+ if not await tab_has_global_var("SP", "DeckyPluginLoader"):
+ logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
+ await self.inject_javascript()
+
+ async def inject_javascript(self, request=None):
+ try:
+ await inject_to_tab("SP", open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read(), True)
+ except:
+ logger.info("Failed to inject JavaScript into tab")
+ pass
+
+ def run(self):
+ return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
+
+if __name__ == "__main__":
+ PluginManager().run()
diff --git a/backend/plugin.py b/backend/plugin.py
new file mode 100644
index 00000000..db335225
--- /dev/null
+++ b/backend/plugin.py
@@ -0,0 +1,109 @@
+from asyncio import (Lock, get_event_loop, new_event_loop,
+ open_unix_connection, set_event_loop, sleep,
+ start_unix_server)
+from concurrent.futures import ProcessPoolExecutor
+from importlib.util import module_from_spec, spec_from_file_location
+from json import dumps, load, loads
+from multiprocessing import Process
+from os import path, setuid
+from signal import SIGINT, signal
+from sys import exit
+from time import time
+
+from injector import inject_to_tab
+
+
+class PluginWrapper:
+ def __init__(self, file, plugin_directory, plugin_path) -> None:
+ self.file = file
+ self.plugin_directory = plugin_directory
+ self.reader = None
+ self.writer = None
+ self.socket_addr = f"/tmp/plugin_socket_{time()}"
+ self.method_call_lock = Lock()
+
+ json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
+
+ self.name = json["name"]
+ self.author = json["author"]
+ self.frontend_bundle = json["frontend_bundle"]
+ self.flags = json["flags"]
+
+ self.passive = not path.isfile(self.file)
+
+ def _init(self):
+ signal(SIGINT, lambda s, f: exit(0))
+
+ set_event_loop(new_event_loop())
+ if self.passive:
+ return
+ setuid(0 if "root" in self.flags else 1000)
+ spec = spec_from_file_location("_", self.file)
+ module = module_from_spec(spec)
+ spec.loader.exec_module(module)
+ self.Plugin = module.Plugin
+
+ if hasattr(self.Plugin, "_main"):
+ get_event_loop().create_task(self.Plugin._main(self.Plugin))
+ get_event_loop().create_task(self._setup_socket())
+ get_event_loop().run_forever()
+
+ async def _setup_socket(self):
+ self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr)
+
+ async def _listen_for_method_call(self, reader, writer):
+ while True:
+ data = loads((await reader.readline()).decode("utf-8"))
+ if "stop" in data:
+ get_event_loop().stop()
+ while get_event_loop().is_running():
+ await sleep(0)
+ get_event_loop().close()
+ return
+ d = {"res": None, "success": True}
+ try:
+ d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"])
+ except Exception as e:
+ d["res"] = str(e)
+ d["success"] = False
+ finally:
+ writer.write((dumps(d)+"\n").encode("utf-8"))
+ await writer.drain()
+
+ async def _open_socket_if_not_exists(self):
+ if not self.reader:
+ while True:
+ try:
+ self.reader, self.writer = await open_unix_connection(self.socket_addr)
+ break
+ except:
+ await sleep(0)
+
+ def start(self):
+ if self.passive:
+ return self
+ Process(target=self._init).start()
+ return self
+
+ def stop(self):
+ if self.passive:
+ return
+ async def _(self):
+ await self._open_socket_if_not_exists()
+ self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
+ await self.writer.drain()
+ self.writer.close()
+ get_event_loop().create_task(_(self))
+
+ async def execute_method(self, method_name, kwargs):
+ if self.passive:
+ raise RuntimeError("This plugin is passive (aka does not implement main.py)")
+ async with self.method_call_lock:
+ await self._open_socket_if_not_exists()
+ self.writer.write(
+ (dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
+ await self.writer.drain()
+ res = loads((await self.reader.readline()).decode("utf-8"))
+ if not res["success"]:
+ raise Exception(res["res"])
+ return res["res"]
diff --git a/backend/utilities.py b/backend/utilities.py
new file mode 100644
index 00000000..39f9ca55
--- /dev/null
+++ b/backend/utilities.py
@@ -0,0 +1,106 @@
+from aiohttp import ClientSession
+from injector import inject_to_tab
+import uuid
+
+class Utilities:
+ def __init__(self, context) -> None:
+ self.context = context
+ self.util_methods = {
+ "ping": self.ping,
+ "http_request": self.http_request,
+ "confirm_plugin_install": self.confirm_plugin_install,
+ "execute_in_tab": self.execute_in_tab,
+ "inject_css_into_tab": self.inject_css_into_tab,
+ "remove_css_from_tab": self.remove_css_from_tab
+ }
+
+ async def confirm_plugin_install(self, request_id):
+ return await self.context.plugin_browser.confirm_plugin_install(request_id)
+
+ async def http_request(self, method="", url="", **kwargs):
+ async with ClientSession() as web:
+ async with web.request(method, url, **kwargs) as res:
+ return {
+ "status": res.status,
+ "headers": dict(res.headers),
+ "body": await res.text()
+ }
+
+ async def ping(self, **kwargs):
+ return "pong"
+
+ async def execute_in_tab(self, tab, run_async, code):
+ try:
+ result = await inject_to_tab(tab, code, run_async)
+ if "exceptionDetails" in result["result"]:
+ return {
+ "success": False,
+ "result": result["result"]
+ }
+
+ return {
+ "success": True,
+ "result" : result["result"]["result"].get("value")
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "result": e
+ }
+
+ async def inject_css_into_tab(self, tab, style):
+ try:
+ css_id = str(uuid.uuid4())
+
+ result = await inject_to_tab(tab,
+ f"""
+ (function() {{
+ const style = document.createElement('style');
+ style.id = "{css_id}";
+ document.head.append(style);
+ style.textContent = `{style}`;
+ }})()
+ """, False)
+
+ if "exceptionDetails" in result["result"]:
+ return {
+ "success": False,
+ "result": result["result"]
+ }
+
+ return {
+ "success": True,
+ "result" : css_id
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "result": e
+ }
+
+ async def remove_css_from_tab(self, tab, css_id):
+ try:
+ result = await inject_to_tab(tab,
+ f"""
+ (function() {{
+ let style = document.getElementById("{css_id}");
+
+ if (style.nodeName.toLowerCase() == 'style')
+ style.parentNode.removeChild(style);
+ }})()
+ """, False)
+
+ if "exceptionDetails" in result["result"]:
+ return {
+ "success": False,
+ "result": result
+ }
+
+ return {
+ "success": True
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "result": e
+ }