summaryrefslogtreecommitdiff
path: root/plugin_loader
diff options
context:
space:
mode:
authormarios <marios8543@gmail.com>2022-04-03 23:50:26 +0300
committerGitHub <noreply@github.com>2022-04-03 23:50:26 +0300
commit5e9c12bac838730d4e216b3779227a9a94447e40 (patch)
tree61f50207f0d45f6fdab09c31d2b35778df8aff63 /plugin_loader
parentfb6f55a44deef64a0efff9cc645368b946ea897d (diff)
downloaddecky-loader-5e9c12bac838730d4e216b3779227a9a94447e40.tar.gz
decky-loader-5e9c12bac838730d4e216b3779227a9a94447e40.zip
Python rewrite (#6)
* Initial commit. Untested * various fixes Core functionality confirmed working: - Iframe injection into steam client - Plugin fetching from the iframe - Plugin opening * Added function to fetch resources from steam * Improved injector module, added server-js communication - Injector module now has methods for better lower-level manipulation of the tab debug websocket. - Our "front-end" can now communicate with the manager (2-way), completely bypassing the chromium sandbox. This works via a dirty debug console trick, whoever wants to know how it works can take a look at the code. - Added utility methods file, along with an implementation of the aiohttp client that our "front-end" can access, via the system described above. - Added js implementations of the communication system described above, which can be imported by plugins. * Added steam_resource endpoint * Added basic installer script * retry logic bug fix * fixed library injection, event propagation, websocket handling - library is injected directly into the plugins as well as the plugin list - resolveMethodCall is implemented in the plugin_list.js file, which in turns calls window.sendMessage on the iframe to propagate the event - websocket method calls are processed in their own tasks now, so as not to block on long-running calls. Co-authored-by: tza <tza@hidden> Co-authored-by: WerWolv <werwolv98@gmail.com>
Diffstat (limited to 'plugin_loader')
-rw-r--r--plugin_loader/injector.py85
-rw-r--r--plugin_loader/loader.py60
-rw-r--r--plugin_loader/main.py77
-rw-r--r--plugin_loader/static/library.js41
-rw-r--r--plugin_loader/static/plugin_page.js44
-rw-r--r--plugin_loader/templates/plugin_view.html31
-rw-r--r--plugin_loader/utilities.py18
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