mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 20:25:04 +03:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbb4bc5ab4 | |||
| b00b04ceeb | |||
| 470f16adda | |||
| 76424174ed | |||
| b618fe1e97 | |||
| 45949e8456 | |||
| e3a965329d | |||
| 6ee41578ea | |||
| 9404215399 | |||
| b8bf150a74 | |||
| add3f77c1a | |||
| 6c42661f86 | |||
| 2b3c219e38 | |||
| 8eb89da373 | |||
| ace9f61e50 | |||
| baa02c129f | |||
| 1e6b3edbf2 | |||
| 085aacea06 | |||
| 675e667a9e | |||
| 58b2c4208d | |||
| c2693869a7 | |||
| 683c51ceac | |||
| 630e8b7213 | |||
| 246b31794a | |||
| b7d57de378 | |||
| ee8aa98446 | |||
| 557a00aed7 | |||
| 4daf028e7a | |||
| 934a50f683 | |||
| aa4f1b1e87 | |||
| 67495d30d6 | |||
| d72f364a8d | |||
| da0f7dd337 | |||
| 518b01f571 | |||
| 3f2a2bbc04 | |||
| 79e8af8be6 | |||
| 18d444e8fc | |||
| abc5ce5382 | |||
| 9619c52720 | |||
| 80b223180e | |||
| 1d5d14b492 | |||
| ce23534ccc | |||
| e6e74d8e9d | |||
| 6289578f68 | |||
| e7c44ee202 | |||
| 39f6a7688d | |||
| 47ca3ece4a | |||
| 3e250dd180 | |||
| 711af3bca3 | |||
| 9a6930571c | |||
| d9dd09c69b | |||
| daca482ed8 | |||
| 99b4b939bd | |||
| a95bf94d87 | |||
| 12f4c7faff | |||
| bbf49470fc | |||
| a1a4d5902b | |||
| 90a65dbace | |||
| f828480715 | |||
| ed1a9222b4 | |||
| 73b36b776a | |||
| 4a2299f3ff | |||
| 6128cbec6b | |||
| c93af19ffa | |||
| cadb687cd7 | |||
| 1114d55931 | |||
| 0f20fe691f | |||
| 86e23686aa | |||
| bd1b2e82fd | |||
| 660e34664e | |||
| 8fcaadd8f3 | |||
| 007860f8f7 | |||
| 44776b393e | |||
| ad1f57795e | |||
| 71dd0ea449 | |||
| a06efc08bc | |||
| 39e56fed3d | |||
| 4b923c1dc7 | |||
| d23f1ac56c | |||
| 74438a3145 |
+99
-17
@@ -2,41 +2,123 @@ name: Builder
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
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
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
name: Packager
|
||||
name: Build PluginLoader
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 🧰 Checkout
|
||||
- name: Checkout 🧰
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up NodeJS 17 💎
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 17
|
||||
|
||||
- name: 🐍 Set up Python 3.10
|
||||
- name: Set up Python 3.10 🐍
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: ⬇️ Install dependencies
|
||||
- name: Install Python dependencies ⬇️
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
|
||||
- name: 🛠️ Build
|
||||
[ -f requirements.txt ] && pip install -r requirements.txt
|
||||
|
||||
- name: Install NodeJS dependencies ⬇️
|
||||
run: |
|
||||
pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./plugin_loader/static:/static --add-data ./plugin_loader/templates:/templates ./plugin_loader/*.py
|
||||
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
|
||||
uses: actions/upload-artifact@v2
|
||||
- name: Upload package artifact ⬆️
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Plugin Loader
|
||||
path: |
|
||||
./dist/*
|
||||
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: Bump version and push tag ⏫
|
||||
id: tag_version
|
||||
uses: mathieudutour/github-tag-action@v6.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- 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
|
||||
|
||||
nightly:
|
||||
name: Release the nightly version of the package
|
||||
if: ${{ github.event_name == 'schedule' || (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: Bump version and push tag ⏫
|
||||
id: tag_version
|
||||
uses: mathieudutour/github-tag-action@v6.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release_branches: ''
|
||||
pre_release_branches: 'main'
|
||||
append_to_pre_release_tag: '-pre'
|
||||
|
||||
- name: Release 📦
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: Nightly ${{ steps.tag_version.outputs.new_tag }}
|
||||
tag_name: ${{ steps.tag_version.outputs.new_tag }}
|
||||
files: ./dist/PluginLoader
|
||||
prerelease: true
|
||||
generate_release_notes: true
|
||||
|
||||
+11
-1
@@ -149,4 +149,14 @@ dmypy.json
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
cython_debug/
|
||||
|
||||
# static files are built
|
||||
backend/static
|
||||
|
||||
# ignore settings.json
|
||||
# prevents leaking login details
|
||||
.vscode/settings.json
|
||||
|
||||
# plugins folder for local launches
|
||||
plugins/*
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/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
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"deckip" : "0.0.0.0",
|
||||
"deckport" : "22",
|
||||
"deckpass" : "ssap",
|
||||
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
|
||||
"deckdir" : "/home/deck"
|
||||
}
|
||||
Vendored
+16
-4
@@ -2,13 +2,25 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug",
|
||||
"name": "Run (Remote)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/plugin_loader/main.py",
|
||||
"preLaunchTask": "Stop Service",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true
|
||||
"preLaunchTask": "remoterun",
|
||||
"cwd": "",
|
||||
"program": "",
|
||||
},
|
||||
{
|
||||
"name": "Run (Local)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/backend/main.py",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"PLUGIN_PATH": "${workspaceFolder}/plugins"
|
||||
},
|
||||
"preLaunchTask": "localrun"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+141
-2
@@ -1,10 +1,149 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
// OTHER
|
||||
{
|
||||
"label": "Stop Service",
|
||||
"label": "checkforsettings",
|
||||
"type": "shell",
|
||||
"command":"systemctl --user stop plugin_loader",
|
||||
"group": "none",
|
||||
"detail": "Check that settings.json has been created",
|
||||
"command": "bash -c ${workspaceFolder}/.vscode/config.sh",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "localrun",
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,30 +2,33 @@
|
||||
|
||||

|
||||
|
||||
Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugin Loader, documentation + tools for plugin development and more.
|
||||
|
||||
## Installation
|
||||
1. Go into the Steam Deck Settings
|
||||
2. Under System -> System Settings toggle `Enable Developer Mode`
|
||||
3. Scroll the sidebar all the way down and click on `Developer`
|
||||
4. Under Miscellaneous, enable `CEF Remote Debugging`
|
||||
5. Click on the `STEAM` button and select `Power` -> `Switch to Desktop`
|
||||
6. Open a terminal and paste the following command into it:
|
||||
6. Make sure you have a password set with the "passwd" command in terminal to install it ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ)).
|
||||
7. Open a terminal and paste the following command into it:
|
||||
- For users:
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_release.sh | sh`
|
||||
- For developers:
|
||||
~~- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_nightly.sh | sh`~~
|
||||
Nightly releases are currently broken.
|
||||
8. Done! Reboot back into Gaming mode and enjoy your plugins!
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/legacy/dist/install_release.sh | sh`
|
||||
- For the latest pre-release,
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_prerelease.sh | sh`
|
||||
- For testers/plugin developers:
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_prerelease.sh | sh`
|
||||
- [Wiki Link](https://deckbrew.xyz/en/loader-dev/development)
|
||||
7. Done! Reboot back into Gaming mode and enjoy your plugins!
|
||||
|
||||
### Install Plugins
|
||||
### Install/Uninstall 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:
|
||||
- For both users and developers:
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/uninstall.sh | sh`
|
||||
|
||||
### Developing plugins
|
||||
- There is no complete plugin development documentation yet. However a good starting point is the [Plugin Template](https://github.com/SteamDeckHomebrew/Plugin-Template) repository
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/uninstall.sh | sh`
|
||||
|
||||
## Features
|
||||
- Clean injecting and loading of one or more plugins
|
||||
@@ -34,9 +37,16 @@
|
||||
- Allows plugins to define python functions and run them from javascript.
|
||||
- Allows plugins to make fetch calls, bypassing cors completely.
|
||||
|
||||
## Caveats
|
||||
## 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.
|
||||
|
||||
- You can only interact with the Plugin Menu via touchscreen.
|
||||
## [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.)
|
||||
|
||||
Source control and deploying plugins are left to each respective contributor for the cloned repos in order to keep depedencies up to date.
|
||||
|
||||
## Credit
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from injector import get_tab
|
||||
from logging import getLogger
|
||||
from os import path, rename, listdir
|
||||
from shutil import rmtree
|
||||
from aiohttp import ClientSession, web
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from asyncio import get_event_loop
|
||||
from time import time
|
||||
from hashlib import sha256
|
||||
from subprocess import Popen
|
||||
|
||||
import json
|
||||
|
||||
import helpers
|
||||
|
||||
class PluginInstallContext:
|
||||
def __init__(self, artifact, name, version, hash) -> None:
|
||||
self.artifact = artifact
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.hash = hash
|
||||
|
||||
class PluginBrowser:
|
||||
def __init__(self, plugin_path, server_instance) -> None:
|
||||
self.log = getLogger("browser")
|
||||
self.plugin_path = plugin_path
|
||||
self.install_requests = {}
|
||||
|
||||
server_instance.add_routes([
|
||||
web.post("/browser/install_plugin", self.install_plugin),
|
||||
web.post("/browser/uninstall_plugin", self.uninstall_plugin)
|
||||
])
|
||||
|
||||
def _unzip_to_plugin_dir(self, zip, name, hash):
|
||||
zip_hash = sha256(zip.getbuffer()).hexdigest()
|
||||
if hash and (zip_hash != hash):
|
||||
return False
|
||||
zip_file = ZipFile(zip)
|
||||
zip_file.extractall(self.plugin_path)
|
||||
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):
|
||||
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)
|
||||
|
||||
async def uninstall_plugin(self, name):
|
||||
tab = await get_tab("SP")
|
||||
await tab.open_websocket()
|
||||
|
||||
try:
|
||||
if type(name) != str:
|
||||
data = await name.post()
|
||||
name = data.get("name")
|
||||
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
|
||||
rmtree(self.find_plugin_folder(name))
|
||||
except FileNotFoundError:
|
||||
self.log.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:
|
||||
self.log.error(f"Plugin {name} not installed, skipping uninstallation")
|
||||
self.log.info(f"Installing {name} (Version: {version})")
|
||||
async with ClientSession() as client:
|
||||
self.log.debug(f"Fetching {artifact}")
|
||||
res = await client.get(artifact, ssl=helpers.get_ssl_context())
|
||||
if res.status == 200:
|
||||
self.log.debug("Got 200. Reading...")
|
||||
data = await res.read()
|
||||
self.log.debug(f"Read {len(data)} bytes")
|
||||
res_zip = BytesIO(data)
|
||||
with ProcessPoolExecutor() as executor:
|
||||
self.log.debug("Unzipping...")
|
||||
ret = await get_event_loop().run_in_executor(
|
||||
executor,
|
||||
self._unzip_to_plugin_dir,
|
||||
res_zip,
|
||||
name,
|
||||
hash
|
||||
)
|
||||
if ret:
|
||||
self.log.info(f"Installed {name} (Version: {version})")
|
||||
else:
|
||||
self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
|
||||
else:
|
||||
self.log.fatal(f"Could not fetch from URL. {await res.text()}")
|
||||
|
||||
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)))
|
||||
return web.Response(text="Requested plugin install")
|
||||
|
||||
async def request_plugin_install(self, artifact, name, version, hash):
|
||||
request_id = str(time())
|
||||
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash)
|
||||
tab = await get_tab("SP")
|
||||
await tab.open_websocket()
|
||||
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
|
||||
|
||||
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)
|
||||
|
||||
def cancel_plugin_install(self, request_id):
|
||||
self.install_requests.pop(request_id)
|
||||
@@ -0,0 +1,7 @@
|
||||
import ssl
|
||||
import certifi
|
||||
|
||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
def get_ssl_context():
|
||||
return ssl_ctx
|
||||
@@ -1,10 +1,11 @@
|
||||
#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from logging import debug, getLogger
|
||||
from asyncio import sleep
|
||||
from logging import debug, getLogger
|
||||
from traceback import format_exc
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
BASE_ADDRESS = "http://localhost:8080"
|
||||
|
||||
logger = getLogger("Injector")
|
||||
@@ -21,7 +22,7 @@ class Tab:
|
||||
async def open_websocket(self):
|
||||
self.client = ClientSession()
|
||||
self.websocket = await self.client.ws_connect(self.ws_url)
|
||||
|
||||
|
||||
async def listen_for_message(self):
|
||||
async for message in self.websocket:
|
||||
yield message
|
||||
@@ -43,13 +44,14 @@ class Tab:
|
||||
"awaitPromise": run_async
|
||||
}
|
||||
})
|
||||
|
||||
await self.client.close()
|
||||
return res
|
||||
|
||||
|
||||
async def get_steam_resource(self, url):
|
||||
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
||||
return res["result"]["result"]["value"]
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return self.title
|
||||
|
||||
@@ -77,13 +79,25 @@ async def get_tab(tab_name):
|
||||
tab = next((i for i in tabs if i.title == tab_name), None)
|
||||
if not tab:
|
||||
raise ValueError(f"Tab {tab_name} not found")
|
||||
return tab
|
||||
return tab
|
||||
|
||||
async def inject_to_tab(tab_name, js, run_async=False):
|
||||
tab = await get_tab(tab_name)
|
||||
|
||||
return await tab.evaluate_js(js, run_async)
|
||||
|
||||
async def tab_has_global_var(tab_name, var_name):
|
||||
try:
|
||||
tab = await get_tab(tab_name)
|
||||
except ValueError:
|
||||
return False
|
||||
res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False)
|
||||
|
||||
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
|
||||
return False
|
||||
|
||||
return res["result"]["result"]["value"]
|
||||
|
||||
async def tab_has_element(tab_name, element_name):
|
||||
try:
|
||||
tab = await get_tab(tab_name)
|
||||
@@ -94,4 +108,4 @@ async def tab_has_element(tab_name, element_name):
|
||||
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
|
||||
return False
|
||||
|
||||
return res["result"]["result"]["value"]
|
||||
return res["result"]["result"]["value"]
|
||||
@@ -8,18 +8,17 @@ window.addEventListener("message", function(evt) {
|
||||
}, false);
|
||||
|
||||
async function call_server_method(method_name, arg_object={}) {
|
||||
let id = `${uuidv4()}`;
|
||||
console.debug(JSON.stringify({
|
||||
"id": id,
|
||||
"method": method_name,
|
||||
"args": arg_object
|
||||
}));
|
||||
return new Promise((resolve, reject) => {
|
||||
method_call_ev_target.addEventListener(`${id}`, function (event) {
|
||||
if (event.data.success) resolve(event.data.result);
|
||||
else reject(event.data.result);
|
||||
});
|
||||
const response = await fetch(`http://127.0.0.1:1337/methods/${method_name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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!
|
||||
@@ -41,11 +40,19 @@ async function fetch_nocors(url, request={}) {
|
||||
async function call_plugin_method(method_name, arg_object={}) {
|
||||
if (plugin_name == undefined)
|
||||
throw new Error("Plugin methods can only be called from inside plugins (duh)");
|
||||
return await call_server_method("plugin_method", {
|
||||
'plugin_name': plugin_name,
|
||||
'method_name': method_name,
|
||||
'args': arg_object
|
||||
const response = await fetch(`http://127.0.0.1:1337/plugins/${plugin_name}/methods/${method_name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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) {
|
||||
@@ -1,23 +1,36 @@
|
||||
from aiohttp import web
|
||||
from aiohttp_jinja2 import template
|
||||
from watchdog.observers.polling import PollingObserver as Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from asyncio import Queue
|
||||
from os import path, listdir
|
||||
from json.decoder import JSONDecodeError
|
||||
from logging import getLogger
|
||||
from time import time
|
||||
|
||||
from injector import get_tabs, get_tab
|
||||
from plugin import PluginWrapper
|
||||
from os import listdir, path
|
||||
from pathlib import Path
|
||||
from traceback import print_exc
|
||||
|
||||
class FileChangeHandler(FileSystemEventHandler):
|
||||
from aiohttp import web
|
||||
from genericpath import exists
|
||||
from watchdog.events import RegexMatchingEventHandler
|
||||
from watchdog.utils import UnsupportedLibc
|
||||
|
||||
try:
|
||||
from watchdog.observers.inotify import InotifyObserver as Observer
|
||||
except UnsupportedLibc:
|
||||
from watchdog.observers.fsevents import FSEventsObserver as Observer
|
||||
|
||||
from injector import get_tab, inject_to_tab
|
||||
from plugin import PluginWrapper
|
||||
|
||||
|
||||
class FileChangeHandler(RegexMatchingEventHandler):
|
||||
def __init__(self, queue, plugin_path) -> None:
|
||||
super().__init__()
|
||||
super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$'])
|
||||
self.logger = getLogger("file-watcher")
|
||||
self.plugin_path = plugin_path
|
||||
self.queue = queue
|
||||
|
||||
def maybe_reload(self, src_path):
|
||||
plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0]
|
||||
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))
|
||||
|
||||
def on_created(self, event):
|
||||
src_path = event.src_path
|
||||
if "__pycache__" in src_path:
|
||||
@@ -30,11 +43,8 @@ class FileChangeHandler(FileSystemEventHandler):
|
||||
# get the directory name of the plugin so that we can find its "main.py" and reload it; the
|
||||
# file that changed is not necessarily the one that needs to be reloaded
|
||||
self.logger.debug(f"file created: {src_path}")
|
||||
rel_path = path.relpath(src_path, path.commonprefix([self.plugin_path, src_path]))
|
||||
plugin_dir = path.split(rel_path)[0]
|
||||
main_file_path = path.join(self.plugin_path, plugin_dir, "main.py")
|
||||
self.queue.put_nowait((main_file_path, plugin_dir, True))
|
||||
|
||||
self.maybe_reload(src_path)
|
||||
|
||||
def on_modified(self, event):
|
||||
src_path = event.src_path
|
||||
if "__pycache__" in src_path:
|
||||
@@ -47,8 +57,7 @@ class FileChangeHandler(FileSystemEventHandler):
|
||||
# get the directory name of the plugin so that we can find its "main.py" and reload it; the
|
||||
# file that changed is not necessarily the one that needs to be reloaded
|
||||
self.logger.debug(f"file modified: {src_path}")
|
||||
plugin_dir = path.split(path.relpath(src_path, path.commonprefix([self.plugin_path, src_path])))[0]
|
||||
self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True))
|
||||
self.maybe_reload(src_path)
|
||||
|
||||
class Loader:
|
||||
def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> None:
|
||||
@@ -57,9 +66,6 @@ class Loader:
|
||||
self.plugin_path = plugin_path
|
||||
self.logger.info(f"plugin_path: {self.plugin_path}")
|
||||
self.plugins = {}
|
||||
self.callsigns = {}
|
||||
self.callsign_matches = {}
|
||||
self.import_plugins()
|
||||
|
||||
if live_reload:
|
||||
self.reload_queue = Queue()
|
||||
@@ -69,13 +75,33 @@ class Loader:
|
||||
self.loop.create_task(self.handle_reloads())
|
||||
|
||||
server_instance.add_routes([
|
||||
web.get("/plugins/iframe", self.plugin_iframe_route),
|
||||
web.get("/plugins", self.get_plugins),
|
||||
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle),
|
||||
web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call),
|
||||
web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_frontend_assets),
|
||||
|
||||
# The following is legacy plugin code.
|
||||
web.get("/plugins/load_main/{name}", self.load_plugin_main_view),
|
||||
web.get("/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route),
|
||||
web.get("/plugins/load_tile/{name}", self.load_plugin_tile_view),
|
||||
web.get("/steam_resource/{path:.+}", self.get_steam_resource)
|
||||
])
|
||||
|
||||
async def get_plugins(self, request):
|
||||
plugins = list(self.plugins.values())
|
||||
return web.json_response([str(i) if not i.legacy else "$LEGACY_"+str(i) for i in plugins])
|
||||
|
||||
def handle_frontend_assets(self, request):
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
file = path.join(self.plugin_path, plugin.plugin_directory, "dist/assets", request.match_info["path"])
|
||||
|
||||
return web.FileResponse(file)
|
||||
|
||||
def handle_frontend_bundle(self, request):
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
|
||||
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), 'r') as bundle:
|
||||
return web.Response(text=bundle.read(), content_type="application/javascript")
|
||||
|
||||
def import_plugin(self, file, plugin_directory, refresh=False):
|
||||
try:
|
||||
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
|
||||
@@ -86,21 +112,17 @@ class Loader:
|
||||
else:
|
||||
self.plugins[plugin.name].stop()
|
||||
self.plugins.pop(plugin.name, None)
|
||||
self.callsigns.pop(self.callsign_matches[file], None)
|
||||
if plugin.passive:
|
||||
self.logger.info(f"Plugin {plugin.name} is passive")
|
||||
callsign = str(time())
|
||||
plugin.callsign = callsign
|
||||
self.plugins[plugin.name] = plugin.start()
|
||||
self.callsigns[callsign] = plugin
|
||||
self.callsign_matches[file] = callsign
|
||||
self.logger.info(f"Loaded {plugin.name}")
|
||||
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not load {file}. {e}")
|
||||
print_exc()
|
||||
finally:
|
||||
if refresh:
|
||||
self.loop.create_task(self.refresh_iframe())
|
||||
|
||||
async def dispatch_plugin(self, name):
|
||||
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}')")
|
||||
|
||||
def import_plugins(self):
|
||||
self.logger.info(f"import plugins from {self.plugin_path}")
|
||||
@@ -115,77 +137,58 @@ class Loader:
|
||||
args = await self.reload_queue.get()
|
||||
self.import_plugin(*args)
|
||||
|
||||
async def handle_plugin_method_call(self, callsign, method_name, **kwargs):
|
||||
if method_name.startswith("_"):
|
||||
raise RuntimeError("Tried to call private method")
|
||||
return await self.callsigns[callsign].execute_method(method_name, kwargs)
|
||||
|
||||
async def get_steam_resource(self, request):
|
||||
tab = (await get_tabs())[0]
|
||||
async def handle_plugin_method_call(self, request):
|
||||
res = {}
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
method_name = request.match_info["method_name"]
|
||||
try:
|
||||
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
|
||||
method_info = await request.json()
|
||||
args = method_info["args"]
|
||||
except JSONDecodeError:
|
||||
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
|
||||
except Exception as e:
|
||||
return web.Response(text=str(e), status=400)
|
||||
res["result"] = str(e)
|
||||
res["success"] = False
|
||||
return web.json_response(res)
|
||||
|
||||
"""
|
||||
The following methods are used to load legacy plugins, which are considered deprecated.
|
||||
I made the choice to re-add them so that the first iteration/version of the react loader
|
||||
can work as a drop-in replacement for the stable branch of the PluginLoader, so that we
|
||||
can introduce it more smoothly and give people the chance to sample the new features even
|
||||
without plugin support. They will be removed once legacy plugins are no longer relevant.
|
||||
"""
|
||||
async def load_plugin_main_view(self, request):
|
||||
plugin = self.callsigns[request.match_info["name"]]
|
||||
|
||||
# open up the main template
|
||||
plugin = self.plugins[request.match_info["name"]]
|
||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), 'r') as template:
|
||||
template_data = template.read()
|
||||
# setup the main script, plugin, and pull in the template
|
||||
ret = f"""
|
||||
<script src="/static/library.js"></script>
|
||||
<script>const plugin_name = '{plugin.callsign}' </script>
|
||||
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.callsign}/">
|
||||
<script src="/legacy/library.js"></script>
|
||||
<script>window.plugin_name = '{plugin.name}' </script>
|
||||
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.name}/">
|
||||
{template_data}
|
||||
"""
|
||||
return web.Response(text=ret, content_type="text/html")
|
||||
|
||||
async def handle_sub_route(self, request):
|
||||
plugin = self.callsigns[request.match_info["name"]]
|
||||
plugin = self.plugins[request.match_info["name"]]
|
||||
route_path = request.match_info["path"]
|
||||
self.logger.info(path)
|
||||
|
||||
ret = ""
|
||||
|
||||
file_path = path.join(self.plugin_path, plugin.plugin_directory, route_path)
|
||||
with open(file_path, 'r') as resource_data:
|
||||
ret = resource_data.read()
|
||||
|
||||
return web.Response(text=ret)
|
||||
|
||||
async def load_plugin_tile_view(self, request):
|
||||
plugin = self.callsigns[request.match_info["name"]]
|
||||
|
||||
inner_content = ""
|
||||
|
||||
# open up the tile template (if we have one defined)
|
||||
if hasattr(plugin, "tile_view_html"):
|
||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.tile_view_html), 'r') as template:
|
||||
template_data = template.read()
|
||||
inner_content = template_data
|
||||
|
||||
# setup the default template
|
||||
ret = f"""
|
||||
<html style="height: fit-content;">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script src="/static/library.js"></script>
|
||||
<script>const plugin_name = '{plugin.callsign}';</script>
|
||||
</head>
|
||||
<body style="height: fit-content; display: block;">
|
||||
{inner_content}
|
||||
</body>
|
||||
<html>
|
||||
"""
|
||||
return web.Response(text=ret, content_type="text/html")
|
||||
|
||||
@template('plugin_view.html')
|
||||
async def plugin_iframe_route(self, request):
|
||||
return {"plugins": self.plugins.values()}
|
||||
|
||||
async def refresh_iframe(self):
|
||||
async def get_steam_resource(self, request):
|
||||
tab = await get_tab("QuickAccess")
|
||||
await tab.open_websocket()
|
||||
return await tab.evaluate_js("reloadIframe()", False)
|
||||
try:
|
||||
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
|
||||
except Exception as e:
|
||||
return web.Response(text=str(e), status=400)
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
from logging import DEBUG, INFO, basicConfig, getLogger
|
||||
from os import getenv
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
CONFIG = {
|
||||
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
||||
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
||||
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
||||
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")]
|
||||
}
|
||||
|
||||
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
||||
|
||||
from asyncio import get_event_loop, sleep
|
||||
from json import dumps, loads
|
||||
from os import path
|
||||
from subprocess import call
|
||||
|
||||
import aiohttp_cors
|
||||
from aiohttp.web import Application, run_app, static
|
||||
from aiohttp_jinja2 import setup as jinja_setup
|
||||
|
||||
from browser import PluginBrowser
|
||||
from injector import inject_to_tab, tab_has_global_var
|
||||
from loader import Loader
|
||||
from utilities import Utilities
|
||||
|
||||
logger = getLogger("Main")
|
||||
|
||||
async def chown_plugin_dir(_):
|
||||
code_chown = call(["chown", "-R", "deck:deck", 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})")
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self) -> None:
|
||||
self.loop = get_event_loop()
|
||||
self.web_app = Application()
|
||||
self.cors = aiohttp_cors.setup(self.web_app, defaults={
|
||||
"https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
|
||||
allow_headers="*")
|
||||
})
|
||||
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
|
||||
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app)
|
||||
self.utilities = Utilities(self)
|
||||
|
||||
jinja_setup(self.web_app)
|
||||
self.web_app.on_startup.append(self.inject_javascript)
|
||||
if CONFIG["chown_plugin_path"] == True:
|
||||
self.web_app.on_startup.append(chown_plugin_dir)
|
||||
self.loop.create_task(self.loader_reinjector())
|
||||
self.loop.create_task(self.load_plugins())
|
||||
self.loop.set_exception_handler(self.exception_handler)
|
||||
for route in list(self.web_app.router.routes()):
|
||||
self.cors.add(route)
|
||||
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
|
||||
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 wait_for_server(self):
|
||||
async with ClientSession() as web:
|
||||
while True:
|
||||
try:
|
||||
await web.get(f"http://{CONFIG['server_host']}:{CONFIG['server_port']}")
|
||||
return
|
||||
except Exception as e:
|
||||
await sleep(0.1)
|
||||
|
||||
async def load_plugins(self):
|
||||
await self.wait_for_server()
|
||||
self.plugin_loader.import_plugins()
|
||||
#await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
||||
|
||||
async def loader_reinjector(self):
|
||||
while True:
|
||||
await sleep(1)
|
||||
if not await tab_has_global_var("SP", "DeckyPluginLoader"):
|
||||
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
|
||||
await self.inject_javascript()
|
||||
|
||||
async def inject_javascript(self, request=None):
|
||||
try:
|
||||
await inject_to_tab("SP", "try{" + open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read() + "}catch(e){console.error(e)}", True)
|
||||
except:
|
||||
logger.info("Failed to inject JavaScript into tab")
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
|
||||
|
||||
if __name__ == "__main__":
|
||||
PluginManager().run()
|
||||
@@ -1,11 +1,16 @@
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
from asyncio import get_event_loop, new_event_loop, set_event_loop, start_unix_server, open_unix_connection, sleep, Lock
|
||||
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 json import loads, dumps, load
|
||||
from time import time
|
||||
from multiprocessing import Process
|
||||
from signal import signal, SIGINT
|
||||
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:
|
||||
@@ -18,14 +23,20 @@ class PluginWrapper:
|
||||
|
||||
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.main_view_html = json["main_view_html"]
|
||||
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
||||
self.flags = json["flags"]
|
||||
|
||||
self.passive = not path.isfile(self.file)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def _init(self):
|
||||
signal(SIGINT, lambda s, f: exit(0))
|
||||
|
||||
@@ -77,7 +88,7 @@ class PluginWrapper:
|
||||
def start(self):
|
||||
if self.passive:
|
||||
return self
|
||||
Process(target=self._init).start()
|
||||
multiprocessing.Process(target=self._init).start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
@@ -1,6 +1,10 @@
|
||||
from aiohttp import ClientSession
|
||||
from injector import inject_to_tab
|
||||
import uuid
|
||||
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:
|
||||
@@ -8,18 +12,43 @@ class Utilities:
|
||||
self.util_methods = {
|
||||
"ping": self.ping,
|
||||
"http_request": self.http_request,
|
||||
"cancel_plugin_install": self.cancel_plugin_install,
|
||||
"confirm_plugin_install": self.confirm_plugin_install,
|
||||
"execute_in_tab": self.execute_in_tab,
|
||||
"inject_css_into_tab": self.inject_css_into_tab,
|
||||
"remove_css_from_tab": self.remove_css_from_tab
|
||||
}
|
||||
|
||||
if context:
|
||||
context.web_app.add_routes([
|
||||
web.post("/methods/{method_name}", self._handle_server_method_call)
|
||||
])
|
||||
|
||||
async def _handle_server_method_call(self, request):
|
||||
method_name = request.match_info["method_name"]
|
||||
try:
|
||||
args = await request.json()
|
||||
except JSONDecodeError:
|
||||
args = {}
|
||||
res = {}
|
||||
try:
|
||||
r = await self.util_methods[method_name](**args)
|
||||
res["result"] = r
|
||||
res["success"] = True
|
||||
except Exception as e:
|
||||
res["result"] = str(e)
|
||||
res["success"] = False
|
||||
return web.json_response(res)
|
||||
|
||||
async def confirm_plugin_install(self, request_id):
|
||||
return await self.context.plugin_browser.confirm_plugin_install(request_id)
|
||||
|
||||
def cancel_plugin_install(self, request_id):
|
||||
return self.context.plugin_browser.cancel_plugin_install(request_id)
|
||||
|
||||
async def http_request(self, method="", url="", **kwargs):
|
||||
async with ClientSession() as web:
|
||||
async with web.request(method, url, **kwargs) as res:
|
||||
async with web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs) as res:
|
||||
return {
|
||||
"status": res.status,
|
||||
"headers": dict(res.headers),
|
||||
@@ -29,7 +58,7 @@ class Utilities:
|
||||
async def ping(self, **kwargs):
|
||||
return "pong"
|
||||
|
||||
async def execute_in_tab(self, tab, run_async, code):
|
||||
async def execute_in_tab(self, tab, run_async, code):
|
||||
try:
|
||||
result = await inject_to_tab(tab, code, run_async)
|
||||
if "exceptionDetails" in result["result"]:
|
||||
@@ -43,7 +72,7 @@ class Utilities:
|
||||
"result" : result["result"]["result"].get("value")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
return {
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
@@ -52,7 +81,7 @@ class Utilities:
|
||||
try:
|
||||
css_id = str(uuid.uuid4())
|
||||
|
||||
result = await inject_to_tab(tab,
|
||||
result = await inject_to_tab(tab,
|
||||
f"""
|
||||
(function() {{
|
||||
const style = document.createElement('style');
|
||||
@@ -73,14 +102,14 @@ class Utilities:
|
||||
"result" : css_id
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
return {
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
|
||||
async def remove_css_from_tab(self, tab, css_id):
|
||||
try:
|
||||
result = await inject_to_tab(tab,
|
||||
result = await inject_to_tab(tab,
|
||||
f"""
|
||||
(function() {{
|
||||
let style = document.getElementById("{css_id}");
|
||||
@@ -100,7 +129,7 @@ class Utilities:
|
||||
"success": True
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
return {
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
Executable
+335
@@ -0,0 +1,335 @@
|
||||
#!/bin/bash
|
||||
|
||||
## Before using this script, enable sshd on the deck and setup an sshd key between the deck and your dev in sshd_config.
|
||||
## This script defaults to port 22 unless otherwise specified, and cannot run without a sudo password or LAN IP.
|
||||
## You will need to specify the path to the ssh key if using key connection exclusively.
|
||||
|
||||
## TODO: document latest changes to wiki
|
||||
|
||||
## Pre-parse arugments for ease of use
|
||||
CLONEFOLDER=${1:-""}
|
||||
INSTALLFOLDER=${2:-""}
|
||||
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")
|
||||
|
||||
## iterate through options array to check their presence
|
||||
count=0
|
||||
for OPTION in ${OPTIONSARRAY[@]}; do
|
||||
! [[ "$OPTION" == "" ]] && count=$(($count+1))
|
||||
# printf "OPTION=$OPTION\n"
|
||||
done
|
||||
|
||||
setfolder() {
|
||||
if [[ "$2" == "clone" ]]; then
|
||||
local ACTION="clone"
|
||||
local DEFAULT="git"
|
||||
elif [[ "$2" == "install" ]]; then
|
||||
local ACTION="install"
|
||||
local DEFAULT="dev"
|
||||
fi
|
||||
|
||||
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"
|
||||
INSTALLFOLDER="${DEFAULT}"
|
||||
fi
|
||||
else
|
||||
printf "Folder type could not be determined, exiting\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
checkdeckip() {
|
||||
### check that ip is provided
|
||||
if [[ "$1" == "" ]]; then
|
||||
printf "An ip address must be provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
### check to make sure it's a potentially valid ipv4 address
|
||||
if ! [[ $1 =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
printf "A valid ip address must be provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
checksshport() {
|
||||
### check to make sure a port was specified
|
||||
if [[ "$1" == "" ]]; then
|
||||
printf "ssh port not provided. Using default, '22'.\n"
|
||||
SSHPORT="22"
|
||||
fi
|
||||
|
||||
### check for valid ssh port
|
||||
if [[ $1 -le 0 ]]; then
|
||||
printf "A valid ssh port must be provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
checksshkey() {
|
||||
### check if ssh key is present at location provided
|
||||
if [[ "$1" == "" ]]; then
|
||||
SSHKEYLOC="$HOME/.ssh/id_rsa"
|
||||
printf "ssh key was not provided. Defaulting to $SSHKEYLOC if it exists.\n"
|
||||
fi
|
||||
|
||||
### check if sshkey is present at location
|
||||
if ! [[ -e "$1" ]]; then
|
||||
SSHKEYLOC=""
|
||||
printf "ssh key does not exist. This script will use password authentication.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
checkpassword() {
|
||||
### check to make sure a password for 'deck' was specified
|
||||
if [[ "$1" == "" ]]; then
|
||||
printf "Remote deck user password was not provided, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
clonefromto() {
|
||||
# printf "repo=$1\n"
|
||||
# printf "outdir=$2\n"
|
||||
# printf "branch=$3\n"
|
||||
printf "Repository: $1\n"
|
||||
git clone $1 $2 &> '/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
|
||||
fi
|
||||
}
|
||||
|
||||
pnpmtransbundle() {
|
||||
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'
|
||||
fi
|
||||
}
|
||||
|
||||
if ! [[ $count -gt 9 ]] ; then
|
||||
printf "Installing Steam Deck Plugin Loader contributor/developer (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 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
|
||||
read -p "Press any key to continue"
|
||||
fi
|
||||
|
||||
printf "\n"
|
||||
|
||||
## User chooses preffered clone & install directories
|
||||
|
||||
if [[ "$CLONEFOLDER" == "" ]]; then
|
||||
setfolder "$CLONEFOLDER" "clone"
|
||||
fi
|
||||
|
||||
if [[ "$INSTALLFOLDER" == "" ]]; then
|
||||
setfolder "$INSTALLFOLDER" "install"
|
||||
fi
|
||||
|
||||
CLONEDIR="$HOME/$CLONEFOLDER"
|
||||
INSTALLDIR="/home/deck/homebrew/$INSTALLFOLDER"
|
||||
|
||||
## Input ip address, port, password and sshkey
|
||||
|
||||
### DECKIP already been parsed?
|
||||
if [[ "$DECKIP" == "" ]]; then
|
||||
### get ip address of deck from user
|
||||
read -p "Enter the ip address of your Steam Deck: " DECKIP
|
||||
fi
|
||||
|
||||
### validate DECKIP
|
||||
checkdeckip "$DECKIP"
|
||||
|
||||
### SSHPORT already been parsed?
|
||||
if [[ "$SSHPORT" == "" ]]; then
|
||||
### get ssh port from user
|
||||
read -p "Enter the ssh port of your Steam Deck: " SSHPORT
|
||||
fi
|
||||
|
||||
### validate SSHPORT
|
||||
checksshport "$SSHPORT"
|
||||
|
||||
### PASSWORD already been parsed?
|
||||
if [[ "$PASSWORD" == "" ]]; then
|
||||
### prompt the user for their deck's password
|
||||
printf "Enter the password for the Steam Deck user 'deck' : "
|
||||
read -s PASSWORD
|
||||
printf "\n"
|
||||
fi
|
||||
|
||||
### validate PASSWORD
|
||||
checkpassword "$PASSWORD"
|
||||
|
||||
### SSHKEYLOC already been parsed?
|
||||
if [[ "$SSHKEYLOC" == "" ]]; then
|
||||
### prompt the user for their ssh key
|
||||
read -p "Enter the directory for your ssh key, for ease of connection : " SSHKEYLOC
|
||||
fi
|
||||
|
||||
### validate SSHKEYLOC
|
||||
checksshkey "$SSHKEYLOC"
|
||||
|
||||
if [[ "$SSHKEYLOC" == "" ]]; then
|
||||
IDENINVOC=""
|
||||
else
|
||||
IDENINVOC="-i ${SSHKEYLOC}"
|
||||
fi
|
||||
|
||||
## Create folder structure
|
||||
|
||||
printf "Cloning git repositories.\n"
|
||||
|
||||
mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
|
||||
### remove folders just in case
|
||||
# rm -r ${CLONEDIR}/pluginloader
|
||||
# rm -r ${CLONEDIR}/pluginlibrary
|
||||
# rm -r ${CLONEDIR}/plugintemplate
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
|
||||
|
||||
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'
|
||||
|
||||
## Transpile and bundle typescript
|
||||
|
||||
[ "$UID" -eq 0 ] || printf "Input password to proceed with install.\n"
|
||||
|
||||
sudo npm install -g pnpm &> '/dev/null'
|
||||
|
||||
type pnpm &> '/dev/null'
|
||||
|
||||
PNPMLIVES=$?
|
||||
|
||||
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
|
||||
printf "pnpm does not appear to be installed, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "Transpiling and bundling typescript.\n"
|
||||
|
||||
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
|
||||
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
|
||||
pnpmtransbundle ${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'
|
||||
|
||||
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"
|
||||
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'
|
||||
if ! [[ $? -eq 0 ]]; then
|
||||
printf "Error occurred when copying $CLONEDIR/plugintemplate to $INSTALLDIR/plugins\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## TODO: direct contributors to wiki for this info?
|
||||
|
||||
printf "Run these commands to deploy your local changes to the deck:\n"
|
||||
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p $SSHPORT $IDENINVOC\""" --exclude='.git/' --exclude='.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 "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"
|
||||
|
||||
## Disable Releases versions if they exist
|
||||
|
||||
### ssh into deck and disable PluginLoader release/nightly service
|
||||
printf "Connecting via ssh to disable any PluginLoader release versions.\n"
|
||||
printf "Script will exit after this. All done!\n"
|
||||
|
||||
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "printf $PASSWORD | sudo -S systemctl disable --now plugin_loader; echo $?" &> '/dev/null'
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
|
||||
## 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")
|
||||
|
||||
## iterate through options array to check their presence
|
||||
count=0
|
||||
for OPTION in ${OPTIONSARRAY[@]}; do
|
||||
! [[ "$OPTION" == "" ]] && count=$(($count+1))
|
||||
# printf "OPTION=$OPTION\n"
|
||||
done
|
||||
|
||||
clonefromto() {
|
||||
# printf "repo=$1\n"
|
||||
# printf "outdir=$2\n"
|
||||
# printf "branch=$3\n"
|
||||
printf "Repository: $1\n"
|
||||
git clone $1 $2 &> '/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
|
||||
fi
|
||||
}
|
||||
|
||||
pnpmtransbundle() {
|
||||
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'
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
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!
|
||||
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"
|
||||
fi
|
||||
|
||||
if ! [[ $count -gt 0 ]] ; 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
|
||||
fi
|
||||
|
||||
CLONEDIR="$HOME/$CLONEFOLDER"
|
||||
|
||||
## Create folder structure
|
||||
|
||||
printf "Cloning git repositories.\n"
|
||||
|
||||
mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
|
||||
### remove folders just in case
|
||||
# rm -r ${CLONEDIR}/pluginloader
|
||||
# rm -r ${CLONEDIR}/pluginlibrary
|
||||
# rm -r ${CLONEDIR}/plugintemplate
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
|
||||
|
||||
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'
|
||||
|
||||
## 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"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo npm install -g pnpm &> '/dev/null'
|
||||
|
||||
type pnpm &> '/dev/null'
|
||||
|
||||
PNPMLIVES=$?
|
||||
|
||||
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
|
||||
printf "pnpm does not appear to be installed, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "Transpiling and bundling typescript.\n"
|
||||
|
||||
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
|
||||
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
|
||||
pnpmtransbundle ${CLONEDIR}/plugintemplate "template"
|
||||
|
||||
printf "Plugin Loader is located at '${CLONEDIR}/pluginloader/'.\n"
|
||||
|
||||
printf "Run in console or in a script these commands to run your development version:\n'export PLUGIN_PATH=${CLONEDIR}/plugins; export CHOWN_PLUGIN_PATH=0; sudo -E python3 ${CLONEDIR}/pluginloader/backend/main.py'\n"
|
||||
|
||||
printf "All done!\n"
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
|
||||
|
||||
echo "Installing Steam Deck Plugin Loader pre-release..."
|
||||
|
||||
HOMEBREW_FOLDER=/home/deck/homebrew
|
||||
|
||||
# # Create folder structure
|
||||
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
|
||||
DOWNLOADURL="$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))" | jq -r ".assets[].browser_download_url")"
|
||||
# printf "DOWNLOADURL=$DOWNLOADURL\n"
|
||||
curl -L $DOWNLOADURL --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
|
||||
[Unit]
|
||||
Description=SteamDeck Plugin Loader
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Restart=always
|
||||
ExecStart=/home/deck/homebrew/services/PluginLoader
|
||||
WorkingDirectory=/home/deck/homebrew/services
|
||||
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
|
||||
Environment=LOG_LEVEL=DEBUG
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOM
|
||||
systemctl daemon-reload
|
||||
systemctl start plugin_loader
|
||||
systemctl enable plugin_loader
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
|
||||
.yalc
|
||||
yalc.lock
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd frontend && npm run lint
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
endOfLine: 'auto',
|
||||
plugins: [require('prettier-plugin-import-sort')],
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "decky_frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "GPLV2",
|
||||
"scripts": {
|
||||
"prepare": "cd .. && husky install frontend/.husky",
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c -w",
|
||||
"lint": "prettier -c src",
|
||||
"format": "prettier -c src -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^21.1.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"@rollup/plugin-typescript": "^8.3.3",
|
||||
"@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-plugin-import-sort": "^0.0.7",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"rollup": "^2.75.7",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"importSort": {
|
||||
".js, .jsx, .ts, .tsx": {
|
||||
"style": "module",
|
||||
"parser": "typescript"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"decky-frontend-lib": "^1.0.2",
|
||||
"react-icons": "^4.4.0"
|
||||
}
|
||||
}
|
||||
Generated
+1738
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import { defineConfig } from 'rollup';
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.tsx',
|
||||
plugins: [
|
||||
commonjs(),
|
||||
nodeResolve(),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
preventAssignment: false,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
],
|
||||
external: ["react", "react-dom"],
|
||||
output: {
|
||||
file: '../backend/static/plugin-loader.iife.js',
|
||||
globals: {
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
},
|
||||
format: 'iife',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ComponentType, FC, createContext, useContext, useEffect, useState } from 'react';
|
||||
import type { RouteProps } from 'react-router';
|
||||
|
||||
export interface RouterEntry {
|
||||
props: Omit<RouteProps, 'path' | 'children'>;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
interface PublicDeckyRouterState {
|
||||
routes: Map<string, RouterEntry>;
|
||||
}
|
||||
|
||||
export class DeckyRouterState {
|
||||
private _routes = new Map<string, RouterEntry>();
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyRouterState {
|
||||
return { routes: this._routes };
|
||||
}
|
||||
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
|
||||
this._routes.set(path, { props, component });
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
removeRoute(path: string) {
|
||||
this._routes.delete(path);
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyRouterStateContext extends PublicDeckyRouterState {
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void;
|
||||
removeRoute(path: string): void;
|
||||
}
|
||||
|
||||
const DeckyRouterStateContext = createContext<DeckyRouterStateContext>(null as any);
|
||||
|
||||
export const useDeckyRouterState = () => useContext(DeckyRouterStateContext);
|
||||
|
||||
interface Props {
|
||||
deckyRouterState: DeckyRouterState;
|
||||
}
|
||||
|
||||
export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRouterState }) => {
|
||||
const [publicDeckyRouterState, setPublicDeckyRouterState] = useState<PublicDeckyRouterState>({
|
||||
...deckyRouterState.publicState(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onUpdate() {
|
||||
setPublicDeckyRouterState({ ...deckyRouterState.publicState() });
|
||||
}
|
||||
|
||||
deckyRouterState.eventBus.addEventListener('update', onUpdate);
|
||||
|
||||
return () => deckyRouterState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const addRoute = (path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) =>
|
||||
deckyRouterState.addRoute(path, component, props);
|
||||
const removeRoute = (path: string) => deckyRouterState.removeRoute(path);
|
||||
|
||||
return (
|
||||
<DeckyRouterStateContext.Provider value={{ ...publicDeckyRouterState, addRoute, removeRoute }}>
|
||||
{children}
|
||||
</DeckyRouterStateContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FC, createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Plugin } from '../plugin';
|
||||
|
||||
interface PublicDeckyState {
|
||||
plugins: Plugin[];
|
||||
activePlugin: Plugin | null;
|
||||
}
|
||||
|
||||
export class DeckyState {
|
||||
private _plugins: Plugin[] = [];
|
||||
private _activePlugin: Plugin | null = null;
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyState {
|
||||
return { plugins: this._plugins, activePlugin: this._activePlugin };
|
||||
}
|
||||
|
||||
setPlugins(plugins: Plugin[]) {
|
||||
this._plugins = plugins;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
setActivePlugin(name: string) {
|
||||
this._activePlugin = this._plugins.find((plugin) => plugin.name === name) ?? null;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
closeActivePlugin() {
|
||||
this._activePlugin = null;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyStateContext extends PublicDeckyState {
|
||||
setActivePlugin(name: string): void;
|
||||
closeActivePlugin(): void;
|
||||
}
|
||||
|
||||
const DeckyStateContext = createContext<DeckyStateContext>(null as any);
|
||||
|
||||
export const useDeckyState = () => useContext(DeckyStateContext);
|
||||
|
||||
interface Props {
|
||||
deckyState: DeckyState;
|
||||
}
|
||||
|
||||
export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) => {
|
||||
const [publicDeckyState, setPublicDeckyState] = useState<PublicDeckyState>({ ...deckyState.publicState() });
|
||||
|
||||
useEffect(() => {
|
||||
function onUpdate() {
|
||||
setPublicDeckyState({ ...deckyState.publicState() });
|
||||
}
|
||||
|
||||
deckyState.eventBus.addEventListener('update', onUpdate);
|
||||
|
||||
return () => deckyState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
|
||||
const closeActivePlugin = () => deckyState.closeActivePlugin();
|
||||
|
||||
return (
|
||||
<DeckyStateContext.Provider value={{ ...publicDeckyState, setActivePlugin, closeActivePlugin }}>
|
||||
{children}
|
||||
</DeckyStateContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { VFC } from 'react';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const LegacyPlugin: VFC<Props> = ({ url }) => {
|
||||
return <iframe style={{ border: 'none', width: '100%', height: '100%' }} src={url}></iframe>;
|
||||
};
|
||||
|
||||
export default LegacyPlugin;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ButtonItem, PanelSection, PanelSectionRow } from 'decky-frontend-lib';
|
||||
import { VFC } from 'react';
|
||||
|
||||
import { useDeckyState } from './DeckyState';
|
||||
|
||||
const PluginView: VFC = () => {
|
||||
const { plugins, activePlugin, setActivePlugin } = useDeckyState();
|
||||
|
||||
if (activePlugin) {
|
||||
return <div style={{ height: '100%' }}>{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>
|
||||
))}
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginView;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib';
|
||||
import { CSSProperties, VFC } from 'react';
|
||||
import { FaArrowLeft, FaCog, FaStore } from 'react-icons/fa';
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
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} 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleView;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { SidebarNavigation } from 'decky-frontend-lib';
|
||||
|
||||
import GeneralSettings from './pages/GeneralSettings';
|
||||
import PluginList from './pages/PluginList';
|
||||
|
||||
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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { DialogButton, Field, TextField } from 'decky-frontend-lib';
|
||||
import { useState } from 'react';
|
||||
import { FaShapes } from 'react-icons/fa';
|
||||
|
||||
import { installFromURL } from '../../store/Store';
|
||||
|
||||
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> */}
|
||||
<Field
|
||||
label="Manual plugin install"
|
||||
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
||||
icon={<FaShapes style={{ display: 'block' }} />}
|
||||
>
|
||||
<DialogButton onClick={() => installFromURL(pluginURL)}>Install</DialogButton>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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.uninstall_plugin(name)}
|
||||
>
|
||||
<FaTrash />
|
||||
</DialogButton>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { SteamSpinner } 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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
|
||||
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]);
|
||||
await fetch('http://localhost:1337/browser/install_plugin', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestPluginInstall(plugin: StorePlugin, selectedVer: StorePluginVersion) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', plugin.name);
|
||||
formData.append('artifact', `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`);
|
||||
formData.append('version', selectedVer.name);
|
||||
formData.append('hash', selectedVer.hash);
|
||||
await fetch('http://localhost:1337/browser/install_plugin', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
const StorePage: FC<{}> = () => {
|
||||
const [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;
|
||||
@@ -0,0 +1,26 @@
|
||||
import PluginLoader from './plugin-loader';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyPluginLoader: PluginLoader;
|
||||
importDeckyPlugin: Function;
|
||||
syncDeckyPlugins: Function;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeckyPluginLoader?.dismountAll();
|
||||
window.DeckyPluginLoader?.deinit();
|
||||
|
||||
window.DeckyPluginLoader = new PluginLoader();
|
||||
window.importDeckyPlugin = function (name: string) {
|
||||
window.DeckyPluginLoader?.importPlugin(name);
|
||||
};
|
||||
|
||||
window.syncDeckyPlugins = async function () {
|
||||
const plugins = await (await fetch('http://127.0.0.1:1337/plugins')).json();
|
||||
for (const plugin of plugins) {
|
||||
window.DeckyPluginLoader?.importPlugin(plugin);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => window.syncDeckyPlugins(), 5000);
|
||||
@@ -0,0 +1,35 @@
|
||||
export const log = (name: string, ...args: any[]) => {
|
||||
console.log(
|
||||
`%c Decky %c ${name} %c`,
|
||||
'background: #16a085; color: black;',
|
||||
'background: #1abc9c; color: black;',
|
||||
'background: transparent;',
|
||||
...args,
|
||||
);
|
||||
};
|
||||
|
||||
export const error = (name: string, ...args: any[]) => {
|
||||
console.log(
|
||||
`%c Decky %c ${name} %c`,
|
||||
'background: #16a085; color: black;',
|
||||
'background: #FF0000;',
|
||||
'background: transparent;',
|
||||
...args,
|
||||
);
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor(private name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
log(this.name, ...args);
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
log(this.name, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export default Logger;
|
||||
@@ -0,0 +1,228 @@
|
||||
import { ModalRoot, QuickAccessTab, Router, showModal, sleep, staticClasses } from 'decky-frontend-lib';
|
||||
import { FaPlug } from 'react-icons/fa';
|
||||
|
||||
import { DeckyState, DeckyStateContextProvider } from './components/DeckyState';
|
||||
import LegacyPlugin from './components/LegacyPlugin';
|
||||
import PluginView from './components/PluginView';
|
||||
import 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';
|
||||
|
||||
declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
class PluginLoader extends Logger {
|
||||
private plugins: Plugin[] = [];
|
||||
private tabsHook: TabsHook = new TabsHook();
|
||||
// private windowHook: WindowHook = new WindowHook();
|
||||
private routerHook: RouterHook = new RouterHook();
|
||||
private deckyState: DeckyState = new DeckyState();
|
||||
|
||||
private reloadLock: boolean = false;
|
||||
// stores a list of plugin names which requested to be reloaded
|
||||
private pluginReloadQueue: string[] = [];
|
||||
|
||||
constructor() {
|
||||
super(PluginLoader.name);
|
||||
this.log('Initialized');
|
||||
|
||||
this.tabsHook.add({
|
||||
id: QuickAccessTab.Decky,
|
||||
title: null,
|
||||
content: (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<TitleView />
|
||||
<PluginView />
|
||||
</DeckyStateContextProvider>
|
||||
),
|
||||
icon: <FaPlug />,
|
||||
});
|
||||
|
||||
this.routerHook.addRoute('/decky/store', () => <StorePage />);
|
||||
this.routerHook.addRoute('/decky/settings', () => {
|
||||
return (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<SettingsPage />
|
||||
</DeckyStateContextProvider>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public addPluginInstallPrompt(artifact: string, version: string, request_id: string, hash: string) {
|
||||
showModal(
|
||||
<ModalRoot
|
||||
onOK={async () => {
|
||||
await this.callServerMethod('confirm_plugin_install', { request_id });
|
||||
Router.NavigateBackOrOpenMenu();
|
||||
await sleep(250);
|
||||
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
|
||||
}}
|
||||
onCancel={() => {
|
||||
this.callServerMethod('cancel_plugin_install', { request_id });
|
||||
}}
|
||||
>
|
||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
||||
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
|
||||
Install {artifact}
|
||||
{version ? ' version ' + version : null}?
|
||||
</div>
|
||||
</ModalRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
public uninstall_plugin(name: 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,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
// do nothing
|
||||
}}
|
||||
>
|
||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
||||
Uninstall {name}?
|
||||
</div>
|
||||
</ModalRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
public dismountAll() {
|
||||
for (const plugin of this.plugins) {
|
||||
this.log(`Dismounting ${plugin.name}`);
|
||||
plugin.onDismount?.();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
this.log(`Trying to load ${name}`);
|
||||
|
||||
this.unloadPlugin(name);
|
||||
|
||||
if (name.startsWith('$LEGACY_')) {
|
||||
await this.importLegacyPlugin(name.replace('$LEGACY_', ''));
|
||||
} else {
|
||||
await this.importReactPlugin(name);
|
||||
}
|
||||
|
||||
this.deckyState.setPlugins(this.plugins);
|
||||
this.log(`Loaded ${name}`);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
this.reloadLock = false;
|
||||
const nextPlugin = this.pluginReloadQueue.shift();
|
||||
if (nextPlugin) {
|
||||
this.importPlugin(nextPlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async importReactPlugin(name: string) {
|
||||
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
|
||||
if (res.ok) {
|
||||
let plugin = await eval(await res.text())(this.createPluginAPI(name));
|
||||
this.plugins.push({
|
||||
...plugin,
|
||||
name: name,
|
||||
});
|
||||
} else throw new Error(`${name} frontend_bundle not OK`);
|
||||
}
|
||||
|
||||
private async importLegacyPlugin(name: string) {
|
||||
const url = `http://127.0.0.1:1337/plugins/load_main/${name}`;
|
||||
this.plugins.push({
|
||||
name: name,
|
||||
icon: <FaPlug />,
|
||||
content: <LegacyPlugin url={url} />,
|
||||
});
|
||||
}
|
||||
|
||||
async callServerMethod(methodName: string, args = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
createPluginAPI(pluginName: string) {
|
||||
return {
|
||||
routerHook: this.routerHook,
|
||||
callServerMethod: this.callServerMethod,
|
||||
async callPluginMethod(methodName: string, args = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
},
|
||||
fetchNoCors(url: string, request: any = {}) {
|
||||
let args = { method: 'POST', headers: {} };
|
||||
const req = { ...args, ...request, url, data: request.body };
|
||||
return this.callServerMethod('http_request', req);
|
||||
},
|
||||
executeInTab(tab: string, runAsync: boolean, code: string) {
|
||||
return this.callServerMethod('execute_in_tab', {
|
||||
tab,
|
||||
run_async: runAsync,
|
||||
code,
|
||||
});
|
||||
},
|
||||
injectCssIntoTab(tab: string, style: string) {
|
||||
return this.callServerMethod('inject_css_into_tab', {
|
||||
tab,
|
||||
style,
|
||||
});
|
||||
},
|
||||
removeCssFromTab(tab: string, cssId: any) {
|
||||
return this.callServerMethod('remove_css_from_tab', {
|
||||
tab,
|
||||
css_id: cssId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginLoader;
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
content?: JSX.Element;
|
||||
onDismount?(): void;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib';
|
||||
import { ReactElement, createElement, memo } from 'react';
|
||||
import type { Route } from 'react-router';
|
||||
|
||||
import {
|
||||
DeckyRouterState,
|
||||
DeckyRouterStateContextProvider,
|
||||
RouterEntry,
|
||||
useDeckyRouterState,
|
||||
} from './components/DeckyRouterState';
|
||||
import Logger from './logger';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ROUTER_HOOK_INSTANCE: any;
|
||||
}
|
||||
}
|
||||
|
||||
class RouterHook extends Logger {
|
||||
private router: any;
|
||||
private memoizedRouter: any;
|
||||
private gamepadWrapper: any;
|
||||
private routerState: DeckyRouterState = new DeckyRouterState();
|
||||
|
||||
constructor() {
|
||||
super('RouterHook');
|
||||
|
||||
this.log('Initialized');
|
||||
window.__ROUTER_HOOK_INSTANCE?.deinit?.();
|
||||
window.__ROUTER_HOOK_INSTANCE = this;
|
||||
|
||||
this.gamepadWrapper = findModuleChild((m) => {
|
||||
if (typeof m !== 'object') return undefined;
|
||||
for (let prop in m) {
|
||||
if (m[prop]?.render?.toString()?.includes('["flow-children","onActivate","onCancel","focusClassName",'))
|
||||
return m[prop];
|
||||
}
|
||||
});
|
||||
|
||||
let Route: new () => Route;
|
||||
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
|
||||
const { routes } = useDeckyRouterState();
|
||||
|
||||
const routerIndex = children.props.children[0].props.children.length - 1;
|
||||
if (
|
||||
!children.props.children[0].props.children[routerIndex].length ||
|
||||
children.props.children[0].props.children !== routes.size
|
||||
) {
|
||||
const newRouterArray: ReactElement[] = [];
|
||||
routes.forEach(({ component, props }, path) => {
|
||||
newRouterArray.push(
|
||||
<Route path={path} {...props}>
|
||||
{createElement(component)}
|
||||
</Route>,
|
||||
);
|
||||
});
|
||||
children.props.children[0].props.children[routerIndex] = newRouterArray;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
||||
if (ret?.props?.children?.props?.children?.length == 5) {
|
||||
if (
|
||||
ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
|
||||
?.toString()
|
||||
?.includes('GamepadUI.Settings.Root()')
|
||||
) {
|
||||
if (!this.router) {
|
||||
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
|
||||
afterPatch(this.router, 'type', (_: any, ret: any) => {
|
||||
if (!Route)
|
||||
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
|
||||
const returnVal = (
|
||||
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
|
||||
<DeckyWrapper>{ret}</DeckyWrapper>
|
||||
</DeckyRouterStateContextProvider>
|
||||
);
|
||||
return returnVal;
|
||||
});
|
||||
this.memoizedRouter = memo(this.router.type);
|
||||
this.memoizedRouter.isDeckyRouter = true;
|
||||
}
|
||||
ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
}
|
||||
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
|
||||
this.routerState.addRoute(path, component, props);
|
||||
}
|
||||
|
||||
removeRoute(path: string) {
|
||||
this.routerState.removeRoute(path);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
unpatch(this.gamepadWrapper, 'render');
|
||||
this.router && unpatch(this.router, 'type');
|
||||
}
|
||||
}
|
||||
|
||||
export default RouterHook;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { QuickAccessTab, afterPatch, sleep, unpatch } from 'decky-frontend-lib';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Logger from './logger';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TABS_HOOK_INSTANCE: any;
|
||||
}
|
||||
interface Array<T> {
|
||||
__filter: any;
|
||||
}
|
||||
}
|
||||
|
||||
const isTabsArray = (tabs: any) => {
|
||||
const length = tabs.length;
|
||||
return length >= 7 && tabs[length - 1]?.tab;
|
||||
};
|
||||
|
||||
interface Tab {
|
||||
id: QuickAccessTab | number;
|
||||
title: any;
|
||||
content: any;
|
||||
icon: any;
|
||||
}
|
||||
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
|
||||
add(tab: Tab) {
|
||||
this.log('Adding tab', tab.id, 'to render array');
|
||||
this.tabs.push(tab);
|
||||
}
|
||||
|
||||
removeById(id: number) {
|
||||
this.log('Removing tab', id);
|
||||
this.tabs = this.tabs.filter((tab) => tab.id !== id);
|
||||
}
|
||||
|
||||
render(existingTabs: any[]) {
|
||||
for (const { title, icon, content, id } of this.tabs) {
|
||||
existingTabs.push({
|
||||
key: id,
|
||||
title,
|
||||
tab: icon,
|
||||
panel: content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TabsHook;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "window.SP_REACT.createElement",
|
||||
"declaration": false,
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
from injector import get_tab
|
||||
from logging import getLogger
|
||||
from os import path, rename
|
||||
from shutil import rmtree
|
||||
from aiohttp import ClientSession, web
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
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, 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, store_url) -> None:
|
||||
self.log = getLogger("browser")
|
||||
self.plugin_path = plugin_path
|
||||
self.store_url = store_url
|
||||
self.install_requests = {}
|
||||
|
||||
server_instance.add_routes([
|
||||
web.post("/browser/install_plugin", self.install_plugin),
|
||||
web.get("/browser/iframe", self.redirect_to_store)
|
||||
])
|
||||
|
||||
def _unzip_to_plugin_dir(self, zip, name, hash):
|
||||
zip_hash = sha256(zip.getbuffer()).hexdigest()
|
||||
if zip_hash != hash:
|
||||
return False
|
||||
zip_file = ZipFile(zip)
|
||||
zip_file.extractall(self.plugin_path)
|
||||
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
|
||||
|
||||
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:
|
||||
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:
|
||||
self.log.debug("Got 200. Reading...")
|
||||
data = await res.read()
|
||||
self.log.debug(f"Read {len(data)} bytes")
|
||||
res_zip = BytesIO(data)
|
||||
with ProcessPoolExecutor() as executor:
|
||||
self.log.debug("Unzipping...")
|
||||
ret = await get_event_loop().run_in_executor(
|
||||
executor,
|
||||
self._unzip_to_plugin_dir,
|
||||
res_zip,
|
||||
name,
|
||||
hash
|
||||
)
|
||||
if ret:
|
||||
self.log.info(f"Installed {artifact} (Version: {version})")
|
||||
else:
|
||||
self.log.fatal(f"SHA-256 Mismatch!!!! {artifact} (Version: {version})")
|
||||
else:
|
||||
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["artifact"], data["version"], data["hash"]))
|
||||
return web.Response(text="Requested plugin install")
|
||||
|
||||
async def request_plugin_install(self, artifact, version, hash):
|
||||
request_id = str(time())
|
||||
self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
|
||||
tab = await get_tab("QuickAccess")
|
||||
await tab.open_websocket()
|
||||
await tab.evaluate_js(f"addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
|
||||
|
||||
async def confirm_plugin_install(self, request_id):
|
||||
request = self.install_requests.pop(request_id)
|
||||
await self._install(request.gh_url, request.version, request.hash)
|
||||
@@ -1,144 +0,0 @@
|
||||
from logging import getLogger, basicConfig, INFO, DEBUG, Filter, root
|
||||
from os import getenv
|
||||
|
||||
CONFIG = {
|
||||
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
||||
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
||||
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
|
||||
"store_url": getenv("STORE_URL", "https://plugins.deckbrew.xyz"),
|
||||
"log_base_events": getenv("LOG_BASE_EVENTS", "0")=="1"
|
||||
}
|
||||
|
||||
class NoBaseEvents(Filter):
|
||||
def filter(self, record):
|
||||
return not "asyncio" in record.name
|
||||
|
||||
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
|
||||
for handler in root.handlers:
|
||||
if not CONFIG["log_base_events"]:
|
||||
handler.addFilter(NoBaseEvents())
|
||||
|
||||
from aiohttp.web import Application, run_app, static
|
||||
from aiohttp_jinja2 import setup as jinja_setup
|
||||
from jinja2 import FileSystemLoader
|
||||
from os import path
|
||||
from asyncio import get_event_loop, sleep
|
||||
from json import loads, dumps
|
||||
from subprocess import Popen
|
||||
|
||||
from loader import Loader
|
||||
from injector import inject_to_tab, get_tab, tab_has_element
|
||||
from utilities import Utilities
|
||||
from browser import PluginBrowser
|
||||
|
||||
logger = getLogger("Main")
|
||||
from traceback import print_exc
|
||||
|
||||
async def chown_plugin_dir(_):
|
||||
Popen(["chown", "-R", "deck:deck", CONFIG["plugin_path"]])
|
||||
Popen(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self) -> None:
|
||||
self.loop = get_event_loop()
|
||||
self.web_app = Application()
|
||||
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
|
||||
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, CONFIG["store_url"])
|
||||
self.utilities = Utilities(self)
|
||||
|
||||
jinja_setup(self.web_app, loader=FileSystemLoader(path.join(path.dirname(__file__), 'templates')))
|
||||
self.web_app.on_startup.append(self.inject_javascript)
|
||||
self.web_app.on_startup.append(chown_plugin_dir)
|
||||
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
|
||||
self.loop.create_task(self.method_call_listener())
|
||||
self.loop.create_task(self.loader_reinjector())
|
||||
|
||||
self.loop.set_exception_handler(self.exception_handler)
|
||||
|
||||
def exception_handler(self, loop, context):
|
||||
if context["message"] == "Unclosed connection":
|
||||
return
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
async def loader_reinjector(self):
|
||||
finished_reinjection = False
|
||||
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
|
||||
while True:
|
||||
await sleep(1)
|
||||
if not await tab_has_element("QuickAccess", "plugin_iframe"):
|
||||
logger.debug("Plugin loader isn't present in Steam anymore, reinjecting...")
|
||||
await self.inject_javascript()
|
||||
finished_reinjection = True
|
||||
elif finished_reinjection:
|
||||
finished_reinjection = False
|
||||
logger.info("Reinjecting successful!")
|
||||
|
||||
self.loop.create_task(self.method_call_listener())
|
||||
|
||||
async def inject_javascript(self, request=None):
|
||||
try:
|
||||
await inject_to_tab("QuickAccess", open(path.join(path.dirname(__file__), "static/library.js"), "r").read())
|
||||
await inject_to_tab("QuickAccess", open(path.join(path.dirname(__file__), "static/plugin_page.js"), "r").read())
|
||||
except:
|
||||
logger.info("Failed to inject JavaScript into tab")
|
||||
pass
|
||||
|
||||
async def resolve_method_call(self, tab, call_id, response):
|
||||
try:
|
||||
r = dumps(response)
|
||||
except Exception as e:
|
||||
logger.error(response["result"])
|
||||
response["result"] = str(response["result"])
|
||||
r = response
|
||||
await tab._send_devtools_cmd({
|
||||
"id": 1,
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": f"resolveMethodCall({call_id}, {r})",
|
||||
"userGesture": True
|
||||
}
|
||||
}, receive=False)
|
||||
|
||||
async def handle_method_call(self, method, tab):
|
||||
res = {}
|
||||
try:
|
||||
if method["method"] == "plugin_method":
|
||||
res["result"] = await self.plugin_loader.handle_plugin_method_call(
|
||||
method["args"]["plugin_name"],
|
||||
method["args"]["method_name"],
|
||||
**method["args"]["args"]
|
||||
)
|
||||
res["success"] = True
|
||||
else:
|
||||
r = await self.utilities.util_methods[method["method"]](**method["args"])
|
||||
res["result"] = r
|
||||
res["success"] = True
|
||||
except Exception as e:
|
||||
res["result"] = str(e)
|
||||
res["success"] = False
|
||||
finally:
|
||||
await self.resolve_method_call(tab, method["id"], res)
|
||||
|
||||
async def method_call_listener(self):
|
||||
while True:
|
||||
try:
|
||||
tab = await get_tab("QuickAccess")
|
||||
break
|
||||
except:
|
||||
await sleep(1)
|
||||
await tab.open_websocket()
|
||||
await tab._send_devtools_cmd({"id": 1, "method": "Runtime.discardConsoleEntries"})
|
||||
await tab._send_devtools_cmd({"id": 1, "method": "Runtime.enable"})
|
||||
async for message in tab.listen_for_message():
|
||||
data = message.json()
|
||||
if not "id" in data and data["method"] == "Runtime.consoleAPICalled" and data["params"]["type"] == "debug":
|
||||
method = loads(data["params"]["args"][0]["value"])
|
||||
self.loop.create_task(self.handle_method_call(method, tab))
|
||||
|
||||
def run(self):
|
||||
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
|
||||
|
||||
if __name__ == "__main__":
|
||||
PluginManager().run()
|
||||
@@ -1,98 +0,0 @@
|
||||
function reloadIframe() {
|
||||
document.getElementById("plugin_iframe").contentWindow.location.href = "http://127.0.0.1:1337/plugins/iframe";
|
||||
}
|
||||
|
||||
function resolveMethodCall(call_id, result) {
|
||||
let iframe = document.getElementById("plugin_iframe").contentWindow;
|
||||
iframe.postMessage({'call_id': call_id, 'result': result}, "http://127.0.0.1:1337");
|
||||
}
|
||||
|
||||
function installPlugin(request_id) {
|
||||
let id = `${new Date().getTime()}`;
|
||||
console.debug(JSON.stringify({
|
||||
"id": id,
|
||||
"method": "confirm_plugin_install",
|
||||
"args": {"request_id": request_id}
|
||||
}));
|
||||
document.getElementById('plugin_install_list').removeChild(document.getElementById(`plugin_install_prompt_${request_id}`));
|
||||
}
|
||||
|
||||
function addPluginInstallPrompt(artifact, version, request_id) {
|
||||
let text = `
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
|
||||
<div id="plugin_install_prompt_${request_id}" style="background-color: #0c131b; display: block; border: 1px solid #22262f; box-shadow: 0px 0px 8px #202020; width: calc(100% - 50px); padding: 0px 10px 10px 10px;">
|
||||
<h3>Install Plugin?</h3>
|
||||
<p style="font-size: 12px;">
|
||||
${artifact}
|
||||
Version: ${version}
|
||||
</p>
|
||||
<button type="button" tabindex="0" class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable"
|
||||
onclick="installPlugin('${request_id}')">
|
||||
Install
|
||||
</button>
|
||||
<p style="margin: 2px;"></p>
|
||||
<button type="button" tabindex="0" class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable"
|
||||
onclick="document.getElementById('plugin_install_list').removeChild(document.getElementById('plugin_install_prompt_${request_id}'))">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('plugin_install_list').innerHTML = text;
|
||||
|
||||
execute_in_tab('SP', false, 'FocusNavController.DispatchVirtualButtonClick(28)')
|
||||
}
|
||||
|
||||
(function () {
|
||||
const PLUGIN_ICON = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plugin" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a7 7 0 1 1 2.898 5.673c-.167-.121-.216-.406-.002-.62l1.8-1.8a3.5 3.5 0 0 0
|
||||
4.572-.328l1.414-1.415a.5.5 0 0 0 0-.707l-.707-.707 1.559-1.563a.5.5 0 1 0-.708-.706l-1.559 1.562-1.414-1.414
|
||||
1.56-1.562a.5.5 0 1 0-.707-.706l-1.56 1.56-.707-.706a.5.5 0 0 0-.707 0L5.318 5.975a3.5 3.5 0 0 0-.328
|
||||
4.571l-1.8 1.8c-.58.58-.62 1.6.121 2.137A8 8 0 1 0 0 8a.5.5 0 0 0 1 0Z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
function createTitle(text) {
|
||||
return `<div id="plugin_title" class="quickaccessmenu_Title_34nl5">${text}</div>`;
|
||||
}
|
||||
|
||||
function createPluginList() {
|
||||
let pages = document.getElementsByClassName("quickaccessmenu_AllTabContents_2yKG4 quickaccessmenu_Down_3rR0o")[0];
|
||||
let pluginPage = pages.children[pages.children.length - 1];
|
||||
pluginPage.innerHTML = createTitle("Plugins");
|
||||
|
||||
pluginPage.innerHTML += `<div id="plugin_install_list" style="position: fixed; height: 100%; z-index: 99; transform: translate(5%, 0);"></div>`
|
||||
|
||||
pluginPage.innerHTML += `<iframe id="plugin_iframe" style="border: none; width: 100%; height: 100%;" src="http://127.0.0.1:1337/plugins/iframe"></iframe>`;
|
||||
}
|
||||
|
||||
function inject() {
|
||||
let tabs = document.getElementsByClassName("quickaccessmenu_TabContentColumn_2z5NL Panel Focusable")[0];
|
||||
tabs.children[tabs.children.length - 1].innerHTML = PLUGIN_ICON;
|
||||
|
||||
createPluginList();
|
||||
}
|
||||
|
||||
let injector = setInterval(function () {
|
||||
if (document.hasFocus()) {
|
||||
inject();
|
||||
document.getElementById("plugin_title").onclick = function() {
|
||||
reloadIframe();
|
||||
document.getElementById("plugin_title").innerText = "Plugins";
|
||||
}
|
||||
window.onmessage = function(ev) {
|
||||
let title = ev.data;
|
||||
if (title.startsWith("PLUGIN_LOADER__")) {
|
||||
document.getElementById("plugin_title").innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left-square-fill" viewBox="0 0 16 16">
|
||||
<path d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12zm-4.5-6.5H5.707l2.147-2.146a.5.5 0 1 0-.708-.708l-3 3a.5.5 0 0 0 0 .708l3 3a.5.5 0 0 0 .708-.708L5.707 8.5H11.5a.5.5 0 0 0 0-1z"/>
|
||||
</svg>
|
||||
${title.replace("PLUGIN_LOADER__", "")}
|
||||
`;
|
||||
}
|
||||
}
|
||||
clearInterval(injector);
|
||||
}
|
||||
}, 100);
|
||||
})();
|
||||
@@ -1,3 +0,0 @@
|
||||
@import url("/steam_resource/css/2.css");
|
||||
@import url("/steam_resource/css/39.css");
|
||||
@import url("/steam_resource/css/library.css");
|
||||
@@ -1,76 +0,0 @@
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script>
|
||||
const tile_iframes = [];
|
||||
window.addEventListener("message", function (evt) {
|
||||
tile_iframes.forEach(iframe => {
|
||||
iframe.contentWindow.postMessage(evt.data, "http://127.0.0.1:1337");
|
||||
});
|
||||
}, false);
|
||||
|
||||
function loadPlugin(callsign, name) {
|
||||
this.parent.postMessage("PLUGIN_LOADER__"+name, "https://steamloopback.host");
|
||||
location.href = `/plugins/load_main/${callsign}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{% if not plugins|length %}
|
||||
<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
|
||||
<div class="quickaccesscontrols_EmptyNotifications_3ZjbM" style="padding-top:7px;">
|
||||
No plugins installed
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="quickaccessmenu_TabGroupPanel_1QO7b Panel Focusable">
|
||||
{% for plugin in plugins %}
|
||||
{% if plugin.tile_view_html|length %}
|
||||
<div class="quickaccesscontrols_PanelSectionRow_26R5w">
|
||||
<div onclick="loadPlugin('{{ plugin.callsign }}', '{{ plugin.name }}')"
|
||||
class="basicdialog_Field_ugL9c basicdialog_WithChildrenBelow_1RjOd basicdialog_InlineWrapShiftsChildrenBelow_3a6QZ basicdialog_ExtraPaddingOnChildrenBelow_2-owv basicdialog_StandardPadding_1HrfN basicdialog_HighlightOnFocus_1xh2W Panel Focusable"
|
||||
style="--indent-level:0; margin: 0px; padding: 0px; padding-top: 8px;">
|
||||
<iframe id="tile_view_iframe_{{ plugin.callsign }}"
|
||||
scrolling="no" marginwidth="0" marginheight="0"
|
||||
hspace="0" vspace="0" frameborder="0"
|
||||
style="border-radius: 2px;"
|
||||
src="/plugins/load_tile/{{ plugin.callsign }}">
|
||||
</iframe>
|
||||
<script>
|
||||
(function() {
|
||||
let iframe = document.getElementById("tile_view_iframe_{{ plugin.callsign }}");
|
||||
tile_iframes.push(document.getElementById("tile_view_iframe_{{ plugin.callsign }}"));
|
||||
|
||||
iframe.onload = function() {
|
||||
let html = iframe.contentWindow.document.children[0];
|
||||
let last_height = 0;
|
||||
|
||||
setInterval(function() {
|
||||
let height = iframe.contentWindow.document.children[0].scrollHeight;
|
||||
if (height != last_height) {
|
||||
iframe.height = height + "px";
|
||||
last_height = height;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
iframe.contentWindow.document.body.onclick = function () {
|
||||
loadPlugin('{{ plugin.callsign }}', '{{ plugin.name }}');
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="quickaccesscontrols_PanelSectionRow_26R5w">
|
||||
<div onclick="loadPlugin('{{ plugin.callsign }}', '{{ plugin.name }}')"
|
||||
class="basicdialog_Field_ugL9c basicdialog_WithChildrenBelow_1RjOd basicdialog_InlineWrapShiftsChildrenBelow_3a6QZ basicdialog_ExtraPaddingOnChildrenBelow_2-owv basicdialog_StandardPadding_1HrfN basicdialog_HighlightOnFocus_1xh2W Panel Focusable"
|
||||
style="--indent-level:0; margin: 0px; padding: 0px; padding-top: 8px;">
|
||||
<div class="basicdialog_FieldChildren_279n8">
|
||||
<button type="button" tabindex="0"
|
||||
class="DialogButton _DialogLayout Secondary basicdialog_Button_1Ievp Focusable">{{ plugin.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
aiohttp==3.8.1
|
||||
aiohttp-jinja2==1.5.0
|
||||
watchdog==2.1.7
|
||||
aiohttp_cors==0.7.0
|
||||
watchdog==2.1.7
|
||||
certifi==2022.6.15
|
||||
Reference in New Issue
Block a user