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>
This commit is contained in:
marios
2022-04-03 23:50:26 +03:00
committed by GitHub
parent fb6f55a44d
commit 5e9c12bac8
24 changed files with 583 additions and 1153 deletions
+85
View File
@@ -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))
+60
View File
@@ -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()}
+77
View File
@@ -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()
+41
View File
@@ -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
});
}
+44
View File
@@ -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");
}
+31
View File
@@ -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>
+18
View File
@@ -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
}