Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ac0abc82b | |||
| 618abec97a | |||
| 2518d1a0b3 | |||
| 010e6a22ab | |||
| 134b896e01 | |||
| 047813b965 | |||
| dbcb549ae2 | |||
| d689614c78 | |||
| ec907627b8 | |||
| a3809222f9 | |||
| 86dc706892 | |||
| 0e409a9f96 | |||
| d58001c323 | |||
| d727ba72f3 | |||
| fa028fa525 | |||
| c947548064 | |||
| 19d5527bdf | |||
| ef51b96f08 | |||
| 617916e8e5 | |||
| 6c4a4d0a44 | |||
| bedcb0fb71 | |||
| 2461f52ca7 | |||
| 3c00eb8cf4 | |||
| 21e1d8504a | |||
| ba93c4add2 | |||
| 61fea41c8a | |||
| e40d3e4db5 | |||
| bbad6bf2be | |||
| 4e04455163 | |||
| 314292b042 | |||
| a264f36966 | |||
| 60c8c5db42 | |||
| 852c52c59a | |||
| 3136ad72ed | |||
| 3700dd7437 | |||
| c6d48389c9 | |||
| 490fc18008 | |||
| 797c7ea3b0 | |||
| 0f06bc1ef0 | |||
| c774451ff4 | |||
| 62a5bdbbb0 | |||
| 7716c73014 | |||
| 8829adc5b6 | |||
| 62bd3e76bd | |||
| 9867d7bea0 | |||
| c4d6731401 | |||
| fded2fa8bf | |||
| 90c523ec45 | |||
| c5ccb4dfb8 | |||
| 8b1925bc53 | |||
| a8c7c2f18f | |||
| 463258febb | |||
| 304fc0f94c | |||
| b5b041fdee | |||
| 9d980618a7 | |||
| 6dad3f81e8 | |||
| adc1a792fb | |||
| 6347ad0856 | |||
| 1377d83023 | |||
| 43d36d2b35 | |||
| 591c58330c | |||
| 501145a210 | |||
| a3659ba425 | |||
| d1887870f5 | |||
| 1892403044 | |||
| f5a1837227 | |||
| 97f95705f8 | |||
| 7c99af9a9a | |||
| b35bd056d5 | |||
| d2da85460d | |||
| 843e03b42c | |||
| 5f469bfb16 | |||
| acaf6c72e4 | |||
| eb439574be | |||
| 16a6e9b6a9 | |||
| 6f84cf94b5 | |||
| 7c06db5ece | |||
| aeb2decfc1 | |||
| b7d7ca04e1 | |||
| d4d1c2bbab | |||
| effc4ab0f5 | |||
| 79db0c779d | |||
| fe2b6b0283 | |||
| b9a87cd785 | |||
| 98e9ce881f | |||
| e49bdd9c05 | |||
| d0fd2ac674 | |||
| de1c89af21 | |||
| 8b3f569a09 | |||
| 1930400032 | |||
| 43dee863cd | |||
| 55a7682663 | |||
| d05e8d36b4 | |||
| 0018b8e957 | |||
| 59038f65ac | |||
| 5960c11d60 | |||
| 8d065eab1f | |||
| 3b1b6d28d6 | |||
| 0a735886c9 | |||
| c9430f5be4 | |||
| a4e2237fc0 | |||
| 85d0398e62 | |||
| 30a538e85e | |||
| 84a19203c5 | |||
| 99cda2907d | |||
| a38582d158 | |||
| 9556994e14 | |||
| dee2cfa47b | |||
| 463403be23 | |||
| b68eaca55d | |||
| 114c54c9b0 | |||
| 47e0661773 | |||
| 6c48dfe7f6 | |||
| ed0ae7c9e2 | |||
| ea265ae6df | |||
| 860caf440b | |||
| 64040879f5 | |||
| e92073162a | |||
| 67426af3ef | |||
| 0dbdb4a143 | |||
| c9e9c45b37 | |||
| 6bc8a4fb1d | |||
| 20094c5f75 | |||
| 198591dbd7 | |||
| f21d34506d | |||
| ab6ec98160 | |||
| f1e809781a | |||
| 789058b72f | |||
| 4a68b1430d | |||
| 66c4a7e16e | |||
| b929b2dddf | |||
| fb0b703438 | |||
| afb2c7c0ed | |||
| 52dded85ed | |||
| 2004bdebbf | |||
| c9bf8d357e | |||
| 09eee761a5 | |||
| 20f43b2fd4 | |||
| e6dd1c29d8 | |||
| 6e88c7c9ac | |||
| f015e00561 | |||
| e07827cdb5 | |||
| 103d43e7c9 | |||
| 23b7df0ce2 | |||
| a5671e19ce | |||
| f2fbd399fe | |||
| 28b91963a9 | |||
| ce2268370f | |||
| 59462041b1 | |||
| d4d32c8d55 | |||
| e600aeccc7 | |||
| 162d1b561b | |||
| ba824fc921 | |||
| 8c8cf180fa | |||
| 05d11cfff0 | |||
| 3c24b37247 | |||
| 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 |
@@ -2,52 +2,250 @@ name: Builder
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "*" ]
|
||||
pull_request:
|
||||
branches: [ "*" ]
|
||||
# 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
|
||||
bump:
|
||||
type: choice
|
||||
description: Semver to bump
|
||||
default: 'none'
|
||||
options:
|
||||
- none
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
name: Packager
|
||||
name: Build PluginLoader
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 🧰 Checkout
|
||||
- name: Print input
|
||||
run : |
|
||||
echo "release: ${{ github.event.inputs.release }}\n"
|
||||
echo "bump: ${{ github.event.inputs.bump }}\n"
|
||||
|
||||
- name: Checkout 🧰
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 💎 Set up NodeJS 17
|
||||
- name: Set up NodeJS 17 💎
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 17
|
||||
|
||||
- name: 🐍 Set up Python 3.10
|
||||
- name: Set up Python 3.10 🐍
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: ⬇️ Install Python dependencies
|
||||
- name: Install Python dependencies ⬇️
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
[ -f requirements.txt ] && pip install -r requirements.txt
|
||||
|
||||
- name: ⬇️ Install NodeJS dependencies
|
||||
- name: Install NodeJS dependencies ⬇️
|
||||
run: |
|
||||
cd frontend
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
- name: 🛠️ Build
|
||||
run: |
|
||||
pyinstaller --noconfirm --onefile --name "Decky" --add-data ./backend/static:/static ./backend/*.py
|
||||
- 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 ⬆️
|
||||
if: ${{ !env.ACT }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Plugin Loader
|
||||
path: |
|
||||
./dist/*
|
||||
name: PluginLoader
|
||||
path: ./dist/PluginLoader
|
||||
|
||||
- name: Download package artifact locally
|
||||
if: ${{ env.ACT }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: ./dist/PluginLoader
|
||||
|
||||
release:
|
||||
name: Release stable version of 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: Install semver-tool asdf
|
||||
uses: asdf-vm/actions/install@v1
|
||||
with:
|
||||
tool_versions: |
|
||||
semver 3.3.0
|
||||
|
||||
- name: Fetch package artifact ⬇️
|
||||
uses: actions/download-artifact@v3
|
||||
if: ${{ !env.ACT }}
|
||||
with:
|
||||
name: PluginLoader
|
||||
path: dist
|
||||
|
||||
- name: Get latest release
|
||||
uses: rez0n/actions-github-release@main
|
||||
id: latest_release
|
||||
env:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: "SteamDeckHomebrew/decky-loader"
|
||||
type: "nodraft"
|
||||
|
||||
- name: Prepare tag ⚙️
|
||||
id: ready_tag
|
||||
run: |
|
||||
export VERSION=${{ steps.latest_release.outputs.release }}
|
||||
echo "VERS: $VERSION"
|
||||
OUT="notsemver"
|
||||
if [[ "$VERSION" =~ "-pre" ]]; then
|
||||
printf "is prerelease, bumping to release\n"
|
||||
OUT=$(semver bump release "$VERSION")
|
||||
printf "OUT: ${OUT}\n"\
|
||||
printf "bumping by selected type.\n"
|
||||
if [[ "${{github.event.inputs.bump}}" != "none" ]]; then
|
||||
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
|
||||
printf "OUT: ${OUT}\n"
|
||||
else
|
||||
printf "no type selected, defaulting to patch.\n"
|
||||
OUT=$(semver bump patch "$OUT")
|
||||
printf "OUT: ${OUT}\n"
|
||||
fi
|
||||
elif [[ ! "$VERSION" =~ "-pre" ]]; then
|
||||
printf "previous tag is a release, bumping by selected type.\n"
|
||||
if [[ "${{github.event.inputs.bump}}" != "none" ]]; then
|
||||
OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION")
|
||||
printf "OUT: ${OUT}\n"
|
||||
else
|
||||
printf "previous tag is a release, but no bump selected. Defaulting to a patch bump.\n"
|
||||
OUT=$(semver bump patch "$VERSION")
|
||||
printf "OUT: ${OUT}\n"
|
||||
fi
|
||||
fi
|
||||
echo "vOUT: v$OUT"
|
||||
echo ::set-output name=tag_name::v$OUT
|
||||
|
||||
- name: Push tag 📤
|
||||
uses: rickstaa/action-create-tag@v1.3.2
|
||||
if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' && !env.ACT }}
|
||||
with:
|
||||
tag: ${{ steps.ready_tag.outputs.tag_name }}
|
||||
message: Pre-release ${{ steps.ready_tag.outputs.tag_name }}
|
||||
|
||||
- name: Release 📦
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
|
||||
with:
|
||||
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
|
||||
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
|
||||
files: ./dist/PluginLoader
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
prerelease:
|
||||
name: Release the pre-release version of the package
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'prerelease' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout 🧰
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install semver-tool asdf
|
||||
uses: asdf-vm/actions/install@v1
|
||||
with:
|
||||
tool_versions: |
|
||||
semver 3.3.0
|
||||
|
||||
- name: Fetch package artifact ⬇️
|
||||
uses: actions/download-artifact@v3
|
||||
if: ${{ !env.ACT }}
|
||||
with:
|
||||
name: PluginLoader
|
||||
path: dist
|
||||
|
||||
- name: Get latest release
|
||||
uses: rez0n/actions-github-release@main
|
||||
id: latest_release
|
||||
env:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: "SteamDeckHomebrew/decky-loader"
|
||||
type: "nodraft"
|
||||
|
||||
- name: Prepare tag ⚙️
|
||||
id: ready_tag
|
||||
run: |
|
||||
export VERSION=${{ steps.latest_release.outputs.release }}
|
||||
echo "VERS: $VERSION"
|
||||
OUT=""
|
||||
if [[ ! "$VERSION" =~ "-pre" ]]; then
|
||||
printf "pre-release from release, bumping by selected type and prerel\n"
|
||||
if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then
|
||||
OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION")
|
||||
printf "OUT: ${OUT}\n"
|
||||
else
|
||||
printf "type not selected, defaulting to patch\n"
|
||||
OUT=$(semver bump patch "$VERSION")
|
||||
printf "OUT: ${OUT}\n"
|
||||
fi
|
||||
OUT="$OUT-pre"
|
||||
OUT=$(semver bump prerel "$OUT")
|
||||
printf "OUT: ${OUT}\n"
|
||||
elif [[ "$VERSION" =~ "-pre" ]]; then
|
||||
printf "pre-release to pre-release, bumping by selected type and or prerel version\n"
|
||||
if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then
|
||||
OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION")
|
||||
printf "OUT: ${OUT}\n"
|
||||
OUT="$OUT-pre"
|
||||
printf "OUT: ${OUT}\n"
|
||||
printf "bumping prerel\n"
|
||||
OUT=$(semver bump prerel "$OUT")
|
||||
printf "OUT: ${OUT}\n"
|
||||
else
|
||||
printf "type not selected, defaulting to new pre-release only\n"
|
||||
printf "bumping prerel\n"
|
||||
OUT="$VERSION-pre"
|
||||
printf "OUT: ${OUT}\n"
|
||||
OUT=$(semver bump prerel "$OUT")
|
||||
printf "OUT: ${OUT}\n"
|
||||
fi
|
||||
fi
|
||||
printf "vOUT: v${OUT}\n"
|
||||
echo ::set-output name=tag_name::v$OUT
|
||||
|
||||
- name: Push tag 📤
|
||||
uses: rickstaa/action-create-tag@v1.3.2
|
||||
if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' && !env.ACT }}
|
||||
with:
|
||||
tag: ${{ steps.ready_tag.outputs.tag_name }}
|
||||
message: Pre-release ${{ steps.ready_tag.outputs.tag_name }}
|
||||
|
||||
- name: Release 📦
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
|
||||
with:
|
||||
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
|
||||
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
|
||||
files: ./dist/PluginLoader
|
||||
prerelease: true
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -154,5 +154,11 @@ cython_debug/
|
||||
# static files are built
|
||||
backend/static
|
||||
|
||||
# pnpm lockfile
|
||||
frontend/pnpm-lock.yaml
|
||||
# ignore settings.json
|
||||
# prevents leaking login details
|
||||
.vscode/settings.json
|
||||
|
||||
# plugins folder for local launches
|
||||
plugins/*
|
||||
act/.directory
|
||||
act/artifacts/*
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"deckip" : "0.0.0.0",
|
||||
"deckport" : "22",
|
||||
"deckpass" : "ssap",
|
||||
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
|
||||
"deckdir" : "/home/deck"
|
||||
}
|
||||
@@ -2,16 +2,25 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug",
|
||||
"name": "Run (Remote)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "remoterun",
|
||||
"cwd": "",
|
||||
"program": "",
|
||||
},
|
||||
{
|
||||
"name": "Run (Local)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/backend/main.py",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"PLUGIN_PATH": "/home/deck/homebrew/plugins"
|
||||
"PLUGIN_PATH": "${workspaceFolder}/plugins"
|
||||
},
|
||||
"preLaunchTask": "Build frontend"
|
||||
"preLaunchTask": "localrun"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,155 @@
|
||||
{
|
||||
"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": "Build frontend",
|
||||
"label": "localrun",
|
||||
"type": "shell",
|
||||
"command":"cd ${workspaceFolder}/frontend; npm run build",
|
||||
}
|
||||
"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": []
|
||||
},
|
||||
// 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; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/services; 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",
|
||||
"deploy",
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "updateandrun",
|
||||
"detail": "Build, deploy and run",
|
||||
"dependsOrder": "sequence",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"dependsOn": [
|
||||
"buildall",
|
||||
"deploy",
|
||||
"runpydeck"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "allinone",
|
||||
"detail": "Build, install dependencies, deploy and run",
|
||||
"dependsOrder": "sequence",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": false
|
||||
},
|
||||
"dependsOn": [
|
||||
"buildall",
|
||||
"createfolders",
|
||||
"dependencies",
|
||||
"deploy",
|
||||
"runpydeck"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,70 +1,111 @@
|
||||
# TODO
|
||||
- Fix button size/display
|
||||
- Add plugin installation prompts for browser
|
||||
- Fix components not updating unless tab opened first (with new tab hook)
|
||||
- Clean up code
|
||||
<h1 align="center">
|
||||
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/logo.png" alt="Deckbrew logo" width="200"></a>
|
||||
<br>
|
||||
Decky Loader
|
||||
</h1>
|
||||
|
||||
# Plugin Loader [](https://discord.gg/ZU74G2NJzk)
|
||||
<p align="center">
|
||||
<a href="https://github.com/SteamDeckHomebrew/decky-loader/releases"><img src="https://img.shields.io/github/downloads/SteamDeckHomebrew/decky-loader/total" /></a>
|
||||
<a href="https://github.com/SteamDeckHomebrew/decky-loader/stargazers"><img src="https://img.shields.io/github/stars/SteamDeckHomebrew/decky-loader" /></a>
|
||||
<a href="https://github.com/SteamDeckHomebrew/decky-loader/commits/main"><img src="https://img.shields.io/github/last-commit/SteamDeckHomebrew/decky-loader.svg" /></a>
|
||||
<a href="https://github.com/SteamDeckHomebrew/decky-loader/blob/main/LICENSE"><img src="https://img.shields.io/github/license/SteamDeckHomebrew/decky-loader" /></a>
|
||||
<a href="https://discord.gg/ZU74G2NJzk"><img src="https://img.shields.io/discord/960281551428522045?color=%235865F2&label=discord" /></a>
|
||||
<br>
|
||||
<br>
|
||||
<img src="https://media.discordapp.net/attachments/966017112244125756/1012466063893610506/main.jpg" alt="Decky screenshot" width="80%">
|
||||
</p>
|
||||
|
||||

|
||||
## 📖 About
|
||||
|
||||
Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugin Loader, documentation + tools for plugin development and more.
|
||||
Decky Loader is a homebrew plugin launcher for the Steam Deck. It can be used to [stylize your menus](https://github.com/suchmememanyskill/SDH-CssLoader), [change system sounds](https://github.com/EMERALD0874/SDH-AudioLoader), [adjust your screen saturation](https://github.com/libvibrant/vibrantDeck), [change additional system settings](https://github.com/NGnius/PowerTools), and [more](https://beta.deckbrew.xyz/).
|
||||
|
||||
## 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:
|
||||
- For users:
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_release.sh | sh`
|
||||
- For plugin developers:
|
||||
~~- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_nightly.sh | sh`~~
|
||||
Nightly releases are currently broken.
|
||||
7. Done! Reboot back into Gaming mode and enjoy your plugins!
|
||||
For more information about Decky Loader as well as documentation and development tools, please visit [our wiki](https://deckbrew.xyz).
|
||||
|
||||
### Install Plugins
|
||||
- Using the shopping bag button in the top right corner, you can go to the offical ["Plugin Store"](https://plugins.deckbrew.xyz/)
|
||||
- Simply copy the plugin's folder into `~/homebrew/plugins`
|
||||
### 🎨 Features
|
||||
|
||||
### 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`
|
||||
🧹 Clean injecting and loading of multiple plugins.
|
||||
🔒 Stays installed between system updates and reboots.
|
||||
🔗 Allows two-way communication between plugins and the loader.
|
||||
🐍 Supports Python functions run from TypeScript React.
|
||||
🌐 Allows plugins to make fetch calls that bypass CORS completely.
|
||||
|
||||
## Features
|
||||
- Clean injecting and loading of one or more plugins
|
||||
- Persistent. It doesn't need to be reinstalled after every system update
|
||||
- Allows 2-way communication between the plugins and the loader.
|
||||
- Allows plugins to define python functions and run them from javascript.
|
||||
- Allows plugins to make fetch calls, bypassing cors completely.
|
||||
### 🤔 Common Issues
|
||||
|
||||
## 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.
|
||||
- Crankshaft is incompatible with Decky Loader. If you are using Crankshaft, please uninstall it before installing Decky Loader.
|
||||
- Syncthing may use port 8080 on Steam Deck, which Decky Loader needs to function. If you are using Syncthing as a service, please change its port to something else.
|
||||
- If you are using any software that uses port 1337, please change its port to something else or uninstall it.
|
||||
|
||||
## Contribution
|
||||
- For Plugin Loader contributors (in possession of a Steam Deck):
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/react-frontend-plugins/contrib/deck.sh | sh`
|
||||
- For PluginLoader contributors (without a Steam Deck):
|
||||
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/react-frontend-plugins/contrib/pc.sh | sh`
|
||||
- [Here's how to get the Steam Deck UI on your enviroment of choice.](https://youtu.be/1IAbZte8e7E?t=112)
|
||||
- (The video shows Windows usage but unless you're using WSL/cygwin this script is unsupported on Windows.)
|
||||
## 💾 Installation
|
||||
|
||||
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Settings menu.
|
||||
1. Navigate to the System menu and scroll to the System Settings. Toggle "Enable Developer Mode" so it is enabled.
|
||||
1. Navigate to the Developer menu and scroll to Miscellaneous. Toggle "CEF Remote Debugging" so it is enabled.
|
||||
1. Select "Restart Now" to apply your changes.
|
||||
1. Prepare a mouse and keyboard if possible.
|
||||
- Keyboards and mice can be connected to the Steam Deck via USB-C or Bluetooth.
|
||||
- Many Bluetooth keyboard and mouse apps are available for iOS and Android.
|
||||
- The Steam Link app is available on [Windows](https://media.steampowered.com/steamlink/windows/latest/SteamLink.zip), [macOS](https://apps.apple.com/us/app/steam-link/id1246969117), and [Linux](https://flathub.org/apps/details/com.valvesoftware.SteamLink). It works well as a remote desktop substitute.
|
||||
- If you have no other options, use the right trackpad as a mouse and press <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16>+<img src="./docs/images/light/x.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/x.svg#gh-light-mode-only" height=16> to open the on-screen keyboard as needed.
|
||||
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
|
||||
1. Select "Switch to Desktop".
|
||||
1. Open the Konsole app and enter the command `passwd`. You can skip this step if you have already created a sudo password using this command. ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ))
|
||||
1. You will be prompted to create a password. Your text will not be visible. After you press enter, you will need to type your password again to confirm.
|
||||
1. Choose the version of Decky Loader you want to install and paste the following command into the Konsole app.
|
||||
- **Latest Release**
|
||||
Intended for most users. This is the latest stable version of Decky Loader.
|
||||
`curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_release.sh | sh`
|
||||
- **Latest Pre-Release**
|
||||
Intended for plugin developers. Pre-releases are unlikely to be fully stable but contain the latest changes. For more information on plugin development, please consult [the wiki page](https://deckbrew.xyz/en/loader-dev/development).
|
||||
`curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh`
|
||||
1. Open the Return to Gaming Mode shortcut on your desktop.
|
||||
|
||||
### 👋 Uninstallation
|
||||
|
||||
We are sorry to see you go! If you are considering uninstalling because you are having issues, please consider [opening an issue](https://github.com/SteamDeckHomebrew/decky-loader/issues) or [joining our Discord](https://discord.gg/ZU74G2NJzk) so we can help you and other users.
|
||||
|
||||
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
|
||||
1. Select "Switch to Desktop".
|
||||
1. Open the Konsole app and run `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/uninstall.sh | sh`.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
Now that you have Decky Loader installed, you can start using plugins. Each plugin is maintained by a different developer and has its own uses, but most follow a general structure outlined below.
|
||||
|
||||
### 📦 Plugins
|
||||
|
||||
1. Press the <img src="./docs/images/light/qam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/qam.svg#gh-light-mode-only" height=16> button and navigate to the <img src="./docs/images/light/plug.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/plug.svg#gh-light-mode-only" height=16> icon. This is the Decky menu used for interacting with plugins and the loader itself.
|
||||
1. Select the <img src="./docs/images/light/store.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/store.svg#gh-light-mode-only" height=16> icon to open the Plugins Browser. This is where you can find and install plugins.
|
||||
- You can also install from URL in the Settings menu. We do not recommend installing plugins from untrusted sources.
|
||||
1. To install a plugin, select the "Install" button on the plugin you want. You can also select a version from a dropdown menu, but this is not recommended.
|
||||
1. To update, uninstall, and reload plugins, navigate to the Decky menu and select the <img src="./docs/images/light/gear.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/gear.svg#gh-light-mode-only" height=16> icon.
|
||||
- Keep in mind that uninstalling a plugin will only remove its plugin files, not any other files it may have created.
|
||||
|
||||
### 🛠️ Plugin Development
|
||||
|
||||
There is no complete plugin development documentation yet. However a good starting point is the [plugin template repository](https://github.com/SteamDeckHomebrew/decky-plugin-template). Consider [joining our Discord](https://discord.gg/ZU74G2NJzk) if you have any questions.
|
||||
|
||||
### 🤝 Contributing
|
||||
|
||||
Please consult [the wiki page regarding development](https://deckbrew.xyz/en/loader-dev/development) for more information on installing development versions of Decky Loader. You can also install the Steam Deck UI on a Windows or Linux computer for testing by following [this YouTube guide](https://youtu.be/1IAbZte8e7E?t=112).
|
||||
|
||||
1. Clone the repository using the latest commit to main before starting your PR.
|
||||
1. In your clone of the repository, run these commands.
|
||||
```bash
|
||||
pnpm i
|
||||
pnpm run build
|
||||
```
|
||||
1. If you are modifying the UI, these commands will need to be run before deploying the changes to your Steam Deck.
|
||||
1. Use the VS Code tasks or `deck.sh` script to deploy your changes to your Steam Deck to test them.
|
||||
1. You will be testing your changes with the Python script version. You will need to build, deploy, and reload each time.
|
||||
|
||||
⚠️ If you are recieving build errors due to an out of date library, you should run this command inside of your repository.
|
||||
|
||||
To run your development version of Plugin Loader on Deck, run a command like this:
|
||||
```bash
|
||||
ssh deck@steamdeck 'export PLUGIN_PATH=/home/deck/loaderdev/plugins; export CHOWN_PLUGIN_PATH=0; echo 'password' | sudo -SE python3 /home/deck/loaderdev/pluginloader/backend/main.py'
|
||||
pnpm update decky-frontend-lib --latest
|
||||
```
|
||||
|
||||
Or on PC with the Deck UI enabled:
|
||||
```bash
|
||||
export PLUGIN_PATH=/home/user/installdirectory/plugins;
|
||||
export CHOWN_PLUGIN_PATH=0;
|
||||
sudo python3 /home/deck/loaderdev/pluginloader/backend/main.py
|
||||
```
|
||||
Source control and deploying plugins are left to each respective contributor for the cloned repos in order to keep dependencies up to date.
|
||||
|
||||
Source control and deploying plugins are left to each respective contributor for the cloned repos in order to keep depedencies up to date.
|
||||
## 📜 Credits
|
||||
|
||||
## Credit
|
||||
|
||||
The original idea for the concept is based on the work of [marios8543's steamdeck-ui-inject](https://github.com/marios8543/steamdeck-ui-inject) project.
|
||||
The original idea for the plugin loader concept is based on the work of [marios8543's Steam Deck UI Inject project](https://github.com/marios8543/steamdeck-ui-inject).
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
this directory contains artifacts generated by invocations of https://github.com/nektos/act in order to do local testing of binary builds
|
||||
|
||||
how to?
|
||||
run:
|
||||
|
||||
./act/run-act.sh prerelease
|
||||
|
||||
or
|
||||
|
||||
./act/run-act.sh release
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"inputs": {
|
||||
"release": "prerelease",
|
||||
"bump": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"inputs": {
|
||||
"release": "release",
|
||||
"bump": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
type=$1
|
||||
# bump=$2
|
||||
|
||||
oldartifactsdir="old"
|
||||
|
||||
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
|
||||
cd "$parent_path"
|
||||
|
||||
artifactfolders=$(find artifacts/ -maxdepth 1 -mindepth 1 -type d)
|
||||
if [[ ${#artifactfolders[@]} > 0 ]]; then
|
||||
for i in ${artifactfolders[@]}; do
|
||||
foldername=$(dirname $i)
|
||||
subfoldername=$(basename $i)
|
||||
out=$foldername/$oldartifactsdir/$subfoldername-$(date +'%s')
|
||||
if [[ ! "$subfoldername" =~ "$oldartifactsdir" ]]; then
|
||||
mkdir -p $out
|
||||
mv $i $out
|
||||
printf "Moved "${foldername}"/"${subfoldername}" to "${out}" \n"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
if [[ "$type" == "release" ]]; then
|
||||
printf "release!\n"
|
||||
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts
|
||||
elif [[ "$type" == "prerelease" ]]; then
|
||||
printf "prerelease!\n"
|
||||
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts
|
||||
else
|
||||
printf "Release type unspecified/badly specified.\n"
|
||||
printf "Options: 'release' or 'prerelease'\n"
|
||||
fi
|
||||
|
||||
cd act/artifacts
|
||||
|
||||
if [[ -d "1" ]]; then
|
||||
cd "1/artifact"
|
||||
cp "PluginLoader.gz__" "PluginLoader.gz"
|
||||
gzip -d "PluginLoader.gz"
|
||||
chmod +x PluginLoader
|
||||
fi
|
||||
@@ -1,92 +1,176 @@
|
||||
from injector import get_tab
|
||||
from logging import getLogger
|
||||
from os import path, rename
|
||||
from shutil import rmtree
|
||||
# Full imports
|
||||
import json
|
||||
|
||||
# Partial imports
|
||||
from aiohttp import ClientSession, web
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from asyncio import get_event_loop, sleep
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from asyncio import get_event_loop
|
||||
from time import time
|
||||
from hashlib import sha256
|
||||
from subprocess import Popen
|
||||
from io import BytesIO
|
||||
from logging import getLogger
|
||||
from os import R_OK, W_OK, path, rename, listdir, access, mkdir
|
||||
from shutil import rmtree
|
||||
from subprocess import call
|
||||
from time import time
|
||||
from zipfile import ZipFile
|
||||
|
||||
# Local modules
|
||||
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
|
||||
from injector import get_tab, inject_to_tab
|
||||
|
||||
logger = getLogger("Browser")
|
||||
|
||||
class PluginInstallContext:
|
||||
def __init__(self, gh_url, version, hash) -> None:
|
||||
self.gh_url = gh_url
|
||||
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, store_url) -> None:
|
||||
self.log = getLogger("browser")
|
||||
def __init__(self, plugin_path, plugins, loader) -> None:
|
||||
self.plugin_path = plugin_path
|
||||
self.store_url = store_url
|
||||
self.plugins = plugins
|
||||
self.loader = loader
|
||||
self.install_requests = {}
|
||||
|
||||
server_instance.add_routes([
|
||||
web.post("/browser/install_plugin", self.install_plugin),
|
||||
web.get("/browser/redirect", self.redirect_to_store)
|
||||
])
|
||||
|
||||
def _unzip_to_plugin_dir(self, zip, name, hash):
|
||||
zip_hash = sha256(zip.getbuffer()).hexdigest()
|
||||
if zip_hash != hash:
|
||||
if hash and (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])
|
||||
plugin_dir = self.find_plugin_folder(name)
|
||||
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), plugin_dir])
|
||||
code_chmod = call(["chmod", "-R", "555", plugin_dir])
|
||||
if code_chown != 0 or code_chmod != 0:
|
||||
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
|
||||
return False
|
||||
return True
|
||||
|
||||
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 _download_remote_binaries_for_plugin_with_name(self, pluginBasePath):
|
||||
rv = False
|
||||
try:
|
||||
packageJsonPath = path.join(pluginBasePath, 'package.json')
|
||||
pluginBinPath = path.join(pluginBasePath, 'bin')
|
||||
|
||||
async def request_plugin_install(self, artifact, version, hash):
|
||||
if access(packageJsonPath, R_OK):
|
||||
with open(packageJsonPath, 'r') as f:
|
||||
packageJson = json.load(f)
|
||||
if len(packageJson["remote_binary"]) > 0:
|
||||
# create bin directory if needed.
|
||||
rc=call(["chmod", "-R", "777", pluginBasePath])
|
||||
if access(pluginBasePath, W_OK):
|
||||
|
||||
if not path.exists(pluginBinPath):
|
||||
mkdir(pluginBinPath)
|
||||
|
||||
if not access(pluginBinPath, W_OK):
|
||||
rc=call(["chmod", "-R", "777", pluginBinPath])
|
||||
|
||||
rv = True
|
||||
for remoteBinary in packageJson["remote_binary"]:
|
||||
# Required Fields. If any Remote Binary is missing these fail the install.
|
||||
binName = remoteBinary["name"]
|
||||
binURL = remoteBinary["url"]
|
||||
binHash = remoteBinary["sha256hash"]
|
||||
if not await download_remote_binary_to_path(binURL, binHash, path.join(pluginBinPath, binName)):
|
||||
rv = False
|
||||
raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}")
|
||||
|
||||
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), self.plugin_path])
|
||||
rc=call(["chmod", "-R", "555", pluginBasePath])
|
||||
else:
|
||||
rv = True
|
||||
logger.debug(f"No Remote Binaries to Download")
|
||||
|
||||
except Exception as e:
|
||||
rv = False
|
||||
logger.debug(str(e))
|
||||
|
||||
return rv
|
||||
|
||||
def find_plugin_folder(self, name):
|
||||
for folder in listdir(self.plugin_path):
|
||||
try:
|
||||
with open(path.join(self.plugin_path, folder, 'plugin.json'), 'r') as f:
|
||||
plugin = json.load(f)
|
||||
|
||||
if plugin['name'] == name:
|
||||
return path.join(self.plugin_path, folder)
|
||||
except:
|
||||
logger.debug(f"skipping {folder}")
|
||||
|
||||
async def uninstall_plugin(self, name):
|
||||
if self.loader.watcher:
|
||||
self.loader.watcher.disabled = True
|
||||
tab = await get_tab("SP")
|
||||
try:
|
||||
logger.info("uninstalling " + name)
|
||||
logger.info(" at dir " + self.find_plugin_folder(name))
|
||||
logger.debug("unloading %s" % str(name))
|
||||
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
|
||||
if self.plugins[name]:
|
||||
self.plugins[name].stop()
|
||||
del self.plugins[name]
|
||||
logger.debug("removing files %s" % str(name))
|
||||
rmtree(self.find_plugin_folder(name))
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Plugin {name} not installed, skipping uninstallation")
|
||||
except Exception as e:
|
||||
logger.error(f"Plugin {name} in {self.find_plugin_folder(name)} was not uninstalled")
|
||||
logger.error(f"Error at %s", exc_info=e)
|
||||
if self.loader.watcher:
|
||||
self.loader.watcher.disabled = False
|
||||
|
||||
async def _install(self, artifact, name, version, hash):
|
||||
if self.loader.watcher:
|
||||
self.loader.watcher.disabled = True
|
||||
try:
|
||||
await self.uninstall_plugin(name)
|
||||
except:
|
||||
logger.error(f"Plugin {name} not installed, skipping uninstallation")
|
||||
logger.info(f"Installing {name} (Version: {version})")
|
||||
async with ClientSession() as client:
|
||||
logger.debug(f"Fetching {artifact}")
|
||||
res = await client.get(artifact, ssl=get_ssl_context())
|
||||
if res.status == 200:
|
||||
logger.debug("Got 200. Reading...")
|
||||
data = await res.read()
|
||||
logger.debug(f"Read {len(data)} bytes")
|
||||
res_zip = BytesIO(data)
|
||||
logger.debug("Unzipping...")
|
||||
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
|
||||
if ret:
|
||||
plugin_dir = self.find_plugin_folder(name)
|
||||
ret = await self._download_remote_binaries_for_plugin_with_name(plugin_dir)
|
||||
if ret:
|
||||
logger.info(f"Installed {name} (Version: {version})")
|
||||
if name in self.loader.plugins:
|
||||
self.loader.plugins[name].stop()
|
||||
self.loader.plugins.pop(name, None)
|
||||
await sleep(1)
|
||||
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
|
||||
# await inject_to_tab("SP", "window.syncDeckyPlugins()")
|
||||
else:
|
||||
logger.fatal(f"Failed Downloading Remote Binaries")
|
||||
else:
|
||||
self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
|
||||
if self.loader.watcher:
|
||||
self.loader.watcher.disabled = False
|
||||
else:
|
||||
logger.fatal(f"Could not fetch from URL. {await res.text()}")
|
||||
|
||||
async def request_plugin_install(self, artifact, name, version, hash):
|
||||
request_id = str(time())
|
||||
self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
|
||||
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('{artifact}', '{version}', '{request_id}')")
|
||||
|
||||
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.gh_url, request.version, request.hash)
|
||||
await self._install(request.artifact, request.name, request.version, request.hash)
|
||||
|
||||
def cancel_plugin_install(self, request_id):
|
||||
self.install_requests.pop(request_id)
|
||||
self.install_requests.pop(request_id)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
import uuid
|
||||
import os
|
||||
from subprocess import check_output
|
||||
from time import sleep
|
||||
from hashlib import sha256
|
||||
from io import BytesIO
|
||||
|
||||
import certifi
|
||||
from aiohttp.web import Response, middleware
|
||||
from aiohttp import ClientSession
|
||||
|
||||
REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
|
||||
|
||||
# global vars
|
||||
csrf_token = str(uuid.uuid4())
|
||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
user = None
|
||||
group = None
|
||||
|
||||
assets_regex = re.compile("^/plugins/.*/assets/.*")
|
||||
frontend_regex = re.compile("^/frontend/.*")
|
||||
|
||||
def get_ssl_context():
|
||||
return ssl_ctx
|
||||
|
||||
def get_csrf_token():
|
||||
return csrf_token
|
||||
|
||||
@middleware
|
||||
async def csrf_middleware(request, handler):
|
||||
if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)):
|
||||
return await handler(request)
|
||||
return Response(text='Forbidden', status='403')
|
||||
|
||||
# Get the user by checking for the first logged in user. As this is run
|
||||
# by systemd at startup the process is likely to start before the user
|
||||
# logs in, so we will wait here until they are available. Note that
|
||||
# other methods such as getenv wont work as there was no $SUDO_USER to
|
||||
# start the systemd service.
|
||||
def set_user():
|
||||
global user
|
||||
cmd = "who | awk '{print $1}' | sort | head -1"
|
||||
while user == None:
|
||||
name = check_output(cmd, shell=True).decode().strip()
|
||||
if name not in [None, '']:
|
||||
user = name
|
||||
sleep(0.1)
|
||||
|
||||
# Get the global user. get_user must be called first.
|
||||
def get_user() -> str:
|
||||
global user
|
||||
if user == None:
|
||||
raise ValueError("helpers.get_user method called before user variable was set. Run helpers.set_user first.")
|
||||
return user
|
||||
|
||||
# Set the global user group. get_user must be called first
|
||||
def set_user_group() -> str:
|
||||
global group
|
||||
global user
|
||||
if user == None:
|
||||
raise ValueError("helpers.set_user_dir method called before user variable was set. Run helpers.set_user first.")
|
||||
if group == None:
|
||||
group = check_output(["id", "-g", "-n", user]).decode().strip()
|
||||
|
||||
# Get the group of the global user. set_user_group must be called first.
|
||||
def get_user_group() -> str:
|
||||
global group
|
||||
if group == None:
|
||||
raise ValueError("helpers.get_user_group method called before group variable was set. Run helpers.set_user_group first.")
|
||||
return group
|
||||
|
||||
# Get the default home path unless a user is specified
|
||||
def get_home_path(username = None) -> str:
|
||||
if username == None:
|
||||
raise ValueError("Username not defined, no home path can be found.")
|
||||
else:
|
||||
return str("/home/"+username)
|
||||
|
||||
# Get the default homebrew path unless a user is specified
|
||||
def get_homebrew_path(home_path = None) -> str:
|
||||
if home_path == None:
|
||||
raise ValueError("Home path not defined, homebrew dir cannot be determined.")
|
||||
else:
|
||||
return str(home_path+"/homebrew")
|
||||
# return str(home_path+"/homebrew")
|
||||
|
||||
# Download Remote Binaries to local Plugin
|
||||
async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
||||
rv = False
|
||||
try:
|
||||
if os.access(os.path.dirname(path), os.W_OK):
|
||||
async with ClientSession() as client:
|
||||
res = await client.get(url, ssl=get_ssl_context())
|
||||
if res.status == 200:
|
||||
data = BytesIO(await res.read())
|
||||
remoteHash = sha256(data.getbuffer()).hexdigest()
|
||||
if binHash == remoteHash:
|
||||
data.seek(0)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data.getbuffer())
|
||||
rv = True
|
||||
else:
|
||||
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
|
||||
else:
|
||||
rv = False
|
||||
except:
|
||||
rv = False
|
||||
|
||||
return rv
|
||||
|
||||
async def is_systemd_unit_active(unit_name: str) -> bool:
|
||||
res = subprocess.run(["systemctl", "is-active", unit_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return res.returncode == 0
|
||||
|
||||
async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
|
||||
cmd = ["systemctl", "stop", unit_name]
|
||||
|
||||
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
|
||||
async def start_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
|
||||
cmd = ["systemctl", "start", unit_name]
|
||||
|
||||
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
@@ -5,6 +5,7 @@ from logging import debug, getLogger
|
||||
from traceback import format_exc
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
|
||||
BASE_ADDRESS = "http://localhost:8080"
|
||||
|
||||
@@ -33,8 +34,10 @@ class Tab:
|
||||
return (await self.websocket.receive_json()) if receive else None
|
||||
raise RuntimeError("Websocket not opened")
|
||||
|
||||
async def evaluate_js(self, js, run_async=False):
|
||||
await self.open_websocket()
|
||||
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
|
||||
if manage_socket:
|
||||
await self.open_websocket()
|
||||
|
||||
res = await self._send_devtools_cmd({
|
||||
"id": 1,
|
||||
"method": "Runtime.evaluate",
|
||||
@@ -43,9 +46,10 @@ class Tab:
|
||||
"userGesture": True,
|
||||
"awaitPromise": run_async
|
||||
}
|
||||
})
|
||||
}, get_result)
|
||||
|
||||
await self.client.close()
|
||||
if manage_socket:
|
||||
await self.client.close()
|
||||
return res
|
||||
|
||||
async def get_steam_resource(self, url):
|
||||
@@ -62,17 +66,19 @@ async def get_tabs():
|
||||
while True:
|
||||
try:
|
||||
res = await web.get(f"{BASE_ADDRESS}/json")
|
||||
break
|
||||
except:
|
||||
except ClientConnectorError:
|
||||
logger.debug("ClientConnectorError excepted.")
|
||||
logger.debug("Steam isn't available yet. Wait for a moment...")
|
||||
logger.debug(format_exc())
|
||||
logger.error(format_exc())
|
||||
await sleep(5)
|
||||
else:
|
||||
break
|
||||
|
||||
if res.status == 200:
|
||||
r = await res.json()
|
||||
return [Tab(i) for i in r]
|
||||
else:
|
||||
raise Exception(f"/json did not return 200. {await r.text()}")
|
||||
raise Exception(f"/json did not return 200. {await res.text()}")
|
||||
|
||||
async def get_tab(tab_name):
|
||||
tabs = await get_tabs()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class PluginEventTarget extends EventTarget { }
|
||||
method_call_ev_target = new PluginEventTarget();
|
||||
|
||||
window.addEventListener("message", function(evt) {
|
||||
let ev = new Event(evt.data.call_id);
|
||||
ev.data = evt.data.result;
|
||||
method_call_ev_target.dispatchEvent(ev);
|
||||
}, false);
|
||||
|
||||
async function call_server_method(method_name, arg_object={}) {
|
||||
const token = await fetch("http://127.0.0.1:1337/auth/token").then(r => r.text());
|
||||
const response = await fetch(`http://127.0.0.1:1337/methods/${method_name}`, {
|
||||
method: 'POST',
|
||||
credentials: "include",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authentication: token
|
||||
},
|
||||
body: JSON.stringify(arg_object),
|
||||
});
|
||||
|
||||
const dta = await response.json();
|
||||
if (!dta.success) throw dta.result;
|
||||
return dta.result;
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/2117523 Thanks!
|
||||
function uuidv4() {
|
||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetch_nocors(url, request={}) {
|
||||
let args = { method: "POST", headers: {}, body: "" };
|
||||
request = {...args, ...request};
|
||||
request.url = url;
|
||||
request.data = request.body;
|
||||
delete request.body; //maintain api-compatibility with fetch
|
||||
return await call_server_method("http_request", request);
|
||||
}
|
||||
|
||||
async function call_plugin_method(method_name, arg_object={}) {
|
||||
if (plugin_name == undefined)
|
||||
throw new Error("Plugin methods can only be called from inside plugins (duh)");
|
||||
const token = await fetch("http://127.0.0.1:1337/auth/token").then(r => r.text());
|
||||
const response = await fetch(`http://127.0.0.1:1337/plugins/${plugin_name}/methods/${method_name}`, {
|
||||
method: 'POST',
|
||||
credentials: "include",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authentication: token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args: arg_object,
|
||||
}),
|
||||
});
|
||||
|
||||
const dta = await response.json();
|
||||
if (!dta.success) throw dta.result;
|
||||
return dta.result;
|
||||
}
|
||||
|
||||
async function execute_in_tab(tab, run_async, code) {
|
||||
return await call_server_method("execute_in_tab", {
|
||||
'tab': tab,
|
||||
'run_async': run_async,
|
||||
'code': code
|
||||
});
|
||||
}
|
||||
|
||||
async function inject_css_into_tab(tab, style) {
|
||||
return await call_server_method("inject_css_into_tab", {
|
||||
'tab': tab,
|
||||
'style': style
|
||||
});
|
||||
}
|
||||
|
||||
async function remove_css_from_tab(tab, css_id) {
|
||||
return await call_server_method("remove_css_from_tab", {
|
||||
'tab': tab,
|
||||
'css_id': css_id
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from asyncio import Queue
|
||||
from asyncio import Queue, sleep
|
||||
from json.decoder import JSONDecodeError
|
||||
from logging import getLogger
|
||||
from os import listdir, path
|
||||
@@ -25,8 +25,11 @@ class FileChangeHandler(RegexMatchingEventHandler):
|
||||
self.logger = getLogger("file-watcher")
|
||||
self.plugin_path = plugin_path
|
||||
self.queue = queue
|
||||
self.disabled = True
|
||||
|
||||
def maybe_reload(self, src_path):
|
||||
if self.disabled:
|
||||
return
|
||||
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))
|
||||
@@ -66,19 +69,24 @@ class Loader:
|
||||
self.plugin_path = plugin_path
|
||||
self.logger.info(f"plugin_path: {self.plugin_path}")
|
||||
self.plugins = {}
|
||||
self.watcher = None
|
||||
self.live_reload = live_reload
|
||||
|
||||
if live_reload:
|
||||
self.reload_queue = Queue()
|
||||
self.observer = Observer()
|
||||
self.observer.schedule(FileChangeHandler(self.reload_queue, plugin_path), self.plugin_path, recursive=True)
|
||||
self.watcher = FileChangeHandler(self.reload_queue, plugin_path)
|
||||
self.observer.schedule(self.watcher, self.plugin_path, recursive=True)
|
||||
self.observer.start()
|
||||
self.loop.create_task(self.handle_reloads())
|
||||
self.loop.create_task(self.enable_reload_wait())
|
||||
|
||||
server_instance.add_routes([
|
||||
web.get("/frontend/{path:.*}", self.handle_frontend_assets),
|
||||
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),
|
||||
web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_plugin_frontend_assets),
|
||||
|
||||
# The following is legacy plugin code.
|
||||
web.get("/plugins/load_main/{name}", self.load_plugin_main_view),
|
||||
@@ -86,15 +94,26 @@ class Loader:
|
||||
web.get("/steam_resource/{path:.+}", self.get_steam_resource)
|
||||
])
|
||||
|
||||
async def enable_reload_wait(self):
|
||||
if self.live_reload:
|
||||
await sleep(10)
|
||||
self.logger.info("Hot reload enabled")
|
||||
self.watcher.disabled = False
|
||||
|
||||
async def handle_frontend_assets(self, request):
|
||||
file = path.join(path.dirname(__file__), "static", request.match_info["path"])
|
||||
|
||||
return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
|
||||
|
||||
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])
|
||||
return web.json_response([{"name": str(i) if not i.legacy else "$LEGACY_"+str(i), "version": i.version} for i in plugins])
|
||||
|
||||
def handle_frontend_assets(self, request):
|
||||
def handle_plugin_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)
|
||||
return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
|
||||
|
||||
def handle_frontend_bundle(self, request):
|
||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||
@@ -116,13 +135,13 @@ class Loader:
|
||||
self.logger.info(f"Plugin {plugin.name} is passive")
|
||||
self.plugins[plugin.name] = plugin.start()
|
||||
self.logger.info(f"Loaded {plugin.name}")
|
||||
self.loop.create_task(self.dispatch_plugin(plugin.name))
|
||||
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Could not load {file}. {e}")
|
||||
print_exc()
|
||||
|
||||
async def dispatch_plugin(self, name):
|
||||
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}')")
|
||||
async def dispatch_plugin(self, name, version):
|
||||
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}', '{version}')")
|
||||
|
||||
def import_plugins(self):
|
||||
self.logger.info(f"import plugins from {self.plugin_path}")
|
||||
@@ -168,8 +187,8 @@ class Loader:
|
||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), 'r') as template:
|
||||
template_data = template.read()
|
||||
ret = f"""
|
||||
<script src="/static/legacy-library.js"></script>
|
||||
<script>const plugin_name = '{plugin.name}' </script>
|
||||
<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}
|
||||
"""
|
||||
|
||||
@@ -1,68 +1,108 @@
|
||||
# Change PyInstaller files permissions
|
||||
import sys
|
||||
from subprocess import call
|
||||
if hasattr(sys, '_MEIPASS'):
|
||||
call(['chmod', '-R', '755', sys._MEIPASS])
|
||||
# Full imports
|
||||
from asyncio import get_event_loop, sleep
|
||||
from json import dumps, loads
|
||||
from logging import DEBUG, INFO, basicConfig, getLogger
|
||||
from os import getenv
|
||||
from os import getenv, chmod
|
||||
from traceback import format_exc
|
||||
|
||||
import aiohttp_cors
|
||||
# Partial imports
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp.web import Application, Response, get, run_app, static
|
||||
from aiohttp_jinja2 import setup as jinja_setup
|
||||
|
||||
# local modules
|
||||
from browser import PluginBrowser
|
||||
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
||||
get_home_path, get_homebrew_path, get_user,
|
||||
get_user_group, set_user, set_user_group,
|
||||
stop_systemd_unit)
|
||||
from injector import inject_to_tab, tab_has_global_var
|
||||
from loader import Loader
|
||||
from settings import SettingsManager
|
||||
from updater import Updater
|
||||
from utilities import Utilities
|
||||
|
||||
# Ensure USER and GROUP vars are set first.
|
||||
# TODO: This isn't the best way to do this but supports the current
|
||||
# implementation. All the config load and environment setting eventually be
|
||||
# moved into init or a config/loader method.
|
||||
set_user()
|
||||
set_user_group()
|
||||
USER = get_user()
|
||||
GROUP = get_user_group()
|
||||
HOME_PATH = "/home/"+USER
|
||||
HOMEBREW_PATH = HOME_PATH+"/homebrew"
|
||||
CONFIG = {
|
||||
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
|
||||
"plugin_path": getenv("PLUGIN_PATH", HOMEBREW_PATH+"/plugins"),
|
||||
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
|
||||
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
|
||||
"server_port": int(getenv("SERVER_PORT", "1337")),
|
||||
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
|
||||
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
|
||||
"store_url": getenv("STORE_URL", "https://beta.deckbrew.xyz")
|
||||
"log_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 Popen
|
||||
|
||||
import aiohttp_cors
|
||||
from aiohttp.web import Application, run_app, static
|
||||
from aiohttp_jinja2 import setup as jinja_setup
|
||||
|
||||
from browser import PluginBrowser
|
||||
from injector import inject_to_tab, tab_has_global_var
|
||||
from loader import Loader
|
||||
from utilities import Utilities
|
||||
basicConfig(
|
||||
level=CONFIG["log_level"],
|
||||
format="[%(module)s][%(levelname)s]: %(message)s"
|
||||
)
|
||||
|
||||
logger = getLogger("Main")
|
||||
|
||||
async def chown_plugin_dir(_):
|
||||
Popen(["chown", "-R", "deck:deck", CONFIG["plugin_path"]])
|
||||
Popen(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
||||
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
|
||||
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
||||
if code_chown != 0 or code_chmod != 0:
|
||||
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self) -> None:
|
||||
self.loop = get_event_loop()
|
||||
self.web_app = Application()
|
||||
self.web_app.middlewares.append(csrf_middleware)
|
||||
self.cors = aiohttp_cors.setup(self.web_app, defaults={
|
||||
"https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
|
||||
allow_headers="*")
|
||||
"https://steamloopback.host": aiohttp_cors.ResourceOptions(
|
||||
expose_headers="*",
|
||||
allow_headers="*",
|
||||
allow_credentials=True
|
||||
)
|
||||
})
|
||||
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.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.plugin_loader.plugins, self.plugin_loader)
|
||||
self.settings = SettingsManager("loader", path.join(HOMEBREW_PATH, "settings"))
|
||||
self.utilities = Utilities(self)
|
||||
self.updater = Updater(self)
|
||||
|
||||
jinja_setup(self.web_app)
|
||||
self.web_app.on_startup.append(self.inject_javascript)
|
||||
if CONFIG["chown_plugin_path"] == True:
|
||||
self.web_app.on_startup.append(chown_plugin_dir)
|
||||
self.loop.create_task(self.loader_reinjector())
|
||||
self.loop.create_task(self.load_plugins())
|
||||
if not self.settings.getSetting("cef_forward", False):
|
||||
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
||||
self.loop.set_exception_handler(self.exception_handler)
|
||||
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
|
||||
|
||||
for route in list(self.web_app.router.routes()):
|
||||
self.cors.add(route)
|
||||
self.cors.add(route)
|
||||
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
|
||||
self.web_app.add_routes([static("/legacy", path.join(path.dirname(__file__), 'legacy'))])
|
||||
|
||||
def exception_handler(self, loop, context):
|
||||
if context["message"] == "Unclosed connection":
|
||||
return
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
async def get_auth_token(self, request):
|
||||
return Response(text=get_csrf_token())
|
||||
|
||||
async def wait_for_server(self):
|
||||
async with ClientSession() as web:
|
||||
while True:
|
||||
@@ -75,20 +115,22 @@ class PluginManager:
|
||||
async def load_plugins(self):
|
||||
await self.wait_for_server()
|
||||
self.plugin_loader.import_plugins()
|
||||
#await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
||||
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
||||
|
||||
async def loader_reinjector(self):
|
||||
await sleep(2)
|
||||
await self.inject_javascript()
|
||||
while True:
|
||||
await sleep(1)
|
||||
if not await tab_has_global_var("SP", "DeckyPluginLoader"):
|
||||
await sleep(5)
|
||||
if not await tab_has_global_var("SP", "deckyHasLoaded"):
|
||||
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)
|
||||
await inject_to_tab("SP", "try{if (window.deckyHasLoaded) location.reload();window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", True)
|
||||
except:
|
||||
logger.info("Failed to inject JavaScript into tab")
|
||||
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import multiprocessing
|
||||
from asyncio import (Lock, get_event_loop, new_event_loop,
|
||||
open_unix_connection, set_event_loop, sleep,
|
||||
start_unix_server)
|
||||
start_unix_server, IncompleteReadError, LimitOverrunError)
|
||||
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 logging import getLogger
|
||||
from traceback import format_exc
|
||||
from os import path, setgid, setuid
|
||||
from signal import SIGINT, signal
|
||||
from sys import exit
|
||||
from time import time
|
||||
|
||||
multiprocessing.set_start_method("fork")
|
||||
|
||||
BUFFER_LIMIT = 2 ** 20 # 1 MiB
|
||||
|
||||
class PluginWrapper:
|
||||
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
||||
self.file = file
|
||||
@@ -21,7 +25,13 @@ class PluginWrapper:
|
||||
self.socket_addr = f"/tmp/plugin_socket_{time()}"
|
||||
self.method_call_lock = Lock()
|
||||
|
||||
self.version = None
|
||||
|
||||
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
|
||||
if path.isfile(path.join(plugin_path, plugin_directory, "package.json")):
|
||||
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r"))
|
||||
self.version = package_json["version"]
|
||||
|
||||
|
||||
self.legacy = False
|
||||
self.main_view_html = json["main_view_html"] if "main_view_html" in json else ""
|
||||
@@ -32,34 +42,53 @@ class PluginWrapper:
|
||||
self.author = json["author"]
|
||||
self.flags = json["flags"]
|
||||
|
||||
self.log = getLogger("plugin")
|
||||
|
||||
self.passive = not path.isfile(self.file)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def _init(self):
|
||||
signal(SIGINT, lambda s, f: exit(0))
|
||||
try:
|
||||
signal(SIGINT, lambda s, f: exit(0))
|
||||
|
||||
set_event_loop(new_event_loop())
|
||||
if self.passive:
|
||||
return
|
||||
setuid(0 if "root" in self.flags else 1000)
|
||||
spec = spec_from_file_location("_", self.file)
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.Plugin = module.Plugin
|
||||
set_event_loop(new_event_loop())
|
||||
if self.passive:
|
||||
return
|
||||
setgid(0 if "root" in self.flags else 1000)
|
||||
setuid(0 if "root" in self.flags else 1000)
|
||||
spec = spec_from_file_location("_", self.file)
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.Plugin = module.Plugin
|
||||
|
||||
if hasattr(self.Plugin, "_main"):
|
||||
get_event_loop().create_task(self.Plugin._main(self.Plugin))
|
||||
get_event_loop().create_task(self._setup_socket())
|
||||
get_event_loop().run_forever()
|
||||
if hasattr(self.Plugin, "_main"):
|
||||
get_event_loop().create_task(self.Plugin._main(self.Plugin))
|
||||
get_event_loop().create_task(self._setup_socket())
|
||||
get_event_loop().run_forever()
|
||||
except:
|
||||
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
|
||||
exit(0)
|
||||
|
||||
async def _setup_socket(self):
|
||||
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr)
|
||||
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
|
||||
|
||||
async def _listen_for_method_call(self, reader, writer):
|
||||
while True:
|
||||
data = loads((await reader.readline()).decode("utf-8"))
|
||||
line = bytearray()
|
||||
while True:
|
||||
try:
|
||||
line.extend(await reader.readuntil())
|
||||
except LimitOverrunError:
|
||||
line.extend(await reader.read(reader._limit))
|
||||
continue
|
||||
except IncompleteReadError as err:
|
||||
line.extend(err.partial)
|
||||
break
|
||||
else:
|
||||
break
|
||||
data = loads(line.decode("utf-8"))
|
||||
if "stop" in data:
|
||||
get_event_loop().stop()
|
||||
while get_event_loop().is_running():
|
||||
@@ -78,12 +107,17 @@ class PluginWrapper:
|
||||
|
||||
async def _open_socket_if_not_exists(self):
|
||||
if not self.reader:
|
||||
while True:
|
||||
retries = 0
|
||||
while retries < 10:
|
||||
try:
|
||||
self.reader, self.writer = await open_unix_connection(self.socket_addr)
|
||||
break
|
||||
self.reader, self.writer = await open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT)
|
||||
return True
|
||||
except:
|
||||
await sleep(0)
|
||||
await sleep(2)
|
||||
retries += 1
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def start(self):
|
||||
if self.passive:
|
||||
@@ -95,21 +129,33 @@ class PluginWrapper:
|
||||
if self.passive:
|
||||
return
|
||||
async def _(self):
|
||||
await self._open_socket_if_not_exists()
|
||||
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
|
||||
await self.writer.drain()
|
||||
self.writer.close()
|
||||
if await self._open_socket_if_not_exists():
|
||||
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
|
||||
await self.writer.drain()
|
||||
self.writer.close()
|
||||
get_event_loop().create_task(_(self))
|
||||
|
||||
async def execute_method(self, method_name, kwargs):
|
||||
if self.passive:
|
||||
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
|
||||
async with self.method_call_lock:
|
||||
await self._open_socket_if_not_exists()
|
||||
self.writer.write(
|
||||
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
|
||||
await self.writer.drain()
|
||||
res = loads((await self.reader.readline()).decode("utf-8"))
|
||||
if not res["success"]:
|
||||
raise Exception(res["res"])
|
||||
return res["res"]
|
||||
if await self._open_socket_if_not_exists():
|
||||
self.writer.write(
|
||||
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
|
||||
await self.writer.drain()
|
||||
line = bytearray()
|
||||
while True:
|
||||
try:
|
||||
line.extend(await self.reader.readuntil())
|
||||
except LimitOverrunError:
|
||||
line.extend(await self.reader.read(self.reader._limit))
|
||||
continue
|
||||
except IncompleteReadError as err:
|
||||
line.extend(err.partial)
|
||||
break
|
||||
else:
|
||||
break
|
||||
res = loads(line.decode("utf-8"))
|
||||
if not res["success"]:
|
||||
raise Exception(res["res"])
|
||||
return res["res"]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import imp
|
||||
from json import dump, load
|
||||
from os import mkdir, path
|
||||
|
||||
from helpers import get_home_path, get_homebrew_path, get_user, set_user
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
def __init__(self, name, settings_directory = None) -> None:
|
||||
set_user()
|
||||
USER = get_user()
|
||||
if settings_directory == None:
|
||||
settings_directory = get_homebrew_path(get_home_path(USER))
|
||||
self.path = path.join(settings_directory, name + ".json")
|
||||
|
||||
if not path.exists(settings_directory):
|
||||
mkdir(settings_directory)
|
||||
|
||||
self.settings = {}
|
||||
|
||||
try:
|
||||
open(self.path, "x")
|
||||
except FileExistsError as e:
|
||||
self.read()
|
||||
pass
|
||||
|
||||
def read(self):
|
||||
try:
|
||||
with open(self.path, "r") as file:
|
||||
self.settings = load(file)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
|
||||
def commit(self):
|
||||
with open(self.path, "w+") as file:
|
||||
dump(self.settings, file, indent=4)
|
||||
|
||||
def getSetting(self, key, default):
|
||||
return self.settings.get(key, default)
|
||||
|
||||
def setSetting(self, key, value):
|
||||
self.settings[key] = value
|
||||
self.commit()
|
||||
@@ -0,0 +1,164 @@
|
||||
import uuid
|
||||
from asyncio import sleep
|
||||
from ensurepip import version
|
||||
from json.decoder import JSONDecodeError
|
||||
from logging import getLogger
|
||||
from os import getcwd, path, remove
|
||||
from subprocess import call
|
||||
|
||||
from aiohttp import ClientSession, web
|
||||
|
||||
import helpers
|
||||
from injector import get_tab, inject_to_tab
|
||||
from settings import SettingsManager
|
||||
|
||||
logger = getLogger("Updater")
|
||||
|
||||
class Updater:
|
||||
def __init__(self, context) -> None:
|
||||
self.context = context
|
||||
self.settings = self.context.settings
|
||||
# Exposes updater methods to frontend
|
||||
self.updater_methods = {
|
||||
"get_branch": self._get_branch,
|
||||
"get_version": self.get_version,
|
||||
"do_update": self.do_update,
|
||||
"do_restart": self.do_restart,
|
||||
"check_for_updates": self.check_for_updates
|
||||
}
|
||||
self.remoteVer = None
|
||||
self.allRemoteVers = None
|
||||
try:
|
||||
logger.info(getcwd())
|
||||
with open(path.join(getcwd(), ".loader.version"), 'r') as version_file:
|
||||
self.localVer = version_file.readline().replace("\n", "")
|
||||
except:
|
||||
self.localVer = False
|
||||
|
||||
try:
|
||||
self.currentBranch = self.get_branch(self.context.settings)
|
||||
except:
|
||||
self.currentBranch = 0
|
||||
logger.error("Current branch could not be determined, defaulting to \"Stable\"")
|
||||
|
||||
if context:
|
||||
context.web_app.add_routes([
|
||||
web.post("/updater/{method_name}", self._handle_server_method_call)
|
||||
])
|
||||
context.loop.create_task(self.version_reloader())
|
||||
|
||||
async def _handle_server_method_call(self, request):
|
||||
method_name = request.match_info["method_name"]
|
||||
try:
|
||||
args = await request.json()
|
||||
except JSONDecodeError:
|
||||
args = {}
|
||||
res = {}
|
||||
try:
|
||||
r = await self.updater_methods[method_name](**args)
|
||||
res["result"] = r
|
||||
res["success"] = True
|
||||
except Exception as e:
|
||||
res["result"] = str(e)
|
||||
res["success"] = False
|
||||
return web.json_response(res)
|
||||
|
||||
def get_branch(self, manager: SettingsManager):
|
||||
ver = manager.getSetting("branch", -1)
|
||||
logger.debug("current branch: %i" % ver)
|
||||
if ver == -1:
|
||||
logger.info("Current branch is not set, determining branch from version...")
|
||||
if self.localVer.startswith("v") and self.localVer.find("-pre"):
|
||||
logger.info("Current version determined to be pre-release")
|
||||
return 1
|
||||
else:
|
||||
logger.info("Current version determined to be stable")
|
||||
return 0
|
||||
return ver
|
||||
|
||||
async def _get_branch(self, manager: SettingsManager):
|
||||
return self.get_branch(manager)
|
||||
|
||||
async def get_version(self):
|
||||
if self.localVer:
|
||||
return {
|
||||
"current": self.localVer,
|
||||
"remote": self.remoteVer,
|
||||
"all": self.allRemoteVers,
|
||||
"updatable": self.localVer != None
|
||||
}
|
||||
else:
|
||||
return {"current": "unknown", "remote": self.remoteVer, "all": self.allRemoteVers, "updatable": False}
|
||||
|
||||
async def check_for_updates(self):
|
||||
logger.debug("checking for updates")
|
||||
selectedBranch = self.get_branch(self.context.settings)
|
||||
async with ClientSession() as web:
|
||||
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
|
||||
remoteVersions = await res.json()
|
||||
self.allRemoteVers = remoteVersions
|
||||
logger.debug("determining release type to find, branch is %i" % selectedBranch)
|
||||
if selectedBranch == 0:
|
||||
logger.debug("release type: release")
|
||||
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
|
||||
elif selectedBranch == 1:
|
||||
logger.debug("release type: pre-release")
|
||||
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
|
||||
# elif selectedBranch == 2:
|
||||
# logger.debug("release type: nightly")
|
||||
# self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("nightly"), remoteVersions), None)
|
||||
else:
|
||||
logger.error("release type: NOT FOUND")
|
||||
raise ValueError("no valid branch found")
|
||||
# doesn't make it to this line below or farther
|
||||
# logger.debug("Remote Version: %s" % self.remoteVer.find("name"))
|
||||
logger.info("Updated remote version information")
|
||||
tab = await get_tab("SP")
|
||||
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
|
||||
return await self.get_version()
|
||||
|
||||
async def version_reloader(self):
|
||||
await sleep(30)
|
||||
while True:
|
||||
try:
|
||||
await self.check_for_updates()
|
||||
except:
|
||||
pass
|
||||
await sleep(60 * 60 * 6) # 6 hours
|
||||
|
||||
async def do_update(self):
|
||||
version = self.remoteVer["tag_name"]
|
||||
download_url = self.remoteVer["assets"][0]["browser_download_url"]
|
||||
|
||||
tab = await get_tab("SP")
|
||||
await tab.open_websocket()
|
||||
async with ClientSession() as web:
|
||||
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
||||
total = int(res.headers.get('content-length', 0))
|
||||
try:
|
||||
remove(path.join(getcwd(), "PluginLoader"))
|
||||
except:
|
||||
pass
|
||||
with open(path.join(getcwd(), "PluginLoader"), "wb") as out:
|
||||
progress = 0
|
||||
raw = 0
|
||||
async for c in res.content.iter_chunked(512):
|
||||
out.write(c)
|
||||
raw += len(c)
|
||||
new_progress = round((raw / total) * 100)
|
||||
if progress != new_progress:
|
||||
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
|
||||
progress = new_progress
|
||||
|
||||
with open(path.join(getcwd(), ".loader.version"), "w") as out:
|
||||
out.write(version)
|
||||
|
||||
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
|
||||
|
||||
logger.info("Updated loader installation.")
|
||||
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
|
||||
await tab.client.close()
|
||||
|
||||
async def do_restart(self):
|
||||
call(["systemctl", "daemon-reload"])
|
||||
call(["systemctl", "restart", "plugin_loader"])
|
||||
@@ -1,9 +1,12 @@
|
||||
import uuid
|
||||
import os
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
from aiohttp import ClientSession, web
|
||||
|
||||
from injector import inject_to_tab
|
||||
import helpers
|
||||
import subprocess
|
||||
|
||||
|
||||
class Utilities:
|
||||
@@ -12,11 +15,18 @@ class Utilities:
|
||||
self.util_methods = {
|
||||
"ping": self.ping,
|
||||
"http_request": self.http_request,
|
||||
"install_plugin": self.install_plugin,
|
||||
"cancel_plugin_install": self.cancel_plugin_install,
|
||||
"confirm_plugin_install": self.confirm_plugin_install,
|
||||
"uninstall_plugin": self.uninstall_plugin,
|
||||
"execute_in_tab": self.execute_in_tab,
|
||||
"inject_css_into_tab": self.inject_css_into_tab,
|
||||
"remove_css_from_tab": self.remove_css_from_tab
|
||||
"remove_css_from_tab": self.remove_css_from_tab,
|
||||
"allow_remote_debugging": self.allow_remote_debugging,
|
||||
"disallow_remote_debugging": self.disallow_remote_debugging,
|
||||
"set_setting": self.set_setting,
|
||||
"get_setting": self.get_setting,
|
||||
"filepicker_ls": self.filepicker_ls
|
||||
}
|
||||
|
||||
if context:
|
||||
@@ -40,15 +50,26 @@ class Utilities:
|
||||
res["success"] = False
|
||||
return web.json_response(res)
|
||||
|
||||
async def install_plugin(self, artifact="", name="No name", version="dev", hash=False):
|
||||
return await self.context.plugin_browser.request_plugin_install(
|
||||
artifact=artifact,
|
||||
name=name,
|
||||
version=version,
|
||||
hash=hash
|
||||
)
|
||||
|
||||
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 uninstall_plugin(self, name):
|
||||
return await self.context.plugin_browser.uninstall_plugin(name)
|
||||
|
||||
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),
|
||||
@@ -69,12 +90,12 @@ class Utilities:
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"result" : result["result"]["result"].get("value")
|
||||
"result": result["result"]["result"].get("value")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"result": e
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
|
||||
async def inject_css_into_tab(self, tab, style):
|
||||
@@ -99,7 +120,7 @@ class Utilities:
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"result" : css_id
|
||||
"result": css_id
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
@@ -133,3 +154,43 @@ class Utilities:
|
||||
"success": False,
|
||||
"result": e
|
||||
}
|
||||
|
||||
async def get_setting(self, key, default):
|
||||
return self.context.settings.getSetting(key, default)
|
||||
|
||||
async def set_setting(self, key, value):
|
||||
return self.context.settings.setSetting(key, value)
|
||||
|
||||
async def allow_remote_debugging(self):
|
||||
await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
|
||||
return True
|
||||
|
||||
async def disallow_remote_debugging(self):
|
||||
await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
|
||||
return True
|
||||
|
||||
async def filepicker_ls(self, path, include_files=True):
|
||||
# def sorter(file): # Modification time
|
||||
# if os.path.isdir(os.path.join(path, file)) or os.path.isfile(os.path.join(path, file)):
|
||||
# return os.path.getmtime(os.path.join(path, file))
|
||||
# return 0
|
||||
# file_names = sorted(os.listdir(path), key=sorter, reverse=True) # TODO provide more sort options
|
||||
file_names = sorted(os.listdir(path)) # Alphabetical
|
||||
|
||||
files = []
|
||||
|
||||
for file in file_names:
|
||||
full_path = os.path.join(path, file)
|
||||
is_dir = os.path.isdir(full_path)
|
||||
|
||||
if is_dir or include_files:
|
||||
files.append({
|
||||
"isdir": is_dir,
|
||||
"name": file,
|
||||
"realpath": os.path.realpath(full_path)
|
||||
})
|
||||
|
||||
return {
|
||||
"realpath": os.path.realpath(path),
|
||||
"files": files
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
## 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:-""}
|
||||
@@ -11,9 +13,13 @@ 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")
|
||||
OPTIONSARRAY=("$CLONEFOLDER" "$INSTALLFOLDER" "$DECKIP" "$SSHPORT" "$PASSWORD" "$SSHKEYLOC" "$LOADERBRANCH" "$LIBRARYBRANCH" "$TEMPLATEBRANCH" "$LATEST")
|
||||
|
||||
## iterate through options array to check their presence
|
||||
count=0
|
||||
@@ -28,19 +34,21 @@ setfolder() {
|
||||
local DEFAULT="git"
|
||||
elif [[ "$2" == "install" ]]; then
|
||||
local ACTION="install"
|
||||
local DEFAULT="loaderdev"
|
||||
local DEFAULT="dev"
|
||||
fi
|
||||
|
||||
printf "Enter the directory in /home/user to ${ACTION} to.\n"
|
||||
printf "Example: if your home directory is /home/user you would type: ${DEFAULT}\n"
|
||||
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
|
||||
|
||||
if [[ "$ACTION" == "clone" ]]; then
|
||||
printf "Enter the directory in /home/user/ to ${ACTION} to.\n"
|
||||
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
|
||||
read -p "Enter your ${ACTION} directory: " CLONEFOLDER
|
||||
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
CLONEFOLDER="${DEFAULT}"
|
||||
fi
|
||||
elif [[ "$ACTION" == "install" ]]; then
|
||||
printf "Enter the directory in /home/deck/homebrew to ${ACTION} pluginloader to.\n"
|
||||
printf "The ${ACTION} directory would be: /home/deck/homebrew/${DEFAULT}/pluginloader\n"
|
||||
printf "It is highly recommended that you use the default folder path seen above, just press enter at the next prompt.\n"
|
||||
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
|
||||
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
@@ -106,47 +114,81 @@ clonefromto() {
|
||||
# printf "repo=$1\n"
|
||||
# printf "outdir=$2\n"
|
||||
# printf "branch=$3\n"
|
||||
if [[ -z $3 ]]; then
|
||||
BRANCH=""
|
||||
else
|
||||
BRANCH="-b $3"
|
||||
fi
|
||||
git clone $1 $2 $BRANCH &> '/dev/null'
|
||||
printf "Repository: $1\n"
|
||||
git clone $1 $2 &> '/dev/null'
|
||||
CODE=$?
|
||||
# printf "CODE=${CODE}"
|
||||
if [[ $CODE -eq 128 ]]; then
|
||||
cd $2
|
||||
git fetch &> '/dev/null'
|
||||
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
|
||||
}
|
||||
|
||||
npmtransbundle() {
|
||||
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" ]] || [[ "$2" == "template" ]]; then
|
||||
npm install --quiet &> '/dev/null'
|
||||
npm link decky-frontend-lib --quiet &> '/dev/null'
|
||||
npm run build --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
|
||||
}
|
||||
|
||||
printf "Installing Steam Deck Plugin Loader contributor (for Steam Deck)...\n"
|
||||
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 PluginLoader?
|
||||
If so, you should not be using this script.\n
|
||||
If you have a release/nightly installed this script will disable it.\n"
|
||||
printf "THIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
|
||||
Not planning to contribute to 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"
|
||||
|
||||
# [[ $count -gt 0 ]] || read -p "Press any key to continue"
|
||||
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
|
||||
@@ -158,7 +200,7 @@ if [[ "$INSTALLFOLDER" == "" ]]; then
|
||||
fi
|
||||
|
||||
CLONEDIR="$HOME/$CLONEFOLDER"
|
||||
INSTALLDIR="/home/deck/$INSTALLFOLDER"
|
||||
INSTALLDIR="/home/deck/homebrew/$INSTALLFOLDER"
|
||||
|
||||
## Input ip address, port, password and sshkey
|
||||
|
||||
@@ -208,7 +250,7 @@ fi
|
||||
|
||||
## Create folder structure
|
||||
|
||||
printf "\nCloning git repositories.\n"
|
||||
printf "Cloning git repositories.\n"
|
||||
|
||||
mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
|
||||
@@ -217,61 +259,72 @@ mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
# rm -r ${CLONEDIR}/pluginlibrary
|
||||
# rm -r ${CLONEDIR}/plugintemplate
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
|
||||
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
|
||||
|
||||
type npm &> '/dev/null'
|
||||
[ "$UID" -eq 0 ] || printf "Input password to proceed with install.\n"
|
||||
|
||||
NPMLIVES=$?
|
||||
sudo npm install -g pnpm &> '/dev/null'
|
||||
|
||||
if ! [[ "$NPMLIVES" -eq 0 ]]; then
|
||||
printf "npm does not to be installed, exiting.\n"
|
||||
type pnpm &> '/dev/null'
|
||||
|
||||
PNPMLIVES=$?
|
||||
|
||||
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
|
||||
printf "pnpm does not appear to be installed, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
|
||||
|
||||
## TODO: add a way of verifying if tsc is installed and to skip this step if it is
|
||||
sudo npm install --quiet -g tsc &> '/dev/null'
|
||||
|
||||
printf "Transpiling and bundling typescript.\n"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/plugintemplate "template"
|
||||
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 --mkpath --rsh="ssh -p ${SSHPORT} ${IDENINVOC}" --exclude='.git/' --exclude='node_modules' --exclude="package-lock.json" --exclude=='frontend' --exclude="*dist*" --exclude="*contrib*" --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader/ &> '/dev/null'
|
||||
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 "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 PluginLoader template
|
||||
rsync -avzp --mkpath --rsh="ssh -p ${SSHPORT} ${IDENINVOC}" --exclude='.git/' --exclude='node_modules' --exclude="package-lock.json" --delete ${CLONEDIR}/plugintemplate deck@${DECKIP}:${INSTALLDIR}/plugins &> '/dev/null'
|
||||
### 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"
|
||||
printf "Error occurred when copying $CLONEDIR/plugintemplate to $INSTALLDIR/plugins\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## TODO: direct contributors to wiki for this info?
|
||||
|
||||
printf "Run these commands to deploy your local changes to the deck:\n"
|
||||
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p ${SSHPORT} ${IDENINVOC}\""" --exclude='.git/' --exclude='node_modules' --exclude='package-lock.json' --delete ${CLONEDIR}/pluginname deck@${DECKIP}:${INSTALLDIR}/plugins'\n"
|
||||
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p ${SSHPORT} ${IDENINVOC}\""" --exclude='.git/' --exclude='node_modules' --exclude='package-lock.json' --exclude=='frontend' --exclude='*dist*' --exclude='*contrib*' --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader/'\n"
|
||||
printf "'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 22 ${IDENINVOC} 'export PLUGIN_PATH=${INSTALLDIR}/plugins; export CHOWN_PLUGIN_PATH=0; echo 'steam' | sudo -SE python3 ${INSTALLDIR}/pluginloader/backend/main.py'\n"
|
||||
printf "Run in console or in a script this command to run your development version:\n'ssh deck@$DECKIP -p $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
|
||||
|
||||
@@ -279,4 +332,4 @@ printf "Run in console or in a script this command to run your development versi
|
||||
printf "Connecting via ssh to disable any PluginLoader release versions.\n"
|
||||
printf "Script will exit after this. All done!\n"
|
||||
|
||||
ssh deck@$DECKIP -p $SSHPORT $IDENINVOC "printf ${PASSWORD} | sudo -S systemctl disable --now plugin_loader; echo $?"
|
||||
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "printf $PASSWORD | sudo -S systemctl disable --now plugin_loader; echo $?" &> '/dev/null'
|
||||
|
||||
@@ -2,87 +2,115 @@
|
||||
|
||||
## Pre-parse arugments for ease of use
|
||||
CLONEFOLDER=${1:-""}
|
||||
LOADERBRANCH=${2:-""}
|
||||
LIBRARYBRANCH=${3:-""}
|
||||
TEMPLATEBRANCH=${4:-""}
|
||||
LATEST=${5:-""}
|
||||
|
||||
setfolder() {
|
||||
if [[ "$2" == "clone" ]]; then
|
||||
local ACTION="clone"
|
||||
local DEFAULT="git"
|
||||
elif [[ "$2" == "install" ]]; then
|
||||
local ACTION="install"
|
||||
local DEFAULT="loaderdev"
|
||||
fi
|
||||
## gather options into an array
|
||||
OPTIONSARRAY=("$CLONEFOLDER" "$LOADERBRANCH" "$LIBRARYBRANCH" "$TEMPLATEBRANCH" "$LATEST")
|
||||
|
||||
printf "Enter the directory in /home/user to ${ACTION} to.\n"
|
||||
printf "Example: if your home directory is /home/user you would type: ${DEFAULT}\n"
|
||||
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
|
||||
if [[ "$ACTION" == "clone" ]]; then
|
||||
read -p "Enter your ${ACTION} directory: " CLONEFOLDER
|
||||
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
CLONEFOLDER="${DEFAULT}"
|
||||
fi
|
||||
elif [[ "$ACTION" == "install" ]]; then
|
||||
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
|
||||
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
|
||||
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
|
||||
INSTALLFOLDER="${DEFAULT}"
|
||||
fi
|
||||
else
|
||||
printf "Folder type could not be determined, exiting\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
## 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"
|
||||
if [[ -z $3 ]]; then
|
||||
BRANCH=""
|
||||
else
|
||||
BRANCH="-b $3"
|
||||
fi
|
||||
git clone $1 $2 $BRANCH &> '/dev/null'
|
||||
printf "Repository: $1\n"
|
||||
git clone $1 $2 &> '/dev/null'
|
||||
CODE=$?
|
||||
# printf "CODE=${CODE}"
|
||||
if [[ $CODE -eq 128 ]]; then
|
||||
cd $2
|
||||
git fetch &> '/dev/null'
|
||||
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
|
||||
}
|
||||
|
||||
npmtransbundle() {
|
||||
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" ]] || [[ "$2" == "template" ]]; then
|
||||
npm install --quiet &> '/dev/null'
|
||||
npm link decky-frontend-lib --quiet &> '/dev/null'
|
||||
npm run build --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
|
||||
}
|
||||
|
||||
printf "Installing Steam Deck Plugin Loader contributor (no Steam Deck)..."
|
||||
|
||||
printf "\nTHIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
|
||||
If you are not planning to contribute to PluginLoader then you should not be using this script.\n"
|
||||
if ! [[ $count -gt 4 ]] ; then
|
||||
printf "Installing Steam Deck Plugin Loader contributor/developer (no Steam Deck)..."
|
||||
|
||||
printf "\nThis script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
|
||||
printf "\nTHIS SCRIPT 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"
|
||||
|
||||
if [[ -z $1 ]]; then
|
||||
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
|
||||
setfolder "$CLONEFOLDER" "clone"
|
||||
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 "\nCloning git repositories.\n"
|
||||
printf "Cloning git repositories.\n"
|
||||
|
||||
mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
|
||||
@@ -91,33 +119,47 @@ mkdir -p ${CLONEDIR} &> '/dev/null'
|
||||
# rm -r ${CLONEDIR}/pluginlibrary
|
||||
# rm -r ${CLONEDIR}/plugintemplate
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
|
||||
|
||||
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
|
||||
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 ! [[ "$NPMLIVES" -eq 0 ]]; then
|
||||
printf "npm needs to be installed, exiting.\n"
|
||||
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
|
||||
printf "npm does not appear to be installed, exiting.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
|
||||
sudo npm install -g pnpm &> '/dev/null'
|
||||
|
||||
sudo npm install --quiet -g tsc &> '/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"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
|
||||
|
||||
npmtransbundle ${CLONEDIR}/plugintemplate "template"
|
||||
pnpmtransbundle ${CLONEDIR}/plugintemplate "template"
|
||||
|
||||
printf "Plugin Loader is located at '${CLONEDIR}/pluginloader/'.\n"
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
echo "Installing Steam Deck Plugin Loader nightly..."
|
||||
|
||||
HOMEBREW_FOLDER=/home/deck/homebrew
|
||||
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
||||
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
||||
|
||||
# Create folder structure
|
||||
rm -rf ${HOMEBREW_FOLDER}/services
|
||||
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services
|
||||
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins
|
||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
||||
|
||||
# Download latest nightly build and install it
|
||||
rm -rf /tmp/plugin_loader
|
||||
@@ -22,7 +23,7 @@ chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
|
||||
systemctl --user stop plugin_loader 2> /dev/null
|
||||
systemctl --user disable plugin_loader 2> /dev/null
|
||||
rm -f /home/deck/.config/systemd/user/plugin_loader.service
|
||||
rm -f ${USER_DIR}/.config/systemd/user/plugin_loader.service
|
||||
|
||||
systemctl stop plugin_loader 2> /dev/null
|
||||
systemctl disable plugin_loader 2> /dev/null
|
||||
@@ -37,10 +38,9 @@ Type=simple
|
||||
User=root
|
||||
Restart=always
|
||||
|
||||
ExecStart=/home/deck/homebrew/services/PluginLoader
|
||||
WorkingDirectory=/home/deck/homebrew/services
|
||||
|
||||
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
|
||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
|
||||
|
||||
echo "Installing Steam Deck Plugin Loader pre-release..."
|
||||
|
||||
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
||||
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
||||
|
||||
# # Create folder structure
|
||||
rm -rf ${HOMEBREW_FOLDER}/services
|
||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
||||
|
||||
# Download latest release and install it
|
||||
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
|
||||
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
|
||||
|
||||
printf "Installing version %s...\n" "${VERSION}"
|
||||
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
|
||||
|
||||
systemctl --user stop plugin_loader 2> /dev/null
|
||||
systemctl --user disable plugin_loader 2> /dev/null
|
||||
|
||||
systemctl stop plugin_loader 2> /dev/null
|
||||
systemctl disable plugin_loader 2> /dev/null
|
||||
rm -f /etc/systemd/system/plugin_loader.service
|
||||
cat > /etc/systemd/system/plugin_loader.service <<- EOM
|
||||
[Unit]
|
||||
Description=SteamDeck Plugin Loader
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Restart=always
|
||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||
Environment=LOG_LEVEL=DEBUG
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOM
|
||||
systemctl daemon-reload
|
||||
systemctl start plugin_loader
|
||||
systemctl enable plugin_loader
|
||||
@@ -4,33 +4,39 @@
|
||||
|
||||
echo "Installing Steam Deck Plugin Loader release..."
|
||||
|
||||
HOMEBREW_FOLDER=/home/deck/homebrew
|
||||
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
||||
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
||||
|
||||
# Create folder structure
|
||||
rm -rf ${HOMEBREW_FOLDER}/services
|
||||
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services
|
||||
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins
|
||||
rm -rf "${HOMEBREW_FOLDER}/services"
|
||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
||||
|
||||
# Download latest release and install it
|
||||
curl -L https://github.com/SteamDeckHomebrew/PluginLoader/releases/latest/download/PluginLoader --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "false"))")
|
||||
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
|
||||
|
||||
printf "Installing version %s...\n" "${VERSION}"
|
||||
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
|
||||
|
||||
systemctl --user stop plugin_loader 2> /dev/null
|
||||
systemctl --user disable plugin_loader 2> /dev/null
|
||||
|
||||
systemctl stop plugin_loader 2> /dev/null
|
||||
systemctl disable plugin_loader 2> /dev/null
|
||||
rm -f /etc/systemd/system/plugin_loader.service
|
||||
cat > /etc/systemd/system/plugin_loader.service <<- EOM
|
||||
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
|
||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOM
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
|
||||
|
||||
echo "Uninstalling Steam Deck Plugin Loader..."
|
||||
|
||||
HOMEBREW_FOLDER=/home/deck/homebrew
|
||||
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
|
||||
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
||||
|
||||
# Disable and remove services
|
||||
sudo systemctl disable --now plugin_loader.service > /dev/null
|
||||
sudo rm -f /home/deck/.config/systemd/user/plugin_loader.service
|
||||
sudo rm -f /etc/systemd/system/plugin_loader.service
|
||||
sudo rm -f "${USER_DIR}/.config/systemd/user/plugin_loader.service"
|
||||
sudo rm -f "/etc/systemd/system/plugin_loader.service"
|
||||
|
||||
# Remove temporary folder if it exists from the install process
|
||||
rm -rf /tmp/plugin_loader
|
||||
rm -rf "/tmp/plugin_loader"
|
||||
|
||||
# Cleanup services folder
|
||||
sudo rm ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||
sudo rm "${HOMEBREW_FOLDER}/services/PluginLoader"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#000" d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336c44.2 0 80-35.8 80-80s-35.8-80-80-80s-80 35.8-80 80s35.8 80 80 80z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#000" d="M96 0C78.3 0 64 14.3 64 32v96h64V32c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32v96h64V32c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32v32c0 77.4 55 142 128 156.8V480c0 17.7 14.3 32 32 32s32-14.3 32-32V412.8C297 398 352 333.4 352 256V224c17.7 0 32-14.3 32-32s-14.3-32-32-32H32z"/></svg>
|
||||
|
After Width: | Height: | Size: 561 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="32" height="14" viewBox="0.395 9 31.21 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.39502 16C0.39502 12.134 3.57877 9 7.50613 9H24.4938C28.4211 9 31.6049 12.134 31.6049 16C31.6049 19.866 28.4211 23 24.4938 23H7.50613C3.57877 23 0.39502 19.866 0.39502 16Z" fill="#000"/>
|
||||
<ellipse cx="8.88886" cy="16" rx="1.77778" ry="1.75" fill="#fff"/>
|
||||
<ellipse cx="15.9999" cy="16" rx="1.77778" ry="1.75" fill="#fff"/>
|
||||
<ellipse cx="23.111" cy="16" rx="1.77778" ry="1.75" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 554 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 36" fill="none" class="footericons_SizeMedium_3-w0R footericons_Light_2e0Fq"><path class="footericons_Background_I3P4e" fill="#000" d="M0 18C0 8.05888 8.05888 0 18 0H82C91.9411 0 100 8.05888 100 18C100 27.9411 91.9411 36 82 36H18C8.05888 36 0 27.9411 0 18Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M21.8011 11.5C22.6531 11.5 23.4391 11.62 24.1591 11.86C24.8791 12.1 25.4851 12.394 25.9771 12.742L24.8611 14.722C24.4171 14.41 23.9191 14.158 23.3671 13.966C22.8271 13.774 22.3111 13.678 21.8191 13.678C21.2191 13.678 20.7511 13.804 20.4151 14.056C20.0791 14.296 19.9111 14.632 19.9111 15.064C19.9111 15.496 20.1091 15.838 20.5051 16.09C20.9011 16.33 21.5071 16.594 22.3231 16.882C23.1631 17.182 23.8351 17.458 24.3391 17.71C24.8431 17.962 25.2811 18.334 25.6531 18.826C26.0371 19.306 26.2291 19.924 26.2291 20.68C26.2291 21.484 26.0191 22.18 25.5991 22.768C25.1911 23.356 24.6151 23.812 23.8711 24.136C23.1271 24.448 22.2751 24.604 21.3151 24.604C20.5351 24.604 19.7371 24.502 18.9211 24.298C18.1171 24.082 17.4091 23.794 16.7971 23.434L17.6251 21.238C18.2011 21.55 18.8071 21.802 19.4431 21.994C20.0911 22.174 20.7271 22.264 21.3511 22.264C22.0351 22.264 22.5451 22.132 22.8811 21.868C23.2291 21.604 23.4031 21.256 23.4031 20.824C23.4031 20.392 23.2171 20.056 22.8451 19.816C22.4731 19.576 21.9031 19.33 21.1351 19.078C20.2711 18.802 19.5751 18.538 19.0471 18.286C18.5191 18.022 18.0631 17.644 17.6791 17.152C17.3071 16.648 17.1211 15.994 17.1211 15.19C17.1211 14.446 17.3131 13.798 17.6971 13.246C18.0931 12.682 18.6451 12.25 19.3531 11.95C20.0611 11.65 20.8771 11.5 21.8011 11.5Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M35.2486 24.388H32.6026V14.056H28.7866V11.788H39.0646V14.056H35.2486V24.388Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M42.3148 11.788H50.8108V14.038H44.9608V16.882H50.0008V19.15H44.9608V22.102H50.8108V24.388H42.3148V11.788Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M65.8582 24.388H62.9962L62.1322 21.94H57.2002L56.3722 24.388H53.6182L58.3342 11.788H60.9982L65.8582 24.388ZM59.6482 14.794L57.9202 19.834H61.4122L59.6482 14.794Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M75.8489 20.734L79.7729 11.788H82.4549V24.388H79.9169V16.378L76.5329 24.028H74.9309L71.4749 16.468V24.388H69.0629V11.788H71.6009L75.8489 20.734Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#000" d="M547.6 103.8L490.3 13.1C485.2 5 476.1 0 466.4 0H109.6C99.9 0 90.8 5 85.7 13.1L28.3 103.8c-29.6 46.8-3.4 111.9 51.9 119.4c4 .5 8.1 .8 12.1 .8c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.2 0 49.3-11.4 65.2-29c16 17.6 39.1 29 65.2 29c4.1 0 8.1-.3 12.1-.8c55.5-7.4 81.8-72.5 52.1-119.4zM499.7 254.9l-.1 0c-5.3 .7-10.7 1.1-16.2 1.1c-12.4 0-24.3-1.9-35.4-5.3V384H128V250.6c-11.2 3.5-23.2 5.4-35.6 5.4c-5.5 0-11-.4-16.3-1.1l-.1 0c-4.1-.6-8.1-1.3-12-2.3V384v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V384 252.6c-4 1-8 1.8-12.3 2.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 850 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30ZM22.0775 9H18.5059L15.8368 13.3393L13.1677 9H9.69202L14.2814 15.5186L9.5 22.5H12.8796L15.8944 17.5821L19.0436 22.5H22.5L17.3538 15.4221L22.0775 9Z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#fff" d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336c44.2 0 80-35.8 80-80s-35.8-80-80-80s-80 35.8-80 80s35.8 80 80 80z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#fff" d="M96 0C78.3 0 64 14.3 64 32v96h64V32c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32v96h64V32c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32v32c0 77.4 55 142 128 156.8V480c0 17.7 14.3 32 32 32s32-14.3 32-32V412.8C297 398 352 333.4 352 256V224c17.7 0 32-14.3 32-32s-14.3-32-32-32H32z"/></svg>
|
||||
|
After Width: | Height: | Size: 561 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="32" height="14" viewBox="0.395 9 31.21 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.39502 16C0.39502 12.134 3.57877 9 7.50613 9H24.4938C28.4211 9 31.6049 12.134 31.6049 16C31.6049 19.866 28.4211 23 24.4938 23H7.50613C3.57877 23 0.39502 19.866 0.39502 16Z" fill="white"/>
|
||||
<ellipse cx="8.88886" cy="16" rx="1.77778" ry="1.75" style="fill: rgb(0, 0, 0);"/>
|
||||
<ellipse cx="15.9999" cy="16" rx="1.77778" ry="1.75" style="fill: rgb(0, 0, 0);"/>
|
||||
<ellipse cx="23.111" cy="16" rx="1.77778" ry="1.75" style="fill: rgb(0, 0, 0);"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 36" fill="none" class="footericons_SizeMedium_3-w0R footericons_Light_2e0Fq"><path class="footericons_Background_I3P4e" fill="#fff" d="M0 18C0 8.05888 8.05888 0 18 0H82C91.9411 0 100 8.05888 100 18C100 27.9411 91.9411 36 82 36H18C8.05888 36 0 27.9411 0 18Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M21.8011 11.5C22.6531 11.5 23.4391 11.62 24.1591 11.86C24.8791 12.1 25.4851 12.394 25.9771 12.742L24.8611 14.722C24.4171 14.41 23.9191 14.158 23.3671 13.966C22.8271 13.774 22.3111 13.678 21.8191 13.678C21.2191 13.678 20.7511 13.804 20.4151 14.056C20.0791 14.296 19.9111 14.632 19.9111 15.064C19.9111 15.496 20.1091 15.838 20.5051 16.09C20.9011 16.33 21.5071 16.594 22.3231 16.882C23.1631 17.182 23.8351 17.458 24.3391 17.71C24.8431 17.962 25.2811 18.334 25.6531 18.826C26.0371 19.306 26.2291 19.924 26.2291 20.68C26.2291 21.484 26.0191 22.18 25.5991 22.768C25.1911 23.356 24.6151 23.812 23.8711 24.136C23.1271 24.448 22.2751 24.604 21.3151 24.604C20.5351 24.604 19.7371 24.502 18.9211 24.298C18.1171 24.082 17.4091 23.794 16.7971 23.434L17.6251 21.238C18.2011 21.55 18.8071 21.802 19.4431 21.994C20.0911 22.174 20.7271 22.264 21.3511 22.264C22.0351 22.264 22.5451 22.132 22.8811 21.868C23.2291 21.604 23.4031 21.256 23.4031 20.824C23.4031 20.392 23.2171 20.056 22.8451 19.816C22.4731 19.576 21.9031 19.33 21.1351 19.078C20.2711 18.802 19.5751 18.538 19.0471 18.286C18.5191 18.022 18.0631 17.644 17.6791 17.152C17.3071 16.648 17.1211 15.994 17.1211 15.19C17.1211 14.446 17.3131 13.798 17.6971 13.246C18.0931 12.682 18.6451 12.25 19.3531 11.95C20.0611 11.65 20.8771 11.5 21.8011 11.5Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M35.2486 24.388H32.6026V14.056H28.7866V11.788H39.0646V14.056H35.2486V24.388Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M42.3148 11.788H50.8108V14.038H44.9608V16.882H50.0008V19.15H44.9608V22.102H50.8108V24.388H42.3148V11.788Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M65.8582 24.388H62.9962L62.1322 21.94H57.2002L56.3722 24.388H53.6182L58.3342 11.788H60.9982L65.8582 24.388ZM59.6482 14.794L57.9202 19.834H61.4122L59.6482 14.794Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M75.8489 20.734L79.7729 11.788H82.4549V24.388H79.9169V16.378L76.5329 24.028H74.9309L71.4749 16.468V24.388H69.0629V11.788H71.6009L75.8489 20.734Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#fff" d="M547.6 103.8L490.3 13.1C485.2 5 476.1 0 466.4 0H109.6C99.9 0 90.8 5 85.7 13.1L28.3 103.8c-29.6 46.8-3.4 111.9 51.9 119.4c4 .5 8.1 .8 12.1 .8c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.2 0 49.3-11.4 65.2-29c16 17.6 39.1 29 65.2 29c4.1 0 8.1-.3 12.1-.8c55.5-7.4 81.8-72.5 52.1-119.4zM499.7 254.9l-.1 0c-5.3 .7-10.7 1.1-16.2 1.1c-12.4 0-24.3-1.9-35.4-5.3V384H128V250.6c-11.2 3.5-23.2 5.4-35.6 5.4c-5.5 0-11-.4-16.3-1.1l-.1 0c-4.1-.6-8.1-1.3-12-2.3V384v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V384 252.6c-4 1-8 1.8-12.3 2.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 850 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30ZM22.0775 9H18.5059L15.8368 13.3393L13.1677 9H9.69202L14.2814 15.5186L9.5 22.5H12.8796L15.8944 17.5821L19.0436 22.5H22.5L17.3538 15.4221L22.0775 9Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 424 B |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "decky_frontend",
|
||||
"version": "0.0.1",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"license": "GPLV2",
|
||||
"scripts": {
|
||||
@@ -13,21 +13,26 @@
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^21.1.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^13.2.1",
|
||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"@rollup/plugin-typescript": "^8.3.2",
|
||||
"@rollup/plugin-typescript": "^8.3.3",
|
||||
"@types/react": "16.14.0",
|
||||
"@types/react-file-icon": "^1.0.1",
|
||||
"@types/react-router": "5.1.18",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"husky": "^8.0.1",
|
||||
"import-sort-style-module": "^6.0.0",
|
||||
"prettier": "^2.6.2",
|
||||
"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.70.2",
|
||||
"rollup": "^2.76.0",
|
||||
"rollup-plugin-delete": "^2.0.0",
|
||||
"rollup-plugin-external-globals": "^0.6.1",
|
||||
"rollup-plugin-polyfill-node": "^0.10.2",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.7.2"
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"importSort": {
|
||||
".js, .jsx, .ts, .tsx": {
|
||||
@@ -36,7 +41,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"decky-frontend-lib": "^0.0.6",
|
||||
"react-icons": "^4.3.1"
|
||||
"decky-frontend-lib": "^3.1.3",
|
||||
"react-file-icon": "^1.2.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-markdown": "^8.0.3",
|
||||
"remark-gfm": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
import externalGlobals from "rollup-plugin-external-globals";
|
||||
import del from 'rollup-plugin-delete'
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import { defineConfig } from 'rollup';
|
||||
@@ -8,8 +10,17 @@ import { defineConfig } from 'rollup';
|
||||
export default defineConfig({
|
||||
input: 'src/index.tsx',
|
||||
plugins: [
|
||||
del({ targets: "../backend/static/*", force: true }),
|
||||
commonjs(),
|
||||
nodeResolve(),
|
||||
externalGlobals({
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
// hack to shut up react-markdown
|
||||
'process': '{cwd: () => {}}',
|
||||
'path': '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
||||
'url': '{fileURLToPath: (f) => f}'
|
||||
}),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
@@ -17,13 +28,12 @@ export default defineConfig({
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
],
|
||||
external: ["react", "react-dom"],
|
||||
preserveEntrySignatures: false,
|
||||
output: {
|
||||
file: '../backend/static/plugin-loader.iife.js',
|
||||
globals: {
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
},
|
||||
format: 'iife',
|
||||
},
|
||||
dir: '../backend/static',
|
||||
format: 'esm',
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
return 'chunk-[hash].js'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,17 +6,21 @@ export interface RouterEntry {
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
export type RoutePatch = (route: RouteProps) => RouteProps;
|
||||
|
||||
interface PublicDeckyRouterState {
|
||||
routes: Map<string, RouterEntry>;
|
||||
routePatches: Map<string, Set<RoutePatch>>;
|
||||
}
|
||||
|
||||
export class DeckyRouterState {
|
||||
private _routes = new Map<string, RouterEntry>();
|
||||
private _routePatches = new Map<string, Set<RoutePatch>>();
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyRouterState {
|
||||
return { routes: this._routes };
|
||||
return { routes: this._routes, routePatches: this._routePatches };
|
||||
}
|
||||
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
|
||||
@@ -24,6 +28,26 @@ export class DeckyRouterState {
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
addPatch(path: string, patch: RoutePatch) {
|
||||
let patchList = this._routePatches.get(path);
|
||||
if (!patchList) {
|
||||
patchList = new Set();
|
||||
this._routePatches.set(path, patchList);
|
||||
}
|
||||
patchList.add(patch);
|
||||
this.notifyUpdate();
|
||||
return patch;
|
||||
}
|
||||
|
||||
removePatch(path: string, patch: RoutePatch) {
|
||||
const patchList = this._routePatches.get(path);
|
||||
patchList?.delete(patch);
|
||||
if (patchList?.size == 0) {
|
||||
this._routePatches.delete(path);
|
||||
}
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
removeRoute(path: string) {
|
||||
this._routes.delete(path);
|
||||
this.notifyUpdate();
|
||||
@@ -36,6 +60,8 @@ export class DeckyRouterState {
|
||||
|
||||
interface DeckyRouterStateContext extends PublicDeckyRouterState {
|
||||
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void;
|
||||
addPatch(path: string, patch: RoutePatch): RoutePatch;
|
||||
removePatch(path: string, patch: RoutePatch): void;
|
||||
removeRoute(path: string): void;
|
||||
}
|
||||
|
||||
@@ -62,12 +88,15 @@ export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRout
|
||||
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);
|
||||
const addRoute = deckyRouterState.addRoute.bind(deckyRouterState);
|
||||
const addPatch = deckyRouterState.addPatch.bind(deckyRouterState);
|
||||
const removePatch = deckyRouterState.removePatch.bind(deckyRouterState);
|
||||
const removeRoute = deckyRouterState.removeRoute.bind(deckyRouterState);
|
||||
|
||||
return (
|
||||
<DeckyRouterStateContext.Provider value={{ ...publicDeckyRouterState, addRoute, removeRoute }}>
|
||||
<DeckyRouterStateContext.Provider
|
||||
value={{ ...publicDeckyRouterState, addRoute, addPatch, removePatch, removeRoute }}
|
||||
>
|
||||
{children}
|
||||
</DeckyRouterStateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
import { FC, createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Plugin } from '../plugin';
|
||||
import { PluginUpdateMapping } from '../store';
|
||||
import { VerInfo } from '../updater';
|
||||
|
||||
interface PublicDeckyState {
|
||||
plugins: Plugin[];
|
||||
activePlugin: Plugin | null;
|
||||
updates: PluginUpdateMapping | null;
|
||||
hasLoaderUpdate?: boolean;
|
||||
isLoaderUpdating: boolean;
|
||||
versionInfo: VerInfo | null;
|
||||
}
|
||||
|
||||
export class DeckyState {
|
||||
private _plugins: Plugin[] = [];
|
||||
private _activePlugin: Plugin | null = null;
|
||||
private _updates: PluginUpdateMapping | null = null;
|
||||
private _hasLoaderUpdate: boolean = false;
|
||||
private _isLoaderUpdating: boolean = false;
|
||||
private _versionInfo: VerInfo | null = null;
|
||||
|
||||
public eventBus = new EventTarget();
|
||||
|
||||
publicState(): PublicDeckyState {
|
||||
return { plugins: this._plugins, activePlugin: this._activePlugin };
|
||||
return {
|
||||
plugins: this._plugins,
|
||||
activePlugin: this._activePlugin,
|
||||
updates: this._updates,
|
||||
hasLoaderUpdate: this._hasLoaderUpdate,
|
||||
isLoaderUpdating: this._isLoaderUpdating,
|
||||
versionInfo: this._versionInfo,
|
||||
};
|
||||
}
|
||||
|
||||
setVersionInfo(versionInfo: VerInfo) {
|
||||
this._versionInfo = versionInfo;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
setPlugins(plugins: Plugin[]) {
|
||||
@@ -32,12 +54,29 @@ export class DeckyState {
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
setUpdates(updates: PluginUpdateMapping) {
|
||||
this._updates = updates;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
setHasLoaderUpdate(hasUpdate: boolean) {
|
||||
this._hasLoaderUpdate = hasUpdate;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
setIsLoaderUpdating(isUpdating: boolean) {
|
||||
this._isLoaderUpdating = isUpdating;
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
private notifyUpdate() {
|
||||
this.eventBus.dispatchEvent(new Event('update'));
|
||||
}
|
||||
}
|
||||
|
||||
interface DeckyStateContext extends PublicDeckyState {
|
||||
setVersionInfo(versionInfo: VerInfo): void;
|
||||
setIsLoaderUpdating(hasUpdate: boolean): void;
|
||||
setActivePlugin(name: string): void;
|
||||
closeActivePlugin(): void;
|
||||
}
|
||||
@@ -63,11 +102,15 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
|
||||
return () => deckyState.eventBus.removeEventListener('update', onUpdate);
|
||||
}, []);
|
||||
|
||||
const setIsLoaderUpdating = (hasUpdate: boolean) => deckyState.setIsLoaderUpdating(hasUpdate);
|
||||
const setVersionInfo = (versionInfo: VerInfo) => deckyState.setVersionInfo(versionInfo);
|
||||
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
|
||||
const closeActivePlugin = () => deckyState.closeActivePlugin();
|
||||
|
||||
return (
|
||||
<DeckyStateContext.Provider value={{ ...publicDeckyState, setActivePlugin, closeActivePlugin }}>
|
||||
<DeckyStateContext.Provider
|
||||
value={{ ...publicDeckyState, setIsLoaderUpdating, setVersionInfo, setActivePlugin, closeActivePlugin }}
|
||||
>
|
||||
{children}
|
||||
</DeckyStateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Focusable } from 'decky-frontend-lib';
|
||||
import { FunctionComponent, useRef } from 'react';
|
||||
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface MarkdownProps extends ReactMarkdownOptions {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const Markdown: FunctionComponent<MarkdownProps> = (props) => {
|
||||
return (
|
||||
<Focusable>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
div: (nodeProps) => <Focusable {...nodeProps.node.properties}>{nodeProps.children}</Focusable>,
|
||||
a: (nodeProps) => {
|
||||
const aRef = useRef<HTMLAnchorElement>(null);
|
||||
return (
|
||||
// TODO fix focus ring
|
||||
<Focusable
|
||||
onActivate={() => {}}
|
||||
onOKButton={() => {
|
||||
aRef?.current?.click();
|
||||
props.onDismiss?.();
|
||||
}}
|
||||
style={{ display: 'inline' }}
|
||||
>
|
||||
<a ref={aRef} {...nodeProps.node.properties}>
|
||||
{nodeProps.children}
|
||||
</a>
|
||||
</Focusable>
|
||||
);
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Focusable>
|
||||
);
|
||||
};
|
||||
|
||||
export default Markdown;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CSSProperties, FunctionComponent } from 'react';
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
show?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const NotificationBadge: FunctionComponent<NotificationBadgeProps> = ({ show, style }) => {
|
||||
return show ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
height: '10px',
|
||||
width: '10px',
|
||||
background: 'orange',
|
||||
borderRadius: '50%',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default NotificationBadge;
|
||||
@@ -1,48 +1,47 @@
|
||||
import { ButtonItem, DialogButton, PanelSection, PanelSectionRow, Router } from 'decky-frontend-lib';
|
||||
import {
|
||||
ButtonItem,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
joinClassNames,
|
||||
scrollClasses,
|
||||
staticClasses,
|
||||
} from 'decky-frontend-lib';
|
||||
import { VFC } from 'react';
|
||||
import { FaArrowLeft, FaStore } from 'react-icons/fa';
|
||||
|
||||
import { useDeckyState } from './DeckyState';
|
||||
import NotificationBadge from './NotificationBadge';
|
||||
|
||||
const PluginView: VFC = () => {
|
||||
const { plugins, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
|
||||
|
||||
const onStoreClick = () => {
|
||||
Router.CloseSideMenus();
|
||||
Router.NavigateToExternalWeb('http://127.0.0.1:1337/browser/redirect');
|
||||
};
|
||||
const { plugins, updates, activePlugin, setActivePlugin } = useDeckyState();
|
||||
|
||||
if (activePlugin) {
|
||||
return (
|
||||
<div style={{ height: '100%' }}>
|
||||
<div style={{ position: 'absolute', top: '3px', left: '16px', zIndex: 20 }}>
|
||||
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={closeActivePlugin}>
|
||||
<FaArrowLeft style={{ display: 'block' }} />
|
||||
</DialogButton>
|
||||
</div>
|
||||
<div
|
||||
className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{activePlugin.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection>
|
||||
<div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
|
||||
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={onStoreClick}>
|
||||
<FaStore style={{ display: 'block' }} />
|
||||
</DialogButton>
|
||||
</div>
|
||||
{plugins.map(({ name, icon }) => (
|
||||
<PanelSectionRow key={name}>
|
||||
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>{icon}</div>
|
||||
<div>{name}</div>
|
||||
</div>
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</PanelSection>
|
||||
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
|
||||
<PanelSection>
|
||||
{plugins
|
||||
.filter((p) => p.content)
|
||||
.map(({ name, icon }) => (
|
||||
<PanelSectionRow key={name}>
|
||||
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{icon}
|
||||
<div>{name}</div>
|
||||
<NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
|
||||
</div>
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</PanelSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import { staticClasses } from 'decky-frontend-lib';
|
||||
import { VFC } from 'react';
|
||||
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',
|
||||
paddingRight: '16px',
|
||||
};
|
||||
|
||||
const TitleView: VFC = () => {
|
||||
const { activePlugin } = useDeckyState();
|
||||
const { activePlugin, closeActivePlugin } = useDeckyState();
|
||||
|
||||
const onSettingsClick = () => {
|
||||
Router.CloseSideMenus();
|
||||
Router.Navigate('/decky/settings');
|
||||
};
|
||||
|
||||
const onStoreClick = () => {
|
||||
Router.CloseSideMenus();
|
||||
Router.Navigate('/decky/store');
|
||||
};
|
||||
|
||||
if (activePlugin === null) {
|
||||
return <div className={staticClasses.Title}>Decky</div>;
|
||||
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={{ paddingLeft: '60px' }}>
|
||||
{activePlugin.name}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
interface ToastProps {
|
||||
toast: {
|
||||
data: ToastData;
|
||||
nToastDurationMS: number;
|
||||
};
|
||||
}
|
||||
|
||||
const toastClasses = findModule((mod) => {
|
||||
if (typeof mod !== 'object') return false;
|
||||
|
||||
if (mod.ToastPlaceholder) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const templateClasses = findModule((mod) => {
|
||||
if (typeof mod !== 'object') return false;
|
||||
|
||||
if (mod.ShortTemplate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
||||
return (
|
||||
<div
|
||||
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
|
||||
className={toastClasses.toastEnter}
|
||||
>
|
||||
<div
|
||||
onClick={toast.data.onClick}
|
||||
className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')}
|
||||
>
|
||||
{toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</div>}
|
||||
<div className={joinClassNames(templateClasses.Content, toast.data.contentClassName || '')}>
|
||||
<div className={templateClasses.Header}>
|
||||
{toast.data.icon && <div className={templateClasses.Icon}>{toast.data.icon}</div>}
|
||||
<div className={templateClasses.Title}>{toast.data.title}</div>
|
||||
</div>
|
||||
<div className={templateClasses.Body}>{toast.data.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Focusable, SteamSpinner } from 'decky-frontend-lib';
|
||||
import { FunctionComponent, ReactElement, ReactNode, Suspense } from 'react';
|
||||
|
||||
interface WithSuspenseProps {
|
||||
children: ReactNode;
|
||||
route?: boolean;
|
||||
}
|
||||
|
||||
// Nice little wrapper around Suspense so we don't have to duplicate the styles and code for the loading spinner
|
||||
const WithSuspense: FunctionComponent<WithSuspenseProps> = (props) => {
|
||||
const propsCopy = { ...props };
|
||||
delete propsCopy.children;
|
||||
(props.children as ReactElement)?.props && Object.assign((props.children as ReactElement).props, propsCopy); // There is probably a better way to do this but valve does it this way so ¯\_(ツ)_/¯
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Focusable
|
||||
// needed to enable focus ring so that the focus properly resets on load
|
||||
onActivate={() => {}}
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
backgroundColor: 'transparent',
|
||||
...(props.route && {
|
||||
marginTop: '40px',
|
||||
height: 'calc( 100% - 40px )',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<SteamSpinner />
|
||||
</Focusable>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default WithSuspense;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ConfirmModal, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib';
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
interface PluginInstallModalProps {
|
||||
artifact: string;
|
||||
version: string;
|
||||
hash: string;
|
||||
// reinstall: boolean;
|
||||
onOK(): void;
|
||||
onCancel(): void;
|
||||
closeModal?(): void;
|
||||
}
|
||||
|
||||
const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, hash, onOK, onCancel, closeModal }) => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
return (
|
||||
<ConfirmModal
|
||||
bOKDisabled={loading}
|
||||
closeModal={closeModal}
|
||||
onOK={async () => {
|
||||
setLoading(true);
|
||||
await onOK();
|
||||
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
|
||||
setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
|
||||
}}
|
||||
onCancel={async () => {
|
||||
await onCancel();
|
||||
}}
|
||||
>
|
||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
||||
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
|
||||
<div style={{ flexDirection: 'row' }}>
|
||||
{loading && <Spinner style={{ width: '20px' }} />} {loading ? 'Installing' : 'Install'} {artifact}
|
||||
{version ? ' version ' + version : null}
|
||||
{!loading && '?'}
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginInstallModal;
|
||||
@@ -0,0 +1,170 @@
|
||||
// https://codesandbox.io/s/react-file-icon-colored-tmwut?file=/src/App.js
|
||||
import { FileIconProps } from 'react-file-icon';
|
||||
|
||||
type T_FileExtList = string[];
|
||||
|
||||
const styleDef: [FileIconProps, T_FileExtList][] = [];
|
||||
|
||||
// video ////////////////////////////////////
|
||||
const videoStyle = {
|
||||
color: '#f00f0f',
|
||||
};
|
||||
const videoExtList = [
|
||||
'avi',
|
||||
'3g2',
|
||||
'3gp',
|
||||
'aep',
|
||||
'asf',
|
||||
'flv',
|
||||
'm4v',
|
||||
'mkv',
|
||||
'mov',
|
||||
'mp4',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
'ogv',
|
||||
'pr',
|
||||
'swfw',
|
||||
'webm',
|
||||
'wmv',
|
||||
'swf',
|
||||
'rm',
|
||||
];
|
||||
|
||||
styleDef.push([videoStyle, videoExtList]);
|
||||
|
||||
// image ////////////////////////////////////
|
||||
const imageStyle = {
|
||||
color: '#d18f00',
|
||||
};
|
||||
|
||||
const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff'];
|
||||
|
||||
styleDef.push([imageStyle, imageExtList]);
|
||||
|
||||
// zip ////////////////////////////////////
|
||||
const zipStyle = {
|
||||
color: '#f7b500',
|
||||
labelTextColor: '#000',
|
||||
// glyphColor: "#de9400"
|
||||
};
|
||||
|
||||
const zipExtList = ['zip', 'zipx', '7zip', 'tar', 'sitx', 'gz', 'rar'];
|
||||
|
||||
styleDef.push([zipStyle, zipExtList]);
|
||||
|
||||
// audio ////////////////////////////////////
|
||||
const audioStyle = {
|
||||
color: '#f00f0f',
|
||||
};
|
||||
|
||||
const audioExtList = ['aac', 'aif', 'aiff', 'flac', 'm4a', 'mid', 'mp3', 'ogg', 'wav'];
|
||||
|
||||
styleDef.push([audioStyle, audioExtList]);
|
||||
|
||||
// text ////////////////////////////////////
|
||||
const textStyle = {
|
||||
color: '#ffffff',
|
||||
glyphColor: '#787878',
|
||||
};
|
||||
|
||||
const textExtList = ['cue', 'odt', 'md', 'rtf', 'txt', 'tex', 'wpd', 'wps', 'xlr', 'fodt'];
|
||||
|
||||
styleDef.push([textStyle, textExtList]);
|
||||
|
||||
// system ////////////////////////////////////
|
||||
const systemStyle = {
|
||||
color: '#111',
|
||||
};
|
||||
|
||||
const systemExtList = ['exe', 'ini', 'dll', 'plist', 'sys'];
|
||||
|
||||
styleDef.push([systemStyle, systemExtList]);
|
||||
|
||||
// srcCode ////////////////////////////////////
|
||||
const srcCodeStyle = {
|
||||
glyphColor: '#787878',
|
||||
color: '#ffffff',
|
||||
};
|
||||
|
||||
const srcCodeExtList = [
|
||||
'asp',
|
||||
'aspx',
|
||||
'c',
|
||||
'cpp',
|
||||
'cs',
|
||||
'css',
|
||||
'scss',
|
||||
'py',
|
||||
'json',
|
||||
'htm',
|
||||
'html',
|
||||
'java',
|
||||
'yml',
|
||||
'php',
|
||||
'js',
|
||||
'ts',
|
||||
'rb',
|
||||
'jsx',
|
||||
'tsx',
|
||||
];
|
||||
|
||||
styleDef.push([srcCodeStyle, srcCodeExtList]);
|
||||
|
||||
// vector ////////////////////////////////////
|
||||
const vectorStyle = {
|
||||
color: '#ffe600',
|
||||
};
|
||||
|
||||
const vectorExtList = ['dwg', 'dxf', 'ps', 'svg', 'eps'];
|
||||
|
||||
styleDef.push([vectorStyle, vectorExtList]);
|
||||
|
||||
// font ////////////////////////////////////
|
||||
const fontStyle = {
|
||||
color: '#555',
|
||||
};
|
||||
|
||||
const fontExtList = ['fnt', 'ttf', 'otf', 'fon', 'eot', 'woff'];
|
||||
|
||||
styleDef.push([fontStyle, fontExtList]);
|
||||
|
||||
// objectModel ////////////////////////////////////
|
||||
const objectModelStyle = {
|
||||
color: '#bf6a02',
|
||||
glyphColor: '#bf6a02',
|
||||
};
|
||||
|
||||
const objectModelExtList = ['3dm', '3ds', 'max', 'obj', 'pkg'];
|
||||
|
||||
styleDef.push([objectModelStyle, objectModelExtList]);
|
||||
|
||||
// sheet ////////////////////////////////////
|
||||
const sheetStyle = {
|
||||
color: '#2a6e00',
|
||||
};
|
||||
|
||||
const sheetExtList = ['csv', 'fods', 'ods', 'xlr'];
|
||||
|
||||
styleDef.push([sheetStyle, sheetExtList]);
|
||||
|
||||
// const defaultStyle: Record<string, FileIconProps> = {
|
||||
// pdf: {
|
||||
// glyphColor: "white",
|
||||
// color: "#D93831"
|
||||
// }
|
||||
// };
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
|
||||
function createStyleObj(extList: T_FileExtList, styleObj: Partial<FileIconProps>) {
|
||||
return Object.fromEntries(
|
||||
extList.map((ext) => {
|
||||
return [ext, { ...styleObj, glyphColor: 'white' }];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const styleDefObj = styleDef.reduce((acc, [fileStyle, fileExtList]) => {
|
||||
return { ...acc, ...createStyleObj(fileExtList, fileStyle) };
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib';
|
||||
import { useEffect } from 'react';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { FileIcon, defaultStyles } from 'react-file-icon';
|
||||
import { FaArrowUp, FaFolder } from 'react-icons/fa';
|
||||
|
||||
import Logger from '../../../logger';
|
||||
import { styleDefObj } from './iconCustomizations';
|
||||
|
||||
const logger = new Logger('FilePicker');
|
||||
|
||||
export interface FilePickerProps {
|
||||
startPath: string;
|
||||
includeFiles?: boolean;
|
||||
regex?: RegExp;
|
||||
onSubmit: (val: { path: string; realpath: string }) => void;
|
||||
closeModal?: () => void;
|
||||
}
|
||||
|
||||
interface File {
|
||||
isdir: boolean;
|
||||
name: string;
|
||||
realpath: string;
|
||||
}
|
||||
|
||||
interface FileListing {
|
||||
realpath: string;
|
||||
files: File[];
|
||||
}
|
||||
|
||||
function getList(
|
||||
path: string,
|
||||
includeFiles: boolean = true,
|
||||
): Promise<{ result: FileListing | string; success: boolean }> {
|
||||
return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles });
|
||||
}
|
||||
|
||||
const iconStyles = {
|
||||
paddingRight: '10px',
|
||||
width: '1em',
|
||||
};
|
||||
|
||||
const FilePicker: FunctionComponent<FilePickerProps> = ({
|
||||
startPath,
|
||||
includeFiles = true,
|
||||
regex,
|
||||
onSubmit,
|
||||
closeModal,
|
||||
}) => {
|
||||
if (startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
|
||||
const [path, setPath] = useState<string>(startPath);
|
||||
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (error) setError(null);
|
||||
setLoading(true);
|
||||
const listing = await getList(path, includeFiles);
|
||||
if (!listing.success) {
|
||||
setListing({ files: [], realpath: path });
|
||||
setLoading(false);
|
||||
setError(listing.result as string);
|
||||
logger.error(listing.result);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
setListing(listing.result as FileListing);
|
||||
logger.log('reloaded', path, listing);
|
||||
})();
|
||||
}, [path]);
|
||||
|
||||
return (
|
||||
<div className="deckyFilePicker">
|
||||
<Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}>
|
||||
<DialogButton
|
||||
style={{
|
||||
minWidth: 'unset',
|
||||
width: '40px',
|
||||
flexGrow: '0',
|
||||
borderRadius: 'unset',
|
||||
margin: '0',
|
||||
padding: '10px',
|
||||
}}
|
||||
onClick={() => {
|
||||
const newPathArr = path.split('/');
|
||||
newPathArr.pop();
|
||||
let newPath = newPathArr.join('/');
|
||||
if (newPath == '') newPath = '/';
|
||||
setPath(newPath);
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</DialogButton>
|
||||
<div style={{ flexGrow: '1', width: '100%' }}>
|
||||
<TextField
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
e.target.value && setPath(e.target.value);
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Focusable>
|
||||
<Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}>
|
||||
{loading && <SteamSpinner style={{ height: '100%' }} />}
|
||||
{!loading &&
|
||||
listing.files
|
||||
.filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name)))
|
||||
.map((file) => {
|
||||
let extension = file.realpath.split('.').pop() as string;
|
||||
return (
|
||||
<DialogButton
|
||||
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
|
||||
onClick={() => {
|
||||
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
|
||||
if (file.isdir) setPath(fullPath);
|
||||
else {
|
||||
onSubmit({ path: fullPath, realpath: file.realpath });
|
||||
closeModal?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
|
||||
{file.isdir ? (
|
||||
<FaFolder style={iconStyles} />
|
||||
) : (
|
||||
<div style={iconStyles}>
|
||||
{file.realpath.includes('.') ? (
|
||||
<FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
|
||||
) : (
|
||||
<FileIcon />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{file.name}
|
||||
</div>
|
||||
</DialogButton>
|
||||
);
|
||||
})}
|
||||
{error}
|
||||
</Focusable>
|
||||
{!loading && !error && !includeFiles && (
|
||||
<DialogButton
|
||||
className="Primary"
|
||||
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
|
||||
onClick={() => {
|
||||
onSubmit({ path, realpath: listing.realpath });
|
||||
closeModal?.();
|
||||
}}
|
||||
>
|
||||
Use this folder
|
||||
</DialogButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePicker;
|
||||
@@ -0,0 +1 @@
|
||||
This directory contains patches that replace Valve's broken file picker with ours.
|
||||
@@ -0,0 +1,10 @@
|
||||
import library from './library';
|
||||
let patches: Function[] = [];
|
||||
|
||||
export function deinitFilepickerPatches() {
|
||||
patches.forEach((unpatch) => unpatch());
|
||||
}
|
||||
|
||||
export async function initFilepickerPatches() {
|
||||
patches.push(await library());
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Patch, findModuleChild, replacePatch } from 'decky-frontend-lib';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SteamClient: any;
|
||||
appDetailsStore: any;
|
||||
}
|
||||
}
|
||||
|
||||
let patch: Patch;
|
||||
|
||||
function rePatch() {
|
||||
// If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur within the first 20s of the last patch
|
||||
patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => {
|
||||
try {
|
||||
const details = window.appDetailsStore.GetAppDetails(appid);
|
||||
console.log(details);
|
||||
// strShortcutStartDir
|
||||
const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', ''));
|
||||
console.log('user selected', file);
|
||||
window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path));
|
||||
const pathArr = file.path.split('/');
|
||||
pathArr.pop();
|
||||
const folder = pathArr.join('/');
|
||||
window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO type and add to frontend-lib
|
||||
const History = findModuleChild((m) => {
|
||||
if (typeof m !== 'object') return undefined;
|
||||
for (let prop in m) {
|
||||
if (m[prop]?.m_history) return m[prop].m_history;
|
||||
}
|
||||
});
|
||||
|
||||
export default async function libraryPatch() {
|
||||
try {
|
||||
rePatch();
|
||||
const unlisten = History.listen(() => {
|
||||
if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) {
|
||||
rePatch();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
patch.unpatch();
|
||||
unlisten();
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error patching library file picker', e);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Focusable, updaterFieldClasses } from 'decky-frontend-lib';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
|
||||
interface InlinePatchNotesProps {
|
||||
date: ReactNode;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const InlinePatchNotes: FunctionComponent<InlinePatchNotesProps> = ({ date, title, children, onClick }) => {
|
||||
return (
|
||||
<Focusable className={updaterFieldClasses.PatchNotes} onActivate={onClick}>
|
||||
<div className={updaterFieldClasses.PostedTime}>{date}</div>
|
||||
<div className={updaterFieldClasses.EventDetailTitle}>{title}</div>
|
||||
<div className={updaterFieldClasses.EventDetailsBody}>{children}</div>
|
||||
</Focusable>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlinePatchNotes;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { SidebarNavigation } from 'decky-frontend-lib';
|
||||
|
||||
import GeneralSettings from './pages/general';
|
||||
import PluginList from './pages/plugin_list';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<SidebarNavigation
|
||||
title="Decky Settings"
|
||||
showTitle
|
||||
pages={[
|
||||
{
|
||||
title: 'General',
|
||||
content: <GeneralSettings />,
|
||||
route: '/decky/settings/general',
|
||||
},
|
||||
{
|
||||
title: 'Plugins',
|
||||
content: <PluginList />,
|
||||
route: '/decky/settings/plugins',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Dropdown, Field } from 'decky-frontend-lib';
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
import Logger from '../../../../logger';
|
||||
import { callUpdaterMethod } from '../../../../updater';
|
||||
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||
|
||||
const logger = new Logger('BranchSelect');
|
||||
|
||||
enum UpdateBranch {
|
||||
Stable,
|
||||
Prerelease,
|
||||
// Nightly,
|
||||
}
|
||||
|
||||
const BranchSelect: FunctionComponent<{}> = () => {
|
||||
const [selectedBranch, setSelectedBranch] = useSetting<UpdateBranch>('branch', UpdateBranch.Prerelease);
|
||||
|
||||
return (
|
||||
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
|
||||
// 0 being stable, 1 being pre-release and 2 being nightly
|
||||
<Field label="Update Channel">
|
||||
<Dropdown
|
||||
rgOptions={Object.values(UpdateBranch)
|
||||
.filter((branch) => typeof branch == 'string')
|
||||
.map((branch) => ({
|
||||
label: branch,
|
||||
data: UpdateBranch[branch],
|
||||
}))}
|
||||
selectedOption={selectedBranch}
|
||||
onChange={async (newVal) => {
|
||||
await setSelectedBranch(newVal.data);
|
||||
callUpdaterMethod('check_for_updates');
|
||||
logger.log('switching branches!');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
export default BranchSelect;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Field, Toggle } from 'decky-frontend-lib';
|
||||
import { FaBug } from 'react-icons/fa';
|
||||
|
||||
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||
|
||||
export default function RemoteDebuggingSettings() {
|
||||
const [allowRemoteDebugging, setAllowRemoteDebugging] = useSetting<boolean>('cef_forward', false);
|
||||
|
||||
return (
|
||||
<Field
|
||||
label="Allow Remote CEF Debugging"
|
||||
description={
|
||||
<span style={{ whiteSpace: 'pre-line' }}>
|
||||
Allow unauthenticated access to the CEF debugger to anyone in your network
|
||||
</span>
|
||||
}
|
||||
icon={<FaBug style={{ display: 'block' }} />}
|
||||
>
|
||||
<Toggle
|
||||
value={allowRemoteDebugging || false}
|
||||
onChange={(toggleValue) => {
|
||||
setAllowRemoteDebugging(toggleValue);
|
||||
if (toggleValue) window.DeckyPluginLoader.callServerMethod('allow_remote_debugging');
|
||||
else window.DeckyPluginLoader.callServerMethod('disallow_remote_debugging');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Carousel,
|
||||
DialogButton,
|
||||
Field,
|
||||
FocusRing,
|
||||
Focusable,
|
||||
ProgressBarWithInfo,
|
||||
Spinner,
|
||||
showModal,
|
||||
} from 'decky-frontend-lib';
|
||||
import { useCallback } from 'react';
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FaArrowDown } from 'react-icons/fa';
|
||||
|
||||
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
|
||||
import { useDeckyState } from '../../../DeckyState';
|
||||
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
|
||||
import WithSuspense from '../../../WithSuspense';
|
||||
|
||||
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
|
||||
|
||||
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
|
||||
return (
|
||||
<Focusable onCancelButton={closeModal}>
|
||||
<FocusRing>
|
||||
<Carousel
|
||||
fnItemRenderer={(id: number) => (
|
||||
<Focusable
|
||||
style={{
|
||||
marginTop: '40px',
|
||||
height: 'calc( 100% - 40px )',
|
||||
overflowY: 'scroll',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
margin: '40px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1>{versionInfo?.all?.[id]?.name}</h1>
|
||||
{versionInfo?.all?.[id]?.body ? (
|
||||
<WithSuspense>
|
||||
<MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
|
||||
</WithSuspense>
|
||||
) : (
|
||||
'no patch notes for this version'
|
||||
)}
|
||||
</div>
|
||||
</Focusable>
|
||||
)}
|
||||
fnGetId={(id) => id}
|
||||
nNumItems={versionInfo?.all?.length}
|
||||
nHeight={window.innerHeight - 40}
|
||||
nItemHeight={window.innerHeight - 40}
|
||||
nItemMarginX={0}
|
||||
initialColumn={0}
|
||||
autoFocus={true}
|
||||
fnGetColumnWidth={() => window.innerWidth}
|
||||
/>
|
||||
</FocusRing>
|
||||
</Focusable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpdaterSettings() {
|
||||
const { isLoaderUpdating, setIsLoaderUpdating, versionInfo, setVersionInfo } = useDeckyState();
|
||||
|
||||
const [checkingForUpdates, setCheckingForUpdates] = useState<boolean>(false);
|
||||
const [updateProgress, setUpdateProgress] = useState<number>(-1);
|
||||
const [reloading, setReloading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.DeckyUpdater = {
|
||||
updateProgress: (i) => {
|
||||
setUpdateProgress(i);
|
||||
setIsLoaderUpdating(true);
|
||||
},
|
||||
finish: async () => {
|
||||
setUpdateProgress(0);
|
||||
setReloading(true);
|
||||
await finishUpdate();
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showPatchNotes = useCallback(() => {
|
||||
showModal(<PatchNotesModal versionInfo={versionInfo} />);
|
||||
}, [versionInfo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
onOptionsActionDescription={versionInfo?.all ? 'Patch Notes' : undefined}
|
||||
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
|
||||
label="Updates"
|
||||
description={
|
||||
versionInfo && (
|
||||
<span style={{ whiteSpace: 'pre-line' }}>{`Current version: ${versionInfo.current}\n${
|
||||
versionInfo.updatable ? `Latest version: ${versionInfo.remote?.tag_name}` : ''
|
||||
}`}</span>
|
||||
)
|
||||
}
|
||||
icon={
|
||||
!versionInfo ? (
|
||||
<Spinner style={{ width: '1em', height: 20, display: 'block' }} />
|
||||
) : (
|
||||
<FaArrowDown style={{ display: 'block' }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{updateProgress == -1 && !isLoaderUpdating ? (
|
||||
<DialogButton
|
||||
disabled={!versionInfo?.updatable || checkingForUpdates}
|
||||
onClick={
|
||||
!versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
|
||||
? async () => {
|
||||
setCheckingForUpdates(true);
|
||||
const res = (await callUpdaterMethod('check_for_updates')) as { result: VerInfo };
|
||||
setVersionInfo(res.result);
|
||||
setCheckingForUpdates(false);
|
||||
}
|
||||
: async () => {
|
||||
setUpdateProgress(0);
|
||||
callUpdaterMethod('do_update');
|
||||
}
|
||||
}
|
||||
>
|
||||
{checkingForUpdates
|
||||
? 'Checking'
|
||||
: !versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
|
||||
? 'Check For Updates'
|
||||
: 'Install Update'}
|
||||
</DialogButton>
|
||||
) : (
|
||||
<ProgressBarWithInfo
|
||||
layout="inline"
|
||||
bottomSeparator="none"
|
||||
nProgress={updateProgress}
|
||||
indeterminate={reloading}
|
||||
sOperationText={reloading ? 'Reloading' : 'Updating'}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{versionInfo?.remote && (
|
||||
<InlinePatchNotes
|
||||
title={versionInfo?.remote.name}
|
||||
date={new Intl.RelativeTimeFormat('en-US', {
|
||||
numeric: 'auto',
|
||||
}).format(
|
||||
Math.ceil((new Date(versionInfo.remote.published_at).getTime() - new Date().getTime()) / 86400000),
|
||||
'day',
|
||||
)}
|
||||
onClick={showPatchNotes}
|
||||
>
|
||||
<Suspense fallback={<Spinner style={{ width: '24', height: '24' }} />}>
|
||||
<MarkdownRenderer>{versionInfo?.remote.body}</MarkdownRenderer>
|
||||
</Suspense>
|
||||
</InlinePatchNotes>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DialogButton, Field, TextField } from 'decky-frontend-lib';
|
||||
import { useState } from 'react';
|
||||
import { FaShapes } from 'react-icons/fa';
|
||||
|
||||
import { installFromURL } from '../../../../store';
|
||||
import BranchSelect from './BranchSelect';
|
||||
import RemoteDebuggingSettings from './RemoteDebugging';
|
||||
import UpdaterSettings from './Updater';
|
||||
|
||||
export default function GeneralSettings() {
|
||||
const [pluginURL, setPluginURL] = useState('');
|
||||
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
|
||||
return (
|
||||
<div>
|
||||
{/* <Field
|
||||
label="A Toggle with an icon"
|
||||
icon={<FaShapes style={{ display: 'block' }} />}
|
||||
>
|
||||
<Toggle
|
||||
value={checked}
|
||||
onChange={(e) => setChecked(e)}
|
||||
/>
|
||||
</Field> */}
|
||||
<UpdaterSettings />
|
||||
<BranchSelect />
|
||||
<RemoteDebuggingSettings />
|
||||
<Field
|
||||
label="Manual plugin install"
|
||||
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
||||
icon={<FaShapes style={{ display: 'block' }} />}
|
||||
>
|
||||
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}>
|
||||
Install
|
||||
</DialogButton>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DialogButton, Focusable, Menu, MenuItem, showContextMenu } from 'decky-frontend-lib';
|
||||
import { useEffect } from 'react';
|
||||
import { FaDownload, FaEllipsisH } from 'react-icons/fa';
|
||||
|
||||
import { requestPluginInstall } from '../../../../store';
|
||||
import { useDeckyState } from '../../../DeckyState';
|
||||
|
||||
export default function PluginList() {
|
||||
const { plugins, updates } = useDeckyState();
|
||||
|
||||
useEffect(() => {
|
||||
window.DeckyPluginLoader.checkPluginUpdates();
|
||||
}, []);
|
||||
|
||||
if (plugins.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<p>No plugins installed</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyleType: 'none' }}>
|
||||
{plugins.map(({ name, version }) => {
|
||||
const update = updates?.get(name);
|
||||
return (
|
||||
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
|
||||
<span>
|
||||
{name} {version}
|
||||
</span>
|
||||
<Focusable style={{ marginLeft: 'auto', boxShadow: 'none', display: 'flex', justifyContent: 'right' }}>
|
||||
{update && (
|
||||
<DialogButton
|
||||
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
|
||||
onClick={() => requestPluginInstall(name, update)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
Update to {update.name}
|
||||
<FaDownload style={{ paddingLeft: '2rem' }} />
|
||||
</div>
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton
|
||||
style={{ height: '40px', width: '40px', padding: '10px 12px', minWidth: '40px' }}
|
||||
onClick={(e: MouseEvent) =>
|
||||
showContextMenu(
|
||||
<Menu label="Plugin Actions">
|
||||
<MenuItem onSelected={() => window.DeckyPluginLoader.importPlugin(name, version)}>
|
||||
Reload
|
||||
</MenuItem>
|
||||
<MenuItem onSelected={() => window.DeckyPluginLoader.uninstallPlugin(name)}>Uninstall</MenuItem>
|
||||
</Menu>,
|
||||
e.currentTarget ?? window,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FaEllipsisH />
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Focusable,
|
||||
QuickAccessTab,
|
||||
Router,
|
||||
SingleDropdownOption,
|
||||
SuspensefulImage,
|
||||
joinClassNames,
|
||||
staticClasses,
|
||||
} from 'decky-frontend-lib';
|
||||
import { FC, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
LegacyStorePlugin,
|
||||
StorePlugin,
|
||||
StorePluginVersion,
|
||||
isLegacyPlugin,
|
||||
requestLegacyPluginInstall,
|
||||
requestPluginInstall,
|
||||
} from '../../store';
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: StorePlugin | LegacyStorePlugin;
|
||||
}
|
||||
|
||||
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="deckyStoreCard"
|
||||
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 className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<a
|
||||
style={{ fontSize: '18pt', padding: '10px' }}
|
||||
className={joinClassNames(staticClasses.Text)}
|
||||
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
|
||||
>
|
||||
{isLegacyPlugin(plugin) ? (
|
||||
<div className="deckyStoreCardNameContainer">
|
||||
<span className="deckyStoreCardLegacyRepoOwner" style={{ color: 'grey' }}>
|
||||
{plugin.artifact.split('/')[0]}/
|
||||
</span>
|
||||
{plugin.artifact.split('/')[1]}
|
||||
</div>
|
||||
) : (
|
||||
plugin.name
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
className="deckyStoreCardBody"
|
||||
>
|
||||
<SuspensefulImage
|
||||
className="deckyStoreCardImage"
|
||||
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',
|
||||
}}
|
||||
className="deckyStoreCardInfo"
|
||||
>
|
||||
<p
|
||||
className={joinClassNames(staticClasses.PanelSectionRow)}
|
||||
style={{ marginTop: '0px', marginLeft: '16px' }}
|
||||
>
|
||||
<span style={{ paddingLeft: '0px' }}>Author: {plugin.author}</span>
|
||||
</p>
|
||||
<p
|
||||
className={joinClassNames(staticClasses.PanelSectionRow)}
|
||||
style={{
|
||||
marginLeft: '16px',
|
||||
marginTop: '0px',
|
||||
marginBottom: '0px',
|
||||
marginRight: '16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ paddingLeft: '0px' }}>{plugin.description}</span>
|
||||
</p>
|
||||
<p
|
||||
className={joinClassNames('deckyStoreCardTagsContainer', staticClasses.PanelSectionRow)}
|
||||
style={{
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '5px 10px',
|
||||
}}
|
||||
>
|
||||
<span style={{ padding: '5px 0' }}>Tags:</span>
|
||||
{plugin.tags.map((tag: string) => (
|
||||
<span
|
||||
className="deckyStoreCardTag"
|
||||
style={{
|
||||
padding: '5px',
|
||||
borderRadius: '5px',
|
||||
background: tag == 'root' ? '#842029' : '#ACB2C947',
|
||||
}}
|
||||
>
|
||||
{tag == 'root' ? 'Requires root' : tag}
|
||||
</span>
|
||||
))}
|
||||
{isLegacyPlugin(plugin) && (
|
||||
<span
|
||||
className="deckyStoreCardTag deckyStoreCardLegacyTag"
|
||||
style={{
|
||||
color: '#232120',
|
||||
padding: '5px',
|
||||
borderRadius: '5px',
|
||||
background: '#EDE841',
|
||||
}}
|
||||
>
|
||||
legacy
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="deckyStoreCardActionsContainer"
|
||||
style={{
|
||||
width: '100%',
|
||||
alignSelf: 'flex-end',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<Focusable
|
||||
className="deckyStoreCardActions"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="deckyStoreCardInstallButtonContainer"
|
||||
style={{
|
||||
flex: '1',
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
className="deckyStoreCardInstallButton"
|
||||
ref={buttonRef}
|
||||
onClick={() =>
|
||||
isLegacyPlugin(plugin)
|
||||
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
|
||||
: requestPluginInstall(plugin.name, plugin.versions[selectedOption])
|
||||
}
|
||||
>
|
||||
Install
|
||||
</DialogButton>
|
||||
</div>
|
||||
<div
|
||||
className="deckyStoreCardVersionDropdownContainer"
|
||||
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,64 @@
|
||||
import { SteamSpinner } from 'decky-frontend-lib';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
|
||||
import Logger from '../../logger';
|
||||
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store';
|
||||
import PluginCard from './PluginCard';
|
||||
|
||||
const logger = new Logger('FilePicker');
|
||||
|
||||
const StorePage: FC<{}> = () => {
|
||||
const [data, setData] = useState<StorePlugin[] | null>(null);
|
||||
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await getPluginList();
|
||||
logger.log('got data!', res);
|
||||
setData(res);
|
||||
})();
|
||||
(async () => {
|
||||
const res = await getLegacyPluginList();
|
||||
logger.log('got legacy data!', 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;
|
||||
@@ -1,25 +1,66 @@
|
||||
import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import PluginLoader from './plugin-loader';
|
||||
import { DeckyUpdater } from './updater';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeckyPluginLoader: PluginLoader;
|
||||
DeckyUpdater?: DeckyUpdater;
|
||||
importDeckyPlugin: Function;
|
||||
syncDeckyPlugins: Function;
|
||||
deckyHasLoaded: boolean;
|
||||
deckyAuthToken: string;
|
||||
webpackJsonp: any;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeckyPluginLoader?.dismountAll();
|
||||
// HACK to fix plugins using webpack v4 push
|
||||
|
||||
window.DeckyPluginLoader = new PluginLoader();
|
||||
window.importDeckyPlugin = function (name: string) {
|
||||
window.DeckyPluginLoader?.importPlugin(name);
|
||||
};
|
||||
const v4Cache = {};
|
||||
for (let m of Object.keys(webpackCache)) {
|
||||
v4Cache[m] = { exports: webpackCache[m] };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) {
|
||||
window.webpackJsonp = {
|
||||
deckyShimmed: true,
|
||||
push: (mod: any): any => {
|
||||
if (mod[1].get_require) return { c: v4Cache };
|
||||
},
|
||||
};
|
||||
CommonUIModule.__deckyButtonItemShim = forwardRef((props: any, ref: any) => {
|
||||
// tricks the old filter into working
|
||||
const dummy = `childrenContainerWidth:"min"`;
|
||||
return <ButtonItem ref={ref} _shim={dummy} {...props} />;
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => window.syncDeckyPlugins(), 5000);
|
||||
(async () => {
|
||||
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
||||
|
||||
window.DeckyPluginLoader?.dismountAll();
|
||||
window.DeckyPluginLoader?.deinit();
|
||||
|
||||
window.DeckyPluginLoader = new PluginLoader();
|
||||
window.importDeckyPlugin = function (name: string, version: string) {
|
||||
window.DeckyPluginLoader?.importPlugin(name, version);
|
||||
};
|
||||
|
||||
window.syncDeckyPlugins = async function () {
|
||||
const plugins = await (
|
||||
await fetch('http://127.0.0.1:1337/plugins', {
|
||||
credentials: 'include',
|
||||
headers: { Authentication: window.deckyAuthToken },
|
||||
})
|
||||
).json();
|
||||
for (const plugin of plugins) {
|
||||
if (!window.DeckyPluginLoader.hasPlugin(plugin.name))
|
||||
window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version);
|
||||
}
|
||||
window.DeckyPluginLoader.checkPluginUpdates();
|
||||
};
|
||||
|
||||
setTimeout(() => window.syncDeckyPlugins(), 5000);
|
||||
})();
|
||||
|
||||
@@ -8,8 +8,18 @@ export const log = (name: string, ...args: any[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const debug = (name: string, ...args: any[]) => {
|
||||
console.debug(
|
||||
`%c Decky %c ${name} %c`,
|
||||
'background: #16a085; color: black;',
|
||||
'background: #1abc9c; color: black;',
|
||||
'color: blue;',
|
||||
...args,
|
||||
);
|
||||
};
|
||||
|
||||
export const error = (name: string, ...args: any[]) => {
|
||||
console.log(
|
||||
console.error(
|
||||
`%c Decky %c ${name} %c`,
|
||||
'background: #16a085; color: black;',
|
||||
'background: #FF0000;',
|
||||
@@ -28,7 +38,11 @@ class Logger {
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
log(this.name, ...args);
|
||||
debug(this.name, ...args);
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
error(this.name, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import { ModalRoot, showModal, staticClasses } from 'decky-frontend-lib';
|
||||
import {
|
||||
ConfirmModal,
|
||||
ModalRoot,
|
||||
Patch,
|
||||
QuickAccessTab,
|
||||
Router,
|
||||
callOriginal,
|
||||
findModuleChild,
|
||||
replacePatch,
|
||||
showModal,
|
||||
sleep,
|
||||
staticClasses,
|
||||
} from 'decky-frontend-lib';
|
||||
import { lazy } from 'react';
|
||||
import { FaPlug } from 'react-icons/fa';
|
||||
|
||||
import { DeckyState, DeckyStateContextProvider } from './components/DeckyState';
|
||||
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
|
||||
import LegacyPlugin from './components/LegacyPlugin';
|
||||
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
||||
import PluginInstallModal from './components/modals/PluginInstallModal';
|
||||
import NotificationBadge from './components/NotificationBadge';
|
||||
import PluginView from './components/PluginView';
|
||||
import TitleView from './components/TitleView';
|
||||
import WithSuspense from './components/WithSuspense';
|
||||
import Logger from './logger';
|
||||
import { Plugin } from './plugin';
|
||||
import RouterHook from './router-hook';
|
||||
import { checkForUpdates } from './store';
|
||||
import TabsHook from './tabs-hook';
|
||||
import Toaster from './toaster';
|
||||
import { VerInfo, callUpdaterMethod } from './updater';
|
||||
|
||||
const StorePage = lazy(() => import('./components/store/Store'));
|
||||
const SettingsPage = lazy(() => import('./components/settings'));
|
||||
|
||||
const FilePicker = lazy(() => import('./components/modals/filepicker'));
|
||||
|
||||
declare global {
|
||||
interface Window {}
|
||||
@@ -19,51 +44,164 @@ class PluginLoader extends Logger {
|
||||
private tabsHook: TabsHook = new TabsHook();
|
||||
// private windowHook: WindowHook = new WindowHook();
|
||||
private routerHook: RouterHook = new RouterHook();
|
||||
private toaster: Toaster = new Toaster();
|
||||
private deckyState: DeckyState = new DeckyState();
|
||||
|
||||
private reloadLock: boolean = false;
|
||||
// stores a list of plugin names which requested to be reloaded
|
||||
private pluginReloadQueue: string[] = [];
|
||||
private pluginReloadQueue: { name: string; version?: string }[] = [];
|
||||
|
||||
private focusWorkaroundPatch?: Patch;
|
||||
|
||||
constructor() {
|
||||
super(PluginLoader.name);
|
||||
this.log('Initialized');
|
||||
|
||||
const TabBadge = () => {
|
||||
const { updates, hasLoaderUpdate } = useDeckyState();
|
||||
return <NotificationBadge show={(updates && updates.size > 0) || hasLoaderUpdate} />;
|
||||
};
|
||||
|
||||
this.tabsHook.add({
|
||||
id: 'main',
|
||||
title: (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<TitleView />
|
||||
</DeckyStateContextProvider>
|
||||
),
|
||||
id: QuickAccessTab.Decky,
|
||||
title: null,
|
||||
content: (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<TitleView />
|
||||
<PluginView />
|
||||
</DeckyStateContextProvider>
|
||||
),
|
||||
icon: <FaPlug />,
|
||||
icon: (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<FaPlug />
|
||||
<TabBadge />
|
||||
</DeckyStateContextProvider>
|
||||
),
|
||||
});
|
||||
|
||||
this.routerHook.addRoute('/decky/store', () => (
|
||||
<WithSuspense route={true}>
|
||||
<StorePage />
|
||||
</WithSuspense>
|
||||
));
|
||||
this.routerHook.addRoute('/decky/settings', () => {
|
||||
return (
|
||||
<DeckyStateContextProvider deckyState={this.deckyState}>
|
||||
<WithSuspense route={true}>
|
||||
<SettingsPage />
|
||||
</WithSuspense>
|
||||
</DeckyStateContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
initFilepickerPatches();
|
||||
|
||||
this.updateVersion();
|
||||
|
||||
const self = this;
|
||||
|
||||
try {
|
||||
// TODO remove all of this once Valve fixes the bug
|
||||
const focusManager = findModuleChild((m) => {
|
||||
if (typeof m !== 'object') return false;
|
||||
for (let prop in m) {
|
||||
if (m[prop]?.prototype?.TakeFocus) return m[prop];
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.focusWorkaroundPatch = replacePatch(focusManager.prototype, 'TakeFocus', function () {
|
||||
// @ts-ignore
|
||||
const classList = this.m_node?.m_element.classList;
|
||||
if (
|
||||
// @ts-ignore
|
||||
(this.m_node?.m_element && classList.contains(staticClasses.TabGroupPanel)) ||
|
||||
classList.contains('FriendsListTab') ||
|
||||
classList.contains('FriendsTabList') ||
|
||||
classList.contains('FriendsListAndChatsSteamDeck')
|
||||
) {
|
||||
self.debug('Intercepted friends re-focus');
|
||||
return true;
|
||||
}
|
||||
|
||||
return callOriginal;
|
||||
});
|
||||
} catch (e) {
|
||||
this.error('Friends focus patch failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
public addPluginInstallPrompt(artifact: string, version: string, request_id: string) {
|
||||
public async updateVersion() {
|
||||
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
|
||||
this.deckyState.setVersionInfo(versionInfo);
|
||||
|
||||
return versionInfo;
|
||||
}
|
||||
|
||||
public async notifyUpdates() {
|
||||
const versionInfo = await this.updateVersion();
|
||||
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
|
||||
this.toaster.toast({
|
||||
title: 'Decky',
|
||||
body: `Update to ${versionInfo?.remote?.tag_name} available!`,
|
||||
onClick: () => Router.Navigate('/decky/settings'),
|
||||
});
|
||||
this.deckyState.setHasLoaderUpdate(true);
|
||||
}
|
||||
await sleep(7000);
|
||||
await this.notifyPluginUpdates();
|
||||
}
|
||||
|
||||
public async checkPluginUpdates() {
|
||||
const updates = await checkForUpdates(this.plugins);
|
||||
this.deckyState.setUpdates(updates);
|
||||
return updates;
|
||||
}
|
||||
|
||||
public async notifyPluginUpdates() {
|
||||
const updates = await this.checkPluginUpdates();
|
||||
if (updates?.size > 0) {
|
||||
this.toaster.toast({
|
||||
title: 'Decky',
|
||||
body: `Updates available for ${updates.size} plugin${updates.size > 1 ? 's' : ''}!`,
|
||||
onClick: () => Router.Navigate('/decky/settings/plugins'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public addPluginInstallPrompt(artifact: string, version: string, request_id: string, hash: string) {
|
||||
showModal(
|
||||
<ModalRoot
|
||||
onOK={() => {
|
||||
console.log('ok');
|
||||
this.callServerMethod('confirm_plugin_install', { request_id });
|
||||
<PluginInstallModal
|
||||
artifact={artifact}
|
||||
version={version}
|
||||
hash={hash}
|
||||
onOK={() => this.callServerMethod('confirm_plugin_install', { request_id })}
|
||||
onCancel={() => this.callServerMethod('cancel_plugin_install', { request_id })}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
public uninstallPlugin(name: string) {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
onOK={async () => {
|
||||
await this.callServerMethod('uninstall_plugin', { name });
|
||||
}}
|
||||
onCancel={() => {
|
||||
console.log('nope');
|
||||
this.callServerMethod('cancel_plugin_install', { request_id });
|
||||
// do nothing
|
||||
}}
|
||||
>
|
||||
<div className={staticClasses.Title}>
|
||||
Install {artifact} version {version}?
|
||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
||||
Uninstall {name}?
|
||||
</div>
|
||||
</ModalRoot>,
|
||||
</ConfirmModal>,
|
||||
);
|
||||
}
|
||||
|
||||
public hasPlugin(name: string) {
|
||||
return Boolean(this.plugins.find((plugin) => plugin.name == name));
|
||||
}
|
||||
|
||||
public dismountAll() {
|
||||
for (const plugin of this.plugins) {
|
||||
this.log(`Dismounting ${plugin.name}`);
|
||||
@@ -71,44 +209,66 @@ class PluginLoader extends Logger {
|
||||
}
|
||||
}
|
||||
|
||||
public async importPlugin(name: string) {
|
||||
try {
|
||||
if (this.reloadLock) {
|
||||
this.log('Reload currently in progress, adding to queue', name);
|
||||
this.pluginReloadQueue.push(name);
|
||||
return;
|
||||
}
|
||||
public deinit() {
|
||||
this.routerHook.removeRoute('/decky/store');
|
||||
this.routerHook.removeRoute('/decky/settings');
|
||||
deinitFilepickerPatches();
|
||||
this.focusWorkaroundPatch?.unpatch();
|
||||
}
|
||||
|
||||
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, version?: string | undefined) {
|
||||
if (this.reloadLock) {
|
||||
this.log('Reload currently in progress, adding to queue', name);
|
||||
this.pluginReloadQueue.push({ name, version: version });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.reloadLock = true;
|
||||
this.log(`Trying to load ${name}`);
|
||||
let find = this.plugins.find((x) => x.name == name);
|
||||
if (find) this.plugins.splice(this.plugins.indexOf(find), 1);
|
||||
|
||||
this.unloadPlugin(name);
|
||||
|
||||
if (name.startsWith('$LEGACY_')) {
|
||||
await this.importLegacyPlugin(name.replace('$LEGACY_', ''));
|
||||
} else {
|
||||
await this.importReactPlugin(name);
|
||||
await this.importReactPlugin(name, version);
|
||||
}
|
||||
this.log(`Loaded ${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);
|
||||
this.importPlugin(nextPlugin.name, nextPlugin.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async importReactPlugin(name: string) {
|
||||
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
|
||||
private async importReactPlugin(name: string, version?: string) {
|
||||
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authentication: window.deckyAuthToken,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
let content = await eval(await res.text())(this.createPluginAPI(name));
|
||||
let plugin_export = await eval(await res.text());
|
||||
let plugin = plugin_export(this.createPluginAPI(name));
|
||||
this.plugins.push({
|
||||
...plugin,
|
||||
name: name,
|
||||
icon: content.icon,
|
||||
content: content.content,
|
||||
version: version,
|
||||
});
|
||||
} else throw new Error(`${name} frontend_bundle not OK`);
|
||||
}
|
||||
@@ -125,8 +285,10 @@ class PluginLoader extends Logger {
|
||||
async callServerMethod(methodName: string, args = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authentication: window.deckyAuthToken,
|
||||
},
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
@@ -134,15 +296,48 @@ class PluginLoader extends Logger {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
openFilePicker(
|
||||
startPath: string,
|
||||
includeFiles?: boolean,
|
||||
regex?: RegExp,
|
||||
): Promise<{ path: string; realpath: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const Content = ({ closeModal }: { closeModal?: () => void }) => (
|
||||
// Purposely outside of the FilePicker component as lazy-loaded ModalRoots don't focus correctly
|
||||
<ModalRoot
|
||||
onCancel={() => {
|
||||
reject('User canceled');
|
||||
closeModal?.();
|
||||
}}
|
||||
>
|
||||
<WithSuspense>
|
||||
<FilePicker
|
||||
startPath={startPath}
|
||||
includeFiles={includeFiles}
|
||||
regex={regex}
|
||||
onSubmit={resolve}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
</WithSuspense>
|
||||
</ModalRoot>
|
||||
);
|
||||
showModal(<Content />);
|
||||
});
|
||||
}
|
||||
|
||||
createPluginAPI(pluginName: string) {
|
||||
return {
|
||||
routerHook: this.routerHook,
|
||||
toaster: this.toaster,
|
||||
callServerMethod: this.callServerMethod,
|
||||
openFilePicker: this.openFilePicker,
|
||||
async callPluginMethod(methodName: string, args = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authentication: window.deckyAuthToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args,
|
||||
@@ -152,7 +347,7 @@ class PluginLoader extends Logger {
|
||||
return response.json();
|
||||
},
|
||||
fetchNoCors(url: string, request: any = {}) {
|
||||
let args = { method: 'POST', headers: {}, body: '' };
|
||||
let args = { method: 'POST', headers: {} };
|
||||
const req = { ...args, ...request, url, data: request.body };
|
||||
return this.callServerMethod('http_request', req);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface Plugin {
|
||||
name: any;
|
||||
content: any;
|
||||
icon: any;
|
||||
name: string;
|
||||
version?: string;
|
||||
icon: JSX.Element;
|
||||
content?: JSX.Element;
|
||||
onDismount?(): void;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib';
|
||||
import { ReactElement, createElement, memo } from 'react';
|
||||
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
|
||||
import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
|
||||
import type { Route } from 'react-router';
|
||||
|
||||
import {
|
||||
DeckyRouterState,
|
||||
DeckyRouterStateContextProvider,
|
||||
RoutePatch,
|
||||
RouterEntry,
|
||||
useDeckyRouterState,
|
||||
} from './components/DeckyRouterState';
|
||||
@@ -21,6 +22,8 @@ class RouterHook extends Logger {
|
||||
private memoizedRouter: any;
|
||||
private gamepadWrapper: any;
|
||||
private routerState: DeckyRouterState = new DeckyRouterState();
|
||||
private wrapperPatch: Patch;
|
||||
private routerPatch?: Patch;
|
||||
|
||||
constructor() {
|
||||
super('RouterHook');
|
||||
@@ -38,14 +41,16 @@ class RouterHook extends Logger {
|
||||
});
|
||||
|
||||
let Route: new () => Route;
|
||||
// Used to store the new replicated routes we create to allow routes to be unpatched.
|
||||
let toReplace = new Map<string, ReactNode>();
|
||||
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
|
||||
const { routes } = useDeckyRouterState();
|
||||
const { routes, routePatches } = 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 routeList = children.props.children[0].props.children;
|
||||
|
||||
let routerIndex = routeList.length;
|
||||
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
|
||||
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
|
||||
const newRouterArray: ReactElement[] = [];
|
||||
routes.forEach(({ component, props }, path) => {
|
||||
newRouterArray.push(
|
||||
@@ -54,12 +59,37 @@ class RouterHook extends Logger {
|
||||
</Route>,
|
||||
);
|
||||
});
|
||||
children.props.children[0].props.children[routerIndex] = newRouterArray;
|
||||
routeList[routerIndex] = newRouterArray;
|
||||
}
|
||||
routeList.forEach((route: Route, index: number) => {
|
||||
const replaced = toReplace.get(route?.props?.path as string);
|
||||
if (replaced) {
|
||||
routeList[index].props.children = replaced;
|
||||
toReplace.delete(route?.props?.path as string);
|
||||
}
|
||||
if (route?.props?.path && routePatches.has(route.props.path as string)) {
|
||||
toReplace.set(
|
||||
route?.props?.path as string,
|
||||
// @ts-ignore
|
||||
routeList[index].props.children,
|
||||
);
|
||||
routePatches.get(route.props.path as string)?.forEach((patch) => {
|
||||
const oType = routeList[index].props.children.type;
|
||||
routeList[index].props.children = patch({
|
||||
...routeList[index].props,
|
||||
children: {
|
||||
...cloneElement(routeList[index].props.children),
|
||||
type: (props) => createElement(oType, props),
|
||||
},
|
||||
}).children;
|
||||
});
|
||||
}
|
||||
});
|
||||
this.debug('Rerendered routes list');
|
||||
return children;
|
||||
};
|
||||
|
||||
afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
||||
this.wrapperPatch = 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
|
||||
@@ -68,7 +98,7 @@ class RouterHook extends Logger {
|
||||
) {
|
||||
if (!this.router) {
|
||||
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
|
||||
afterPatch(this.router, 'type', (_: any, ret: any) => {
|
||||
this.routerPatch = 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 = (
|
||||
@@ -92,9 +122,21 @@ class RouterHook extends Logger {
|
||||
this.routerState.addRoute(path, component, props);
|
||||
}
|
||||
|
||||
addPatch(path: string, patch: RoutePatch) {
|
||||
return this.routerState.addPatch(path, patch);
|
||||
}
|
||||
|
||||
removePatch(path: string, patch: RoutePatch) {
|
||||
this.routerState.removePatch(path, patch);
|
||||
}
|
||||
|
||||
removeRoute(path: string) {
|
||||
this.routerState.removeRoute(path);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
unpatch(this.gamepadWrapper, 'render');
|
||||
this.router && unpatch(this.router, 'type');
|
||||
this.wrapperPatch.unpatch();
|
||||
this.routerPatch?.unpatch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib';
|
||||
|
||||
import { Plugin } from './plugin';
|
||||
|
||||
export interface StorePluginVersion {
|
||||
name: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface StorePlugin {
|
||||
id: number;
|
||||
name: string;
|
||||
versions: StorePluginVersion[];
|
||||
author: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface LegacyStorePlugin {
|
||||
artifact: string;
|
||||
versions: {
|
||||
[version: string]: string;
|
||||
};
|
||||
author: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// name: version
|
||||
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
|
||||
|
||||
export function getPluginList(): Promise<StorePlugin[]> {
|
||||
return fetch('https://beta.deckbrew.xyz/plugins', {
|
||||
method: 'GET',
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
export function getLegacyPluginList(): Promise<LegacyStorePlugin[]> {
|
||||
return fetch('https://plugins.deckbrew.xyz/get_plugins', {
|
||||
method: 'GET',
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function installFromURL(url: string) {
|
||||
const splitURL = url.split('/');
|
||||
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
||||
name: splitURL[splitURL.length - 1].replace('.zip', ''),
|
||||
artifact: url,
|
||||
});
|
||||
}
|
||||
|
||||
export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
onOK={() => {
|
||||
window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
||||
name: plugin.artifact,
|
||||
artifact: `https://github.com/${plugin.artifact}/archive/refs/tags/${selectedVer}.zip`,
|
||||
version: selectedVer,
|
||||
hash: plugin.versions[selectedVer],
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
// do nothing
|
||||
}}
|
||||
>
|
||||
<div className={staticClasses.Title} style={{ flexDirection: 'column', boxShadow: 'unset' }}>
|
||||
Using legacy plugins
|
||||
</div>
|
||||
You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues.
|
||||
Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the
|
||||
touchscreen.
|
||||
</ConfirmModal>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
|
||||
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
||||
name: plugin,
|
||||
artifact: `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`,
|
||||
version: selectedVer.name,
|
||||
hash: selectedVer.hash,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMapping> {
|
||||
const serverData = await getPluginList();
|
||||
const updateMap = new Map<string, StorePluginVersion>();
|
||||
for (let plugin of plugins) {
|
||||
const remotePlugin = serverData?.find((x) => x.name == plugin.name);
|
||||
if (remotePlugin && remotePlugin.versions?.length > 0 && plugin.version != remotePlugin?.versions?.[0]?.name) {
|
||||
updateMap.set(plugin.name, remotePlugin.versions[0]);
|
||||
}
|
||||
}
|
||||
return updateMap;
|
||||
}
|
||||
|
||||
export function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
|
||||
return 'artifact' in plugin;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Patch, QuickAccessTab, afterPatch, sleep } from 'decky-frontend-lib';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Logger from './logger';
|
||||
|
||||
declare global {
|
||||
@@ -11,11 +14,11 @@ declare global {
|
||||
|
||||
const isTabsArray = (tabs: any) => {
|
||||
const length = tabs.length;
|
||||
return length === 7 && tabs[length - 1]?.key === 6 && tabs[length - 1]?.tab;
|
||||
return length >= 7 && tabs[length - 1]?.tab;
|
||||
};
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
id: QuickAccessTab | number;
|
||||
title: any;
|
||||
content: any;
|
||||
icon: any;
|
||||
@@ -24,33 +27,111 @@ interface Tab {
|
||||
class TabsHook extends Logger {
|
||||
// private keys = 7;
|
||||
tabs: Tab[] = [];
|
||||
private quickAccess: any;
|
||||
private tabRenderer: any;
|
||||
private memoizedQuickAccess: any;
|
||||
private cNode: any;
|
||||
|
||||
private qAPTree: any;
|
||||
private rendererTree: any;
|
||||
|
||||
private cNodePatch?: Patch;
|
||||
|
||||
constructor() {
|
||||
super('TabsHook');
|
||||
|
||||
this.log('Initialized');
|
||||
window.__TABS_HOOK_INSTANCE?.deinit?.();
|
||||
window.__TABS_HOOK_INSTANCE = this;
|
||||
|
||||
const self = this;
|
||||
|
||||
const filter = Array.prototype.__filter ?? Array.prototype.filter;
|
||||
Array.prototype.__filter = filter;
|
||||
Array.prototype.filter = function (...args: any[]) {
|
||||
if (isTabsArray(this)) {
|
||||
self.render(this);
|
||||
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
||||
let scrollRoot: any;
|
||||
async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
|
||||
if (iters >= 30) {
|
||||
self.error(
|
||||
'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// @ts-ignore
|
||||
return filter.call(this, ...args);
|
||||
};
|
||||
currentNode = currentNode?.child;
|
||||
if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
|
||||
self.log(`Scroll root was found in ${iters} recursion cycles`);
|
||||
return currentNode;
|
||||
}
|
||||
if (!currentNode) return null;
|
||||
if (currentNode.sibling) {
|
||||
let node = await findScrollRoot(currentNode.sibling, iters + 1);
|
||||
if (node !== null) return node;
|
||||
}
|
||||
return await findScrollRoot(currentNode, iters + 1);
|
||||
}
|
||||
(async () => {
|
||||
scrollRoot = await findScrollRoot(tree, 0);
|
||||
while (!scrollRoot) {
|
||||
this.log('Failed to find scroll root node, reattempting in 5 seconds');
|
||||
await sleep(5000);
|
||||
scrollRoot = await findScrollRoot(tree, 0);
|
||||
}
|
||||
let newQA: any;
|
||||
let newQATabRenderer: any;
|
||||
this.cNodePatch = 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();
|
||||
this.log('Finished initial injection');
|
||||
})();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.cNodePatch?.unpatch();
|
||||
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.debug('Adding tab', tab.id, 'to render array');
|
||||
this.tabs.push(tab);
|
||||
}
|
||||
|
||||
removeById(id: string) {
|
||||
this.log('Removing tab', id);
|
||||
removeById(id: number) {
|
||||
this.debug('Removing tab', id);
|
||||
this.tabs = this.tabs.filter((tab) => tab.id !== id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import Toast from './components/Toast';
|
||||
import Logger from './logger';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TOASTER_INSTANCE: any;
|
||||
NotificationStore: any;
|
||||
}
|
||||
}
|
||||
|
||||
class Toaster extends Logger {
|
||||
private instanceRetPatch?: Patch;
|
||||
private node: any;
|
||||
private settingsModule: any;
|
||||
private ready: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super('Toaster');
|
||||
|
||||
window.__TOASTER_INSTANCE?.deinit?.();
|
||||
window.__TOASTER_INSTANCE = this;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
let instance: any;
|
||||
|
||||
while (true) {
|
||||
instance = findInReactTree(
|
||||
(document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
|
||||
(x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
|
||||
);
|
||||
if (instance) break;
|
||||
this.debug('finding instance');
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
this.node = instance.sibling.child;
|
||||
let toast: any;
|
||||
let renderedToast: ReactNode = null;
|
||||
afterPatch(this.node, 'type', (args: any[], ret: any) => {
|
||||
const currentToast = args[0].notification;
|
||||
if (currentToast?.decky) {
|
||||
if (currentToast !== toast) {
|
||||
toast = currentToast;
|
||||
renderedToast = <Toast toast={toast} />;
|
||||
}
|
||||
ret.props.children = renderedToast;
|
||||
} else {
|
||||
toast = null;
|
||||
renderedToast = null;
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
this.settingsModule = findModuleChild((m) => {
|
||||
if (typeof m !== 'object') return undefined;
|
||||
for (let prop in m) {
|
||||
if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
|
||||
}
|
||||
});
|
||||
this.log('Initialized');
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
async toast(toast: ToastData) {
|
||||
while (!this.ready) {
|
||||
await sleep(100);
|
||||
}
|
||||
const settings = this.settingsModule?.settings;
|
||||
let toastData = {
|
||||
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
|
||||
rtCreated: Date.now(),
|
||||
eType: 15,
|
||||
nToastDurationMS: toast.duration || 5e3,
|
||||
data: toast,
|
||||
decky: true,
|
||||
};
|
||||
// @ts-ignore
|
||||
toastData.data.appid = () => 0;
|
||||
if (
|
||||
(settings?.bDisableAllToasts && !toast.critical) ||
|
||||
(settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
|
||||
)
|
||||
return;
|
||||
window.NotificationStore.m_rgNotificationToasts.push(toastData);
|
||||
window.NotificationStore.DispatchNextToast();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.instanceRetPatch?.unpatch();
|
||||
this.node && delete this.node.stateNode.render;
|
||||
this.node && this.node.stateNode.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
export default Toaster;
|
||||
@@ -0,0 +1,47 @@
|
||||
export enum Branches {
|
||||
Release,
|
||||
Prerelease,
|
||||
Nightly,
|
||||
}
|
||||
|
||||
export interface DeckyUpdater {
|
||||
updateProgress: (val: number) => void;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
export interface RemoteVerInfo {
|
||||
assets: {
|
||||
browser_download_url: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
name: string;
|
||||
body: string;
|
||||
prerelease: boolean;
|
||||
published_at: string;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
export interface VerInfo {
|
||||
current: string;
|
||||
remote: RemoteVerInfo | null;
|
||||
all: RemoteVerInfo[] | null;
|
||||
updatable: boolean;
|
||||
}
|
||||
|
||||
export async function callUpdaterMethod(methodName: string, args = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:1337/updater/${methodName}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authentication: window.deckyAuthToken,
|
||||
},
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function finishUpdate() {
|
||||
callUpdaterMethod('do_restart');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface GetSettingArgs<T> {
|
||||
key: string;
|
||||
default: T;
|
||||
}
|
||||
|
||||
interface SetSettingArgs<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export function useSetting<T>(key: string, def: T): [value: T | null, setValue: (value: T) => Promise<void>] {
|
||||
const [value, setValue] = useState(def);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', {
|
||||
key,
|
||||
default: def,
|
||||
} as GetSettingArgs<T>)) as { result: T };
|
||||
setValue(res.result);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return [
|
||||
value,
|
||||
async (val: T) => {
|
||||
setValue(val);
|
||||
await window.DeckyPluginLoader.callServerMethod('set_setting', {
|
||||
key,
|
||||
value: val,
|
||||
} as SetSettingArgs<T>);
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "window.SP_REACT.createElement",
|
||||
"jsxFragmentFactory": "window.SP_REACT.Fragment",
|
||||
"declaration": false,
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": true,
|
||||
|
||||
@@ -2,3 +2,4 @@ aiohttp==3.8.1
|
||||
aiohttp-jinja2==1.5.0
|
||||
aiohttp_cors==0.7.0
|
||||
watchdog==2.1.7
|
||||
certifi==2022.6.15
|
||||