mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-12 23:21:55 +00:00
Work on react frontend loader
This commit is contained in:
@@ -19,20 +19,31 @@ jobs:
|
|||||||
- name: 🧰 Checkout
|
- name: 🧰 Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: 💎 Set up NodeJS 17
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 17
|
||||||
|
|
||||||
- name: 🐍 Set up Python 3.10
|
- name: 🐍 Set up Python 3.10
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
|
||||||
- name: ⬇️ Install dependencies
|
- name: ⬇️ Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||||
|
|
||||||
|
- name: ⬇️ Install NodeJS dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm i
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: 🛠️ Build
|
- name: 🛠️ Build
|
||||||
run: |
|
run: |
|
||||||
pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./plugin_loader/static:/static --add-data ./plugin_loader/templates:/templates ./plugin_loader/*.py
|
pyinstaller --noconfirm --onefile --name "Decky" --add-data ./backend/static:/static ./backend/*.py
|
||||||
|
|
||||||
- name: ⬆️ Upload package
|
- name: ⬆️ Upload package
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
|
|||||||
@@ -150,3 +150,6 @@ dmypy.json
|
|||||||
|
|
||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
|
# static files are built
|
||||||
|
backend/static
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
|
#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
from logging import debug, getLogger
|
|
||||||
from asyncio import sleep
|
from asyncio import sleep
|
||||||
|
from logging import debug, getLogger
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
BASE_ADDRESS = "http://localhost:8080"
|
BASE_ADDRESS = "http://localhost:8080"
|
||||||
|
|
||||||
logger = getLogger("Injector")
|
logger = getLogger("Injector")
|
||||||
@@ -43,13 +44,10 @@ class Tab:
|
|||||||
"awaitPromise": run_async
|
"awaitPromise": run_async
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await self.client.close()
|
await self.client.close()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def get_steam_resource(self, url):
|
|
||||||
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
|
||||||
return res["result"]["result"]["value"]
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
@@ -84,12 +82,12 @@ async def inject_to_tab(tab_name, js, run_async=False):
|
|||||||
|
|
||||||
return await tab.evaluate_js(js, run_async)
|
return await tab.evaluate_js(js, run_async)
|
||||||
|
|
||||||
async def tab_has_element(tab_name, element_name):
|
async def tab_has_global_var(tab_name, var_name):
|
||||||
try:
|
try:
|
||||||
tab = await get_tab(tab_name)
|
tab = await get_tab(tab_name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null", 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"]:
|
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
|
||||||
return False
|
return False
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
from importlib.util import spec_from_file_location, module_from_spec
|
from asyncio import (Lock, get_event_loop, new_event_loop,
|
||||||
from asyncio import get_event_loop, new_event_loop, set_event_loop, start_unix_server, open_unix_connection, sleep, Lock
|
open_unix_connection, set_event_loop, sleep,
|
||||||
from os import path, setuid
|
start_unix_server)
|
||||||
from json import loads, dumps, load
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from time import time
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from json import dumps, load, loads
|
||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
from signal import signal, SIGINT
|
from os import path, setuid
|
||||||
|
from signal import SIGINT, signal
|
||||||
from sys import exit
|
from sys import exit
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
from injector import inject_to_tab
|
||||||
|
|
||||||
|
|
||||||
class PluginWrapper:
|
class PluginWrapper:
|
||||||
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
||||||
@@ -20,8 +26,7 @@ class PluginWrapper:
|
|||||||
|
|
||||||
self.name = json["name"]
|
self.name = json["name"]
|
||||||
self.author = json["author"]
|
self.author = json["author"]
|
||||||
self.main_view_html = json["main_view_html"]
|
self.frontend_bundle = json["frontend_bundle"]
|
||||||
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
|
||||||
self.flags = json["flags"]
|
self.flags = json["flags"]
|
||||||
|
|
||||||
self.passive = not path.isfile(self.file)
|
self.passive = not path.isfile(self.file)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
cd frontend && npm run lint
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
semi: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
singleQuote: true,
|
||||||
|
printWidth: 120,
|
||||||
|
tabWidth: 2,
|
||||||
|
endOfLine: 'auto',
|
||||||
|
plugins: [require('prettier-plugin-import-sort')],
|
||||||
|
};
|
||||||
Generated
+2323
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "decky_frontend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"license": "GPLV2",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cd .. && husky install frontend/.husky",
|
||||||
|
"build": "rollup -c",
|
||||||
|
"lint": "prettier -c src",
|
||||||
|
"format": "prettier -c src -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^22.0.0",
|
||||||
|
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||||
|
"@rollup/plugin-typescript": "^8.3.2",
|
||||||
|
"husky": "^8.0.1",
|
||||||
|
"import-sort-style-module": "^6.0.0",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"prettier-plugin-import-sort": "^0.0.7",
|
||||||
|
"rollup": "^2.71.1"
|
||||||
|
},
|
||||||
|
"importSort": {
|
||||||
|
".js, .jsx, .ts, .tsx": {
|
||||||
|
"style": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
|
import resolve from '@rollup/plugin-node-resolve';
|
||||||
|
import typescript from '@rollup/plugin-typescript';
|
||||||
|
|
||||||
|
|
||||||
|
/** @type {import('rollup').RollupOptions} */
|
||||||
|
const options = {
|
||||||
|
input: 'src/index.ts',
|
||||||
|
output: {
|
||||||
|
file: '../backend/static/plugin-loader.iife.js',
|
||||||
|
format: 'iife',
|
||||||
|
},
|
||||||
|
plugins: [commonjs(), resolve(), typescript()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default options
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import PluginLoader from './plugin-loader';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
DeckyPluginLoader?: PluginLoader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.DeckyPluginLoader) {
|
||||||
|
window.DeckyPluginLoader?.dismountAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DeckyPluginLoader = new PluginLoader();
|
||||||
|
setTimeout(async () => {
|
||||||
|
window.DeckyPluginLoader?.loadAllPlugins();
|
||||||
|
}, 5000);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const log = (name: string, ...args: any[]) => {
|
||||||
|
console.log(
|
||||||
|
`%c Decky %c ${name} %c`,
|
||||||
|
'background: #16a085; color: black;',
|
||||||
|
'background: #1abc9c; color: black;',
|
||||||
|
'background: transparent;',
|
||||||
|
...args,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const error = (name: string, ...args: any[]) => {
|
||||||
|
console.log(
|
||||||
|
`%c Decky %c ${name} %c`,
|
||||||
|
'background: #16a085; color: black;',
|
||||||
|
'background: #FF0000;',
|
||||||
|
'background: transparent;',
|
||||||
|
...args,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
constructor(private name: string) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(...args: any[]) {
|
||||||
|
log(this.name, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(...args: any[]) {
|
||||||
|
log(this.name, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logger;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Logger from './logger';
|
||||||
|
import TabsHook from './tabs-hook';
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
title: any;
|
||||||
|
content: any;
|
||||||
|
icon: any;
|
||||||
|
onDismount?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PluginLoader extends Logger {
|
||||||
|
private pluginInstances: Record<string, Plugin> = {};
|
||||||
|
private tabsHook: TabsHook;
|
||||||
|
private lock = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(PluginLoader.name);
|
||||||
|
|
||||||
|
this.log('Initialized');
|
||||||
|
this.tabsHook = new TabsHook();
|
||||||
|
}
|
||||||
|
|
||||||
|
dismountPlugin(name: string) {
|
||||||
|
this.log(`Dismounting ${name}`);
|
||||||
|
this.pluginInstances[name]?.onDismount?.();
|
||||||
|
delete this.pluginInstances[name];
|
||||||
|
this.tabsHook.removeById(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllPlugins() {
|
||||||
|
this.log('Loading all plugins');
|
||||||
|
const plugins = await (await fetch(`http://127.0.0.1:1337/plugins`)).json();
|
||||||
|
this.log('Received:', plugins);
|
||||||
|
|
||||||
|
return Promise.all(plugins.map((plugin) => this.loadPlugin(plugin.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPlugin(name) {
|
||||||
|
this.log('Loading Plugin:', name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this.lock === 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
this.lock = 1;
|
||||||
|
|
||||||
|
if (this.pluginInstances[name]) {
|
||||||
|
this.dismountPlugin(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
|
||||||
|
const code = await response.text();
|
||||||
|
|
||||||
|
const pluginAPI = PluginLoader.createPluginAPI(name);
|
||||||
|
this.pluginInstances[name] = await eval(code)(pluginAPI);
|
||||||
|
|
||||||
|
const { title, icon, content } = this.pluginInstances[name];
|
||||||
|
this.tabsHook.add({
|
||||||
|
id: name,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
this.lock = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismountAll() {
|
||||||
|
for (const name of Object.keys(this.pluginInstances)) {
|
||||||
|
this.dismountPlugin(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static createPluginAPI(pluginName) {
|
||||||
|
return {
|
||||||
|
async callServerMethod(methodName, args = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(args),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
async callPluginMethod(methodName, args = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
args,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
fetchNoCors(url, request: any = {}) {
|
||||||
|
let args = { method: 'POST', headers: {}, body: '' };
|
||||||
|
const req = { ...args, ...request, url, data: request.body };
|
||||||
|
return this.callServerMethod('http_request', req);
|
||||||
|
},
|
||||||
|
executeInTab(tab, runAsync, code) {
|
||||||
|
return this.callServerMethod('execute_in_tab', {
|
||||||
|
tab,
|
||||||
|
run_async: runAsync,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
injectCssIntoTab(tab, style) {
|
||||||
|
return this.callServerMethod('inject_css_into_tab', {
|
||||||
|
tab,
|
||||||
|
style,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeCssFromTab(tab, cssId) {
|
||||||
|
return this.callServerMethod('remove_css_from_tab', {
|
||||||
|
tab,
|
||||||
|
css_id: cssId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginLoader;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Logger from './logger';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__TABS_HOOK_INSTANCE: any;
|
||||||
|
}
|
||||||
|
interface Array<T> {
|
||||||
|
__filter: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTabsArray = (tabs) => {
|
||||||
|
const length = tabs.length;
|
||||||
|
return length === 7 && tabs[length - 1]?.key === 6 && tabs[length - 1]?.tab;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: string;
|
||||||
|
title: any;
|
||||||
|
content: any;
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TabsHook extends Logger {
|
||||||
|
// private keys = 7;
|
||||||
|
tabs: Tab[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('TabsHook');
|
||||||
|
|
||||||
|
this.log('Initialized');
|
||||||
|
window.__TABS_HOOK_INSTANCE = this;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
const filter = Array.prototype.__filter ?? Array.prototype.filter;
|
||||||
|
Array.prototype.__filter = filter;
|
||||||
|
Array.prototype.filter = function (...args) {
|
||||||
|
if (isTabsArray(this)) {
|
||||||
|
self.render(this);
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
return filter.call(this, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
add(tab: Tab) {
|
||||||
|
this.log('Adding tab', tab.id, 'to render array');
|
||||||
|
this.tabs.push(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeById(id: string) {
|
||||||
|
this.log('Removing tab', id);
|
||||||
|
this.tabs = this.tabs.filter((tab) => tab.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(existingTabs: any[]) {
|
||||||
|
for (const { title, icon, content, id } of this.tabs) {
|
||||||
|
existingTabs.push({
|
||||||
|
key: id,
|
||||||
|
title,
|
||||||
|
tab: icon,
|
||||||
|
panel: content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabsHook;
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
from aiohttp import web
|
|
||||||
from aiohttp_jinja2 import template
|
|
||||||
from watchdog.observers.polling import PollingObserver as Observer
|
|
||||||
from watchdog.events import FileSystemEventHandler
|
|
||||||
from asyncio import Queue
|
|
||||||
from os import path, listdir
|
|
||||||
from logging import getLogger
|
|
||||||
from time import time
|
|
||||||
|
|
||||||
from injector import get_tabs, get_tab
|
|
||||||
from plugin import PluginWrapper
|
|
||||||
from traceback import print_exc
|
|
||||||
|
|
||||||
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 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}")
|
|
||||||
rel_path = path.relpath(src_path, path.commonprefix([self.plugin_path, src_path]))
|
|
||||||
plugin_dir = path.split(rel_path)[0]
|
|
||||||
main_file_path = path.join(self.plugin_path, plugin_dir, "main.py")
|
|
||||||
self.queue.put_nowait((main_file_path, plugin_dir, True))
|
|
||||||
|
|
||||||
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}")
|
|
||||||
plugin_dir = path.split(path.relpath(src_path, path.commonprefix([self.plugin_path, src_path])))[0]
|
|
||||||
self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True))
|
|
||||||
|
|
||||||
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.callsigns = {}
|
|
||||||
self.callsign_matches = {}
|
|
||||||
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/iframe", self.plugin_iframe_route),
|
|
||||||
web.get("/plugins/load_main/{name}", self.load_plugin_main_view),
|
|
||||||
web.get("/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route),
|
|
||||||
web.get("/plugins/load_tile/{name}", self.load_plugin_tile_view),
|
|
||||||
web.get("/steam_resource/{path:.+}", self.get_steam_resource)
|
|
||||||
])
|
|
||||||
|
|
||||||
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)
|
|
||||||
self.callsigns.pop(self.callsign_matches[file], None)
|
|
||||||
if plugin.passive:
|
|
||||||
self.logger.info(f"Plugin {plugin.name} is passive")
|
|
||||||
callsign = str(time())
|
|
||||||
plugin.callsign = callsign
|
|
||||||
self.plugins[plugin.name] = plugin.start()
|
|
||||||
self.callsigns[callsign] = plugin
|
|
||||||
self.callsign_matches[file] = callsign
|
|
||||||
self.logger.info(f"Loaded {plugin.name}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Could not load {file}. {e}")
|
|
||||||
print_exc()
|
|
||||||
finally:
|
|
||||||
if refresh:
|
|
||||||
self.loop.create_task(self.refresh_iframe())
|
|
||||||
|
|
||||||
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_plugin_method_call(self, callsign, method_name, **kwargs):
|
|
||||||
if method_name.startswith("_"):
|
|
||||||
raise RuntimeError("Tried to call private method")
|
|
||||||
return await self.callsigns[callsign].execute_method(method_name, kwargs)
|
|
||||||
|
|
||||||
async def get_steam_resource(self, request):
|
|
||||||
tab = (await get_tabs())[0]
|
|
||||||
try:
|
|
||||||
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
|
|
||||||
except Exception as e:
|
|
||||||
return web.Response(text=str(e), status=400)
|
|
||||||
|
|
||||||
async def load_plugin_main_view(self, request):
|
|
||||||
plugin = self.callsigns[request.match_info["name"]]
|
|
||||||
|
|
||||||
# open up the main template
|
|
||||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), 'r') as template:
|
|
||||||
template_data = template.read()
|
|
||||||
# setup the main script, plugin, and pull in the template
|
|
||||||
ret = f"""
|
|
||||||
<script src="/static/library.js"></script>
|
|
||||||
<script>const plugin_name = '{plugin.callsign}' </script>
|
|
||||||
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.callsign}/">
|
|
||||||
{template_data}
|
|
||||||
"""
|
|
||||||
return web.Response(text=ret, content_type="text/html")
|
|
||||||
|
|
||||||
async def handle_sub_route(self, request):
|
|
||||||
plugin = self.callsigns[request.match_info["name"]]
|
|
||||||
route_path = request.match_info["path"]
|
|
||||||
self.logger.info(path)
|
|
||||||
|
|
||||||
ret = ""
|
|
||||||
|
|
||||||
file_path = path.join(self.plugin_path, plugin.plugin_directory, route_path)
|
|
||||||
with open(file_path, 'r') as resource_data:
|
|
||||||
ret = resource_data.read()
|
|
||||||
|
|
||||||
return web.Response(text=ret)
|
|
||||||
|
|
||||||
async def load_plugin_tile_view(self, request):
|
|
||||||
plugin = self.callsigns[request.match_info["name"]]
|
|
||||||
|
|
||||||
inner_content = ""
|
|
||||||
|
|
||||||
# open up the tile template (if we have one defined)
|
|
||||||
if hasattr(plugin, "tile_view_html"):
|
|
||||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.tile_view_html), 'r') as template:
|
|
||||||
template_data = template.read()
|
|
||||||
inner_content = template_data
|
|
||||||
|
|
||||||
# setup the default template
|
|
||||||
ret = f"""
|
|
||||||
<html style="height: fit-content;">
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
<script src="/static/library.js"></script>
|
|
||||||
<script>const plugin_name = '{plugin.callsign}';</script>
|
|
||||||
</head>
|
|
||||||
<body style="height: fit-content; display: block;">
|
|
||||||
{inner_content}
|
|
||||||
</body>
|
|
||||||
<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()}
|
|
||||||
|
|
||||||
async def refresh_iframe(self):
|
|
||||||
tab = await get_tab("QuickAccess")
|
|
||||||
await tab.open_websocket()
|
|
||||||
return await tab.evaluate_js("reloadIframe()", False)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from logging import getLogger, basicConfig, INFO, DEBUG, Filter, root
|
|
||||||
from os import getenv
|
|
||||||
|
|
||||||
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")),
|
|
||||||
"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://plugins.deckbrew.xyz"),
|
|
||||||
"log_base_events": getenv("LOG_BASE_EVENTS", "0")=="1"
|
|
||||||
}
|
|
||||||
|
|
||||||
class NoBaseEvents(Filter):
|
|
||||||
def filter(self, record):
|
|
||||||
return not "asyncio" in record.name
|
|
||||||
|
|
||||||
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
|
||||||
for handler in root.handlers:
|
|
||||||
if not CONFIG["log_base_events"]:
|
|
||||||
handler.addFilter(NoBaseEvents())
|
|
||||||
|
|
||||||
from aiohttp.web import Application, run_app, static
|
|
||||||
from aiohttp_jinja2 import setup as jinja_setup
|
|
||||||
from jinja2 import FileSystemLoader
|
|
||||||
from os import path
|
|
||||||
from asyncio import get_event_loop, sleep
|
|
||||||
from json import loads, dumps
|
|
||||||
from subprocess import Popen
|
|
||||||
|
|
||||||
from loader import Loader
|
|
||||||
from injector import inject_to_tab, get_tab, tab_has_element
|
|
||||||
from utilities import Utilities
|
|
||||||
from browser import PluginBrowser
|
|
||||||
|
|
||||||
logger = getLogger("Main")
|
|
||||||
from traceback import print_exc
|
|
||||||
|
|
||||||
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.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, loader=FileSystemLoader(path.join(path.dirname(__file__), 'templates')))
|
|
||||||
self.web_app.on_startup.append(self.inject_javascript)
|
|
||||||
self.web_app.on_startup.append(chown_plugin_dir)
|
|
||||||
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
|
|
||||||
self.loop.create_task(self.method_call_listener())
|
|
||||||
self.loop.create_task(self.loader_reinjector())
|
|
||||||
|
|
||||||
self.loop.set_exception_handler(self.exception_handler)
|
|
||||||
|
|
||||||
def exception_handler(self, loop, context):
|
|
||||||
if context["message"] == "Unclosed connection":
|
|
||||||
return
|
|
||||||
loop.default_exception_handler(context)
|
|
||||||
|
|
||||||
async def loader_reinjector(self):
|
|
||||||
finished_reinjection = False
|
|
||||||
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
|
|
||||||
while True:
|
|
||||||
await sleep(1)
|
|
||||||
if not await tab_has_element("QuickAccess", "plugin_iframe"):
|
|
||||||
logger.debug("Plugin loader isn't present in Steam anymore, reinjecting...")
|
|
||||||
await self.inject_javascript()
|
|
||||||
finished_reinjection = True
|
|
||||||
elif finished_reinjection:
|
|
||||||
finished_reinjection = False
|
|
||||||
logger.info("Reinjecting successful!")
|
|
||||||
|
|
||||||
self.loop.create_task(self.method_call_listener())
|
|
||||||
|
|
||||||
async def inject_javascript(self, request=None):
|
|
||||||
try:
|
|
||||||
await inject_to_tab("QuickAccess", open(path.join(path.dirname(__file__), "static/library.js"), "r").read())
|
|
||||||
await inject_to_tab("QuickAccess", open(path.join(path.dirname(__file__), "static/plugin_page.js"), "r").read())
|
|
||||||
except:
|
|
||||||
logger.info("Failed to inject JavaScript into tab")
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def resolve_method_call(self, tab, call_id, response):
|
|
||||||
try:
|
|
||||||
r = dumps(response)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(response["result"])
|
|
||||||
response["result"] = str(response["result"])
|
|
||||||
r = response
|
|
||||||
await tab._send_devtools_cmd({
|
|
||||||
"id": 1,
|
|
||||||
"method": "Runtime.evaluate",
|
|
||||||
"params": {
|
|
||||||
"expression": f"resolveMethodCall({call_id}, {r})",
|
|
||||||
"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 self.utilities.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):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
tab = await get_tab("QuickAccess")
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
await sleep(1)
|
|
||||||
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))
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
class PluginEventTarget extends EventTarget { }
|
|
||||||
method_call_ev_target = new PluginEventTarget();
|
|
||||||
|
|
||||||
window.addEventListener("message", function(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 = `${uuidv4()}`;
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source: https://stackoverflow.com/a/2117523 Thanks!
|
|
||||||
function uuidv4() {
|
|
||||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
|
||||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetch_nocors(url, request={}) {
|
|
||||||
let args = { method: "POST", headers: {}, body: "" };
|
|
||||||
request = {...args, ...request};
|
|
||||||
request.url = url;
|
|
||||||
request.data = request.body;
|
|
||||||
delete request.body; //maintain api-compatibility with fetch
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function execute_in_tab(tab, run_async, code) {
|
|
||||||
return await call_server_method("execute_in_tab", {
|
|
||||||
'tab': tab,
|
|
||||||
'run_async': run_async,
|
|
||||||
'code': code
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inject_css_into_tab(tab, style) {
|
|
||||||
return await call_server_method("inject_css_into_tab", {
|
|
||||||
'tab': tab,
|
|
||||||
'style': style
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove_css_from_tab(tab, css_id) {
|
|
||||||
return await call_server_method("remove_css_from_tab", {
|
|
||||||
'tab': tab,
|
|
||||||
'css_id': css_id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
function reloadIframe() {
|
|
||||||
document.getElementById("plugin_iframe").contentWindow.location.href = "http://127.0.0.1:1337/plugins/iframe";
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
function installPlugin(request_id) {
|
|
||||||
let id = `${new Date().getTime()}`;
|
|
||||||
console.debug(JSON.stringify({
|
|
||||||
"id": id,
|
|
||||||
"method": "confirm_plugin_install",
|
|
||||||
"args": {"request_id": request_id}
|
|
||||||
}));
|
|
||||||
document.getElementById('plugin_install_list').removeChild(document.getElementById(`plugin_install_prompt_${request_id}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPluginInstallPrompt(artifact, version, request_id) {
|
|
||||||
let text = `
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
|
|
||||||
<div id="plugin_install_prompt_${request_id}" style="background-color: #0c131b; display: block; border: 1px solid #22262f; box-shadow: 0px 0px 8px #202020; width: calc(100% - 50px); padding: 0px 10px 10px 10px;">
|
|
||||||
<h3>Install Plugin?</h3>
|
|
||||||
<p style="font-size: 12px;">
|
|
||||||
${artifact}
|
|
||||||
Version: ${version}
|
|
||||||
</p>
|
|
||||||
<button type="button" tabindex="0" class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable"
|
|
||||||
onclick="installPlugin('${request_id}')">
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
<p style="margin: 2px;"></p>
|
|
||||||
<button type="button" tabindex="0" class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable"
|
|
||||||
onclick="document.getElementById('plugin_install_list').removeChild(document.getElementById('plugin_install_prompt_${request_id}'))">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.getElementById('plugin_install_list').innerHTML = text;
|
|
||||||
|
|
||||||
execute_in_tab('SP', false, 'FocusNavController.DispatchVirtualButtonClick(28)')
|
|
||||||
}
|
|
||||||
|
|
||||||
(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 += `<div id="plugin_install_list" style="position: fixed; height: 100%; z-index: 99; transform: translate(5%, 0);"></div>`
|
|
||||||
|
|
||||||
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() {
|
|
||||||
reloadIframe();
|
|
||||||
document.getElementById("plugin_title").innerText = "Plugins";
|
|
||||||
}
|
|
||||||
window.onmessage = function(ev) {
|
|
||||||
let title = ev.data;
|
|
||||||
if (title.startsWith("PLUGIN_LOADER__")) {
|
|
||||||
document.getElementById("plugin_title").innerHTML = `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left-square-fill" viewBox="0 0 16 16">
|
|
||||||
<path d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12zm-4.5-6.5H5.707l2.147-2.146a.5.5 0 1 0-.708-.708l-3 3a.5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .708-.708L5.707 8.5H11.5a.5.5 0 0 0 0-1z"/>
|
|
||||||
</svg>
|
|
||||||
${title.replace("PLUGIN_LOADER__", "")}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearInterval(injector);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
})();
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@import url("/steam_resource/css/2.css");
|
|
||||||
@import url("/steam_resource/css/39.css");
|
|
||||||
@import url("/steam_resource/css/library.css");
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
<script>
|
|
||||||
const tile_iframes = [];
|
|
||||||
window.addEventListener("message", function (evt) {
|
|
||||||
tile_iframes.forEach(iframe => {
|
|
||||||
iframe.contentWindow.postMessage(evt.data, "http://127.0.0.1:1337");
|
|
||||||
});
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
function loadPlugin(callsign, name) {
|
|
||||||
this.parent.postMessage("PLUGIN_LOADER__"+name, "https://steamloopback.host");
|
|
||||||
location.href = `/plugins/load_main/${callsign}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% if not plugins|length %}
|
|
||||||
<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
|
|
||||||
<div class="quickaccesscontrols_EmptyNotifications_3ZjbM" style="padding-top:7px;">
|
|
||||||
No plugins installed
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
|
|
||||||
{% for plugin in plugins %}
|
|
||||||
{% if plugin.tile_view_html|length %}
|
|
||||||
<div class="quickaccesscontrols_PanelSectionRow_26R5w">
|
|
||||||
<div onclick="loadPlugin('{{ plugin.callsign }}', '{{ 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; margin: 0px; padding: 0px; padding-top: 8px;">
|
|
||||||
<iframe id="tile_view_iframe_{{ plugin.callsign }}"
|
|
||||||
scrolling="no" marginwidth="0" marginheight="0"
|
|
||||||
hspace="0" vspace="0" frameborder="0"
|
|
||||||
style="border-radius: 2px;"
|
|
||||||
src="/plugins/load_tile/{{ plugin.callsign }}">
|
|
||||||
</iframe>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
let iframe = document.getElementById("tile_view_iframe_{{ plugin.callsign }}");
|
|
||||||
tile_iframes.push(document.getElementById("tile_view_iframe_{{ plugin.callsign }}"));
|
|
||||||
|
|
||||||
iframe.onload = function() {
|
|
||||||
let html = iframe.contentWindow.document.children[0];
|
|
||||||
let last_height = 0;
|
|
||||||
|
|
||||||
setInterval(function() {
|
|
||||||
let height = iframe.contentWindow.document.children[0].scrollHeight;
|
|
||||||
if (height != last_height) {
|
|
||||||
iframe.height = height + "px";
|
|
||||||
last_height = height;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
iframe.contentWindow.document.body.onclick = function () {
|
|
||||||
loadPlugin('{{ plugin.callsign }}', '{{ plugin.name }}');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="quickaccesscontrols_PanelSectionRow_26R5w">
|
|
||||||
<div onclick="loadPlugin('{{ plugin.callsign }}', '{{ 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; margin: 0px; padding: 0px; padding-top: 8px;">
|
|
||||||
<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>
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
aiohttp==3.8.1
|
aiohttp==3.8.1
|
||||||
aiohttp-jinja2==1.5.0
|
aiohttp-jinja2==1.5.0
|
||||||
|
aiohttp_cors==0.7.0
|
||||||
watchdog==2.1.7
|
watchdog==2.1.7
|
||||||
Reference in New Issue
Block a user