Compare commits

..

2 Commits

Author SHA1 Message Date
Jonas Dellinger 70821ee47b Add stop functionality 2022-06-16 19:09:06 +02:00
Jonas Dellinger 0a12fe6102 First draft of backend independent plugins 2022-06-16 18:33:43 +02:00
53 changed files with 4602 additions and 4098 deletions
+19 -178
View File
@@ -2,211 +2,52 @@ name: Builder
on:
push:
branches: [ "*" ]
pull_request:
# schedule:
# - cron: '0 13 * * *' # run at 1 PM UTC
workflow_dispatch:
inputs:
release:
type: choice
description: Release the asset
default: 'none'
options:
- none
- prerelease
- release
branches: [ "*" ]
permissions:
contents: write
contents: read
jobs:
build:
name: Build PluginLoader
name: Packager
runs-on: ubuntu-latest
steps:
- name: Checkout 🧰
- name: 🧰 Checkout
uses: actions/checkout@v3
- name: Set up NodeJS 17 💎
- name: 💎 Set up NodeJS 17
uses: actions/setup-node@v3
with:
node-version: 17
- name: Set up Python 3.10 🐍
- name: 🐍 Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install Python dependencies ⬇️
- name: ⬇️ Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
[ -f requirements.txt ] && pip install -r requirements.txt
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Install NodeJS dependencies ⬇️
- name: ⬇️ Install NodeJS dependencies
run: |
cd frontend
npm i
npm run build
- name: Build 🛠️
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
- name: Upload package artifact ⬆️
uses: actions/upload-artifact@v3
with:
name: PluginLoader
path: ./dist/PluginLoader
release:
name: Release the package
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }}
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: Release 📦
uses: softprops/action-gh-release@v1
with:
name: Release ${{ steps.tag_version.outputs.new_tag }}
tag_name: ${{ steps.tag_version.outputs.new_tag }}
files: ./dist/PluginLoader
generate_release_notes: true
prerelease:
name: Release the pre-release version of the package
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'prerelease' }}
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: Install semver-tool asdf
uses: asdf-vm/actions/install@v1
with:
tool_versions: |
semver 3.3.0
- 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
- name: 🛠️ Build
run: |
export VERSION=${{ steps.old_tag.outputs.tag }}
echo "VERS: $VERSION"
OUT=$(semver bump prerel "$VERSION")
echo "OUT: $OUT"
echo ::set-output name=tag_name::v$OUT
- name: Push tag 📤
uses: rickstaa/action-create-tag@v1.3.2
if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' }}
pyinstaller --noconfirm --onefile --name "Decky" --add-data ./backend/static:/static ./backend/*.py
- name: ⬆️ Upload package
uses: actions/upload-artifact@v2
with:
tag: ${{ steps.ready_tag.outputs.tag_name }}
message: Pre-release ${{ 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
# 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 ⏫
# id: bump
# if: ${{ github.event_name == 'schedule' }}
# run: |
# git_hash=$(git rev-parse --short "$GITHUB_SHA")
# echo ::set-output new_tag="nightly-$git_hash"
# - name: Push tag 📤
# uses: rickstaa/action-create-tag@v1.3.2
# if: ${{ github.event_name == 'schedule' }}
# with:
# tag: ${{ steps.bump.outputs.new_tag }}
# message: Nightly ${{ steps.bump.outputs.new_tag }}
# - name: Release 📦
# uses: softprops/action-gh-release@v1
# if: ${{ github.event_name == 'schedule' }}
# with:
# name: Nightly ${{ steps.bump.outputs.new_tag }}
# tag_name: ${{ steps.bump.outputs.new_tag }}
# files: ./dist/PluginLoader
# prerelease: true
# generate_release_notes: true
name: Plugin Loader
path: |
./dist/*
+2 -6
View File
@@ -154,9 +154,5 @@ cython_debug/
# static files are built
backend/static
# ignore settings.json
# prevents leaking login details
.vscode/settings.json
# plugins folder for local launches
plugins/*
# pnpm lockfile
frontend/pnpm-lock.yaml
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
# printf "${SCRIPT_DIR}\n"
# printf "$(dirname $0)\n"
if ! [[ -e "${SCRIPT_DIR}/settings.json" ]]; then
printf '.vscode/settings.json does not exist. Creating it with default settings. Exiting afterwards. Run your task again.\n\n'
cp "${SCRIPT_DIR}/defsettings.json" "${SCRIPT_DIR}/settings.json"
exit 1
else
printf '.vscode/settings.json does exist. Congrats.\n'
printf 'Make sure to change settings.json to match your deck.\n'
fi
-7
View File
@@ -1,7 +0,0 @@
{
"deckip" : "0.0.0.0",
"deckport" : "22",
"deckpass" : "ssap",
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
"deckdir" : "/home/deck"
}
+3 -12
View File
@@ -2,25 +2,16 @@
"version": "0.2.0",
"configurations": [
{
"name": "Run (Remote)",
"type": "python",
"request": "launch",
"console": "integratedTerminal",
"preLaunchTask": "remoterun",
"cwd": "",
"program": "",
},
{
"name": "Run (Local)",
"name": "Debug",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/backend/main.py",
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PLUGIN_PATH": "${workspaceFolder}/plugins"
"PLUGIN_PATH": "/home/deck/homebrew/plugins"
},
"preLaunchTask": "localrun"
"preLaunchTask": "Build frontend"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"[python]": {
"editor.detectIndentation": false,
"editor.tabSize": 4
}
}
+5 -139
View File
@@ -1,149 +1,15 @@
{
"version": "2.0.0",
"tasks": [
// OTHER
{
"label": "checkforsettings",
"label": "Stop Service",
"type": "shell",
"group": "none",
"detail": "Check that settings.json has been created",
"command": "bash -c ${workspaceFolder}/.vscode/config.sh",
"problemMatcher": []
"command":"systemctl --user stop plugin_loader",
},
{
"label": "localrun",
"label": "Build frontend",
"type": "shell",
"group": "none",
"dependsOn" : ["buildall"],
"detail": "Check for local runs, create a plugins folder",
"command": "mkdir -p plugins",
"problemMatcher": []
},
{
"label": "remoterun",
"type": "shell",
"group": "none",
"dependsOn": [
"updateremote",
"runpydeck"
],
"detail": "Task for remote run launches",
"command": "exit 0",
"problemMatcher": []
},
{
"label": "dependencies",
"type": "shell",
"group": "none",
"detail": "Check for local runs, create a plugins folder",
"command": "rsync -azp --rsh='ssh -p ${config:deckport} ${config:deckkey}' requirements.txt deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader/requirements.txt && ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'python -m ensurepip && python -m pip install --upgrade pip && python -m pip install --upgrade setuptools && python -m pip install -r ${config:deckdir}/homebrew/dev/pluginloader/requirements.txt'",
"problemMatcher": []
},
// BUILD
{
"label": "pnpmsetup",
"type": "shell",
"group": "build",
"detail": "Setup pnpm",
"command": "cd frontend && pnpm i",
"problemMatcher": []
},
{
"label": "buildfrontend",
"type": "npm",
"group": "build",
"detail": "rollup -c",
"script": "build",
"path": "frontend",
"problemMatcher": [],
},
{
"label": "buildall",
"group": "build",
"detail": "Deploy pluginloader to deck",
"dependsOrder": "sequence",
"dependsOn": [
"pnpmsetup",
"buildfrontend"
],
"problemMatcher": []
},
// DEPLOY
{
"label": "createfolders",
"detail": "Create plugins folder in expected directory",
"type": "shell",
"group": "none",
"dependsOn": [
"checkforsettings"
],
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'mkdir -p ${config:deckdir}/homebrew/dev/pluginloader && mkdir -p ${config:deckdir}/homebrew/dev/plugins'",
"problemMatcher": []
},
{
"label": "deploy",
"detail": "Deploy dev PluginLoader to deck",
"type": "shell",
"group": "none",
"command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader",
"problemMatcher": []
},
{
"label": "deployall",
"dependsOrder": "sequence",
"group": "none",
"dependsOn": [
"createfolders",
"dependencies",
"deploy"
],
"problemMatcher": []
},
// RUN
{
"label": "runpydeck",
"detail": "Run indev PluginLoader on Deck",
"type": "shell",
"group": "none",
"dependsOn" : ["checkforsettings"],
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'",
"problemMatcher": []
},
{
"label": "runpylocal",
"detail": "Run PluginLoader from python locally",
"type": "shell",
"group": "none",
"command": "export PLUGIN_PATH=${workspaceFolder}/plugins; export CHOWN_PLUGIN_PATH=0; sudo -E python3 ${workspaceFolder}/backend/main.py",
"problemMatcher": []
},
// ALL-IN-ONES
{
"label": "updateremote",
"detail": "Build and deploy",
"dependsOrder": "sequence",
"group": "none",
"dependsOn": [
"buildall",
"deployall",
],
"problemMatcher": []
},
{
"label": "allinone",
"detail": "Build, deploy and run",
"dependsOrder": "sequence",
"group": {
"kind": "build",
"isDefault": true
},
"dependsOn": [
"buildall",
"deployall",
"runpydeck"
],
"problemMatcher": []
}
"command":"cd ${workspaceFolder}/frontend; npm run build",
}
]
}
+31 -30
View File
@@ -1,3 +1,9 @@
# TODO
- Fix button size/display
- Add plugin installation prompts for browser
- Fix components not updating unless tab opened first (with new tab hook)
- Clean up code
# Plugin Loader [![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)
@@ -10,25 +16,22 @@ Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugi
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. 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 the latest pre-release:
- `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh`
- For testers/plugin developers:
- `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh`
- [Wiki Link](https://deckbrew.xyz/en/loader-dev/development)
- For the legacy version (unsupported):
- `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/legacy/dist/install_release.sh | sh`
6. Open a terminal and paste the following command into it:
- For users:
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_release.sh | sh`
- For plugin developers:
~~- `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!
### Install/Uninstall 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`
- Use the settings menu to uninstall plugins, this will not remove any files made in different directories by plugins.
### Uninstall
- Open a terminal and paste the following command into it:
- `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/uninstall.sh | sh`
- For both users and developers:
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/uninstall.sh | sh`
## Features
- Clean injecting and loading of one or more plugins
@@ -40,26 +43,24 @@ Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugi
## 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.
## [Contribution](https://deckbrew.xyz/en/loader-dev/development)
- Please consult the [Wiki](https://deckbrew.xyz/en/loader-dev/development) for installing development versions of PluginLoader.
- This is also useful for Plugin Developers looking to target new but unreleased versions of PluginLoader.
- [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 Arch WSL/cygwin this script is unsupported on Windows.)
### Getting Started
1. Clone the repository using the latest commit to main before starting your PR.
2. In your clone of the repository run these commands:
1. ``pnpm i``
2. ``pnpm run build``
3. If you are modifying the UI, these will need to be run before deploying the changes to your Deck.
4. Use the vscode tasks or ``deck.sh`` script to deploy your changes to your Deck to test them.
5. You will be testing your changes with the python script version, so you will need to build, deploy and reload each time.
Note: If you are recieveing build errors due to an out of date library, you should run this command inside of your repository:
## 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
pnpm update decky-frontend-lib --latest
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.
+48 -82
View File
@@ -1,126 +1,92 @@
# 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 injector import get_tab
from logging import getLogger
from os import path, rename, listdir
from os import path, rename
from shutil import rmtree
from subprocess import call
from time import time
from aiohttp import ClientSession, web
from io import BytesIO
from zipfile import ZipFile
# Local modules
from helpers import get_ssl_context, get_user, get_user_group
from injector import get_tab, inject_to_tab
logger = getLogger("Browser")
from concurrent.futures import ProcessPoolExecutor
from asyncio import get_event_loop
from time import time
from hashlib import sha256
from subprocess import Popen
class PluginInstallContext:
def __init__(self, artifact, name, version, hash) -> None:
self.artifact = artifact
self.name = name
def __init__(self, gh_url, version, hash) -> None:
self.gh_url = gh_url
self.version = version
self.hash = hash
class PluginBrowser:
def __init__(self, plugin_path, server_instance, plugins) -> None:
def __init__(self, plugin_path, server_instance, store_url) -> None:
self.log = getLogger("browser")
self.plugin_path = plugin_path
self.plugins = plugins
self.store_url = store_url
self.install_requests = {}
server_instance.add_routes([
web.post("/browser/install_plugin", self.install_plugin),
web.post("/browser/uninstall_plugin", self.uninstall_plugin)
web.get("/browser/redirect", self.redirect_to_store)
])
def _unzip_to_plugin_dir(self, zip, name, hash):
zip_hash = sha256(zip.getbuffer()).hexdigest()
if hash and (zip_hash != hash):
if zip_hash != hash:
return False
zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path)
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), 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
rename(path.join(self.plugin_path, zip_file.namelist()[0]), path.join(self.plugin_path, name))
Popen(["chown", "-R", "deck:deck", self.plugin_path])
Popen(["chmod", "-R", "555", self.plugin_path])
return True
def find_plugin_folder(self, name):
for folder in listdir(self.plugin_path):
try:
with open(path.join(self.plugin_path, folder, 'plugin.json'), 'r') as f:
plugin = json.load(f)
if plugin['name'] == name:
return path.join(self.plugin_path, folder)
except:
logger.debug(f"skipping {folder}")
async def uninstall_plugin(self, name):
tab = await get_tab("SP")
try:
if type(name) != str:
data = await name.post()
name = data.get("name", "undefined")
logger.info("uninstalling " + name)
logger.info(" at dir " + self.find_plugin_folder(name))
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
if self.plugins[name]:
self.plugins[name].stop()
self.plugins.pop(name, None)
rmtree(self.find_plugin_folder(name))
except FileNotFoundError:
logger.warning(f"Plugin {name} not installed, skipping uninstallation")
return web.Response(text="Requested plugin uninstall")
async def _install(self, artifact, name, version, hash):
try:
await self.uninstall_plugin(name)
except:
logger.error(f"Plugin {name} not installed, skipping uninstallation")
logger.info(f"Installing {name} (Version: {version})")
async def _install(self, artifact, version, hash):
name = artifact.split("/")[-1]
rmtree(path.join(self.plugin_path, name), ignore_errors=True)
self.log.info(f"Installing {artifact} (Version: {version})")
async with ClientSession() as client:
logger.debug(f"Fetching {artifact}")
res = await client.get(artifact, ssl=get_ssl_context())
url = f"https://github.com/{artifact}/archive/refs/tags/{version}.zip"
self.log.debug(f"Fetching {url}")
res = await client.get(url)
if res.status == 200:
logger.debug("Got 200. Reading...")
self.log.debug("Got 200. Reading...")
data = await res.read()
logger.debug(f"Read {len(data)} bytes")
self.log.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data)
with ProcessPoolExecutor() as executor:
logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
self.log.debug("Unzipping...")
ret = await get_event_loop().run_in_executor(
executor,
self._unzip_to_plugin_dir,
res_zip,
name,
hash
)
if ret:
logger.info(f"Installed {name} (Version: {version})")
await inject_to_tab("SP", "window.syncDeckyPlugins()")
self.log.info(f"Installed {artifact} (Version: {version})")
else:
logger.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
self.log.fatal(f"SHA-256 Mismatch!!!! {artifact} (Version: {version})")
else:
logger.fatal(f"Could not fetch from URL. {await res.text()}")
self.log.fatal(f"Could not fetch from github. {await res.text()}")
async def redirect_to_store(self, request):
return web.Response(status=302, headers={"Location": self.store_url})
async def install_plugin(self, request):
data = await request.post()
get_event_loop().create_task(self.request_plugin_install(data.get("artifact", ""), data.get("name", "No name"), data.get("version", "dev"), data.get("hash", False)))
get_event_loop().create_task(self.request_plugin_install(data["artifact"], data["version"], data["hash"]))
return web.Response(text="Requested plugin install")
async def request_plugin_install(self, artifact, name, version, hash):
async def request_plugin_install(self, artifact, version, hash):
request_id = str(time())
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash)
self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
tab = await get_tab("SP")
await tab.open_websocket()
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
async def confirm_plugin_install(self, request_id):
request = self.install_requests.pop(request_id)
await self._install(request.artifact, request.name, request.version, request.hash)
await self._install(request.gh_url, request.version, request.hash)
def cancel_plugin_install(self, request_id):
self.install_requests.pop(request_id)
self.install_requests.pop(request_id)
-65
View File
@@ -1,65 +0,0 @@
import certifi
import ssl
import uuid
import re
from aiohttp.web import middleware, Response
from subprocess import check_output
from time import sleep
# global vars
csrf_token = str(uuid.uuid4())
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
user = None
group = None
assets_regex = re.compile("^/plugins/.*/assets/.*")
def get_ssl_context():
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
+5 -8
View File
@@ -33,10 +33,8 @@ class Tab:
return (await self.websocket.receive_json()) if receive else None
raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
if manage_socket:
await self.open_websocket()
async def evaluate_js(self, js, run_async=False):
await self.open_websocket()
res = await self._send_devtools_cmd({
"id": 1,
"method": "Runtime.evaluate",
@@ -45,10 +43,9 @@ class Tab:
"userGesture": True,
"awaitPromise": run_async
}
}, get_result)
})
if manage_socket:
await self.client.close()
await self.client.close()
return res
async def get_steam_resource(self, url):
@@ -75,7 +72,7 @@ async def get_tabs():
r = await res.json()
return [Tab(i) for i in r]
else:
raise Exception(f"/json did not return 200. {await res.text()}")
raise Exception(f"/json did not return 200. {await r.text()}")
async def get_tab(tab_name):
tabs = await get_tabs()
-84
View File
@@ -1,84 +0,0 @@
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={}) {
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}`, {
method: 'POST',
credentials: "include",
headers: {
'Content-Type': 'application/json',
Authentication: token
},
body: JSON.stringify(arg_object),
});
const dta = await response.json();
if (!dta.success) throw dta.result;
return dta.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)");
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}`, {
method: 'POST',
credentials: "include",
headers: {
'Content-Type': 'application/json',
Authentication: token
},
body: JSON.stringify({
args: arg_object,
}),
});
const dta = await response.json();
if (!dta.success) throw dta.result;
return dta.result;
}
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
});
}
+16 -18
View File
@@ -1,4 +1,4 @@
from asyncio import Queue
from asyncio import Queue, get_event_loop, sleep, wait_for
from json.decoder import JSONDecodeError
from logging import getLogger
from os import listdir, path
@@ -16,7 +16,7 @@ except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer
from injector import get_tab, inject_to_tab
from plugin import PluginWrapper
from plugin_wrapper import PluginWrapper
class FileChangeHandler(RegexMatchingEventHandler):
@@ -29,7 +29,7 @@ class FileChangeHandler(RegexMatchingEventHandler):
def maybe_reload(self, src_path):
plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0]
if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")):
self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True))
self.queue.put_nowait(plugin_dir, True)
def on_created(self, event):
src_path = event.src_path
@@ -102,9 +102,9 @@ class Loader:
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, file, plugin_directory, refresh=False):
def import_plugin(self, plugin_directory, refresh=False):
try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
plugin = PluginWrapper(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,13 +112,12 @@ class Loader:
else:
self.plugins[plugin.name].stop()
self.plugins.pop(plugin.name, None)
if plugin.passive:
self.logger.info(f"Plugin {plugin.name} is passive")
self.plugins[plugin.name] = plugin.start()
self.plugins[plugin.name] = plugin
self.loop.create_task(plugin.start())
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))
except Exception as e:
self.logger.error(f"Could not load {file}. {e}")
self.logger.error(f"Could not load {plugin_directory}. {e}")
print_exc()
async def dispatch_plugin(self, name):
@@ -130,7 +129,7 @@ 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(path.join(self.plugin_path, directory, "main.py"), directory)
self.import_plugin(directory)
async def handle_reloads(self):
while True:
@@ -143,16 +142,15 @@ class Loader:
method_name = request.match_info["method_name"]
try:
method_info = await request.json()
args = method_info["args"]
method_args = method_info["args"]
except JSONDecodeError:
args = {}
method_args = {}
try:
if method_name.startswith("_"):
raise RuntimeError("Tried to call private method")
res["result"] = await plugin.execute_method(method_name, args)
res["success"] = True
res = await plugin.call_method(method_name, method_args)
except Exception as e:
res["result"] = str(e)
res["result"] = repr(e)
res["success"] = False
return web.json_response(res)
@@ -168,8 +166,8 @@ class Loader:
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="/legacy/library.js"></script>
<script>window.plugin_name = '{plugin.name}' </script>
<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}
"""
+26 -46
View File
@@ -1,87 +1,68 @@
# Full imports
import aiohttp_cors
# Partial imports
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
from os import getenv
# local modules
from browser import PluginBrowser
from helpers import csrf_middleware, get_csrf_token, get_user, get_user_group, set_user, set_user_group
from injector import inject_to_tab, tab_has_global_var
from loader import Loader
from updater import Updater
from utilities import Utilities
from aiohttp import ClientSession
# 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
CONFIG = {
"plugin_path": getenv("PLUGIN_PATH", HOME_PATH+"/homebrew/plugins"),
"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")]
"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(_):
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
code_chmod = call(["chmod", "-R", "555", CONFIG["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})")
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.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={
"https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
allow_headers="*", allow_credentials=True)
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, self.plugin_loader.plugins)
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, CONFIG["store_url"])
self.utilities = Utilities(self)
self.updater = Updater(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)
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
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'))])
self.web_app.add_routes([static("/legacy", path.join(path.dirname(__file__), 'legacy'))])
def exception_handler(self, loop, context):
if context["message"] == "Unclosed connection":
return
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 with ClientSession() as web:
while True:
@@ -97,10 +78,9 @@ class PluginManager:
#await inject_to_tab("SP", "window.syncDeckyPlugins();")
async def loader_reinjector(self):
await sleep(5)
while True:
await sleep(5)
if not await tab_has_global_var("SP", "deckyHasLoaded"):
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()
-120
View File
@@ -1,120 +0,0 @@
import multiprocessing
from asyncio import (Lock, get_event_loop, new_event_loop,
open_unix_connection, set_event_loop, sleep,
start_unix_server)
from concurrent.futures import ProcessPoolExecutor
from importlib.util import module_from_spec, spec_from_file_location
from json import dumps, load, loads
from os import path, setuid
from signal import SIGINT, signal
from sys import exit
from time import time
multiprocessing.set_start_method("fork")
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.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.passive = not path.isfile(self.file)
def __str__(self) -> str:
return self.name
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:
retries = 0
while retries < 10:
try:
self.reader, self.writer = await open_unix_connection(self.socket_addr)
return True
except:
await sleep(2)
retries += 1
return False
else:
return True
def start(self):
if self.passive:
return self
multiprocessing.Process(target=self._init).start()
return self
def stop(self):
if self.passive:
return
async def _(self):
if 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:
if 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"]
+59
View File
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,129 @@
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
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,37 @@
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
-122
View File
@@ -1,122 +0,0 @@
import uuid
from logging import getLogger
from json.decoder import JSONDecodeError
from asyncio import sleep
from aiohttp import ClientSession, web
from injector import inject_to_tab, get_tab
from os import getcwd, path, remove
from subprocess import call
import helpers
logger = getLogger("Updater")
class Updater:
def __init__(self, context) -> None:
self.context = context
self.updater_methods = {
"get_version": self.get_version,
"do_update": self.do_update,
"do_restart": self.do_restart,
"check_for_updates": self.check_for_updates
}
self.remoteVer = None
try:
with open(path.join(getcwd(), ".loader.version"), 'r') as version_file:
self.localVer = version_file.readline().replace("\n", "")
except:
self.localVer = False
if context:
context.web_app.add_routes([
web.post("/updater/{method_name}", self._handle_server_method_call)
])
context.loop.create_task(self.version_reloader())
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.updater_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 get_version(self):
if self.localVer:
return {
"current": self.localVer,
"remote": self.remoteVer,
"updatable": self.localVer != None
}
else:
return {"current": "unknown", "remote": self.remoteVer, "updatable": False}
async def check_for_updates(self):
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:
remoteVersions = await res.json()
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")
tab = await get_tab("SP")
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version()
async def version_reloader(self):
await sleep(30)
while True:
try:
await self.check_for_updates()
except:
pass
await sleep(60 * 60 * 6) # 6 hours
async def do_update(self):
version = self.remoteVer["tag_name"]
#TODO don't hardcode this
download_url = self.remoteVer["assets"][0]["browser_download_url"]
tab = await get_tab("SP")
await tab.open_websocket()
async with ClientSession() as web:
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
total = int(res.headers.get('content-length', 0))
try:
remove(path.join(getcwd(), "PluginLoader"))
except:
pass
with open(path.join(getcwd(), "PluginLoader"), "wb") as out:
progress = 0
raw = 0
async for c in res.content.iter_chunked(512):
out.write(c)
raw += len(c)
new_progress = round((raw / total) * 100)
if progress != new_progress:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w") as out:
out.write(version)
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
logger.info("Updated loader installation.")
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await tab.client.close()
async def do_restart(self):
call(["systemctl", "daemon-reload"])
call(["systemctl", "restart", "plugin_loader"])
+2 -2
View File
@@ -4,7 +4,7 @@ from json.decoder import JSONDecodeError
from aiohttp import ClientSession, web
from injector import inject_to_tab
import helpers
class Utilities:
def __init__(self, context) -> None:
@@ -48,7 +48,7 @@ class Utilities:
async def http_request(self, method="", url="", **kwargs):
async with ClientSession() as web:
async with web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs) as res:
async with web.request(method, url, **kwargs) as res:
return {
"status": res.status,
"headers": dict(res.headers),
+52 -105
View File
@@ -4,8 +4,6 @@
## 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.
## TODO: document latest changes to wiki
## Pre-parse arugments for ease of use
CLONEFOLDER=${1:-""}
INSTALLFOLDER=${2:-""}
@@ -13,13 +11,9 @@ DECKIP=${3:-""}
SSHPORT=${4:-""}
PASSWORD=${5:-""}
SSHKEYLOC=${6:-""}
LOADERBRANCH=${7:-""}
LIBRARYBRANCH=${8:-""}
TEMPLATEBRANCH=${9:-""}
LATEST=${10:-""}
## gather options into an array
OPTIONSARRAY=("$CLONEFOLDER" "$INSTALLFOLDER" "$DECKIP" "$SSHPORT" "$PASSWORD" "$SSHKEYLOC" "$LOADERBRANCH" "$LIBRARYBRANCH" "$TEMPLATEBRANCH" "$LATEST")
OPTIONSARRAY=("$CLONEFOLDER" $INSTALLFOLDER "$DECKIP" "$SSHPORT" "$PASSWORD" "$SSHKEYLOC")
## iterate through options array to check their presence
count=0
@@ -34,21 +28,19 @@ setfolder() {
local DEFAULT="git"
elif [[ "$2" == "install" ]]; then
local ACTION="install"
local DEFAULT="dev"
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
printf "Enter the directory in /home/user/ to ${ACTION} to.\n"
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
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
printf "Enter the directory in /home/deck/homebrew to ${ACTION} pluginloader to.\n"
printf "The ${ACTION} directory would be: /home/deck/homebrew/${DEFAULT}/pluginloader\n"
printf "It is highly recommended that you use the default folder path seen above, just press enter at the next prompt.\n"
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
@@ -114,81 +106,47 @@ clonefromto() {
# printf "repo=$1\n"
# printf "outdir=$2\n"
# printf "branch=$3\n"
printf "Repository: $1\n"
git clone $1 $2 &> '/dev/null'
if [[ -z $3 ]]; then
BRANCH=""
else
BRANCH="-b $3"
fi
git clone $1 $2 $BRANCH &> '/dev/null'
CODE=$?
# printf "CODE=${CODE}"
if [[ $CODE -eq 128 ]]; then
cd $2
git fetch --all &> '/dev/null'
fi
if [[ -z $3 ]]; then
printf "Enter the desired branch for repository "$1" :\n"
local OUT="$(git branch -r | sed '/\/HEAD/d')"
# $OUT="$($OUT > )"
printf "$OUT\nbranch: "
read BRANCH
else
printf "on branch: $3\n"
BRANCH="$3"
fi
if ! [[ -z ${BRANCH} ]]; then
git checkout $BRANCH &> '/dev/null'
fi
if [[ ${LATEST} == "true" ]]; then
git pull --all
elif [[ ${LATEST} == "true" ]]; then
printf "Assuming user not pulling latest commits.\n"
else
printf "Pull latest commits? (y/N): "
read PULL
case ${PULL:0:1} in
y|Y )
printf "Pulling latest commits.\n"
git pull --all
;;
* )
printf "Not pulling latest commits.\n"
;;
esac
if ! [[ "$PULL" =~ ^[[:alnum:]]+$ ]]; then
printf "Assuming user not pulling latest commits.\n"
fi
git fetch &> '/dev/null'
fi
}
pnpmtransbundle() {
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" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/dev/null'
elif [[ "$2" == "template" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/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
}
if ! [[ $count -gt 9 ]] ; then
printf "Installing Steam Deck Plugin Loader contributor/developer (for Steam Deck)...\n"
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 or develop for 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 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"
fi
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
printf "\n"
## User chooses preffered clone & install directories
if [[ "$CLONEFOLDER" == "" ]]; then
@@ -200,7 +158,7 @@ if [[ "$INSTALLFOLDER" == "" ]]; then
fi
CLONEDIR="$HOME/$CLONEFOLDER"
INSTALLDIR="/home/deck/homebrew/$INSTALLFOLDER"
INSTALLDIR="/home/deck/$INSTALLFOLDER"
## Input ip address, port, password and sshkey
@@ -250,7 +208,7 @@ fi
## Create folder structure
printf "Cloning git repositories.\n"
printf "\nCloning git repositories.\n"
mkdir -p ${CLONEDIR} &> '/dev/null'
@@ -259,72 +217,61 @@ mkdir -p ${CLONEDIR} &> '/dev/null'
# rm -r ${CLONEDIR}/pluginlibrary
# rm -r ${CLONEDIR}/plugintemplate
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate "$TEMPLATEBRANCH"
## install python dependencies to deck
printf "\nInstalling python dependencies.\n"
rsync -azp --rsh="ssh -p $SSHPORT $IDENINVOC" ${CLONEDIR}/pluginloader/requirements.txt deck@${DECKIP}:${INSTALLDIR}/pluginloader/requirements.txt &> '/dev/null'
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "python -m ensurepip && python -m pip install --upgrade pip && python -m pip install --upgrade setuptools && python -m pip install -r $INSTALLDIR/pluginloader/requirements.txt" &> '/dev/null'
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
## Transpile and bundle typescript
[ "$UID" -eq 0 ] || printf "Input password to proceed with install.\n"
type npm &> '/dev/null'
sudo npm install -g pnpm &> '/dev/null'
NPMLIVES=$?
type pnpm &> '/dev/null'
PNPMLIVES=$?
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
printf "pnpm does not appear to be installed, exiting.\n"
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"
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
pnpmtransbundle ${CLONEDIR}/plugintemplate "template"
npmtransbundle ${CLONEDIR}/plugintemplate "template"
## Transfer relevant files to deck
printf "Copying relevant files to install directory\n\n"
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "mkdir -p $INSTALLDIR/pluginloader && mkdir -p $INSTALLDIR/plugins" &> '/dev/null'
### copy files for PluginLoader
rsync -avzp --rsh="ssh -p $SSHPORT $IDENINVOC" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader &> '/dev/null'
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"
printf "Check that your Steam Deck is active, ssh is enabled and running and is accepting connections.\n"
printf "Error occurred when copying ${CLONEDIR}/pluginloader/ to ${INSTALLDIR}/pluginloader/\n"
exit 1
fi
### copy files for plugin template
rsync -avzp --rsh="ssh -p $SSHPORT $IDENINVOC" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='node_modules/' --exclude='src/' --exclude='*.log' --exclude='.gitignore' --exclude='pnpm-lock.yaml' --exclude='package.json' --exclude='rollup.config.js' --exclude='tsconfig.json' --delete ${CLONEDIR}/plugintemplate deck@${DECKIP}:${INSTALLDIR}/plugins &> '/dev/null'
### 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"
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='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' --delete $CLONEDIR/pluginloader/* deck@$DECKIP:$INSTALLDIR/pluginloader/'\n"
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p $SSHPORT $IDENINVOC\""" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='node_modules/' --exclude='src/' --exclude='*.log' --exclude='.gitignore' --exclude='package-lock.json' --delete $CLONEDIR/pluginname deck@$DECKIP:$INSTALLDIR/plugins'\n\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 $SSHPORT $IDENINVOC 'export PLUGIN_PATH=$INSTALLDIR/plugins; export CHOWN_PLUGIN_PATH=0; echo 'steam' | sudo -SE python3 $INSTALLDIR/pluginloader/backend/main.py'\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
@@ -332,4 +279,4 @@ printf "Run in console or in a script this command to run your development versi
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 $?" &> '/dev/null'
ssh deck@$DECKIP -p $SSHPORT $IDENINVOC "printf ${PASSWORD} | sudo -S systemctl disable --now plugin_loader; echo $?"
+57 -99
View File
@@ -2,115 +2,87 @@
## Pre-parse arugments for ease of use
CLONEFOLDER=${1:-""}
LOADERBRANCH=${2:-""}
LIBRARYBRANCH=${3:-""}
TEMPLATEBRANCH=${4:-""}
LATEST=${5:-""}
## gather options into an array
OPTIONSARRAY=("$CLONEFOLDER" "$LOADERBRANCH" "$LIBRARYBRANCH" "$TEMPLATEBRANCH" "$LATEST")
setfolder() {
if [[ "$2" == "clone" ]]; then
local ACTION="clone"
local DEFAULT="git"
elif [[ "$2" == "install" ]]; then
local ACTION="install"
local DEFAULT="loaderdev"
fi
## iterate through options array to check their presence
count=0
for OPTION in ${OPTIONSARRAY[@]}; do
! [[ "$OPTION" == "" ]] && count=$(($count+1))
# printf "OPTION=$OPTION\n"
done
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"
printf "Repository: $1\n"
git clone $1 $2 &> '/dev/null'
if [[ -z $3 ]]; then
BRANCH=""
else
BRANCH="-b $3"
fi
git clone $1 $2 $BRANCH &> '/dev/null'
CODE=$?
# printf "CODE=${CODE}"
if [[ $CODE -eq 128 ]]; then
cd $2
git fetch --all &> '/dev/null'
fi
if [[ -z $3 ]]; then
printf "Enter the desired branch for repository "$1" :\n"
local OUT="$(git branch -r | sed '/\/HEAD/d')"
# $OUT="$($OUT > )"
printf "$OUT\nbranch: "
read BRANCH
else
printf "on branch: $3\n"
BRANCH="$3"
fi
if ! [[ -z ${BRANCH} ]]; then
git checkout $BRANCH &> '/dev/null'
fi
if [[ ${LATEST} == "true" ]]; then
git pull --all
elif [[ ${LATEST} == "true" ]]; then
printf "Assuming user not pulling latest commits.\n"
else
printf "Pull latest commits? (y/N): "
read PULL
case ${PULL:0:1} in
y|Y )
printf "Pulling latest commits.\n"
git pull --all
;;
* )
printf "Not pulling latest commits.\n"
;;
esac
if ! [[ "$PULL" =~ ^[[:alnum:]]+$ ]]; then
printf "Assuming user not pulling latest commits.\n"
fi
git fetch &> '/dev/null'
fi
}
pnpmtransbundle() {
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" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/dev/null'
elif [[ "$2" == "template" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/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)..."
if ! [[ $count -gt 4 ]] ; then
printf "Installing Steam Deck Plugin Loader contributor/developer (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 ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
Not planning to contribute to or develop for 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"
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"
fi
if ! [[ $count -gt 0 ]] ; then
if [[ -z $1 ]]; then
read -p "Press any key to continue"
fi
printf "\n"
if [[ "$CLONEFOLDER" == "" ]]; then
printf "Enter the directory in /home/user/ to clone to.\n"
printf "The clone directory would be: ${HOME}/git \n"
read -p "Enter your clone directory: " CLONEFOLDER
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
CLONEFOLDER="${DEFAULT}"
fi
setfolder "$CLONEFOLDER" "clone"
fi
CLONEDIR="$HOME/$CLONEFOLDER"
## Create folder structure
printf "Cloning git repositories.\n"
printf "\nCloning git repositories.\n"
mkdir -p ${CLONEDIR} &> '/dev/null'
@@ -119,47 +91,33 @@ mkdir -p ${CLONEDIR} &> '/dev/null'
# rm -r ${CLONEDIR}/pluginlibrary
# rm -r ${CLONEDIR}/plugintemplate
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate "$TEMPLATEBRANCH"
## install python dependencies (maybe use venv?)
python -m pip install -r ${CLONEDIR}/pluginloader/requirements.txt &> '/dev/null'
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
## Transpile and bundle typescript
[ "$UID" -eq 0 ] || printf "Input password to proceed with install.\n"
type npm &> '/dev/null'
NPMLIVES=$?
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
printf "npm does not appear to be installed, exiting.\n"
if ! [[ "$NPMLIVES" -eq 0 ]]; then
printf "npm needs to be installed, exiting.\n"
exit 1
fi
sudo npm install -g pnpm &> '/dev/null'
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
type pnpm &> '/dev/null'
PNPMLIVES=$?
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
printf "pnpm does not appear to be installed, exiting.\n"
exit 1
fi
sudo npm install --quiet -g tsc &> '/dev/null'
printf "Transpiling and bundling typescript.\n"
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
pnpmtransbundle ${CLONEDIR}/plugintemplate "template"
npmtransbundle ${CLONEDIR}/plugintemplate "template"
printf "Plugin Loader is located at '${CLONEDIR}/pluginloader/'.\n"
+8 -8
View File
@@ -4,13 +4,12 @@
echo "Installing Steam Deck Plugin Loader nightly..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
HOMEBREW_FOLDER=/home/deck/homebrew
# Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins
# Download latest nightly build and install it
rm -rf /tmp/plugin_loader
@@ -23,7 +22,7 @@ chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
rm -f ${USER_DIR}/.config/systemd/user/plugin_loader.service
rm -f /home/deck/.config/systemd/user/plugin_loader.service
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
@@ -38,9 +37,10 @@ Type=simple
User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
WantedBy=multi-user.target
-46
View File
@@ -1,46 +0,0 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader pre-release..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# # Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
# Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f /etc/systemd/system/plugin_loader.service
cat > /etc/systemd/system/plugin_loader.service <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=DEBUG
[Install]
WantedBy=multi-user.target
EOM
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+11 -12
View File
@@ -4,34 +4,33 @@
echo "Installing Steam Deck Plugin Loader release..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
HOMEBREW_FOLDER=/home/deck/homebrew
# Create folder structure
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
rm -rf ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins
# Download latest release and install it
curl -L https://github.com/SteamDeckHomebrew/PluginLoader/releases/latest/download/PluginLoader --output "${HOMEBREW_FOLDER}/services/PluginLoader"
chmod +x "${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
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f "/etc/systemd/system/plugin_loader.service"
cat > "/etc/systemd/system/plugin_loader.service" <<- EOM
rm -f /etc/systemd/system/plugin_loader.service
cat > /etc/systemd/system/plugin_loader.service <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
WantedBy=multi-user.target
EOM
+5 -8
View File
@@ -1,20 +1,17 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Uninstalling Steam Deck Plugin Loader..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
HOMEBREW_FOLDER=/home/deck/homebrew
# Disable and remove services
sudo systemctl disable --now plugin_loader.service > /dev/null
sudo rm -f "${USER_DIR}/.config/systemd/user/plugin_loader.service"
sudo rm -f "/etc/systemd/system/plugin_loader.service"
sudo rm -f /home/deck/.config/systemd/user/plugin_loader.service
sudo rm -f /etc/systemd/system/plugin_loader.service
# Remove temporary folder if it exists from the install process
rm -rf "/tmp/plugin_loader"
rm -rf /tmp/plugin_loader
# Cleanup services folder
sudo rm "${HOMEBREW_FOLDER}/services/PluginLoader"
sudo rm ${HOMEBREW_FOLDER}/services/PluginLoader
+3881
View File
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -13,22 +13,21 @@
"devDependencies": {
"@rollup/plugin-commonjs": "^21.1.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-node-resolve": "^13.2.1",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.3",
"@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",
"inquirer": "^8.2.4",
"prettier": "^2.7.1",
"prettier": "^2.6.2",
"prettier-plugin-import-sort": "^0.0.7",
"react": "16.14.0",
"react-dom": "16.14.0",
"rollup": "^2.76.0",
"rollup": "^2.70.2",
"tslib": "^2.4.0",
"typescript": "^4.7.4"
"typescript": "^4.7.2"
},
"importSort": {
".js, .jsx, .ts, .tsx": {
@@ -37,7 +36,7 @@
}
},
"dependencies": {
"decky-frontend-lib": "^1.5.1",
"react-icons": "^4.4.0"
"decky-frontend-lib": "^0.0.6",
"react-icons": "^4.3.1"
}
}
-1744
View File
File diff suppressed because it is too large Load Diff
+5 -34
View File
@@ -6,21 +6,17 @@ export interface RouterEntry {
component: ComponentType;
}
export type RoutePatch = (route: RouteProps) => RouteProps;
interface PublicDeckyRouterState {
routes: Map<string, RouterEntry>;
routePatches: Map<string, Set<RoutePatch>>;
}
export class DeckyRouterState {
private _routes = new Map<string, RouterEntry>();
private _routePatches = new Map<string, Set<RoutePatch>>();
public eventBus = new EventTarget();
publicState(): PublicDeckyRouterState {
return { routes: this._routes, routePatches: this._routePatches };
return { routes: this._routes };
}
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
@@ -28,26 +24,6 @@ export class DeckyRouterState {
this.notifyUpdate();
}
addPatch(path: string, patch: RoutePatch) {
let patchList = this._routePatches.get(path);
if (!patchList) {
patchList = new Set();
this._routePatches.set(path, patchList);
}
patchList.add(patch);
this.notifyUpdate();
return patch;
}
removePatch(path: string, patch: RoutePatch) {
const patchList = this._routePatches.get(path);
patchList?.delete(patch);
if (patchList?.size == 0) {
this._routePatches.delete(path);
}
this.notifyUpdate();
}
removeRoute(path: string) {
this._routes.delete(path);
this.notifyUpdate();
@@ -60,8 +36,6 @@ export class DeckyRouterState {
interface DeckyRouterStateContext extends PublicDeckyRouterState {
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void;
addPatch(path: string, patch: RoutePatch): RoutePatch;
removePatch(path: string, patch: RoutePatch): void;
removeRoute(path: string): void;
}
@@ -88,15 +62,12 @@ export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRout
return () => deckyRouterState.eventBus.removeEventListener('update', onUpdate);
}, []);
const addRoute = deckyRouterState.addRoute.bind(deckyRouterState);
const addPatch = deckyRouterState.addPatch.bind(deckyRouterState);
const removePatch = deckyRouterState.removePatch.bind(deckyRouterState);
const removeRoute = deckyRouterState.removeRoute.bind(deckyRouterState);
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, addPatch, removePatch, removeRoute }}
>
<DeckyRouterStateContext.Provider value={{ ...publicDeckyRouterState, addRoute, removeRoute }}>
{children}
</DeckyRouterStateContext.Provider>
);
+33 -15
View File
@@ -1,29 +1,47 @@
import { ButtonItem, PanelSection, PanelSectionRow } from 'decky-frontend-lib';
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 } = useDeckyState();
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%' }}>{activePlugin.content}</div>;
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>
{plugins
.filter((p) => p.content)
.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>
))}
<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>
);
};
+6 -47
View File
@@ -1,59 +1,18 @@
import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib';
import { CSSProperties, VFC } from 'react';
import { FaArrowLeft, FaCog, FaStore } from 'react-icons/fa';
import { staticClasses } from 'decky-frontend-lib';
import { VFC } from 'react';
import { useDeckyState } from './DeckyState';
const titleStyles: CSSProperties = {
display: 'flex',
paddingTop: '3px',
paddingBottom: '14px',
paddingRight: '16px',
boxShadow: 'unset',
};
const TitleView: VFC = () => {
const { activePlugin, closeActivePlugin } = useDeckyState();
const onSettingsClick = () => {
Router.CloseSideMenus();
Router.Navigate('/decky/settings');
};
const onStoreClick = () => {
Router.CloseSideMenus();
Router.Navigate('/decky/store');
};
const { activePlugin } = useDeckyState();
if (activePlugin === null) {
return (
<Focusable style={titleStyles} className={staticClasses.Title}>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={onSettingsClick}
>
<FaCog style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<div style={{ marginRight: 'auto', flex: 0.9 }}>Decky</div>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={onStoreClick}
>
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
</Focusable>
);
return <div className={staticClasses.Title}>Decky</div>;
}
return (
<div className={staticClasses.Title} style={titleStyles}>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={closeActivePlugin}
>
<FaArrowLeft style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<div style={{ flex: 0.9 }}>{activePlugin.name}</div>
<div className={staticClasses.Title} style={{ paddingLeft: '60px' }}>
{activePlugin.name}
</div>
);
};
-54
View File
@@ -1,54 +0,0 @@
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;
@@ -1,43 +0,0 @@
import { ModalRoot, QuickAccessTab, Router, Spinner, sleep, staticClasses } from 'decky-frontend-lib';
import { FC, useState } from 'react';
interface PluginInstallModalProps {
artifact: string;
version: string;
hash: string;
// reinstall: boolean;
onOK(): void;
onCancel(): void;
closeModal?(): void;
}
const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, hash, onOK, onCancel, closeModal }) => {
const [loading, setLoading] = useState<boolean>(false);
return (
<ModalRoot
bOKDisabled={loading}
closeModal={closeModal}
onOK={async () => {
setLoading(true);
await onOK();
Router.NavigateBackOrOpenMenu();
await sleep(250);
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
}}
onCancel={async () => {
await onCancel();
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
<div style={{ flexDirection: 'row' }}>
{loading && <Spinner style={{ width: '20px' }} />} {loading ? 'Installing' : 'Install'} {artifact}
{version ? ' version ' + version : null}
{!loading && '?'}
</div>
</div>
</ModalRoot>
);
};
export default PluginInstallModal;
@@ -1,25 +0,0 @@
import { SidebarNavigation } from 'decky-frontend-lib';
import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list';
export default function SettingsPage() {
return (
<SidebarNavigation
title="Decky Settings"
showTitle
pages={[
{
title: 'General',
content: <GeneralSettings />,
route: '/decky/settings/general',
},
{
title: 'Plugins',
content: <PluginList />,
route: '/decky/settings/plugins',
},
]}
/>
);
}
@@ -1,81 +0,0 @@
import { DialogButton, Field, ProgressBarWithInfo, Spinner } from 'decky-frontend-lib';
import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
export default function UpdaterSettings() {
const [versionInfo, setVersionInfo] = useState<VerInfo | null>(null);
const [updateProgress, setUpdateProgress] = useState<number>(-1);
const [reloading, setReloading] = useState<boolean>(false);
const [checkingForUpdates, setCheckingForUpdates] = useState<boolean>(false);
useEffect(() => {
(async () => {
const res = (await callUpdaterMethod('get_version')) as { result: VerInfo };
setVersionInfo(res.result);
})();
}, []);
return (
<Field
label="Updates"
description={
versionInfo && (
<span style={{ whiteSpace: 'pre-line' }}>{`Current version: ${versionInfo.current}\n${
versionInfo.updatable ? `Latest version: ${versionInfo.remote?.tag_name}` : ''
}`}</span>
)
}
icon={
!versionInfo ? (
<Spinner style={{ width: '1em', height: 20, display: 'block' }} />
) : (
<FaArrowDown style={{ display: 'block' }} />
)
}
>
{updateProgress == -1 ? (
<DialogButton
disabled={!versionInfo?.updatable || checkingForUpdates}
onClick={
!versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
? async () => {
setCheckingForUpdates(true);
const res = (await callUpdaterMethod('check_for_updates')) as { result: VerInfo };
setVersionInfo(res.result);
setCheckingForUpdates(false);
}
: async () => {
window.DeckyUpdater = {
updateProgress: (i) => {
setUpdateProgress(i);
},
finish: async () => {
setUpdateProgress(0);
setReloading(true);
await finishUpdate();
},
};
setUpdateProgress(0);
callUpdaterMethod('do_update');
}
}
>
{checkingForUpdates
? 'Checking'
: !versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
? 'Check For Updates'
: 'Install Update'}
</DialogButton>
) : (
<ProgressBarWithInfo
layout="inline"
bottomSeparator={false}
nProgress={updateProgress}
indeterminate={reloading}
sOperationText={reloading ? 'Reloading' : 'Updating'}
/>
)}
</Field>
);
}
@@ -1,34 +0,0 @@
import { DialogButton, Field, TextField } from 'decky-frontend-lib';
import { useState } from 'react';
import { FaShapes } from 'react-icons/fa';
import { installFromURL } from '../../../store/Store';
import UpdaterSettings from './Updater';
export default function GeneralSettings() {
const [pluginURL, setPluginURL] = useState('');
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
return (
<div>
{/* <Field
label="A Toggle with an icon"
icon={<FaShapes style={{ display: 'block' }} />}
>
<Toggle
value={checked}
onChange={(e) => setChecked(e)}
/>
</Field> */}
<UpdaterSettings />
<Field
label="Manual plugin install"
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
icon={<FaShapes style={{ display: 'block' }} />}
>
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}>
Install
</DialogButton>
</Field>
</div>
);
}
@@ -1,34 +0,0 @@
import { DialogButton, staticClasses } from 'decky-frontend-lib';
import { FaTrash } from 'react-icons/fa';
import { useDeckyState } from '../../../DeckyState';
export default function PluginList() {
const { plugins } = useDeckyState();
if (plugins.length === 0) {
return (
<div>
<p>No plugins installed</p>
</div>
);
}
return (
<ul style={{ listStyleType: 'none' }}>
{plugins.map(({ name }) => (
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<span>{name}</span>
<div className={staticClasses.Title} style={{ marginLeft: 'auto', boxShadow: 'none' }}>
<DialogButton
style={{ height: '40px', width: '40px', padding: '10px 12px' }}
onClick={() => window.DeckyPluginLoader.uninstallPlugin(name)}
>
<FaTrash />
</DialogButton>
</div>
</li>
))}
</ul>
);
}
@@ -1,210 +0,0 @@
import {
DialogButton,
Dropdown,
Focusable,
QuickAccessTab,
Router,
SingleDropdownOption,
SuspensefulImage,
staticClasses,
} from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react';
import {
LegacyStorePlugin,
StorePlugin,
StorePluginVersion,
requestLegacyPluginInstall,
requestPluginInstall,
} from './Store';
interface PluginCardProps {
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 [selectedOption, setSelectedOption] = useState<number>(0);
const buttonRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
style={{
padding: '30px',
paddingTop: '10px',
paddingBottom: '10px',
}}
>
{/* TODO: abstract this messy focus hackiness into a custom component in lib */}
<Focusable
// className="Panel Focusable"
ref={containerRef}
onActivate={(_: CustomEvent) => {
buttonRef.current!.focus();
}}
onCancel={(_: CustomEvent) => {
if (containerRef.current!.querySelectorAll('* :focus').length === 0) {
Router.NavigateBackOrOpenMenu();
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
} else {
containerRef.current!.focus();
}
}}
style={{
display: 'flex',
flexDirection: 'column',
background: '#ACB2C924',
height: 'unset',
marginBottom: 'unset',
// boxShadow: var(--gpShadow-Medium);
scrollSnapAlign: 'start',
boxSizing: 'border-box',
}}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<a
style={{ fontSize: '18pt', padding: '10px' }}
className={classNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
>
{isLegacyPlugin(plugin) ? (
<div>
<span style={{ color: 'grey' }}>{plugin.artifact.split('/')[0]}/</span>
{plugin.artifact.split('/')[1]}
</div>
) : (
plugin.name
)}
</a>
</div>
<div
style={{
display: 'flex',
flexDirection: 'row',
}}
>
<SuspensefulImage
suspenseWidth="256px"
style={{
width: 'auto',
height: '160px',
}}
src={
isLegacyPlugin(plugin)
? `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.artifact.replace(
'/',
'_',
)}.png`
: `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.name.replace(
'/',
'_',
)}.png`
}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<p className={classNames(staticClasses.PanelSectionRow)}>
<span>Author: {plugin.author}</span>
</p>
<p className={classNames(staticClasses.PanelSectionRow)}>
<span>Tags:</span>
{plugin.tags.map((tag: string) => (
<span
style={{
padding: '5px',
marginRight: '10px',
borderRadius: '5px',
background: tag == 'root' ? '#842029' : '#ACB2C947',
}}
>
{tag == 'root' ? 'Requires root' : tag}
</span>
))}
{isLegacyPlugin(plugin) && (
<span
style={{
color: '#232120',
padding: '5px',
marginRight: '10px',
borderRadius: '5px',
background: '#EDE841',
}}
>
legacy
</span>
)}
</p>
</div>
</div>
<div
style={{
width: '100%',
alignSelf: 'flex-end',
display: 'flex',
flexDirection: 'row',
}}
>
<Focusable
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<div
style={{
flex: '1',
}}
>
<DialogButton
ref={buttonRef}
onClick={() =>
isLegacyPlugin(plugin)
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
: requestPluginInstall(plugin, plugin.versions[selectedOption])
}
>
Install
</DialogButton>
</div>
<div
style={{
flex: '0.2',
}}
>
<Dropdown
rgOptions={
(isLegacyPlugin(plugin)
? Object.keys(plugin.versions).map((v, k) => ({
data: k,
label: v,
}))
: plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
}))) as SingleDropdownOption[]
}
strDefaultLabel={'Select a version'}
selectedOption={selectedOption}
onChange={({ data }) => setSelectedOption(data)}
/>
</div>
</Focusable>
</div>
</Focusable>
</div>
);
};
export default PluginCard;
-151
View File
@@ -1,151 +0,0 @@
import { ModalRoot, SteamSpinner, showModal, staticClasses } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
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,
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: 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,
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
}
const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null);
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
useEffect(() => {
(async () => {
const res = await fetch('https://beta.deckbrew.xyz/plugins', {
method: 'GET',
}).then((r) => r.json());
console.log(res);
setData(res.filter((x: StorePlugin) => x.name !== 'Example Plugin'));
})();
(async () => {
const res = await fetch('https://plugins.deckbrew.xyz/get_plugins', {
method: 'GET',
}).then((r) => r.json());
console.log(res);
setLegacyData(res);
})();
}, []);
return (
<div
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
overflowY: 'scroll',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'column',
height: '100%',
}}
>
{!data ? (
<div style={{ height: '100%' }}>
<SteamSpinner />
</div>
) : (
<div>
{data.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} />
))}
{!legacyData ? (
<SteamSpinner />
) : (
legacyData.map((plugin: LegacyStorePlugin) => <PluginCard plugin={plugin} />)
)}
</div>
)}
</div>
</div>
);
};
export default StorePage;
+12 -28
View File
@@ -1,41 +1,25 @@
import { sleep } from 'decky-frontend-lib';
import PluginLoader from './plugin-loader';
import { DeckyUpdater } from './updater';
declare global {
interface Window {
DeckyPluginLoader: PluginLoader;
DeckyUpdater?: DeckyUpdater;
importDeckyPlugin: Function;
syncDeckyPlugins: Function;
deckyHasLoaded: boolean;
deckyAuthToken: string;
}
}
(async () => {
window.deckyHasLoaded = true;
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
window.DeckyPluginLoader?.dismountAll();
window.DeckyPluginLoader?.deinit();
window.DeckyPluginLoader?.dismountAll();
window.DeckyPluginLoader = new PluginLoader();
window.importDeckyPlugin = function (name: string) {
window.DeckyPluginLoader?.importPlugin(name);
};
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', {
credentials: 'include',
headers: { Authentication: window.deckyAuthToken },
})
).json();
for (const plugin of plugins) {
if (!window.DeckyPluginLoader.hasPlugin(plugin)) window.DeckyPluginLoader?.importPlugin(plugin);
}
};
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);
})();
setTimeout(() => window.syncDeckyPlugins(), 5000);
+1 -11
View File
@@ -8,16 +8,6 @@ export const log = (name: string, ...args: any[]) => {
);
};
export const debug = (name: string, ...args: any[]) => {
console.debug(
`%c Decky %c ${name} %c`,
'background: #16a085; color: black;',
'background: #1abc9c; color: black;',
'color: blue;',
...args,
);
};
export const error = (name: string, ...args: any[]) => {
console.log(
`%c Decky %c ${name} %c`,
@@ -38,7 +28,7 @@ class Logger {
}
debug(...args: any[]) {
debug(this.name, ...args);
log(this.name, ...args);
}
}
+29 -97
View File
@@ -1,19 +1,14 @@
import { ModalRoot, QuickAccessTab, showModal, staticClasses } from 'decky-frontend-lib';
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 PluginInstallModal from './components/modals/PluginInstallModal';
import PluginView from './components/PluginView';
import SettingsPage from './components/settings';
import StorePage from './components/store/Store';
import TitleView from './components/TitleView';
import Logger from './logger';
import { Plugin } from './plugin';
import RouterHook from './router-hook';
import TabsHook from './tabs-hook';
import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater';
declare global {
interface Window {}
@@ -24,7 +19,6 @@ class PluginLoader extends Logger {
private tabsHook: TabsHook = new TabsHook();
// private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook();
private toaster: Toaster = new Toaster();
private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false;
@@ -36,79 +30,40 @@ class PluginLoader extends Logger {
this.log('Initialized');
this.tabsHook.add({
id: QuickAccessTab.Decky,
title: null,
content: (
id: 'main',
title: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<TitleView />
</DeckyStateContextProvider>
),
content: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<PluginView />
</DeckyStateContextProvider>
),
icon: <FaPlug />,
});
this.routerHook.addRoute('/decky/store', () => <StorePage />);
this.routerHook.addRoute('/decky/settings', () => {
return (
<DeckyStateContextProvider deckyState={this.deckyState}>
<SettingsPage />
</DeckyStateContextProvider>
);
});
}
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} availiable!`,
});
}
}
public addPluginInstallPrompt(artifact: string, version: string, request_id: string, hash: string) {
showModal(
<PluginInstallModal
artifact={artifact}
version={version}
hash={hash}
onOK={() => this.callServerMethod('confirm_plugin_install', { request_id })}
onCancel={() => this.callServerMethod('cancel_plugin_install', { request_id })}
/>,
);
}
public uninstallPlugin(name: string) {
public addPluginInstallPrompt(artifact: string, version: string, request_id: string) {
showModal(
<ModalRoot
onOK={async () => {
const formData = new FormData();
formData.append('name', name);
await fetch('http://localhost:1337/browser/uninstall_plugin', {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
onOK={() => {
console.log('ok');
this.callServerMethod('confirm_plugin_install', { request_id });
}}
onCancel={() => {
// do nothing
console.log('nope');
this.callServerMethod('cancel_plugin_install', { request_id });
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
Uninstall {name}?
<div className={staticClasses.Title}>
Install {artifact} version {version}?
</div>
</ModalRoot>,
);
}
public hasPlugin(name: string) {
return Boolean(this.plugins.find((plugin) => plugin.name == name));
}
public dismountAll() {
for (const plugin of this.plugins) {
this.log(`Dismounting ${plugin.name}`);
@@ -116,39 +71,25 @@ class PluginLoader extends Logger {
}
}
public deinit() {
this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings');
}
public unloadPlugin(name: string) {
const plugin = this.plugins.find((plugin) => plugin.name === name || plugin.name === name.replace('$LEGACY_', ''));
plugin?.onDismount?.();
this.plugins = this.plugins.filter((p) => p !== plugin);
this.deckyState.setPlugins(this.plugins);
}
public async importPlugin(name: string) {
if (this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push(name);
return;
}
try {
this.reloadLock = true;
if (this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push(name);
return;
}
this.log(`Trying to load ${name}`);
this.unloadPlugin(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);
this.log(`Loaded ${name}`);
} catch (e) {
throw e;
} finally {
@@ -161,17 +102,13 @@ class PluginLoader extends Logger {
}
private async importReactPlugin(name: string) {
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
if (res.ok) {
let plugin = await eval(await res.text())(this.createPluginAPI(name));
let content = await eval(await res.text())(this.createPluginAPI(name));
this.plugins.push({
...plugin,
name: name,
icon: content.icon,
content: content.content,
});
} else throw new Error(`${name} frontend_bundle not OK`);
}
@@ -188,10 +125,8 @@ class PluginLoader extends Logger {
async callServerMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
body: JSON.stringify(args),
});
@@ -202,15 +137,12 @@ class PluginLoader extends Logger {
createPluginAPI(pluginName: string) {
return {
routerHook: this.routerHook,
toaster: this.toaster,
callServerMethod: this.callServerMethod,
async callPluginMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
body: JSON.stringify({
args,
@@ -220,7 +152,7 @@ class PluginLoader extends Logger {
return response.json();
},
fetchNoCors(url: string, request: any = {}) {
let args = { method: 'POST', headers: {} };
let args = { method: 'POST', headers: {}, body: '' };
const req = { ...args, ...request, url, data: request.body };
return this.callServerMethod('http_request', req);
},
+3 -3
View File
@@ -1,6 +1,6 @@
export interface Plugin {
name: string;
icon: JSX.Element;
content?: JSX.Element;
name: any;
content: any;
icon: any;
onDismount?(): void;
}
+8 -48
View File
@@ -1,11 +1,10 @@
import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib';
import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import { ReactElement, createElement, memo } from 'react';
import type { Route } from 'react-router';
import {
DeckyRouterState,
DeckyRouterStateContextProvider,
RoutePatch,
RouterEntry,
useDeckyRouterState,
} from './components/DeckyRouterState';
@@ -39,16 +38,14 @@ class RouterHook extends Logger {
});
let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched.
let toReplace = new Map<string, ReactNode>();
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
const { routes, routePatches } = useDeckyRouterState();
const { routes } = useDeckyRouterState();
const routeList = children.props.children[0].props.children;
let routerIndex = routeList.length;
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
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(
@@ -57,33 +54,8 @@ class RouterHook extends Logger {
</Route>,
);
});
routeList[routerIndex] = newRouterArray;
children.props.children[0].props.children[routerIndex] = newRouterArray;
}
routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string);
if (replaced) {
routeList[index].props.children = replaced;
toReplace.delete(route?.props?.path as string);
}
if (route?.props?.path && routePatches.has(route.props.path as string)) {
toReplace.set(
route?.props?.path as string,
// @ts-ignore
routeList[index].props.children,
);
routePatches.get(route.props.path as string)?.forEach((patch) => {
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;
});
}
});
this.debug('Rerendered routes list');
return children;
};
@@ -120,18 +92,6 @@ class RouterHook extends Logger {
this.routerState.addRoute(path, component, props);
}
addPatch(path: string, patch: RoutePatch) {
return this.routerState.addPatch(path, patch);
}
removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch);
}
removeRoute(path: string) {
this.routerState.removeRoute(path);
}
deinit() {
unpatch(this.gamepadWrapper, 'render');
this.router && unpatch(this.router, 'type');
+14 -79
View File
@@ -1,6 +1,3 @@
import { QuickAccessTab, afterPatch, sleep, unpatch } from 'decky-frontend-lib';
import { memo } from 'react';
import Logger from './logger';
declare global {
@@ -14,11 +11,11 @@ declare global {
const isTabsArray = (tabs: any) => {
const length = tabs.length;
return length >= 7 && tabs[length - 1]?.tab;
return length === 7 && tabs[length - 1]?.key === 6 && tabs[length - 1]?.tab;
};
interface Tab {
id: QuickAccessTab | number;
id: string;
title: any;
content: any;
icon: any;
@@ -27,95 +24,33 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
private quickAccess: any;
private tabRenderer: any;
private memoizedQuickAccess: any;
private cNode: any;
private qAPTree: any;
private rendererTree: any;
constructor() {
super('TabsHook');
this.log('Initialized');
window.__TABS_HOOK_INSTANCE?.deinit?.();
window.__TABS_HOOK_INSTANCE = this;
const self = this;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let scrollRoot: any;
let currentNode = tree;
(async () => {
let iters = 0;
while (!scrollRoot) {
iters++;
currentNode = currentNode?.child;
if (iters >= 30 || !currentNode) {
iters = 0;
currentNode = tree;
await sleep(5000);
}
if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) scrollRoot = currentNode;
}
let newQA: any;
let newQATabRenderer: any;
afterPatch(scrollRoot.stateNode, 'render', (_: any, ret: any) => {
if (!this.quickAccess && ret.props.children.props.children[4]) {
this.quickAccess = ret?.props?.children?.props?.children[4].type;
newQA = (...args: any) => {
const ret = this.quickAccess.type(...args);
if (ret) {
if (!newQATabRenderer) {
this.tabRenderer = ret.props.children[1].children.type;
newQATabRenderer = (...args: any) => {
const oFilter = Array.prototype.filter;
Array.prototype.filter = function (...args: any[]) {
if (isTabsArray(this)) {
self.render(this);
}
// @ts-ignore
return oFilter.call(this, ...args);
};
// TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
const ret = this.tabRenderer(...args);
Array.prototype.filter = oFilter;
return ret;
};
}
this.rendererTree = ret.props.children[1].children;
ret.props.children[1].children.type = newQATabRenderer;
}
return ret;
};
this.memoizedQuickAccess = memo(newQA);
this.memoizedQuickAccess.isDeckyQuickAccess = true;
}
if (ret.props.children.props.children[4]) {
this.qAPTree = ret.props.children.props.children[4];
ret.props.children.props.children[4].type = this.memoizedQuickAccess;
}
return ret;
});
this.cNode = scrollRoot;
this.cNode.stateNode.forceUpdate();
})();
}
deinit() {
if (this.cNode) unpatch(this.cNode.stateNode, 'render');
if (this.qAPTree) this.qAPTree.type = this.quickAccess;
if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
if (this.cNode) this.cNode.stateNode.forceUpdate();
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.debug('Adding tab', tab.id, 'to render array');
this.log('Adding tab', tab.id, 'to render array');
this.tabs.push(tab);
}
removeById(id: number) {
this.debug('Removing tab', id);
removeById(id: string) {
this.log('Removing tab', id);
this.tabs = this.tabs.filter((tab) => tab.id !== id);
}
-93
View File
@@ -1,93 +0,0 @@
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;
-48
View File
@@ -1,48 +0,0 @@
import { sleep } from 'decky-frontend-lib';
export enum Branches {
Release,
Prerelease,
Nightly,
}
export interface DeckyUpdater {
updateProgress: (val: number) => 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 = {}) {
const response = await fetch(`http://127.0.0.1:1337/updater/${methodName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
body: JSON.stringify(args),
});
return response.json();
}
export async function finishUpdate() {
callUpdaterMethod('do_restart');
await sleep(3000);
location.reload();
}
-1
View File
@@ -5,7 +5,6 @@
"target": "ES2020",
"jsx": "react",
"jsxFactory": "window.SP_REACT.createElement",
"jsxFragmentFactory": "window.SP_REACT.Fragment",
"declaration": false,
"moduleResolution": "node",
"noUnusedLocals": true,
-1
View File
@@ -2,4 +2,3 @@ aiohttp==3.8.1
aiohttp-jinja2==1.5.0
aiohttp_cors==0.7.0
watchdog==2.1.7
certifi==2022.6.15