Skip to content

Commit

Permalink
Callsigns (#37)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
marios8543 and WerWolv authored Apr 18, 2022
1 parent 4576fed commit fa776f0
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 102 deletions.
3 changes: 3 additions & 0 deletions plugin_loader/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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:
Expand All @@ -35,6 +36,8 @@ def _unzip_to_plugin_dir(self, zip, name, hash):
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):
Expand Down
52 changes: 31 additions & 21 deletions plugin_loader/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
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
Expand All @@ -31,7 +33,7 @@ def on_created(self, event):
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
Expand All @@ -46,7 +48,7 @@ def on_modified(self, event):
# 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:
Expand All @@ -55,16 +57,18 @@ def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> Non
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),
Expand All @@ -75,18 +79,23 @@ 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 "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())
Expand All @@ -99,14 +108,15 @@ def import_plugins(self):
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]
Expand All @@ -116,22 +126,22 @@ async def get_steam_resource(self, request):
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:
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.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)

Expand All @@ -144,7 +154,7 @@ async def handle_sub_route(self, request):
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 = ""

Expand All @@ -160,7 +170,7 @@ async def load_plugin_tile_view(self, request):
<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}
Expand Down
36 changes: 21 additions & 15 deletions plugin_loader/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@
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()
Expand All @@ -37,6 +41,7 @@ def __init__(self) -> None:

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())
Expand All @@ -47,6 +52,21 @@ 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_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({
Expand Down Expand Up @@ -88,20 +108,6 @@ async def method_call_listener(self):
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)
Expand Down
16 changes: 9 additions & 7 deletions plugin_loader/plugin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +25,7 @@ def __init__(self, file, plugin_directory, plugin_path) -> None:
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)
Expand All @@ -46,6 +47,8 @@ async def _listen_for_method_call(self, reader, writer):
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}
Expand All @@ -67,25 +70,24 @@ async def _open_socket_if_not_exists(self):
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):
await self._open_socket_if_not_exists()
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:
Expand Down
32 changes: 20 additions & 12 deletions plugin_loader/static/plugin_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading

0 comments on commit fa776f0

Please sign in to comment.