mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-11 08:02:00 +00:00
display overhaul, compatibility with legacy plugins, fixes
This commit is contained in:
Vendored
+6
-3
@@ -5,10 +5,13 @@
|
|||||||
"name": "Debug",
|
"name": "Debug",
|
||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/plugin_loader/main.py",
|
"program": "${workspaceFolder}/backend/main.py",
|
||||||
"preLaunchTask": "Stop Service",
|
"cwd": "${workspaceFolder}/backend",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": true
|
"env": {
|
||||||
|
"PLUGIN_PATH": "/home/deck/homebrew/plugins"
|
||||||
|
},
|
||||||
|
"preLaunchTask": "Build frontend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
-1
@@ -5,6 +5,11 @@
|
|||||||
"label": "Stop Service",
|
"label": "Stop Service",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command":"systemctl --user stop plugin_loader",
|
"command":"systemctl --user stop plugin_loader",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"label": "Build frontend",
|
||||||
|
"type": "shell",
|
||||||
|
"command":"cd ${workspaceFolder}/frontend; npm run build",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# TODO
|
||||||
|
- Fix button size/display
|
||||||
|
- Add plugin installation prompts for browser
|
||||||
|
- Fix components not updating unless tab opened first (with new tab hook)
|
||||||
|
- Clean up code
|
||||||
|
|
||||||
# Plugin Loader [](https://discord.gg/ZU74G2NJzk)
|
# Plugin Loader [](https://discord.gg/ZU74G2NJzk)
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ class PluginBrowser:
|
|||||||
|
|
||||||
server_instance.add_routes([
|
server_instance.add_routes([
|
||||||
web.post("/browser/install_plugin", self.install_plugin),
|
web.post("/browser/install_plugin", self.install_plugin),
|
||||||
web.get("/browser/iframe", self.redirect_to_store)
|
web.get("/browser/redirect", self.redirect_to_store)
|
||||||
])
|
])
|
||||||
|
|
||||||
def _unzip_to_plugin_dir(self, zip, name, hash):
|
def _unzip_to_plugin_dir(self, zip, name, hash):
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class Tab:
|
|||||||
await self.client.close()
|
await self.client.close()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def get_steam_resource(self, url):
|
||||||
|
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
||||||
|
return res["result"]["result"]["value"]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
@@ -93,3 +97,15 @@ async def tab_has_global_var(tab_name, var_name):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return res["result"]["result"]["value"]
|
return res["result"]["result"]["value"]
|
||||||
|
|
||||||
|
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", False)
|
||||||
|
|
||||||
|
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return res["result"]["result"]["value"]
|
||||||
+57
-36
@@ -2,16 +2,15 @@ from asyncio import Queue
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from os import listdir, path
|
from os import listdir, path
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import time
|
|
||||||
from traceback import print_exc
|
from traceback import print_exc
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp_jinja2 import template
|
|
||||||
from genericpath import exists
|
from genericpath import exists
|
||||||
|
from json.decoder import JSONDecodeError
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
from watchdog.observers.polling import PollingObserver as Observer
|
from watchdog.observers.polling import PollingObserver as Observer
|
||||||
|
|
||||||
from injector import inject_to_tab
|
from injector import inject_to_tab, get_tab
|
||||||
from plugin import PluginWrapper
|
from plugin import PluginWrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ 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.import_plugins()
|
|
||||||
|
|
||||||
if live_reload:
|
if live_reload:
|
||||||
self.reload_queue = Queue()
|
self.reload_queue = Queue()
|
||||||
@@ -73,17 +71,21 @@ class Loader:
|
|||||||
self.loop.create_task(self.handle_reloads())
|
self.loop.create_task(self.handle_reloads())
|
||||||
|
|
||||||
server_instance.add_routes([
|
server_instance.add_routes([
|
||||||
web.get("/plugins", self.handle_plugins),
|
web.get("/plugins", self.get_plugins),
|
||||||
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle),
|
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle),
|
||||||
web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call),
|
web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call),
|
||||||
web.post("/methods/{method_name}", self.handle_server_method_call)
|
|
||||||
|
# The following is legacy plugin code.
|
||||||
|
web.get("/plugins/load_main/{name}", self.load_plugin_main_view),
|
||||||
|
web.get("/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route),
|
||||||
|
web.get("/steam_resource/{path:.+}", self.get_steam_resource)
|
||||||
])
|
])
|
||||||
|
|
||||||
def handle_plugins(self, request):
|
async def get_plugins(self, request):
|
||||||
plugins = list(map(lambda kv: dict([("name", kv[0])]), self.plugins.items()))
|
plugins = list(self.plugins.values())
|
||||||
return web.json_response(plugins)
|
return web.json_response([str(i) if not i.legacy else "$LEGACY_"+str(i) for i in plugins])
|
||||||
|
|
||||||
def handle_frontend_bundle(self, request):
|
async def handle_frontend_bundle(self, request):
|
||||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||||
|
|
||||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.frontend_bundle), 'r') as bundle:
|
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.frontend_bundle), 'r') as bundle:
|
||||||
@@ -103,14 +105,13 @@ class Loader:
|
|||||||
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.plugins[plugin.name] = plugin.start()
|
||||||
self.logger.info(f"Loaded {plugin.name}")
|
self.logger.info(f"Loaded {plugin.name}")
|
||||||
if refresh:
|
#self.loop.create_task(self.dispatch_plugin(plugin.name))
|
||||||
self.loop.create_task(self.reload_frontend_plugin(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()
|
print_exc()
|
||||||
|
|
||||||
async def reload_frontend_plugin(self, name):
|
async def dispatch_plugin(self, name):
|
||||||
await inject_to_tab("SP", f"window.DeckyPluginLoader?.loadPlugin('{name}')")
|
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}')")
|
||||||
|
|
||||||
def import_plugins(self):
|
def import_plugins(self):
|
||||||
self.logger.info(f"import plugins from {self.plugin_path}")
|
self.logger.info(f"import plugins from {self.plugin_path}")
|
||||||
@@ -125,38 +126,58 @@ class Loader:
|
|||||||
args = await self.reload_queue.get()
|
args = await self.reload_queue.get()
|
||||||
self.import_plugin(*args)
|
self.import_plugin(*args)
|
||||||
|
|
||||||
async def handle_server_method_call(self, request):
|
|
||||||
method_name = request.match_info["method_name"]
|
|
||||||
method_info = await request.json()
|
|
||||||
args = method_info["args"]
|
|
||||||
|
|
||||||
res = {}
|
|
||||||
try:
|
|
||||||
r = await self.utilities.util_methods[method_name](**args)
|
|
||||||
res["result"] = r
|
|
||||||
res["success"] = True
|
|
||||||
except Exception as e:
|
|
||||||
res["result"] = str(e)
|
|
||||||
res["success"] = False
|
|
||||||
|
|
||||||
return web.json_response(res)
|
|
||||||
|
|
||||||
async def handle_plugin_method_call(self, request):
|
async def handle_plugin_method_call(self, request):
|
||||||
res = {}
|
res = {}
|
||||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||||
method_name = request.match_info["method_name"]
|
method_name = request.match_info["method_name"]
|
||||||
|
try:
|
||||||
method_info = await request.json()
|
method_info = await request.json()
|
||||||
args = method_info["args"]
|
args = method_info["args"]
|
||||||
|
except JSONDecodeError:
|
||||||
|
args = {}
|
||||||
try:
|
try:
|
||||||
if method_name.startswith("_"):
|
if method_name.startswith("_"):
|
||||||
raise RuntimeError("Tried to call private method")
|
raise RuntimeError("Tried to call private method")
|
||||||
|
|
||||||
res["result"] = await plugin.execute_method(method_name, args)
|
res["result"] = await plugin.execute_method(method_name, args)
|
||||||
res["success"] = True
|
res["success"] = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
res["result"] = str(e)
|
res["result"] = str(e)
|
||||||
res["success"] = False
|
res["success"] = False
|
||||||
|
|
||||||
return web.json_response(res)
|
return web.json_response(res)
|
||||||
|
|
||||||
|
"""
|
||||||
|
The following methods are used to load legacy plugins, which are considered deprecated.
|
||||||
|
I made the choice to re-add them so that the first iteration/version of the react loader
|
||||||
|
can work as a drop-in replacement for the stable branch of the PluginLoader, so that we
|
||||||
|
can introduce it more smoothly and give people the chance to sample the new features even
|
||||||
|
without plugin support. They will be removed once legacy plugins are no longer relevant.
|
||||||
|
"""
|
||||||
|
async def load_plugin_main_view(self, request):
|
||||||
|
plugin = self.plugins[request.match_info["name"]]
|
||||||
|
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), 'r') as template:
|
||||||
|
template_data = template.read()
|
||||||
|
ret = f"""
|
||||||
|
<script src="/static/legacy-library.js"></script>
|
||||||
|
<script>const plugin_name = '{plugin.name}' </script>
|
||||||
|
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.name}/">
|
||||||
|
{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"]]
|
||||||
|
route_path = request.match_info["path"]
|
||||||
|
self.logger.info(path)
|
||||||
|
ret = ""
|
||||||
|
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 get_steam_resource(self, request):
|
||||||
|
tab = await get_tab("QuickAccess")
|
||||||
|
try:
|
||||||
|
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
|
||||||
|
except Exception as e:
|
||||||
|
return web.Response(text=str(e), status=400)
|
||||||
+20
-7
@@ -1,6 +1,8 @@
|
|||||||
from logging import DEBUG, INFO, basicConfig, getLogger
|
from logging import DEBUG, INFO, basicConfig, getLogger
|
||||||
from os import getenv
|
from os import getenv
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
||||||
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
||||||
@@ -8,7 +10,7 @@ CONFIG = {
|
|||||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||||
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
||||||
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
|
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
|
||||||
"store_url": getenv("STORE_URL", "https://sdh.tzatzi.me/browse")
|
"store_url": getenv("STORE_URL", "https://beta.deckbrew.xyz")
|
||||||
}
|
}
|
||||||
|
|
||||||
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
||||||
@@ -21,10 +23,9 @@ from subprocess import Popen
|
|||||||
import aiohttp_cors
|
import aiohttp_cors
|
||||||
from aiohttp.web import Application, run_app, static
|
from aiohttp.web import Application, run_app, static
|
||||||
from aiohttp_jinja2 import setup as jinja_setup
|
from aiohttp_jinja2 import setup as jinja_setup
|
||||||
from jinja2 import FileSystemLoader
|
|
||||||
|
|
||||||
from browser import PluginBrowser
|
from browser import PluginBrowser
|
||||||
from injector import get_tab, inject_to_tab, tab_has_global_var
|
from injector import inject_to_tab, tab_has_global_var
|
||||||
from loader import Loader
|
from loader import Loader
|
||||||
from utilities import Utilities
|
from utilities import Utilities
|
||||||
|
|
||||||
@@ -48,22 +49,34 @@ class PluginManager:
|
|||||||
|
|
||||||
jinja_setup(self.web_app)
|
jinja_setup(self.web_app)
|
||||||
self.web_app.on_startup.append(self.inject_javascript)
|
self.web_app.on_startup.append(self.inject_javascript)
|
||||||
|
|
||||||
if CONFIG["chown_plugin_path"] == True:
|
if CONFIG["chown_plugin_path"] == True:
|
||||||
self.web_app.on_startup.append(chown_plugin_dir)
|
self.web_app.on_startup.append(chown_plugin_dir)
|
||||||
|
|
||||||
self.loop.create_task(self.loader_reinjector())
|
self.loop.create_task(self.loader_reinjector())
|
||||||
|
self.loop.create_task(self.load_plugins())
|
||||||
self.loop.set_exception_handler(self.exception_handler)
|
self.loop.set_exception_handler(self.exception_handler)
|
||||||
|
|
||||||
for route in list(self.web_app.router.routes()):
|
for route in list(self.web_app.router.routes()):
|
||||||
self.cors.add(route)
|
self.cors.add(route)
|
||||||
|
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
|
||||||
|
|
||||||
def exception_handler(self, loop, context):
|
def exception_handler(self, loop, context):
|
||||||
if context["message"] == "Unclosed connection":
|
if context["message"] == "Unclosed connection":
|
||||||
return
|
return
|
||||||
loop.default_exception_handler(context)
|
loop.default_exception_handler(context)
|
||||||
|
|
||||||
|
async def wait_for_server(self):
|
||||||
|
async with ClientSession() as web:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await web.get(f"http://{CONFIG['server_host']}:{CONFIG['server_port']}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
await sleep(0.1)
|
||||||
|
|
||||||
|
async def load_plugins(self):
|
||||||
|
await self.wait_for_server()
|
||||||
|
self.plugin_loader.import_plugins()
|
||||||
|
#await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
||||||
|
|
||||||
async def loader_reinjector(self):
|
async def loader_reinjector(self):
|
||||||
while True:
|
while True:
|
||||||
await sleep(1)
|
await sleep(1)
|
||||||
|
|||||||
+11
-4
@@ -10,9 +10,6 @@ from signal import SIGINT, signal
|
|||||||
from sys import exit
|
from sys import exit
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from injector import inject_to_tab
|
|
||||||
|
|
||||||
|
|
||||||
class PluginWrapper:
|
class PluginWrapper:
|
||||||
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
||||||
self.file = file
|
self.file = file
|
||||||
@@ -24,13 +21,23 @@ class PluginWrapper:
|
|||||||
|
|
||||||
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
|
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
|
||||||
|
|
||||||
|
if "frontend_bundle" in json:
|
||||||
|
self.frontend_bundle = json["frontend_bundle"]
|
||||||
|
self.legacy = False
|
||||||
|
else:
|
||||||
|
self.main_view_html = json["main_view_html"]
|
||||||
|
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
||||||
|
self.legacy = True
|
||||||
|
|
||||||
self.name = json["name"]
|
self.name = json["name"]
|
||||||
self.author = json["author"]
|
self.author = json["author"]
|
||||||
self.frontend_bundle = json["frontend_bundle"]
|
|
||||||
self.flags = json["flags"]
|
self.flags = json["flags"]
|
||||||
|
|
||||||
self.passive = not path.isfile(self.file)
|
self.passive = not path.isfile(self.file)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
def _init(self):
|
def _init(self):
|
||||||
signal(SIGINT, lambda s, f: exit(0))
|
signal(SIGINT, lambda s, f: exit(0))
|
||||||
|
|
||||||
|
|||||||
+39
-2
@@ -1,5 +1,6 @@
|
|||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession, web
|
||||||
from injector import inject_to_tab
|
from injector import inject_to_tab
|
||||||
|
from json.decoder import JSONDecodeError
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
class Utilities:
|
class Utilities:
|
||||||
@@ -11,9 +12,32 @@ class Utilities:
|
|||||||
"confirm_plugin_install": self.confirm_plugin_install,
|
"confirm_plugin_install": self.confirm_plugin_install,
|
||||||
"execute_in_tab": self.execute_in_tab,
|
"execute_in_tab": self.execute_in_tab,
|
||||||
"inject_css_into_tab": self.inject_css_into_tab,
|
"inject_css_into_tab": self.inject_css_into_tab,
|
||||||
"remove_css_from_tab": self.remove_css_from_tab
|
"remove_css_from_tab": self.remove_css_from_tab,
|
||||||
|
"open_plugin_store": self.open_plugin_store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if context:
|
||||||
|
context.web_app.add_routes([
|
||||||
|
web.post("/methods/{method_name}", self._handle_server_method_call)
|
||||||
|
])
|
||||||
|
|
||||||
|
async def _handle_server_method_call(self, request):
|
||||||
|
method_name = request.match_info["method_name"]
|
||||||
|
try:
|
||||||
|
method_info = await request.json()
|
||||||
|
args = method_info["args"]
|
||||||
|
except JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
res = {}
|
||||||
|
try:
|
||||||
|
r = await self.util_methods[method_name](**args)
|
||||||
|
res["result"] = r
|
||||||
|
res["success"] = True
|
||||||
|
except Exception as e:
|
||||||
|
res["result"] = str(e)
|
||||||
|
res["success"] = False
|
||||||
|
return web.json_response(res)
|
||||||
|
|
||||||
async def confirm_plugin_install(self, request_id):
|
async def confirm_plugin_install(self, request_id):
|
||||||
return await self.context.plugin_browser.confirm_plugin_install(request_id)
|
return await self.context.plugin_browser.confirm_plugin_install(request_id)
|
||||||
|
|
||||||
@@ -104,3 +128,16 @@ class Utilities:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"result": e
|
"result": e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def open_plugin_store(self):
|
||||||
|
await inject_to_tab("SP", """
|
||||||
|
(function() {
|
||||||
|
wpRequire = webpackJsonp.push([[], { get_require: (mod, _exports, wpRequire) => mod.exports = wpRequire }, [["get_require"]]]);
|
||||||
|
const all = () => Object.keys(wpRequire.c).map((x) => wpRequire.c[x].exports).filter((x) => x);
|
||||||
|
router = all().map(m => {
|
||||||
|
if (typeof m !== "object") return undefined;
|
||||||
|
for (let prop in m) { if (m[prop]?.Navigate) return m[prop]}
|
||||||
|
}).find(x => x)
|
||||||
|
router.NavigateToExternalWeb("http://127.0.0.1:1337/browser/redirect")
|
||||||
|
})();
|
||||||
|
""")
|
||||||
Generated
+1546
-15
File diff suppressed because it is too large
Load Diff
+14
-4
@@ -10,18 +10,28 @@
|
|||||||
"format": "prettier -c src -w"
|
"format": "prettier -c src -w"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^22.0.0",
|
|
||||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
|
||||||
"@rollup/plugin-typescript": "^8.3.2",
|
|
||||||
"husky": "^8.0.1",
|
"husky": "^8.0.1",
|
||||||
"import-sort-style-module": "^6.0.0",
|
"import-sort-style-module": "^6.0.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"prettier-plugin-import-sort": "^0.0.7",
|
"prettier-plugin-import-sort": "^0.0.7",
|
||||||
"rollup": "^2.71.1"
|
"@rollup/plugin-commonjs": "^21.1.0",
|
||||||
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
|
"@rollup/plugin-node-resolve": "^13.2.1",
|
||||||
|
"@rollup/plugin-replace": "^4.0.0",
|
||||||
|
"@rollup/plugin-typescript": "^8.3.2",
|
||||||
|
"@types/react": "16.14.0",
|
||||||
|
"@types/webpack": "^5.28.0",
|
||||||
|
"react": "16.14.0",
|
||||||
|
"react-dom": "16.14.0",
|
||||||
|
"rollup": "^2.70.2"
|
||||||
},
|
},
|
||||||
"importSort": {
|
"importSort": {
|
||||||
".js, .jsx, .ts, .tsx": {
|
".js, .jsx, .ts, .tsx": {
|
||||||
"style": "module"
|
"style": "module"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"decky-frontend-lib": "^0.0.2",
|
||||||
|
"react-icons": "^4.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import commonjs from '@rollup/plugin-commonjs';
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
import resolve from '@rollup/plugin-node-resolve';
|
import json from '@rollup/plugin-json';
|
||||||
|
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||||
|
import replace from '@rollup/plugin-replace';
|
||||||
import typescript from '@rollup/plugin-typescript';
|
import typescript from '@rollup/plugin-typescript';
|
||||||
|
import { defineConfig } from 'rollup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
/** @type {import('rollup').RollupOptions} */
|
input: 'src/index.tsx',
|
||||||
const options = {
|
plugins: [
|
||||||
input: 'src/index.ts',
|
commonjs(),
|
||||||
|
nodeResolve(),
|
||||||
|
typescript(),
|
||||||
|
json(),
|
||||||
|
replace({
|
||||||
|
preventAssignment: false,
|
||||||
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
output: {
|
output: {
|
||||||
file: '../backend/static/plugin-loader.iife.js',
|
file: '../backend/static/plugin-loader.iife.js',
|
||||||
format: 'iife',
|
format: 'iife',
|
||||||
},
|
},
|
||||||
plugins: [commonjs(), resolve(), typescript()]
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export default options
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
class LegacyPlugin extends React.Component {
|
||||||
|
constructor(props: object) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <iframe style={{ border: 'none', width: '100%', height: '100%' }} src={this.props.url}></iframe>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LegacyPlugin;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Button } from "decky-frontend-lib";
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
class PluginView extends React.Component<{}, { runningPlugin: string, plugins: Array<any> }> {
|
||||||
|
constructor() {
|
||||||
|
super({});
|
||||||
|
this.state = {
|
||||||
|
plugins: [],
|
||||||
|
runningPlugin: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.__DeckyEvLoop.addEventListener("pluginClose", (_) => { this.setState({ runningPlugin: "", plugins: this.state.plugins }) });
|
||||||
|
window.__DeckyEvLoop.addEventListener("setPlugins", (ev) => { console.log(ev); this.setState({ plugins: ev.data, runningPlugin: this.state.runningPlugin }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private openPlugin(name: string) {
|
||||||
|
const ev = new Event("pluginOpen");
|
||||||
|
ev.data = name;
|
||||||
|
window.__DeckyEvLoop.dispatchEvent(ev);
|
||||||
|
this.setState({ runningPlugin: name, plugins: this.state.plugins })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.runningPlugin) {
|
||||||
|
return this.state.plugins.find(x => x.name == this.state.runningPlugin).content;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let buttons = [];
|
||||||
|
for (const plugin of this.state.plugins) {
|
||||||
|
buttons.push(<Button layout="below" onClick={(_) => this.openPlugin(plugin.name)}>{plugin.icon}{plugin.name}</Button>)
|
||||||
|
}
|
||||||
|
if (buttons.length == 0) return <div className='staticClasses.Text'>No plugins...</div>;
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginView;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Button, staticClasses } from "decky-frontend-lib";
|
||||||
|
import React from "react"
|
||||||
|
import { FaArrowCircleLeft, FaShoppingBag } from "react-icons/fa"
|
||||||
|
|
||||||
|
class TitleView extends React.Component<{}, { runningPlugin: string }> {
|
||||||
|
constructor() {
|
||||||
|
super({});
|
||||||
|
this.state = {
|
||||||
|
runningPlugin: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.__DeckyEvLoop.addEventListener("pluginOpen", (ev) => this.setState({ runningPlugin: ev.data }));
|
||||||
|
window.__DeckyEvLoop.addEventListener("pluginClose", (_) => this.setState({ runningPlugin: "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private openPluginStore() {
|
||||||
|
fetch("http://127.0.0.1:1337/methods/open_plugin_store", {method: "POST"})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.runningPlugin)
|
||||||
|
return <div className={staticClasses.Title}>
|
||||||
|
<Button bottomSeparator={false} onClick={(_) => {
|
||||||
|
window.__DeckyEvLoop.dispatchEvent(new Event("pluginClose"));
|
||||||
|
this.setState({ runningPlugin: "" });
|
||||||
|
}}><FaArrowCircleLeft /></Button>
|
||||||
|
{this.state.runningPlugin}
|
||||||
|
</div>
|
||||||
|
else
|
||||||
|
return <div className={staticClasses.Title}>
|
||||||
|
Plugins
|
||||||
|
<Button bottomSeparator={false} onClick={ (_) => this.openPluginStore() }><FaShoppingBag /></Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TitleView;
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import PluginLoader from './plugin-loader';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
DeckyPluginLoader?: PluginLoader;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.DeckyPluginLoader) {
|
|
||||||
window.DeckyPluginLoader?.dismountAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.DeckyPluginLoader = new PluginLoader();
|
|
||||||
setTimeout(async () => {
|
|
||||||
window.DeckyPluginLoader?.loadAllPlugins();
|
|
||||||
}, 5000);
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import PluginLoader from "./plugin-loader"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
DeckyPluginLoader: PluginLoader;
|
||||||
|
importDeckyPlugin: Function;
|
||||||
|
syncDeckyPlugins: Function;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.DeckyPluginLoader = new PluginLoader();
|
||||||
|
window.importDeckyPlugin = function(name: string) {
|
||||||
|
window.DeckyPluginLoader?.importPlugin(name);
|
||||||
|
}
|
||||||
|
window.syncDeckyPlugins = async function() {
|
||||||
|
const plugins = await (await fetch("http://127.0.0.1:1337/plugins")).json();
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
window.DeckyPluginLoader?.importPlugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => window.syncDeckyPlugins(), 5000);
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import Logger from './logger';
|
|
||||||
import TabsHook from './tabs-hook';
|
|
||||||
|
|
||||||
interface Plugin {
|
|
||||||
title: any;
|
|
||||||
content: any;
|
|
||||||
icon: any;
|
|
||||||
onDismount?(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PluginLoader extends Logger {
|
|
||||||
private pluginInstances: Record<string, Plugin> = {};
|
|
||||||
private tabsHook: TabsHook;
|
|
||||||
private lock = 0;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(PluginLoader.name);
|
|
||||||
|
|
||||||
this.log('Initialized');
|
|
||||||
this.tabsHook = new TabsHook();
|
|
||||||
}
|
|
||||||
|
|
||||||
dismountPlugin(name: string) {
|
|
||||||
this.log(`Dismounting ${name}`);
|
|
||||||
this.pluginInstances[name]?.onDismount?.();
|
|
||||||
delete this.pluginInstances[name];
|
|
||||||
this.tabsHook.removeById(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadAllPlugins() {
|
|
||||||
this.log('Loading all plugins');
|
|
||||||
const plugins = await (await fetch(`http://127.0.0.1:1337/plugins`)).json();
|
|
||||||
this.log('Received:', plugins);
|
|
||||||
|
|
||||||
return Promise.all(plugins.map((plugin) => this.loadPlugin(plugin.name)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPlugin(name) {
|
|
||||||
this.log('Loading Plugin:', name);
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (this.lock === 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
this.lock = 1;
|
|
||||||
|
|
||||||
if (this.pluginInstances[name]) {
|
|
||||||
this.dismountPlugin(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
|
|
||||||
const code = await response.text();
|
|
||||||
|
|
||||||
const pluginAPI = PluginLoader.createPluginAPI(name);
|
|
||||||
this.pluginInstances[name] = await eval(code)(pluginAPI);
|
|
||||||
|
|
||||||
const { title, icon, content } = this.pluginInstances[name];
|
|
||||||
this.tabsHook.add({
|
|
||||||
id: name,
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
this.lock = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dismountAll() {
|
|
||||||
for (const name of Object.keys(this.pluginInstances)) {
|
|
||||||
this.dismountPlugin(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static createPluginAPI(pluginName) {
|
|
||||||
return {
|
|
||||||
async callServerMethod(methodName, args = {}) {
|
|
||||||
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(args),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
async callPluginMethod(methodName, args = {}) {
|
|
||||||
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
args,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
fetchNoCors(url, request: any = {}) {
|
|
||||||
let args = { method: 'POST', headers: {}, body: '' };
|
|
||||||
const req = { ...args, ...request, url, data: request.body };
|
|
||||||
return this.callServerMethod('http_request', req);
|
|
||||||
},
|
|
||||||
executeInTab(tab, runAsync, code) {
|
|
||||||
return this.callServerMethod('execute_in_tab', {
|
|
||||||
tab,
|
|
||||||
run_async: runAsync,
|
|
||||||
code,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
injectCssIntoTab(tab, style) {
|
|
||||||
return this.callServerMethod('inject_css_into_tab', {
|
|
||||||
tab,
|
|
||||||
style,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
removeCssFromTab(tab, cssId) {
|
|
||||||
return this.callServerMethod('remove_css_from_tab', {
|
|
||||||
tab,
|
|
||||||
css_id: cssId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PluginLoader;
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import Logger from './logger';
|
||||||
|
import TabsHook from './tabs-hook';
|
||||||
|
import { FaPlug } from "react-icons/fa";
|
||||||
|
|
||||||
|
import PluginView from "./components/PluginView";
|
||||||
|
import TitleView from "./components/TitleView";
|
||||||
|
import LegacyPlugin from "./components/LegacyPlugin"
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
name: any;
|
||||||
|
content: any;
|
||||||
|
icon: any;
|
||||||
|
onDismount?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__DeckyEvLoop: PluginEventTarget;
|
||||||
|
__DeckyRunningPlugin: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class PluginEventTarget extends EventTarget { }
|
||||||
|
window.__DeckyEvLoop = new PluginEventTarget();
|
||||||
|
|
||||||
|
class PluginLoader extends Logger {
|
||||||
|
private plugins: Plugin[] = [];
|
||||||
|
private tabsHook: TabsHook = new TabsHook();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(PluginLoader.name);
|
||||||
|
this.log('Initialized');
|
||||||
|
this.tabsHook.add({
|
||||||
|
id: "main",
|
||||||
|
title: <TitleView />,
|
||||||
|
content: <PluginView />,
|
||||||
|
icon: <FaPlug />
|
||||||
|
});
|
||||||
|
SteamClient.Input.RegisterForControllerInputMessages(this.handleBack);
|
||||||
|
window.__DeckyEvLoop.addEventListener("pluginOpen", (x) => window.__DeckyRunningPlugin = x.data);
|
||||||
|
window.__DeckyEvLoop.addEventListener("pluginClose", (_) => window.__DeckyRunningPlugin = "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBack(ev) {
|
||||||
|
const e = ev[0];
|
||||||
|
if (e.strActionName == "B" && window.__DeckyRunningPlugin)
|
||||||
|
window.__DeckyEvLoop.dispatchEvent(new Event("pluginClose"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async importPlugin(name: string) {
|
||||||
|
this.log(`Trying to load ${name}`);
|
||||||
|
let find = this.plugins.find(x => x.name == name);
|
||||||
|
if (find)
|
||||||
|
this.plugins.splice(this.plugins.indexOf(find), 1);
|
||||||
|
if (name.startsWith("$LEGACY_"))
|
||||||
|
this.importLegacyPlugin(name.replace("$LEGACY_", ""));
|
||||||
|
else
|
||||||
|
this.importReactPlugin(name);
|
||||||
|
this.log(`Loaded ${name}`);
|
||||||
|
const ev = new Event("setPlugins");
|
||||||
|
ev.data = this.plugins;
|
||||||
|
window.__DeckyEvLoop.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async importReactPlugin(name: string) {
|
||||||
|
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
|
||||||
|
if (res.ok) {
|
||||||
|
let content = await eval(await res.text())(PluginLoader.createPluginAPI(name));
|
||||||
|
this.plugins.push({
|
||||||
|
name: name,
|
||||||
|
icon: content.icon,
|
||||||
|
content: content.content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else throw new Error(`${name} frontend_bundle not OK`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async importLegacyPlugin(name: string) {
|
||||||
|
const url = `http://127.0.0.1:1337/plugins/load_main/${name}`;
|
||||||
|
this.plugins.push({
|
||||||
|
name: name,
|
||||||
|
icon: <FaPlug />,
|
||||||
|
content: <LegacyPlugin url={ url } />
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static createPluginAPI(pluginName) {
|
||||||
|
return {
|
||||||
|
async callServerMethod(methodName, args = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(args),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
async callPluginMethod(methodName, args = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
args,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
fetchNoCors(url, request: any = {}) {
|
||||||
|
let args = { method: 'POST', headers: {}, body: '' };
|
||||||
|
const req = { ...args, ...request, url, data: request.body };
|
||||||
|
return this.callServerMethod('http_request', req);
|
||||||
|
},
|
||||||
|
executeInTab(tab, runAsync, code) {
|
||||||
|
return this.callServerMethod('execute_in_tab', {
|
||||||
|
tab,
|
||||||
|
run_async: runAsync,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
injectCssIntoTab(tab, style) {
|
||||||
|
return this.callServerMethod('inject_css_into_tab', {
|
||||||
|
tab,
|
||||||
|
style,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeCssFromTab(tab, cssId) {
|
||||||
|
return this.callServerMethod('remove_css_from_tab', {
|
||||||
|
tab,
|
||||||
|
css_id: cssId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginLoader;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES2020",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"declaration": false,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strict": true,
|
||||||
|
"suppressImplicitAnyIndexErrors": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user