Compare commits

..

15 Commits

Author SHA1 Message Date
marios fa776f0d0b Callsigns (#37)
* Plugin callsigns, filechangehandler thread bug fix, plugin file perms

- Plugins are now assigned a callsign (a random string), which they use for all internal identification, like resource fetching and method calls. This is to ensure that plugins only access their own resources and methods.
- Made FileChangeHandler send off events to a queue, that is then consumed by the Loader, instead of calling import_plugin on its own, since that caused weird issues with the event loop and the thread watchdog is using.
- Plugins are now owned by root and have read-only permissions. This is handled automatically.

* Improved general look and feel of plugin tab

* Make all plugin entries have the same padding between them

* Make "No plugins installed" text look the same as "No new notifications"

Co-authored-by: WerWolv <werwolv98@gmail.com>
2022-04-18 15:57:51 +03:00
WerWolv 4576fed01b Properly delete old user plugin loader service on install 2022-04-13 23:18:58 +02:00
tza de435e22fb added default value to injector tab run_async 2022-04-14 00:01:35 +03:00
tza 6694d5ab71 fixed passive plugin reload bug and close event loop properly 2022-04-13 23:50:26 +03:00
WerWolv c084abecfc Fixed install script root access 2022-04-13 22:19:48 +02:00
tza f685eeb420 Added support for passive plugins (that don't implement main.py) 2022-04-13 22:47:22 +03:00
marios 6250fafa6e Fix release script 2022-04-13 21:50:18 +03:00
WerWolv efa5dc61c7 Update install scripts to install loader as system service 2022-04-13 12:55:33 +02:00
marios e3d7b50bd9 Root plugins (#35)
* root plugins

plugins can now specify if they want their methods to be ran as root. this is done via the multiprocess module. method calls are delegated to a separate process that is then down-privileged by default to user 1000, so the loader can safely be ran as root

except it isn't really safe because the plugin is imported as root anyway

* working implementation

- follows the new plugin format with the plugin.json file
- plugins are loaded in their own isolated process along with their own event loop and unix socket server for calling methods
- private methods are now prepended with _ instead of __

* converted format to f-strings
2022-04-13 02:14:44 +03:00
WerWolv 0359fd966a Use f-strings instead of .format 2022-04-12 22:27:46 +02:00
WerWolv fe9faefd0b Added functions to inject and remove css from tabs 2022-04-12 21:59:09 +02:00
WerWolv 012274b1a0 Added library function to execute code in a different tab 2022-04-12 21:15:36 +02:00
Spyrex 070d11154f Bundle stylesheets (#34) 2022-04-11 11:48:41 +02:00
Spyrex 02f73b795d Add vscode debugging (#33) 2022-04-11 12:45:00 +03:00
tza 4ffe2fdf24 added sha-256 hash checking to browser 2022-04-09 00:14:15 +03:00
14 changed files with 442 additions and 186 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/plugin_loader/main.py",
"preLaunchTask": "Stop Service",
"console": "integratedTerminal",
"justMyCode": true
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Stop Service",
"type": "shell",
"command":"systemctl --user stop plugin_loader",
}
]
}
+14 -5
View File
@@ -1,5 +1,7 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader nightly..."
HOMEBREW_FOLDER=/home/deck/homebrew
@@ -21,12 +23,19 @@ chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
rm -f /home/deck/.config/systemd/user/plugin_loader.service
cat > /home/deck/.config/systemd/user/plugin_loader.service <<- EOM
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f /etc/systemd/system/plugin_loader.service
cat > /etc/systemd/system/plugin_loader.service <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
User=root
Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
@@ -34,8 +43,8 @@ WorkingDirectory=/home/deck/homebrew/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
WantedBy=default.target
WantedBy=multi-user.target
EOM
systemctl --user daemon-reload
systemctl --user start plugin_loader
systemctl --user enable plugin_loader
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+1 -5
View File
@@ -19,18 +19,14 @@ rm -f /home/deck/.config/systemd/user/plugin_loader.service
cat > /home/deck/.config/systemd/user/plugin_loader.service <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
WantedBy=default.target
EOM
systemctl --user daemon-reload
systemctl --user start plugin_loader
systemctl --user enable plugin_loader
systemctl --user enable plugin_loader
+30 -17
View File
@@ -8,11 +8,14 @@ from zipfile import ZipFile
from concurrent.futures import ProcessPoolExecutor
from asyncio import get_event_loop
from time import time
from hashlib import sha256
from subprocess import Popen
class PluginInstallContext:
def __init__(self, gh_url, version) -> None:
def __init__(self, gh_url, version, hash) -> None:
self.gh_url = gh_url
self.version = version
self.hash = hash
class PluginBrowser:
def __init__(self, plugin_path, server_instance, store_url) -> None:
@@ -26,51 +29,61 @@ class PluginBrowser:
web.get("/browser/iframe", self.redirect_to_store)
])
def _unzip_to_plugin_dir(self, zip, name):
def _unzip_to_plugin_dir(self, zip, name, hash):
zip_hash = sha256(zip.getbuffer()).hexdigest()
if zip_hash != hash:
return False
zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path)
(rename(path.join(self.plugin_path, zip_file.namelist()[0]), path.join(self.plugin_path, name)))
rename(path.join(self.plugin_path, zip_file.namelist()[0]), path.join(self.plugin_path, name))
Popen(["chown", "-R", "deck:deck", self.plugin_path])
Popen(["chmod", "-R", "555", self.plugin_path])
return True
async def _install(self, artifact, version):
async def _install(self, artifact, version, hash):
name = artifact.split("/")[-1]
rmtree(path.join(self.plugin_path, name), ignore_errors=True)
self.log.info("Installing {} (Version: {})".format(artifact, version))
self.log.info(f"Installing {artifact} (Version: {version})")
async with ClientSession() as client:
url = "https://github.com/{}/archive/refs/tags/{}.zip".format(artifact, version)
self.log.debug("Fetching {}".format(url))
url = f"https://github.com/{artifact}/archive/refs/tags/{version}.zip"
self.log.debug(f"Fetching {url}")
res = await client.get(url)
if res.status == 200:
self.log.debug("Got 200. Reading...")
data = await res.read()
self.log.debug("Read {} bytes".format(len(data)))
self.log.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data)
with ProcessPoolExecutor() as executor:
self.log.debug("Unzipping...")
await get_event_loop().run_in_executor(
ret = await get_event_loop().run_in_executor(
executor,
self._unzip_to_plugin_dir,
res_zip,
name
name,
hash
)
self.log.info("Installed {} (Version: {})".format(artifact, version))
if ret:
self.log.info(f"Installed {artifact} (Version: {version})")
else:
self.log.fatal(f"SHA-256 Mismatch!!!! {artifact} (Version: {version})")
else:
self.log.fatal("Could not fetch from github. {}".format(await res.text()))
self.log.fatal(f"Could not fetch from github. {await res.text()}")
async def redirect_to_store(self, request):
return web.Response(status=302, headers={"Location": self.store_url})
async def install_plugin(self, request):
data = await request.post()
get_event_loop().create_task(self.request_plugin_install(data["artifact"], data["version"]))
get_event_loop().create_task(self.request_plugin_install(data["artifact"], data["version"], data["hash"]))
return web.Response(text="Requested plugin install")
async def request_plugin_install(self, artifact, version):
async def request_plugin_install(self, artifact, version, hash):
request_id = str(time())
self.install_requests[request_id] = PluginInstallContext(artifact, version)
self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
tab = await get_tab("QuickAccess")
await tab.open_websocket()
await tab.evaluate_js("addPluginInstallPrompt('{}', '{}', '{}')".format(artifact, version, request_id))
await tab.evaluate_js(f"addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
async def confirm_plugin_install(self, request_id):
request = self.install_requests.pop(request_id)
await self._install(request.gh_url, request.version)
await self._install(request.gh_url, request.version, request.hash)
+11 -19
View File
@@ -31,31 +31,22 @@ class Tab:
return (await self.websocket.receive_json()) if receive else None
raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js):
async def evaluate_js(self, js, run_async=False):
await self.open_websocket()
res = await self._send_devtools_cmd({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True
"userGesture": True,
"awaitPromise": run_async
}
})
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()
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
return res["result"]["result"]["value"]
def __repr__(self):
@@ -67,7 +58,7 @@ async def get_tabs():
while True:
try:
res = await web.get("{}/json".format(BASE_ADDRESS))
res = await web.get(f"{BASE_ADDRESS}/json")
break
except:
logger.info("Steam isn't available yet. Wait for a moment...")
@@ -77,25 +68,26 @@ async def get_tabs():
res = await res.json()
return [Tab(i) for i in res]
else:
raise Exception("/json did not return 200. {}".format(await res.text()))
raise Exception(f"/json did not return 200. {await res.text()}")
async def get_tab(tab_name):
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".format(tab_name))
raise ValueError(f"Tab {tab_name} not found")
return tab
async def inject_to_tab(tab_name, js):
async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name)
logger.debug(f"Injected JavaScript Result: {await tab.evaluate_js(js)}")
return await tab.evaluate_js(js, run_async)
async def tab_has_element(tab_name, element_name):
try:
tab = await get_tab(tab_name)
except ValueError:
return False
res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null")
res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null", False)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
+54 -60
View File
@@ -2,19 +2,21 @@ 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 importlib.util import spec_from_file_location, module_from_spec
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, loader, plugin_path) -> None:
def __init__(self, queue, plugin_path) -> None:
super().__init__()
self.logger = getLogger("file-watcher")
self.loader : Loader = loader
self.plugin_path = plugin_path
self.queue = queue
def on_created(self, event):
src_path = event.src_path
@@ -31,9 +33,7 @@ class FileChangeHandler(FileSystemEventHandler):
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")
if not path.isfile(main_file_path):
return
self.loader.import_plugin(main_file_path, plugin_dir, refresh=True)
self.queue.put_nowait((main_file_path, plugin_dir, True))
def on_modified(self, event):
src_path = event.src_path
@@ -48,7 +48,7 @@ class FileChangeHandler(FileSystemEventHandler):
# 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.loader.import_plugin(path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, refresh=True)
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:
@@ -57,17 +57,18 @@ class Loader:
self.plugin_path = plugin_path
self.logger.info(f"plugin_path: {self.plugin_path}")
self.plugins = {}
self.callsigns = {}
self.import_plugins()
if live_reload:
self.reload_queue = Queue()
self.observer = Observer()
self.observer.schedule(FileChangeHandler(self, plugin_path), self.plugin_path, recursive=True)
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/reload", self.reload_plugins),
web.post("/plugins/method_call", self.handle_plugin_method_call),
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),
@@ -76,31 +77,25 @@ class Loader:
def import_plugin(self, file, plugin_directory, refresh=False):
try:
spec = spec_from_file_location("_", file)
module = module_from_spec(spec)
spec.loader.exec_module(module)
# add member for what directory the given plugin lives under
module.Plugin._plugin_directory = plugin_directory
if not hasattr(module.Plugin, "name"):
raise KeyError("Plugin {} has not defined a name".format(file))
if module.Plugin.name in self.plugins:
if hasattr(module.Plugin, "hot_reload") and not module.Plugin.hot_reload and refresh:
self.logger.info("Plugin {} is already loaded and has requested to not be re-loaded"
.format(module.Plugin.name))
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:
if hasattr(self.plugins[module.Plugin.name], "task"):
self.plugins[module.Plugin.name].task.cancel()
self.plugins.pop(module.Plugin.name, None)
self.plugins[module.Plugin.name] = module.Plugin()
if hasattr(module.Plugin, "__main"):
setattr(self.plugins[module.Plugin.name], "task",
self.loop.create_task(self.plugins[module.Plugin.name].__main()))
self.logger.info("Loaded {}".format(module.Plugin.name))
self.plugins[plugin.name].stop()
self.plugins.pop(plugin.name, None)
self.callsigns.pop(plugin.callsign, 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.logger.info(f"Loaded {plugin.name}")
except Exception as e:
self.logger.error("Could not load {}. {}".format(file, e))
self.logger.error(f"Could not load {file}. {e}")
print_exc()
finally:
if refresh:
self.loop.create_task(self.refresh_iframe())
@@ -108,19 +103,20 @@ class Loader:
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, "main.py"))]
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 reload_plugins(self, request=None):
self.logger.info("Re-importing plugins.")
self.import_plugins()
async def handle_reloads(self):
while True:
args = await self.reload_queue.get()
self.import_plugin(*args)
async def handle_plugin_method_call(self, plugin_name, method_name, **kwargs):
if method_name.startswith("__"):
async def handle_plugin_method_call(self, callsign, method_name, **kwargs):
if method_name.startswith("_"):
raise RuntimeError("Tried to call private method")
return await getattr(self.plugins[plugin_name], method_name)(**kwargs)
return await self.callsigns[callsign].execute_method(method_name, kwargs)
async def get_steam_resource(self, request):
tab = (await get_tabs())[0]
@@ -130,59 +126,57 @@ class Loader:
return web.Response(text=str(e), status=400)
async def load_plugin_main_view(self, request):
plugin = self.plugins[request.match_info["name"]]
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:
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 = """
ret = f"""
<script src="/static/library.js"></script>
<script>const plugin_name = '{}' </script>
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{}/">
{}
""".format(plugin.name, plugin.name, template_data)
<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.plugins[request.match_info["name"]]
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)
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.plugins[request.match_info["name"]]
plugin = self.callsigns[request.match_info["name"]]
inner_content = ""
# open up the tile template (if we have one defined)
if len(plugin.tile_view_html) > 0:
with open(path.join(self.plugin_path, plugin._plugin_directory, plugin.tile_view_html), 'r') as template:
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 = """
ret = f"""
<html style="height: fit-content;">
<head>
<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">
<link rel="stylesheet" href="/static/styles.css">
<script src="/static/library.js"></script>
<script>const plugin_name = '{name}';</script>
<script>const plugin_name = '{plugin.callsign}';</script>
</head>
<body style="height: fit-content; display: block;">
{content}
{inner_content}
</body>
<html>
""".format(name=plugin.name, content=inner_content)
"""
return web.Response(text=ret, content_type="text/html")
@template('plugin_view.html')
@@ -192,4 +186,4 @@ class Loader:
async def refresh_iframe(self):
tab = await get_tab("QuickAccess")
await tab.open_websocket()
return await tab.evaluate_js("reloadIframe()")
return await tab.evaluate_js("reloadIframe()", False)
+22 -16
View File
@@ -18,15 +18,19 @@ 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")
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()
@@ -37,6 +41,7 @@ class PluginManager:
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())
@@ -47,13 +52,28 @@ class PluginManager:
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_element("QuickAccess", "plugin_iframe"):
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("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):
await tab._send_devtools_cmd({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "resolveMethodCall({}, {})".format(call_id, dumps(response)),
"expression": f"resolveMethodCall({call_id}, {dumps(response)})",
"userGesture": True
}
}, receive=False)
@@ -88,20 +108,6 @@ class PluginManager:
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 loader_reinjector(self):
while True:
await sleep(1)
if not await tab_has_element("QuickAccess", "plugin_iframe"):
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("QuickAccess", open(path.join(path.dirname(__file__), "static/plugin_page.js"), "r").read())
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)
+103
View File
@@ -0,0 +1,103 @@
from importlib.util import spec_from_file_location, module_from_spec
from asyncio import get_event_loop, new_event_loop, set_event_loop, start_unix_server, open_unix_connection, sleep, Lock
from os import path, setuid
from json import loads, dumps, load
from concurrent.futures import ProcessPoolExecutor
from time import time
class PluginWrapper:
def __init__(self, file, plugin_directory, plugin_path) -> None:
self.file = file
self.plugin_directory = plugin_directory
self.reader = None
self.writer = None
self.socket_addr = f"/tmp/plugin_socket_{time()}"
self.method_call_lock = Lock()
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
self.name = json["name"]
self.author = json["author"]
self.main_view_html = json["main_view_html"]
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
self.flags = json["flags"]
self.passive = not path.isfile(self.file)
def _init(self):
set_event_loop(new_event_loop())
if self.passive:
return
setuid(0 if "root" in self.flags else 1000)
spec = spec_from_file_location("_", self.file)
module = module_from_spec(spec)
spec.loader.exec_module(module)
self.Plugin = module.Plugin
if hasattr(self.Plugin, "_main"):
get_event_loop().create_task(self.Plugin._main(self.Plugin))
get_event_loop().create_task(self._setup_socket())
get_event_loop().run_forever()
async def _setup_socket(self):
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr)
async def _listen_for_method_call(self, reader, writer):
while True:
data = loads((await reader.readline()).decode("utf-8"))
if "stop" in data:
get_event_loop().stop()
while get_event_loop().is_running():
await sleep(0)
get_event_loop().close()
return
d = {"res": None, "success": True}
try:
d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"])
except Exception as e:
d["res"] = str(e)
d["success"] = False
finally:
writer.write((dumps(d)+"\n").encode("utf-8"))
await writer.drain()
async def _open_socket_if_not_exists(self):
if not self.reader:
while True:
try:
self.reader, self.writer = await open_unix_connection(self.socket_addr)
break
except:
await sleep(0)
def start(self):
if self.passive:
return self
get_event_loop().run_in_executor(
ProcessPoolExecutor(),
self._init
)
return self
def stop(self):
if self.passive:
return
async def _(self):
await self._open_socket_if_not_exists()
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
await self.writer.drain()
self.writer.close()
get_event_loop().create_task(_(self))
async def execute_method(self, method_name, kwargs):
if self.passive:
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
async with self.method_call_lock:
await self._open_socket_if_not_exists()
self.writer.write(
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
await self.writer.drain()
res = loads((await self.reader.readline()).decode("utf-8"))
if not res["success"]:
raise Exception(res["res"])
return res["res"]
+22
View File
@@ -39,4 +39,26 @@ async function call_plugin_method(method_name, arg_object={}) {
'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
});
}
+20 -12
View File
@@ -19,20 +19,28 @@ function installPlugin(request_id) {
function addPluginInstallPrompt(artifact, version, request_id) {
let text = `
<div id="plugin_install_prompt_${request_id}" style="display: block; background: #304375; border-radius: 5px;">
<h3 style="padding-left: 1rem;">Install plugin</h3>
<ul style="padding-left: 10px; padding-right: 10px; padding-bottom: 20px; margin: 0;">
<li>${artifact}</li>
<li>${version}</li>
</ul>
<div style="text-align: center; padding-bottom: 10px;">
<button onclick="installPlugin('${request_id}')" style="display: inline-block; background-color: green;">Install</button>
<button onclick="document.getElementById('plugin_install_list').removeChild(document.getElementById('plugin_install_prompt_${request_id}'))"
style="display: inline-block; background-color: red;">Ignore</button>
</div>
<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;
document.getElementById('plugin_install_list').innerHTML = text;
execute_in_tab('SP', false, 'FocusNavController.DispatchVirtualButtonClick(28)')
}
(function () {
+3
View File
@@ -0,0 +1,3 @@
@import url("/steam_resource/css/2.css");
@import url("/steam_resource/css/39.css");
@import url("/steam_resource/css/library.css");
+54 -50
View File
@@ -1,6 +1,4 @@
<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">
<link rel="stylesheet" href="/static/styles.css">
<script>
const tile_iframes = [];
window.addEventListener("message", function (evt) {
@@ -9,64 +7,70 @@
});
}, false);
function loadPlugin(name) {
function loadPlugin(callsign, name) {
this.parent.postMessage("PLUGIN_LOADER__"+name, "https://steamloopback.host");
location.href = `/plugins/load_main/${name}`;
location.href = `/plugins/load_main/${callsign}`;
}
</script>
{% 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 class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
<div class="quickaccesscontrols_EmptyNotifications_3ZjbM" style="padding-top:7px;">
No plugins installed
</div>
</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.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;">
<iframe id="tile_view_iframe_{{ plugin.name }}" style="display:block; padding: 0; border: none;" scrolling="no"
src="/plugins/load_tile/{{ plugin.name }}"></iframe>
<script>
(function() {
let iframe = document.getElementById("tile_view_iframe_{{ plugin.name }}");
tile_iframes.push(document.getElementById("tile_view_iframe_{{ plugin.name }}"));
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.name }}');
};
}
})();
</script>
</div>
</div>
{% else %}
<div class="quickaccesscontrols_PanelSectionRow_26R5w">
<div onclick="loadPlugin('{{ 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>
{% 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>
</div>
{% endif %}
{% endfor %}
{% endfor %}
</div>
+84 -2
View File
@@ -1,4 +1,6 @@
from aiohttp import ClientSession
from injector import inject_to_tab
import uuid
class Utilities:
def __init__(self, context) -> None:
@@ -6,7 +8,10 @@ class Utilities:
self.util_methods = {
"ping": self.ping,
"http_request": self.http_request,
"confirm_plugin_install": self.confirm_plugin_install
"confirm_plugin_install": self.confirm_plugin_install,
"execute_in_tab": self.execute_in_tab,
"inject_css_into_tab": self.inject_css_into_tab,
"remove_css_from_tab": self.remove_css_from_tab
}
async def confirm_plugin_install(self, request_id):
@@ -22,4 +27,81 @@ class Utilities:
}
async def ping(self, **kwargs):
return "pong"
return "pong"
async def execute_in_tab(self, tab, run_async, code):
try:
result = await inject_to_tab(tab, code, run_async)
if "exceptionDetails" in result["result"]:
return {
"success": False,
"result": result["result"]
}
return {
"success": True,
"result" : result["result"]["result"]["value"]
}
except Exception as e:
return {
"success": False,
"result": e
}
async def inject_css_into_tab(self, tab, style):
try:
css_id = str(uuid.uuid4())
result = await inject_to_tab(tab,
f"""
(function() {{
const style = document.createElement('style');
style.id = "{css_id}";
document.head.append(style);
style.sheet.insertRule(`{style}`);
}})()
""", False)
if "exceptionDetails" in result["result"]:
return {
"success": False,
"result": result["result"]
}
return {
"success": True,
"result" : css_id
}
except Exception as e:
return {
"success": False,
"result": e
}
async def remove_css_from_tab(self, tab, css_id):
try:
result = await inject_to_tab(tab,
f"""
(function() {{
let style = document.getElementById("{css_id}");
if (style.nodeName.toLowerCase() == 'style')
style.parentNode.removeChild(style);
}})()
""", False)
if "exceptionDetails" in result["result"]:
return {
"success": False,
"result": result
}
return {
"success": True
}
except Exception as e:
return {
"success": False,
"result": e
}