Compare commits

..

7 Commits

Author SHA1 Message Date
AAGaming e6cc4bba5c hotfix: change store URL in service file 2022-06-28 13:01:21 -04:00
Liam Dawe 1199c080bc Update README.md, password is needed (#70)
* Update README.md

There is no password by default, so people need to set one before running that script.

* Update README.md

add the guide for password
2022-06-06 14:35:56 -07:00
Jonas Dellinger 414e0da2f3 Fix hot-reload when there are subdirs (#56) 2022-05-11 02:11:14 +03:00
tza cb9b888dc6 Merge branch 'main' of https://github.com/SteamDeckHomebrew/PluginLoader 2022-05-10 23:17:12 +03:00
tza f3ab0f5989 Plugin store button now uses built-in browser 2022-05-10 23:17:09 +03:00
marios e132aba0f8 Fixed race condition pr 2022-05-10 21:11:51 +03:00
tza 0d0e57e35a Added store button 2022-05-10 20:31:39 +03:00
46 changed files with 680 additions and 5701 deletions
+5 -16
View File
@@ -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
View File
@@ -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/
+3 -6
View File
@@ -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
}
]
}
-6
View File
@@ -1,6 +0,0 @@
{
"[python]": {
"editor.detectIndentation": false,
"editor.tabSize": 4
}
}
+1 -6
View File
@@ -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",
}
}
]
}
+9 -35
View File
@@ -1,31 +1,23 @@
# 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 [![Chat](https://img.shields.io/badge/chat-on%20discord-7289da.svg)](https://discord.gg/ZU74G2NJzk)
![steamuserimages-a akamaihd](https://user-images.githubusercontent.com/10835354/161068262-ca723dc5-6795-417a-80f6-d8c1f9d03e93.jpg)
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`
3. Scroll the sidebar all the way down and click on `Developer`
4. Under Miscellaneous, enable `CEF Remote Debugging`
5. Click on the `STEAM` button and select `Power` -> `Switch to Desktop`
6. Open a terminal and paste the following command into it:
6. Make sure you have a password set with the "passwd" command in terminal to install it ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ)).
7. 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 +25,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 +35,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
-98
View File
@@ -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()
-59
View File
@@ -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)
-18
View File
@@ -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
-18
View File
@@ -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)
-129
View File
@@ -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")
-46
View File
@@ -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))
-37
View File
@@ -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
View File
@@ -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 $?"
-126
View File
@@ -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
View File
@@ -40,6 +40,7 @@ Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=STORE_URL=https://plugins.deckbrew.xyz
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
+1
View File
@@ -30,6 +30,7 @@ User=root
Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=STORE_URL=https://plugins.deckbrew.xyz
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
WantedBy=multi-user.target
-4
View File
@@ -1,4 +0,0 @@
node_modules/
.yalc
yalc.lock
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd frontend && npm run lint
-9
View File
@@ -1,9 +0,0 @@
module.exports = {
semi: true,
trailingComma: 'all',
singleQuote: true,
printWidth: 120,
tabWidth: 2,
endOfLine: 'auto',
plugins: [require('prettier-plugin-import-sort')],
};
-3881
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -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"
}
}
-29
View File
@@ -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>
);
};
-74
View File
@@ -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>
);
};
-11
View File
@@ -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;
-49
View File
@@ -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;
-20
View File
@@ -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;
-25
View File
@@ -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);
-35
View File
@@ -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;
-182
View File
@@ -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;
-6
View File
@@ -1,6 +0,0 @@
export interface Plugin {
name: any;
content: any;
icon: any;
onDismount?(): void;
}
-101
View File
@@ -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;
-69
View File
@@ -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;
-23
View File
@@ -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"]
+103 -104
View File
@@ -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)
+144
View File
@@ -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()
+104
View File
@@ -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"]
+71
View File
@@ -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
});
}
+121
View File
@@ -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);
})();
+3
View File
@@ -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");
+76
View File
@@ -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
View File
@@ -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