Compare commits

...

37 Commits

Author SHA1 Message Date
AAGaming 8b3f569a09 Add plugin updater, notification badge, fixes 2022-08-21 16:41:25 -04:00
Collin Diekvoss 1930400032 Better wrapping of plugin tags (#150) 2022-08-20 21:40:57 -04:00
Sefa Eyeoglu 43dee863cd Add CEF Remote Debugging toggle (#129)
* feat: add CEF Remote Debugging toggle

* feat: disable remote debugger on startup

* refactor: stop debugger instead of disable

* feat: add option to allow remote debugging by default

Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
2022-08-18 14:50:59 -07:00
TrainDoctor 55a7682663 fix ButtonItem shim applying when not needed 2022-08-17 17:32:50 -07:00
TrainDoctor d05e8d36b4 Update to latest decky-frontend-lib 2022-08-17 17:28:30 -07:00
AAGaming 0018b8e957 bump lib and add temporary shims for webpack v5 2022-08-17 20:03:45 -04:00
TrainDoctor 59038f65ac Fix log spam from injection related errors 2022-08-17 12:57:58 -07:00
AAGaming 5960c11d60 add class names to PluginCard for theming 2022-08-17 15:27:22 -04:00
Sefa Eyeoglu 8d065eab1f Add Plugin Reload Button to Settings (#128)
* feat: add reload button to plugin list

Signed-off-by: Sefa Eyeoglu <contact@scrumplex.net>

* refactor: move plugin actions into context menu

Signed-off-by: Sefa Eyeoglu <contact@scrumplex.net>
2022-08-16 16:51:39 -07:00
AAGaming 3b1b6d28d6 add some classes for nicer scrolling, update lib 2022-08-15 13:22:38 -04:00
AAGaming 0a735886c9 fix toasts breaking sometimes 2022-08-14 21:59:55 -04:00
AAGaming c9430f5be4 less stupid method 2022-08-14 13:17:39 -04:00
AAGaming a4e2237fc0 fix loader not re-injecting on restart 2022-08-14 12:51:07 -04:00
AAGaming 85d0398e62 shut typescript up 2022-08-14 00:02:01 -04:00
AAGaming 30a538e85e FINALLY fix the multiple injections bug 2022-08-13 23:58:57 -04:00
AAGaming 84a19203c5 fix injecting twice 2022-08-13 11:57:52 -04:00
AAGaming 99cda2907d fix TS errors 2022-08-12 21:02:11 -04:00
AAGaming a38582d158 Fix toaster deinit error 2022-08-12 16:49:28 -04:00
TrainDoctor 9556994e14 fix empty settings and store screens after reboot 2022-08-12 11:45:29 -07:00
OMGDuke dee2cfa47b remove console.log that was causing lots of log spam (#138) 2022-08-12 09:54:57 -04:00
TrainDoctor 463403be23 Update build.yml 2022-08-11 20:37:46 -07:00
TrainDoctor b68eaca55d Updater should now find all version tags 2022-08-11 20:12:17 -07:00
AAGaming 114c54c9b0 Fix route unpatching 2022-08-11 20:34:55 -04:00
TrainDoctor 47e0661773 Add releases back to the mix 2022-08-11 17:10:37 -07:00
TrainDoctor 6c48dfe7f6 Actually send the proper variable out 2022-08-11 16:54:13 -07:00
TrainDoctor ed0ae7c9e2 Removed un-needed trimming 2022-08-11 16:48:50 -07:00
TrainDoctor ea265ae6df Corrected dummy tag, added echoing 2022-08-11 16:18:21 -07:00
TrainDoctor 860caf440b Add semver tool, temporarily disable triggered pre-releases 2022-08-11 16:10:00 -07:00
TrainDoctor 64040879f5 Update to latest version of decky-frontend-lib 2022-08-10 15:48:48 -07:00
AAGaming e92073162a oops: remove test log 2022-08-10 16:34:53 -04:00
AAGaming 67426af3ef Add api for showing toast notifications 2022-08-09 21:52:03 -04:00
Sefa Eyeoglu 0dbdb4a143 fix: don't pass unzip job to event loop (#136)
For some reason this broke installation of plugins when another specific
plugin was present (vibrantDeck)
2022-08-09 12:06:33 -07:00
TrainDoctor c9e9c45b37 Standardize logging in browser.py 2022-08-08 13:06:04 -07:00
TrainDoctor 6bc8a4fb1d Add missing import 2022-08-08 12:38:35 -07:00
Derek J. Clark 20094c5f75 Use Environment Variables (#123)
Uses environment variables instead of hard coding the "deck" user/group.
This adds support for systems other than the steam deck that are using the DeckUI.

* Use Environment Variables

* Use method to get USER from a systemd root process

* Fix imports. Add get_user and get_user_group methods in helpers.py. Removed duplicated code

* Add separate setters/getters for user vars. Ensure sleep prevents race condition of user setter in while loop
2022-08-08 11:32:14 -07:00
AAGaming 198591dbd7 whoops don't need it here 2022-08-05 21:18:19 -04:00
AAGaming f21d34506d Implement CSRF protection 2022-08-05 21:16:29 -04:00
37 changed files with 932 additions and 303 deletions
+63 -13
View File
@@ -75,12 +75,6 @@ jobs:
name: PluginLoader name: PluginLoader
path: dist path: dist
- name: Bump version and push tag ⏫
id: tag_version
uses: mathieudutour/github-tag-action@v6.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Release 📦 - name: Release 📦
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
@@ -89,9 +83,9 @@ jobs:
files: ./dist/PluginLoader files: ./dist/PluginLoader
generate_release_notes: true generate_release_notes: true
nightly: prerelease:
name: Release the nightly version of the package name: Release the pre-release version of the package
if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'prerelease') }} if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'prerelease' }}
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -105,6 +99,12 @@ jobs:
name: PluginLoader name: PluginLoader
path: dist path: dist
- name: Install semver-tool asdf
uses: asdf-vm/actions/install@v1
with:
tool_versions: |
semver 3.3.0
- name: Get tag 🏷️ - name: Get tag 🏷️
id: old_tag id: old_tag
uses: rafarlopes/get-latest-pre-release-tag-action@v1 uses: rafarlopes/get-latest-pre-release-tag-action@v1
@@ -112,20 +112,22 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
repository: 'decky-loader' repository: 'decky-loader'
- name: Prepare tag ⚙️ - name: Prepare tag ⚙️
id: ready_tag id: ready_tag
run: | run: |
export VERSION=${{ steps.old_tag.outputs.tag }} export VERSION=${{ steps.old_tag.outputs.tag }}
export COMMIT=$(git log -1 --pretty=format:%h) echo "VERS: $VERSION"
echo ::set-output name=tag_name::$(sed -r 's/(-.*)?-pre$//' <<< $VERSION)-$COMMIT-pre OUT=$(semver bump prerel "$VERSION")
echo "OUT: $OUT"
echo ::set-output name=tag_name::v$OUT
- name: Push tag 📤 - name: Push tag 📤
uses: rickstaa/action-create-tag@v1.3.2 uses: rickstaa/action-create-tag@v1.3.2
if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' }} if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' }}
with: with:
tag: ${{ steps.ready_tag.outputs.tag_name }} tag: ${{ steps.ready_tag.outputs.tag_name }}
message: Nightly ${{ steps.ready_tag.outputs.tag_name }} message: Pre-release ${{ steps.ready_tag.outputs.tag_name }}
- name: Release 📦 - name: Release 📦
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
@@ -137,6 +139,54 @@ jobs:
prerelease: true prerelease: true
generate_release_notes: true generate_release_notes: true
# nightly:
# name: Release the nightly version of the package
# if: ${{ github.event_name == 'schedule' }}
# needs: build
# runs-on: ubuntu-latest
# steps:
# - name: Checkout 🧰
# uses: actions/checkout@v3
# - name: Fetch package artifact ⬇️
# uses: actions/download-artifact@v3
# with:
# name: PluginLoader
# path: dist
# - name: Get tag 🏷️
# id: old_tag
# uses: rafarlopes/get-latest-pre-release-tag-action@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# repository: 'decky-loader'
# - name: Prepare tag ⚙️
# id: ready_tag
# run: |
# export VERSION=${{ steps.old_tag.outputs.tag }}
# export COMMIT=$(git log -1 --pretty=format:%h)
# echo ::set-output name=tag_name::$(sed -r 's/(-.*)?-pre$//' <<< $VERSION)-$COMMIT-nightly
# - name: Push tag 📤
# uses: rickstaa/action-create-tag@v1.3.2
# if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' }}
# with:
# tag: ${{ steps.ready_tag.outputs.tag_name }}
# message: Nightly ${{ steps.ready_tag.outputs.tag_name }}
# - name: Release 📦
# uses: softprops/action-gh-release@v1
# if: ${{ github.event_name == 'workflow_dispatch' }}
# with:
# name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
# tag_name: ${{ steps.ready_tag.outputs.tag_name }}
# files: ./dist/PluginLoader
# prerelease: true
# generate_release_notes: true
# - name: Bump prerelease ⏫ # - name: Bump prerelease ⏫
# id: bump # id: bump
# if: ${{ github.event_name == 'schedule' }} # if: ${{ github.event_name == 'schedule' }}
+35 -35
View File
@@ -1,20 +1,24 @@
from injector import get_tab # Full imports
import json
# Partial imports
from aiohttp import ClientSession, web
from asyncio import get_event_loop
from concurrent.futures import ProcessPoolExecutor
from hashlib import sha256
from io import BytesIO
from logging import getLogger from logging import getLogger
from os import path, rename, listdir from os import path, rename, listdir
from shutil import rmtree from shutil import rmtree
from aiohttp import ClientSession, web from subprocess import call
from io import BytesIO
from zipfile import ZipFile
from concurrent.futures import ProcessPoolExecutor
from asyncio import get_event_loop
from time import time from time import time
from hashlib import sha256 from zipfile import ZipFile
from subprocess import Popen
from injector import inject_to_tab
import json # Local modules
from helpers import get_ssl_context, get_user, get_user_group
from injector import get_tab, inject_to_tab
import helpers logger = getLogger("Browser")
class PluginInstallContext: class PluginInstallContext:
def __init__(self, artifact, name, version, hash) -> None: def __init__(self, artifact, name, version, hash) -> None:
@@ -25,7 +29,6 @@ class PluginInstallContext:
class PluginBrowser: class PluginBrowser:
def __init__(self, plugin_path, server_instance, plugins) -> None: def __init__(self, plugin_path, server_instance, plugins) -> None:
self.log = getLogger("browser")
self.plugin_path = plugin_path self.plugin_path = plugin_path
self.plugins = plugins self.plugins = plugins
self.install_requests = {} self.install_requests = {}
@@ -41,8 +44,11 @@ class PluginBrowser:
return False return False
zip_file = ZipFile(zip) zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path) zip_file.extractall(self.plugin_path)
Popen(["chown", "-R", "deck:deck", self.plugin_path]) code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), self.plugin_path])
Popen(["chmod", "-R", "555", self.plugin_path]) code_chmod = call(["chmod", "-R", "555", self.plugin_path])
if code_chown != 0 or code_chmod != 0:
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
return False
return True return True
def find_plugin_folder(self, name): def find_plugin_folder(self, name):
@@ -54,7 +60,7 @@ class PluginBrowser:
if plugin['name'] == name: if plugin['name'] == name:
return path.join(self.plugin_path, folder) return path.join(self.plugin_path, folder)
except: except:
self.log.debug(f"skipping {folder}") logger.debug(f"skipping {folder}")
async def uninstall_plugin(self, name): async def uninstall_plugin(self, name):
tab = await get_tab("SP") tab = await get_tab("SP")
@@ -63,15 +69,15 @@ class PluginBrowser:
if type(name) != str: if type(name) != str:
data = await name.post() data = await name.post()
name = data.get("name", "undefined") name = data.get("name", "undefined")
self.log.info("uninstalling " + name) logger.info("uninstalling " + name)
self.log.info(" at dir " + self.find_plugin_folder(name)) logger.info(" at dir " + self.find_plugin_folder(name))
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')") await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
if self.plugins[name]: if self.plugins[name]:
self.plugins[name].stop() self.plugins[name].stop()
self.plugins.pop(name, None) self.plugins.pop(name, None)
rmtree(self.find_plugin_folder(name)) rmtree(self.find_plugin_folder(name))
except FileNotFoundError: except FileNotFoundError:
self.log.warning(f"Plugin {name} not installed, skipping uninstallation") logger.warning(f"Plugin {name} not installed, skipping uninstallation")
return web.Response(text="Requested plugin uninstall") return web.Response(text="Requested plugin uninstall")
@@ -79,32 +85,26 @@ class PluginBrowser:
try: try:
await self.uninstall_plugin(name) await self.uninstall_plugin(name)
except: except:
self.log.error(f"Plugin {name} not installed, skipping uninstallation") logger.error(f"Plugin {name} not installed, skipping uninstallation")
self.log.info(f"Installing {name} (Version: {version})") logger.info(f"Installing {name} (Version: {version})")
async with ClientSession() as client: async with ClientSession() as client:
self.log.debug(f"Fetching {artifact}") logger.debug(f"Fetching {artifact}")
res = await client.get(artifact, ssl=helpers.get_ssl_context()) res = await client.get(artifact, ssl=get_ssl_context())
if res.status == 200: if res.status == 200:
self.log.debug("Got 200. Reading...") logger.debug("Got 200. Reading...")
data = await res.read() data = await res.read()
self.log.debug(f"Read {len(data)} bytes") logger.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data) res_zip = BytesIO(data)
with ProcessPoolExecutor() as executor: with ProcessPoolExecutor() as executor:
self.log.debug("Unzipping...") logger.debug("Unzipping...")
ret = await get_event_loop().run_in_executor( ret = self._unzip_to_plugin_dir(res_zip, name, hash)
executor,
self._unzip_to_plugin_dir,
res_zip,
name,
hash
)
if ret: if ret:
self.log.info(f"Installed {name} (Version: {version})") logger.info(f"Installed {name} (Version: {version})")
await inject_to_tab("SP", "window.syncDeckyPlugins()") await inject_to_tab("SP", "window.syncDeckyPlugins()")
else: else:
self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})") logger.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
else: else:
self.log.fatal(f"Could not fetch from URL. {await res.text()}") logger.fatal(f"Could not fetch from URL. {await res.text()}")
async def install_plugin(self, request): async def install_plugin(self, request):
data = await request.post() data = await request.post()
+78 -2
View File
@@ -1,7 +1,83 @@
import ssl
import certifi import certifi
import ssl
import uuid
import re
import subprocess
from aiohttp.web import middleware, Response
from subprocess import check_output
from time import sleep
REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
# global vars
csrf_token = str(uuid.uuid4())
ssl_ctx = ssl.create_default_context(cafile=certifi.where()) ssl_ctx = ssl.create_default_context(cafile=certifi.where())
user = None
group = None
assets_regex = re.compile("^/plugins/.*/assets/.*")
def get_ssl_context(): def get_ssl_context():
return ssl_ctx return ssl_ctx
def get_csrf_token():
return csrf_token
@middleware
async def csrf_middleware(request, handler):
if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or assets_regex.match(str(request.rel_url)):
return await handler(request)
return Response(text='Forbidden', status='403')
# Get the user by checking for the first logged in user. As this is run
# by systemd at startup the process is likely to start before the user
# logs in, so we will wait here until they are available. Note that
# other methods such as getenv wont work as there was no $SUDO_USER to
# start the systemd service.
def set_user():
global user
cmd = "who | awk '{print $1}' | sort | head -1"
while user == None:
name = check_output(cmd, shell=True).decode().strip()
if name not in [None, '']:
user = name
sleep(0.1)
# Get the global user. get_user must be called first.
def get_user() -> str:
global user
if user == None:
raise ValueError("helpers.get_user method called before user variable was set. Run helpers.set_user first.")
return user
# Set the global user group. get_user must be called first
def set_user_group() -> str:
global group
global user
if user == None:
raise ValueError("helpers.set_user_dir method called before user variable was set. Run helpers.set_user first.")
if group == None:
group = check_output(["id", "-g", "-n", user]).decode().strip()
# Get the group of the global user. set_user_group must be called first.
def get_user_group() -> str:
global group
if group == None:
raise ValueError("helpers.get_user_group method called before group variable was set. Run helpers.set_user_group first.")
return group
async def is_systemd_unit_active(unit_name: str) -> bool:
res = subprocess.run(["systemctl", "is-active", unit_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return res.returncode == 0
async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "stop", unit_name]
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def start_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "start", unit_name]
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+5 -2
View File
@@ -5,6 +5,7 @@ from logging import debug, getLogger
from traceback import format_exc from traceback import format_exc
from aiohttp import ClientSession from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
BASE_ADDRESS = "http://localhost:8080" BASE_ADDRESS = "http://localhost:8080"
@@ -65,11 +66,13 @@ async def get_tabs():
while True: while True:
try: try:
res = await web.get(f"{BASE_ADDRESS}/json") res = await web.get(f"{BASE_ADDRESS}/json")
break except ClientConnectorError:
except: logger.debug("ClientConnectorError excepted.")
logger.debug("Steam isn't available yet. Wait for a moment...") logger.debug("Steam isn't available yet. Wait for a moment...")
logger.debug(format_exc()) logger.debug(format_exc())
await sleep(5) await sleep(5)
else:
break
if res.status == 200: if res.status == 200:
r = await res.json() r = await res.json()
+7 -1
View File
@@ -8,10 +8,13 @@ window.addEventListener("message", function(evt) {
}, false); }, false);
async function call_server_method(method_name, arg_object={}) { async function call_server_method(method_name, arg_object={}) {
const token = await fetch("http://127.0.0.1:1337/auth/token").then(r => r.text());
const response = await fetch(`http://127.0.0.1:1337/methods/${method_name}`, { const response = await fetch(`http://127.0.0.1:1337/methods/${method_name}`, {
method: 'POST', method: 'POST',
credentials: "include",
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authentication: token
}, },
body: JSON.stringify(arg_object), body: JSON.stringify(arg_object),
}); });
@@ -40,10 +43,13 @@ async function fetch_nocors(url, request={}) {
async function call_plugin_method(method_name, arg_object={}) { async function call_plugin_method(method_name, arg_object={}) {
if (plugin_name == undefined) if (plugin_name == undefined)
throw new Error("Plugin methods can only be called from inside plugins (duh)"); throw new Error("Plugin methods can only be called from inside plugins (duh)");
const token = await fetch("http://127.0.0.1:1337/auth/token").then(r => r.text());
const response = await fetch(`http://127.0.0.1:1337/plugins/${plugin_name}/methods/${method_name}`, { const response = await fetch(`http://127.0.0.1:1337/plugins/${plugin_name}/methods/${method_name}`, {
method: 'POST', method: 'POST',
credentials: "include",
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authentication: token
}, },
body: JSON.stringify({ body: JSON.stringify({
args: arg_object, args: arg_object,
+4 -4
View File
@@ -88,7 +88,7 @@ class Loader:
async def get_plugins(self, request): async def get_plugins(self, request):
plugins = list(self.plugins.values()) plugins = list(self.plugins.values())
return web.json_response([str(i) if not i.legacy else "$LEGACY_"+str(i) for i in plugins]) return web.json_response([{"name": str(i) if not i.legacy else "$LEGACY_"+str(i), "version": i.version} for i in plugins])
def handle_frontend_assets(self, request): def handle_frontend_assets(self, request):
plugin = self.plugins[request.match_info["plugin_name"]] plugin = self.plugins[request.match_info["plugin_name"]]
@@ -116,13 +116,13 @@ class Loader:
self.logger.info(f"Plugin {plugin.name} is passive") self.logger.info(f"Plugin {plugin.name} is passive")
self.plugins[plugin.name] = plugin.start() self.plugins[plugin.name] = plugin.start()
self.logger.info(f"Loaded {plugin.name}") self.logger.info(f"Loaded {plugin.name}")
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name)) self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version))
except Exception as e: except Exception as e:
self.logger.error(f"Could not load {file}. {e}") self.logger.error(f"Could not load {file}. {e}")
print_exc() print_exc()
async def dispatch_plugin(self, name): async def dispatch_plugin(self, name, version):
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}')") await inject_to_tab("SP", f"window.importDeckyPlugin('{name}', '{version}')")
def import_plugins(self): def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}") self.logger.info(f"import plugins from {self.plugin_path}")
+45 -22
View File
@@ -1,10 +1,36 @@
from logging import DEBUG, INFO, basicConfig, getLogger # Full imports
from os import getenv import aiohttp_cors
# Partial imports
from aiohttp import ClientSession from aiohttp import ClientSession
from aiohttp.web import Application, run_app, static, get, Response
from aiohttp_jinja2 import setup as jinja_setup
from asyncio import get_event_loop, sleep
from json import dumps, loads
from logging import DEBUG, INFO, basicConfig, getLogger
from os import getenv, path
from subprocess import call
# local modules
from browser import PluginBrowser
from helpers import csrf_middleware, get_csrf_token, get_user, get_user_group, set_user, set_user_group, stop_systemd_unit, REMOTE_DEBUGGER_UNIT
from injector import inject_to_tab, tab_has_global_var
from loader import Loader
from updater import Updater
from utilities import Utilities
# Ensure USER and GROUP vars are set first.
# TODO: This isn't the best way to do this but supports the current
# implementation. All the config load and environment setting eventually be
# moved into init or a config/loader method.
set_user()
set_user_group()
USER = get_user()
GROUP = get_user_group()
HOME_PATH = "/home/"+USER
HOMEBREW_PATH = HOME_PATH+"/homebrew"
CONFIG = { CONFIG = {
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"), "plugin_path": getenv("PLUGIN_PATH", HOMEBREW_PATH+"/plugins"),
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1", "chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
"server_host": getenv("SERVER_HOST", "127.0.0.1"), "server_host": getenv("SERVER_HOST", "127.0.0.1"),
"server_port": int(getenv("SERVER_PORT", "1337")), "server_port": int(getenv("SERVER_PORT", "1337")),
@@ -14,36 +40,25 @@ CONFIG = {
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s") 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 call
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
from updater import Updater
logger = getLogger("Main") logger = getLogger("Main")
async def chown_plugin_dir(_): async def chown_plugin_dir(_):
code_chown = call(["chown", "-R", "deck:deck", CONFIG["plugin_path"]]) code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]]) code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
if code_chown != 0 or code_chmod != 0: if code_chown != 0 or code_chmod != 0:
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})") logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
def remote_debugging_allowed():
return path.exists(HOMEBREW_PATH + "/allow_remote_debugging")
class PluginManager: class PluginManager:
def __init__(self) -> None: def __init__(self) -> None:
self.loop = get_event_loop() self.loop = get_event_loop()
self.web_app = Application() self.web_app = Application()
self.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={ self.cors = aiohttp_cors.setup(self.web_app, defaults={
"https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*", "https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
allow_headers="*") allow_headers="*", allow_credentials=True)
}) })
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"]) 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, self.plugin_loader.plugins) self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, self.plugin_loader.plugins)
@@ -51,12 +66,15 @@ class PluginManager:
self.updater = Updater(self) self.updater = Updater(self)
jinja_setup(self.web_app) jinja_setup(self.web_app)
self.web_app.on_startup.append(self.inject_javascript)
if CONFIG["chown_plugin_path"] == True: if CONFIG["chown_plugin_path"] == True:
self.web_app.on_startup.append(chown_plugin_dir) self.web_app.on_startup.append(chown_plugin_dir)
self.loop.create_task(self.loader_reinjector()) self.loop.create_task(self.loader_reinjector())
self.loop.create_task(self.load_plugins()) self.loop.create_task(self.load_plugins())
if not remote_debugging_allowed():
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
self.loop.set_exception_handler(self.exception_handler) self.loop.set_exception_handler(self.exception_handler)
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
for route in list(self.web_app.router.routes()): for route in list(self.web_app.router.routes()):
self.cors.add(route) self.cors.add(route)
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))]) self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
@@ -67,6 +85,9 @@ class PluginManager:
return return
loop.default_exception_handler(context) loop.default_exception_handler(context)
async def get_auth_token(self, request):
return Response(text=get_csrf_token())
async def wait_for_server(self): async def wait_for_server(self):
async with ClientSession() as web: async with ClientSession() as web:
while True: while True:
@@ -82,6 +103,8 @@ class PluginManager:
#await inject_to_tab("SP", "window.syncDeckyPlugins();") #await inject_to_tab("SP", "window.syncDeckyPlugins();")
async def loader_reinjector(self): async def loader_reinjector(self):
await sleep(2)
await self.inject_javascript()
while True: while True:
await sleep(5) await sleep(5)
if not await tab_has_global_var("SP", "deckyHasLoaded"): if not await tab_has_global_var("SP", "deckyHasLoaded"):
@@ -90,7 +113,7 @@ class PluginManager:
async def inject_javascript(self, request=None): async def inject_javascript(self, request=None):
try: 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) await inject_to_tab("SP", "try{window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};" + open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read() + "})();}catch(e){console.error(e)}", True)
except: except:
logger.info("Failed to inject JavaScript into tab") logger.info("Failed to inject JavaScript into tab")
pass pass
+6
View File
@@ -21,7 +21,13 @@ class PluginWrapper:
self.socket_addr = f"/tmp/plugin_socket_{time()}" self.socket_addr = f"/tmp/plugin_socket_{time()}"
self.method_call_lock = Lock() self.method_call_lock = Lock()
self.version = None
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r")) json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
if path.isfile(path.join(plugin_path, plugin_directory, "package.json")):
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r"))
self.version = package_json["version"]
self.legacy = False self.legacy = False
self.main_view_html = json["main_view_html"] if "main_view_html" in json else "" self.main_view_html = json["main_view_html"] if "main_view_html" in json else ""
+5 -3
View File
@@ -62,14 +62,16 @@ class Updater:
"updatable": self.localVer != None "updatable": self.localVer != None
} }
else: else:
return {"current": "unknown", "updatable": False} return {"current": "unknown", "remote": self.remoteVer, "updatable": False}
async def check_for_updates(self): async def check_for_updates(self):
async with ClientSession() as web: async with ClientSession() as web:
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res: async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
remoteVersions = await res.json() remoteVersions = await res.json()
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].endswith("-pre"), remoteVersions), None) self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
logger.info("Updated remote version information") logger.info("Updated remote version information")
tab = await get_tab("SP")
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version() return await self.get_version()
async def version_reloader(self): async def version_reloader(self):
@@ -79,7 +81,7 @@ class Updater:
await self.check_for_updates() await self.check_for_updates()
except: except:
pass pass
await sleep(60 * 60) # 1 hour await sleep(60 * 60 * 6) # 6 hours
async def do_update(self): async def do_update(self):
version = self.remoteVer["tag_name"] version = self.remoteVer["tag_name"]
+17 -1
View File
@@ -5,6 +5,7 @@ from aiohttp import ClientSession, web
from injector import inject_to_tab from injector import inject_to_tab
import helpers import helpers
import subprocess
class Utilities: class Utilities:
def __init__(self, context) -> None: def __init__(self, context) -> None:
@@ -16,7 +17,10 @@ class Utilities:
"confirm_plugin_install": self.confirm_plugin_install, "confirm_plugin_install": self.confirm_plugin_install,
"execute_in_tab": self.execute_in_tab, "execute_in_tab": self.execute_in_tab,
"inject_css_into_tab": self.inject_css_into_tab, "inject_css_into_tab": self.inject_css_into_tab,
"remove_css_from_tab": self.remove_css_from_tab "remove_css_from_tab": self.remove_css_from_tab,
"allow_remote_debugging": self.allow_remote_debugging,
"disallow_remote_debugging": self.disallow_remote_debugging,
"remote_debugging_allowed": self.remote_debugging_allowed
} }
if context: if context:
@@ -133,3 +137,15 @@ class Utilities:
"success": False, "success": False,
"result": e "result": e
} }
async def remote_debugging_allowed(self):
return await helpers.is_systemd_unit_active(helpers.REMOTE_DEBUGGER_UNIT)
async def allow_remote_debugging(self):
await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
return True
async def disallow_remote_debugging(self):
await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
return True
+8 -8
View File
@@ -4,12 +4,13 @@
echo "Installing Steam Deck Plugin Loader nightly..." echo "Installing Steam Deck Plugin Loader nightly..."
HOMEBREW_FOLDER=/home/deck/homebrew USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Create folder structure # Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services rm -rf ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
# Download latest nightly build and install it # Download latest nightly build and install it
rm -rf /tmp/plugin_loader rm -rf /tmp/plugin_loader
@@ -22,7 +23,7 @@ chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
systemctl --user stop plugin_loader 2> /dev/null systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null systemctl --user disable plugin_loader 2> /dev/null
rm -f /home/deck/.config/systemd/user/plugin_loader.service rm -f ${USER_DIR}/.config/systemd/user/plugin_loader.service
systemctl stop plugin_loader 2> /dev/null systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null systemctl disable plugin_loader 2> /dev/null
@@ -37,10 +38,9 @@ Type=simple
User=root User=root
Restart=always Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
Vendored Executable → Regular
+4 -3
View File
@@ -4,12 +4,13 @@
echo "Installing Steam Deck Plugin Loader pre-release..." echo "Installing Steam Deck Plugin Loader pre-release..."
HOMEBREW_FOLDER=/home/deck/homebrew USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# # Create folder structure # # Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services rm -rf ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
# Download latest release and install it # Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))") RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
+12 -11
View File
@@ -4,33 +4,34 @@
echo "Installing Steam Deck Plugin Loader release..." echo "Installing Steam Deck Plugin Loader release..."
HOMEBREW_FOLDER=/home/deck/homebrew USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Create folder structure # Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
# Download latest release and install it # Download latest release and install it
curl -L https://github.com/SteamDeckHomebrew/PluginLoader/releases/latest/download/PluginLoader --output ${HOMEBREW_FOLDER}/services/PluginLoader curl -L https://github.com/SteamDeckHomebrew/PluginLoader/releases/latest/download/PluginLoader --output "${HOMEBREW_FOLDER}/services/PluginLoader"
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader chmod +x "${HOMEBREW_FOLDER}/services/PluginLoader"
systemctl --user stop plugin_loader 2> /dev/null systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null systemctl disable plugin_loader 2> /dev/null
rm -f /etc/systemd/system/plugin_loader.service rm -f "/etc/systemd/system/plugin_loader.service"
cat > /etc/systemd/system/plugin_loader.service <<- EOM cat > "/etc/systemd/system/plugin_loader.service" <<- EOM
[Unit] [Unit]
Description=SteamDeck Plugin Loader Description=SteamDeck Plugin Loader
[Service] [Service]
Type=simple Type=simple
User=root User=root
Restart=always Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOM EOM
+8 -5
View File
@@ -1,17 +1,20 @@
#!/bin/sh #!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Uninstalling Steam Deck Plugin Loader..." echo "Uninstalling Steam Deck Plugin Loader..."
HOMEBREW_FOLDER=/home/deck/homebrew USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Disable and remove services # Disable and remove services
sudo systemctl disable --now plugin_loader.service > /dev/null sudo systemctl disable --now plugin_loader.service > /dev/null
sudo rm -f /home/deck/.config/systemd/user/plugin_loader.service sudo rm -f "${USER_DIR}/.config/systemd/user/plugin_loader.service"
sudo rm -f /etc/systemd/system/plugin_loader.service sudo rm -f "/etc/systemd/system/plugin_loader.service"
# Remove temporary folder if it exists from the install process # Remove temporary folder if it exists from the install process
rm -rf /tmp/plugin_loader rm -rf "/tmp/plugin_loader"
# Cleanup services folder # Cleanup services folder
sudo rm ${HOMEBREW_FOLDER}/services/PluginLoader sudo rm "${HOMEBREW_FOLDER}/services/PluginLoader"
+1 -1
View File
@@ -37,7 +37,7 @@
} }
}, },
"dependencies": { "dependencies": {
"decky-frontend-lib": "^1.2.4", "decky-frontend-lib": "^1.7.5",
"react-icons": "^4.4.0" "react-icons": "^4.4.0"
} }
} }
+6 -4
View File
@@ -9,7 +9,7 @@ specifiers:
'@types/react': 16.14.0 '@types/react': 16.14.0
'@types/react-router': 5.1.18 '@types/react-router': 5.1.18
'@types/webpack': ^5.28.0 '@types/webpack': ^5.28.0
decky-frontend-lib: ^1.2.4 decky-frontend-lib: ^1.7.5
husky: ^8.0.1 husky: ^8.0.1
import-sort-style-module: ^6.0.0 import-sort-style-module: ^6.0.0
inquirer: ^8.2.4 inquirer: ^8.2.4
@@ -23,7 +23,7 @@ specifiers:
typescript: ^4.7.4 typescript: ^4.7.4
dependencies: dependencies:
decky-frontend-lib: 1.2.4 decky-frontend-lib: 1.7.5
react-icons: 4.4.0_react@16.14.0 react-icons: 4.4.0_react@16.14.0
devDependencies: devDependencies:
@@ -806,8 +806,10 @@ packages:
ms: 2.1.2 ms: 2.1.2
dev: true dev: true
/decky-frontend-lib/1.2.4: /decky-frontend-lib/1.7.5:
resolution: {integrity: sha512-r3mLEey9KUkF68geJVSjNlOz/Fg4vpMKUzoutSceyd8o/J5l+QR+Vf0b3gwK3UN9Sp4Pj4XQ1eB82+/W0ApsFg==} resolution: {integrity: sha512-1OX/Ix9W76gF0NJjfm0k/01LYPmC2k/k+k/qqH8JJPlPHh5+W5P8ZG2T8m5wKsqoP7jx2W3k7RNZBh9vAqFoFw==}
dependencies:
minimist: 1.2.6
dev: false dev: false
/deepmerge/4.2.2: /deepmerge/4.2.2:
@@ -80,7 +80,6 @@ export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRout
useEffect(() => { useEffect(() => {
function onUpdate() { function onUpdate() {
console.log('test', deckyRouterState.publicState());
setPublicDeckyRouterState({ ...deckyRouterState.publicState() }); setPublicDeckyRouterState({ ...deckyRouterState.publicState() });
} }
+21 -1
View File
@@ -1,20 +1,30 @@
import { FC, createContext, useContext, useEffect, useState } from 'react'; import { FC, createContext, useContext, useEffect, useState } from 'react';
import { Plugin } from '../plugin'; import { Plugin } from '../plugin';
import { PluginUpdateMapping } from '../store';
interface PublicDeckyState { interface PublicDeckyState {
plugins: Plugin[]; plugins: Plugin[];
activePlugin: Plugin | null; activePlugin: Plugin | null;
updates: PluginUpdateMapping | null;
hasLoaderUpdate?: boolean;
} }
export class DeckyState { export class DeckyState {
private _plugins: Plugin[] = []; private _plugins: Plugin[] = [];
private _activePlugin: Plugin | null = null; private _activePlugin: Plugin | null = null;
private _updates: PluginUpdateMapping | null = null;
private _hasLoaderUpdate: boolean = false;
public eventBus = new EventTarget(); public eventBus = new EventTarget();
publicState(): PublicDeckyState { publicState(): PublicDeckyState {
return { plugins: this._plugins, activePlugin: this._activePlugin }; return {
plugins: this._plugins,
activePlugin: this._activePlugin,
updates: this._updates,
hasLoaderUpdate: this._hasLoaderUpdate,
};
} }
setPlugins(plugins: Plugin[]) { setPlugins(plugins: Plugin[]) {
@@ -32,6 +42,16 @@ export class DeckyState {
this.notifyUpdate(); this.notifyUpdate();
} }
setUpdates(updates: PluginUpdateMapping) {
this._updates = updates;
this.notifyUpdate();
}
setHasLoaderUpdate(hasUpdate: boolean) {
this._hasLoaderUpdate = hasUpdate;
this.notifyUpdate();
}
private notifyUpdate() { private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update')); this.eventBus.dispatchEvent(new Event('update'));
} }
@@ -0,0 +1,25 @@
import { CSSProperties, FunctionComponent } from 'react';
interface NotificationBadgeProps {
show?: boolean;
style?: CSSProperties;
}
const NotificationBadge: FunctionComponent<NotificationBadgeProps> = ({ show, style }) => {
return show ? (
<div
style={{
position: 'absolute',
top: '8px',
right: '8px',
height: '10px',
width: '10px',
background: 'orange',
borderRadius: '50%',
...style,
}}
/>
) : null;
};
export default NotificationBadge;
+35 -18
View File
@@ -1,30 +1,47 @@
import { ButtonItem, PanelSection, PanelSectionRow } from 'decky-frontend-lib'; import {
ButtonItem,
PanelSection,
PanelSectionRow,
joinClassNames,
scrollClasses,
staticClasses,
} from 'decky-frontend-lib';
import { VFC } from 'react'; import { VFC } from 'react';
import { useDeckyState } from './DeckyState'; import { useDeckyState } from './DeckyState';
import NotificationBadge from './NotificationBadge';
const PluginView: VFC = () => { const PluginView: VFC = () => {
const { plugins, activePlugin, setActivePlugin } = useDeckyState(); const { plugins, updates, activePlugin, setActivePlugin } = useDeckyState();
if (activePlugin) { if (activePlugin) {
return <div style={{ height: '100%' }}>{activePlugin.content}</div>; return (
<div
className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}
style={{ height: '100%' }}
>
{activePlugin.content}
</div>
);
} }
return ( return (
<PanelSection> <div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
{plugins <PanelSection>
.filter((p) => p.content) {plugins
.map(({ name, icon }) => ( .filter((p) => p.content)
<PanelSectionRow key={name}> .map(({ name, icon }) => (
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}> <PanelSectionRow key={name}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
<div>{icon}</div> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>{name}</div> <div>{icon}</div>
</div> <div>{name}</div>
</ButtonItem> <NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
</PanelSectionRow> </div>
))} </ButtonItem>
</PanelSection> </PanelSectionRow>
))}
</PanelSection>
</div>
); );
}; };
-1
View File
@@ -7,7 +7,6 @@ import { useDeckyState } from './DeckyState';
const titleStyles: CSSProperties = { const titleStyles: CSSProperties = {
display: 'flex', display: 'flex',
paddingTop: '3px', paddingTop: '3px',
paddingBottom: '14px',
paddingRight: '16px', paddingRight: '16px',
boxShadow: 'unset', boxShadow: 'unset',
}; };
+54
View File
@@ -0,0 +1,54 @@
import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
interface ToastProps {
toast: {
data: ToastData;
nToastDurationMS: number;
};
}
const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ToastPlaceholder) {
return true;
}
return false;
});
const templateClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ShortTemplate) {
return true;
}
return false;
});
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return (
<div
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
className={joinClassNames(toastClasses.ToastPopup, toastClasses.toastEnter)}
>
<div
onClick={toast.data.onClick}
className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')}
>
{toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</div>}
<div className={joinClassNames(templateClasses.Content, toast.data.contentClassName || '')}>
<div className={templateClasses.Header}>
{toast.data.icon && <div className={templateClasses.Icon}>{toast.data.icon}</div>}
<div className={templateClasses.Title}>{toast.data.title}</div>
</div>
<div className={templateClasses.Body}>{toast.data.body}</div>
</div>
</div>
</div>
);
};
export default Toast;
@@ -20,9 +20,7 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha
onOK={async () => { onOK={async () => {
setLoading(true); setLoading(true);
await onOK(); await onOK();
Router.NavigateBackOrOpenMenu(); setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
await sleep(250);
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
}} }}
onCancel={async () => { onCancel={async () => {
await onCancel(); await onCancel();
@@ -0,0 +1,34 @@
import { Field, Toggle } from 'decky-frontend-lib';
import { useEffect, useState } from 'react';
import { FaBug } from 'react-icons/fa';
export default function RemoteDebuggingSettings() {
const [allowRemoteDebugging, setAllowRemoteDebugging] = useState<boolean>(false);
useEffect(() => {
(async () => {
const res = (await window.DeckyPluginLoader.callServerMethod('remote_debugging_allowed')) as { result: boolean };
setAllowRemoteDebugging(res.result);
})();
}, []);
return (
<Field
label="Allow Remote CEF Debugging"
description={
<span style={{ whiteSpace: 'pre-line' }}>
Allow unauthenticated access to the CEF debugger to anyone in your network
</span>
}
icon={<FaBug style={{ display: 'block' }} />}
>
<Toggle
value={allowRemoteDebugging}
onChange={(toggleValue) => {
setAllowRemoteDebugging(toggleValue);
if (toggleValue) window.DeckyPluginLoader.callServerMethod('allow_remote_debugging');
else window.DeckyPluginLoader.callServerMethod('disallow_remote_debugging');
}}
/>
</Field>
);
}
@@ -2,23 +2,7 @@ import { DialogButton, Field, ProgressBarWithInfo, Spinner } from 'decky-fronten
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa'; import { FaArrowDown } from 'react-icons/fa';
import { callUpdaterMethod, finishUpdate } from '../../../../updater'; import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
interface VerInfo {
current: string;
remote: {
assets: {
browser_download_url: string;
created_at: string;
}[];
name: string;
body: string;
prerelease: boolean;
published_at: string;
tag_name: string;
} | null;
updatable: boolean;
}
export default function UpdaterSettings() { export default function UpdaterSettings() {
const [versionInfo, setVersionInfo] = useState<VerInfo | null>(null); const [versionInfo, setVersionInfo] = useState<VerInfo | null>(null);
@@ -2,7 +2,8 @@ import { DialogButton, Field, TextField } from 'decky-frontend-lib';
import { useState } from 'react'; import { useState } from 'react';
import { FaShapes } from 'react-icons/fa'; import { FaShapes } from 'react-icons/fa';
import { installFromURL } from '../../../store/Store'; import { installFromURL } from '../../../../store';
import RemoteDebuggingSettings from './RemoteDebugging';
import UpdaterSettings from './Updater'; import UpdaterSettings from './Updater';
export default function GeneralSettings() { export default function GeneralSettings() {
@@ -20,6 +21,7 @@ export default function GeneralSettings() {
/> />
</Field> */} </Field> */}
<UpdaterSettings /> <UpdaterSettings />
<RemoteDebuggingSettings />
<Field <Field
label="Manual plugin install" label="Manual plugin install"
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />} description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
@@ -1,10 +1,16 @@
import { DialogButton, staticClasses } from 'decky-frontend-lib'; import { DialogButton, Focusable, Menu, MenuItem, showContextMenu } from 'decky-frontend-lib';
import { FaTrash } from 'react-icons/fa'; import { useEffect } from 'react';
import { FaDownload, FaEllipsisH } from 'react-icons/fa';
import { requestPluginInstall } from '../../../../store';
import { useDeckyState } from '../../../DeckyState'; import { useDeckyState } from '../../../DeckyState';
export default function PluginList() { export default function PluginList() {
const { plugins } = useDeckyState(); const { plugins, updates } = useDeckyState();
useEffect(() => {
window.DeckyPluginLoader.checkPluginUpdates();
}, []);
if (plugins.length === 0) { if (plugins.length === 0) {
return ( return (
@@ -16,19 +22,45 @@ export default function PluginList() {
return ( return (
<ul style={{ listStyleType: 'none' }}> <ul style={{ listStyleType: 'none' }}>
{plugins.map(({ name }) => ( {plugins.map(({ name, version }) => {
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> const update = updates?.get(name);
<span>{name}</span> return (
<div className={staticClasses.Title} style={{ marginLeft: 'auto', boxShadow: 'none' }}> <li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
<DialogButton <span>
style={{ height: '40px', width: '40px', padding: '10px 12px' }} {name} {version}
onClick={() => window.DeckyPluginLoader.uninstallPlugin(name)} </span>
> <Focusable style={{ marginLeft: 'auto', boxShadow: 'none', display: 'flex', justifyContent: 'right' }}>
<FaTrash /> {update && (
</DialogButton> <DialogButton
</div> style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
</li> onClick={() => requestPluginInstall(name, update)}
))} >
<div style={{ display: 'flex', flexDirection: 'row' }}>
Update to {update.name}
<FaDownload style={{ paddingLeft: '2rem' }} />
</div>
</DialogButton>
)}
<DialogButton
style={{ height: '40px', width: '40px', padding: '10px 12px', minWidth: '40px' }}
onClick={(e: MouseEvent) =>
showContextMenu(
<Menu label="Plugin Actions">
<MenuItem onSelected={() => window.DeckyPluginLoader.importPlugin(name, version)}>
Reload
</MenuItem>
<MenuItem onSelected={() => window.DeckyPluginLoader.uninstallPlugin(name)}>Uninstall</MenuItem>
</Menu>,
e.currentTarget ?? window,
)
}
>
<FaEllipsisH />
</DialogButton>
</Focusable>
</li>
);
})}
</ul> </ul>
); );
} }
+32 -20
View File
@@ -6,6 +6,7 @@ import {
Router, Router,
SingleDropdownOption, SingleDropdownOption,
SuspensefulImage, SuspensefulImage,
joinClassNames,
staticClasses, staticClasses,
} from 'decky-frontend-lib'; } from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react'; import { FC, useRef, useState } from 'react';
@@ -14,22 +15,15 @@ import {
LegacyStorePlugin, LegacyStorePlugin,
StorePlugin, StorePlugin,
StorePluginVersion, StorePluginVersion,
isLegacyPlugin,
requestLegacyPluginInstall, requestLegacyPluginInstall,
requestPluginInstall, requestPluginInstall,
} from './Store'; } from '../../store';
interface PluginCardProps { interface PluginCardProps {
plugin: StorePlugin | LegacyStorePlugin; plugin: StorePlugin | LegacyStorePlugin;
} }
const classNames = (...classes: string[]) => {
return classes.join(' ');
};
function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
return 'artifact' in plugin;
}
const PluginCard: FC<PluginCardProps> = ({ plugin }) => { const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
const [selectedOption, setSelectedOption] = useState<number>(0); const [selectedOption, setSelectedOption] = useState<number>(0);
const buttonRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLDivElement>(null);
@@ -44,7 +38,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
> >
{/* TODO: abstract this messy focus hackiness into a custom component in lib */} {/* TODO: abstract this messy focus hackiness into a custom component in lib */}
<Focusable <Focusable
// className="Panel Focusable" className="deckyStoreCard"
ref={containerRef} ref={containerRef}
onActivate={(_: CustomEvent) => { onActivate={(_: CustomEvent) => {
buttonRef.current!.focus(); buttonRef.current!.focus();
@@ -68,15 +62,17 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center' }}> <div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
<a <a
style={{ fontSize: '18pt', padding: '10px' }} style={{ fontSize: '18pt', padding: '10px' }}
className={classNames(staticClasses.Text)} className={joinClassNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)} // onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
> >
{isLegacyPlugin(plugin) ? ( {isLegacyPlugin(plugin) ? (
<div> <div className="deckyStoreCardNameContainer">
<span style={{ color: 'grey' }}>{plugin.artifact.split('/')[0]}/</span> <span className="deckyStoreCardLegacyRepoOwner" style={{ color: 'grey' }}>
{plugin.artifact.split('/')[0]}/
</span>
{plugin.artifact.split('/')[1]} {plugin.artifact.split('/')[1]}
</div> </div>
) : ( ) : (
@@ -89,8 +85,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
}} }}
className="deckyStoreCardBody"
> >
<SuspensefulImage <SuspensefulImage
className="deckyStoreCardImage"
suspenseWidth="256px" suspenseWidth="256px"
style={{ style={{
width: 'auto', width: 'auto',
@@ -113,17 +111,26 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}} }}
className="deckyStoreCardInfo"
> >
<p className={classNames(staticClasses.PanelSectionRow)}> <p className={joinClassNames(staticClasses.PanelSectionRow)}>
<span>Author: {plugin.author}</span> <span>Author: {plugin.author}</span>
</p> </p>
<p className={classNames(staticClasses.PanelSectionRow)}> <p
<span>Tags:</span> className={joinClassNames('deckyStoreCardTagsContainer', staticClasses.PanelSectionRow)}
style={{
padding: '0 16px',
display: 'flex',
flexWrap: 'wrap',
gap: '5px 10px',
}}
>
<span style={{ padding: '5px 0' }}>Tags:</span>
{plugin.tags.map((tag: string) => ( {plugin.tags.map((tag: string) => (
<span <span
className="deckyStoreCardTag"
style={{ style={{
padding: '5px', padding: '5px',
marginRight: '10px',
borderRadius: '5px', borderRadius: '5px',
background: tag == 'root' ? '#842029' : '#ACB2C947', background: tag == 'root' ? '#842029' : '#ACB2C947',
}} }}
@@ -133,10 +140,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
))} ))}
{isLegacyPlugin(plugin) && ( {isLegacyPlugin(plugin) && (
<span <span
className="deckyStoreCardTag deckyStoreCardLegacyTag"
style={{ style={{
color: '#232120', color: '#232120',
padding: '5px', padding: '5px',
marginRight: '10px',
borderRadius: '5px', borderRadius: '5px',
background: '#EDE841', background: '#EDE841',
}} }}
@@ -148,6 +155,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
</div> </div>
</div> </div>
<div <div
className="deckyStoreCardActionsContainer"
style={{ style={{
width: '100%', width: '100%',
alignSelf: 'flex-end', alignSelf: 'flex-end',
@@ -156,6 +164,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}} }}
> >
<Focusable <Focusable
className="deckyStoreCardActions"
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@@ -163,22 +172,25 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}} }}
> >
<div <div
className="deckyStoreCardInstallButtonContainer"
style={{ style={{
flex: '1', flex: '1',
}} }}
> >
<DialogButton <DialogButton
className="deckyStoreCardInstallButton"
ref={buttonRef} ref={buttonRef}
onClick={() => onClick={() =>
isLegacyPlugin(plugin) isLegacyPlugin(plugin)
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption]) ? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
: requestPluginInstall(plugin, plugin.versions[selectedOption]) : requestPluginInstall(plugin.name, plugin.versions[selectedOption])
} }
> >
Install Install
</DialogButton> </DialogButton>
</div> </div>
<div <div
className="deckyStoreCardVersionDropdownContainer"
style={{ style={{
flex: '0.2', flex: '0.2',
}} }}
+5 -79
View File
@@ -1,95 +1,21 @@
import { ModalRoot, SteamSpinner, showModal, staticClasses } from 'decky-frontend-lib'; import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store';
import PluginCard from './PluginCard'; import PluginCard from './PluginCard';
export interface StorePluginVersion {
name: string;
hash: string;
}
export interface StorePlugin {
id: number;
name: string;
versions: StorePluginVersion[];
author: string;
description: string;
tags: string[];
}
export interface LegacyStorePlugin {
artifact: string;
versions: {
[version: string]: string;
};
author: string;
description: string;
tags: string[];
}
export async function installFromURL(url: string) {
const formData = new FormData();
const splitURL = url.split('/');
formData.append('name', splitURL[splitURL.length - 1].replace('.zip', ''));
formData.append('artifact', url);
await fetch('http://localhost:1337/browser/install_plugin', {
method: 'POST',
body: formData,
});
}
export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
showModal(
<ModalRoot
onOK={() => {
const formData = new FormData();
formData.append('name', plugin.artifact);
formData.append('artifact', `https://github.com/${plugin.artifact}/archive/refs/tags/${selectedVer}.zip`);
formData.append('version', selectedVer);
formData.append('hash', plugin.versions[selectedVer]);
fetch('http://localhost:1337/browser/install_plugin', {
method: 'POST',
body: formData,
});
}}
onCancel={() => {
// do nothing
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column', boxShadow: 'unset' }}>
Using legacy plugins
</div>
You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues.
Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the
touchscreen.
</ModalRoot>,
);
}
export async function requestPluginInstall(plugin: StorePlugin, selectedVer: StorePluginVersion) {
const formData = new FormData();
formData.append('name', plugin.name);
formData.append('artifact', `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`);
formData.append('version', selectedVer.name);
formData.append('hash', selectedVer.hash);
await fetch('http://localhost:1337/browser/install_plugin', {
method: 'POST',
body: formData,
});
}
const StorePage: FC<{}> = () => { const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null); const [data, setData] = useState<StorePlugin[] | null>(null);
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null); const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const res = await fetch('https://beta.deckbrew.xyz/plugins', { method: 'GET' }).then((r) => r.json()); const res = await getPluginList();
console.log(res); console.log(res);
setData(res.filter((x: StorePlugin) => x.name !== 'Example Plugin')); setData(res);
})(); })();
(async () => { (async () => {
const res = await fetch('https://plugins.deckbrew.xyz/get_plugins', { method: 'GET' }).then((r) => r.json()); const res = await getLegacyPluginList();
console.log(res); console.log(res);
setLegacyData(res); setLegacyData(res);
})(); })();
+49 -14
View File
@@ -1,3 +1,6 @@
import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib';
import { forwardRef } from 'react';
import PluginLoader from './plugin-loader'; import PluginLoader from './plugin-loader';
import { DeckyUpdater } from './updater'; import { DeckyUpdater } from './updater';
@@ -8,24 +11,56 @@ declare global {
importDeckyPlugin: Function; importDeckyPlugin: Function;
syncDeckyPlugins: Function; syncDeckyPlugins: Function;
deckyHasLoaded: boolean; deckyHasLoaded: boolean;
deckyAuthToken: string;
webpackJsonp: any;
} }
} }
window.DeckyPluginLoader?.dismountAll(); // HACK to fix plugins using webpack v4 push
window.DeckyPluginLoader?.deinit();
window.DeckyPluginLoader = new PluginLoader(); const v4Cache = {};
window.importDeckyPlugin = function (name: string) { for (let m of Object.keys(webpackCache)) {
window.DeckyPluginLoader?.importPlugin(name); v4Cache[m] = { exports: webpackCache[m] };
}; }
window.syncDeckyPlugins = async function () { if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) {
const plugins = await (await fetch('http://127.0.0.1:1337/plugins')).json(); window.webpackJsonp = {
for (const plugin of plugins) { deckyShimmed: true,
if (!window.DeckyPluginLoader.hasPlugin(plugin)) window.DeckyPluginLoader?.importPlugin(plugin); push: (mod: any): any => {
} if (mod[1].get_require) return { c: v4Cache };
}; },
};
CommonUIModule.__deckyButtonItemShim = forwardRef((props: any, ref: any) => {
// tricks the old filter into working
const dummy = `childrenContainerWidth:"min"`;
return <ButtonItem ref={ref} _shim={dummy} {...props} />;
});
}
setTimeout(() => window.syncDeckyPlugins(), 5000); (async () => {
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
window.deckyHasLoaded = true; window.DeckyPluginLoader?.dismountAll();
window.DeckyPluginLoader?.deinit();
window.DeckyPluginLoader = new PluginLoader();
window.importDeckyPlugin = function (name: string, version: string) {
window.DeckyPluginLoader?.importPlugin(name, version);
};
window.syncDeckyPlugins = async function () {
const plugins = await (
await fetch('http://127.0.0.1:1337/plugins', {
credentials: 'include',
headers: { Authentication: window.deckyAuthToken },
})
).json();
for (const plugin of plugins) {
if (!window.DeckyPluginLoader.hasPlugin(plugin.name))
window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version);
}
window.DeckyPluginLoader.checkPluginUpdates();
};
setTimeout(() => window.syncDeckyPlugins(), 5000);
})();
+73 -10
View File
@@ -1,9 +1,10 @@
import { ModalRoot, QuickAccessTab, showModal, staticClasses } from 'decky-frontend-lib'; import { ModalRoot, QuickAccessTab, Router, showModal, sleep, staticClasses } from 'decky-frontend-lib';
import { FaPlug } from 'react-icons/fa'; import { FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider } from './components/DeckyState'; import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin'; import LegacyPlugin from './components/LegacyPlugin';
import PluginInstallModal from './components/modals/PluginInstallModal'; import PluginInstallModal from './components/modals/PluginInstallModal';
import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView'; import PluginView from './components/PluginView';
import SettingsPage from './components/settings'; import SettingsPage from './components/settings';
import StorePage from './components/store/Store'; import StorePage from './components/store/Store';
@@ -11,7 +12,10 @@ import TitleView from './components/TitleView';
import Logger from './logger'; import Logger from './logger';
import { Plugin } from './plugin'; import { Plugin } from './plugin';
import RouterHook from './router-hook'; import RouterHook from './router-hook';
import { checkForUpdates } from './store';
import TabsHook from './tabs-hook'; import TabsHook from './tabs-hook';
import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater';
declare global { declare global {
interface Window {} interface Window {}
@@ -22,16 +26,22 @@ class PluginLoader extends Logger {
private tabsHook: TabsHook = new TabsHook(); private tabsHook: TabsHook = new TabsHook();
// private windowHook: WindowHook = new WindowHook(); // private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook(); private routerHook: RouterHook = new RouterHook();
private toaster: Toaster = new Toaster();
private deckyState: DeckyState = new DeckyState(); private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false; private reloadLock: boolean = false;
// stores a list of plugin names which requested to be reloaded // stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: string[] = []; private pluginReloadQueue: { name: string; version?: string }[] = [];
constructor() { constructor() {
super(PluginLoader.name); super(PluginLoader.name);
this.log('Initialized'); this.log('Initialized');
const TabIcon = () => {
const { updates, hasLoaderUpdate } = useDeckyState();
return <NotificationBadge show={(updates && updates.size > 0) || hasLoaderUpdate} />;
};
this.tabsHook.add({ this.tabsHook.add({
id: QuickAccessTab.Decky, id: QuickAccessTab.Decky,
title: null, title: null,
@@ -41,7 +51,14 @@ class PluginLoader extends Logger {
<PluginView /> <PluginView />
</DeckyStateContextProvider> </DeckyStateContextProvider>
), ),
icon: <FaPlug />, icon: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<>
<FaPlug />
<TabIcon />
</>
</DeckyStateContextProvider>
),
}); });
this.routerHook.addRoute('/decky/store', () => <StorePage />); this.routerHook.addRoute('/decky/store', () => <StorePage />);
@@ -54,6 +71,37 @@ class PluginLoader extends Logger {
}); });
} }
public async notifyUpdates() {
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.toaster.toast({
title: 'Decky',
body: `Update to ${versionInfo?.remote?.tag_name} available!`,
onClick: () => Router.Navigate('/decky/settings'),
});
this.deckyState.setHasLoaderUpdate(true);
}
await sleep(7000);
await this.notifyPluginUpdates();
}
public async checkPluginUpdates() {
const updates = await checkForUpdates(this.plugins);
this.deckyState.setUpdates(updates);
return updates;
}
public async notifyPluginUpdates() {
const updates = await this.checkPluginUpdates();
if (updates?.size > 0) {
this.toaster.toast({
title: 'Decky',
body: `Updates available for ${updates.size} plugin${updates.size > 1 ? 's' : ''}!`,
onClick: () => Router.Navigate('/decky/settings/plugins'),
});
}
}
public addPluginInstallPrompt(artifact: string, version: string, request_id: string, hash: string) { public addPluginInstallPrompt(artifact: string, version: string, request_id: string, hash: string) {
showModal( showModal(
<PluginInstallModal <PluginInstallModal
@@ -75,6 +123,10 @@ class PluginLoader extends Logger {
await fetch('http://localhost:1337/browser/uninstall_plugin', { await fetch('http://localhost:1337/browser/uninstall_plugin', {
method: 'POST', method: 'POST',
body: formData, body: formData,
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
}); });
}} }}
onCancel={() => { onCancel={() => {
@@ -111,10 +163,10 @@ class PluginLoader extends Logger {
this.deckyState.setPlugins(this.plugins); this.deckyState.setPlugins(this.plugins);
} }
public async importPlugin(name: string) { public async importPlugin(name: string, version?: string | undefined) {
if (this.reloadLock) { if (this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name); this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push(name); this.pluginReloadQueue.push({ name, version: version });
return; return;
} }
@@ -127,7 +179,7 @@ class PluginLoader extends Logger {
if (name.startsWith('$LEGACY_')) { if (name.startsWith('$LEGACY_')) {
await this.importLegacyPlugin(name.replace('$LEGACY_', '')); await this.importLegacyPlugin(name.replace('$LEGACY_', ''));
} else { } else {
await this.importReactPlugin(name); await this.importReactPlugin(name, version);
} }
this.deckyState.setPlugins(this.plugins); this.deckyState.setPlugins(this.plugins);
@@ -138,18 +190,24 @@ class PluginLoader extends Logger {
this.reloadLock = false; this.reloadLock = false;
const nextPlugin = this.pluginReloadQueue.shift(); const nextPlugin = this.pluginReloadQueue.shift();
if (nextPlugin) { if (nextPlugin) {
this.importPlugin(nextPlugin); this.importPlugin(nextPlugin.name, nextPlugin.version);
} }
} }
} }
private async importReactPlugin(name: string) { private async importReactPlugin(name: string, version?: string) {
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`); let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
if (res.ok) { if (res.ok) {
let plugin = await eval(await res.text())(this.createPluginAPI(name)); let plugin = await eval(await res.text())(this.createPluginAPI(name));
this.plugins.push({ this.plugins.push({
...plugin, ...plugin,
name: name, name: name,
version: version,
}); });
} else throw new Error(`${name} frontend_bundle not OK`); } else throw new Error(`${name} frontend_bundle not OK`);
} }
@@ -166,8 +224,10 @@ class PluginLoader extends Logger {
async callServerMethod(methodName: string, args = {}) { async callServerMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, { const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
}, },
body: JSON.stringify(args), body: JSON.stringify(args),
}); });
@@ -178,12 +238,15 @@ class PluginLoader extends Logger {
createPluginAPI(pluginName: string) { createPluginAPI(pluginName: string) {
return { return {
routerHook: this.routerHook, routerHook: this.routerHook,
toaster: this.toaster,
callServerMethod: this.callServerMethod, callServerMethod: this.callServerMethod,
async callPluginMethod(methodName: string, args = {}) { async callPluginMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, { const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
args, args,
+1
View File
@@ -1,5 +1,6 @@
export interface Plugin { export interface Plugin {
name: string; name: string;
version?: string;
icon: JSX.Element; icon: JSX.Element;
content?: JSX.Element; content?: JSX.Element;
onDismount?(): void; onDismount?(): void;
+13 -6
View File
@@ -1,6 +1,6 @@
import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib'; import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib';
import { ReactElement, createElement, memo } from 'react'; import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import type { Route, RouteProps } from 'react-router'; import type { Route } from 'react-router';
import { import {
DeckyRouterState, DeckyRouterState,
@@ -40,7 +40,7 @@ class RouterHook extends Logger {
let Route: new () => Route; let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched. // Used to store the new replicated routes we create to allow routes to be unpatched.
let toReplace = new Map<string, Route>(); let toReplace = new Map<string, ReactNode>();
const DeckyWrapper = ({ children }: { children: ReactElement }) => { const DeckyWrapper = ({ children }: { children: ReactElement }) => {
const { routes, routePatches } = useDeckyRouterState(); const { routes, routePatches } = useDeckyRouterState();
@@ -62,17 +62,24 @@ class RouterHook extends Logger {
routeList.forEach((route: Route, index: number) => { routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string); const replaced = toReplace.get(route?.props?.path as string);
if (replaced) { if (replaced) {
routeList[index] = replaced; routeList[index].props.children = replaced;
toReplace.delete(route?.props?.path as string); toReplace.delete(route?.props?.path as string);
} }
if (route?.props?.path && routePatches.has(route.props.path as string)) { if (route?.props?.path && routePatches.has(route.props.path as string)) {
toReplace.set( toReplace.set(
route?.props?.path as string, route?.props?.path as string,
// @ts-ignore // @ts-ignore
createElement(routeList[index].type, routeList[index].props, routeList[index].props.children), routeList[index].props.children,
); );
routePatches.get(route.props.path as string)?.forEach((patch) => { routePatches.get(route.props.path as string)?.forEach((patch) => {
routeList[index].props = patch(routeList[index].props); const oType = routeList[index].props.children.type;
routeList[index].props.children = patch({
...routeList[index].props,
children: {
...cloneElement(routeList[index].props.children),
type: (props) => createElement(oType, props),
},
}).children;
}); });
} }
}); });
+121
View File
@@ -0,0 +1,121 @@
import { ModalRoot, showModal, staticClasses } from 'decky-frontend-lib';
import { Plugin } from './plugin';
export interface StorePluginVersion {
name: string;
hash: string;
}
export interface StorePlugin {
id: number;
name: string;
versions: StorePluginVersion[];
author: string;
description: string;
tags: string[];
}
export interface LegacyStorePlugin {
artifact: string;
versions: {
[version: string]: string;
};
author: string;
description: string;
tags: string[];
}
// name: version
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
export function getPluginList(): Promise<StorePlugin[]> {
return fetch('https://beta.deckbrew.xyz/plugins', {
method: 'GET',
}).then((r) => r.json());
}
export function getLegacyPluginList(): Promise<LegacyStorePlugin[]> {
return fetch('https://plugins.deckbrew.xyz/get_plugins', {
method: 'GET',
}).then((r) => r.json());
}
export async function installFromURL(url: string) {
const formData = new FormData();
const splitURL = url.split('/');
formData.append('name', splitURL[splitURL.length - 1].replace('.zip', ''));
formData.append('artifact', url);
await fetch('http://localhost:1337/browser/install_plugin', {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
}
export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
showModal(
<ModalRoot
onOK={() => {
const formData = new FormData();
formData.append('name', plugin.artifact);
formData.append('artifact', `https://github.com/${plugin.artifact}/archive/refs/tags/${selectedVer}.zip`);
formData.append('version', selectedVer);
formData.append('hash', plugin.versions[selectedVer]);
fetch('http://localhost:1337/browser/install_plugin', {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
}}
onCancel={() => {
// do nothing
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column', boxShadow: 'unset' }}>
Using legacy plugins
</div>
You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues.
Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the
touchscreen.
</ModalRoot>,
);
}
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
const formData = new FormData();
formData.append('name', plugin);
formData.append('artifact', `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`);
formData.append('version', selectedVer.name);
formData.append('hash', selectedVer.hash);
await fetch('http://localhost:1337/browser/install_plugin', {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
}
export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMapping> {
const serverData = await getPluginList();
const updateMap = new Map<string, StorePluginVersion>();
for (let plugin of plugins) {
const remotePlugin = serverData?.find((x) => x.name == plugin.name);
if (remotePlugin && remotePlugin.versions?.length > 0 && plugin.version != remotePlugin?.versions?.[0]?.name) {
updateMap.set(plugin.name, remotePlugin.versions[0]);
}
}
return updateMap;
}
export function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
return 'artifact' in plugin;
}
+1 -1
View File
@@ -103,7 +103,7 @@ class TabsHook extends Logger {
} }
deinit() { deinit() {
unpatch(this.cNode.stateNode, 'render'); if (this.cNode) unpatch(this.cNode.stateNode, 'render');
if (this.qAPTree) this.qAPTree.type = this.quickAccess; if (this.qAPTree) this.qAPTree.type = this.quickAccess;
if (this.rendererTree) this.rendererTree.type = this.tabRenderer; if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
if (this.cNode) this.cNode.stateNode.forceUpdate(); if (this.cNode) this.cNode.stateNode.forceUpdate();
+93
View File
@@ -0,0 +1,93 @@
import { ToastData, afterPatch, findInReactTree, findModuleChild, sleep, unpatch } from 'decky-frontend-lib';
import Toast from './components/Toast';
import Logger from './logger';
declare global {
interface Window {
__TOASTER_INSTANCE: any;
NotificationStore: any;
}
}
class Toaster extends Logger {
private instanceRet: any;
private node: any;
private settingsModule: any;
constructor() {
super('Toaster');
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
this.init();
}
async init() {
this.settingsModule = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop]?.settings?.bDisableToastsInGame !== 'undefined') return m[prop];
}
});
let instance: any;
while (true) {
instance = findInReactTree(
(document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
(x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
);
if (instance) break;
this.debug('finding instance');
await sleep(2000);
}
this.node = instance.return.return;
this.node.stateNode.render = (...args: any[]) => {
const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
if (ret) {
this.instanceRet = ret;
afterPatch(ret, 'type', (_: any, ret: any) => {
if (ret?.props?.children[1]?.children?.props?.notification?.decky) {
const toast = ret.props.children[1].children.props.notification;
ret.props.children[1].children.type = () => <Toast toast={toast} />;
}
return ret;
});
}
return ret;
};
this.node.stateNode.forceUpdate();
this.log('Initialized');
}
toast(toast: ToastData) {
const settings = this.settingsModule.settings;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
eType: 15,
nToastDurationMS: toast.duration || 5e3,
data: toast,
decky: true,
};
// @ts-ignore
toastData.data.appid = () => 0;
if (
(settings.bDisableAllToasts && !toast.critical) ||
(settings.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
)
return;
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
window.NotificationStore.m_rgNotificationToasts.pop();
}
deinit() {
this.instanceRet && unpatch(this.instanceRet, 'type');
this.node && delete this.node.stateNode.render;
this.node && this.node.stateNode.forceUpdate();
}
}
export default Toaster;
+18
View File
@@ -11,11 +11,29 @@ export interface DeckyUpdater {
finish: () => void; finish: () => void;
} }
export interface VerInfo {
current: string;
remote: {
assets: {
browser_download_url: string;
created_at: string;
}[];
name: string;
body: string;
prerelease: boolean;
published_at: string;
tag_name: string;
} | null;
updatable: boolean;
}
export async function callUpdaterMethod(methodName: string, args = {}) { export async function callUpdaterMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/updater/${methodName}`, { const response = await fetch(`http://127.0.0.1:1337/updater/${methodName}`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
}, },
body: JSON.stringify(args), body: JSON.stringify(args),
}); });