mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-10 23:41:57 +00:00
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:
@@ -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))
|
||||
@@ -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()}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user