diff options
Diffstat (limited to 'plugin_loader')
| -rw-r--r-- | plugin_loader/injector.py | 85 | ||||
| -rw-r--r-- | plugin_loader/loader.py | 60 | ||||
| -rw-r--r-- | plugin_loader/main.py | 77 | ||||
| -rw-r--r-- | plugin_loader/static/library.js | 41 | ||||
| -rw-r--r-- | plugin_loader/static/plugin_page.js | 44 | ||||
| -rw-r--r-- | plugin_loader/templates/plugin_view.html | 31 | ||||
| -rw-r--r-- | plugin_loader/utilities.py | 18 |
7 files changed, 356 insertions, 0 deletions
diff --git a/plugin_loader/injector.py b/plugin_loader/injector.py new file mode 100644 index 00000000..771d5c51 --- /dev/null +++ b/plugin_loader/injector.py @@ -0,0 +1,85 @@ +#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there. + +from aiohttp import ClientSession +from logging import info +from asyncio import sleep + +BASE_ADDRESS = "http://localhost:8080" + +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): + await self.open_websocket() + res = await self._send_devtools_cmd({ + "id": 1, + "method": "Runtime.evaluate", + "params": { + "expression": js, + "userGesture": True + } + }) + await self.client.close() + return res + + async def get_steam_resource(self, url): + await self.open_websocket() + res = await self._send_devtools_cmd({ + "id": 1, + "method": "Runtime.evaluate", + "params": { + "expression": f'(async function test() {{ return await (await fetch("{url}")).text() }})()', + "userGesture": True, + "awaitPromise": True + } + }) + await self.client.close() + return res["result"]["result"]["value"] + + def __repr__(self): + return self.title + +async def get_tabs(): + async with ClientSession() as web: + res = {} + + while True: + try: + res = await web.get("{}/json".format(BASE_ADDRESS)) + break + except: + print("Steam isn't available yet. Wait for a moment...") + await sleep(5) + + if res.status == 200: + res = await res.json() + return [Tab(i) for i in res] + else: + raise Exception("/json did not return 200. {}".format(await res.text())) + +async def inject_to_tab(tab_name, js): + tabs = await get_tabs() + tab = next((i for i in tabs if i.title == tab_name), None) + if not tab: + raise ValueError("Tab {} not found in running tabs".format(tab_name)) + info(await tab.evaluate_js(js)) diff --git a/plugin_loader/loader.py b/plugin_loader/loader.py new file mode 100644 index 00000000..0ed58b39 --- /dev/null +++ b/plugin_loader/loader.py @@ -0,0 +1,60 @@ +from aiohttp import web +from aiohttp_jinja2 import template + +from os import path, listdir +from importlib.util import spec_from_file_location, module_from_spec +from logging import getLogger + +import injector + +class Loader: + def __init__(self, server_instance, plugin_path) -> None: + self.logger = getLogger("Loader") + self.plugin_path = plugin_path + self.plugins = self.import_plugins() + + server_instance.add_routes([ + web.get("/plugins/iframe", self.plugin_iframe_route), + web.get("/plugins/reload", self.reload_plugins), + web.post("/plugins/method_call", self.handle_plugin_method_call), + web.get("/plugins/load/{name}", self.load_plugin), + web.get("/steam_resource/{path:.+}", self.get_steam_resource) + ]) + + def import_plugins(self): + files = [i for i in listdir(self.plugin_path) if i.endswith(".py")] + dc = {} + for file in files: + try: + spec = spec_from_file_location("_", path.join(self.plugin_path, file)) + module = module_from_spec(spec) + spec.loader.exec_module(module) + dc[module.Plugin.name] = module.Plugin + self.logger.info("Loaded {}".format(module.Plugin.name)) + except Exception as e: + self.logger.error("Could not load {}. {}".format(file, e)) + return dc + + async def reload_plugins(self, request=None): + self.logger.info("Re-importing all plugins.") + self.plugins = self.import_plugins() + + async def handle_plugin_method_call(self, plugin_name, method_name, **kwargs): + return await getattr(self.plugins[plugin_name], method_name)(**kwargs) + + async def get_steam_resource(self, request): + tab = (await injector.get_tabs())[0] + return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html") + + async def load_plugin(self, request): + plugin = self.plugins[request.match_info["name"]] + ret = """ + <script src="/static/library.js"></script> + <script>const plugin_name = '{}' </script> + {} + """.format(plugin.name, plugin.main_view_html) + return web.Response(text=ret, content_type="text/html") + + @template('plugin_view.html') + async def plugin_iframe_route(self, request): + return {"plugins": self.plugins.values()} diff --git a/plugin_loader/main.py b/plugin_loader/main.py new file mode 100644 index 00000000..4ecb5158 --- /dev/null +++ b/plugin_loader/main.py @@ -0,0 +1,77 @@ +from aiohttp.web import Application, run_app, static +from aiohttp_jinja2 import setup as jinja_setup +from jinja2 import FileSystemLoader +from os import getenv, path +from asyncio import get_event_loop +from json import loads, dumps + +from loader import Loader +from injector import inject_to_tab, get_tabs +from utilities import util_methods + +CONFIG = { + "plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"), + "server_host": getenv("SERVER_HOST", "127.0.0.1"), + "server_port": int(getenv("SERVER_PORT", "1337")) +} + +class PluginManager: + def __init__(self) -> None: + self.loop = get_event_loop() + self.web_app = Application() + self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"]) + + jinja_setup(self.web_app, loader=FileSystemLoader(path.join(path.dirname(__file__), 'templates'))) + self.web_app.on_startup.append(self.inject_javascript) + self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))]) + self.loop.create_task(self.method_call_listener()) + + async def resolve_method_call(self, tab, call_id, response): + await tab._send_devtools_cmd({ + "id": 1, + "method": "Runtime.evaluate", + "params": { + "expression": "resolveMethodCall({}, {})".format(call_id, dumps(response)), + "userGesture": True + } + }, receive=False) + + async def handle_method_call(self, method, tab): + res = {} + try: + if method["method"] == "plugin_method": + res["result"] = await self.plugin_loader.handle_plugin_method_call( + method["args"]["plugin_name"], + method["args"]["method_name"], + **method["args"]["args"] + ) + res["success"] = True + else: + r = await util_methods[method["method"]](**method["args"]) + res["result"] = r + res["success"] = True + except Exception as e: + res["result"] = str(e) + res["success"] = False + finally: + await self.resolve_method_call(tab, method["id"], res) + + async def method_call_listener(self): + tab = next((i for i in await get_tabs() if i.title == "QuickAccess"), None) + await tab.open_websocket() + await tab._send_devtools_cmd({"id": 1, "method": "Runtime.discardConsoleEntries"}) + await tab._send_devtools_cmd({"id": 1, "method": "Runtime.enable"}) + async for message in tab.listen_for_message(): + data = message.json() + if not "id" in data and data["method"] == "Runtime.consoleAPICalled" and data["params"]["type"] == "debug": + method = loads(data["params"]["args"][0]["value"]) + self.loop.create_task(self.handle_method_call(method, tab)) + + async def inject_javascript(self, request=None): + await inject_to_tab("QuickAccess", open(path.join(path.dirname(__file__), "static/plugin_page.js"), "r").read()) + + def run(self): + return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop) + +if __name__ == "__main__": + PluginManager().run()
\ No newline at end of file diff --git a/plugin_loader/static/library.js b/plugin_loader/static/library.js new file mode 100644 index 00000000..1e35eee6 --- /dev/null +++ b/plugin_loader/static/library.js @@ -0,0 +1,41 @@ +class PluginEventTarget extends EventTarget { } +method_call_ev_target = new PluginEventTarget(); + +window.addEventListener("message", function(evt) { + console.log(evt); + let ev = new Event(evt.data.call_id); + ev.data = evt.data.result; + method_call_ev_target.dispatchEvent(ev); +}, false); + +async function call_server_method(method_name, arg_object={}) { + let id = `${new Date().getTime()}`; + console.debug(JSON.stringify({ + "id": id, + "method": method_name, + "args": arg_object + })); + return new Promise((resolve, reject) => { + method_call_ev_target.addEventListener(`${id}`, function (event) { + if (event.data.success) resolve(event.data.result); + else reject(event.data.result); + }); + }); +} + +async function fetch_nocors(url, request={}) { + let args = { method: "POST", headers: {}, body: "" }; + request = {...args, ...request}; + request.url = url; + return await call_server_method("http_request", request); +} + +async function call_plugin_method(method_name, arg_object={}) { + if (plugin_name == undefined) + throw new Error("Plugin methods can only be called from inside plugins (duh)"); + return await call_server_method("plugin_method", { + 'plugin_name': plugin_name, + 'method_name': method_name, + 'args': arg_object + }); +}
\ No newline at end of file diff --git a/plugin_loader/static/plugin_page.js b/plugin_loader/static/plugin_page.js new file mode 100644 index 00000000..c58188db --- /dev/null +++ b/plugin_loader/static/plugin_page.js @@ -0,0 +1,44 @@ +(function () { + const PLUGIN_ICON = ` + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plugin" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M1 8a7 7 0 1 1 2.898 5.673c-.167-.121-.216-.406-.002-.62l1.8-1.8a3.5 3.5 0 0 0 + 4.572-.328l1.414-1.415a.5.5 0 0 0 0-.707l-.707-.707 1.559-1.563a.5.5 0 1 0-.708-.706l-1.559 1.562-1.414-1.414 + 1.56-1.562a.5.5 0 1 0-.707-.706l-1.56 1.56-.707-.706a.5.5 0 0 0-.707 0L5.318 5.975a3.5 3.5 0 0 0-.328 + 4.571l-1.8 1.8c-.58.58-.62 1.6.121 2.137A8 8 0 1 0 0 8a.5.5 0 0 0 1 0Z"/> + </svg> + `; + + function createTitle(text) { + return `<div id="plugin_title" class="quickaccessmenu_Title_34nl5">${text}</div>`; + } + + function createPluginList() { + let pages = document.getElementsByClassName("quickaccessmenu_AllTabContents_2yKG4 quickaccessmenu_Down_3rR0o")[0]; + let pluginPage = pages.children[pages.children.length - 1]; + pluginPage.innerHTML = createTitle("Plugins"); + + pluginPage.innerHTML += `<iframe id="plugin_iframe" style="border: none; width: 100%; height: 100%;" src="http://127.0.0.1:1337/plugins/iframe"></iframe>`; + } + + function inject() { + let tabs = document.getElementsByClassName("quickaccessmenu_TabContentColumn_2z5NL Panel Focusable")[0]; + tabs.children[tabs.children.length - 1].innerHTML = PLUGIN_ICON; + + createPluginList(); + } + + let injector = setInterval(function () { + if (document.hasFocus()) { + inject(); + document.getElementById("plugin_title").onclick = function() { + document.getElementById("plugin_iframe").contentWindow.location.href = "http://127.0.0.1:1337/plugins/iframe"; + } + clearInterval(injector); + } + }, 100); +})(); + +function resolveMethodCall(call_id, result) { + let iframe = document.getElementById("plugin_iframe").contentWindow; + iframe.postMessage({'call_id': call_id, 'result': result}, "http://127.0.0.1:1337"); +}
\ No newline at end of file diff --git a/plugin_loader/templates/plugin_view.html b/plugin_loader/templates/plugin_view.html new file mode 100644 index 00000000..8a482fae --- /dev/null +++ b/plugin_loader/templates/plugin_view.html @@ -0,0 +1,31 @@ +<link rel="stylesheet" href="/steam_resource/css/2.css"> +<link rel="stylesheet" href="/steam_resource/css/39.css"> +<link rel="stylesheet" href="/steam_resource/css/library.css"> + +{% if not plugins|length %} +<div class="basicdialog_Field_ugL9c basicdialog_WithChildrenBelow_1RjOd basicdialog_InlineWrapShiftsChildrenBelow_3a6QZ basicdialog_ExtraPaddingOnChildrenBelow_2-owv basicdialog_StandardPadding_1HrfN basicdialog_HighlightOnFocus_1xh2W Panel Focusable" style="--indent-level:0;"> + <div class="basicdialog_FieldChildren_279n8" style="color: white; font-size: large; padding-top: 10px;"> + No plugins installed :( + </div> +</div> +{% endif %} + +<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable"> + <div class="quickaccesscontrols_PanelSectionRow_26R5w"> + {% for plugin in plugins %} + {% if plugin.tile_view_html|length %} + <div onclick="location.href = '/plugins/load/{{ plugin.name }}'" class="basicdialog_Field_ugL9c basicdialog_WithChildrenBelow_1RjOd basicdialog_InlineWrapShiftsChildrenBelow_3a6QZ basicdialog_ExtraPaddingOnChildrenBelow_2-owv basicdialog_StandardPadding_1HrfN basicdialog_HighlightOnFocus_1xh2W Panel Focusable" style="--indent-level:0;"> + {{ plugin.tile_view_html }} + </div> + {% else %} + <div class="quickaccesscontrols_PanelSectionRow_26R5w"> + <div onclick="location.href = '/plugins/load/{{ plugin.name }}'" class="basicdialog_Field_ugL9c basicdialog_WithChildrenBelow_1RjOd basicdialog_InlineWrapShiftsChildrenBelow_3a6QZ basicdialog_ExtraPaddingOnChildrenBelow_2-owv basicdialog_StandardPadding_1HrfN basicdialog_HighlightOnFocus_1xh2W Panel Focusable" style="--indent-level:0;"> + <div class="basicdialog_FieldChildren_279n8"> + <button type="button" tabindex="0" class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable">{{ plugin.name }}</button> + </div> + </div> + </div> + {% endif %} + {% endfor %} + </div> +</div>
\ No newline at end of file diff --git a/plugin_loader/utilities.py b/plugin_loader/utilities.py new file mode 100644 index 00000000..4e9a1ac0 --- /dev/null +++ b/plugin_loader/utilities.py @@ -0,0 +1,18 @@ +from aiohttp import ClientSession + +async def http_request(method="", url="", **kwargs): + async with ClientSession() as web: + res = await web.request(method, url, **kwargs) + return { + "status": res.status, + "headers": dict(res.headers), + "body": await res.text() + } + +async def ping(**kwargs): + return "pong" + +util_methods = { + "ping": ping, + "http_request": http_request +}
\ No newline at end of file |
