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 asyncio import get_event_loop
from time import time from time import time
from hashlib import sha256 from hashlib import sha256
from subprocess import Popen
class PluginInstallContext: class PluginInstallContext:
def __init__(self, gh_url, version, hash) -> None: def __init__(self, gh_url, version, hash) -> None:
@@ -35,6 +36,8 @@ class PluginBrowser:
zip_file = ZipFile(zip) zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path) 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 return True
async def _install(self, artifact, version, hash): 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 aiohttp_jinja2 import template
from watchdog.observers.polling import PollingObserver as Observer from watchdog.observers.polling import PollingObserver as Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from asyncio import Queue
from os import path, listdir from os import path, listdir
from logging import getLogger from logging import getLogger
from time import time
from injector import get_tabs, get_tab from injector import get_tabs, get_tab
from plugin import PluginWrapper from plugin import PluginWrapper
from traceback import print_exc
class FileChangeHandler(FileSystemEventHandler): class FileChangeHandler(FileSystemEventHandler):
def __init__(self, loader, plugin_path) -> None: def __init__(self, queue, plugin_path) -> None:
super().__init__() super().__init__()
self.logger = getLogger("file-watcher") self.logger = getLogger("file-watcher")
self.loader : Loader = loader
self.plugin_path = plugin_path self.plugin_path = plugin_path
self.queue = queue
def on_created(self, event): def on_created(self, event):
src_path = event.src_path 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])) rel_path = path.relpath(src_path, path.commonprefix([self.plugin_path, src_path]))
plugin_dir = path.split(rel_path)[0] plugin_dir = path.split(rel_path)[0]
main_file_path = path.join(self.plugin_path, plugin_dir, "main.py") 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): def on_modified(self, event):
src_path = event.src_path 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 # file that changed is not necessarily the one that needs to be reloaded
self.logger.debug(f"file modified: {src_path}") self.logger.debug(f"file modified: {src_path}")
plugin_dir = path.split(path.relpath(src_path, path.commonprefix([self.plugin_path, src_path])))[0] 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: class Loader:
def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> None: def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> None:
@@ -55,16 +57,18 @@ class Loader:
self.plugin_path = plugin_path self.plugin_path = plugin_path
self.logger.info(f"plugin_path: {self.plugin_path}") self.logger.info(f"plugin_path: {self.plugin_path}")
self.plugins = {} self.plugins = {}
self.callsigns = {}
self.import_plugins() self.import_plugins()
if live_reload: if live_reload:
self.reload_queue = Queue()
self.observer = Observer() 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.observer.start()
self.loop.create_task(self.handle_reloads())
server_instance.add_routes([ server_instance.add_routes([
web.get("/plugins/iframe", self.plugin_iframe_route), 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/load_main/{name}", self.load_plugin_main_view),
web.get("/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route), web.get("/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route),
web.get("/plugins/load_tile/{name}", self.load_plugin_tile_view), web.get("/plugins/load_tile/{name}", self.load_plugin_tile_view),
@@ -75,18 +79,23 @@ class Loader:
try: try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path) plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
if plugin.name in self.plugins: 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") self.logger.info(f"Plugin {plugin.name} is already loaded and has requested to not be re-loaded")
return return
else: else:
self.plugins[plugin.name].stop(self.loop) self.plugins[plugin.name].stop()
self.plugins.pop(plugin.name, None) self.plugins.pop(plugin.name, None)
self.callsigns.pop(plugin.callsign, None)
if plugin.passive: if plugin.passive:
self.logger.info(f"Plugin {plugin.name} is 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}") self.logger.info(f"Loaded {plugin.name}")
except Exception as e: except Exception as e:
self.logger.error(f"Could not load {file}. {e}") self.logger.error(f"Could not load {file}. {e}")
print_exc()
finally: finally:
if refresh: if refresh:
self.loop.create_task(self.refresh_iframe()) self.loop.create_task(self.refresh_iframe())
@@ -99,14 +108,15 @@ class Loader:
self.logger.info(f"found plugin: {directory}") self.logger.info(f"found plugin: {directory}")
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory) self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory)
async def reload_plugins(self, request=None): async def handle_reloads(self):
self.logger.info("Re-importing plugins.") while True:
self.import_plugins() 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("_"): if method_name.startswith("_"):
raise RuntimeError("Tried to call private method") 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): async def get_steam_resource(self, request):
tab = (await get_tabs())[0] tab = (await get_tabs())[0]
@@ -116,7 +126,7 @@ class Loader:
return web.Response(text=str(e), status=400) return web.Response(text=str(e), status=400)
async def load_plugin_main_view(self, request): 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 # 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:
@@ -124,14 +134,14 @@ class Loader:
# setup the main script, plugin, and pull in the template # setup the main script, plugin, and pull in the template
ret = f""" ret = f"""
<script src="/static/library.js"></script> <script src="/static/library.js"></script>
<script>const plugin_name = '{plugin.name}' </script> <script>const plugin_name = '{plugin.callsign}' </script>
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.name}/"> <base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.callsign}/">
{template_data} {template_data}
""" """
return web.Response(text=ret, content_type="text/html") return web.Response(text=ret, content_type="text/html")
async def handle_sub_route(self, request): 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"] route_path = request.match_info["path"]
self.logger.info(path) self.logger.info(path)
@@ -144,7 +154,7 @@ class Loader:
return web.Response(text=ret) return web.Response(text=ret)
async def load_plugin_tile_view(self, request): async def load_plugin_tile_view(self, request):
plugin = self.plugins[request.match_info["name"]] plugin = self.callsigns[request.match_info["name"]]
inner_content = "" inner_content = ""
@@ -160,7 +170,7 @@ class Loader:
<head> <head>
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
<script src="/static/library.js"></script> <script src="/static/library.js"></script>
<script>const plugin_name = '{plugin.name}';</script> <script>const plugin_name = '{plugin.callsign}';</script>
</head> </head>
<body style="height: fit-content; display: block;"> <body style="height: fit-content; display: block;">
{inner_content} {inner_content}
+21 -15
View File
@@ -18,15 +18,19 @@ from jinja2 import FileSystemLoader
from os import path from os import path
from asyncio import get_event_loop, sleep from asyncio import get_event_loop, sleep
from json import loads, dumps from json import loads, dumps
from subprocess import Popen
from loader import Loader from loader import Loader
from injector import inject_to_tab, get_tab, tab_has_element from injector import inject_to_tab, get_tab, tab_has_element
from utilities import Utilities from utilities import Utilities
from browser import PluginBrowser from browser import PluginBrowser
logger = getLogger("Main") 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: class PluginManager:
def __init__(self) -> None: def __init__(self) -> None:
self.loop = get_event_loop() self.loop = get_event_loop()
@@ -37,6 +41,7 @@ class PluginManager:
jinja_setup(self.web_app, loader=FileSystemLoader(path.join(path.dirname(__file__), 'templates'))) 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(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.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.method_call_listener())
self.loop.create_task(self.loader_reinjector()) self.loop.create_task(self.loader_reinjector())
@@ -48,6 +53,21 @@ class PluginManager:
return return
loop.default_exception_handler(context) 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): async def resolve_method_call(self, tab, call_id, response):
await tab._send_devtools_cmd({ await tab._send_devtools_cmd({
"id": 1, "id": 1,
@@ -89,20 +109,6 @@ class PluginManager:
method = loads(data["params"]["args"][0]["value"]) method = loads(data["params"]["args"][0]["value"])
self.loop.create_task(self.handle_method_call(method, tab)) 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): def run(self):
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None) 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 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 os import path, setuid
from json import loads, dumps, load from json import loads, dumps, load
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
@@ -25,6 +25,7 @@ class PluginWrapper:
self.passive = not path.isfile(self.file) self.passive = not path.isfile(self.file)
def _init(self): def _init(self):
set_event_loop(new_event_loop())
if self.passive: if self.passive:
return return
setuid(0 if "root" in self.flags else 1000) setuid(0 if "root" in self.flags else 1000)
@@ -46,6 +47,8 @@ class PluginWrapper:
data = loads((await reader.readline()).decode("utf-8")) data = loads((await reader.readline()).decode("utf-8"))
if "stop" in data: if "stop" in data:
get_event_loop().stop() get_event_loop().stop()
while get_event_loop().is_running():
await sleep(0)
get_event_loop().close() get_event_loop().close()
return return
d = {"res": None, "success": True} d = {"res": None, "success": True}
@@ -67,17 +70,16 @@ class PluginWrapper:
except: except:
await sleep(0) await sleep(0)
def start(self, loop): def start(self):
if self.passive: if self.passive:
return self return self
executor = ProcessPoolExecutor() get_event_loop().run_in_executor(
loop.run_in_executor( ProcessPoolExecutor(),
executor,
self._init self._init
) )
return self return self
def stop(self, loop): def stop(self):
if self.passive: if self.passive:
return return
async def _(self): async def _(self):
@@ -85,7 +87,7 @@ class PluginWrapper:
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8")) self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
await self.writer.drain() await self.writer.drain()
self.writer.close() self.writer.close()
loop.create_task(_(self)) get_event_loop().create_task(_(self))
async def execute_method(self, method_name, kwargs): async def execute_method(self, method_name, kwargs):
if self.passive: if self.passive:
+20 -12
View File
@@ -19,20 +19,28 @@ function installPlugin(request_id) {
function addPluginInstallPrompt(artifact, version, request_id) { function addPluginInstallPrompt(artifact, version, request_id) {
let text = ` let text = `
<div id="plugin_install_prompt_${request_id}" style="display: block; background: #304375; border-radius: 5px;"> <link rel="stylesheet" href="/static/styles.css">
<h3 style="padding-left: 1rem;">Install plugin</h3>
<ul style="padding-left: 10px; padding-right: 10px; padding-bottom: 20px; margin: 0;"> <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;">
<li>${artifact}</li> <h3>Install Plugin?</h3>
<li>${version}</li> <p style="font-size: 12px;">
</ul> ${artifact}
<div style="text-align: center; padding-bottom: 10px;"> Version: ${version}
<button onclick="installPlugin('${request_id}')" style="display: inline-block; background-color: green;">Install</button> </p>
<button onclick="document.getElementById('plugin_install_list').removeChild(document.getElementById('plugin_install_prompt_${request_id}'))" <button type="button" tabindex="0" class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable"
style="display: inline-block; background-color: red;">Ignore</button> onclick="installPlugin('${request_id}')">
</div> 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> </div>
`; `;
document.getElementById('plugin_install_list').innerHTML += text; document.getElementById('plugin_install_list').innerHTML = text;
execute_in_tab('SP', false, 'FocusNavController.DispatchVirtualButtonClick(28)')
} }
(function () { (function () {
+24 -18
View File
@@ -7,37 +7,42 @@
}); });
}, false); }, false);
function loadPlugin(name) { function loadPlugin(callsign, name) {
this.parent.postMessage("PLUGIN_LOADER__"+name, "https://steamloopback.host"); this.parent.postMessage("PLUGIN_LOADER__"+name, "https://steamloopback.host");
location.href = `/plugins/load_main/${name}`; location.href = `/plugins/load_main/${callsign}`;
} }
</script> </script>
{% if not plugins|length %} {% 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" <div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
style="--indent-level:0;"> <div class="quickaccesscontrols_EmptyNotifications_3ZjbM" style="padding-top:7px;">
<div class="basicdialog_FieldChildren_279n8" style="color: white; font-size: large; padding-top: 10px;"> No plugins installed
No plugins installed :( </div>
</div> </div>
</div>
{% endif %} {% endif %}
<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable"> <div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
{% for plugin in plugins %} {% for plugin in plugins %}
{% if plugin.tile_view_html|length %} {% if plugin.tile_view_html|length %}
<div class="quickaccesscontrols_PanelSectionRow_26R5w"> <div class="quickaccesscontrols_PanelSectionRow_26R5w">
<div onclick="loadPlugin('{{ plugin.name }}')" <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" 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;"> style="--indent-level:0; margin: 0px; padding: 0px; padding-top: 8px;">
<iframe id="tile_view_iframe_{{ plugin.name }}" style="display:block; padding: 0; border: none;" scrolling="no" <iframe id="tile_view_iframe_{{ plugin.callsign }}"
src="/plugins/load_tile/{{ plugin.name }}"></iframe> scrolling="no" marginwidth="0" marginheight="0"
hspace="0" vspace="0" frameborder="0"
style="border-radius: 2px;"
src="/plugins/load_tile/{{ plugin.callsign }}">
</iframe>
<script> <script>
(function() { (function() {
let iframe = document.getElementById("tile_view_iframe_{{ plugin.name }}"); let iframe = document.getElementById("tile_view_iframe_{{ plugin.callsign }}");
tile_iframes.push(document.getElementById("tile_view_iframe_{{ plugin.name }}")); tile_iframes.push(document.getElementById("tile_view_iframe_{{ plugin.callsign }}"));
iframe.onload = function() { iframe.onload = function() {
let html = iframe.contentWindow.document.children[0]; let html = iframe.contentWindow.document.children[0];
let last_height = 0; let last_height = 0;
setInterval(function() { setInterval(function() {
let height = iframe.contentWindow.document.children[0].scrollHeight; let height = iframe.contentWindow.document.children[0].scrollHeight;
if (height != last_height) { if (height != last_height) {
@@ -45,8 +50,9 @@
last_height = height; last_height = height;
} }
}, 100); }, 100);
iframe.contentWindow.document.body.onclick = function () { iframe.contentWindow.document.body.onclick = function () {
loadPlugin('{{ plugin.name }}'); loadPlugin('{{ plugin.callsign }}', '{{ plugin.name }}');
}; };
} }
})(); })();
@@ -55,13 +61,13 @@
</div> </div>
{% else %} {% else %}
<div class="quickaccesscontrols_PanelSectionRow_26R5w"> <div class="quickaccesscontrols_PanelSectionRow_26R5w">
<div onclick="loadPlugin('{{ plugin.name }}')" <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" 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;"> style="--indent-level:0; margin: 0px; padding: 0px; padding-top: 8px;">
<div class="basicdialog_FieldChildren_279n8"> <div class="basicdialog_FieldChildren_279n8">
<button type="button" tabindex="0" <button type="button" tabindex="0"
class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable">{{ plugin.name class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable">{{ plugin.name }}
}}</button> </button>
</div> </div>
</div> </div>
</div> </div>
-1
View File
@@ -1,4 +1,3 @@
aiohttp==3.8.1 aiohttp==3.8.1
aiohttp-jinja2==1.5.0 aiohttp-jinja2==1.5.0
watchdog==2.1.7 watchdog==2.1.7
multiprocess==0.70.12.2