mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 12:15:09 +03:00
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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user