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>
This commit is contained in:
marios
2022-04-18 15:57:51 +03:00
committed by GitHub
parent 4576fed01b
commit fa776f0d0b
7 changed files with 138 additions and 104 deletions
+3
View File
@@ -9,6 +9,7 @@ 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, hash) -> None:
@@ -35,6 +36,8 @@ class PluginBrowser:
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))
Popen(["chown", "-R", "deck:deck", self.plugin_path])
Popen(["chmod", "-R", "555", self.plugin_path])
return True
async def _install(self, artifact, version, hash):
+31 -21
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 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,7 +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")
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
@@ -46,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:
@@ -55,16 +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.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),
@@ -75,18 +79,23 @@ class Loader:
try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
if plugin.name in self.plugins:
if not "hot_reload" in plugin.flags and refresh:
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.loop)
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")
self.plugins[plugin.name] = plugin.start(self.loop)
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(f"Could not load {file}. {e}")
print_exc()
finally:
if refresh:
self.loop.create_task(self.refresh_iframe())
@@ -99,14 +108,15 @@ class Loader:
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):
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.plugins[plugin_name].execute_method(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]
@@ -116,7 +126,7 @@ 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:
@@ -124,14 +134,14 @@ class Loader:
# setup the main script, plugin, and pull in the template
ret = f"""
<script src="/static/library.js"></script>
<script>const plugin_name = '{plugin.name}' </script>
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.name}/">
<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)
@@ -144,7 +154,7 @@ class Loader:
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 = ""
@@ -160,7 +170,7 @@ class Loader:
<head>
<link rel="stylesheet" href="/static/styles.css">
<script src="/static/library.js"></script>
<script>const plugin_name = '{plugin.name}';</script>
<script>const plugin_name = '{plugin.callsign}';</script>
</head>
<body style="height: fit-content; display: block;">
{inner_content}
+21 -15
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,6 +52,21 @@ 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({
@@ -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)
+9 -7
View File
@@ -1,5 +1,5 @@
from importlib.util import spec_from_file_location, module_from_spec
from asyncio import get_event_loop, start_unix_server, open_unix_connection, sleep, Lock
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
@@ -25,6 +25,7 @@ class PluginWrapper:
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)
@@ -46,6 +47,8 @@ class PluginWrapper:
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}
@@ -67,17 +70,16 @@ class PluginWrapper:
except:
await sleep(0)
def start(self, loop):
def start(self):
if self.passive:
return self
executor = ProcessPoolExecutor()
loop.run_in_executor(
executor,
get_event_loop().run_in_executor(
ProcessPoolExecutor(),
self._init
)
return self
def stop(self, loop):
def stop(self):
if self.passive:
return
async def _(self):
@@ -85,7 +87,7 @@ class PluginWrapper:
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
await self.writer.drain()
self.writer.close()
loop.create_task(_(self))
get_event_loop().create_task(_(self))
async def execute_method(self, method_name, kwargs):
if self.passive:
+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 () {
+53 -47
View File
@@ -7,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>
+1 -2
View File
@@ -1,4 +1,3 @@
aiohttp==3.8.1
aiohttp-jinja2==1.5.0
watchdog==2.1.7
multiprocess==0.70.12.2
watchdog==2.1.7