diff options
| author | Jonas Dellinger <jonas@dellinger.dev> | 2022-05-13 19:14:47 +0200 |
|---|---|---|
| committer | Jonas Dellinger <jonas@dellinger.dev> | 2022-05-13 19:14:47 +0200 |
| commit | 74438a31458af8bddd08d90eacc6d63677bab844 (patch) | |
| tree | a7bfc044941f65c7f9971c5386c463eac31be768 /backend | |
| parent | 945db5de4788feefebc845817752472419051640 (diff) | |
| download | decky-loader-74438a31458af8bddd08d90eacc6d63677bab844.tar.gz decky-loader-74438a31458af8bddd08d90eacc6d63677bab844.zip | |
Work on react frontend loader
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/browser.py | 89 | ||||
| -rw-r--r-- | backend/injector.py | 95 | ||||
| -rw-r--r-- | backend/loader.py | 162 | ||||
| -rw-r--r-- | backend/main.py | 85 | ||||
| -rw-r--r-- | backend/plugin.py | 109 | ||||
| -rw-r--r-- | backend/utilities.py | 106 |
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 + } |
