mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-14 04:35:07 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 414e0da2f3 | |||
| cb9b888dc6 | |||
| f3ab0f5989 | |||
| e132aba0f8 | |||
| 0d0e57e35a |
@@ -2,9 +2,9 @@ name: Builder
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "*" ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ "*" ]
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,32 +18,21 @@ jobs:
|
||||
steps:
|
||||
- name: 🧰 Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 💎 Set up NodeJS 17
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 17
|
||||
|
||||
- name: 🐍 Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: ⬇️ Install Python dependencies
|
||||
- name: ⬇️ Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
|
||||
- name: ⬇️ Install NodeJS dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
|
||||
- name: 🛠️ Build
|
||||
run: |
|
||||
pyinstaller --noconfirm --onefile --name "Decky" --add-data ./backend/static:/static ./backend/*.py
|
||||
pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./plugin_loader/static:/static --add-data ./plugin_loader/templates:/templates ./plugin_loader/*.py
|
||||
|
||||
- name: ⬆️ Upload package
|
||||
uses: actions/upload-artifact@v2
|
||||
|
||||
+1
-7
@@ -149,10 +149,4 @@ dmypy.json
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# static files are built
|
||||
backend/static
|
||||
|
||||
# pnpm lockfile
|
||||
frontend/pnpm-lock.yaml
|
||||
cython_debug/
|
||||
Vendored
+3
-6
@@ -5,13 +5,10 @@
|
||||
"name": "Debug",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/backend/main.py",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"program": "${workspaceFolder}/plugin_loader/main.py",
|
||||
"preLaunchTask": "Stop Service",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"PLUGIN_PATH": "/home/deck/homebrew/plugins"
|
||||
},
|
||||
"preLaunchTask": "Build frontend"
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.detectIndentation": false,
|
||||
"editor.tabSize": 4
|
||||
}
|
||||
}
|
||||
Vendored
+1
-6
@@ -5,11 +5,6 @@
|
||||
"label": "Stop Service",
|
||||
"type": "shell",
|
||||
"command":"systemctl --user stop plugin_loader",
|
||||
},
|
||||
{
|
||||
"label": "Build frontend",
|
||||
"type": "shell",
|
||||
"command":"cd ${workspaceFolder}/frontend; npm run build",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
# 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)
|
||||
|
||||

|
||||
|
||||
Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugin Loader, documentation + tools for plugin development and more.
|
||||
|
||||
## Installation
|
||||
1. Go into the Steam Deck Settings
|
||||
2. Under System -> System Settings toggle `Enable Developer Mode`
|
||||
@@ -19,13 +11,12 @@ Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugi
|
||||
6. Open a terminal and paste the following command into it:
|
||||
- For users:
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_release.sh | sh`
|
||||
- For plugin developers:
|
||||
- For developers:
|
||||
~~- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_nightly.sh | sh`~~
|
||||
Nightly releases are currently broken.
|
||||
7. Done! Reboot back into Gaming mode and enjoy your plugins!
|
||||
8. Done! Reboot back into Gaming mode and enjoy your plugins!
|
||||
|
||||
### Install Plugins
|
||||
- Using the shopping bag button in the top right corner, you can go to the offical ["Plugin Store"](https://plugins.deckbrew.xyz/)
|
||||
- Simply copy the plugin's folder into `~/homebrew/plugins`
|
||||
|
||||
### Uninstall
|
||||
@@ -33,6 +24,9 @@ Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugi
|
||||
- For both users and developers:
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/uninstall.sh | sh`
|
||||
|
||||
### Developing plugins
|
||||
- There is no complete plugin development documentation yet. However a good starting point is the [Plugin Template](https://github.com/SteamDeckHomebrew/Plugin-Template) repository
|
||||
|
||||
## Features
|
||||
- Clean injecting and loading of one or more plugins
|
||||
- Persistent. It doesn't need to be reinstalled after every system update
|
||||
@@ -40,30 +34,9 @@ Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugi
|
||||
- Allows plugins to define python functions and run them from javascript.
|
||||
- Allows plugins to make fetch calls, bypassing cors completely.
|
||||
|
||||
## Developing plugins
|
||||
- There is no complete plugin development documentation yet. However a good starting point is the [Plugin Template](https://github.com/SteamDeckHomebrew/decky-plugin-template) repository.
|
||||
## Caveats
|
||||
|
||||
## Contribution
|
||||
- For Plugin Loader contributors (in possession of a Steam Deck):
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/react-frontend-plugins/contrib/deck.sh | sh`
|
||||
- For PluginLoader contributors (without a Steam Deck):
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/react-frontend-plugins/contrib/pc.sh | sh`
|
||||
- [Here's how to get the Steam Deck UI on your enviroment of choice.](https://youtu.be/1IAbZte8e7E?t=112)
|
||||
- (The video shows Windows usage but unless you're using WSL/cygwin this script is unsupported on Windows.)
|
||||
|
||||
To run your development version of Plugin Loader on Deck, run a command like this:
|
||||
```bash
|
||||
ssh deck@steamdeck 'export PLUGIN_PATH=/home/deck/loaderdev/plugins; export CHOWN_PLUGIN_PATH=0; echo 'password' | sudo -SE python3 /home/deck/loaderdev/pluginloader/backend/main.py'
|
||||
```
|
||||
|
||||
Or on PC with the Deck UI enabled:
|
||||
```bash
|
||||
export PLUGIN_PATH=/home/user/installdirectory/plugins;
|
||||
export CHOWN_PLUGIN_PATH=0;
|
||||
sudo python3 /home/deck/loaderdev/pluginloader/backend/main.py
|
||||
```
|
||||
|
||||
Source control and deploying plugins are left to each respective contributor for the cloned repos in order to keep depedencies up to date.
|
||||
- You can only interact with the Plugin Menu via touchscreen.
|
||||
|
||||
## Credit
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
from logging import DEBUG, INFO, basicConfig, getLogger
|
||||
from os import getenv
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
CONFIG = {
|
||||
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
||||
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
||||
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
||||
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
|
||||
"store_url": getenv("STORE_URL", "https://beta.deckbrew.xyz")
|
||||
}
|
||||
|
||||
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
||||
|
||||
from asyncio import get_event_loop, sleep
|
||||
from json import dumps, loads
|
||||
from os import path
|
||||
from subprocess import Popen
|
||||
|
||||
import aiohttp_cors
|
||||
from aiohttp.web import Application, run_app, static
|
||||
from aiohttp_jinja2 import setup as jinja_setup
|
||||
|
||||
from browser import PluginBrowser
|
||||
from injector import inject_to_tab, tab_has_global_var
|
||||
from loader import Loader
|
||||
from utilities import Utilities
|
||||
|
||||
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()
|
||||
self.web_app = Application()
|
||||
self.cors = aiohttp_cors.setup(self.web_app, defaults={
|
||||
"https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
|
||||
allow_headers="*")
|
||||
})
|
||||
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
|
||||
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, CONFIG["store_url"])
|
||||
self.utilities = Utilities(self)
|
||||
|
||||
jinja_setup(self.web_app)
|
||||
self.web_app.on_startup.append(self.inject_javascript)
|
||||
if CONFIG["chown_plugin_path"] == True:
|
||||
self.web_app.on_startup.append(chown_plugin_dir)
|
||||
self.loop.create_task(self.loader_reinjector())
|
||||
self.loop.create_task(self.load_plugins())
|
||||
self.loop.set_exception_handler(self.exception_handler)
|
||||
for route in list(self.web_app.router.routes()):
|
||||
self.cors.add(route)
|
||||
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
|
||||
|
||||
def exception_handler(self, loop, context):
|
||||
if context["message"] == "Unclosed connection":
|
||||
return
|
||||
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):
|
||||
while True:
|
||||
await sleep(1)
|
||||
if not await tab_has_global_var("SP", "DeckyPluginLoader"):
|
||||
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("SP", "try{" + open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read() + "}catch(e){console.error(e)}", True)
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
PluginManager().run()
|
||||
@@ -1,59 +0,0 @@
|
||||
import os
|
||||
from asyncio import get_event_loop, sleep, subprocess
|
||||
from posixpath import join
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from plugin_protocol import PluginProtocolServer
|
||||
|
||||
|
||||
class BinaryPlugin:
|
||||
def __init__(self, plugin_directory, file_name, flags, logger) -> None:
|
||||
self.server = PluginProtocolServer(self)
|
||||
self.connection = None
|
||||
self.process = None
|
||||
|
||||
self.flags = flags
|
||||
self.logger = logger
|
||||
|
||||
self.plugin_directory = plugin_directory
|
||||
self.file_name = file_name
|
||||
|
||||
|
||||
async def start(self):
|
||||
if self.connection and self.connection.is_serving:
|
||||
self.connection.close()
|
||||
|
||||
self.unix_socket_path = BinaryPlugin.generate_socket_path()
|
||||
self.logger.debug(f"starting unix server on {self.unix_socket_path}")
|
||||
self.connection = await get_event_loop().create_unix_server(lambda: self.server, path=self.unix_socket_path)
|
||||
|
||||
env = dict(DECKY_PLUGIN_SOCKET = self.unix_socket_path)
|
||||
self.process = await subprocess.create_subprocess_exec(join(self.plugin_directory, self.file_name), env=env)
|
||||
get_event_loop().create_task(self.process_loop())
|
||||
|
||||
async def stop(self):
|
||||
self.stopped = True
|
||||
if self.connection and self.connection.is_serving:
|
||||
self.connection.close()
|
||||
|
||||
if self.process and self.process.is_alive:
|
||||
self.process.terminate()
|
||||
|
||||
async def process_loop(self):
|
||||
await self.process.wait()
|
||||
if not self.stopped:
|
||||
self.logger.info("backend process was killed - restarting in 10 seconds")
|
||||
await sleep(10)
|
||||
await self.start()
|
||||
|
||||
def generate_socket_path():
|
||||
tmp_dir = mkdtemp("decky-plugin")
|
||||
os.chown(tmp_dir, 1000, 1000)
|
||||
return join(tmp_dir, "socket")
|
||||
|
||||
# called on the server/loader process
|
||||
async def call_method(self, method_name, method_args):
|
||||
if self.process.returncode == None:
|
||||
return dict(success = False, result = "Process not alive")
|
||||
|
||||
return await self.server.call_method(method_name, method_args)
|
||||
@@ -1,18 +0,0 @@
|
||||
class PassivePlugin:
|
||||
def __init__(self, logger) -> None:
|
||||
self.logger
|
||||
pass
|
||||
|
||||
def call_method(self, method_name, args):
|
||||
self.logger.debug(f"Tried to call method {method_name}, but plugin is in passive mode")
|
||||
pass
|
||||
|
||||
def execute_method(self, method_name, method_args):
|
||||
self.logger.debug(f"Tried to execute method {method_name}, but plugin is in passive mode")
|
||||
pass
|
||||
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
pass
|
||||
@@ -1,18 +0,0 @@
|
||||
from posixpath import join
|
||||
|
||||
from genericpath import isfile
|
||||
|
||||
from plugin.binary_plugin import BinaryPlugin
|
||||
from plugin.passive_plugin import PassivePlugin
|
||||
from plugin.python_plugin import PythonPlugin
|
||||
|
||||
|
||||
def get_plugin_backend(spec, plugin_directory, flags, logger):
|
||||
if spec == None and isfile(join(plugin_directory, "main.py")):
|
||||
return PythonPlugin(plugin_directory, "main.py", flags, logger)
|
||||
elif spec["type"] == "python":
|
||||
return PythonPlugin(plugin_directory, spec["file"], flags, logger)
|
||||
elif spec["type"] == "binary":
|
||||
return BinaryPlugin(plugin_directory, spec["file"], flags, logger)
|
||||
else:
|
||||
return PassivePlugin(logger)
|
||||
@@ -1,129 +0,0 @@
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import uuid
|
||||
from asyncio import (Protocol, get_event_loop, new_event_loop, set_event_loop,
|
||||
sleep)
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from posixpath import join
|
||||
from signal import SIGINT, signal
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from plugin_protocol import PluginProtocolServer
|
||||
|
||||
multiprocessing.set_start_method("fork")
|
||||
|
||||
# only useable by the python backend
|
||||
class PluginProtocolClient(Protocol):
|
||||
def __init__(self, backend, logger) -> None:
|
||||
super().__init__()
|
||||
self.backend = backend
|
||||
self.logger = logger
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def data_received(self, data: bytes) -> None:
|
||||
message = json.loads(data.decode("utf-8"))
|
||||
message_id = str(uuid.UUID(message["id"]))
|
||||
message_type = message["type"]
|
||||
payload = message["payload"]
|
||||
|
||||
self.logger.debug(f"received {message_id} {message_type} {payload}")
|
||||
if message_type == "method_call":
|
||||
get_event_loop().create_task(self.handle_method_call(message_id, payload["name"], payload["args"]))
|
||||
|
||||
async def handle_method_call(self, message_id, method_name, method_args):
|
||||
try:
|
||||
result = await self.backend.execute_method(method_name, method_args)
|
||||
self.respond_message(message_id, "method_response", dict(success = True, result = result))
|
||||
except AttributeError as e:
|
||||
self.respond_message(message_id, "method_response", dict(success = False, result = f"plugin does not expose a method called {method_name}"))
|
||||
except Exception as e:
|
||||
self.respond_message(message_id, "method_response", dict(success = False, result = str(e)))
|
||||
|
||||
def respond_message(self, message_id, message_type, payload):
|
||||
self.logger.debug(f"sending {message_id} {message_type} {payload}")
|
||||
message = json.dumps(dict(id = str(message_id), type = message_type, payload = payload))
|
||||
self.transport.write(message.encode('utf-8'))
|
||||
|
||||
|
||||
class PythonPlugin:
|
||||
def __init__(self, plugin_directory, file_name, flags, logger) -> None:
|
||||
self.client = PluginProtocolClient(self, logger)
|
||||
self.server = PluginProtocolServer(self)
|
||||
self.connection = None
|
||||
self.process = None
|
||||
self.stopped = False
|
||||
|
||||
self.plugin_directory = plugin_directory
|
||||
self.file_name = file_name
|
||||
self.flags = flags
|
||||
self.logger = logger
|
||||
|
||||
def _init(self):
|
||||
self.logger.debug(f"child process Initializing")
|
||||
signal(SIGINT, lambda s, f: exit(0))
|
||||
|
||||
set_event_loop(new_event_loop())
|
||||
# TODO: both processes can access the socket
|
||||
# setuid(0 if "root" in self.flags else 1000)
|
||||
spec = spec_from_file_location("_", join(self.plugin_directory, self.file_name))
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.Plugin = module.Plugin
|
||||
|
||||
if hasattr(self.Plugin, "_main"):
|
||||
self.logger.debug("Found _main, calling it")
|
||||
get_event_loop().create_task(self.Plugin._main(self.Plugin))
|
||||
|
||||
get_event_loop().create_task(self._connect())
|
||||
get_event_loop().run_forever()
|
||||
|
||||
async def _connect(self):
|
||||
self.logger.debug(f"connecting to unix server on {self.unix_socket_path}")
|
||||
await get_event_loop().create_unix_connection(lambda: self.client, path=self.unix_socket_path)
|
||||
|
||||
async def start(self):
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
|
||||
self.unix_socket_path = PythonPlugin.generate_socket_path()
|
||||
self.logger.debug(f"starting unix server on {self.unix_socket_path}")
|
||||
self.connection = await get_event_loop().create_unix_server(lambda: self.server, path=self.unix_socket_path)
|
||||
|
||||
self.process = multiprocessing.Process(target=self._init)
|
||||
self.process.start()
|
||||
get_event_loop().create_task(self.process_loop())
|
||||
self.stopped = False
|
||||
|
||||
async def stop(self):
|
||||
self.stopped = True
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
|
||||
if self.process and self.process.is_alive:
|
||||
self.process.terminate()
|
||||
|
||||
async def process_loop(self):
|
||||
await get_event_loop().run_in_executor(None, self.process.join)
|
||||
if not self.stopped:
|
||||
self.logger.info("backend process was killed - restarting in 10 seconds")
|
||||
await sleep(10)
|
||||
await self.start()
|
||||
|
||||
# called on the server/loader process
|
||||
async def call_method(self, method_name, method_args):
|
||||
if not self.process.is_alive():
|
||||
return dict(success = False, result = "Process not alive")
|
||||
|
||||
return await self.server.call_method(method_name, method_args)
|
||||
|
||||
# called on the client
|
||||
def execute_method(self, method_name, method_args):
|
||||
return getattr(self.Plugin, method_name)(self.Plugin, **method_args)
|
||||
|
||||
def generate_socket_path():
|
||||
tmp_dir = mkdtemp("decky-plugin")
|
||||
os.chown(tmp_dir, 1000, 1000)
|
||||
return join(tmp_dir, "socket")
|
||||
@@ -1,46 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from asyncio import Protocol, TimeoutError, get_event_loop, wait_for
|
||||
from gc import callbacks
|
||||
from subprocess import call
|
||||
|
||||
|
||||
class PluginProtocolServer(Protocol):
|
||||
def __init__(self, backend) -> None:
|
||||
super().__init__()
|
||||
self.backend = backend
|
||||
self.callbacks = {}
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def data_received(self, data: bytes) -> None:
|
||||
message = json.loads(data.decode("utf-8"))
|
||||
message_id = str(uuid.UUID(message["id"]))
|
||||
message_type = message["type"]
|
||||
payload = message["payload"]
|
||||
|
||||
if message_type == "method_response":
|
||||
get_event_loop().create_task(self.handle_method_response(message_id, payload["success"], payload["result"]))
|
||||
|
||||
async def handle_method_response(self, message_id, success, result):
|
||||
if message_id in self.callbacks:
|
||||
self.callbacks[message_id].set_result(dict(success = success, result = result))
|
||||
del self.callbacks[message_id]
|
||||
|
||||
async def send_message(self, type, payload):
|
||||
id = str(uuid.uuid4())
|
||||
callback = get_event_loop().create_future()
|
||||
message = json.dumps(dict(id = id, type = type, payload = payload))
|
||||
|
||||
self.callbacks[id] = callback
|
||||
self.transport.write(message.encode('utf-8'))
|
||||
|
||||
try:
|
||||
return await wait_for(callback, 10)
|
||||
except TimeoutError as e:
|
||||
del self.callbacks[id]
|
||||
raise e
|
||||
|
||||
def call_method(self, method_name, method_args):
|
||||
return self.send_message("method_call", dict(name = method_name, args = method_args))
|
||||
@@ -1,37 +0,0 @@
|
||||
import multiprocessing
|
||||
from json import load
|
||||
from logging import getLogger
|
||||
from os import path
|
||||
|
||||
from plugin.plugin import get_plugin_backend
|
||||
|
||||
|
||||
class PluginWrapper:
|
||||
def __init__(self, plugin_relative_directory, plugin_path) -> None:
|
||||
self.plugin_directory = path.join(plugin_path, plugin_relative_directory)
|
||||
|
||||
json = load(open(path.join(self.plugin_directory, "plugin.json"), "r"))
|
||||
|
||||
self.legacy = False
|
||||
self.main_view_html = json["main_view_html"] if "main_view_html" in json else ""
|
||||
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
||||
self.legacy = self.main_view_html or self.tile_view_html
|
||||
|
||||
self.name = json["name"]
|
||||
self.author = json["author"]
|
||||
self.flags = json["flags"]
|
||||
self.logger = getLogger(f"{self.name}")
|
||||
|
||||
self.backend = get_plugin_backend(json.get("backend"), self.plugin_directory, self.flags, self.logger)
|
||||
|
||||
def call_method(self, method_name, args):
|
||||
return self.backend.call_method(method_name, args)
|
||||
|
||||
def start(self):
|
||||
return self.backend.start()
|
||||
|
||||
def stop(self):
|
||||
return self.backend.stop()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
## Before using this script, enable sshd on the deck and setup an sshd key between the deck and your dev in sshd_config.
|
||||
## This script defaults to port 22 unless otherwise specified, and cannot run without a sudo password or LAN IP.
|
||||
## You will need to specify the path to the ssh key if using key connection exclusively.
|
||||
|
||||
## Pre-parse arugments for ease of use
|
||||
CLONEFOLDER=${1:-""}
|
||||
INSTALLFOLDER=${2:-""}
|
||||
DECKIP=${3:-""}
|
||||
SSHPORT=${4:-""}
|
||||
PASSWORD=${5:-""}
|
||||
SSHKEYLOC=${6:-""}
|
||||
|
||||
## gather options into an array
|
||||
OPTIONSARRAY=("$CLONEFOLDER" $INSTALLFOLDER "$DECKIP" "$SSHPORT" "$PASSWORD" "$SSHKEYLOC")
|
||||
|
||||
## iterate through options array to check their presence
|
||||
count=0
|
||||
for OPTION in ${OPTIONSARRAY[@]}; do
|
||||
! [[ "$OPTION" == "" ]] && count=$(($count+1))
|
||||
# printf "OPTION=$OPTION\n"
|
||||
done
|
||||
|
||||
setfolder() {
|
||||
if [[ "$2" == "clone" ]]; then
|
||||
local ACTION="clone"
|
||||
local DEFAULT="git"
|
||||
elif [[ "$2" == "install" ]]; then
|
||||
local ACTION="install"
|
||||
local DEFAULT="loaderdev"
|
||||
fi
|
||||
|
||||
printf "Enter the directory in /home/user to ${ACTION} to.\n"
|
||||
printf "Example: if your home directory is /home/user you would type: ${DEFAULT}\n"
|
||||
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
|
||||
if [[ "$ACTION" == "clone" ]]; then
|
||||
read -p "Enter your ${ACTION} directory: " CLONEFOLDER
|
||||
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
CLONEFOLDER="${DEFAULT}"
|
||||
fi
|
||||
elif [[ "$ACTION" == "install" ]]; then
|
||||
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
|
||||
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
INSTALLFOLDER="${DEFAULT}"
|
||||
fi
|
||||
else
|
||||
printf "Folder type could not be determined, exiting\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkdeckip() {
|
||||
### check that ip is provided
|
||||
if [[ "$1" == "" ]]; then
|
||||
printf "An ip address must be provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
### check to make sure it's a potentially valid ipv4 address
|
||||
if ! [[ $1 =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
printf "A valid ip address must be provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
checksshport() {
|
||||
### check to make sure a port was specified
|
||||
if [[ "$1" == "" ]]; then
|
||||
printf "ssh port not provided. Using default, '22'.\n"
|
||||
SSHPORT="22"
|
||||
fi
|
||||
|
||||
### check for valid ssh port
|
||||
if [[ $1 -le 0 ]]; then
|
||||
printf "A valid ssh port must be provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
checksshkey() {
|
||||
### check if ssh key is present at location provided
|
||||
if [[ "$1" == "" ]]; then
|
||||
SSHKEYLOC="$HOME/.ssh/id_rsa"
|
||||
printf "ssh key was not provided. Defaulting to $SSHKEYLOC if it exists.\n"
|
||||
fi
|
||||
|
||||
### check if sshkey is present at location
|
||||
if ! [[ -e "$1" ]]; then
|
||||
SSHKEYLOC=""
|
||||
printf "ssh key does not exist. This script will use password authentication.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
checkpassword() {
|
||||
### check to make sure a password for 'deck' was specified
|
||||
if [[ "$1" == "" ]]; then
|
||||
printf "Remote deck user password was not provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
clonefromto() {
|
||||
# printf "repo=$1\n"
|
||||
# printf "outdir=$2\n"
|
||||
# printf "branch=$3\n"
|
||||
if [[ -z $3 ]]; then
|
||||
BRANCH=""
|
||||
else
|
||||
BRANCH="-b $3"
|
||||
fi
|
||||
git clone $1 $2 $BRANCH &> '/dev/null'
|
||||
CODE=$?
|
||||
if [[ $CODE -eq 128 ]]; then
|
||||
cd $2
|
||||
git fetch &> '/dev/null'
|
||||
fi
|
||||
}
|
||||
|
||||
npmtransbundle() {
|
||||
cd $1
|
||||
if [[ "$2" == "library" ]]; then
|
||||
npm install --quiet &> '/dev/null'
|
||||
npm run build --quiet &> '/dev/null'
|
||||
sudo npm link --quiet &> '/dev/null'
|
||||
elif [[ "$2" == "frontend" ]] || [[ "$2" == "template" ]]; then
|
||||
npm install --quiet &> '/dev/null'
|
||||
npm link decky-frontend-lib --quiet &> '/dev/null'
|
||||
npm run build --quiet &> '/dev/null'
|
||||
fi
|
||||
}
|
||||
|
||||
printf "Installing Steam Deck Plugin Loader contributor (for Steam Deck)...\n"
|
||||
|
||||
printf "THIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
|
||||
Not planning to contribute to PluginLoader?
|
||||
If so, you should not be using this script.\n
|
||||
If you have a release/nightly installed this script will disable it.\n"
|
||||
|
||||
printf "This script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
|
||||
|
||||
# [[ $count -gt 0 ]] || read -p "Press any key to continue"
|
||||
|
||||
if ! [[ $count -gt 0 ]] ; then
|
||||
read -p "Press any key to continue"
|
||||
fi
|
||||
|
||||
## User chooses preffered clone & install directories
|
||||
|
||||
if [[ "$CLONEFOLDER" == "" ]]; then
|
||||
setfolder "$CLONEFOLDER" "clone"
|
||||
fi
|
||||
|
||||
if [[ "$INSTALLFOLDER" == "" ]]; then
|
||||
setfolder "$INSTALLFOLDER" "install"
|
||||
fi
|
||||
|
||||
CLONEDIR="$HOME/$CLONEFOLDER"
|
||||
INSTALLDIR="/home/deck/$INSTALLFOLDER"
|
||||
|
||||
## Input ip address, port, password and sshkey
|
||||
|
||||
### DECKIP already been parsed?
|
||||
if [[ "$DECKIP" == "" ]]; then
|
||||
### get ip address of deck from user
|
||||
read -p "Enter the ip address of your Steam Deck: " DECKIP
|
||||
fi
|
||||
|
||||
### validate DECKIP
|
||||
checkdeckip "$DECKIP"
|
||||
|
||||
### SSHPORT already been parsed?
|
||||
if [[ "$SSHPORT" == "" ]]; then
|
||||
### get ssh port from user
|
||||
read -p "Enter the ssh port of your Steam Deck: " SSHPORT
|
||||
fi
|
||||
|
||||
### validate SSHPORT
|
||||
checksshport "$SSHPORT"
|
||||
|
||||
### PASSWORD already been parsed?
|
||||
if [[ "$PASSWORD" == "" ]]; then
|
||||
### prompt the user for their deck's password
|
||||
printf "Enter the password for the Steam Deck user 'deck' : "
|
||||
read -s PASSWORD
|
||||
printf "\n"
|
||||
fi
|
||||
|
||||
### validate PASSWORD
|
||||
checkpassword "$PASSWORD"
|
||||
|
||||
### SSHKEYLOC already been parsed?
|
||||
if [[ "$SSHKEYLOC" == "" ]]; then
|
||||
### prompt the user for their ssh key
|
||||
read -p "Enter the directory for your ssh key, for ease of connection : " SSHKEYLOC
|
||||
fi
|
||||
|
||||
### validate SSHKEYLOC
|
||||
checksshkey "$SSHKEYLOC"
|
||||
|
||||
if [[ "$SSHKEYLOC" == "" ]]; then
|
||||
IDENINVOC=""
|
||||
else
|
||||
IDENINVOC="-i ${SSHKEYLOC}"
|
||||
fi
|
||||
|
||||
## Create folder structure
|
||||
|
||||
printf "\nCloning git repositories.\n"
|
||||
|
||||
mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
|
||||
### remove folders just in case
|
||||
# rm -r ${CLONEDIR}/pluginloader
|
||||
# rm -r ${CLONEDIR}/pluginlibrary
|
||||
# rm -r ${CLONEDIR}/plugintemplate
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
|
||||
|
||||
## Transpile and bundle typescript
|
||||
|
||||
type npm &> '/dev/null'
|
||||
|
||||
NPMLIVES=$?
|
||||
|
||||
if ! [[ "$NPMLIVES" -eq 0 ]]; then
|
||||
printf "npm does not to be installed, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
|
||||
|
||||
## TODO: add a way of verifying if tsc is installed and to skip this step if it is
|
||||
sudo npm install --quiet -g tsc &> '/dev/null'
|
||||
|
||||
printf "Transpiling and bundling typescript.\n"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/plugintemplate "template"
|
||||
|
||||
## Transfer relevant files to deck
|
||||
|
||||
printf "Copying relevant files to install directory\n\n"
|
||||
|
||||
### copy files for PluginLoader
|
||||
rsync -avzp --mkpath --rsh="ssh -p ${SSHPORT} ${IDENINVOC}" --exclude='.git/' --exclude='node_modules' --exclude="package-lock.json" --exclude=='frontend' --exclude="*dist*" --exclude="*contrib*" --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader/ &> '/dev/null'
|
||||
if ! [[ $? -eq 0 ]]; then
|
||||
printf "Error occurred when copying ${CLONEDIR}/pluginloader/ to ${INSTALLDIR}/pluginloader/\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
### copy files for PluginLoader template
|
||||
rsync -avzp --mkpath --rsh="ssh -p ${SSHPORT} ${IDENINVOC}" --exclude='.git/' --exclude='node_modules' --exclude="package-lock.json" --delete ${CLONEDIR}/plugintemplate deck@${DECKIP}:${INSTALLDIR}/plugins &> '/dev/null'
|
||||
if ! [[ $? -eq 0 ]]; then
|
||||
printf "Error occurred when copying ${CLONEDIR}/plugintemplate to ${INSTALLDIR}/plugins\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## TODO: direct contributors to wiki for this info?
|
||||
|
||||
printf "Run these commands to deploy your local changes to the deck:\n"
|
||||
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p ${SSHPORT} ${IDENINVOC}\""" --exclude='.git/' --exclude='node_modules' --exclude='package-lock.json' --delete ${CLONEDIR}/pluginname deck@${DECKIP}:${INSTALLDIR}/plugins'\n"
|
||||
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p ${SSHPORT} ${IDENINVOC}\""" --exclude='.git/' --exclude='node_modules' --exclude='package-lock.json' --exclude=='frontend' --exclude='*dist*' --exclude='*contrib*' --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader/'\n"
|
||||
|
||||
printf "Run in console or in a script this command to run your development version:\n'ssh deck@${DECKIP} -p 22 ${IDENINVOC} 'export PLUGIN_PATH=${INSTALLDIR}/plugins; export CHOWN_PLUGIN_PATH=0; echo 'steam' | sudo -SE python3 ${INSTALLDIR}/pluginloader/backend/main.py'\n"
|
||||
|
||||
## Disable Releases versions if they exist
|
||||
|
||||
### ssh into deck and disable PluginLoader release/nightly service
|
||||
printf "Connecting via ssh to disable any PluginLoader release versions.\n"
|
||||
printf "Script will exit after this. All done!\n"
|
||||
|
||||
ssh deck@$DECKIP -p $SSHPORT $IDENINVOC "printf ${PASSWORD} | sudo -S systemctl disable --now plugin_loader; echo $?"
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
## Pre-parse arugments for ease of use
|
||||
CLONEFOLDER=${1:-""}
|
||||
|
||||
setfolder() {
|
||||
if [[ "$2" == "clone" ]]; then
|
||||
local ACTION="clone"
|
||||
local DEFAULT="git"
|
||||
elif [[ "$2" == "install" ]]; then
|
||||
local ACTION="install"
|
||||
local DEFAULT="loaderdev"
|
||||
fi
|
||||
|
||||
printf "Enter the directory in /home/user to ${ACTION} to.\n"
|
||||
printf "Example: if your home directory is /home/user you would type: ${DEFAULT}\n"
|
||||
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
|
||||
if [[ "$ACTION" == "clone" ]]; then
|
||||
read -p "Enter your ${ACTION} directory: " CLONEFOLDER
|
||||
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
CLONEFOLDER="${DEFAULT}"
|
||||
fi
|
||||
elif [[ "$ACTION" == "install" ]]; then
|
||||
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
|
||||
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
INSTALLFOLDER="${DEFAULT}"
|
||||
fi
|
||||
else
|
||||
printf "Folder type could not be determined, exiting\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
clonefromto() {
|
||||
# printf "repo=$1\n"
|
||||
# printf "outdir=$2\n"
|
||||
# printf "branch=$3\n"
|
||||
if [[ -z $3 ]]; then
|
||||
BRANCH=""
|
||||
else
|
||||
BRANCH="-b $3"
|
||||
fi
|
||||
git clone $1 $2 $BRANCH &> '/dev/null'
|
||||
CODE=$?
|
||||
if [[ $CODE -eq 128 ]]; then
|
||||
cd $2
|
||||
git fetch &> '/dev/null'
|
||||
fi
|
||||
}
|
||||
|
||||
npmtransbundle() {
|
||||
cd $1
|
||||
if [[ "$2" == "library" ]]; then
|
||||
npm install --quiet &> '/dev/null'
|
||||
npm run build --quiet &> '/dev/null'
|
||||
sudo npm link --quiet &> '/dev/null'
|
||||
elif [[ "$2" == "frontend" ]] || [[ "$2" == "template" ]]; then
|
||||
npm install --quiet &> '/dev/null'
|
||||
npm link decky-frontend-lib --quiet &> '/dev/null'
|
||||
npm run build --quiet &> '/dev/null'
|
||||
fi
|
||||
}
|
||||
|
||||
printf "Installing Steam Deck Plugin Loader contributor (no Steam Deck)..."
|
||||
|
||||
printf "\nTHIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
|
||||
If you are not planning to contribute to PluginLoader then you should not be using this script.\n"
|
||||
|
||||
printf "\nThis script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
|
||||
|
||||
if [[ -z $1 ]]; then
|
||||
read -p "Press any key to continue"
|
||||
fi
|
||||
|
||||
if [[ "$CLONEFOLDER" == "" ]]; then
|
||||
setfolder "$CLONEFOLDER" "clone"
|
||||
fi
|
||||
|
||||
CLONEDIR="$HOME/$CLONEFOLDER"
|
||||
|
||||
## Create folder structure
|
||||
|
||||
printf "\nCloning git repositories.\n"
|
||||
|
||||
mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
|
||||
### remove folders just in case
|
||||
# rm -r ${CLONEDIR}/pluginloader
|
||||
# rm -r ${CLONEDIR}/pluginlibrary
|
||||
# rm -r ${CLONEDIR}/plugintemplate
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
|
||||
|
||||
## Transpile and bundle typescript
|
||||
type npm &> '/dev/null'
|
||||
|
||||
NPMLIVES=$?
|
||||
|
||||
if ! [[ "$NPMLIVES" -eq 0 ]]; then
|
||||
printf "npm needs to be installed, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
|
||||
|
||||
sudo npm install --quiet -g tsc &> '/dev/null'
|
||||
|
||||
printf "Transpiling and bundling typescript.\n"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/plugintemplate "template"
|
||||
|
||||
printf "Plugin Loader is located at '${CLONEDIR}/pluginloader/'.\n"
|
||||
|
||||
printf "Run in console or in a script these commands to run your development version:\n'export PLUGIN_PATH=${CLONEDIR}/plugins; export CHOWN_PLUGIN_PATH=0; sudo -E python3 ${CLONEDIR}/pluginloader/backend/main.py'\n"
|
||||
|
||||
printf "All done!\n"
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
|
||||
.yalc
|
||||
yalc.lock
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd frontend && npm run lint
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
endOfLine: 'auto',
|
||||
plugins: [require('prettier-plugin-import-sort')],
|
||||
};
|
||||
Generated
-3881
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "decky_frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "GPLV2",
|
||||
"scripts": {
|
||||
"prepare": "cd .. && husky install frontend/.husky",
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c -w",
|
||||
"lint": "prettier -c src",
|
||||
"format": "prettier -c src -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/react-router": "5.1.18",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"husky": "^8.0.1",
|
||||
"import-sort-style-module": "^6.0.0",
|
||||
"prettier": "^2.6.2",
|
||||
"prettier-plugin-import-sort": "^0.0.7",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"rollup": "^2.70.2",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.7.2"
|
||||
},
|
||||
"importSort": {
|
||||
".js, .jsx, .ts, .tsx": {
|
||||
"style": "module",
|
||||
"parser": "typescript"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"decky-frontend-lib": "^0.0.6",
|
||||
"react-icons": "^4.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
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 { defineConfig } from 'rollup';
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.tsx',
|
||||
plugins: [
|
||||
commonjs(),
|
||||
nodeResolve(),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
preventAssignment: false,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
],
|
||||
external: ["react", "react-dom"],
|
||||
output: {
|
||||
file: '../backend/static/plugin-loader.iife.js',
|
||||
globals: {
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
},
|
||||
format: 'iife',
|
||||
},
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { ComponentType, FC, createContext, useContext, useEffect, useState } from 'react';
|
||||
import type { RouteProps } from 'react-router';
|
||||
|
||||
export interface RouterEntry {
|
||||
props: Omit<RouteProps, 'path' | 'children'>;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
interface PublicDeckyRouterState {
|
||||
routes: Map<string, RouterEntry>;
|
||||
}
|
||||
|
||||
export class DeckyRouterState {
|
||||
private _routes = new Map<string, RouterEntry>();
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyRouterState {
|
||||
return { routes: this._routes };
|
||||
}
|
||||
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
|
||||
this._routes.set(path, { props, component });
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
removeRoute(path: string) {
|
||||
this._routes.delete(path);
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyRouterStateContext extends PublicDeckyRouterState {
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void;
|
||||
removeRoute(path: string): void;
|
||||
}
|
||||
|
||||
const DeckyRouterStateContext = createContext<DeckyRouterStateContext>(null as any);
|
||||
|
||||
export const useDeckyRouterState = () => useContext(DeckyRouterStateContext);
|
||||
|
||||
interface Props {
|
||||
deckyRouterState: DeckyRouterState;
|
||||
}
|
||||
|
||||
export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRouterState }) => {
|
||||
const [publicDeckyRouterState, setPublicDeckyRouterState] = useState<PublicDeckyRouterState>({
|
||||
...deckyRouterState.publicState(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onUpdate() {
|
||||
setPublicDeckyRouterState({ ...deckyRouterState.publicState() });
|
||||
}
|
||||
|
||||
deckyRouterState.eventBus.addEventListener('update', onUpdate);
|
||||
|
||||
return () => deckyRouterState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const addRoute = (path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) =>
|
||||
deckyRouterState.addRoute(path, component, props);
|
||||
const removeRoute = (path: string) => deckyRouterState.removeRoute(path);
|
||||
|
||||
return (
|
||||
<DeckyRouterStateContext.Provider value={{ ...publicDeckyRouterState, addRoute, removeRoute }}>
|
||||
{children}
|
||||
</DeckyRouterStateContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import { FC, createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Plugin } from '../plugin';
|
||||
|
||||
interface PublicDeckyState {
|
||||
plugins: Plugin[];
|
||||
activePlugin: Plugin | null;
|
||||
}
|
||||
|
||||
export class DeckyState {
|
||||
private _plugins: Plugin[] = [];
|
||||
private _activePlugin: Plugin | null = null;
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyState {
|
||||
return { plugins: this._plugins, activePlugin: this._activePlugin };
|
||||
}
|
||||
|
||||
setPlugins(plugins: Plugin[]) {
|
||||
this._plugins = plugins;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
setActivePlugin(name: string) {
|
||||
this._activePlugin = this._plugins.find((plugin) => plugin.name === name) ?? null;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
closeActivePlugin() {
|
||||
this._activePlugin = null;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyStateContext extends PublicDeckyState {
|
||||
setActivePlugin(name: string): void;
|
||||
closeActivePlugin(): void;
|
||||
}
|
||||
|
||||
const DeckyStateContext = createContext<DeckyStateContext>(null as any);
|
||||
|
||||
export const useDeckyState = () => useContext(DeckyStateContext);
|
||||
|
||||
interface Props {
|
||||
deckyState: DeckyState;
|
||||
}
|
||||
|
||||
export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) => {
|
||||
const [publicDeckyState, setPublicDeckyState] = useState<PublicDeckyState>({ ...deckyState.publicState() });
|
||||
|
||||
useEffect(() => {
|
||||
function onUpdate() {
|
||||
setPublicDeckyState({ ...deckyState.publicState() });
|
||||
}
|
||||
|
||||
deckyState.eventBus.addEventListener('update', onUpdate);
|
||||
|
||||
return () => deckyState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
|
||||
const closeActivePlugin = () => deckyState.closeActivePlugin();
|
||||
|
||||
return (
|
||||
<DeckyStateContext.Provider value={{ ...publicDeckyState, setActivePlugin, closeActivePlugin }}>
|
||||
{children}
|
||||
</DeckyStateContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { VFC } from 'react';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const LegacyPlugin: VFC<Props> = ({ url }) => {
|
||||
return <iframe style={{ border: 'none', width: '100%', height: '100%' }} src={url}></iframe>;
|
||||
};
|
||||
|
||||
export default LegacyPlugin;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ButtonItem, DialogButton, PanelSection, PanelSectionRow, Router } from 'decky-frontend-lib';
|
||||
import { VFC } from 'react';
|
||||
import { FaArrowLeft, FaStore } from 'react-icons/fa';
|
||||
|
||||
import { useDeckyState } from './DeckyState';
|
||||
|
||||
const PluginView: VFC = () => {
|
||||
const { plugins, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
|
||||
|
||||
const onStoreClick = () => {
|
||||
Router.CloseSideMenus();
|
||||
Router.NavigateToExternalWeb('http://127.0.0.1:1337/browser/redirect');
|
||||
};
|
||||
|
||||
if (activePlugin) {
|
||||
return (
|
||||
<div style={{ height: '100%' }}>
|
||||
<div style={{ position: 'absolute', top: '3px', left: '16px', zIndex: 20 }}>
|
||||
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={closeActivePlugin}>
|
||||
<FaArrowLeft style={{ display: 'block' }} />
|
||||
</DialogButton>
|
||||
</div>
|
||||
{activePlugin.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection>
|
||||
<div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
|
||||
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={onStoreClick}>
|
||||
<FaStore style={{ display: 'block' }} />
|
||||
</DialogButton>
|
||||
</div>
|
||||
{plugins.map(({ name, icon }) => (
|
||||
<PanelSectionRow key={name}>
|
||||
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>{icon}</div>
|
||||
<div>{name}</div>
|
||||
</div>
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginView;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { staticClasses } from 'decky-frontend-lib';
|
||||
import { VFC } from 'react';
|
||||
|
||||
import { useDeckyState } from './DeckyState';
|
||||
|
||||
const TitleView: VFC = () => {
|
||||
const { activePlugin } = useDeckyState();
|
||||
|
||||
if (activePlugin === null) {
|
||||
return <div className={staticClasses.Title}>Decky</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={staticClasses.Title} style={{ paddingLeft: '60px' }}>
|
||||
{activePlugin.name}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleView;
|
||||
@@ -1,25 +0,0 @@
|
||||
import PluginLoader from './plugin-loader';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyPluginLoader: PluginLoader;
|
||||
importDeckyPlugin: Function;
|
||||
syncDeckyPlugins: Function;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeckyPluginLoader?.dismountAll();
|
||||
|
||||
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,35 +0,0 @@
|
||||
export const log = (name: string, ...args: any[]) => {
|
||||
console.log(
|
||||
`%c Decky %c ${name} %c`,
|
||||
'background: #16a085; color: black;',
|
||||
'background: #1abc9c; color: black;',
|
||||
'background: transparent;',
|
||||
...args,
|
||||
);
|
||||
};
|
||||
|
||||
export const error = (name: string, ...args: any[]) => {
|
||||
console.log(
|
||||
`%c Decky %c ${name} %c`,
|
||||
'background: #16a085; color: black;',
|
||||
'background: #FF0000;',
|
||||
'background: transparent;',
|
||||
...args,
|
||||
);
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor(private name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
log(this.name, ...args);
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
log(this.name, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export default Logger;
|
||||
@@ -1,182 +0,0 @@
|
||||
import { ModalRoot, showModal, staticClasses } from 'decky-frontend-lib';
|
||||
import { FaPlug } from 'react-icons/fa';
|
||||
|
||||
import { DeckyState, DeckyStateContextProvider } from './components/DeckyState';
|
||||
import LegacyPlugin from './components/LegacyPlugin';
|
||||
import PluginView from './components/PluginView';
|
||||
import TitleView from './components/TitleView';
|
||||
import Logger from './logger';
|
||||
import { Plugin } from './plugin';
|
||||
import RouterHook from './router-hook';
|
||||
import TabsHook from './tabs-hook';
|
||||
|
||||
declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
class PluginLoader extends Logger {
|
||||
private plugins: Plugin[] = [];
|
||||
private tabsHook: TabsHook = new TabsHook();
|
||||
// private windowHook: WindowHook = new WindowHook();
|
||||
private routerHook: RouterHook = new RouterHook();
|
||||
private deckyState: DeckyState = new DeckyState();
|
||||
|
||||
private reloadLock: boolean = false;
|
||||
// stores a list of plugin names which requested to be reloaded
|
||||
private pluginReloadQueue: string[] = [];
|
||||
|
||||
constructor() {
|
||||
super(PluginLoader.name);
|
||||
this.log('Initialized');
|
||||
|
||||
this.tabsHook.add({
|
||||
id: 'main',
|
||||
title: (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<TitleView />
|
||||
</DeckyStateContextProvider>
|
||||
),
|
||||
content: (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<PluginView />
|
||||
</DeckyStateContextProvider>
|
||||
),
|
||||
icon: <FaPlug />,
|
||||
});
|
||||
}
|
||||
|
||||
public addPluginInstallPrompt(artifact: string, version: string, request_id: string) {
|
||||
showModal(
|
||||
<ModalRoot
|
||||
onOK={() => {
|
||||
console.log('ok');
|
||||
this.callServerMethod('confirm_plugin_install', { request_id });
|
||||
}}
|
||||
onCancel={() => {
|
||||
console.log('nope');
|
||||
this.callServerMethod('cancel_plugin_install', { request_id });
|
||||
}}
|
||||
>
|
||||
<div className={staticClasses.Title}>
|
||||
Install {artifact} version {version}?
|
||||
</div>
|
||||
</ModalRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
public dismountAll() {
|
||||
for (const plugin of this.plugins) {
|
||||
this.log(`Dismounting ${plugin.name}`);
|
||||
plugin.onDismount?.();
|
||||
}
|
||||
}
|
||||
|
||||
public async importPlugin(name: string) {
|
||||
try {
|
||||
if (this.reloadLock) {
|
||||
this.log('Reload currently in progress, adding to queue', name);
|
||||
this.pluginReloadQueue.push(name);
|
||||
return;
|
||||
}
|
||||
|
||||
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_')) {
|
||||
await this.importLegacyPlugin(name.replace('$LEGACY_', ''));
|
||||
} else {
|
||||
await this.importReactPlugin(name);
|
||||
}
|
||||
this.log(`Loaded ${name}`);
|
||||
|
||||
this.deckyState.setPlugins(this.plugins);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
this.reloadLock = false;
|
||||
const nextPlugin = this.pluginReloadQueue.shift();
|
||||
if (nextPlugin) {
|
||||
this.importPlugin(nextPlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())(this.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} />,
|
||||
});
|
||||
}
|
||||
|
||||
async callServerMethod(methodName: string, 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();
|
||||
}
|
||||
|
||||
createPluginAPI(pluginName: string) {
|
||||
return {
|
||||
routerHook: this.routerHook,
|
||||
callServerMethod: this.callServerMethod,
|
||||
async callPluginMethod(methodName: string, 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: string, request: any = {}) {
|
||||
let args = { method: 'POST', headers: {}, body: '' };
|
||||
const req = { ...args, ...request, url, data: request.body };
|
||||
return this.callServerMethod('http_request', req);
|
||||
},
|
||||
executeInTab(tab: string, runAsync: boolean, code: string) {
|
||||
return this.callServerMethod('execute_in_tab', {
|
||||
tab,
|
||||
run_async: runAsync,
|
||||
code,
|
||||
});
|
||||
},
|
||||
injectCssIntoTab(tab: string, style: string) {
|
||||
return this.callServerMethod('inject_css_into_tab', {
|
||||
tab,
|
||||
style,
|
||||
});
|
||||
},
|
||||
removeCssFromTab(tab: string, cssId: any) {
|
||||
return this.callServerMethod('remove_css_from_tab', {
|
||||
tab,
|
||||
css_id: cssId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginLoader;
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface Plugin {
|
||||
name: any;
|
||||
content: any;
|
||||
icon: any;
|
||||
onDismount?(): void;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib';
|
||||
import { ReactElement, createElement, memo } from 'react';
|
||||
import type { Route } from 'react-router';
|
||||
|
||||
import {
|
||||
DeckyRouterState,
|
||||
DeckyRouterStateContextProvider,
|
||||
RouterEntry,
|
||||
useDeckyRouterState,
|
||||
} from './components/DeckyRouterState';
|
||||
import Logger from './logger';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ROUTER_HOOK_INSTANCE: any;
|
||||
}
|
||||
}
|
||||
|
||||
class RouterHook extends Logger {
|
||||
private router: any;
|
||||
private memoizedRouter: any;
|
||||
private gamepadWrapper: any;
|
||||
private routerState: DeckyRouterState = new DeckyRouterState();
|
||||
|
||||
constructor() {
|
||||
super('RouterHook');
|
||||
|
||||
this.log('Initialized');
|
||||
window.__ROUTER_HOOK_INSTANCE?.deinit?.();
|
||||
window.__ROUTER_HOOK_INSTANCE = this;
|
||||
|
||||
this.gamepadWrapper = findModuleChild((m) => {
|
||||
if (typeof m !== 'object') return undefined;
|
||||
for (let prop in m) {
|
||||
if (m[prop]?.render?.toString()?.includes('["flow-children","onActivate","onCancel","focusClassName",'))
|
||||
return m[prop];
|
||||
}
|
||||
});
|
||||
|
||||
let Route: new () => Route;
|
||||
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
|
||||
const { routes } = useDeckyRouterState();
|
||||
|
||||
const routerIndex = children.props.children[0].props.children.length - 1;
|
||||
if (
|
||||
!children.props.children[0].props.children[routerIndex].length ||
|
||||
children.props.children[0].props.children !== routes.size
|
||||
) {
|
||||
const newRouterArray: ReactElement[] = [];
|
||||
routes.forEach(({ component, props }, path) => {
|
||||
newRouterArray.push(
|
||||
<Route path={path} {...props}>
|
||||
{createElement(component)}
|
||||
</Route>,
|
||||
);
|
||||
});
|
||||
children.props.children[0].props.children[routerIndex] = newRouterArray;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
||||
if (ret?.props?.children?.props?.children?.length == 5) {
|
||||
if (
|
||||
ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
|
||||
?.toString()
|
||||
?.includes('GamepadUI.Settings.Root()')
|
||||
) {
|
||||
if (!this.router) {
|
||||
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
|
||||
afterPatch(this.router, 'type', (_: any, ret: any) => {
|
||||
if (!Route)
|
||||
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
|
||||
const returnVal = (
|
||||
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
|
||||
<DeckyWrapper>{ret}</DeckyWrapper>
|
||||
</DeckyRouterStateContextProvider>
|
||||
);
|
||||
return returnVal;
|
||||
});
|
||||
this.memoizedRouter = memo(this.router.type);
|
||||
this.memoizedRouter.isDeckyRouter = true;
|
||||
}
|
||||
ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
}
|
||||
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
|
||||
this.routerState.addRoute(path, component, props);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
unpatch(this.gamepadWrapper, 'render');
|
||||
this.router && unpatch(this.router, 'type');
|
||||
}
|
||||
}
|
||||
|
||||
export default RouterHook;
|
||||
@@ -1,69 +0,0 @@
|
||||
import Logger from './logger';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TABS_HOOK_INSTANCE: any;
|
||||
}
|
||||
interface Array<T> {
|
||||
__filter: any;
|
||||
}
|
||||
}
|
||||
|
||||
const isTabsArray = (tabs: any) => {
|
||||
const length = tabs.length;
|
||||
return length === 7 && tabs[length - 1]?.key === 6 && tabs[length - 1]?.tab;
|
||||
};
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
title: any;
|
||||
content: any;
|
||||
icon: any;
|
||||
}
|
||||
|
||||
class TabsHook extends Logger {
|
||||
// private keys = 7;
|
||||
tabs: Tab[] = [];
|
||||
|
||||
constructor() {
|
||||
super('TabsHook');
|
||||
|
||||
this.log('Initialized');
|
||||
window.__TABS_HOOK_INSTANCE = this;
|
||||
|
||||
const self = this;
|
||||
|
||||
const filter = Array.prototype.__filter ?? Array.prototype.filter;
|
||||
Array.prototype.__filter = filter;
|
||||
Array.prototype.filter = function (...args: any[]) {
|
||||
if (isTabsArray(this)) {
|
||||
self.render(this);
|
||||
}
|
||||
// @ts-ignore
|
||||
return filter.call(this, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
add(tab: Tab) {
|
||||
this.log('Adding tab', tab.id, 'to render array');
|
||||
this.tabs.push(tab);
|
||||
}
|
||||
|
||||
removeById(id: string) {
|
||||
this.log('Removing tab', id);
|
||||
this.tabs = this.tabs.filter((tab) => tab.id !== id);
|
||||
}
|
||||
|
||||
render(existingTabs: any[]) {
|
||||
for (const { title, icon, content, id } of this.tabs) {
|
||||
existingTabs.push({
|
||||
key: id,
|
||||
title,
|
||||
tab: icon,
|
||||
panel: content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TabsHook;
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "window.SP_REACT.createElement",
|
||||
"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"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from injector import get_tab
|
||||
from injector import get_tab, inject_to_tab
|
||||
from logging import getLogger
|
||||
from os import path, rename
|
||||
from shutil import rmtree
|
||||
@@ -26,7 +26,7 @@ class PluginBrowser:
|
||||
|
||||
server_instance.add_routes([
|
||||
web.post("/browser/install_plugin", self.install_plugin),
|
||||
web.get("/browser/redirect", self.redirect_to_store)
|
||||
web.get("/browser/redirect", self.redirect_to_store),
|
||||
])
|
||||
|
||||
def _unzip_to_plugin_dir(self, zip, name, hash):
|
||||
@@ -80,13 +80,10 @@ class PluginBrowser:
|
||||
async def request_plugin_install(self, artifact, version, hash):
|
||||
request_id = str(time())
|
||||
self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
|
||||
tab = await get_tab("SP")
|
||||
tab = await get_tab("QuickAccess")
|
||||
await tab.open_websocket()
|
||||
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
|
||||
await tab.evaluate_js(f"addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
|
||||
|
||||
async def confirm_plugin_install(self, request_id):
|
||||
request = self.install_requests.pop(request_id)
|
||||
await self._install(request.gh_url, request.version, request.hash)
|
||||
|
||||
def cancel_plugin_install(self, request_id):
|
||||
self.install_requests.pop(request_id)
|
||||
await self._install(request.gh_url, request.version, request.hash)
|
||||
@@ -1,10 +1,9 @@
|
||||
#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
|
||||
|
||||
from asyncio import sleep
|
||||
from logging import debug, getLogger
|
||||
from traceback import format_exc
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from logging import debug, getLogger
|
||||
from asyncio import sleep
|
||||
from traceback import format_exc
|
||||
|
||||
BASE_ADDRESS = "http://localhost:8080"
|
||||
|
||||
@@ -22,7 +21,7 @@ class Tab:
|
||||
async def open_websocket(self):
|
||||
self.client = ClientSession()
|
||||
self.websocket = await self.client.ws_connect(self.ws_url)
|
||||
|
||||
|
||||
async def listen_for_message(self):
|
||||
async for message in self.websocket:
|
||||
yield message
|
||||
@@ -44,14 +43,13 @@ class Tab:
|
||||
"awaitPromise": run_async
|
||||
}
|
||||
})
|
||||
|
||||
await self.client.close()
|
||||
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):
|
||||
return self.title
|
||||
|
||||
@@ -79,25 +77,13 @@ async def get_tab(tab_name):
|
||||
tab = next((i for i in tabs if i.title == tab_name), None)
|
||||
if not tab:
|
||||
raise ValueError(f"Tab {tab_name} not found")
|
||||
return tab
|
||||
return tab
|
||||
|
||||
async def inject_to_tab(tab_name, js, run_async=False):
|
||||
tab = await get_tab(tab_name)
|
||||
|
||||
return await tab.evaluate_js(js, run_async)
|
||||
|
||||
async def tab_has_global_var(tab_name, var_name):
|
||||
try:
|
||||
tab = await get_tab(tab_name)
|
||||
except ValueError:
|
||||
return False
|
||||
res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", 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"]
|
||||
|
||||
async def tab_has_element(tab_name, element_name):
|
||||
try:
|
||||
tab = await get_tab(tab_name)
|
||||
@@ -108,4 +94,4 @@ async def tab_has_element(tab_name, element_name):
|
||||
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"]
|
||||
return res["result"]["result"]["value"]
|
||||
@@ -1,35 +1,30 @@
|
||||
from asyncio import Queue, get_event_loop, sleep, wait_for
|
||||
from json.decoder import JSONDecodeError
|
||||
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 os import listdir, path
|
||||
from time import time
|
||||
from genericpath import exists
|
||||
from pathlib import Path
|
||||
|
||||
from injector import get_tabs, get_tab
|
||||
from plugin import PluginWrapper
|
||||
from traceback import print_exc
|
||||
|
||||
from aiohttp import web
|
||||
from genericpath import exists
|
||||
from watchdog.events import RegexMatchingEventHandler
|
||||
from watchdog.utils import UnsupportedLibc
|
||||
|
||||
try:
|
||||
from watchdog.observers.inotify import InotifyObserver as Observer
|
||||
except UnsupportedLibc:
|
||||
from watchdog.observers.fsevents import FSEventsObserver as Observer
|
||||
|
||||
from injector import get_tab, inject_to_tab
|
||||
from plugin_wrapper import PluginWrapper
|
||||
|
||||
|
||||
class FileChangeHandler(RegexMatchingEventHandler):
|
||||
class FileChangeHandler(FileSystemEventHandler):
|
||||
def __init__(self, queue, plugin_path) -> None:
|
||||
super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$'])
|
||||
super().__init__()
|
||||
self.logger = getLogger("file-watcher")
|
||||
self.plugin_path = plugin_path
|
||||
self.queue = queue
|
||||
|
||||
def maybe_reload(self, src_path):
|
||||
plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0]
|
||||
self.logger.info(path.join(self.plugin_path, plugin_dir, "plugin.json"))
|
||||
if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")):
|
||||
self.queue.put_nowait(plugin_dir, True)
|
||||
self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True))
|
||||
|
||||
def on_created(self, event):
|
||||
src_path = event.src_path
|
||||
@@ -40,11 +35,9 @@ class FileChangeHandler(RegexMatchingEventHandler):
|
||||
if path.isdir(src_path):
|
||||
return
|
||||
|
||||
# get the directory name of the plugin so that we can find its "main.py" and reload it; the
|
||||
# file that changed is not necessarily the one that needs to be reloaded
|
||||
self.logger.debug(f"file created: {src_path}")
|
||||
self.maybe_reload(src_path)
|
||||
|
||||
|
||||
def on_modified(self, event):
|
||||
src_path = event.src_path
|
||||
if "__pycache__" in src_path:
|
||||
@@ -54,8 +47,6 @@ class FileChangeHandler(RegexMatchingEventHandler):
|
||||
if path.isdir(src_path):
|
||||
return
|
||||
|
||||
# get the directory name of the plugin so that we can find its "main.py" and reload it; the
|
||||
# file that changed is not necessarily the one that needs to be reloaded
|
||||
self.logger.debug(f"file modified: {src_path}")
|
||||
self.maybe_reload(src_path)
|
||||
|
||||
@@ -66,6 +57,9 @@ class Loader:
|
||||
self.plugin_path = plugin_path
|
||||
self.logger.info(f"plugin_path: {self.plugin_path}")
|
||||
self.plugins = {}
|
||||
self.callsigns = {}
|
||||
self.callsign_matches = {}
|
||||
self.import_plugins()
|
||||
|
||||
if live_reload:
|
||||
self.reload_queue = Queue()
|
||||
@@ -75,36 +69,16 @@ class Loader:
|
||||
self.loop.create_task(self.handle_reloads())
|
||||
|
||||
server_instance.add_routes([
|
||||
web.get("/plugins", self.get_plugins),
|
||||
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.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_frontend_assets),
|
||||
|
||||
# The following is legacy plugin code.
|
||||
web.get("/plugins/iframe", self.plugin_iframe_route),
|
||||
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),
|
||||
web.get("/steam_resource/{path:.+}", self.get_steam_resource)
|
||||
])
|
||||
|
||||
async def get_plugins(self, request):
|
||||
plugins = list(self.plugins.values())
|
||||
return web.json_response([str(i) if not i.legacy else "$LEGACY_"+str(i) for i in plugins])
|
||||
|
||||
def handle_frontend_assets(self, request):
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
file = path.join(self.plugin_path, plugin.plugin_directory, "dist/assets", request.match_info["path"])
|
||||
|
||||
return web.FileResponse(file)
|
||||
|
||||
def handle_frontend_bundle(self, request):
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
|
||||
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), 'r') as bundle:
|
||||
return web.Response(text=bundle.read(), content_type="application/javascript")
|
||||
|
||||
def import_plugin(self, plugin_directory, refresh=False):
|
||||
def import_plugin(self, file, plugin_directory, refresh=False):
|
||||
try:
|
||||
plugin = PluginWrapper(plugin_directory, self.plugin_path)
|
||||
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
|
||||
if plugin.name in self.plugins:
|
||||
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")
|
||||
@@ -112,16 +86,21 @@ class Loader:
|
||||
else:
|
||||
self.plugins[plugin.name].stop()
|
||||
self.plugins.pop(plugin.name, None)
|
||||
self.plugins[plugin.name] = plugin
|
||||
self.loop.create_task(plugin.start())
|
||||
self.callsigns.pop(self.callsign_matches[file], None)
|
||||
if plugin.passive:
|
||||
self.logger.info(f"Plugin {plugin.name} is passive")
|
||||
callsign = str(time())
|
||||
plugin.callsign = callsign
|
||||
self.plugins[plugin.name] = plugin.start()
|
||||
self.callsigns[callsign] = plugin
|
||||
self.callsign_matches[file] = callsign
|
||||
self.logger.info(f"Loaded {plugin.name}")
|
||||
self.loop.create_task(self.dispatch_plugin(plugin.name))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not load {plugin_directory}. {e}")
|
||||
self.logger.error(f"Could not load {file}. {e}")
|
||||
print_exc()
|
||||
|
||||
async def dispatch_plugin(self, name):
|
||||
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}')")
|
||||
finally:
|
||||
if refresh:
|
||||
self.loop.create_task(self.refresh_iframe())
|
||||
|
||||
def import_plugins(self):
|
||||
self.logger.info(f"import plugins from {self.plugin_path}")
|
||||
@@ -129,60 +108,17 @@ class Loader:
|
||||
directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))]
|
||||
for directory in directories:
|
||||
self.logger.info(f"found plugin: {directory}")
|
||||
self.import_plugin(directory)
|
||||
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory)
|
||||
|
||||
async def handle_reloads(self):
|
||||
while True:
|
||||
args = await self.reload_queue.get()
|
||||
self.import_plugin(*args)
|
||||
|
||||
async def handle_plugin_method_call(self, request):
|
||||
res = {}
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
method_name = request.match_info["method_name"]
|
||||
try:
|
||||
method_info = await request.json()
|
||||
method_args = method_info["args"]
|
||||
except JSONDecodeError:
|
||||
method_args = {}
|
||||
try:
|
||||
if method_name.startswith("_"):
|
||||
raise RuntimeError("Tried to call private method")
|
||||
res = await plugin.call_method(method_name, method_args)
|
||||
except Exception as e:
|
||||
res["result"] = repr(e)
|
||||
res["success"] = False
|
||||
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 handle_plugin_method_call(self, callsign, method_name, **kwargs):
|
||||
if method_name.startswith("_"):
|
||||
raise RuntimeError("Tried to call private method")
|
||||
return await self.callsigns[callsign].execute_method(method_name, kwargs)
|
||||
|
||||
async def get_steam_resource(self, request):
|
||||
tab = await get_tab("QuickAccess")
|
||||
@@ -190,3 +126,66 @@ class Loader:
|
||||
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)
|
||||
|
||||
async def load_plugin_main_view(self, request):
|
||||
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.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.callsigns[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 load_plugin_tile_view(self, request):
|
||||
plugin = self.callsigns[request.match_info["name"]]
|
||||
|
||||
inner_content = ""
|
||||
|
||||
# open up the tile template (if we have one defined)
|
||||
if hasattr(plugin, "tile_view_html"):
|
||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.tile_view_html), 'r') as template:
|
||||
template_data = template.read()
|
||||
inner_content = template_data
|
||||
|
||||
# setup the default template
|
||||
ret = f"""
|
||||
<html style="height: fit-content;">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script src="/static/library.js"></script>
|
||||
<script>const plugin_name = '{plugin.callsign}';</script>
|
||||
</head>
|
||||
<body style="height: fit-content; display: block;">
|
||||
{inner_content}
|
||||
</body>
|
||||
<html>
|
||||
"""
|
||||
return web.Response(text=ret, content_type="text/html")
|
||||
|
||||
@template('plugin_view.html')
|
||||
async def plugin_iframe_route(self, request):
|
||||
return {"plugins": self.plugins.values()}
|
||||
|
||||
async def refresh_iframe(self):
|
||||
tab = await get_tab("QuickAccess")
|
||||
await tab.open_websocket()
|
||||
return await tab.evaluate_js("reloadIframe()", False)
|
||||
@@ -0,0 +1,144 @@
|
||||
from logging import getLogger, basicConfig, INFO, DEBUG, Filter, root
|
||||
from os import getenv
|
||||
|
||||
CONFIG = {
|
||||
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
||||
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
||||
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
|
||||
"store_url": getenv("STORE_URL", "https://beta.deckbrew.xyz"),
|
||||
"log_base_events": getenv("LOG_BASE_EVENTS", "0")=="1"
|
||||
}
|
||||
|
||||
class NoBaseEvents(Filter):
|
||||
def filter(self, record):
|
||||
return not "asyncio" in record.name
|
||||
|
||||
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
||||
for handler in root.handlers:
|
||||
if not CONFIG["log_base_events"]:
|
||||
handler.addFilter(NoBaseEvents())
|
||||
|
||||
from aiohttp.web import Application, run_app, static
|
||||
from aiohttp_jinja2 import setup as jinja_setup
|
||||
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")
|
||||
from traceback import print_exc
|
||||
|
||||
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()
|
||||
self.web_app = Application()
|
||||
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
|
||||
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, CONFIG["store_url"])
|
||||
self.utilities = Utilities(self)
|
||||
|
||||
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())
|
||||
|
||||
self.loop.set_exception_handler(self.exception_handler)
|
||||
|
||||
def exception_handler(self, loop, context):
|
||||
if context["message"] == "Unclosed connection":
|
||||
return
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
async def loader_reinjector(self):
|
||||
finished_reinjection = False
|
||||
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
|
||||
while True:
|
||||
await sleep(1)
|
||||
if not await tab_has_element("QuickAccess", "plugin_iframe"):
|
||||
logger.debug("Plugin loader isn't present in Steam anymore, reinjecting...")
|
||||
await self.inject_javascript()
|
||||
finished_reinjection = True
|
||||
elif finished_reinjection:
|
||||
finished_reinjection = False
|
||||
logger.info("Reinjecting successful!")
|
||||
|
||||
self.loop.create_task(self.method_call_listener())
|
||||
|
||||
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):
|
||||
try:
|
||||
r = dumps(response)
|
||||
except Exception as e:
|
||||
logger.error(response["result"])
|
||||
response["result"] = str(response["result"])
|
||||
r = response
|
||||
await tab._send_devtools_cmd({
|
||||
"id": 1,
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": f"resolveMethodCall('{call_id}', {r})",
|
||||
"userGesture": True
|
||||
}
|
||||
}, receive=False)
|
||||
|
||||
async def handle_method_call(self, method, tab):
|
||||
res = {}
|
||||
try:
|
||||
if method["method"] == "plugin_method":
|
||||
res["result"] = await self.plugin_loader.handle_plugin_method_call(
|
||||
method["args"]["plugin_name"],
|
||||
method["args"]["method_name"],
|
||||
**method["args"]["args"]
|
||||
)
|
||||
res["success"] = True
|
||||
else:
|
||||
r = await self.utilities.util_methods[method["method"]](**method["args"])
|
||||
res["result"] = r
|
||||
res["success"] = True
|
||||
except Exception as e:
|
||||
res["result"] = str(e)
|
||||
res["success"] = False
|
||||
finally:
|
||||
await self.resolve_method_call(tab, method["id"], res)
|
||||
|
||||
async def method_call_listener(self):
|
||||
while True:
|
||||
try:
|
||||
tab = await get_tab("QuickAccess")
|
||||
break
|
||||
except:
|
||||
await sleep(1)
|
||||
await tab.open_websocket()
|
||||
await tab._send_devtools_cmd({"id": 1, "method": "Runtime.discardConsoleEntries"})
|
||||
await tab._send_devtools_cmd({"id": 1, "method": "Runtime.enable"})
|
||||
async for message in tab.listen_for_message():
|
||||
data = message.json()
|
||||
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))
|
||||
|
||||
def run(self):
|
||||
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
|
||||
|
||||
if __name__ == "__main__":
|
||||
PluginManager().run()
|
||||
@@ -0,0 +1,104 @@
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
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 time import time
|
||||
from multiprocessing import Process
|
||||
from signal import signal, SIGINT
|
||||
from sys import exit
|
||||
|
||||
class PluginWrapper:
|
||||
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
||||
self.file = file
|
||||
self.plugin_directory = plugin_directory
|
||||
self.reader = None
|
||||
self.writer = None
|
||||
self.socket_addr = f"/tmp/plugin_socket_{time()}"
|
||||
self.method_call_lock = Lock()
|
||||
|
||||
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
|
||||
|
||||
self.name = json["name"]
|
||||
self.author = json["author"]
|
||||
self.main_view_html = json["main_view_html"]
|
||||
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
||||
self.flags = json["flags"]
|
||||
|
||||
self.passive = not path.isfile(self.file)
|
||||
|
||||
def _init(self):
|
||||
signal(SIGINT, lambda s, f: exit(0))
|
||||
|
||||
set_event_loop(new_event_loop())
|
||||
if self.passive:
|
||||
return
|
||||
setuid(0 if "root" in self.flags else 1000)
|
||||
spec = spec_from_file_location("_", self.file)
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.Plugin = module.Plugin
|
||||
|
||||
if hasattr(self.Plugin, "_main"):
|
||||
get_event_loop().create_task(self.Plugin._main(self.Plugin))
|
||||
get_event_loop().create_task(self._setup_socket())
|
||||
get_event_loop().run_forever()
|
||||
|
||||
async def _setup_socket(self):
|
||||
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr)
|
||||
|
||||
async def _listen_for_method_call(self, reader, writer):
|
||||
while True:
|
||||
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}
|
||||
try:
|
||||
d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"])
|
||||
except Exception as e:
|
||||
d["res"] = str(e)
|
||||
d["success"] = False
|
||||
finally:
|
||||
writer.write((dumps(d)+"\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
async def _open_socket_if_not_exists(self):
|
||||
if not self.reader:
|
||||
while True:
|
||||
try:
|
||||
self.reader, self.writer = await open_unix_connection(self.socket_addr)
|
||||
break
|
||||
except:
|
||||
await sleep(0)
|
||||
|
||||
def start(self):
|
||||
if self.passive:
|
||||
return self
|
||||
Process(target=self._init).start()
|
||||
return self
|
||||
|
||||
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()
|
||||
get_event_loop().create_task(_(self))
|
||||
|
||||
async def execute_method(self, method_name, kwargs):
|
||||
if self.passive:
|
||||
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
|
||||
async with self.method_call_lock:
|
||||
await self._open_socket_if_not_exists()
|
||||
self.writer.write(
|
||||
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
|
||||
await self.writer.drain()
|
||||
res = loads((await self.reader.readline()).decode("utf-8"))
|
||||
if not res["success"]:
|
||||
raise Exception(res["res"])
|
||||
return res["res"]
|
||||
@@ -0,0 +1,71 @@
|
||||
class PluginEventTarget extends EventTarget { }
|
||||
method_call_ev_target = new PluginEventTarget();
|
||||
|
||||
window.addEventListener("message", function(evt) {
|
||||
let ev = new Event(evt.data.call_id);
|
||||
ev.data = evt.data.result;
|
||||
method_call_ev_target.dispatchEvent(ev);
|
||||
}, false);
|
||||
|
||||
async function call_server_method(method_name, arg_object={}) {
|
||||
let id = `${uuidv4()}`;
|
||||
console.debug(JSON.stringify({
|
||||
"id": id,
|
||||
"method": method_name,
|
||||
"args": arg_object
|
||||
}));
|
||||
return new Promise((resolve, reject) => {
|
||||
method_call_ev_target.addEventListener(`${id}`, function (event) {
|
||||
if (event.data.success) resolve(event.data.result);
|
||||
else reject(event.data.result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/2117523 Thanks!
|
||||
function uuidv4() {
|
||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetch_nocors(url, request={}) {
|
||||
let args = { method: "POST", headers: {}, body: "" };
|
||||
request = {...args, ...request};
|
||||
request.url = url;
|
||||
request.data = request.body;
|
||||
delete request.body; //maintain api-compatibility with fetch
|
||||
return await call_server_method("http_request", request);
|
||||
}
|
||||
|
||||
async function call_plugin_method(method_name, arg_object={}) {
|
||||
if (plugin_name == undefined)
|
||||
throw new Error("Plugin methods can only be called from inside plugins (duh)");
|
||||
return await call_server_method("plugin_method", {
|
||||
'plugin_name': plugin_name,
|
||||
'method_name': method_name,
|
||||
'args': arg_object
|
||||
});
|
||||
}
|
||||
|
||||
async function execute_in_tab(tab, run_async, code) {
|
||||
return await call_server_method("execute_in_tab", {
|
||||
'tab': tab,
|
||||
'run_async': run_async,
|
||||
'code': code
|
||||
});
|
||||
}
|
||||
|
||||
async function inject_css_into_tab(tab, style) {
|
||||
return await call_server_method("inject_css_into_tab", {
|
||||
'tab': tab,
|
||||
'style': style
|
||||
});
|
||||
}
|
||||
|
||||
async function remove_css_from_tab(tab, css_id) {
|
||||
return await call_server_method("remove_css_from_tab", {
|
||||
'tab': tab,
|
||||
'css_id': css_id
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
function reloadIframe() {
|
||||
document.getElementById("plugin_iframe").contentWindow.location.href = "http://127.0.0.1:1337/plugins/iframe";
|
||||
}
|
||||
|
||||
function resolveMethodCall(call_id, result) {
|
||||
let iframe = document.getElementById("plugin_iframe").contentWindow;
|
||||
iframe.postMessage({'call_id': call_id, 'result': result}, "http://127.0.0.1:1337");
|
||||
}
|
||||
|
||||
function installPlugin(request_id) {
|
||||
let id = `${new Date().getTime()}`;
|
||||
console.debug(JSON.stringify({
|
||||
"id": id,
|
||||
"method": "confirm_plugin_install",
|
||||
"args": {"request_id": request_id}
|
||||
}));
|
||||
document.getElementById('plugin_install_list').removeChild(document.getElementById(`plugin_install_prompt_${request_id}`));
|
||||
}
|
||||
|
||||
function addPluginInstallPrompt(artifact, version, request_id) {
|
||||
let text = `
|
||||
<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;
|
||||
|
||||
execute_in_tab('SP', false, 'FocusNavController.DispatchVirtualButtonClick(28)')
|
||||
}
|
||||
|
||||
(function () {
|
||||
const PLUGIN_ICON = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plugin" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a7 7 0 1 1 2.898 5.673c-.167-.121-.216-.406-.002-.62l1.8-1.8a3.5 3.5 0 0 0
|
||||
4.572-.328l1.414-1.415a.5.5 0 0 0 0-.707l-.707-.707 1.559-1.563a.5.5 0 1 0-.708-.706l-1.559 1.562-1.414-1.414
|
||||
1.56-1.562a.5.5 0 1 0-.707-.706l-1.56 1.56-.707-.706a.5.5 0 0 0-.707 0L5.318 5.975a3.5 3.5 0 0 0-.328
|
||||
4.571l-1.8 1.8c-.58.58-.62 1.6.121 2.137A8 8 0 1 0 0 8a.5.5 0 0 0 1 0Z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const SHOP_ICON = `
|
||||
<button
|
||||
class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable"
|
||||
style="width: auto; padding-left: 10px; padding-right: 10px; margin-right: 1rem; margin-left: auto; padding-top: 3px;"
|
||||
id="open_shop_button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bag-fill" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2.5 2.5 0 0 1 2.5 2.5V4h-5v-.5A2.5 2.5 0 0 1 8 1zm3.5 3v-.5a3.5 3.5 0 1 0-7 0V4H1v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4h-3.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
`
|
||||
|
||||
function createTitle(text) {
|
||||
return `
|
||||
<div class="quickaccessmenu_Title_34nl5"><div id="plugin_title">${text}</div>${SHOP_ICON}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function createPluginList() {
|
||||
let pages = document.getElementsByClassName("quickaccessmenu_AllTabContents_2yKG4 quickaccessmenu_Down_3rR0o")[0];
|
||||
let pluginPage = pages.children[pages.children.length - 1];
|
||||
pluginPage.innerHTML = createTitle("Plugins");
|
||||
|
||||
pluginPage.innerHTML += `<div id="plugin_install_list" style="position: fixed; height: 100%; z-index: 99; transform: translate(5%, 0);"></div>`
|
||||
|
||||
pluginPage.innerHTML += `<iframe id="plugin_iframe" style="border: none; width: 100%; height: 100%;" src="http://127.0.0.1:1337/plugins/iframe"></iframe>`;
|
||||
}
|
||||
|
||||
function inject() {
|
||||
let tabs = document.getElementsByClassName("quickaccessmenu_TabContentColumn_2z5NL Panel Focusable")[0];
|
||||
tabs.children[tabs.children.length - 1].innerHTML = PLUGIN_ICON;
|
||||
|
||||
createPluginList();
|
||||
}
|
||||
|
||||
let injector = setInterval(function () {
|
||||
if (document.hasFocus()) {
|
||||
inject();
|
||||
document.getElementById("plugin_title").onclick = function() {
|
||||
reloadIframe();
|
||||
document.getElementById("plugin_title").innerText = `Plugins`;
|
||||
document.getElementById("open_shop_button").style.display = 'block';
|
||||
}
|
||||
document.getElementById("open_shop_button").onclick = function(ev) {
|
||||
console.debug(JSON.stringify({
|
||||
"id": 1,
|
||||
"method": "open_plugin_store",
|
||||
"args": {}
|
||||
}));
|
||||
}
|
||||
window.onmessage = function(ev) {
|
||||
let title = ev.data;
|
||||
if (title.startsWith("PLUGIN_LOADER__")) {
|
||||
document.getElementById("open_shop_button").style.display = 'none';
|
||||
document.getElementById("plugin_title").innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left-square-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12zm-4.5-6.5H5.707l2.147-2.146a.5.5 0 1 0-.708-.708l-3 3a.5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .708-.708L5.707 8.5H11.5a.5.5 0 0 0 0-1z"/>
|
||||
</svg>
|
||||
${title.replace("PLUGIN_LOADER__", "")}
|
||||
`;
|
||||
}
|
||||
}
|
||||
clearInterval(injector);
|
||||
}
|
||||
}, 100);
|
||||
})();
|
||||
@@ -0,0 +1,3 @@
|
||||
@import url("/steam_resource/css/2.css");
|
||||
@import url("/steam_resource/css/39.css");
|
||||
@import url("/steam_resource/css/library.css");
|
||||
@@ -0,0 +1,76 @@
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script>
|
||||
const tile_iframes = [];
|
||||
window.addEventListener("message", function (evt) {
|
||||
tile_iframes.forEach(iframe => {
|
||||
iframe.contentWindow.postMessage(evt.data, "http://127.0.0.1:1337");
|
||||
});
|
||||
}, false);
|
||||
|
||||
function loadPlugin(callsign, name) {
|
||||
this.parent.postMessage("PLUGIN_LOADER__"+name, "https://steamloopback.host");
|
||||
location.href = `/plugins/load_main/${callsign}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{% if not plugins|length %}
|
||||
<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
|
||||
<div class="quickaccesscontrols_EmptyNotifications_3ZjbM" style="padding-top:7px;">
|
||||
No plugins installed
|
||||
</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.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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,10 +1,6 @@
|
||||
import uuid
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
from aiohttp import ClientSession, web
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from injector import inject_to_tab
|
||||
|
||||
import uuid
|
||||
|
||||
class Utilities:
|
||||
def __init__(self, context) -> None:
|
||||
@@ -12,40 +8,16 @@ class Utilities:
|
||||
self.util_methods = {
|
||||
"ping": self.ping,
|
||||
"http_request": self.http_request,
|
||||
"cancel_plugin_install": self.cancel_plugin_install,
|
||||
"confirm_plugin_install": self.confirm_plugin_install,
|
||||
"execute_in_tab": self.execute_in_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:
|
||||
args = await request.json()
|
||||
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):
|
||||
return await self.context.plugin_browser.confirm_plugin_install(request_id)
|
||||
|
||||
def cancel_plugin_install(self, request_id):
|
||||
return self.context.plugin_browser.cancel_plugin_install(request_id)
|
||||
|
||||
async def http_request(self, method="", url="", **kwargs):
|
||||
async with ClientSession() as web:
|
||||
async with web.request(method, url, **kwargs) as res:
|
||||
@@ -58,7 +30,7 @@ class Utilities:
|
||||
async def ping(self, **kwargs):
|
||||
return "pong"
|
||||
|
||||
async def execute_in_tab(self, tab, run_async, code):
|
||||
async def execute_in_tab(self, tab, run_async, code):
|
||||
try:
|
||||
result = await inject_to_tab(tab, code, run_async)
|
||||
if "exceptionDetails" in result["result"]:
|
||||
@@ -72,7 +44,7 @@ class Utilities:
|
||||
"result" : result["result"]["result"].get("value")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
return {
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
@@ -81,7 +53,7 @@ class Utilities:
|
||||
try:
|
||||
css_id = str(uuid.uuid4())
|
||||
|
||||
result = await inject_to_tab(tab,
|
||||
result = await inject_to_tab(tab,
|
||||
f"""
|
||||
(function() {{
|
||||
const style = document.createElement('style');
|
||||
@@ -102,14 +74,14 @@ class Utilities:
|
||||
"result" : css_id
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
return {
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
|
||||
async def remove_css_from_tab(self, tab, css_id):
|
||||
try:
|
||||
result = await inject_to_tab(tab,
|
||||
result = await inject_to_tab(tab,
|
||||
f"""
|
||||
(function() {{
|
||||
let style = document.getElementById("{css_id}");
|
||||
@@ -129,7 +101,20 @@ class Utilities:
|
||||
"success": True
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
return {
|
||||
"success": False,
|
||||
"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")
|
||||
})();
|
||||
""")
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
aiohttp==3.8.1
|
||||
aiohttp-jinja2==1.5.0
|
||||
aiohttp_cors==0.7.0
|
||||
watchdog==2.1.7
|
||||
watchdog==2.1.7
|
||||
Reference in New Issue
Block a user